diff --git a/studio/frontend/src/components/markdown/markdown-preview.tsx b/studio/frontend/src/components/markdown/markdown-preview.tsx
new file mode 100644
index 0000000000..cc6ddede65
--- /dev/null
+++ b/studio/frontend/src/components/markdown/markdown-preview.tsx
@@ -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 (
+
+
+ {markdown.trim() ? markdown : "_Empty note_"}
+
+
+ );
+}
+
+export const MarkdownPreview = memo(MarkdownPreviewImpl);
diff --git a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts
index 461d119aae..75d860290e 100644
--- a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts
+++ b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts
@@ -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");
}
diff --git a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx
index 0b930fb0ce..f99c1c78da 100644
--- a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx
@@ -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" ? (
) : null;
+ case "markdown_note":
+ return config.kind === "markdown_note" ? (
+
+ ) : null;
}
}
diff --git a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx
index f602fc55c7..9c765fd5ee 100644
--- a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx
@@ -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;
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 = {
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();
}
}}
/>
diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
index 483a99a165..e58510aa2d 100644
--- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
@@ -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) => void,
): ReactElement {
+ if (config?.kind === "markdown_note") {
+ return ;
+ }
+
if (config && isInlineConfig(config)) {
const onUpdate = (patch: Partial) => updateConfig(config.id, patch);
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/markdown-note/markdown-note-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/markdown-note/markdown-note-dialog.tsx
new file mode 100644
index 0000000000..4724ff9efe
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/dialogs/markdown-note/markdown-note-dialog.tsx
@@ -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) => void;
+};
+
+export function MarkdownNoteDialog({
+ config,
+ onUpdate,
+}: MarkdownNoteDialogProps): ReactElement {
+ const markdownId = `${config.id}-markdown`;
+
+ return (
+
+
onUpdate({ name: value })} />
+
+
+
+
+ );
+}
diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
index b7ad23eafe..3a17155945 100644
--- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
+++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
@@ -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}
diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
index ca947998ba..038705cc27 100644
--- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
@@ -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) => void;
loadRecipe: (snapshot: RecipeSnapshot) => void;
setAuxNodePosition: (id: string, position: XYPosition) => void;
@@ -438,6 +446,8 @@ export const useRecipeStudioStore = create((set, get) => ({
}),
addExpressionNode: () =>
set((state) => buildAddedNodeState(state, "expression", "expression")),
+ addMarkdownNoteNode: () =>
+ set((state) => buildAddedNodeState(state, "note", "markdown_note")),
loadRecipe: (snapshot) =>
set((state) => ({
configs: snapshot.configs,
diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts
index e435d3c62a..f2febc4bc0 100644
--- a/studio/frontend/src/features/recipe-studio/types/index.ts
+++ b/studio/frontend/src/features/recipe-studio/types/index.ts
@@ -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;
diff --git a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
index 70f83dc39a..dee84dacec 100644
--- a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
@@ -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[],
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
index a02bdb473b..60f44c3825 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
@@ -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,
@@ -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}`;
diff --git a/studio/frontend/src/features/recipe-studio/utils/index.ts b/studio/frontend/src/features/recipe-studio/utils/index.ts
index af5ae9c074..7ff7a5fe57 100644
--- a/studio/frontend/src/features/recipe-studio/utils/index.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/index.ts
@@ -1,6 +1,7 @@
export {
makeExpressionConfig,
makeLlmConfig,
+ makeMarkdownNoteConfig,
makeModelConfig,
makeModelProviderConfig,
makeSamplerConfig,
diff --git a/studio/frontend/src/features/recipe-studio/utils/node-data.ts b/studio/frontend/src/features/recipe-studio/utils/node-data.ts
index dbf2cdca54..96e9a0f4e2 100644
--- a/studio/frontend/src/features/recipe-studio/utils/node-data.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/node-data.ts
@@ -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 =
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
index 583de3cfa1..ee49fec0aa 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
@@ -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);
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
index c407431288..12e3fce9ff 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
@@ -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;