diff --git a/.claude/gw-plans/canvas/README.md b/.claude/gw-plans/canvas/README.md index 84fb574..950d587 100644 --- a/.claude/gw-plans/canvas/README.md +++ b/.claude/gw-plans/canvas/README.md @@ -7,8 +7,8 @@ React 19 + React Flow frontend phases. Depends on execution phases 3-4 for API s | Phase | Plan | Status | |-------|------|--------| | 1 | [Canvas core](phase-1-canvas-core/overview.md) -- Home view, Start/LLM/End nodes, edge wiring, config panel, save/load | Complete | -| 2 | [SSE run panel](phase-2-sse-run-panel/overview.md) -- SSE streaming, run panel, node highlighting, reconnection, resume | In progress | -| 3 | Full node set -- Tool/Condition/HumanInput nodes, settings page | Not started | +| 2 | [SSE run panel](phase-2-sse-run-panel/overview.md) -- SSE streaming, run panel, node highlighting, reconnection, resume | Complete | +| 3 | [Full node set](phase-3-full-node-set/overview.md) -- Tool/Condition/HumanInput nodes, settings page | Planned | | 4 | Validation, run input modal, state panel | Not started | | 5 | Error handling, run history, debug panel, JSON schema panel | Not started | | 6 | Python export, JSON read/write, dark mode, polish | Not started | diff --git a/.claude/gw-plans/canvas/phase-3-full-node-set/overview.md b/.claude/gw-plans/canvas/phase-3-full-node-set/overview.md new file mode 100644 index 0000000..a51ce95 --- /dev/null +++ b/.claude/gw-plans/canvas/phase-3-full-node-set/overview.md @@ -0,0 +1,141 @@ +# Canvas Phase 3 — Full Node Set + Settings Page + +## Goal + +Add Tool, Condition, and HumanInput node components to the canvas so users can +build complete graphs visually. Add a settings page showing provider status. +After this phase, every node type in GraphSchema is drawable and configurable. + +## What already exists + +| Layer | Status | +|-------|--------| +| Schema types (ToolNode, ConditionNode, HumanInputNode) | Fully defined in `schema.ts` | +| Execution layer (builder, routers, tool registry) | Fully implemented | +| Tool registry (8 tools: calculator, datetime, url_fetch, etc.) | Production-ready | +| `GET /settings/providers` endpoint | Returns configured status per provider | +| BaseNodeShell, StartNode, LLMNode, EndNode components | Complete | +| Config panel pattern (NodeConfigPanel, LLMNodeConfig) | Complete | +| NODE_DEFAULTS, TOOLBAR_ITEMS, nodeTypes | Only start/llm/end registered | +| Client-side validation (validateGraph) | Only start/end/llm rules | + +## Architecture + +``` + TOOLBAR (toolbarItems.ts) + | + adds 3 new items: tool, condition, human_input + | + v + NODE DEFAULTS (nodeDefaults.ts) + | + adds default configs for tool, condition, human_input + | + v + +-------------------+-------------------+ + | | | + v v v + ToolNode.tsx ConditionNode.tsx HumanInputNode.tsx + (wrench icon) (git-branch icon) (user icon) + | | | + v v v + BaseNodeShell BaseNodeShell BaseNodeShell + (tool_name badge) (condition type (prompt preview) + badge + branch + count) + | | | + v v v + ToolNodeConfig ConditionNodeConfig HumanInputNodeConfig + (tool select, (condition type (prompt, input_key, + input_map, form, branches, timeout) + output_key) default_branch) +``` + +### Edge flow for Condition nodes + +``` + Condition Node + | + |--- edge { condition_branch: "yes" } ---> Node A + |--- edge { condition_branch: "no" } ---> Node B + |--- edge { condition_branch: "default" } ---> Node C + + Canvas stores condition_branch on EdgeSchema. + Builder reads it via edge.get("condition_branch"). + React Flow shows it as an edge label. +``` + +### Settings page data flow + +``` + /settings route (App.tsx) + | + v + SettingsPage.tsx (component) + | + reads from settingsSlice (store) + | + v + settingsSlice.ts (store) + | + calls settings.ts (api) + | + v + GET /settings/providers (execution) + GET /settings/tools (execution, new endpoint) + | + v + { openai: { configured, models }, ... } + [ { name, description }, ... ] +``` + +## Scope decisions + +### 1. Input/output mapping UI for Tool nodes + +**Include basic input_map + output_key fields in ToolNodeConfig.** The LLMNodeConfig +defers these to Phase 4's State panel, but Tool nodes are unusable without them +(tool_name alone is not enough -- the builder calls `resolve_input_map` on every +tool invocation). Simple key-value pair editor for input_map, text input for output_key. + +### 2. Condition branch edge wiring + +**Edge label via config panel.** When user connects an edge FROM a condition node, +auto-set `condition_branch` to a default like `branch_N`. Users edit the branch +name by clicking the condition node's config panel. `config.branches` is derived +from edges at save time -- not manually maintained. + +### 3. Settings page scope + +**Provider status display only.** Show configured/not-configured per provider. +Model fetching is Phase 4+ (endpoint currently returns empty models arrays). + +## Parts + +| Part | Summary | Depends on | +|------|---------|------------| +| 3.1 | [Node defaults + toolbar](phase-3.1-node-defaults-toolbar.md) -- Register 3 new node types | -- | +| 3.2 | [ToolNode config](phase-3.2-tool-node-config.md) -- Tool select, input_map editor, output_key | 3.1 | +| 3.3 | [ConditionNode config + edge wiring](phase-3.3-condition-node-config.md) -- 6 condition types, branch edges | 3.1 | +| 3.4 | [HumanInputNode config](phase-3.4-human-input-node-config.md) -- Prompt, input_key, timeout | 3.1 | +| 3.5 | [Validation rules](phase-3.5-validation.md) -- Client-side rules for new nodes | 3.2, 3.3, 3.4 | +| 3.6 | [Settings page](phase-3.6-settings-page.md) -- Provider status + tool list | 3.2 (settingsSlice) | + +## Out of scope (Phase 4+) + +- **State panel** (Phase 4) -- full state field management, input_map visual wiring +- **Model fetching** from providers -- settings/providers currently returns empty models[] +- **Custom edge components** -- edges use default React Flow rendering with labels +- **Condition branch auto-creation** -- users create edges manually +- **Run input modal** (Phase 4) -- schema-driven form for providing run input +- **Debug panel** (Phase 5) -- per-node state inspection during runs +- **LLM router condition wizard** -- users fill in the form fields directly + +## Architecture constraints + +- Components read store only -- no `fetch()`, no API imports +- `@api` layer handles all HTTP calls +- Settings data cached in `settingsSlice` (fetch-once pattern) +- `condition_branch` stored on `EdgeSchema` -- builder already reads it +- `config.branches` derived from edges at save time, not manually maintained +- New nodes are additive -- no schema changes, no breaking changes diff --git a/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.1-node-defaults-toolbar.md b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.1-node-defaults-toolbar.md new file mode 100644 index 0000000..6506f49 --- /dev/null +++ b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.1-node-defaults-toolbar.md @@ -0,0 +1,142 @@ +# Phase 3.1 — Node Defaults + Toolbar + +## Goal + +Register tool, condition, and human_input in NODE_DEFAULTS, TOOLBAR_ITEMS, +nodeTypes, and CSS. Placeholder node components render with correct icons and +colors so users can immediately drag all node types onto the canvas. + +## Files to modify + +| File | Action | +|------|--------| +| `packages/canvas/src/utils/nodeDefaults.ts` | Add 3 defaults | +| `packages/canvas/src/constants/toolbarItems.ts` | Add 3 toolbar items | +| `packages/canvas/src/index.css` | Add 3 accent classes | +| `packages/canvas/src/components/canvas/nodes/ToolNode.tsx` | New | +| `packages/canvas/src/components/canvas/nodes/ConditionNode.tsx` | New | +| `packages/canvas/src/components/canvas/nodes/HumanInputNode.tsx` | New | +| `packages/canvas/src/components/canvas/nodes/nodeTypes.ts` | Register 3 types | + +## Design + +### Toolbar layout after this part + +``` + +-------+ +-------+ +-------+ +-----------+ +-------------+ +-------+ + | Start | | LLM | | Tool | | Condition | | Human Input | | End | + +-------+ +-------+ +-------+ +-----------+ +-------------+ +-------+ + emerald indigo amber violet cyan red +``` + +### Node defaults + +```typescript +tool: () => ({ + type: "tool", + label: "Tool", + config: { + tool_name: "calculator", + input_map: {}, + output_key: "tool_result", + }, +}), +condition: () => ({ + type: "condition", + label: "Condition", + config: { + condition: { type: "field_equals", field: "", value: "", branch: "yes" }, + branches: {}, + default_branch: "", + }, +}), +human_input: () => ({ + type: "human_input", + label: "Human Input", + config: { + prompt: "Please provide input:", + input_key: "user_input", + timeout_ms: 300000, + }, +}), +``` + +### Toolbar items (lucide-react icons) + +| Type | Icon | Accent | Description | +|------|------|--------|-------------| +| tool | `Wrench` | amber | "Run a tool with inputs" | +| condition | `GitBranch` | violet | "Branch based on state" | +| human_input | `UserCircle` | cyan | "Pause for user input" | + +### CSS accent classes + +```css +.gw-node-tool { @apply border-amber-500; } +.gw-node-condition { @apply border-violet-500; } +.gw-node-human_input { @apply border-cyan-500; } +``` + +### Node component pattern + +Each is a thin wrapper around BaseNodeShell (same as StartNode/EndNode). +They'll be enhanced with config badges in parts 3.2-3.4. + +``` + Placeholder (this part): + + ●──┤ [Wrench] Tool ├──● amber border + ●──┤ [Branch] Condition ├──● violet border + ●──┤ [User] Human Input ├──● cyan border + + After enhancement (parts 3.2-3.4): + + ●──┤ [Wrench] Tool ├──● + │ CALCULATOR │ tool_name badge + └──────────────────────-┘ + + ●──┤ [Branch] Condition ├──● + │ FIELD_EQUALS 2 branch │ condition type + branch count + └──────────────────────-┘ + + ●──┤ [User] Human Input ├──● + │ Please provide inp... │ truncated prompt preview + └──────────────────────-┘ +``` + +### Canvas with all 6 node types + +``` + ┌──────────────────────────────────────────────────────────┐ + │ │ + │ ┌──────────┐ │ + │ │ ▷ Start │──────────┐ │ + │ └──────────┘ │ │ + │ v │ + │ ┌─────────────┐ │ + │ │ 🧠 LLM │ │ + │ │ GEMINI │──────┐ │ + │ └─────────────┘ │ │ + │ │ v │ + │ v ┌─────────────┐ │ + │ ┌────────────┐│ 🔧 Tool │ │ + │ │ ⑂ Cond. ││ WEB_SEARCH │ │ + │ │ FIELD_EQ │└──────┬───────┘ │ + │ └──┬────┬───┘ │ │ + │ yes ────┘ └──── no │ │ + │ v v │ │ + │ ┌──────────┐ ┌──────────┐ │ │ + │ │ 👤 Human │ │ □ End │ │ │ + │ │ Input │ └──────────┘ │ │ + │ └─────┬─────┘ │ │ + │ └────────────────────┘ │ + │ │ + └──────────────────────────────────────────────────────────┘ +``` + +## Verification + +- `tsc --noEmit` passes +- All 6 node types appear in the floating toolbar +- Dragging each new type onto canvas renders correctly with proper icon/color +- Drop-on-edge insertion works for new types (uses existing findNearestEdge) diff --git a/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.2-tool-node-config.md b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.2-tool-node-config.md new file mode 100644 index 0000000..a1ff607 --- /dev/null +++ b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.2-tool-node-config.md @@ -0,0 +1,158 @@ +# Phase 3.2 — ToolNode Config + +## Goal + +Add ToolNodeConfig panel with tool select dropdown, input_map key-value editor, +and output_key input. Add `GET /settings/tools` endpoint to serve the tool +registry to the frontend. Create settings API client and settingsSlice. + +## Files to modify + +| File | Action | +|------|--------| +| `packages/execution/app/main.py` | Add `GET /settings/tools` endpoint | +| `packages/canvas/src/api/settings.ts` | New -- `getTools()`, `getProviders()` | +| `packages/canvas/src/store/settingsSlice.ts` | New -- tools/providers cache | +| `packages/canvas/src/components/panels/config/ToolNodeConfig.tsx` | New | +| `packages/canvas/src/components/panels/NodeConfigPanel.tsx` | Add tool case | +| `packages/canvas/src/components/canvas/nodes/ToolNode.tsx` | Enhance with tool_name badge | + +## Design + +### Data flow + +``` + ToolNodeConfig + | + reads tools from settingsSlice + | + v + settingsSlice.loadTools() + | + calls settings.ts API client + | + v + GET /settings/tools (new endpoint) + | + returns from REGISTRY + | + v + [ { name: "calculator", description: "Evaluate math" }, + { name: "web_search", description: "Search the web" }, + ... ] +``` + +### GET /settings/tools endpoint + +No auth required — same pattern as `/settings/providers` and `/health`. +Tool list contains no secrets (names + descriptions only). + +```python +@app.get("/settings/tools", tags=["System"]) +async def get_tools() -> list[dict]: + from app.tools.registry import REGISTRY + return [ + {"name": tool.name, "description": tool.description} + for tool in REGISTRY.values() + ] +``` + +**Test required** (`test_settings.py`): verify `GET /settings/tools` returns +a list of `{name, description}` dicts matching REGISTRY entries. + +### ToolNodeConfig layout + +``` + +------------------------------------------+ + | Label | + | [___________________________________] | + | | + | Tool | + | [calculator v] | + | | + | Input Mapping | + | Param Name State Key | + | [expression ] [user_query ] [x] | + | [_____________ ] [______________ ] [x] | + | [+ Add mapping] | + | | + | Output Key | + | [tool_result________________________] | + +------------------------------------------+ +``` + +### Input map semantics + +`input_map: Record` maps **param name → state key**. +Example: `{ "expression": "user_query" }` means "take `state['user_query']` +and pass it as `expression` to the tool". + +UI shows: param name on left, state key on right. Internal state is +`Array<{ param: string; stateKey: string }>`, serialized to Record on change. + +### settingsSlice + +```typescript +interface SettingsSlice { + tools: ToolInfo[]; + toolsLoaded: boolean; + toolsError: string | null; + loadTools: () => Promise; + providers: Record | null; + providersLoaded: boolean; + providersError: string | null; + loadProviders: () => Promise; +} +``` + +Fetch-once pattern: `loadTools()` returns early if already loaded. Called from +ToolNodeConfig on mount and from SettingsPage (Part 3.6). + +Both `loadTools()` and `loadProviders()` catch errors and surface them: + +```typescript +loadTools: async () => { + if (get().toolsLoaded) return; + try { + const tools = await getTools(); + set({ tools, toolsLoaded: true, toolsError: null }); + } catch (err) { + set({ toolsError: err instanceof Error ? err.message : "Failed to load tools" }); + } +}, +loadProviders: async () => { + if (get().providersLoaded) return; + try { + const providers = await getProviders(); + set({ providers, providersLoaded: true, providersError: null }); + } catch (err) { + set({ providersError: err instanceof Error ? err.message : "Failed to load providers" }); + } +}, +``` + +### ToolNode presenter enhancement + +Show `tool_name` as a badge below the label (same pattern as LLMNode +showing provider + model): + +``` + +---------------------------+ + | [Wrench] My Tool | + | CALCULATOR | + +---------------------------+ +``` + +## Required tests + +| Test file | Test case | Priority | +|-----------|-----------|----------| +| `ToolNodeConfig.test.tsx` | Renders tool options from settings store | HIGH | + +## Verification + +- `tsc --noEmit` passes +- `GET /settings/tools` returns tool list from execution server +- Clicking a Tool node opens config panel with tool dropdown populated +- Changing tool updates the node badge on canvas +- Input map editor: add/remove rows, values persist on save/reload diff --git a/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.3-condition-node-config.md b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.3-condition-node-config.md new file mode 100644 index 0000000..966d226 --- /dev/null +++ b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.3-condition-node-config.md @@ -0,0 +1,655 @@ +# Phase 3.3 — ConditionNode Config + Edge Wiring + +## Goal + +Add ConditionNodeConfig panel supporting all 6 condition types. Wire +`condition_branch` onto edges from condition nodes. Show branch names +as edge labels on canvas. Derive `config.branches` from edges at save time. + +## Files to modify + +| File | Action | +|------|--------| +| `packages/canvas/src/components/panels/config/ConditionNodeConfig.tsx` | New | +| `packages/canvas/src/components/panels/config/ConditionBranchEditor.tsx` | New | +| `packages/canvas/src/components/panels/NodeConfigPanel.tsx` | Add condition case | +| `packages/canvas/src/components/canvas/nodes/ConditionNode.tsx` | Enhance | +| `packages/canvas/src/components/canvas/GraphCanvas.tsx` | Modify onConnect, onReconnect, isValidConnection | +| `packages/canvas/src/hooks/useNodePlacement.ts` | Preserve condition_branch in spliceEdge | +| `packages/canvas/src/store/graphSlice.ts` | Add `updateEdge`, branch sync | +| `packages/canvas/src/types/mappers.ts` | Pass condition_branch through | + +## Design + +### Edge wiring flow + +**Edge ID generation**: The current codebase uses `e-${source}-${target}` for +edge IDs. This collides when a condition node has multiple edges to the same +target (e.g., both "on_error" and "on_success" route to End). **All edge IDs +in `onConnect` and `onReconnect` must use `crypto.randomUUID()`** to guarantee +uniqueness. This also applies to `spliceEdge` calls in `useNodePlacement.ts` +(see below). The `e-${source}-${target}` pattern in `graphSlice.ts` +`initGraph` is fine (only creates the single Start→End edge). + +``` + User drags edge from Condition → Target + | + v + onConnect fires + | + reads source node type via useGraphStore.getState().nodes + (avoids adding storeNodes/storeEdges to dependency array — + matches the getState() pattern in useNodePlacement.ts) + | + checks: is source a condition node? + | + yes | no + v v + count existing edges from normal edge + source (getState().edges (no branch) + .filter) + auto-set condition_branch + = "branch_N" (highest N + 1) + | + v + edge.id = crypto.randomUUID() + edge appears on canvas with label "branch_N" + + Later, in ConditionNodeConfig: + ConditionBranchEditor shows all outgoing edges + User can rename "branch_1" → "yes", "branch_2" → "no" +``` + +### onReconnect — preserve condition_branch when rewiring + +`onReconnect` (GraphCanvas.tsx ~line 203) creates a new edge with only +`id/source/target`. If the old edge had a `condition_branch`, it is lost. + +``` + Before rewire: + [Condition] ──yes──→ [LLM] + + User drags edge target from LLM to Tool: + + WRONG (current): [Condition] ────────→ [Tool] (branch lost!) + CORRECT (planned): [Condition] ──yes──→ [Tool] (branch preserved) +``` + +Fix: In `onReconnect`, read `oldEdge.data?.condition_branch` (via the RF edge) +or look up the store edge's `condition_branch`. Copy it to the new edge. +Use `crypto.randomUUID()` for the new edge ID (same as `onConnect`). +If the source changed (not just target), and the new source is a condition +node, auto-assign a branch name. + +### Replacement onConnect callback (consolidated) + +```typescript +const onConnect = useCallback((connection: Connection) => { + const { nodes: storeNodes, edges: currentEdges, addEdge } = + useGraphStore.getState(); + const sourceNode = storeNodes.find((n) => n.id === connection.source); + const isCondition = sourceNode?.type === "condition"; + + let condition_branch: string | undefined; + if (isCondition) { + // Collision-safe auto-naming: find highest existing N, use N + 1 + const existing = currentEdges + .filter((e) => e.source === connection.source && e.condition_branch) + .map((e) => e.condition_branch!); + const maxN = existing.reduce((max, name) => { + const m = name.match(/^branch_(\d+)$/); + return m ? Math.max(max, Number(m[1])) : max; + }, 0); + condition_branch = `branch_${maxN + 1}`; + } + + addEdge({ + id: crypto.randomUUID(), + source: connection.source!, + target: connection.target!, + ...(condition_branch ? { condition_branch } : {}), + }); +}, []); +``` + +### Replacement onReconnect callback (consolidated) + +```typescript +const onReconnect = useCallback( + (oldEdge: Edge, newConnection: Connection) => { + const { nodes: storeNodes, edges: currentEdges, removeEdge, addEdge } = + useGraphStore.getState(); + + // Preserve condition_branch from the old edge + const oldStoreEdge = currentEdges.find((e) => e.id === oldEdge.id); + let condition_branch = oldStoreEdge?.condition_branch; + + // If the SOURCE changed (not just target), check if new source is condition + if (newConnection.source !== oldEdge.source) { + const newSourceNode = storeNodes.find( + (n) => n.id === newConnection.source, + ); + if (newSourceNode?.type === "condition") { + // Auto-assign a new branch name for the new source + const existing = currentEdges + .filter( + (e) => e.source === newConnection.source && e.condition_branch, + ) + .map((e) => e.condition_branch!); + const maxN = existing.reduce((max, name) => { + const m = name.match(/^branch_(\d+)$/); + return m ? Math.max(max, Number(m[1])) : max; + }, 0); + condition_branch = `branch_${maxN + 1}`; + } else { + condition_branch = undefined; // new source is not a condition node + } + } + + removeEdge(oldEdge.id); + addEdge({ + id: crypto.randomUUID(), + source: newConnection.source!, + target: newConnection.target!, + ...(condition_branch ? { condition_branch } : {}), + }); + }, + [], +); +``` + +### isValidConnection — allow multiple condition edges to same target + +Current `isValidConnection` rejects duplicate `source→target` pairs. But +condition nodes legitimately route multiple branches to the same target +(e.g., both "on_error" and "on_success" → End). + +``` + Valid graph that current validation blocks: + + [Condition] ──on_error──→ [End] + ──on_success─→ [End] ← BLOCKED by duplicate check +``` + +Fix: When source is a condition node, skip the duplicate-edge check. +Each edge gets a unique `condition_branch` via auto-assignment anyway. + +### spliceEdge — preserve condition_branch on drop-on-edge + +`useNodePlacement.ts` splits an edge into two when dropping a node on it. +Neither new edge inherits `condition_branch` from the original. + +``` + Before drop: + [Condition] ──yes──→ [End] + + User drops Tool node on the edge: + + WRONG (current): + [Condition] ────→ [Tool] ────→ [End] (branch lost!) + + CORRECT (planned): + [Condition] ──yes──→ [Tool] ────→ [End] (branch on first segment) +``` + +Fix: In `useNodePlacement.ts`, when the original edge has `condition_branch`, +copy it to `newEdge1` (original source → inserted node). `newEdge2` +(inserted node → original target) does NOT get a branch — the inserted +node is not a condition node. **Also change both `newEdge1` and `newEdge2` IDs +from `e-${source}-${target}` to `crypto.randomUUID()`** — this prevents +collisions when splicing an edge where the condition node already has another +edge to the same inserted node. + +```typescript +spliceEdge( + nearestEdge.id, + newNode, + { + id: crypto.randomUUID(), + source: nearestEdge.source, + target: newNode.id, + condition_branch: nearestEdge.condition_branch, // preserve from original + }, + { + id: crypto.randomUUID(), + source: newNode.id, + target: nearestEdge.target, + // no condition_branch — inserted node is not a condition node + }, +); +``` + +### Edge deletion — destructive for branch data (known limitation) + +Deleting an edge from a condition node permanently removes that branch. +No undo system exists. This is acceptable for now but should be +documented. Consider adding a confirmation dialog when deleting edges +from condition nodes in a future polish pass. + +### Branch auto-naming — collision avoidance + +Simple count-based naming (`branch_N` where N = count + 1) creates +duplicates after deletions. Example: + +``` + Create: branch_1, branch_2, branch_3 + Delete: branch_2 + Create: branch_3 ← DUPLICATE! +``` + +Fix: Parse existing branch names, find the highest N, use N + 1: + +```typescript +const { edges: currentEdges } = useGraphStore.getState(); +const existing = currentEdges + .filter((e) => e.source === sourceId && e.condition_branch) + .map((e) => e.condition_branch!); +const maxN = existing.reduce((max, name) => { + const m = name.match(/^branch_(\d+)$/); + return m ? Math.max(max, Number(m[1])) : max; +}, 0); +const branchName = `branch_${maxN + 1}`; +``` + +### Edge label display + +``` + Before (no labels): + + [Condition] ──────────→ [LLM] + ──────────→ [End] + + After (with condition_branch labels): + + [Condition] ──branch_1──→ [LLM] + ──branch_2──→ [End] + + After user renames: + + [Condition] ────yes────→ [LLM] + ────no─────→ [End] +``` + +### Mapper changes (CRITICAL — condition_branch round-trip) + +The `@xyflow/react` `Edge` type supports a generic `data` parameter. Both +mapper functions must preserve `condition_branch` through the round-trip, +or branch data is silently lost on every store↔RF sync cycle. + +**`toRFEdge`** — read from `EdgeSchema`, put on RF edge `data` + `label`: +```typescript +type GWEdgeData = { condition_branch?: string; label?: string }; + +export function toRFEdge(edge: EdgeSchema): Edge { + return { + id: edge.id, + source: edge.source, + target: edge.target, + // RF label is for display only — condition_branch takes priority + label: edge.condition_branch ?? edge.label, + // Store both original label AND condition_branch in data for lossless round-trip + data: { condition_branch: edge.condition_branch, label: edge.label }, + }; +} +``` + +**`toEdgeSchema`** — extract from RF edge `data` back to `EdgeSchema`. +Read `label` from `data.label` (not from RF `label` which may be the +condition_branch display value): +```typescript +export function toEdgeSchema(rfEdge: Edge): EdgeSchema { + return { + id: rfEdge.id, + source: rfEdge.source, + target: rfEdge.target, + // Read original label from data, not from RF label (which may be condition_branch) + label: rfEdge.data?.label, + condition_branch: rfEdge.data?.condition_branch, + }; +} +``` + +This prevents data drift: RF `label` is display-only, while `data` holds +the canonical values for the lossless round-trip. + +**Test required** (`mappers.test.ts`): round-trip an edge with +`condition_branch: "yes"` through `toRFEdge` → `toEdgeSchema` and +verify the value survives. + +### graphSlice additions + +**`updateEdge` action** — add to `GraphSlice` interface AND implementation: +```typescript +// In GraphSlice interface: +updateEdge: (id: string, updates: Partial) => void; + +// In create() implementation: +updateEdge: (id, updates) => + set((s) => ({ + edges: s.edges.map((e) => (e.id === id ? { ...e, ...updates } : e)), + dirty: true, + })), +``` + +ConditionBranchEditor calls `useGraphStore(s => s.updateEdge)` to rename +branches. The store change triggers the storeEdges → toRFEdge → RF sync +cycle, updating the edge label on canvas. + +**Branch sync in `saveGraph` (CRITICAL — bridges visual↔execution)** + +Without this step, `config.branches` remains `{}` and the builder raises +`GraphBuildError` ("Edge from condition node missing condition_branch"). + +Insert the sync **inside `saveGraph`**, between reading state and constructing +the API payload. Replace `state.nodes` with `syncedNodes` in the schema: + +```typescript +// In saveGraph, BEFORE constructing the schema payload: +const syncedNodes = state.nodes.map((node) => { + if (node.type !== "condition") return node; + const outEdges = state.edges.filter( + (e) => e.source === node.id && e.condition_branch + ); + const branches: Record = {}; + for (const e of outEdges) { + branches[e.condition_branch!] = e.target; + } + return { ...node, config: { ...node.config, branches } }; +}); + +// Use syncedNodes (not state.nodes) when building the schema +const schema = { ...graphMeta, nodes: syncedNodes, edges: state.edges }; +``` + +**Test required** (`graphSlice.test.ts`): create a condition node with +outgoing edges that have `condition_branch`, call `saveGraph`, verify the +serialized payload has `config.branches` populated correctly. + +### ConditionNodeConfig layout + +**field_equals example (with default_branch):** +``` + +------------------------------------------+ + | Label | + | [___________________________________] | + | | + | Condition Type | + | [field_equals v] | + | | + | Field | + | [status____________________________ ] | + | Value | + | [approved__________________________ ] | + | Match Branch | + | [yes________________________________] | + | | + | Default Branch (when condition fails) | + | [no v] | ← dropdown from edge branches + | | + | Outgoing Branches | + | +--------------------------------------+ | + | | yes → LLM Node | | + | | no → End Node | | + | +--------------------------------------+ | + | | + | When "Match Branch" changes, the | + | corresponding edge label auto-updates. | + +------------------------------------------+ +``` + +**tool_error example (exhaustive — no default_branch):** +``` + +------------------------------------------+ + | Label | + | [___________________________________] | + | | + | Condition Type | + | [tool_error v] | + | | + | On Error Branch | + | [error______________________________] | + | On Success Branch | + | [success____________________________] | + | | + | Outgoing Branches | + | +--------------------------------------+ | + | | error → Retry Node | | + | | success → LLM Node | | + | +--------------------------------------+ | + | | + | This condition always matches exactly | + | one of the two branches. | + +------------------------------------------+ +``` + +**llm_router example:** +``` + +------------------------------------------+ + | Label | + | [___________________________________] | + | | + | Condition Type | + | [llm_router v] | + | | + | Prompt | + | [Classify the user's intent: ] | + | [_________________________________ ] | + | Options (comma-separated) | + | [positive, negative, neutral________] | + | Routing Model (optional) | + | [gpt-4o-mini________________________] | + | | + | Default Branch (when no option matches) | + | [neutral v] | ← dropdown from edge branches + | | + | Outgoing Branches | + | +--------------------------------------+ | + | | positive → Happy Path | | + | | negative → Escalate | | + | | neutral → Default | | + | +--------------------------------------+ | + +------------------------------------------+ +``` + +### CRITICAL — branch name coupling between config and edges + +The builder's router functions return branch names from `condition` config +fields. `add_conditional_edges` maps against `branch_map` keys from +`edge.condition_branch`. **These must be the same strings or the graph +crashes at runtime.** + +``` + Router returns: branch_map expects: + ───────────── ────────────────── + field_equals: condition["branch"] edge.condition_branch values + field_contains: condition["branch"] edge.condition_branch values + field_exists: condition["branch"] edge.condition_branch values + llm_router: one of condition["options"] edge.condition_branch values + tool_error: condition["on_error"] or ["on_success"] edge.condition_branch values + iteration_limit: condition["exceeded"] or ["continue"] edge.condition_branch values +``` + +**Design decision: condition config values drive edge branch names.** + +For field-based conditions (`field_equals`, `field_contains`, `field_exists`): +- The `branch` field in the condition config is the "match" branch name +- When the user changes `branch`, auto-rename the corresponding edge's + `condition_branch` to match +- `default_branch` handles the "no match" case + +For exhaustive conditions (`tool_error`, `iteration_limit`): +- The condition config defines exactly 2 branch names + (`on_error`/`on_success` or `exceeded`/`continue`) +- When the user edits these, auto-rename the corresponding edges +- `default_branch` is not used (hide the dropdown for these types) + +For `llm_router`: +- `options` array defines the branch names the LLM can return +- When options change, edges should be renamed to match +- `default_branch` handles "no match" case + +### Condition type forms + +| Type | Config fields | Branch names produced | +|------|---------------|----------------------| +| `field_equals` | field, value, branch | `branch` value + `default_branch` | +| `field_contains` | field, value (label "Contains"), branch | `branch` value + `default_branch` | +| `field_exists` | field, branch | `branch` value + `default_branch` | +| `llm_router` | prompt (Textarea), options (comma-separated), routing_model (optional) | each option + `default_branch` | +| `tool_error` | on_error, on_success | exactly `on_error` + `on_success` (no default) | +| `iteration_limit` | field, max (number), exceeded, continue | exactly `exceeded` + `continue` (no default) | + +### Condition defaults (reset on type change) + +These are defaults for `config.condition` only (the inner ConditionConfig). +Type change resets `config.condition` but **preserves** `config.branches` +and `config.default_branch`. + +```typescript +// These apply to config.condition ONLY, not the full config +const CONDITION_CONFIG_DEFAULTS: Record = { + field_equals: { type: "field_equals", field: "", value: "", branch: "yes" }, + field_contains: { type: "field_contains", field: "", value: "", branch: "yes" }, + field_exists: { type: "field_exists", field: "", branch: "yes" }, + llm_router: { type: "llm_router", prompt: "", options: [] }, + tool_error: { type: "tool_error", on_error: "error", on_success: "success" }, + iteration_limit: { type: "iteration_limit", field: "", max: 5, exceeded: "exceeded", continue: "continue" }, +}; + +// On type change: +onChange({ + config: { + condition: CONDITION_CONFIG_DEFAULTS[newType], + // preserve branches and default_branch — they are edge-derived + }, +}); +``` + +### Default Branch behavior per condition type + +``` + field_equals / field_contains / field_exists: + +------------------------------------------+ + | Default Branch | + | [branch_2 v] | ← dropdown from edge branches + +------------------------------------------+ + Router uses default_branch when condition does NOT match. + + tool_error / iteration_limit: + +------------------------------------------+ + | (Default Branch hidden — router is | + | exhaustive, always returns one of two) | + +------------------------------------------+ + + llm_router: + +------------------------------------------+ + | Default Branch | + | [fallback v] | ← dropdown from edge branches + +------------------------------------------+ + Router uses default_branch when LLM response doesn't match any option. +``` + +### ConditionNode presenter enhancement + +Show condition type badge + branch count: + +``` + +---------------------------+ + | [GitBranch] Condition | + | FIELD_EQUALS 2 branches | + +---------------------------+ +``` + +### Full UX flow: condition edge wiring + +``` + Step 1: User places a Condition node + + ┌──────────────┐ + │ ⑂ Condition │ + │ FIELD_EQUALS │ (no edges yet) + └──────────────┘ + + Step 2: User drags edge to LLM node → auto-labeled "branch_1" + + ┌──────────────┐ ──branch_1──→ ┌───────┐ + │ ⑂ Condition │ │ LLM │ + └──────────────┘ └───────┘ + + Step 3: User drags edge to End node → auto-labeled "branch_2" + + ┌──────────────┐ ──branch_1──→ ┌───────┐ + │ ⑂ Condition │ │ LLM │ + │ │ ──branch_2──→ ┌───────┐ + └──────────────┘ │ End │ + └───────┘ + + Step 4: User clicks Condition node → config panel opens + ConditionBranchEditor at bottom shows: + + +------------------------------------------+ + | Outgoing Branches | + | +--------------------------------------+ | + | | [yes____] → LLM | | + | | [no_____] → End | | + | +--------------------------------------+ | + +------------------------------------------+ + + Step 5: Canvas updates with renamed labels + + ┌──────────────┐ ────yes────→ ┌───────┐ + │ ⑂ Condition │ │ LLM │ + │ FIELD_EQUALS │ ────no─────→ ┌───────┐ + │ 2 branches │ │ End │ + └──────────────┘ └───────┘ + + Step 6: On save, graphSlice derives config.branches: + { "yes": "", "no": "" } +``` + +### ConditionBranchEditor: duplicate prevention + +Reject duplicate branch names — if two branches have the same name, +`config.branches` (a `Record`) silently drops one. +Show inline validation error on the duplicate input. + +## Required tests + +| Test file | Test case | Priority | +|-----------|-----------|----------| +| `mappers.test.ts` | Round-trip edge with `condition_branch` through toRFEdge → toEdgeSchema | HIGH | +| `mappers.test.ts` | Round-trip preserves original `label` separately from `condition_branch` | HIGH | +| `graphSlice.test.ts` | saveGraph derives `config.branches` from condition edges | HIGH | +| `GraphCanvas.test.tsx` | onConnect from condition node auto-assigns `condition_branch` | HIGH | +| `GraphCanvas.test.tsx` | onReconnect preserves `condition_branch` from old edge | HIGH | +| `GraphCanvas.test.tsx` | isValidConnection allows multiple edges from condition to same target | HIGH | +| `GraphCanvas.test.tsx` | Two edges from condition to same target get unique IDs | HIGH | +| `GraphCanvas.test.tsx` | Auto-branch naming avoids collisions after deletions | MEDIUM | +| `graphSlice.test.ts` | updateEdge modifies condition_branch and sets dirty | HIGH | +| `useNodePlacement.test.ts` | spliceEdge on condition edge preserves `condition_branch` on first segment | HIGH | +| `ConditionBranchEditor.test.tsx` | Rejects duplicate branch names | HIGH | +| `ConditionBranchEditor.test.tsx` | Renaming branch calls updateEdge with new condition_branch | MEDIUM | +| `ConditionNodeConfig.test.tsx` | Type change resets `config.condition` but preserves `branches`/`default_branch` | HIGH | +| `ConditionNodeConfig.test.tsx` | `tool_error`/`iteration_limit` hide Default Branch dropdown | MEDIUM | +| `graphSlice.test.ts` | saveGraph synced `default_branch` is a key in derived `branches` | HIGH | +| `GraphCanvas.test.tsx` | onConnect from LLM node creates edge without `condition_branch` (regression) | HIGH | +| `graphSlice.test.ts` | saveGraph without condition nodes does not modify nodes (regression) | HIGH | + +## Verification + +- `tsc --noEmit` passes +- All tests above pass +- Connect edge from Condition → any node: edge auto-gets branch label +- Reconnect condition edge to new target: branch label preserved +- Drop node on condition edge: first segment keeps branch label +- Multiple edges from condition to same target: allowed, each with unique ID +- Branch label visible on canvas +- ConditionBranchEditor: rename a branch, label updates on canvas +- ConditionBranchEditor: duplicate name shows inline error +- Change condition type: form resets condition config, preserves branches/default_branch +- tool_error/iteration_limit: Default Branch dropdown hidden +- field_equals: changing "Match Branch" auto-renames the edge label +- llm_router: changing options auto-renames corresponding edges +- Save + reload: branches persist correctly +- `config.branches` correctly derived from edges at save time +- End-to-end: build Start → LLM → Condition(field_equals) → End graph, run it, verify routing works diff --git a/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.4-human-input-node-config.md b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.4-human-input-node-config.md new file mode 100644 index 0000000..f5939ca --- /dev/null +++ b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.4-human-input-node-config.md @@ -0,0 +1,74 @@ +# Phase 3.4 — HumanInputNode Config + +## Goal + +Add HumanInputNodeConfig panel with prompt, input_key, and timeout fields. +Enhance the node presenter with a truncated prompt preview. + +## Files to modify + +| File | Action | +|------|--------| +| `packages/canvas/src/components/panels/config/HumanInputNodeConfig.tsx` | New | +| `packages/canvas/src/components/panels/NodeConfigPanel.tsx` | Add human_input case | +| `packages/canvas/src/components/canvas/nodes/HumanInputNode.tsx` | Enhance with prompt preview | + +## Design + +### HumanInputNodeConfig layout + +``` + +------------------------------------------+ + | Label | + | [___________________________________] | + | | + | Prompt | + | [Please provide input: ] | + | [ ] | + | [___________________________________] | + | | + | Input Key | + | [user_input________________________] | + | | + | Timeout (ms) | + | [300000____________________________] | + | | + | The graph will pause at this node and | + | wait for user input. The response is | + | stored in the state key specified above. | + +------------------------------------------+ +``` + +### HumanInputNode presenter + +Show truncated prompt preview below the label: + +``` + +-------------------------------+ + | [UserCircle] Human Input | + | Please provide input:... | + +-------------------------------+ +``` + +Prompt truncated to ~40 chars with CSS `truncate` + `max-w-[140px]`. + +### Input sanitization + +`timeout_ms` is optional in the schema (`timeout_ms?: number`). If the user +clears the field, `Number.parseInt("")` returns `NaN`. Sanitize on change: +if empty or NaN, fall back to `300000`. Same pattern as LLMNodeConfig where +temperature/max_tokens always have values. + +## Required tests + +| Test file | Test case | Priority | +|-----------|-----------|----------| +| `HumanInputNodeConfig.test.tsx` | Empty timeout field defaults to 300000 | HIGH | + +## Verification + +- `tsc --noEmit` passes +- Click Human Input node: config panel opens with prompt, input_key, timeout fields +- Edit prompt: preview updates on canvas node +- Clear timeout field: defaults back to 300000, no NaN in serialized JSON +- Save + reload: config persists correctly diff --git a/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.5-validation.md b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.5-validation.md new file mode 100644 index 0000000..fda73ed --- /dev/null +++ b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.5-validation.md @@ -0,0 +1,83 @@ +# Phase 3.5 — Validation Rules for New Nodes + +## Goal + +Add client-side validation rules for tool, condition, and human_input nodes. +These run before `POST /run` to give fast feedback. + +## Files to modify + +| File | Action | +|------|--------| +| `packages/canvas/src/utils/validateGraph.ts` | Add 6 new rules | +| `packages/canvas/src/utils/__tests__/validateGraph.test.ts` | Add test cases | + +## Design + +### New rules (after existing rules 1-5) + +``` + Existing rules: + 1. Exactly one Start node + 2. At least one End node + 3. All non-end nodes have outgoing edges + 4. All non-start nodes have incoming edges + 5. LLM nodes have non-empty system_prompt + + New rules: + 6. Tool nodes: tool_name must not be empty + 7. Tool nodes: output_key must not be empty + 8. Condition nodes: must have at least one outgoing edge with condition_branch + 9. Condition nodes: all outgoing edges must have condition_branch set + 10. Condition nodes: default_branch validation (two sub-rules): + a. For NON-EXHAUSTIVE types (field_equals, field_contains, field_exists, + llm_router): default_branch must be non-empty AND reference a valid + branch name (an outgoing edge's condition_branch). Empty string is + invalid — the server-side check `if default_branch and ...` skips + empty strings, causing the router to return "" which isn't in + branch_map, crashing at runtime. + b. For EXHAUSTIVE types (tool_error, iteration_limit): default_branch + is ignored (the router always returns one of exactly 2 branches). + Skip validation for these types. + 11. HumanInput nodes: prompt must not be empty + 12. HumanInput nodes: input_key must not be empty +``` + +### Error message format + +Follow existing pattern — use the node's label for context: + +``` + "LLM node needs a system prompt" (existing) + "My Tool node needs a tool selected" (new) + "Condition node has edges without branch names" (new) + "Human Input node needs a prompt" (new) +``` + +### Type narrowing + +Narrow with `node.type === "tool"` before accessing `config.tool_name`, +following the existing LLM pattern (rule 5). + +## Required tests + +| Test case | Priority | +|-----------|----------| +| Tool node with empty tool_name fails validation | HIGH | +| Tool node with empty output_key fails validation | HIGH | +| Condition node with no outgoing edges fails validation (rule 8) | HIGH | +| Condition node with edge missing condition_branch fails (rule 9) | HIGH | +| Condition node (field_equals) with empty default_branch fails (rule 10a) | HIGH | +| Condition node (field_equals) with default_branch not in edge branches fails (rule 10a) | HIGH | +| Condition node (tool_error) with empty default_branch passes (rule 10b, exhaustive) | HIGH | +| HumanInput node with empty prompt fails validation | HIGH | +| HumanInput node with empty input_key fails validation | HIGH | + +## Verification + +- `pnpm --filter canvas test` — all new test cases pass +- Existing validation tests still pass +- Manual: create a Tool node with empty tool_name, click Run — see toast error +- Manual: create a Condition node with no outgoing edges, click Run — see toast error +- Manual: create a field_equals Condition with empty default_branch, click Run — see toast error +- Manual: create a tool_error Condition with empty default_branch, click Run — no error (exhaustive) diff --git a/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.6-settings-page.md b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.6-settings-page.md new file mode 100644 index 0000000..2455829 --- /dev/null +++ b/.claude/gw-plans/canvas/phase-3-full-node-set/phase-3.6-settings-page.md @@ -0,0 +1,108 @@ +# Phase 3.6 — Settings Page + +## Goal + +Add a `/settings` route showing which LLM providers are configured and which +tools are available. Accessible from the home view via a gear icon. + +## Files to modify + +| File | Action | +|------|--------| +| `packages/canvas/src/components/settings/SettingsPage.tsx` | New | +| `packages/canvas/src/App.tsx` | Add `/settings` route | +| `packages/canvas/src/components/home/HomeView.tsx` | Add settings link | + +Note: `settings.ts` API client and `settingsSlice` are created in Part 3.2. + +## Design + +### Page layout + +``` + +------------------------------------------------------------+ + | [<- Home] Settings | + +------------------------------------------------------------+ + | | + | LLM Providers | + | +----------------+ +----------------+ +----------------+ | + | | OpenAI | | Gemini | | Anthropic | | + | | [check] Ready | | [check] Ready | | [x] Not set | | + | +----------------+ +----------------+ +----------------+ | + | | + | Available Tools (8) | + | +------------------------------------------------------+ | + | | calculator Evaluate mathematical expressions | | + | | datetime Get current date and time | | + | | url_fetch Fetch content from a URL | | + | | web_search Search the web | | + | | wikipedia Search Wikipedia | | + | | file_read Read a file | | + | | file_write Write a file | | + | | weather Get weather information | | + | +------------------------------------------------------+ | + | | + +------------------------------------------------------------+ +``` + +### Provider cards + +Each uses the `Card` UI component: +- **Configured:** green `CheckCircle` icon + "Configured" +- **Not configured:** amber `AlertTriangle` icon + "Not configured" + hint + "Set `{PROVIDER}_API_KEY` in .env" +- Never display actual key values (CLAUDE.md rule) + +### Home view link + +Add a gear icon (`Settings` from lucide-react) in the HomeView header, +linking to `/settings`. + +### Loading and error states + +``` + Loading state: + +------------------------------------------------------------+ + | [<- Home] Settings | + +------------------------------------------------------------+ + | | + | LLM Providers | + | [spinner] Loading provider status... | + | | + +------------------------------------------------------------+ + + Error state (execution server down): + +------------------------------------------------------------+ + | [<- Home] Settings | + +------------------------------------------------------------+ + | | + | +------------------------------------------------------+ | + | | [!] Could not connect to execution server. | | + | | Check that Docker is running. | | + | +------------------------------------------------------+ | + | | + +------------------------------------------------------------+ +``` + +### Home view with settings link + +``` + Before: + +--------------------------------------------+ + | GraphWeave [+ New Graph] | + +--------------------------------------------+ + + After: + +--------------------------------------------+ + | GraphWeave [gear] [+ New Graph] | + +--------------------------------------------+ +``` + +## Verification + +- `tsc --noEmit` passes +- Navigate to `/settings` — provider cards show correct status +- Tool list populated from execution server +- Settings gear icon visible on home page +- Back arrow navigates to home +- If execution server is down, shows graceful error message diff --git a/packages/canvas/src/App.tsx b/packages/canvas/src/App.tsx index 71a624e..b5a85cb 100644 --- a/packages/canvas/src/App.tsx +++ b/packages/canvas/src/App.tsx @@ -1,12 +1,14 @@ import { Navigate, Route, Routes } from "react-router"; import { CanvasRoute } from "./components/canvas/CanvasRoute"; import { HomeView } from "./components/home/HomeView"; +import { SettingsPage } from "./components/settings/SettingsPage"; export default function App() { return ( } /> } /> + } /> } /> ); diff --git a/packages/canvas/src/api/settings.ts b/packages/canvas/src/api/settings.ts new file mode 100644 index 0000000..17bc1b2 --- /dev/null +++ b/packages/canvas/src/api/settings.ts @@ -0,0 +1,19 @@ +import { request } from "./client"; + +export interface ToolInfo { + name: string; + description: string; +} + +export interface ProviderStatus { + configured: boolean; + models: string[]; +} + +export function getTools(): Promise { + return request("/settings/tools"); +} + +export function getProviders(): Promise> { + return request>("/settings/providers"); +} diff --git a/packages/canvas/src/components/canvas/GraphCanvas.tsx b/packages/canvas/src/components/canvas/GraphCanvas.tsx index 2615d5f..2c87352 100644 --- a/packages/canvas/src/components/canvas/GraphCanvas.tsx +++ b/packages/canvas/src/components/canvas/GraphCanvas.tsx @@ -140,6 +140,8 @@ export function GraphCanvas() { if (sourceNode.type === "end") return false; // Allow duplicate during reconnect (same edge being moved) if (reconnectingEdgeRef.current) return true; + // Condition nodes can have multiple edges to the same target (different branches) + if (sourceNode.type === "condition") return true; const duplicate = storeEdges.some( (e) => e.source === connection.source && e.target === connection.target, ); @@ -179,15 +181,35 @@ export function GraphCanvas() { const onConnect: OnConnect = useCallback( (connection) => { - if (connection.source && connection.target) { - const edge = { - id: `e-${connection.source}-${connection.target}`, - source: connection.source, - target: connection.target, - }; - dispatch({ type: "ADD_EDGE", edge }); - addEdge(edge); + if (!connection.source || !connection.target) return; + const { nodes: storeNodesNow, edges: currentEdges } = + useGraphStore.getState(); + const sourceNode = storeNodesNow.find((n) => n.id === connection.source); + const isCondition = sourceNode?.type === "condition"; + + let condition_branch: string | undefined; + if (isCondition) { + const existing = currentEdges + .filter( + (e): e is typeof e & { condition_branch: string } => + e.source === connection.source && !!e.condition_branch, + ) + .map((e) => e.condition_branch); + const maxN = existing.reduce((max, name) => { + const m = name.match(/^branch_(\d+)$/); + return m ? Math.max(max, Number(m[1])) : max; + }, 0); + condition_branch = `branch_${maxN + 1}`; } + + const edge = { + id: crypto.randomUUID(), + source: connection.source, + target: connection.target, + ...(condition_branch ? { condition_branch } : {}), + }; + dispatch({ type: "ADD_EDGE", edge }); + addEdge(edge); }, [addEdge], ); @@ -203,15 +225,44 @@ export function GraphCanvas() { const onReconnect: OnReconnect = useCallback( (oldEdge, newConnection) => { reconnectingEdgeRef.current = null; - if (newConnection.source && newConnection.target) { - removeEdge(oldEdge.id); - const newEdge = { - id: `e-${newConnection.source}-${newConnection.target}`, - source: newConnection.source, - target: newConnection.target, - }; - addEdge(newEdge); + if (!newConnection.source || !newConnection.target) return; + + const { nodes: storeNodesNow, edges: currentEdges } = + useGraphStore.getState(); + + // Preserve condition_branch from old edge + const oldStoreEdge = currentEdges.find((e) => e.id === oldEdge.id); + let condition_branch = oldStoreEdge?.condition_branch; + + // If source changed, re-evaluate branch assignment + if (newConnection.source !== oldEdge.source) { + const newSourceNode = storeNodesNow.find( + (n) => n.id === newConnection.source, + ); + if (newSourceNode?.type === "condition") { + const existing = currentEdges + .filter( + (e): e is typeof e & { condition_branch: string } => + e.source === newConnection.source && !!e.condition_branch, + ) + .map((e) => e.condition_branch); + const maxN = existing.reduce((max, name) => { + const m = name.match(/^branch_(\d+)$/); + return m ? Math.max(max, Number(m[1])) : max; + }, 0); + condition_branch = `branch_${maxN + 1}`; + } else { + condition_branch = undefined; + } } + + removeEdge(oldEdge.id); + addEdge({ + id: crypto.randomUUID(), + source: newConnection.source, + target: newConnection.target, + ...(condition_branch ? { condition_branch } : {}), + }); }, [removeEdge, addEdge], ); diff --git a/packages/canvas/src/components/canvas/__tests__/CanvasRoute.test.tsx b/packages/canvas/src/components/canvas/__tests__/CanvasRoute.test.tsx index 18e60c2..68ce4d5 100644 --- a/packages/canvas/src/components/canvas/__tests__/CanvasRoute.test.tsx +++ b/packages/canvas/src/components/canvas/__tests__/CanvasRoute.test.tsx @@ -37,6 +37,8 @@ vi.mock("@store/graphSlice", () => ({ saveError: mockSaveError, loadGraph: mockLoadGraph, dirty: false, + nodes: [], + edges: [], }), })); @@ -64,6 +66,10 @@ vi.mock("../../panels/NodeConfigPanel", () => ({ NodeConfigPanel: () =>
Panel
, })); +vi.mock("../../panels/RunPanel", () => ({ + RunPanel: () =>
RunPanel
, +})); + beforeEach(() => { mockGraph = null; mockSaveError = null; diff --git a/packages/canvas/src/components/canvas/nodes/ConditionNode.tsx b/packages/canvas/src/components/canvas/nodes/ConditionNode.tsx new file mode 100644 index 0000000..2abbc51 --- /dev/null +++ b/packages/canvas/src/components/canvas/nodes/ConditionNode.tsx @@ -0,0 +1,49 @@ +import type { Node, NodeProps } from "@xyflow/react"; +import { GitBranch } from "lucide-react"; +import { memo } from "react"; +import { BaseNodeShell } from "./BaseNodeShell"; + +interface ConditionNodeData { + label: string; + config: { + condition: { + type: string; + [key: string]: unknown; + }; + branches: Record; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +type ConditionNodeProps = NodeProps>; + +function ConditionNodeComponent({ data, selected }: ConditionNodeProps) { + const conditionType = data.config.condition?.type ?? ""; + const branchCount = Object.keys(data.config.branches ?? {}).length; + + return ( + + {conditionType && ( +
+ + {conditionType.replace(/_/g, " ")} + + {branchCount > 0 && ( + + {branchCount} {branchCount === 1 ? "branch" : "branches"} + + )} +
+ )} +
+ ); +} + +export const ConditionNode = memo(ConditionNodeComponent); diff --git a/packages/canvas/src/components/canvas/nodes/HumanInputNode.tsx b/packages/canvas/src/components/canvas/nodes/HumanInputNode.tsx new file mode 100644 index 0000000..bb808e7 --- /dev/null +++ b/packages/canvas/src/components/canvas/nodes/HumanInputNode.tsx @@ -0,0 +1,39 @@ +import type { Node, NodeProps } from "@xyflow/react"; +import { UserCircle } from "lucide-react"; +import { memo } from "react"; +import { BaseNodeShell } from "./BaseNodeShell"; + +interface HumanInputNodeData { + label: string; + config: { + prompt: string; + input_key: string; + timeout_ms?: number; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +type HumanInputNodeProps = NodeProps>; + +function HumanInputNodeComponent({ data, selected }: HumanInputNodeProps) { + const prompt = data.config.prompt ?? ""; + + return ( + + {prompt && ( +
+ {prompt} +
+ )} +
+ ); +} + +export const HumanInputNode = memo(HumanInputNodeComponent); diff --git a/packages/canvas/src/components/canvas/nodes/ToolNode.tsx b/packages/canvas/src/components/canvas/nodes/ToolNode.tsx new file mode 100644 index 0000000..6c1704a --- /dev/null +++ b/packages/canvas/src/components/canvas/nodes/ToolNode.tsx @@ -0,0 +1,37 @@ +import type { Node, NodeProps } from "@xyflow/react"; +import { Wrench } from "lucide-react"; +import { memo } from "react"; +import { BaseNodeShell } from "./BaseNodeShell"; + +interface ToolNodeData { + label: string; + config: { + tool_name: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +type ToolNodeProps = NodeProps>; + +function ToolNodeComponent({ data, selected }: ToolNodeProps) { + return ( + + {data.config.tool_name && ( +
+ + {data.config.tool_name} + +
+ )} +
+ ); +} + +export const ToolNode = memo(ToolNodeComponent); diff --git a/packages/canvas/src/components/canvas/nodes/nodeTypes.ts b/packages/canvas/src/components/canvas/nodes/nodeTypes.ts index 7b461aa..ece1ca8 100644 --- a/packages/canvas/src/components/canvas/nodes/nodeTypes.ts +++ b/packages/canvas/src/components/canvas/nodes/nodeTypes.ts @@ -1,11 +1,17 @@ import type { NodeTypes } from "@xyflow/react"; +import { ConditionNode } from "./ConditionNode"; import { EndNode } from "./EndNode"; +import { HumanInputNode } from "./HumanInputNode"; import { LLMNode } from "./LLMNode"; import { StartNode } from "./StartNode"; +import { ToolNode } from "./ToolNode"; /** React Flow nodeTypes registry — keys must match NodeSchema.type */ export const nodeTypes: NodeTypes = { start: StartNode, llm: LLMNode, + tool: ToolNode, + condition: ConditionNode, + human_input: HumanInputNode, end: EndNode, } as const; diff --git a/packages/canvas/src/components/home/HomeView.tsx b/packages/canvas/src/components/home/HomeView.tsx index 936af40..cfa98c3 100644 --- a/packages/canvas/src/components/home/HomeView.tsx +++ b/packages/canvas/src/components/home/HomeView.tsx @@ -4,7 +4,7 @@ import { useUIStore } from "@store/uiSlice"; import { Button } from "@ui/Button"; import { Dialog } from "@ui/Dialog"; import { Input } from "@ui/Input"; -import { Brain, Play, Plus, Square } from "lucide-react"; +import { Brain, Play, Plus, Settings, Square } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { GraphCard } from "./GraphCard"; @@ -79,9 +79,19 @@ export function HomeView() {

GraphWeave

- +
+ + +
diff --git a/packages/canvas/src/components/panels/NodeConfigPanel.tsx b/packages/canvas/src/components/panels/NodeConfigPanel.tsx index a4c5bce..59f44fa 100644 --- a/packages/canvas/src/components/panels/NodeConfigPanel.tsx +++ b/packages/canvas/src/components/panels/NodeConfigPanel.tsx @@ -1,13 +1,22 @@ import { useCanvasContext } from "@contexts/CanvasContext"; -import type { LLMNode, NodeSchema } from "@shared/schema"; +import type { + ConditionNode, + HumanInputNode, + LLMNode, + NodeSchema, + ToolNode, +} from "@shared/schema"; import { useGraphStore } from "@store/graphSlice"; import { Button } from "@ui/Button"; import { Sheet } from "@ui/Sheet"; import { Trash2 } from "lucide-react"; import { useCallback, useMemo } from "react"; +import { ConditionNodeConfig } from "./config/ConditionNodeConfig"; import { EndNodeConfig } from "./config/EndNodeConfig"; +import { HumanInputNodeConfig } from "./config/HumanInputNodeConfig"; import { LLMNodeConfig } from "./config/LLMNodeConfig"; import { StartNodeConfig } from "./config/StartNodeConfig"; +import { ToolNodeConfig } from "./config/ToolNodeConfig"; export function NodeConfigPanel() { const { selectedNodeId, setSelectedNodeId } = useCanvasContext(); @@ -71,6 +80,18 @@ function isLLMNode(node: NodeSchema): node is LLMNode { return node.type === "llm"; } +function isToolNode(node: NodeSchema): node is ToolNode { + return node.type === "tool"; +} + +function isConditionNode(node: NodeSchema): node is ConditionNode { + return node.type === "condition"; +} + +function isHumanInputNode(node: NodeSchema): node is HumanInputNode { + return node.type === "human_input"; +} + function renderConfigForm( node: NodeSchema, onChange: (updates: { @@ -84,6 +105,15 @@ function renderConfigForm( case "llm": if (!isLLMNode(node)) return null; return ; + case "tool": + if (!isToolNode(node)) return null; + return ; + case "condition": + if (!isConditionNode(node)) return null; + return ; + case "human_input": + if (!isHumanInputNode(node)) return null; + return ; case "end": return ; default: diff --git a/packages/canvas/src/components/panels/config/ConditionBranchEditor.tsx b/packages/canvas/src/components/panels/config/ConditionBranchEditor.tsx new file mode 100644 index 0000000..4a29930 --- /dev/null +++ b/packages/canvas/src/components/panels/config/ConditionBranchEditor.tsx @@ -0,0 +1,77 @@ +import type { EdgeSchema, NodeSchema } from "@shared/schema"; +import { useGraphStore } from "@store/graphSlice"; +import { Input } from "@ui/Input"; +import { memo, useCallback, useMemo } from "react"; + +interface ConditionBranchEditorProps { + nodeId: string; + nodes: NodeSchema[]; + edges: EdgeSchema[]; +} + +function ConditionBranchEditorComponent({ + nodeId, + nodes, + edges, +}: ConditionBranchEditorProps) { + const updateEdge = useGraphStore((s) => s.updateEdge); + + const outgoingEdges = useMemo( + () => edges.filter((e) => e.source === nodeId), + [edges, nodeId], + ); + + const nodeMap = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); + + // Track duplicate detection + const branchNames = outgoingEdges.map((e) => e.condition_branch ?? ""); + const duplicates = new Set( + branchNames.filter((name, i) => name && branchNames.indexOf(name) !== i), + ); + + const handleBranchChange = useCallback( + (edgeId: string, newBranch: string) => { + updateEdge(edgeId, { condition_branch: newBranch }); + }, + [updateEdge], + ); + + if (outgoingEdges.length === 0) { + return ( +

Connect edges to define branches.

+ ); + } + + return ( +
+ {outgoingEdges.map((edge) => { + const targetNode = nodeMap.get(edge.target); + const targetLabel = targetNode?.label ?? edge.target; + const branchValue = edge.condition_branch ?? ""; + const isDuplicate = duplicates.has(branchValue) && branchValue !== ""; + + return ( +
+ handleBranchChange(edge.id, e.target.value)} + placeholder="branch name" + className={isDuplicate ? "border-red-500" : ""} + /> + + + {targetLabel} + +
+ ); + })} + {duplicates.size > 0 && ( +

+ Duplicate branch names — each branch must be unique. +

+ )} +
+ ); +} + +export const ConditionBranchEditor = memo(ConditionBranchEditorComponent); diff --git a/packages/canvas/src/components/panels/config/ConditionNodeConfig.tsx b/packages/canvas/src/components/panels/config/ConditionNodeConfig.tsx new file mode 100644 index 0000000..da11983 --- /dev/null +++ b/packages/canvas/src/components/panels/config/ConditionNodeConfig.tsx @@ -0,0 +1,416 @@ +import type { + ConditionConfig, + ConditionNode, + NodeSchema, +} from "@shared/schema"; +import type { EdgeSchema } from "@shared/schema"; +import { useGraphStore } from "@store/graphSlice"; +import { Input } from "@ui/Input"; +import { Select } from "@ui/Select"; +import { Textarea } from "@ui/Textarea"; +import { type ChangeEvent, memo, useCallback, useMemo } from "react"; +import { ConditionBranchEditor } from "./ConditionBranchEditor"; + +const CONDITION_TYPES = [ + "field_equals", + "field_contains", + "field_exists", + "llm_router", + "tool_error", + "iteration_limit", +] as const; + +const CONDITION_CONFIG_DEFAULTS: Record = { + field_equals: { type: "field_equals", field: "", value: "", branch: "yes" }, + field_contains: { + type: "field_contains", + field: "", + value: "", + branch: "yes", + }, + field_exists: { type: "field_exists", field: "", branch: "yes" }, + llm_router: { type: "llm_router", prompt: "", options: [] }, + tool_error: { type: "tool_error", on_error: "error", on_success: "success" }, + iteration_limit: { + type: "iteration_limit", + field: "", + max: 5, + exceeded: "exceeded", + continue: "continue", + }, +}; + +const EXHAUSTIVE_TYPES = new Set(["tool_error", "iteration_limit"]); + +interface ConditionNodeConfigProps { + node: ConditionNode; + onChange: (updates: { + label?: string; + config?: Partial; + }) => void; +} + +function ConditionNodeConfigComponent({ + node, + onChange, +}: ConditionNodeConfigProps) { + const nodes = useGraphStore((s) => s.nodes); + const edges = useGraphStore((s) => s.edges); + + const outgoingEdges: EdgeSchema[] = useMemo( + () => edges.filter((e) => e.source === node.id), + [edges, node.id], + ); + + const branchOptions = useMemo( + () => + outgoingEdges + .map((e) => e.condition_branch) + .filter((b): b is string => !!b), + [outgoingEdges], + ); + + const conditionType = node.config.condition.type; + const isExhaustive = EXHAUSTIVE_TYPES.has(conditionType); + + const handleLabelChange = useCallback( + (e: ChangeEvent) => { + onChange({ label: e.target.value }); + }, + [onChange], + ); + + const handleTypeChange = useCallback( + (e: ChangeEvent) => { + const newType = e.target.value; + onChange({ + config: { + condition: CONDITION_CONFIG_DEFAULTS[newType] as ConditionConfig, + // preserve branches and default_branch — edge-derived + }, + }); + }, + [onChange], + ); + + const handleConditionField = useCallback( + (field: string, value: string | number | string[]) => { + onChange({ + config: { + condition: { + ...node.config.condition, + [field]: value, + } as ConditionConfig, + }, + }); + }, + [onChange, node.config.condition], + ); + + const handleDefaultBranchChange = useCallback( + (e: ChangeEvent) => { + onChange({ config: { default_branch: e.target.value } }); + }, + [onChange], + ); + + const cond = node.config.condition; + + return ( +
+
+ + +
+ +
+ + +
+ + {/* field_equals / field_contains / field_exists */} + {(cond.type === "field_equals" || + cond.type === "field_contains" || + cond.type === "field_exists") && ( + <> +
+ + handleConditionField("field", e.target.value)} + placeholder="state_field" + /> +
+ {(cond.type === "field_equals" || cond.type === "field_contains") && ( +
+ + handleConditionField("value", e.target.value)} + placeholder="expected value" + /> +
+ )} +
+ + handleConditionField("branch", e.target.value)} + placeholder="yes" + /> +
+ + )} + + {/* llm_router */} + {cond.type === "llm_router" && ( + <> +
+ +