From 29c93eeb35655d1b88eb685f57e06c7ac2791bc6 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Thu, 9 Apr 2026 14:28:27 +0200 Subject: [PATCH] feat(agent): implement phase 2 runtime and inline clarification --- components/canvas/node-types.ts | 2 + components/canvas/nodes/agent-node.tsx | 314 +++++- components/canvas/nodes/agent-output-node.tsx | 72 ++ convex/_generated/api.d.ts | 2 + convex/agents.ts | 951 ++++++++++++++++++ convex/canvasGraph.ts | 15 +- convex/openrouter.ts | 61 ++ lib/agent-models.ts | 77 ++ lib/agent-run-contract.ts | 71 ++ lib/canvas-connection-policy.ts | 9 +- lib/canvas-node-catalog.ts | 3 +- lib/canvas-utils.ts | 21 +- tests/agent-node-runtime.test.ts | 296 ++++++ tests/agent-output-node.test.ts | 115 +++ tests/canvas-connection-policy.test.ts | 26 + .../openrouter-structured-output.test.ts | 189 ++++ tests/lib/agent-models.test.ts | 61 ++ tests/lib/agent-run-contract.test.ts | 96 ++ 18 files changed, 2376 insertions(+), 5 deletions(-) create mode 100644 components/canvas/nodes/agent-output-node.tsx create mode 100644 convex/agents.ts create mode 100644 lib/agent-models.ts create mode 100644 lib/agent-run-contract.ts create mode 100644 tests/agent-node-runtime.test.ts create mode 100644 tests/agent-output-node.test.ts create mode 100644 tests/convex/openrouter-structured-output.test.ts create mode 100644 tests/lib/agent-models.test.ts create mode 100644 tests/lib/agent-run-contract.test.ts diff --git a/components/canvas/node-types.ts b/components/canvas/node-types.ts index 36e5432..e31d1f1 100644 --- a/components/canvas/node-types.ts +++ b/components/canvas/node-types.ts @@ -17,6 +17,7 @@ import DetailAdjustNode from "./nodes/detail-adjust-node"; import RenderNode from "./nodes/render-node"; import CropNode from "./nodes/crop-node"; import AgentNode from "./nodes/agent-node"; +import AgentOutputNode from "./nodes/agent-output-node"; /** * Node-Type-Map für React Flow. @@ -45,4 +46,5 @@ export const nodeTypes = { crop: CropNode, render: RenderNode, agent: AgentNode, + "agent-output": AgentOutputNode, } as const; diff --git a/components/canvas/nodes/agent-node.tsx b/components/canvas/nodes/agent-node.tsx index cda0113..9ddc6ea 100644 --- a/components/canvas/nodes/agent-node.tsx +++ b/components/canvas/nodes/agent-node.tsx @@ -1,14 +1,44 @@ "use client"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Bot } from "lucide-react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { useAction } from "convex/react"; +import type { FunctionReference } from "convex/server"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; +import { useAuthQuery } from "@/hooks/use-auth-query"; +import { + DEFAULT_AGENT_MODEL_ID, + getAgentModel, + getAvailableAgentModels, + type AgentModelId, +} from "@/lib/agent-models"; +import { + type AgentClarificationAnswerMap, + type AgentClarificationQuestion, +} from "@/lib/agent-run-contract"; import { getAgentTemplate } from "@/lib/agent-templates"; +import { normalizePublicTier } from "@/lib/tier-credits"; +import { toast } from "@/lib/toast"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import BaseNodeWrapper from "./base-node-wrapper"; type AgentNodeData = { templateId?: string; canvasId?: string; + modelId?: string; + clarificationQuestions?: AgentClarificationQuestion[]; + clarificationAnswers?: AgentClarificationAnswerMap | Array<{ id: string; value: string }>; _status?: string; _statusMessage?: string; }; @@ -17,6 +47,68 @@ type AgentNodeType = Node; const DEFAULT_AGENT_TEMPLATE_ID = "campaign-distributor"; +function useSafeCanvasSync() { + try { + return useCanvasSync(); + } catch { + return { + queueNodeDataUpdate: async () => undefined, + status: { isOffline: false, isSyncing: false, pendingCount: 0 }, + }; + } +} + +function useSafeSubscription() { + try { + return useAuthQuery(api.credits.getSubscription); + } catch { + return undefined; + } +} + +function useSafeAction(reference: FunctionReference<"action", "public", any, unknown>) { + try { + return useAction(reference); + } catch { + return async (_args: any) => undefined; + } +} + +function normalizeClarificationAnswers(raw: AgentNodeData["clarificationAnswers"]): AgentClarificationAnswerMap { + if (!raw) { + return {}; + } + + if (Array.isArray(raw)) { + const entries = raw + .filter((item) => typeof item?.id === "string" && typeof item?.value === "string") + .map((item) => [item.id, item.value] as const); + return Object.fromEntries(entries); + } + + return raw; +} + +function areAnswerMapsEqual( + left: AgentClarificationAnswerMap, + right: AgentClarificationAnswerMap, +): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + + if (leftEntries.length !== rightEntries.length) { + return false; + } + + for (const [key, value] of leftEntries) { + if (right[key] !== value) { + return false; + } + } + + return true; +} + function CompactList({ items }: { items: readonly string[] }) { return (