model config and provider fixes and inline dialog
This commit is contained in:
parent
7350e52f2f
commit
2a9a332ce3
10 changed files with 506 additions and 41 deletions
284
docs/canvas-lab-architecture.md
Normal file
284
docs/canvas-lab-architecture.md
Normal 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.
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue