decouple aux nodes and text dom for aux nodes
This commit is contained in:
parent
b1445febdb
commit
e7d8b55948
9 changed files with 627 additions and 168 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue