From 2a9a332ce3de07efde03eb2767c8f97f5adeafac Mon Sep 17 00:00:00 2001 From: shine1i Date: Thu, 5 Feb 2026 20:55:39 +0100 Subject: [PATCH] model config and provider fixes and inline dialog --- docs/canvas-lab-architecture.md | 284 ++++++++++++++++++ studio/frontend/src/components/ui/dialog.tsx | 26 +- .../features/canvas-lab/canvas-lab-page.tsx | 2 + .../canvas-lab/dialogs/config-dialog.tsx | 10 +- .../canvas-lab/dialogs/import-dialog.tsx | 24 +- .../canvas-lab/dialogs/llm/llm-dialog.tsx | 57 +++- .../dialogs/models/model-config-dialog.tsx | 66 ++-- .../features/canvas-lab/stores/canvas-lab.ts | 31 ++ .../features/canvas-lab/utils/import/edges.ts | 24 +- .../src/features/canvas-lab/utils/payload.ts | 23 +- 10 files changed, 506 insertions(+), 41 deletions(-) create mode 100644 docs/canvas-lab-architecture.md diff --git a/docs/canvas-lab-architecture.md b/docs/canvas-lab-architecture.md new file mode 100644 index 0000000000..629ae49f26 --- /dev/null +++ b/docs/canvas-lab-architecture.md @@ -0,0 +1,284 @@ +# Canvas Lab Architecture (Current) + +Root: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab` + +This doc explains current architecture, how nodes map to payload/import, and how to add new blocks safely. + +## 1) High-level flow + +1. UI renders canvas + dialogs in: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx` +2. Add-block sheet uses registry metadata to create config objects: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx` +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx` +3. Zustand store owns nodes/edges/configs and all mutation logic: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts` +4. Graph connection logic updates references + semantic edges: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts` +5. Export (preview/copy) converts in-memory graph/config to API payload: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload.ts` +6. Import reconstructs configs, nodes, edges from JSON: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/importer.ts` + +## 2) Core types (single source of truth) + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/types/index.ts` + +`NodeConfig` is the main union the whole feature uses: + +```ts +export type NodeConfig = + | SamplerConfig + | LlmConfig + | ExpressionConfig + | ModelProviderConfig + | ModelConfig; +``` + +Canvas node UI data (`CanvasNodeData`) is derived from config via `nodeDataFromConfig`. + +## 3) Entrypoint wiring + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx` + +Key wiring: + +```ts +const NODE_TYPES: NodeTypes = { builder: CanvasNode }; +const EDGE_TYPES: EdgeTypes = { canvas: CanvasEdge, semantic: CanvasEdge }; +``` + +`CanvasLabPage` pulls actions/state from store and passes add handlers into `BlockSheet`: + +```ts + +``` + +Preview/copy route through `buildCanvasPayload`, import route through `importCanvasPayload`. + +## 4) Registry-driven block system + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx` + +Registry defines each block in one place: +- sheet title/icon/description +- config factory (`createConfig`) +- config dialog (`renderDialog`) + +Example (model blocks): + +```ts +{ + kind: "llm", + type: "model_provider", + createConfig: (id, existing) => makeModelProviderConfig(id, existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "model_provider" ? ( + onUpdate(config.id, patch)} /> + ) : null, +} +``` + +Important: `getBlockDefinitionForConfig` must map every new `config.kind`, else dialog won't render. + +## 5) Config factories + node label mapping + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/index.ts` + +Responsibilities: +- create default config objects (`makeSamplerConfig`, `makeLlmConfig`, `makeModelProviderConfig`, `makeModelConfig`, `makeExpressionConfig`) +- map `NodeConfig -> CanvasNodeData` via `nodeDataFromConfig` + +Example mapping: + +```ts +if (config.kind === "model_provider") { + return { + title: "Model Provider", + kind: "model_provider", + subtype: config.provider_type || "Provider", + blockType: "model_provider", + name: config.name, + layoutDirection, + }; +} +``` + +This is what controls visible node title/subtitle in the canvas. + +## 6) Store responsibilities + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts` + +Store owns: +- graph state (`nodes`, `edges`) +- config map (`configs[id]`) +- add/update/remove/connect operations +- layout direction + apply layout + +Add-node pattern (all block types follow same shape): + +```ts +const definition = getBlockDefinition("llm", "model_config"); +const config = definition.createConfig(id, existing); +return buildNodeUpdate(state, config, state.layoutDirection); +``` + +When model config `provider` field changes, store auto-syncs semantic edge to matching provider name. + +## 7) Edge semantics + connection behavior + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts` + +Semantic edge classifier: + +```ts +function isSemanticEdge(source: NodeConfig, target: NodeConfig): boolean { + if (source.kind === "model_provider" && target.kind === "model_config") return true; + if (source.kind === "model_config" && target.kind === "llm") return true; + return source.kind === "sampler" && source.sampler_type === "category" + && target.kind === "sampler" && target.sampler_type === "subcategory"; +} +``` + +Connection side effects: +- `model_provider -> model_config`: set `model_config.provider = source.name` +- `model_config -> llm`: set `llm.model_alias = source.name` +- regular data edges into LLM/expression append `{{ source_name }}` refs + +Edge rendering (dotted semantic edges): +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/canvas-edge.tsx` + +```ts +const nextStyle = type === "semantic" + ? { ...style, strokeDasharray: "4 4" } + : style; +``` + +## 8) Rename/remove propagation + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts` + +Centralized consistency updates: +- rename updates: + - Jinja refs in `llm.prompt/system_prompt/output_format` + - expression `expr` + - subcategory parent + - `model_config.provider` + - `llm.model_alias` +- removal clears same references + +This keeps graph fields stable when upstream nodes renamed/deleted. + +## 9) Payload building (node graph -> API) + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload.ts` + +`buildCanvasPayload(configs, nodes, edges)` outputs: + +```ts +{ + recipe: { + model_providers: [...], + model_configs: [...], + columns: [...], + processors: [], + }, + run: { rows: 5, preview: true, output_formats: ["jsonl"] }, + ui: { nodes: [...], edges: [...] } +} +``` + +How relation is enforced: +- collect `model_alias` values used by LLM columns +- ensure each alias exists in `recipe.model_configs` +- validate `model_config.provider` points to existing provider +- require endpoint/provider_type only for providers that are actually referenced + +This is why unused provider/config blocks can exist without blocking preview. + +## 10) Import pipeline (API -> node graph) + +Entry file: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/importer.ts` + +Order of reconstruction: +1. parse `recipe.model_providers` -> `ModelProviderConfig` +2. parse `recipe.model_configs` -> `ModelConfig` +3. parse `recipe.columns` -> sampler/llm/expression +4. build nodes with positions +5. build edges + +Edge inference file: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/edges.ts` + +If UI edges missing, infer semantic edges from fields: +- `subcategory_parent` +- `model_config.provider` +- `llm.model_alias` + +## 11) Dialog routing and edit UIs + +Config dialog shell: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx` + +It calls: +`renderBlockDialog(config, categoryOptions, onUpdate)` + +Model dialogs: +- provider: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/dialogs/models/model-provider-dialog.tsx` +- model config: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx` + +`ModelConfigDialog` and `LlmDialog` use shadcn `Combobox` fed from store configs: +- model config `provider` suggests model-provider node names +- llm `model_alias` suggests model-config aliases + +## 12) How to add a new block (checklist) + +Minimal path for a new block type: + +1. Add/extend type in: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/types/index.ts` +2. Add default factory + node label mapping in: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/index.ts` +3. Add block definition in: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx` +4. Add dialog component and route it via `renderDialog` in registry. +5. Add store add-action if block should be special-cased from sheet: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts` +6. Add payload serialization/validation in: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload.ts` +7. Add import parsing + inferred edges in: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts` +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/importer.ts` +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/edges.ts` +8. If connection has semantic meaning, extend: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts` + +## 13) Practical mental model + +- `NodeConfig` is source-of-truth business state. +- `CanvasNodeData` is derived display state. +- registry = block metadata + factories + dialog routing. +- store = mutation orchestration. +- graph utils = connection semantics. +- payload/import utils = external contract boundary. + +If one piece changes, keep all six in sync. diff --git a/studio/frontend/src/components/ui/dialog.tsx b/studio/frontend/src/components/ui/dialog.tsx index 7c3151c815..05ab36fdb7 100644 --- a/studio/frontend/src/components/ui/dialog.tsx +++ b/studio/frontend/src/components/ui/dialog.tsx @@ -34,13 +34,17 @@ function DialogClose({ function DialogOverlay({ className, + position = "fixed", ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + position?: "fixed" | "absolute"; +}) { return ( & { showCloseButton?: boolean; + container?: HTMLElement | null; + position?: "fixed" | "absolute"; + overlayClassName?: string; + overlayPosition?: "fixed" | "absolute"; }) { return ( - - + + ); diff --git a/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx index 06ad08b188..693208cf91 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx @@ -12,6 +12,7 @@ type ConfigDialogProps = { config: NodeConfig | null; categoryOptions: SamplerConfig[]; onUpdate: (id: string, patch: Partial) => void; + container?: HTMLDivElement | null; }; export function ConfigDialog({ @@ -20,10 +21,17 @@ export function ConfigDialog({ config, categoryOptions, onUpdate, + container, }: ConfigDialogProps): ReactElement { return ( - + {!config && (
diff --git a/studio/frontend/src/features/canvas-lab/dialogs/import-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/import-dialog.tsx index c5b93501bf..83badb8a98 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/import-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/import-dialog.tsx @@ -7,29 +7,31 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; -import { type ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useState } from "react"; type ImportDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; onImport: (value: string) => string | null; + container?: HTMLDivElement | null; }; export function ImportDialog({ open, onOpenChange, onImport, + container, }: ImportDialogProps): ReactElement { const [value, setValue] = useState(""); const [error, setError] = useState(null); const payloadId = "canvas-import-payload"; - - useEffect(() => { - if (!open) { + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { setValue(""); setError(null); } - }, [open]); + onOpenChange(nextOpen); + }; const handleImport = () => { const message = onImport(value); @@ -37,12 +39,18 @@ export function ImportDialog({ setError(message); return; } - onOpenChange(false); + handleOpenChange(false); }; return ( - - + + Import recipe diff --git a/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx index 9d0a96a8ce..7e02b2ab52 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx @@ -1,4 +1,12 @@ import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; import { Select, @@ -8,7 +16,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import type { ReactElement } from "react"; +import { type ReactElement, useMemo, useRef } from "react"; +import { useCanvasLabStore } from "../../stores/canvas-lab"; import type { LlmConfig, Score, ScoreOption } from "../../types"; import { NameField } from "../shared/name-field"; @@ -37,11 +46,20 @@ type LlmDialogProps = { }; export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement { + const configs = useCanvasLabStore((state) => state.configs); + const modelConfigAliases = useMemo( + () => + Object.values(configs) + .filter((item) => item.kind === "model_config") + .map((item) => item.name), + [configs], + ); const modelAliasId = `${config.id}-model-alias`; const codeLangId = `${config.id}-code-lang`; const promptId = `${config.id}-prompt`; const outputFormatId = `${config.id}-output-format`; const systemPromptId = `${config.id}-system-prompt`; + const modelAliasAnchorRef = useRef(null); const scores = config.scores ?? []; const updateField = ( key: K, @@ -118,12 +136,37 @@ export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement { > Model alias - updateField("model_alias", event.target.value)} - /> +
+ updateField("model_alias", value ?? "")} + onInputValueChange={(value) => updateField("model_alias", value)} + itemToStringValue={(value) => value} + autoHighlight={true} + > + + + No model configs found + + {(alias: string) => ( + + {alias} + + )} + + + +
+

+ Pick a model config alias. Matching node link becomes semantic. +

{config.llm_type === "code" && (
diff --git a/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx index 0848ac0ee1..899e79ee34 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx @@ -1,6 +1,14 @@ import { Checkbox } from "@/components/ui/checkbox"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; -import type { ReactElement } from "react"; +import { type ReactElement, useMemo, useRef } from "react"; import type { ModelConfig } from "../../types"; import { useCanvasLabStore } from "../../stores/canvas-lab"; import { NameField } from "../shared/name-field"; @@ -14,17 +22,20 @@ export function ModelConfigDialog({ config, onUpdate, }: ModelConfigDialogProps): ReactElement { - const providerOptions = useCanvasLabStore((state) => - Object.values(state.configs) - .filter((item) => item.kind === "model_provider") - .map((item) => item.name), + const configs = useCanvasLabStore((state) => state.configs); + const providerOptions = useMemo( + () => + Object.values(configs) + .filter((item) => item.kind === "model_provider") + .map((item) => item.name), + [configs], ); const modelId = `${config.id}-model`; const providerId = `${config.id}-provider`; - const providerListId = `${config.id}-provider-list`; const tempId = `${config.id}-temperature`; const topPId = `${config.id}-top-p`; const maxTokensId = `${config.id}-max-tokens`; + const providerAnchorRef = useRef(null); const updateField = ( key: K, value: ModelConfig[K], @@ -60,18 +71,37 @@ export function ModelConfigDialog({ > Provider name - updateField("provider", event.target.value)} - /> - - {providerOptions.map((provider) => ( - +
+ updateField("provider", value ?? "")} + onInputValueChange={(value) => updateField("provider", value)} + itemToStringValue={(value) => value} + autoHighlight={true} + > + + + No providers found + + {(provider: string) => ( + + {provider} + + )} + + + +
+

+ Pick provider name from list. Matching node link becomes semantic. +