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 })} /> +
+ +