feat: introduce markdown note blocks for canvas documentation
- Added "Markdown Note" block to allow users to add UI-only markdown notes to the canvas for documentation purposes. - Integrated note creation, editing, and rendering in the `recipe-studio` UI, including markdown previews. - Updated payload generation logic to omit markdown notes from backend payloads. - Enhanced block types, definitions, and dialog support to include the new "Markdown Note" feature.
This commit is contained in:
parent
507e9b0669
commit
8f6e475145
15 changed files with 269 additions and 6 deletions
38
studio/frontend/src/components/markdown/markdown-preview.tsx
Normal file
38
studio/frontend/src/components/markdown/markdown-preview.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { memo, type ReactElement } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
const MARKDOWN_PLUGINS = { code, math, mermaid } as const;
|
||||
|
||||
type MarkdownPreviewProps = {
|
||||
markdown: string;
|
||||
className?: string;
|
||||
plain?: boolean;
|
||||
};
|
||||
|
||||
function MarkdownPreviewImpl({
|
||||
markdown,
|
||||
className,
|
||||
plain = false,
|
||||
}: MarkdownPreviewProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
plain
|
||||
? "nodrag h-full w-full overflow-auto p-2 text-xs leading-relaxed"
|
||||
: "nodrag max-h-56 overflow-auto rounded-md border border-border/60 bg-muted/20 p-2 text-xs leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Streamdown mode="static" plugins={MARKDOWN_PLUGINS} controls={false}>
|
||||
{markdown.trim() ? markdown : "_Empty note_"}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MarkdownPreview = memo(MarkdownPreviewImpl);
|
||||
|
|
@ -21,17 +21,19 @@ import type { LlmType, NodeConfig, SamplerType, SeedSourceType } from "../types"
|
|||
import {
|
||||
makeExpressionConfig,
|
||||
makeLlmConfig,
|
||||
makeMarkdownNoteConfig,
|
||||
makeModelConfig,
|
||||
makeModelProviderConfig,
|
||||
makeSamplerConfig,
|
||||
makeSeedConfig,
|
||||
} from "../utils";
|
||||
|
||||
export type BlockKind = "sampler" | "llm" | "expression" | "seed";
|
||||
export type BlockKind = "sampler" | "llm" | "expression" | "seed" | "note";
|
||||
export type BlockType =
|
||||
| SamplerType
|
||||
| LlmType
|
||||
| "expression"
|
||||
| "markdown_note"
|
||||
| "seed"
|
||||
| "seed_hf"
|
||||
| "seed_local"
|
||||
|
|
@ -52,6 +54,7 @@ export type BlockGroup = {
|
|||
|
||||
export type BlockDialogKey =
|
||||
| "seed"
|
||||
| "markdown_note"
|
||||
| "category"
|
||||
| "subcategory"
|
||||
| "uniform"
|
||||
|
|
@ -101,6 +104,12 @@ export const BLOCK_GROUPS: BlockGroup[] = [
|
|||
description: "Derive columns with Jinja templates.",
|
||||
icon: FunctionIcon,
|
||||
},
|
||||
{
|
||||
kind: "note",
|
||||
title: "Notes",
|
||||
description: "Add markdown notes to document your flow.",
|
||||
icon: PencilEdit02Icon,
|
||||
},
|
||||
];
|
||||
|
||||
const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
||||
|
|
@ -275,6 +284,15 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
|||
dialogKey: "expression",
|
||||
createConfig: (id, existing) => makeExpressionConfig(id, existing),
|
||||
},
|
||||
{
|
||||
kind: "note",
|
||||
type: "markdown_note",
|
||||
title: "Markdown note",
|
||||
description: "UI-only markdown notes on canvas, not sent to backend.",
|
||||
icon: PencilEdit02Icon,
|
||||
dialogKey: "markdown_note",
|
||||
createConfig: (id, existing) => makeMarkdownNoteConfig(id, existing),
|
||||
},
|
||||
];
|
||||
|
||||
export function getBlocksForKind(kind: BlockKind): BlockDefinition[] {
|
||||
|
|
@ -319,5 +337,8 @@ export function getBlockDefinitionForConfig(
|
|||
if (config.kind === "model_config") {
|
||||
return getBlockDefinition("llm", "model_config");
|
||||
}
|
||||
if (config.kind === "markdown_note") {
|
||||
return getBlockDefinition("note", "markdown_note");
|
||||
}
|
||||
return getBlockDefinition("expression", "expression");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { SubcategoryDialog } from "../dialogs/samplers/subcategory-dialog";
|
|||
import { TimedeltaDialog } from "../dialogs/samplers/timedelta-dialog";
|
||||
import { UniformDialog } from "../dialogs/samplers/uniform-dialog";
|
||||
import { UuidDialog } from "../dialogs/samplers/uuid-dialog";
|
||||
import { MarkdownNoteDialog } from "../dialogs/markdown-note/markdown-note-dialog";
|
||||
|
||||
export function renderBlockDialog(
|
||||
config: NodeConfig | null,
|
||||
|
|
@ -108,5 +109,9 @@ export function renderBlockDialog(
|
|||
return config.kind === "expression" ? (
|
||||
<ExpressionDialog config={config} onUpdate={update} />
|
||||
) : null;
|
||||
case "markdown_note":
|
||||
return config.kind === "markdown_note" ? (
|
||||
<MarkdownNoteDialog config={config} onUpdate={update} />
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,15 @@ import {
|
|||
type SeedBlockType,
|
||||
} from "../blocks/registry";
|
||||
|
||||
type SheetView = "root" | "sampler" | "seed" | "llm" | "expression" | "processor";
|
||||
type SheetKind = "sampler" | "seed" | "llm" | "expression";
|
||||
type SheetView =
|
||||
| "root"
|
||||
| "sampler"
|
||||
| "seed"
|
||||
| "llm"
|
||||
| "expression"
|
||||
| "note"
|
||||
| "processor";
|
||||
type SheetKind = "sampler" | "seed" | "llm" | "expression" | "note";
|
||||
type RootSheetView = Exclude<SheetView, "root">;
|
||||
type RootGroup = {
|
||||
kind: RootSheetView;
|
||||
|
|
@ -48,6 +55,7 @@ type BlockSheetProps = {
|
|||
onAddModelProvider: () => void;
|
||||
onAddModelConfig: () => void;
|
||||
onAddExpression: () => void;
|
||||
onAddMarkdownNote: () => void;
|
||||
onOpenProcessors: () => void;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
|
|
@ -67,6 +75,9 @@ function getSheetTitle(sheetView: SheetView): string {
|
|||
if (sheetView === "expression") {
|
||||
return "Expression blocks";
|
||||
}
|
||||
if (sheetView === "note") {
|
||||
return "Note blocks";
|
||||
}
|
||||
if (sheetView === "processor") {
|
||||
return "Processor blocks";
|
||||
}
|
||||
|
|
@ -79,6 +90,7 @@ const VIEW_KIND: Record<SheetView, SheetKind | null> = {
|
|||
seed: "seed",
|
||||
llm: "llm",
|
||||
expression: "expression",
|
||||
note: "note",
|
||||
processor: null,
|
||||
};
|
||||
|
||||
|
|
@ -142,6 +154,7 @@ export function BlockSheet({
|
|||
onAddModelProvider,
|
||||
onAddModelConfig,
|
||||
onAddExpression,
|
||||
onAddMarkdownNote,
|
||||
onOpenProcessors,
|
||||
copied,
|
||||
onCopy,
|
||||
|
|
@ -150,6 +163,7 @@ export function BlockSheet({
|
|||
const sheetTitle = getSheetTitle(sheetView);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||
const expressionBlocks = useMemo(() => getBlocksForKind("expression"), []);
|
||||
const noteBlocks = useMemo(() => getBlocksForKind("note"), []);
|
||||
const seedBlocks = useMemo(() => getBlocksForKind("seed"), []);
|
||||
const isControlled = typeof open === "boolean";
|
||||
const sheetOpen = isControlled ? (open as boolean) : uncontrolledOpen;
|
||||
|
|
@ -233,6 +247,11 @@ export function BlockSheet({
|
|||
onAddExpression();
|
||||
return;
|
||||
}
|
||||
if (item.kind === "note" && noteBlocks.length === 1) {
|
||||
setSheetOpen(false);
|
||||
onAddMarkdownNote();
|
||||
return;
|
||||
}
|
||||
onViewChange(item.kind);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -270,8 +289,10 @@ export function BlockSheet({
|
|||
} else {
|
||||
onAddLlm(item.type as LlmType);
|
||||
}
|
||||
} else {
|
||||
} else if (item.kind === "expression") {
|
||||
onAddExpression();
|
||||
} else {
|
||||
onAddMarkdownNote();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownPreview } from "@/components/markdown/markdown-preview";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BalanceScaleIcon,
|
||||
|
|
@ -63,6 +64,9 @@ const NODE_META = {
|
|||
expression: {
|
||||
tone: "bg-indigo-50 text-indigo-600 border-indigo-100",
|
||||
},
|
||||
note: {
|
||||
tone: "bg-violet-50 text-violet-700 border-violet-100",
|
||||
},
|
||||
seed: {
|
||||
tone: "bg-lime-50 text-lime-700 border-lime-100",
|
||||
},
|
||||
|
|
@ -107,6 +111,9 @@ function resolveNodeIcon(
|
|||
if (kind === "expression") {
|
||||
return FunctionIcon;
|
||||
}
|
||||
if (kind === "note") {
|
||||
return PencilEdit02Icon;
|
||||
}
|
||||
if (kind === "model_provider") {
|
||||
return Shield02Icon;
|
||||
}
|
||||
|
|
@ -197,6 +204,13 @@ function getConfigSummary(config: NodeConfig | undefined): string {
|
|||
return "Upload PDF/DOCX/TXT file";
|
||||
}
|
||||
|
||||
if (config.kind === "markdown_note") {
|
||||
if (config.markdown.trim()) {
|
||||
return "Markdown preview";
|
||||
}
|
||||
return "Add markdown content";
|
||||
}
|
||||
|
||||
return "Open details for config";
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +219,10 @@ function renderNodeBody(
|
|||
summary: string,
|
||||
updateConfig: (id: string, patch: Partial<NodeConfig>) => void,
|
||||
): ReactElement {
|
||||
if (config?.kind === "markdown_note") {
|
||||
return <MarkdownPreview markdown={config.markdown} />;
|
||||
}
|
||||
|
||||
if (config && isInlineConfig(config)) {
|
||||
const onUpdate = (patch: Partial<NodeConfig>) => updateConfig(config.id, patch);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { ReactElement } from "react";
|
||||
import type { MarkdownNoteConfig } from "../../types";
|
||||
import { FieldLabel } from "../shared/field-label";
|
||||
import { NameField } from "../shared/name-field";
|
||||
|
||||
type MarkdownNoteDialogProps = {
|
||||
config: MarkdownNoteConfig;
|
||||
onUpdate: (patch: Partial<MarkdownNoteConfig>) => void;
|
||||
};
|
||||
|
||||
export function MarkdownNoteDialog({
|
||||
config,
|
||||
onUpdate,
|
||||
}: MarkdownNoteDialogProps): ReactElement {
|
||||
const markdownId = `${config.id}-markdown`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<NameField value={config.name} onChange={(value) => onUpdate({ name: value })} />
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Markdown"
|
||||
htmlFor={markdownId}
|
||||
hint="UI-only note. Not sent to backend payload recipe."
|
||||
/>
|
||||
<Textarea
|
||||
id={markdownId}
|
||||
className="corner-squircle nodrag min-h-[180px]"
|
||||
placeholder="## Note"
|
||||
value={config.markdown}
|
||||
onChange={(event) => onUpdate({ markdown: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -112,6 +112,7 @@ export function RecipeStudioPage({
|
|||
addModelProviderNode,
|
||||
addModelConfigNode,
|
||||
addExpressionNode,
|
||||
addMarkdownNoteNode,
|
||||
selectConfig,
|
||||
updateConfig,
|
||||
isValidConnection,
|
||||
|
|
@ -149,6 +150,7 @@ export function RecipeStudioPage({
|
|||
addModelProviderNode: state.addModelProviderNode,
|
||||
addModelConfigNode: state.addModelConfigNode,
|
||||
addExpressionNode: state.addExpressionNode,
|
||||
addMarkdownNoteNode: state.addMarkdownNoteNode,
|
||||
selectConfig: state.selectConfig,
|
||||
updateConfig: state.updateConfig,
|
||||
isValidConnection: state.isValidConnection,
|
||||
|
|
@ -462,6 +464,7 @@ export function RecipeStudioPage({
|
|||
onAddModelProvider={addModelProviderNode}
|
||||
onAddModelConfig={addModelConfigNode}
|
||||
onAddExpression={addExpressionNode}
|
||||
onAddMarkdownNote={addMarkdownNoteNode}
|
||||
onOpenProcessors={openProcessorsFromSheet}
|
||||
copied={copied}
|
||||
onCopy={copyRecipe}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,14 @@ import {
|
|||
updateNodeData,
|
||||
} from "./recipe-studio-helpers";
|
||||
|
||||
type SheetView = "root" | "sampler" | "seed" | "llm" | "expression" | "processor";
|
||||
type SheetView =
|
||||
| "root"
|
||||
| "sampler"
|
||||
| "seed"
|
||||
| "llm"
|
||||
| "expression"
|
||||
| "note"
|
||||
| "processor";
|
||||
|
||||
type RecipeStudioState = {
|
||||
nodes: RecipeNode[];
|
||||
|
|
@ -72,6 +79,7 @@ type RecipeStudioState = {
|
|||
addModelProviderNode: () => void;
|
||||
addModelConfigNode: () => void;
|
||||
addExpressionNode: () => void;
|
||||
addMarkdownNoteNode: () => void;
|
||||
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
|
||||
loadRecipe: (snapshot: RecipeSnapshot) => void;
|
||||
setAuxNodePosition: (id: string, position: XYPosition) => void;
|
||||
|
|
@ -438,6 +446,8 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
}),
|
||||
addExpressionNode: () =>
|
||||
set((state) => buildAddedNodeState(state, "expression", "expression")),
|
||||
addMarkdownNoteNode: () =>
|
||||
set((state) => buildAddedNodeState(state, "note", "markdown_note")),
|
||||
loadRecipe: (snapshot) =>
|
||||
set((state) => ({
|
||||
configs: snapshot.configs,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type RecipeNodeData = {
|
|||
| "llm"
|
||||
| "expression"
|
||||
| "seed"
|
||||
| "note"
|
||||
| "model_provider"
|
||||
| "model_config";
|
||||
subtype: string;
|
||||
|
|
@ -38,6 +39,7 @@ export type RecipeNodeData = {
|
|||
| LlmType
|
||||
| "expression"
|
||||
| "seed"
|
||||
| "markdown_note"
|
||||
| "model_provider"
|
||||
| "model_config";
|
||||
layoutDirection?: LayoutDirection;
|
||||
|
|
@ -215,6 +217,13 @@ export type ExpressionConfig = {
|
|||
dtype: ExpressionDtype;
|
||||
};
|
||||
|
||||
export type MarkdownNoteConfig = {
|
||||
id: string;
|
||||
kind: "markdown_note";
|
||||
name: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
export type SeedConfig = {
|
||||
id: string;
|
||||
kind: "seed";
|
||||
|
|
@ -265,6 +274,7 @@ export type NodeConfig =
|
|||
| SamplerConfig
|
||||
| LlmConfig
|
||||
| ExpressionConfig
|
||||
| MarkdownNoteConfig
|
||||
| SeedConfig
|
||||
| ModelProviderConfig
|
||||
| ModelConfig;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
ExpressionConfig,
|
||||
LlmConfig,
|
||||
LlmType,
|
||||
MarkdownNoteConfig,
|
||||
ModelConfig,
|
||||
ModelProviderConfig,
|
||||
NodeConfig,
|
||||
|
|
@ -277,6 +278,18 @@ export function makeExpressionConfig(
|
|||
};
|
||||
}
|
||||
|
||||
export function makeMarkdownNoteConfig(
|
||||
id: string,
|
||||
existing: NodeConfig[],
|
||||
): MarkdownNoteConfig {
|
||||
return {
|
||||
id,
|
||||
kind: "markdown_note",
|
||||
name: nextName(existing, "note"),
|
||||
markdown: "## Note\n\nAdd markdown here.",
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSeedConfig(
|
||||
id: string,
|
||||
existing: NodeConfig[],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
LlmConfig,
|
||||
LlmMcpProviderConfig,
|
||||
LlmToolConfig,
|
||||
MarkdownNoteConfig,
|
||||
NodeConfig,
|
||||
RecipeProcessorConfig,
|
||||
SeedSourceType,
|
||||
|
|
@ -40,6 +41,11 @@ type UiInput = {
|
|||
unstructured_chunk_overlap?: unknown;
|
||||
};
|
||||
|
||||
type UiMarkdownNoteNode = {
|
||||
name: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
function readStringNumber(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
|
|
@ -221,6 +227,31 @@ function cloneMcpProvider(config: LlmMcpProviderConfig): LlmMcpProviderConfig {
|
|||
};
|
||||
}
|
||||
|
||||
function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
const noteNodes: UiMarkdownNoteNode[] = [];
|
||||
for (const node of input) {
|
||||
if (!isRecord(node)) {
|
||||
continue;
|
||||
}
|
||||
const nodeType = readString(node.node_type) ?? readString(node.type);
|
||||
if (nodeType !== "markdown_note") {
|
||||
continue;
|
||||
}
|
||||
const name = readString(node.name) ?? readString(node.id);
|
||||
if (!name?.trim()) {
|
||||
continue;
|
||||
}
|
||||
noteNodes.push({
|
||||
name: name.trim(),
|
||||
markdown: readString(node.markdown) ?? "",
|
||||
});
|
||||
}
|
||||
return noteNodes;
|
||||
}
|
||||
|
||||
function attachLlmTooling(
|
||||
config: LlmConfig,
|
||||
toolConfigsByAlias: Map<string, LlmToolConfig>,
|
||||
|
|
@ -301,6 +332,24 @@ export function importRecipePayload(input: string): ImportResult {
|
|||
const uiUnstructuredChunkOverlap = readStringNumber(
|
||||
ui?.unstructured_chunk_overlap,
|
||||
);
|
||||
const uiMarkdownNotes = parseUiMarkdownNoteNodes(ui?.nodes);
|
||||
|
||||
for (const note of uiMarkdownNotes) {
|
||||
const id = `n${nextId}`;
|
||||
nextId += 1;
|
||||
const config: MarkdownNoteConfig = {
|
||||
id,
|
||||
kind: "markdown_note",
|
||||
name: note.name,
|
||||
markdown: note.markdown,
|
||||
};
|
||||
if (nameToId.has(config.name)) {
|
||||
errors.push(`Duplicate column name: ${config.name}.`);
|
||||
continue;
|
||||
}
|
||||
nameToId.set(config.name, config.id);
|
||||
configs.push(config);
|
||||
}
|
||||
|
||||
if (recipe.seed_config) {
|
||||
const id = `n${nextId}`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export {
|
||||
makeExpressionConfig,
|
||||
makeLlmConfig,
|
||||
makeMarkdownNoteConfig,
|
||||
makeModelConfig,
|
||||
makeModelProviderConfig,
|
||||
makeSamplerConfig,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@ export function nodeDataFromConfig(
|
|||
layoutDirection,
|
||||
};
|
||||
}
|
||||
if (config.kind === "markdown_note") {
|
||||
return {
|
||||
title: "Note",
|
||||
kind: "note",
|
||||
subtype: "Markdown",
|
||||
blockType: "markdown_note",
|
||||
name: config.name,
|
||||
layoutDirection,
|
||||
};
|
||||
}
|
||||
if (config.kind === "seed") {
|
||||
const seedSourceType = config.seed_source_type ?? "hf";
|
||||
const subtype =
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ export function buildRecipePayload(
|
|||
// SeedConfig is global config (seed_config); seed-dataset columns are added by DataDesigner.
|
||||
continue;
|
||||
}
|
||||
if (config.kind === "markdown_note") {
|
||||
continue;
|
||||
}
|
||||
if (config.kind === "model_provider") {
|
||||
modelProviderNames.add(config.name);
|
||||
modelProviders.push(buildModelProvider(config, errors));
|
||||
|
|
@ -189,6 +192,19 @@ export function buildRecipePayload(
|
|||
return [];
|
||||
}
|
||||
const width = readNodeWidth(node);
|
||||
if (config.kind === "markdown_note") {
|
||||
return [
|
||||
{
|
||||
id: config.name,
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
...(width !== null ? { width } : {}),
|
||||
node_type: "markdown_note" as const,
|
||||
name: config.name,
|
||||
markdown: config.markdown,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: config.name,
|
||||
|
|
@ -205,6 +221,9 @@ export function buildRecipePayload(
|
|||
if (!(source && target)) {
|
||||
return [];
|
||||
}
|
||||
if (source.kind === "markdown_note" || target.kind === "markdown_note") {
|
||||
return [];
|
||||
}
|
||||
const semantic =
|
||||
edge.type === "semantic" || isSemanticRelation(source, target);
|
||||
const sourceHandleNormalized = normalizeRecipeHandleId(edge.sourceHandle);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,15 @@ export type RecipePayload = {
|
|||
merge_batches?: boolean;
|
||||
};
|
||||
ui: {
|
||||
nodes: { id: string; x: number; y: number }[];
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
node_type?: "markdown_note";
|
||||
name?: string;
|
||||
markdown?: string;
|
||||
}>;
|
||||
edges: {
|
||||
from: string;
|
||||
to: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue