model config and provider fixes and inline dialog

This commit is contained in:
shine1i 2026-02-05 20:55:39 +01:00
commit 2a9a332ce3
10 changed files with 506 additions and 41 deletions

View file

@ -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
<BlockSheet
onAddSampler={addSamplerNode}
onAddLlm={addLlmNode}
onAddModelProvider={addModelProviderNode}
onAddModelConfig={addModelConfigNode}
onAddExpression={addExpressionNode}
/>
```
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" ? (
<ModelProviderDialog config={config} onUpdate={(patch) => 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.

View file

@ -34,13 +34,17 @@ function DialogClose({
function DialogOverlay({
className,
position = "fixed",
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
}: React.ComponentProps<typeof DialogPrimitive.Overlay> & {
position?: "fixed" | "absolute";
}) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 inset-0 isolate z-50",
position === "fixed" ? "fixed" : "absolute",
className,
)}
{...props}
@ -52,17 +56,29 @@ function DialogContent({
className,
children,
showCloseButton = true,
container,
position = "fixed",
overlayClassName,
overlayPosition,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
container?: HTMLElement | null;
position?: "fixed" | "absolute";
overlayClassName?: string;
overlayPosition?: "fixed" | "absolute";
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPortal container={container ?? undefined}>
<DialogOverlay
className={overlayClassName}
position={overlayPosition ?? position}
/>
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 sm:max-w-md top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
position === "fixed" ? "fixed" : "absolute",
className,
)}
{...props}

View file

@ -325,11 +325,13 @@ export function CanvasLabPage(): ReactElement {
config={config}
categoryOptions={categoryOptions}
onUpdate={updateConfig}
container={sheetContainer}
/>
<ImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImport={handleImport}
container={sheetContainer}
/>
</div>
);

View file

@ -12,6 +12,7 @@ type ConfigDialogProps = {
config: NodeConfig | null;
categoryOptions: SamplerConfig[];
onUpdate: (id: string, patch: Partial<NodeConfig>) => void;
container?: HTMLDivElement | null;
};
export function ConfigDialog({
@ -20,10 +21,17 @@ export function ConfigDialog({
config,
categoryOptions,
onUpdate,
container,
}: ConfigDialogProps): ReactElement {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogContent
container={container}
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="sm:max-w-2xl shadow-border"
>
<DialogShell />
{!config && (
<div className="text-sm text-muted-foreground">

View file

@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-auto sm:max-w-2xl">
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
container={container}
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="max-h-[85vh] overflow-auto sm:max-w-2xl"
>
<DialogHeader>
<DialogTitle>Import recipe</DialogTitle>
</DialogHeader>

View file

@ -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<HTMLDivElement>(null);
const scores = config.scores ?? [];
const updateField = <K extends keyof LlmConfig>(
key: K,
@ -118,12 +136,37 @@ export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement {
>
Model alias
</label>
<Input
id={modelAliasId}
className="nodrag"
value={config.model_alias}
onChange={(event) => updateField("model_alias", event.target.value)}
/>
<div ref={modelAliasAnchorRef}>
<Combobox
items={modelConfigAliases}
filteredItems={modelConfigAliases}
filter={null}
value={config.model_alias || null}
onValueChange={(value) => updateField("model_alias", value ?? "")}
onInputValueChange={(value) => updateField("model_alias", value)}
itemToStringValue={(value) => value}
autoHighlight={true}
>
<ComboboxInput
id={modelAliasId}
className="nodrag w-full"
placeholder="Pick model alias or type"
/>
<ComboboxContent anchor={modelAliasAnchorRef}>
<ComboboxEmpty>No model configs found</ComboboxEmpty>
<ComboboxList>
{(alias: string) => (
<ComboboxItem key={alias} value={alias}>
{alias}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<p className="text-xs text-muted-foreground">
Pick a model config alias. Matching node link becomes semantic.
</p>
</div>
{config.llm_type === "code" && (
<div className="grid gap-2">

View file

@ -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<HTMLDivElement>(null);
const updateField = <K extends keyof ModelConfig>(
key: K,
value: ModelConfig[K],
@ -60,18 +71,37 @@ export function ModelConfigDialog({
>
Provider name
</label>
<Input
id={providerId}
className="nodrag"
value={config.provider}
list={providerListId}
onChange={(event) => updateField("provider", event.target.value)}
/>
<datalist id={providerListId}>
{providerOptions.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
<div ref={providerAnchorRef}>
<Combobox
items={providerOptions}
filteredItems={providerOptions}
filter={null}
value={config.provider || null}
onValueChange={(value) => updateField("provider", value ?? "")}
onInputValueChange={(value) => updateField("provider", value)}
itemToStringValue={(value) => value}
autoHighlight={true}
>
<ComboboxInput
id={providerId}
className="nodrag w-full"
placeholder="Pick provider or type name"
/>
<ComboboxContent anchor={providerAnchorRef}>
<ComboboxEmpty>No providers found</ComboboxEmpty>
<ComboboxList>
{(provider: string) => (
<ComboboxItem key={provider} value={provider}>
{provider}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<p className="text-xs text-muted-foreground">
Pick provider name from list. Matching node link becomes semantic.
</p>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">

View file

@ -251,6 +251,37 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
}
}
const hasModelAliasPatch = Object.prototype.hasOwnProperty.call(
patch,
"model_alias",
);
if (current.kind === "llm" && hasModelAliasPatch) {
const nextAlias =
(patch as Partial<NodeConfig> & { model_alias?: string }).model_alias ?? "";
edges = edges.filter((edge) => {
if (edge.target !== id) {
return true;
}
const source = configs[edge.source];
return !(source && source.kind === "model_config");
});
if (nextAlias) {
const modelConfigId = findNodeIdByName(configs, nextAlias);
if (modelConfigId) {
edges = addEdge(
{
source: modelConfigId,
target: id,
sourceHandle: null,
targetHandle: null,
type: "semantic",
},
edges,
);
}
}
}
if (isCategoryConfig(current)) {
const nextCategory = isCategoryConfig(next) ? next : current;
const oldValues = current.values ?? [];

View file

@ -2,6 +2,21 @@ import type { Edge } from "@xyflow/react";
import type { NodeConfig } from "../../types";
import { extractRefs } from "./helpers";
function isSemanticConnection(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"
);
}
export function buildEdges(
configs: NodeConfig[],
nameToId: Map<string, string>,
@ -9,6 +24,7 @@ export function buildEdges(
): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const configByName = new Map(configs.map((config) => [config.name, config]));
const addEdgeByName = (from: string, to: string, type?: string) => {
const sourceId = nameToId.get(from);
const targetId = nameToId.get(to);
@ -20,11 +36,17 @@ export function buildEdges(
return;
}
seen.add(key);
const source = configByName.get(from);
const target = configByName.get(to);
const normalizedType =
source && target && isSemanticConnection(source, target)
? "semantic"
: (type ?? "canvas");
edges.push({
id: `e-${key}`,
source: sourceId,
target: targetId,
type: type ?? "canvas",
type: normalizedType,
});
};

View file

@ -36,6 +36,24 @@ export type CanvasPayloadResult = {
payload: CanvasPayload;
};
function isSemanticRelation(
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"
);
}
function parseNumber(value?: string): number | null {
if (!value) {
return null;
@ -485,7 +503,10 @@ export function buildCanvasPayload(
{
from: source.name,
to: target.name,
type: edge.type ?? "canvas",
type:
edge.type === "semantic" || isSemanticRelation(source, target)
? "semantic"
: "canvas",
},
];
});