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:
Shine1i 2026-02-24 03:11:29 +01:00
commit 8f6e475145
15 changed files with 269 additions and 6 deletions

View 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);

View file

@ -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");
}

View file

@ -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;
}
}

View file

@ -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();
}
}}
/>

View file

@ -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);

View file

@ -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>
);
}

View file

@ -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}

View file

@ -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,

View file

@ -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;

View file

@ -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[],

View file

@ -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}`;

View file

@ -1,6 +1,7 @@
export {
makeExpressionConfig,
makeLlmConfig,
makeMarkdownNoteConfig,
makeModelConfig,
makeModelProviderConfig,
makeSamplerConfig,

View file

@ -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 =

View file

@ -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);

View file

@ -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;