decouple aux nodes and text dom for aux nodes

This commit is contained in:
shine1i 2026-02-06 12:12:32 +01:00
commit e7d8b55948
9 changed files with 627 additions and 168 deletions

View file

@ -3,7 +3,7 @@
Root:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab`
This doc reflects current code shape (React Flow UI node/edge shell + inline config split).
This doc reflects current code shape (React Flow UI node/edge shell + inline/dialog split + derived aux nodes).
## 1) High-level flow
@ -46,7 +46,7 @@ File:
Current wiring:
```ts
const NODE_TYPES: NodeTypes = { builder: CanvasNode };
const NODE_TYPES: NodeTypes = { builder: CanvasNode, aux: CanvasAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: CanvasSemanticEdge };
```
@ -62,6 +62,7 @@ defaultEdgeOptions={{
Node click selects config (`selectConfig`), does not auto-open dialog.
Dialog opens via node `Details` button (`openConfig`) or explicit flows.
Aux nodes (prompt/system/scorer) are derived in page and mounted as `type: "aux"`.
## 4) Registry-driven block system
@ -82,6 +83,7 @@ File:
Store owns:
- `nodes`, `edges`, `configs`, `processors`
- `auxNodePositions` (derived aux-node position persistence)
- add/update/remove/connect logic
- `layoutDirection` + dagre apply-layout
- config selection/dialog state
@ -90,6 +92,10 @@ Current config-selection API:
- `selectConfig(id)`: select node config, keep dialog closed
- `openConfig(id)`: select + open modal
Aux-node API:
- `setAuxNodePosition(id, position)`: persist independent drag position
- `syncAuxNodePositions(activeIds, defaults)`: cleanup stale aux positions + seed new defaults
Add-node behavior is mode-aware via:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts`
@ -120,6 +126,11 @@ Inline editors:
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-llm.tsx`
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-expression.tsx`
LLM inline scope:
- text: `model_alias` only
- code: `model_alias`, `code_lang`
- prompt/system prompt edited in dialog or spawned aux prompt nodes
## 7) Node UI architecture (React Flow UI shell)
File:
@ -135,6 +146,7 @@ Current node UX:
- summary text for dialog-first configs
- `Details` button opens modal dialog
- node resizer logic enabled (`NodeResizer`), visuals hidden (no corner/box affordance)
- LLM input handles (system/prompt/scorers) render in dedicated content rows (no overlay on inline controls)
## 8) Handles + layout direction
@ -146,6 +158,10 @@ dataIn: "data-in"
dataOut: "data-out"
semanticIn: "semantic-in"
semanticOut: "semantic-out"
llmPromptIn: "llm-prompt-in"
llmSystemIn: "llm-system-in"
llmInputOut: "llm-input-out"
getLlmJudgeScoreHandleId(index): `llm-judge-score-in-${index}`
```
`canvas-node.tsx` switches handle positions by layout direction:
@ -172,6 +188,20 @@ Features:
Legacy mixed edge component is removed (no `canvas-edge.tsx` path in active flow).
## 9.1) Derived aux nodes (LLM prompt/system/scorer)
Files:
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/canvas-aux-node.tsx`
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx`
Behavior:
- If `llm.prompt` is non-empty, spawn `llm-prompt-input` aux node
- If `llm.system_prompt` is non-empty, spawn `llm-prompt-input` aux node
- For `llm_type === "judge"`, spawn one `llm-judge-score` aux node per `scores[index]`
- Aux edges auto-connect from aux `llmInputOut` to parent LLM target handles
- Aux nodes are draggable independently; positions persist in Zustand `auxNodePositions`
- Aux nodes are derived UI/editor projections only (no payload schema change)
## 10) Connection semantics + side effects
File:

View file

@ -2,15 +2,25 @@ import {
Background,
BackgroundVariant,
Controls,
type Edge,
type EdgeChange,
type EdgeTypes,
type Node,
type NodeChange,
type NodeTypes,
type XYPosition,
Panel,
ReactFlow,
useReactFlow,
useUpdateNodeInternals,
} from "@xyflow/react";
import { type ReactElement, useCallback, useMemo, useState } from "react";
import {
type ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useShallow } from "zustand/react/shallow";
import "@xyflow/react/dist/style.css";
import { Button } from "@/components/ui/button";
@ -18,6 +28,7 @@ import { Spinner } from "@/components/ui/spinner";
import { EyeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { previewCanvas } from "./api";
import { CanvasAuxNode, type CanvasAuxNodeData } from "./components/canvas-aux-node";
import { BlockSheet } from "./components/block-sheet";
import { CanvasNode } from "./components/canvas-node";
import { CanvasSemanticEdge } from "./components/canvas-semantic-edge";
@ -26,13 +37,18 @@ import { ConfigDialog } from "./dialogs/config-dialog";
import { ImportDialog } from "./dialogs/import-dialog";
import { ProcessorsDialog } from "./dialogs/processors-dialog";
import { useCanvasLabStore } from "./stores/canvas-lab";
import type { CanvasNodeData, SamplerConfig } from "./types";
import type {
CanvasNode as CanvasBuilderNode,
CanvasNodeData,
SamplerConfig,
} from "./types";
import { isCategoryConfig } from "./utils";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "./utils/handles";
import { importCanvasPayload } from "./utils/import";
import { buildCanvasPayload } from "./utils/payload";
import { buildDefaultSchemaTransform } from "./utils/processors";
const NODE_TYPES: NodeTypes = { builder: CanvasNode };
const NODE_TYPES: NodeTypes = { builder: CanvasNode, aux: CanvasAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: CanvasSemanticEdge };
type LayoutControlsProps = {
@ -41,6 +57,28 @@ type LayoutControlsProps = {
onToggleDirection: () => void;
};
type InternalsSyncProps = {
nodeIds: string[];
};
function InternalsSync({ nodeIds }: InternalsSyncProps): null {
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
if (nodeIds.length === 0) {
return;
}
requestAnimationFrame(() => {
updateNodeInternals(nodeIds);
requestAnimationFrame(() => {
updateNodeInternals(nodeIds);
});
});
}, [nodeIds, updateNodeInternals]);
return null;
}
function LayoutControls({
direction,
onLayout,
@ -70,6 +108,9 @@ function LayoutControls({
onToggleDirection();
requestAnimationFrame(() => {
refreshNodeInternals();
requestAnimationFrame(() => {
refreshNodeInternals();
});
});
}, [onToggleDirection, refreshNodeInternals]);
@ -89,6 +130,7 @@ export function CanvasLabPage(): ReactElement {
const {
nodes,
edges,
auxNodePositions,
configs,
processors,
sheetView,
@ -112,10 +154,13 @@ export function CanvasLabPage(): ReactElement {
loadCanvas,
setLayoutDirection,
applyLayout,
setAuxNodePosition,
syncAuxNodePositions,
} = useCanvasLabStore(
useShallow((state) => ({
nodes: state.nodes,
edges: state.edges,
auxNodePositions: state.auxNodePositions,
configs: state.configs,
processors: state.processors,
sheetView: state.sheetView,
@ -139,6 +184,8 @@ export function CanvasLabPage(): ReactElement {
loadCanvas: state.loadCanvas,
setLayoutDirection: state.setLayoutDirection,
applyLayout: state.applyLayout,
setAuxNodePosition: state.setAuxNodePosition,
syncAuxNodePositions: state.syncAuxNodePositions,
})),
);
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
@ -153,13 +200,191 @@ export function CanvasLabPage(): ReactElement {
text: string;
} | null>(null);
const baseNodeIds = useMemo(
() => new Set(nodes.map((node) => node.id)),
[nodes],
);
const baseEdgeIds = useMemo(
() => new Set(edges.map((edge) => edge.id)),
[edges],
);
const displayGraph = useMemo(() => {
const auxNodes: Node<CanvasAuxNodeData>[] = [];
const auxEdges: Edge[] = [];
const auxDefaults: Record<string, XYPosition> = {};
const auxNodeIds: string[] = [];
for (const node of nodes) {
const config = configs[node.id];
if (!(config && config.kind === "llm")) {
continue;
}
const llmDirection = node.data.layoutDirection ?? layoutDirection;
const items: Array<{
key: string;
targetHandle: string;
data: CanvasAuxNodeData;
}> = [];
if (config.system_prompt.trim()) {
items.push({
key: "system",
targetHandle: HANDLE_IDS.llmSystemIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "system_prompt",
title: "System Prompt",
layoutDirection: llmDirection,
},
});
}
if (config.prompt.trim()) {
items.push({
key: "prompt",
targetHandle: HANDLE_IDS.llmPromptIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "prompt",
title: "Prompt",
layoutDirection: llmDirection,
},
});
}
if (config.llm_type === "judge") {
(config.scores ?? []).forEach((_score, scoreIndex) => {
items.push({
key: `score-${scoreIndex}`,
targetHandle: getLlmJudgeScoreHandleId(scoreIndex),
data: {
kind: "llm-judge-score",
llmId: config.id,
scoreIndex,
layoutDirection: llmDirection,
},
});
});
}
if (items.length === 0) {
continue;
}
const itemSpan = 140;
const itemCenterOffset = ((items.length - 1) * itemSpan) / 2;
const horizontalSpan = 300;
const horizontalCenterOffset = ((items.length - 1) * horizontalSpan) / 2;
items.forEach((item, index) => {
const auxId = `aux-${node.id}-${item.key}`;
const defaultPosition =
llmDirection === "TB"
? {
x: node.position.x + index * horizontalSpan - horizontalCenterOffset,
y: node.position.y - 210,
}
: {
x: node.position.x - 330,
y: node.position.y + index * itemSpan - itemCenterOffset,
};
const position = auxNodePositions[auxId] ?? defaultPosition;
auxNodeIds.push(auxId);
if (!auxNodePositions[auxId]) {
auxDefaults[auxId] = defaultPosition;
}
auxNodes.push({
id: auxId,
type: "aux",
data: item.data,
position,
draggable: true,
selectable: true,
focusable: true,
connectable: false,
});
auxEdges.push({
id: `e-${auxId}-${node.id}`,
source: auxId,
sourceHandle: HANDLE_IDS.llmInputOut,
target: node.id,
targetHandle: item.targetHandle,
type: "canvas",
data: { path: "auto" },
selectable: false,
focusable: false,
style: { strokeWidth: 1.5, stroke: "var(--border)" },
});
});
}
return {
nodes: [...nodes, ...auxNodes],
edges: [...edges, ...auxEdges],
auxNodeIds,
auxDefaults,
};
}, [auxNodePositions, configs, edges, layoutDirection, nodes]);
const displayNodeIds = useMemo(
() => displayGraph.nodes.map((node) => node.id),
[displayGraph.nodes],
);
useEffect(() => {
syncAuxNodePositions(displayGraph.auxNodeIds, displayGraph.auxDefaults);
}, [displayGraph.auxDefaults, displayGraph.auxNodeIds, syncAuxNodePositions]);
const handleNodeClick = useCallback(
(_: unknown, node: Node<CanvasNodeData>) => {
(_: unknown, node: Node<CanvasNodeData | CanvasAuxNodeData>) => {
if (node.type !== "builder") {
return;
}
selectConfig(node.id);
},
[selectConfig],
);
const handleNodesChange = useCallback(
(changes: NodeChange<Node<CanvasNodeData | CanvasAuxNodeData>>[]) => {
for (const change of changes) {
if (
!("id" in change) ||
change.type !== "position" ||
!change.id.startsWith("aux-") ||
!change.position
) {
continue;
}
setAuxNodePosition(change.id, change.position);
}
const next = changes.filter(
(change): change is NodeChange<CanvasBuilderNode> =>
"id" in change && baseNodeIds.has(change.id),
);
if (next.length > 0) {
onNodesChange(next);
}
},
[baseNodeIds, onNodesChange, setAuxNodePosition],
);
const handleEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
const next = changes.filter(
(change): change is EdgeChange<Edge> =>
"id" in change && baseEdgeIds.has(change.id),
);
if (next.length > 0) {
onEdgesChange(next);
}
},
[baseEdgeIds, onEdgesChange],
);
const config = activeConfigId ? configs[activeConfigId] : null;
const categoryOptions = useMemo<SamplerConfig[]>(
() => Object.values(configs).filter(isCategoryConfig),
@ -310,8 +535,8 @@ export function CanvasLabPage(): ReactElement {
ref={setSheetContainer}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodes={displayGraph.nodes}
edges={displayGraph.edges}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
@ -319,8 +544,8 @@ export function CanvasLabPage(): ReactElement {
data: { key: "name", path: "auto" },
style: { strokeWidth: 1.5, stroke: "var(--border)" },
}}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
isValidConnection={isValidConnection}
@ -332,6 +557,7 @@ export function CanvasLabPage(): ReactElement {
onLayout={applyLayout}
onToggleDirection={handleToggleDirection}
/>
<InternalsSync nodeIds={displayNodeIds} />
<Background
variant={BackgroundVariant.Dots}
gap={18}

View file

@ -0,0 +1,200 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { memo, type ReactElement } from "react";
import { useCanvasLabStore } from "../stores/canvas-lab";
import type { LayoutDirection, LlmConfig, Score, ScoreOption } from "../types";
import { HANDLE_IDS } from "../utils/handles";
import { BaseNode, BaseNodeContent, BaseNodeHeader, BaseNodeHeaderTitle } from "./rf-ui/base-node";
type PromptField = "prompt" | "system_prompt";
type PromptInputNodeData = {
kind: "llm-prompt-input";
llmId: string;
field: PromptField;
title: string;
layoutDirection: LayoutDirection;
};
type JudgeScoreNodeData = {
kind: "llm-judge-score";
llmId: string;
scoreIndex: number;
layoutDirection: LayoutDirection;
};
export type CanvasAuxNodeData = PromptInputNodeData | JudgeScoreNodeData;
export type CanvasAuxNodeType = Node<CanvasAuxNodeData, "aux">;
function updateScoreAt(
config: LlmConfig,
scoreIndex: number,
patch: Partial<Score>,
): Score[] {
const scores = config.scores ?? [];
return scores.map((score, index) =>
index === scoreIndex ? { ...score, ...patch } : score,
);
}
function updateOptionAt(
score: Score,
optionIndex: number,
patch: Partial<ScoreOption>,
): ScoreOption[] {
return score.options.map((option, index) =>
index === optionIndex ? { ...option, ...patch } : option,
);
}
function AuxNodeBase({ data }: NodeProps<CanvasAuxNodeType>): ReactElement | null {
const config = useCanvasLabStore((state) => state.configs[data.llmId]);
const updateConfig = useCanvasLabStore((state) => state.updateConfig);
if (!(config && config.kind === "llm")) {
return null;
}
const sourcePosition =
data.layoutDirection === "TB" ? Position.Bottom : Position.Right;
if (data.kind === "llm-prompt-input") {
const value = data.field === "prompt" ? config.prompt : config.system_prompt;
return (
<BaseNode className="corner-squircle min-w-[250px] rounded-lg border-border/60 bg-card shadow-sm">
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">{data.title}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent className="px-3 py-2">
<Textarea
className="nodrag min-h-[78px] text-xs"
value={value}
onChange={(event) =>
updateConfig(data.llmId, {
[data.field]: event.target.value,
} as Partial<LlmConfig>)
}
/>
</BaseNodeContent>
<Handle
id={HANDLE_IDS.llmInputOut}
type="source"
position={sourcePosition}
className="!size-2 !border-border !bg-background"
/>
</BaseNode>
);
}
const score = config.scores?.[data.scoreIndex];
if (!score) {
return null;
}
const updateScore = (patch: Partial<Score>): void => {
updateConfig(data.llmId, {
scores: updateScoreAt(config, data.scoreIndex, patch),
});
};
const removeScore = (): void => {
const nextScores = (config.scores ?? []).filter(
(_score, index) => index !== data.scoreIndex,
);
updateConfig(data.llmId, { scores: nextScores });
};
const addOption = (): void => {
updateScore({
options: [...score.options, { value: "", description: "" }],
});
};
const removeOption = (optionIndex: number): void => {
updateScore({
options: score.options.filter((_option, index) => index !== optionIndex),
});
};
const updateOption = (
optionIndex: number,
patch: Partial<ScoreOption>,
): void => {
updateScore({
options: updateOptionAt(score, optionIndex, patch),
});
};
return (
<BaseNode className="corner-squircle min-w-[280px] rounded-lg border-border/60 bg-card shadow-sm">
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">
{score.name.trim() || `Scorer ${data.scoreIndex + 1}`}
</BaseNodeHeaderTitle>
<Button type="button" size="xs" variant="ghost" className="nodrag" onClick={removeScore}>
Remove
</Button>
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<Input
className="nodrag h-7 text-xs"
placeholder="Score name"
value={score.name}
onChange={(event) => updateScore({ name: event.target.value })}
/>
<Textarea
className="nodrag min-h-[56px] text-xs"
placeholder="Score description"
value={score.description}
onChange={(event) => updateScore({ description: event.target.value })}
/>
<div className="space-y-1">
{score.options.map((option, optionIndex) => (
<div key={`${data.llmId}-score-${data.scoreIndex}-opt-${optionIndex}`} className="grid grid-cols-[74px_1fr_auto] gap-1">
<Input
className="nodrag h-7 text-xs"
placeholder="Value"
value={option.value}
onChange={(event) =>
updateOption(optionIndex, { value: event.target.value })
}
/>
<Input
className="nodrag h-7 text-xs"
placeholder="Description"
value={option.description}
onChange={(event) =>
updateOption(optionIndex, {
description: event.target.value,
})
}
/>
<Button
type="button"
size="xs"
variant="ghost"
className="nodrag"
onClick={() => removeOption(optionIndex)}
>
x
</Button>
</div>
))}
<Button type="button" size="xs" variant="outline" className="nodrag mt-1" onClick={addOption}>
Add option
</Button>
</div>
</BaseNodeContent>
<Handle
id={HANDLE_IDS.llmInputOut}
type="source"
position={sourcePosition}
className="!size-2 !border-border !bg-background"
/>
</BaseNode>
);
}
export const CanvasAuxNode = memo(AuxNodeBase);

View file

@ -19,6 +19,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Handle,
NodeResizer,
Position,
useUpdateNodeInternals,
@ -32,7 +33,7 @@ import type {
NodeConfig,
SamplerType,
} from "../types";
import { HANDLE_IDS } from "../utils/handles";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../utils/handles";
import { InlineExpression } from "./inline/inline-expression";
import { InlineLlm } from "./inline/inline-llm";
import { InlineModel } from "./inline/inline-model";
@ -108,20 +109,6 @@ function resolveNodeIcon(
return DiceFaces03Icon;
}
function toSingleLine(value: string | undefined): string {
if (!value) {
return "";
}
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) {
return "";
}
if (normalized.length <= 96) {
return normalized;
}
return `${normalized.slice(0, 93)}...`;
}
function getConfigSummary(config: NodeConfig | undefined): string {
if (!config) {
return "Open details for config";
@ -164,21 +151,14 @@ function getConfigSummary(config: NodeConfig | undefined): string {
}
if (config.kind === "llm") {
const prompt = toSingleLine(config.prompt);
if (config.llm_type === "structured") {
if (prompt) {
return `Prompt: ${prompt}`;
}
return "Structured output schema in details";
}
if (config.llm_type === "judge") {
const scoreCount = config.scores?.length ?? 0;
if (prompt) {
return `${scoreCount} scores · ${prompt}`;
}
return `${scoreCount} scores`;
return `${scoreCount} scorers`;
}
return "Open details for config";
return "Prompt/system via linked input nodes";
}
return "Open details for config";
@ -228,6 +208,83 @@ function renderInlineEditor(
return null;
}
type LlmInputHandleItem = {
id: string;
label: string;
};
function getLlmInputHandleItems(config: NodeConfig | undefined): LlmInputHandleItem[] {
if (!(config && config.kind === "llm")) {
return [];
}
const items: LlmInputHandleItem[] = [];
if (config.system_prompt.trim()) {
items.push({ id: HANDLE_IDS.llmSystemIn, label: "System" });
}
if (config.prompt.trim()) {
items.push({ id: HANDLE_IDS.llmPromptIn, label: "Prompt" });
}
if (config.llm_type === "judge") {
(config.scores ?? []).forEach((score, index) => {
items.push({
id: getLlmJudgeScoreHandleId(index),
label: score.name.trim() || `Score ${index + 1}`,
});
});
}
return items;
}
type LlmInputHandlesProps = {
items: LlmInputHandleItem[];
isTopBottom: boolean;
};
function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactElement | null {
if (items.length === 0) {
return null;
}
if (isTopBottom) {
return (
<div className="flex flex-wrap gap-2 pb-1">
{items.map((item) => (
<div
key={item.id}
className="pointer-events-none relative flex min-w-[80px] flex-1 justify-center pt-2"
>
<Handle
id={item.id}
type="target"
position={Position.Top}
className="pointer-events-auto !size-2 !border-border !bg-background"
style={{ left: "50%", top: 0, transform: "translate(-50%, -50%)" }}
/>
<span className="text-[10px] text-muted-foreground">{item.label}</span>
</div>
))}
</div>
);
}
return (
<div className="space-y-1 pb-1">
{items.map((item) => (
<div key={item.id} className="pointer-events-none relative pl-3">
<Handle
id={item.id}
type="target"
position={Position.Left}
className="pointer-events-auto !size-2 !border-border !bg-background"
style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }}
/>
<span className="text-[10px] text-muted-foreground">{item.label}</span>
</div>
))}
</div>
);
}
function CanvasNodeBase({
id,
data,
@ -258,6 +315,7 @@ function CanvasNodeBase({
const inlineEditor = renderInlineEditor(config, updateConfig);
const summary = getConfigSummary(config);
const llmInputHandles = getLlmInputHandleItems(config);
return (
<BaseNode className="corner-squircle relative min-w-[260px] overflow-visible rounded-lg border-border/60 shadow-sm">
@ -308,6 +366,7 @@ function CanvasNodeBase({
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<LlmInputHandles items={llmInputHandles} isTopBottom={isTopBottom} />
{inlineEditor ? (
inlineEditor
) : (

View file

@ -6,7 +6,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
import type { LlmConfig } from "../../types";
@ -72,12 +71,9 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement {
</SelectContent>
</Select>
)}
<Textarea
className="nodrag min-h-[56px] text-xs"
placeholder="Prompt"
value={config.prompt}
onChange={(event) => onUpdate({ prompt: event.target.value })}
/>
<p className="text-[11px] text-muted-foreground">
Prompt/System are edited in dialog or linked input nodes.
</p>
</div>
);
}

View file

@ -18,8 +18,8 @@ const INLINE_FIELD_MAP: InlineFieldMap = {
uuid: ["uuid_format"],
model_provider: ["provider_type", "endpoint"],
model_config: ["provider", "model", "inference_temperature"],
llm_text: ["model_alias", "prompt"],
llm_code: ["model_alias", "code_lang", "prompt"],
llm_text: ["model_alias"],
llm_code: ["model_alias", "code_lang"],
expression: ["dtype", "expr"],
};

View file

@ -7,7 +7,6 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@ -18,7 +17,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, useMemo, useRef } from "react";
import { useCanvasLabStore } from "../../stores/canvas-lab";
import type { LlmConfig, Score, ScoreOption } from "../../types";
import type { LlmConfig, Score } from "../../types";
import { NameField } from "../shared/name-field";
const CODE_LANG_OPTIONS = [
@ -68,13 +67,6 @@ export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement {
onUpdate({ [key]: value } as Partial<LlmConfig>);
};
const updateScores = (next: Score[]) => updateField("scores", next);
const updateScore = (index: number, patch: Partial<Score>) => {
updateScores(
scores.map((score, i) =>
i === index ? { ...score, ...patch } : score,
),
);
};
const removeScore = (index: number) => {
updateScores(scores.filter((_, i) => i !== index));
};
@ -91,38 +83,6 @@ export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement {
},
]);
};
const updateOption = (
scoreIndex: number,
optionIndex: number,
patch: Partial<ScoreOption>,
) => {
const score = scores[scoreIndex];
if (!score) {
return;
}
const nextOptions = score.options.map((option, i) =>
i === optionIndex ? { ...option, ...patch } : option,
);
updateScore(scoreIndex, { options: nextOptions });
};
const addOption = (scoreIndex: number) => {
const score = scores[scoreIndex];
if (!score) {
return;
}
updateScore(scoreIndex, {
options: [...score.options, { value: "", description: "" }],
});
};
const removeOption = (scoreIndex: number, optionIndex: number) => {
const score = scores[scoreIndex];
if (!score) {
return;
}
updateScore(scoreIndex, {
options: score.options.filter((_, i) => i !== optionIndex),
});
};
return (
<div className="space-y-4">
<NameField
@ -211,100 +171,30 @@ export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement {
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Scores
Scorers
</p>
<Button type="button" size="xs" variant="outline" onClick={addScore}>
Add score
Add scorer block
</Button>
</div>
{scores.length === 0 && (
<p className="text-xs text-muted-foreground">
Add at least one score to define evaluation criteria.
Add scorer blocks. Each block spawns on canvas and connects to this judge node.
</p>
)}
{scores.map((score, index) => (
<div
key={`${config.id}-score-${index}`}
className="rounded-2xl border border-border/60 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="grid flex-1 gap-2">
<Input
className="nodrag"
placeholder="Score name (e.g., Relevance)"
value={score.name}
onChange={(event) =>
updateScore(index, { name: event.target.value })
}
/>
<Textarea
className="nodrag"
placeholder="Score description and scoring guide"
value={score.description}
onChange={(event) =>
updateScore(index, { description: event.target.value })
}
/>
</div>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => removeScore(index)}
>
Remove
</Button>
</div>
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Options
</p>
<Button
type="button"
size="xs"
variant="outline"
onClick={() => addOption(index)}
>
Add option
</Button>
</div>
{score.options.map((option, optionIndex) => (
<div
key={`${config.id}-score-${index}-opt-${optionIndex}`}
className="flex items-start gap-2"
>
<Input
className="nodrag w-20"
placeholder="Value"
value={option.value}
onChange={(event) =>
updateOption(index, optionIndex, {
value: event.target.value,
})
}
/>
<Textarea
className="nodrag min-h-[2.5rem] flex-1"
placeholder="Description"
value={option.description}
onChange={(event) =>
updateOption(index, optionIndex, {
description: event.target.value,
})
}
/>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => removeOption(index, optionIndex)}
>
Remove
</Button>
</div>
))}
<div key={`${config.id}-score-${index}`} className="flex items-center justify-between rounded-xl border border-border/60 px-3 py-2">
<div>
<p className="text-xs font-semibold text-foreground">
{score.name.trim() || `Scorer ${index + 1}`}
</p>
<p className="text-[11px] text-muted-foreground">
{(score.options ?? []).length} options
</p>
</div>
<Button type="button" size="xs" variant="ghost" onClick={() => removeScore(index)}>
Remove
</Button>
</div>
))}
</div>

View file

@ -4,6 +4,7 @@ import {
type EdgeChange,
type IsValidConnection,
type NodeChange,
type XYPosition,
addEdge,
applyEdgeChanges,
applyNodeChanges,
@ -40,6 +41,7 @@ type SheetView = "root" | "sampler" | "llm" | "expression" | "processor";
type CanvasLabState = {
nodes: CanvasNode[];
edges: Edge[];
auxNodePositions: Record<string, XYPosition>;
configs: Record<string, NodeConfig>;
processors: CanvasProcessorConfig[];
sheetView: SheetView;
@ -62,6 +64,11 @@ type CanvasLabState = {
addExpressionNode: () => void;
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
loadCanvas: (snapshot: CanvasSnapshot) => void;
setAuxNodePosition: (id: string, position: XYPosition) => void;
syncAuxNodePositions: (
activeIds: string[],
defaults: Record<string, XYPosition>,
) => void;
onNodesChange: (changes: NodeChange<CanvasNode>[]) => void;
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
onConnect: (connection: Connection) => void;
@ -71,6 +78,7 @@ type CanvasLabState = {
export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
nodes: [],
edges: [],
auxNodePositions: {},
configs: {},
processors: [],
sheetView: "root",
@ -181,10 +189,52 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
processors: snapshot.processors,
nextId: snapshot.nextId,
nextY: snapshot.nextY,
auxNodePositions: {},
activeConfigId: null,
dialogOpen: false,
sheetView: "root",
})),
setAuxNodePosition: (id, position) =>
set((state) => {
const current = state.auxNodePositions[id];
if (current && current.x === position.x && current.y === position.y) {
return state;
}
return {
auxNodePositions: {
...state.auxNodePositions,
[id]: position,
},
};
}),
syncAuxNodePositions: (activeIds, defaults) =>
set((state) => {
const nextPositions: Record<string, XYPosition> = {};
for (const id of activeIds) {
const existing = state.auxNodePositions[id];
if (existing) {
nextPositions[id] = existing;
continue;
}
const fallback = defaults[id];
if (fallback) {
nextPositions[id] = fallback;
}
}
const prevIds = Object.keys(state.auxNodePositions);
const nextIds = Object.keys(nextPositions);
if (prevIds.length !== nextIds.length) {
return { auxNodePositions: nextPositions };
}
for (const id of nextIds) {
const prev = state.auxNodePositions[id];
const next = nextPositions[id];
if (!(prev && prev.x === next.x && prev.y === next.y)) {
return { auxNodePositions: nextPositions };
}
}
return state;
}),
updateConfig: (id, patch) => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
const applyUpdate = (state: CanvasLabState) => {

View file

@ -5,6 +5,14 @@ export const HANDLE_IDS = {
// semantic dependency lanes
semanticIn: "semantic-in",
semanticOut: "semantic-out",
// llm prompt/scorer lanes
llmPromptIn: "llm-prompt-in",
llmSystemIn: "llm-system-in",
llmInputOut: "llm-input-out",
} as const;
export type CanvasHandleId = (typeof HANDLE_IDS)[keyof typeof HANDLE_IDS];
export function getLlmJudgeScoreHandleId(index: number): string {
return `llm-judge-score-in-${index}`;
}