From dad7405ab787a6414340c55cb9e4b492b123850a Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Thu, 5 Nov 2026 01:39:54 +0800 Subject: [PATCH 1/8] feat(graph): add studio helpers for edge insertion, run progress and run grouping Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/graphs/draft-store.test.ts | 13 ++ packages/core/graphs/index.ts | 1 + packages/core/graphs/studio.test.ts | 186 ++++++++++++++++++++++ packages/core/graphs/studio.ts | 164 +++++++++++++++++++ packages/core/stores/graph-draft-store.ts | 20 +++ 5 files changed, 384 insertions(+) create mode 100644 packages/core/graphs/studio.test.ts create mode 100644 packages/core/graphs/studio.ts diff --git a/packages/core/graphs/draft-store.test.ts b/packages/core/graphs/draft-store.test.ts index 8ce3fb04465..e94f4a90a10 100644 --- a/packages/core/graphs/draft-store.test.ts +++ b/packages/core/graphs/draft-store.test.ts @@ -193,6 +193,19 @@ function stored(storage: StorageAdapter, draftKey: string) { } describe("Graph draft conversation binding", () => { + it("moves a newly chosen executor to the head of the scope", () => { + const store = createGraphDraftStore(memoryStorage()); + store.getState().open(key, definition, 1); + store.getState().bindAi(key, ai); + store + .getState() + .updateAi(key, { executorId: "agent-2", approverId: "member-2" }); + expect(store.getState().documents[key]?.ai).toMatchObject({ + executorId: "agent-2", + scopeIds: ["agent-2", "agent-1"], + approverId: "member-2", + }); + }); it("returns to the same conversation after a reload, with the reply it merged", () => { const storage = memoryStorage(), first = createGraphDraftStore(storage); diff --git a/packages/core/graphs/index.ts b/packages/core/graphs/index.ts index 9726587c76d..52494a7fbd4 100644 --- a/packages/core/graphs/index.ts +++ b/packages/core/graphs/index.ts @@ -7,3 +7,4 @@ export * from "./mutations"; export * from "./draft-store"; export * from "./input-schema"; export * from "./authoring-protocol"; +export * from "./studio"; diff --git a/packages/core/graphs/studio.test.ts b/packages/core/graphs/studio.test.ts new file mode 100644 index 00000000000..9609e218780 --- /dev/null +++ b/packages/core/graphs/studio.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { + graphChangeMarks, + graphRunProgress, + groupLatestRuns, + insertGraphNodeOnEdge, + nextGraphNodeKey, +} from "./studio"; +import type { GraphDefinition, GraphNodeRun, GraphRun } from "./types"; + +const base: GraphDefinition = { + schemaVersion: 2, + name: "Flow", + inputSchema: { type: "object" }, + nodes: [ + { nodeKey: "start", type: "start" }, + { nodeKey: "check", type: "branch" }, + { nodeKey: "write", type: "agent" }, + { nodeKey: "end", type: "end" }, + ], + edges: [ + { from: "start", to: "check" }, + { from: "check", to: "write", port: "true" }, + { from: "check", to: "end", port: "false" }, + { from: "write", to: "end" }, + ], + layout: { + nodes: { + start: { x: 0, y: 0 }, + check: { x: 300, y: 0 }, + write: { x: 600, y: 0 }, + end: { x: 900, y: 0 }, + }, + }, +}; + +describe("insertGraphNodeOnEdge", () => { + it("splits a plain connection and places the step between its ends", () => { + const next = insertGraphNodeOnEdge( + base, + { from: "start", to: "check" }, + { nodeKey: "agent_1", type: "agent" }, + ); + expect(next.edges.slice(0, 2)).toEqual([ + { from: "start", to: "agent_1" }, + { from: "agent_1", to: "check" }, + ]); + expect(next.edges).toHaveLength(5); + expect(next.layout?.nodes.agent_1).toEqual({ x: 150, y: 80 }); + }); + + it("keeps a branch outcome on the incoming side", () => { + const next = insertGraphNodeOnEdge( + base, + { from: "check", to: "write", port: "true" }, + { nodeKey: "review", type: "approval" }, + ); + expect(next.edges).toContainEqual({ + from: "check", + to: "review", + port: "true", + }); + expect(next.edges).toContainEqual({ from: "review", to: "write" }); + }); + + it("continues an inserted branch on its true outcome", () => { + const next = insertGraphNodeOnEdge( + base, + { from: "write", to: "end" }, + { nodeKey: "branch_2", type: "branch" }, + ); + expect(next.edges).toContainEqual({ + from: "branch_2", + to: "end", + port: "true", + }); + }); + + it("refuses missing connections, duplicate keys and structural steps", () => { + expect(() => + insertGraphNodeOnEdge( + base, + { from: "start", to: "end" }, + { nodeKey: "x", type: "agent" }, + ), + ).toThrow(); + expect(() => + insertGraphNodeOnEdge( + base, + { from: "start", to: "check" }, + { nodeKey: "write", type: "agent" }, + ), + ).toThrow(); + expect(() => + insertGraphNodeOnEdge( + base, + { from: "start", to: "check" }, + { nodeKey: "join_1", type: "join" }, + ), + ).toThrow(); + }); +}); + +describe("nextGraphNodeKey", () => { + it("skips keys already taken", () => { + const definition = { + ...base, + nodes: [...base.nodes, { nodeKey: "agent_2", type: "start" }], + }; + expect(nextGraphNodeKey(definition, "agent")).toEqual({ + key: "agent_3", + index: 3, + }); + }); +}); + +function nodeRun( + nodeKey: string, + nodeType: string, + state: string, + scopeId = "root", +): GraphNodeRun { + return { + id: nodeKey, + nodeKey, + nodeType, + state, + scopeId, + input: {}, + inputHash: "", + maxAttempts: 1, + grantedAttempts: 0, + canRetry: false, + canResolveBlock: false, + attempts: [], + submissions: [], + }; +} + +describe("graphRunProgress", () => { + it("counts root working steps only", () => { + expect( + graphRunProgress({ + scopes: [{ id: "root" }, { id: "loop", parent_node_run_id: "x" }], + nodes: [ + nodeRun("start", "start", "succeeded"), + nodeRun("a", "agent", "succeeded"), + nodeRun("b", "branch", "skipped"), + nodeRun("c", "agent", "failed"), + nodeRun("d", "approval", "waiting_approval"), + nodeRun("inner", "agent", "succeeded", "loop"), + nodeRun("end", "end", "pending"), + ], + }), + ).toEqual({ done: 2, total: 4, failed: 1 }); + }); +}); + +describe("groupLatestRuns", () => { + it("groups newest first and caps each graph", () => { + const run = (id: string, graphId: string | undefined, createdAt: string) => + ({ id, graphId, createdAt }) as GraphRun; + const grouped = groupLatestRuns( + [ + run("1", "g1", "2026-01-01T00:00:00Z"), + run("2", "g1", "2026-01-03T00:00:00Z"), + run("3", "g1", "2026-01-02T00:00:00Z"), + run("4", "g2", "2026-01-01T00:00:00Z"), + run("5", undefined, "2026-01-05T00:00:00Z"), + ], + 2, + ); + expect(grouped.get("g1")?.map((item) => item.id)).toEqual(["2", "3"]); + expect(grouped.get("g2")?.map((item) => item.id)).toEqual(["4"]); + expect(grouped.size).toBe(2); + }); +}); + +describe("graphChangeMarks", () => { + it("lets an added mark win over a changed one", () => { + expect(graphChangeMarks(["a"], ["a", "b"])).toEqual({ + a: "added", + b: "changed", + }); + }); +}); diff --git a/packages/core/graphs/studio.ts b/packages/core/graphs/studio.ts new file mode 100644 index 00000000000..03ab7b14a5d --- /dev/null +++ b/packages/core/graphs/studio.ts @@ -0,0 +1,164 @@ +import type { + GraphDefinition, + GraphEdge, + GraphNodeDefinition, + GraphNodeRun, + GraphRun, +} from "./types"; + +/** + * Pure helpers behind the AI-first Graph studio: inserting a step on an + * existing connection, reading a run's progress, and grouping recent runs by + * the graph they belong to. Kept free of React so they are testable on their + * own and reusable from mobile. + */ + +/** Node types that cannot sit in the middle of a single connection. */ +const NOT_INSERTABLE = new Set(["start", "end", "join"]); + +export function canInsertGraphNode(type: string): boolean { + return !NOT_INSERTABLE.has(type); +} + +/** The first free `_` key in a scope, and its ordinal. */ +export function nextGraphNodeKey( + definition: GraphDefinition, + type: string, +): { key: string; index: number } { + let index = definition.nodes.filter((node) => node.type === type).length + 1; + let key = `${type}_${index}`; + while (definition.nodes.some((node) => node.nodeKey === key)) + key = `${type}_${++index}`; + return { key, index }; +} + +/** + * Replaces the connection `edge` with `edge.from -> node -> edge.to`. + * + * The incoming side keeps the original port, so a step inserted on a branch's + * "true" path stays on that path. A branch inserted in the middle continues the + * original target on its own "true" outcome; its "false" outcome is left for + * the user to connect. + */ +export function insertGraphNodeOnEdge( + definition: GraphDefinition, + edge: GraphEdge, + node: GraphNodeDefinition, +): GraphDefinition { + if (!canInsertGraphNode(node.type)) + throw new Error("This step cannot be inserted on a connection."); + if (definition.nodes.some((item) => item.nodeKey === node.nodeKey)) + throw new Error("Node keys must be unique identifiers."); + const index = definition.edges.findIndex( + (item) => + item.from === edge.from && + item.to === edge.to && + (item.port ?? "") === (edge.port ?? ""), + ); + if (index < 0) throw new Error("The connection no longer exists."); + const original = definition.edges[index]!; + const incoming: GraphEdge = { + from: original.from, + to: node.nodeKey, + ...(original.port ? { port: original.port } : {}), + }; + const outgoing: GraphEdge = { + from: node.nodeKey, + to: original.to, + ...(node.type === "branch" ? { port: "true" } : {}), + }; + const positions = definition.layout?.nodes; + const from = positions?.[original.from]; + const to = positions?.[original.to]; + return { + ...definition, + schemaVersion: 2, + nodes: [...definition.nodes, node], + edges: [ + ...definition.edges.slice(0, index), + incoming, + outgoing, + ...definition.edges.slice(index + 1), + ], + ...(definition.layout + ? { + layout: { + ...definition.layout, + nodes: { + ...definition.layout.nodes, + ...(from && to + ? { + [node.nodeKey]: { + x: Math.round((from.x + to.x) / 2), + y: Math.round((from.y + to.y) / 2) + 80, + }, + } + : {}), + }, + }, + } + : {}), + }; +} + +/** Node states that count as finished for a run's progress bar. */ +const DONE_STATES = new Set(["succeeded", "skipped", "completed", "approved"]); +/** Synthetic nodes that do no work and would inflate the step count. */ +const STRUCTURAL = new Set(["start", "end"]); + +export interface GraphRunProgress { + done: number; + total: number; + failed: number; +} + +/** + * How far a run has come, counted over the root scope's working steps. Nested + * loop and subgraph iterations are left out: their counts change as the run + * advances, which would make the bar move backwards. + */ +export function graphRunProgress(run: Pick): GraphRunProgress { + const rootScope = run.scopes?.find((scope) => !scope.parent_node_run_id)?.id; + const nodes = (run.nodes ?? []).filter( + (node: GraphNodeRun) => + !STRUCTURAL.has(node.nodeType) && + (rootScope ? node.scopeId === rootScope || !node.scopeId : true), + ); + return { + done: nodes.filter((node) => DONE_STATES.has(node.state)).length, + total: nodes.length, + failed: nodes.filter((node) => node.state === "failed").length, + }; +} + +/** + * Recent runs by graph, newest first, at most `perGraph` each. The list page + * reads a graph's health from this instead of asking the server once per row. + */ +export function groupLatestRuns( + runs: GraphRun[], + perGraph = 7, +): Map { + const sorted = [...runs].sort( + (a, b) => Date.parse(b.createdAt || "") - Date.parse(a.createdAt || ""), + ); + const grouped = new Map(); + for (const run of sorted) { + if (!run.graphId) continue; + const list = grouped.get(run.graphId) ?? []; + if (list.length < perGraph) list.push(run); + grouped.set(run.graphId, list); + } + return grouped; +} + +/** Node keys an AI turn touched, for marking them on the canvas. */ +export function graphChangeMarks( + added: string[], + changed: string[], +): Record { + return { + ...Object.fromEntries(changed.map((key) => [key, "changed" as const])), + ...Object.fromEntries(added.map((key) => [key, "added" as const])), + }; +} diff --git a/packages/core/stores/graph-draft-store.ts b/packages/core/stores/graph-draft-store.ts index cb2e0f964fc..532985310c4 100644 --- a/packages/core/stores/graph-draft-store.ts +++ b/packages/core/stores/graph-draft-store.ts @@ -73,6 +73,15 @@ interface GraphDraftState { bindAi: (key: string, ai: GraphDraftAi) => void; /** Records the assistant reply whose draft is now in `definition`. */ markAiApplied: (key: string, messageId: string) => void; + /** + * Changes who new work defaults to. Callers must not do this while a turn is + * in flight: aliases are positional, and a reply written against the old + * directory would merge into the wrong teammate. + */ + updateAi: ( + key: string, + patch: Partial>, + ) => void; /** Unbinds the conversation without touching the definition. */ clearAi: (key: string) => void; clearWorkspace: (slug: string, adapter?: StorageAdapter) => void; @@ -384,6 +393,17 @@ export function createGraphDraftStore(storage: StorageAdapter) { ai: { ...current.ai, appliedMessageId: messageId }, }); }, + updateAi: (key, patch) => { + const current = get().documents[key]; + if (!current?.ai) return; + const ai = { ...current.ai, ...patch }; + // The executor reads as `agent_default`, so it always leads the scope. + ai.scopeIds = [ + ai.executorId, + ...ai.scopeIds.filter((id) => id !== ai.executorId), + ]; + sync(key, { ...current, ai }); + }, clearAi: (key) => { const current = get().documents[key]; if (!current?.ai) return; From 3123e0c54caf9fd3f9f4ca2e38d349fdd29eeb72 Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Thu, 5 Nov 2026 01:46:43 +0800 Subject: [PATCH 2/8] feat(graph): colour steps by category and mark AI changes, issues and run state on the canvas Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/ui/styles/tokens.css | 13 + packages/views/graphs/graph-canvas.tsx | 750 ++++++++++++------- packages/views/graphs/graph-node-catalog.tsx | 196 +++++ packages/views/locales/en/graphs.json | 14 + packages/views/locales/ja/graphs.json | 14 + packages/views/locales/ko/graphs.json | 14 + packages/views/locales/zh-Hans/graphs.json | 14 + 7 files changed, 744 insertions(+), 271 deletions(-) create mode 100644 packages/views/graphs/graph-node-catalog.tsx diff --git a/packages/ui/styles/tokens.css b/packages/ui/styles/tokens.css index cba754f12c1..06c71c3a0fc 100644 --- a/packages/ui/styles/tokens.css +++ b/packages/ui/styles/tokens.css @@ -34,6 +34,10 @@ --color-success: var(--success); --color-warning: var(--warning); --color-info: var(--info); + --color-graph-agent: var(--graph-agent); + --color-graph-human: var(--graph-human); + --color-graph-logic: var(--graph-logic); + --color-graph-tool: var(--graph-tool); --color-brand: var(--brand); --color-brand-foreground: var(--brand-foreground); --color-accent-foreground: var(--accent-foreground); @@ -210,6 +214,11 @@ --success: oklch(0.55 0.16 145); --warning: oklch(0.75 0.16 85); --info: oklch(0.55 0.18 250); + /* Graph step categories: AI work, people, flow control, utilities. */ + --graph-agent: oklch(0.55 0.16 275); + --graph-human: oklch(0.70 0.15 65); + --graph-logic: oklch(0.60 0.11 200); + --graph-tool: oklch(0.55 0.02 260); --scrollbar-thumb: oklch(0 0 0 / 10%); --scrollbar-thumb-hover: oklch(0 0 0 / 18%); --scrollbar-track: transparent; @@ -280,6 +289,10 @@ --success: oklch(0.65 0.15 145); --warning: oklch(0.70 0.16 85); --info: oklch(0.65 0.18 250); + --graph-agent: oklch(0.68 0.15 275); + --graph-human: oklch(0.75 0.14 65); + --graph-logic: oklch(0.70 0.10 200); + --graph-tool: oklch(0.65 0.02 260); --scrollbar-thumb: oklch(1 0 0 / 8%); --scrollbar-thumb-hover: oklch(1 0 0 / 18%); --scrollbar-track: transparent; diff --git a/packages/views/graphs/graph-canvas.tsx b/packages/views/graphs/graph-canvas.tsx index 2f1a89ac529..2b24c898c89 100644 --- a/packages/views/graphs/graph-canvas.tsx +++ b/packages/views/graphs/graph-canvas.tsx @@ -1,110 +1,228 @@ "use client"; -import { useMemo, useState, type CSSProperties } from "react"; +import { + createContext, + useContext, + useMemo, + useState, + type CSSProperties, + type ReactNode, +} from "react"; import { Background, + BaseEdge, Controls, + EdgeLabelRenderer, Handle, MarkerType, MiniMap, Position, ReactFlow, + getSmoothStepPath, + type Edge, + type EdgeProps, type Node, type NodeProps, - type Edge, - useReactFlow, - Panel, } from "@xyflow/react"; import { - Bot, - CheckCircle2, - Circle, - Clock, - Code2, - GitBranch, - GitFork, - Globe, - Layers, - Merge, - Repeat2, - ShieldCheck, + AlertCircle, + Check, + Loader2, + Pause, + Plus, + X, + type LucideIcon, } from "lucide-react"; import { + canInsertGraphNode, connectGraphNodes, layoutGraph, type GraphDefinition, + type GraphEdge, type GraphNodeDefinition, } from "@multica/core/graphs"; -import { Button } from "@multica/ui/components/ui/button"; -import { useGraphStateLabels } from "./shared"; import { cn } from "@multica/ui/lib/utils"; import { useT } from "../i18n"; +import { useGraphStateLabels } from "./shared"; +import { + GRAPH_CATEGORY_STYLES, + GraphAddNodeMenu, + GraphNodeIcon, + graphNodeCategory, + useGraphNodeLabels, +} from "./graph-node-catalog"; import "@xyflow/react/dist/style.css"; +export { useGraphNodeLabels } from "./graph-node-catalog"; + +export type GraphNodeMark = "added" | "changed"; + type FlowNode = Node< - { node: GraphNodeDefinition; label: string; detail: string; state?: string }, + { + node: GraphNodeDefinition; + label: string; + detail: string; + state?: string; + mark?: GraphNodeMark; + issues?: number; + }, "graph" >; -const icons = { - start: Circle, - end: CheckCircle2, - agent: Bot, - approval: ShieldCheck, - branch: GitBranch, - parallel: GitFork, - join: Merge, - loop: Repeat2, - subgraph: Layers, - delay: Clock, - http: Globe, - script: Code2, +type FlowEdge = Edge<{ edge: GraphEdge }, "graph">; + +/** Run states grouped by how they should read on a node. */ +const STATE_TONE: Record< + string, + { tone: "done" | "live" | "wait" | "bad" | "idle"; icon?: LucideIcon } +> = { + succeeded: { tone: "done", icon: Check }, + completed: { tone: "done", icon: Check }, + approved: { tone: "done", icon: Check }, + running: { tone: "live", icon: Loader2 }, + started: { tone: "live", icon: Loader2 }, + dispatched: { tone: "live", icon: Loader2 }, + queued: { tone: "live", icon: Loader2 }, + waiting_approval: { tone: "wait", icon: Pause }, + blocked: { tone: "wait", icon: AlertCircle }, + retry_wait: { tone: "wait", icon: Pause }, + failed: { tone: "bad", icon: X }, + rejected: { tone: "bad", icon: X }, + cancelled: { tone: "bad", icon: X }, + skipped: { tone: "idle" }, + pending: { tone: "idle" }, + ready: { tone: "idle" }, +}; + +const TONE_CLASS = { + done: "border-success/60 bg-success/5", + live: "border-info ring-2 ring-info/20", + wait: "border-warning ring-2 ring-warning/25", + bad: "border-destructive/70 bg-destructive/5", + idle: "", +}; +const TONE_TEXT = { + done: "text-success", + live: "text-info", + wait: "text-warning", + bad: "text-destructive", + idle: "text-muted-foreground", }; function GraphFlowNode({ data, selected, isConnectable }: NodeProps) { + const { t } = useT("graphs"); const stateLabels = useGraphStateLabels(); - const Icon = icons[data.node.type as keyof typeof icons] ?? Circle; + const type = data.node.type; + const capsule = type === "start" || type === "end"; + const category = graphNodeCategory(type); + const tone = data.state ? (STATE_TONE[data.state]?.tone ?? "idle") : null; + const StateIcon = data.state ? STATE_TONE[data.state]?.icon : undefined; return (
- {data.node.type !== "start" && ( + {!capsule && ( +
); } -const nodeTypes = { graph: GraphFlowNode }; -function FocusNode({ nodeKey }: { nodeKey?: string }) { - const { fitView } = useReactFlow(); - const { t } = useT("graphs"); - return nodeKey ? ( - - - - ) : null; -} +/** Lets the custom edge reach the insert callback without rebuilding edges. */ +const InsertContext = createContext< + ((edge: GraphEdge, type: string) => void) | null +>(null); -export function useGraphNodeLabels(): Record { +function GraphFlowEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, + style, + label, + data, + selected, +}: EdgeProps) { const { t } = useT("graphs"); - return { - start: t(($) => $.studio.start), - end: t(($) => $.studio.end), - agent: t(($) => $.editor.agent), - approval: t(($) => $.studio.approval), - branch: t(($) => $.production.branch), - parallel: t(($) => $.production.parallel), - join: t(($) => $.production.join), - loop: t(($) => $.production.loop), - subgraph: t(($) => $.production.subgraph), - delay: t(($) => $.production.delay), - http: t(($) => $.production.http), - script: t(($) => $.production.script), - }; + const insert = useContext(InsertContext); + const [path, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + return ( + <> + + {(label || (insert && data)) && ( + +
+ {label && ( + + {label} + + )} + {insert && data && ( + insert(data.edge, type)} + trigger={ + + } + /> + )} +
+
+ )} + + ); } +const nodeTypes = { graph: GraphFlowNode }; +const edgeTypes = { graph: GraphFlowEdge }; + export function GraphCanvas({ definition, onChange, onSelect, + onInsertOnEdge, selectedKey, disabled, onError, states, details, + marks, + issues, overview, + className, + children, }: { definition: GraphDefinition; onChange?: (definition: GraphDefinition) => void; onSelect?: (key: string) => void; + /** Shows a "+" on every connection; picking a step type calls this. */ + onInsertOnEdge?: (edge: GraphEdge, type: string) => void; selectedKey?: string; disabled?: boolean; onError?: (error: Error) => void; states?: Record; /** Per-node line shown under the label. The run page passes what a node produced. */ details?: Record; + /** Steps the last AI turn added or changed. */ + marks?: Record; + /** Validation problems per node key. */ + issues?: Record; /** * Read the graph as a whole: ignore the viewport whoever last edited the * draft left behind, and let the fit zoom out far enough to include every @@ -206,6 +367,9 @@ export function GraphCanvas({ * the worst outcome; an editor is read node by node, so it stays legible. */ overview?: boolean; + className?: string; + /** Extra ``s, rendered inside the flow. */ + children?: ReactNode; }) { const labels = useGraphNodeLabels(); const { t } = useT("graphs"); @@ -217,6 +381,7 @@ export function GraphCanvas({ () => layoutGraph(definition).layout!.nodes, [definition], ); + const editable = !disabled && Boolean(onChange); const nodes: FlowNode[] = definition.nodes.map((node) => ({ id: node.nodeKey, ariaLabel: `${labels[node.type] ?? node.type} · ${node.label || node.nodeKey}`, @@ -231,6 +396,8 @@ export function GraphCanvas({ node.script?.language || "", state: states?.[node.nodeKey], + mark: marks?.[node.nodeKey], + issues: issues?.[node.nodeKey], }, position: dragPositions[node.nodeKey] ?? @@ -239,25 +406,28 @@ export function GraphCanvas({ selected: selectedKey === node.nodeKey, deletable: !["start", "end"].includes(node.type), })); - const edges: Edge[] = definition.edges.map((edge, index) => ({ - id: `${index}:${edge.from}:${edge.port ?? ""}:${edge.to}`, - selected: selectedEdges.includes( - `${index}:${edge.from}:${edge.port ?? ""}:${edge.to}`, - ), - source: edge.from, - target: edge.to, - sourceHandle: edge.port, - label: - edge.port === "true" - ? t(($) => $.production.true_path) - : edge.port === "false" - ? t(($) => $.production.false_path) - : undefined, - type: "smoothstep", - markerEnd: { type: MarkerType.ArrowClosed }, - animated: states?.[edge.from] === "running", - })); - const editable = !disabled && Boolean(onChange); + const edges: FlowEdge[] = definition.edges.map((edge, index) => { + const id = `${index}:${edge.from}:${edge.port ?? ""}:${edge.to}`; + const source = definition.nodes.find((node) => node.nodeKey === edge.from); + return { + id, + type: "graph", + data: + editable && source?.type !== "parallel" ? { edge } : undefined, + selected: selectedEdges.includes(id), + source: edge.from, + target: edge.to, + sourceHandle: edge.port, + label: + edge.port === "true" + ? t(($) => $.production.true_path) + : edge.port === "false" + ? t(($) => $.production.false_path) + : undefined, + markerEnd: { type: MarkerType.ArrowClosed }, + animated: states?.[edge.from] === "running", + }; + }); const style = { "--xy-background-color": "var(--background)", "--xy-node-color": "var(--foreground)", @@ -269,168 +439,206 @@ export function GraphCanvas({ "--xy-minimap-background-color": "var(--card)", "--xy-background-pattern-dots-color": "var(--border)", } as CSSProperties; + const insert = + editable && onInsertOnEdge + ? (edge: GraphEdge, type: string) => { + if (canInsertGraphNode(type)) onInsertOnEdge(edge, type); + } + : null; return (
$.studio.flow)} > - - nodes={nodes} - edges={edges} - nodeTypes={nodeTypes} - style={style} - fitView={overview || !definition.layout?.viewport} - fitViewOptions={{ minZoom: overview ? 0.3 : 0.65, maxZoom: 1, padding: 0.12 }} - defaultViewport={overview ? undefined : definition.layout?.viewport} - minZoom={0.1} - maxZoom={4} - nodesDraggable={editable} - nodesConnectable={editable} - edgesReconnectable={editable} - elementsSelectable - deleteKeyCode={editable ? ["Backspace", "Delete"] : null} - onNodeClick={(_, node) => onSelect?.(node.id)} - onEdgesChange={(changes) => - setSelectedEdges((current) => { - const next = new Set(current); + + + nodes={nodes} + edges={edges} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + style={style} + fitView={overview || !definition.layout?.viewport} + fitViewOptions={{ + minZoom: overview ? 0.3 : 0.65, + maxZoom: 1, + padding: 0.12, + }} + defaultViewport={overview ? undefined : definition.layout?.viewport} + minZoom={0.1} + maxZoom={4} + nodesDraggable={editable} + nodesConnectable={editable} + edgesReconnectable={editable} + elementsSelectable + deleteKeyCode={editable ? ["Backspace", "Delete"] : null} + onNodeClick={(_, node) => onSelect?.(node.id)} + onEdgesChange={(changes) => + setSelectedEdges((current) => { + const next = new Set(current); + for (const change of changes) + if (change.type === "select") { + if (change.selected) next.add(change.id); + else next.delete(change.id); + } + return [...next]; + }) + } + onNodesChange={(changes) => { for (const change of changes) - if (change.type === "select") { - if (change.selected) next.add(change.id); - else next.delete(change.id); - } - return [...next]; - }) - } - onNodesChange={(changes) => { - for (const change of changes) - if (change.type === "select" && change.selected) - onSelect?.(change.id); - if (!editable) return; - const positionsChanged = changes.filter( - (change) => change.type === "position" && change.position, - ); - if (positionsChanged.length) { - const nextPositions = { - ...positions, - ...definition.layout?.nodes, - ...dragPositions, - }; - for (const change of positionsChanged) - if (change.type === "position" && change.position) - nextPositions[change.id] = change.position; - if ( - positionsChanged.some( - (change) => change.type === "position" && change.dragging, + if (change.type === "select" && change.selected) + onSelect?.(change.id); + if (!editable) return; + const positionsChanged = changes.filter( + (change) => change.type === "position" && change.position, + ); + if (positionsChanged.length) { + const nextPositions = { + ...positions, + ...definition.layout?.nodes, + ...dragPositions, + }; + for (const change of positionsChanged) + if (change.type === "position" && change.position) + nextPositions[change.id] = change.position; + if ( + positionsChanged.some( + (change) => change.type === "position" && change.dragging, + ) ) - ) - setDragPositions(nextPositions); - else { - setDragPositions({}); - onChange?.({ - ...definition, - layout: { ...definition.layout, nodes: nextPositions }, - }); + setDragPositions(nextPositions); + else { + setDragPositions({}); + onChange?.({ + ...definition, + layout: { ...definition.layout, nodes: nextPositions }, + }); + } } - } - }} - onDelete={({ nodes: removedNodes, edges: removedEdges }) => { - if (!editable) return; - const removed = new Set(removedNodes.map((node) => node.id)); - const edgeIds = new Set(removedEdges.map((edge) => edge.id)); - onChange?.({ - ...definition, - nodes: definition.nodes.filter( - (node) => !removed.has(node.nodeKey), - ), - edges: definition.edges.filter( - (edge, index) => - !removed.has(edge.from) && - !removed.has(edge.to) && - !edgeIds.has(edges[index]!.id), - ), - ...(definition.layout - ? { - layout: { - ...definition.layout, - nodes: Object.fromEntries( - Object.entries(definition.layout.nodes).filter( - ([key]) => !removed.has(key), - ), - ), - }, - } - : {}), - }); - }} - onConnect={(connection) => { - if (!editable) return; - try { - onChange?.( - connectGraphNodes(definition, { - from: connection.source, - to: connection.target, - ...(connection.sourceHandle - ? { port: connection.sourceHandle } - : {}), - }), - ); - } catch (error) { - onError?.( - error instanceof Error ? error : new Error(String(error)), - ); - } - }} - onReconnect={(oldEdge, connection) => { - if (!editable) return; - try { - const withoutOld = { + }} + onDelete={({ nodes: removedNodes, edges: removedEdges }) => { + if (!editable) return; + const removed = new Set(removedNodes.map((node) => node.id)); + const edgeIds = new Set(removedEdges.map((edge) => edge.id)); + onChange?.({ ...definition, + nodes: definition.nodes.filter( + (node) => !removed.has(node.nodeKey), + ), edges: definition.edges.filter( - (_, index) => edges[index]!.id !== oldEdge.id, + (edge, index) => + !removed.has(edge.from) && + !removed.has(edge.to) && + !edgeIds.has(edges[index]!.id), ), - }; - onChange?.( - connectGraphNodes(withoutOld, { - from: connection.source, - to: connection.target, - ...(connection.sourceHandle - ? { port: connection.sourceHandle } - : {}), - }), - ); - } catch (error) { - onError?.( - error instanceof Error ? error : new Error(String(error)), - ); - } - }} - onMoveEnd={(event, viewport) => { - if ( - editable && - event && - JSON.stringify(viewport) !== - JSON.stringify(definition.layout?.viewport) - ) - onChange?.({ - ...definition, - layout: { - nodes: { ...positions, ...definition.layout?.nodes }, - viewport, - }, + ...(definition.layout + ? { + layout: { + ...definition.layout, + nodes: Object.fromEntries( + Object.entries(definition.layout.nodes).filter( + ([key]) => !removed.has(key), + ), + ), + }, + } + : {}), }); - }} - > - - - - - + }} + onConnect={(connection) => { + if (!editable) return; + try { + onChange?.( + connectGraphNodes(definition, { + from: connection.source, + to: connection.target, + ...(connection.sourceHandle + ? { port: connection.sourceHandle } + : {}), + }), + ); + } catch (error) { + onError?.( + error instanceof Error ? error : new Error(String(error)), + ); + } + }} + onReconnect={(oldEdge, connection) => { + if (!editable) return; + try { + const withoutOld = { + ...definition, + edges: definition.edges.filter( + (_, index) => edges[index]!.id !== oldEdge.id, + ), + }; + onChange?.( + connectGraphNodes(withoutOld, { + from: connection.source, + to: connection.target, + ...(connection.sourceHandle + ? { port: connection.sourceHandle } + : {}), + }), + ); + } catch (error) { + onError?.( + error instanceof Error ? error : new Error(String(error)), + ); + } + }} + onMoveEnd={(event, viewport) => { + if ( + editable && + event && + JSON.stringify(viewport) !== + JSON.stringify(definition.layout?.viewport) + ) + onChange?.({ + ...definition, + layout: { + nodes: { ...positions, ...definition.layout?.nodes }, + viewport, + }, + }); + }} + > + {children} + + + + +
); } + +/** Colour key for a run's flow: what each node tone means. */ +export function GraphStateLegend() { + const { t } = useT("graphs"); + const items: [string, string][] = [ + ["bg-success", t(($) => $.studio.ux.legend_done)], + ["bg-info", t(($) => $.studio.ux.legend_running)], + ["bg-warning", t(($) => $.studio.ux.legend_waiting)], + ["bg-destructive", t(($) => $.studio.ux.legend_failed)], + ["bg-muted-foreground/40", t(($) => $.studio.ux.legend_idle)], + ]; + return ( +
    + {items.map(([dot, label]) => ( +
  • +
  • + ))} +
+ ); +} diff --git a/packages/views/graphs/graph-node-catalog.tsx b/packages/views/graphs/graph-node-catalog.tsx new file mode 100644 index 00000000000..01cba49394d --- /dev/null +++ b/packages/views/graphs/graph-node-catalog.tsx @@ -0,0 +1,196 @@ +"use client"; + +import type { ReactElement } from "react"; +import { + Bot, + CheckCircle2, + Circle, + Clock, + Code2, + GitBranch, + GitFork, + Globe, + Layers, + Merge, + Repeat2, + ShieldCheck, + type LucideIcon, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@multica/ui/components/ui/dropdown-menu"; +import { cn } from "@multica/ui/lib/utils"; +import { useT } from "../i18n"; + +export type GraphNodeCategory = "agent" | "human" | "logic" | "tool" | "edge"; + +export const GRAPH_NODE_ICONS: Record = { + start: Circle, + end: CheckCircle2, + agent: Bot, + approval: ShieldCheck, + branch: GitBranch, + parallel: GitFork, + join: Merge, + loop: Repeat2, + subgraph: Layers, + delay: Clock, + http: Globe, + script: Code2, +}; + +export function graphNodeCategory(type: string): GraphNodeCategory { + switch (type) { + case "agent": + return "agent"; + case "approval": + return "human"; + case "branch": + case "parallel": + case "join": + case "loop": + case "subgraph": + return "logic"; + case "delay": + case "http": + case "script": + return "tool"; + default: + return "edge"; + } +} + +/** + * Category colour classes. Spelled out in full so Tailwind can see every class; + * the colours themselves are the `--graph-*` tokens in `tokens.css`. + */ +export const GRAPH_CATEGORY_STYLES: Record< + GraphNodeCategory, + { bar: string; icon: string; dot: string } +> = { + agent: { + bar: "bg-graph-agent", + icon: "bg-graph-agent/12 text-graph-agent", + dot: "bg-graph-agent", + }, + human: { + bar: "bg-graph-human", + icon: "bg-graph-human/15 text-graph-human", + dot: "bg-graph-human", + }, + logic: { + bar: "bg-graph-logic", + icon: "bg-graph-logic/12 text-graph-logic", + dot: "bg-graph-logic", + }, + tool: { + bar: "bg-graph-tool", + icon: "bg-graph-tool/12 text-graph-tool", + dot: "bg-graph-tool", + }, + edge: { + bar: "bg-muted-foreground/40", + icon: "bg-muted text-muted-foreground", + dot: "bg-muted-foreground/40", + }, +}; + +export function useGraphNodeLabels(): Record { + const { t } = useT("graphs"); + return { + start: t(($) => $.studio.start), + end: t(($) => $.studio.end), + agent: t(($) => $.editor.agent), + approval: t(($) => $.studio.approval), + branch: t(($) => $.production.branch), + parallel: t(($) => $.production.parallel), + join: t(($) => $.production.join), + loop: t(($) => $.production.loop), + subgraph: t(($) => $.production.subgraph), + delay: t(($) => $.production.delay), + http: t(($) => $.production.http), + script: t(($) => $.production.script), + }; +} + +const GROUPS: { key: "people" | "logic" | "tools"; types: string[] }[] = [ + { key: "people", types: ["agent", "approval"] }, + { key: "logic", types: ["branch", "parallel", "join", "loop", "subgraph"] }, + { key: "tools", types: ["delay", "http", "script"] }, +]; + +export function GraphNodeIcon({ + type, + className, +}: { + type: string; + className?: string; +}) { + const Icon = GRAPH_NODE_ICONS[type] ?? Circle; + return ( + + ); +} + +/** + * The one way to add a step: a grouped menu of step types. Used from the canvas + * toolbar (append after the selection) and from the "+" on a connection + * (insert on it), so both places offer the same vocabulary. + */ +export function GraphAddNodeMenu({ + trigger, + onPick, + exclude = [], + disabled, + align = "start", +}: { + trigger: ReactElement; + onPick: (type: string) => void; + exclude?: string[]; + disabled?: boolean; + align?: "start" | "center" | "end"; +}) { + const { t } = useT("graphs"); + const labels = useGraphNodeLabels(); + const groupLabels = { + people: t(($) => $.studio.ux.group_people), + logic: t(($) => $.studio.ux.group_logic), + tools: t(($) => $.studio.ux.group_tools), + }; + return ( + + + + {GROUPS.map((group) => { + const types = group.types.filter((type) => !exclude.includes(type)); + if (!types.length) return null; + return ( + + {groupLabels[group.key]} + {types.map((type) => ( + onPick(type)}> + + {labels[type] ?? type} + + ))} + + ); + })} + + + ); +} diff --git a/packages/views/locales/en/graphs.json b/packages/views/locales/en/graphs.json index 0ebf19415cb..95dcf61cc70 100644 --- a/packages/views/locales/en/graphs.json +++ b/packages/views/locales/en/graphs.json @@ -246,6 +246,20 @@ "merge_invalid": "The draft in this reply is not a valid workflow, so the canvas is unchanged.", "merge_unknown_alias": "This reply referred to a teammate that does not exist ({{alias}}), so the canvas is unchanged. Available teammates: {{available}}.", "merge_too_large": "The draft in this reply is over the size limit, so the canvas is unchanged." + }, + "ux": { + "group_people": "AI and people", + "group_logic": "Logic", + "group_tools": "Tools", + "mark_added": "New", + "mark_changed": "Changed", + "node_issues": "{{count}} problems on this step", + "insert_step": "Insert a step here", + "legend_done": "Done", + "legend_running": "Running", + "legend_waiting": "Waiting", + "legend_failed": "Failed", + "legend_idle": "Not started / skipped" } }, "production": { diff --git a/packages/views/locales/ja/graphs.json b/packages/views/locales/ja/graphs.json index 9e83dd2680e..09cccc84c6c 100644 --- a/packages/views/locales/ja/graphs.json +++ b/packages/views/locales/ja/graphs.json @@ -246,6 +246,20 @@ "merge_invalid": "この返信の下書きは有効なワークフローではないため、キャンバスは変更していません。", "merge_unknown_alias": "この返信は存在しないメンバー({{alias}})を参照しているため、キャンバスは変更していません。使用できるメンバー:{{available}}。", "merge_too_large": "この返信の下書きはサイズ上限を超えているため、キャンバスは変更していません。" + }, + "ux": { + "group_people": "AI と人", + "group_logic": "ロジック", + "group_tools": "ツール", + "mark_added": "追加", + "mark_changed": "変更", + "node_issues": "このステップに {{count}} 件の問題", + "insert_step": "ここにステップを挿入", + "legend_done": "完了", + "legend_running": "実行中", + "legend_waiting": "待機", + "legend_failed": "失敗", + "legend_idle": "未開始 / スキップ" } }, "production": { diff --git a/packages/views/locales/ko/graphs.json b/packages/views/locales/ko/graphs.json index 6de3f7b1a4d..1f5b222ae11 100644 --- a/packages/views/locales/ko/graphs.json +++ b/packages/views/locales/ko/graphs.json @@ -246,6 +246,20 @@ "merge_invalid": "이 답변의 초안이 올바른 워크플로가 아니라 캔버스를 그대로 두었습니다.", "merge_unknown_alias": "이 답변이 없는 멤버({{alias}})를 가리켜 캔버스를 그대로 두었습니다. 사용할 수 있는 멤버: {{available}}.", "merge_too_large": "이 답변의 초안이 크기 상한을 넘어 캔버스를 그대로 두었습니다." + }, + "ux": { + "group_people": "AI와 사람", + "group_logic": "로직", + "group_tools": "도구", + "mark_added": "추가", + "mark_changed": "변경", + "node_issues": "이 단계에 문제 {{count}}개", + "insert_step": "여기에 단계 삽입", + "legend_done": "완료", + "legend_running": "실행 중", + "legend_waiting": "대기", + "legend_failed": "실패", + "legend_idle": "시작 전 / 건너뜀" } }, "production": { diff --git a/packages/views/locales/zh-Hans/graphs.json b/packages/views/locales/zh-Hans/graphs.json index 02c53563f15..fe5a3528c0b 100644 --- a/packages/views/locales/zh-Hans/graphs.json +++ b/packages/views/locales/zh-Hans/graphs.json @@ -246,6 +246,20 @@ "merge_invalid": "这次回复的草稿不合法,画布保持原样。", "merge_unknown_alias": "这次回复引用了不存在的成员({{alias}}),画布保持原样。可用的成员:{{available}}。", "merge_too_large": "这次回复的草稿超过了大小上限,画布保持原样。" + }, + "ux": { + "group_people": "AI 与人工", + "group_logic": "逻辑", + "group_tools": "工具", + "mark_added": "新增", + "mark_changed": "修改", + "node_issues": "这一步有 {{count}} 个问题", + "insert_step": "在这里插入一步", + "legend_done": "完成", + "legend_running": "运行中", + "legend_waiting": "等待", + "legend_failed": "失败", + "legend_idle": "未开始 / 跳过" } }, "production": { From 2d60af3f35ff5aead243ad43dae7b48b8ddc0e4c Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Thu, 5 Nov 2026 02:05:42 +0800 Subject: [PATCH 3/8] feat(graph): AI-first studio with an editable canvas, step drawer and one-click publish The editor is now one surface: the design conversation beside an editable canvas. A step opens in a drawer instead of a separate mode, flow-level settings live behind the start and end steps, and each AI turn is marked on the canvas with a one-click undo. A conversation is created by the first message rather than by opening the page, with the executor and approver defaulted and changeable under the composer. The graph page header carries the name, draft state, problem count, a run dialog and a single publish action that saves, validates and publishes, pinning any problems to their steps. Runs get their own tab, and new graphs start from a describe / copy / blank entry. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../views/graphs/graph-ai-composer.test.tsx | 165 --- packages/views/graphs/graph-ai-composer.tsx | 314 ----- .../views/graphs/graph-ai-setup-panel.tsx | 370 ----- packages/views/graphs/graph-ai-workspace.tsx | 425 ------ packages/views/graphs/graph-canvas.tsx | 1 - .../views/graphs/graph-definition-helpers.ts | 172 +++ .../views/graphs/graph-detail-page.test.tsx | 63 - packages/views/graphs/graph-detail-page.tsx | 407 ++++-- packages/views/graphs/graph-editor.test.tsx | 557 -------- packages/views/graphs/graph-editor.tsx | 1186 +---------------- packages/views/graphs/graph-flow-settings.tsx | 203 +++ packages/views/graphs/graph-new-entry.tsx | 160 +++ .../views/graphs/graph-node-inspector.tsx | 2 +- packages/views/graphs/graph-run-list.tsx | 248 ++++ ...ce.test.tsx => graph-studio-chat.test.tsx} | 324 +++-- packages/views/graphs/graph-studio-chat.tsx | 821 ++++++++++++ packages/views/graphs/graph-studio.test.tsx | 600 +++++++++ packages/views/graphs/graph-studio.tsx | 633 +++++++++ .../views/graphs/use-graph-publish-flow.ts | 237 ++++ packages/views/locales/en/graphs.json | 35 +- packages/views/locales/ja/graphs.json | 35 +- packages/views/locales/ko/graphs.json | 35 +- packages/views/locales/zh-Hans/graphs.json | 35 +- 23 files changed, 3728 insertions(+), 3300 deletions(-) delete mode 100644 packages/views/graphs/graph-ai-composer.test.tsx delete mode 100644 packages/views/graphs/graph-ai-composer.tsx delete mode 100644 packages/views/graphs/graph-ai-setup-panel.tsx delete mode 100644 packages/views/graphs/graph-ai-workspace.tsx create mode 100644 packages/views/graphs/graph-definition-helpers.ts delete mode 100644 packages/views/graphs/graph-detail-page.test.tsx delete mode 100644 packages/views/graphs/graph-editor.test.tsx create mode 100644 packages/views/graphs/graph-flow-settings.tsx create mode 100644 packages/views/graphs/graph-new-entry.tsx create mode 100644 packages/views/graphs/graph-run-list.tsx rename packages/views/graphs/{graph-ai-workspace.test.tsx => graph-studio-chat.test.tsx} (51%) create mode 100644 packages/views/graphs/graph-studio-chat.tsx create mode 100644 packages/views/graphs/graph-studio.test.tsx create mode 100644 packages/views/graphs/graph-studio.tsx create mode 100644 packages/views/graphs/use-graph-publish-flow.ts diff --git a/packages/views/graphs/graph-ai-composer.test.tsx b/packages/views/graphs/graph-ai-composer.test.tsx deleted file mode 100644 index 52c32d7f187..00000000000 --- a/packages/views/graphs/graph-ai-composer.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { ApiError } from "@multica/core/api/client"; -import type { GraphGenerationMode } from "@multica/core/graphs"; -import labels from "../locales/en/graphs.json"; -import issueLabels from "../locales/en/issues.json"; -import { GraphAiComposer } from "./graph-ai-composer"; - -vi.mock("../i18n", () => ({ - useT: (namespace: string) => ({ - t: ( - selector: (value: unknown) => string, - options?: Record, - ) => { - let text = selector(namespace === "issues" ? issueLabels : labels); - for (const [key, value] of Object.entries(options ?? {})) - text = text.replace(`{{${key}}}`, String(value)); - return text; - }, - }), -})); - -const agents = [ - { - id: "agent-1", - name: "Builder", - }, - { - id: "agent-2", - name: "Reviewer", - }, -] as never; -const members = [{ id: "member-1", name: "Ada", email: "ada@example.com" }] as never; - -function composer(overrides: Record = {}) { - const handlers = { onGenerate: vi.fn(), onManual: vi.fn(), onPromptChange: vi.fn() }; - const props = { - prompt: "Research our release", - defaultAgentId: "agent-1", - onDefaultAgentChange: vi.fn(), - approverId: "member-1", - onApproverChange: vi.fn(), - mode: "legacy" as GraphGenerationMode, - agents, - members, - loading: false, - pending: false, - error: undefined as unknown, - hasDraft: false, - ...handlers, - ...overrides, - }; - render(); - return { ...handlers, props }; -} - -const generate = () => screen.getByRole("button", { name: "Generate workflow" }); -// A locked composer swaps the label for the in-flight one, so the button is -// still the same affordance — just not the one the idle state advertises. -const busy = () => screen.getByRole("button", { name: "Generating…" }); - -describe("GraphAiComposer one-shot form", () => { - it("asks for one agent and one approver", () => { - composer(); - expect( - screen.getByRole("combobox", { name: "Workflow agent" }), - ).toHaveTextContent("Builder"); - expect( - screen.getByRole("combobox", { name: "Final approver" }), - ).toHaveTextContent("Ada"); - }); - - it("no longer asks for a participating scope or a runtime quota", () => { - // Both belonged to the runtime pipeline: it needed a scope to hand the - // model, and it spent a submission budget that this path never had. - composer(); - expect( - screen.queryByRole("combobox", { name: "Participating agents" }), - ).toBeNull(); - expect(screen.queryByText(/uses its quota/)).toBeNull(); - expect(screen.queryByText(/may use only these agents/)).toBeNull(); - }); - - it.each([ - ["no agent", { defaultAgentId: "" }], - ["no approver", { approverId: "" }], - ["an empty description", { prompt: "" }], - ["AI authoring this server cannot serve", { mode: "unavailable" }], - ["the editor withholding permission", { disabled: true }], - ["generation options still loading", { loading: true }], - ])("withholds Generate with %s", (_label, overrides) => { - composer(overrides); - expect(generate()).toBeDisabled(); - }); - - it("swaps Generate for an in-flight label while a request is pending", () => { - composer({ pending: true }); - expect(busy()).toBeDisabled(); - expect(screen.queryByRole("button", { name: "Generate workflow" })).toBeNull(); - expect(screen.getByLabelText("What should this workflow do?")).toBeDisabled(); - }); - - it("fills the description from an example instead of sending it", () => { - const { onPromptChange, onGenerate } = composer({ prompt: "" }); - fireEvent.click( - screen.getByRole("button", { - name: "Research a topic, summarize findings, then request my approval.", - }), - ); - expect(onPromptChange).toHaveBeenCalledWith( - "Research a topic, summarize findings, then request my approval.", - ); - expect(onGenerate).not.toHaveBeenCalled(); - }); - - it("still offers manual authoring while AI authoring is unavailable", () => { - const { onManual } = composer({ mode: "unavailable" }); - expect(generate()).toBeDisabled(); - expect( - screen.getByText(/has not been configured on this server/), - ).toBeVisible(); - fireEvent.click(screen.getByRole("button", { name: "Build manually" })); - expect(onManual).toHaveBeenCalledOnce(); - }); -}); - -describe("GraphAiComposer failure reporting", () => { - it.each([ - ["generation_timeout", /Generation took too long/], - ["generation_invalid", /could not produce a valid workflow/], - ["generation_failed", /AI generation failed/], - ["capability_missing", /cannot draft workflows yet/], - ["agent_unavailable", /selected agent is unavailable/], - ["forbidden", /do not have permission/], - ])("explains a %s rejection", (code, copy) => { - composer({ error: new ApiError("raw", 400, "Bad Request", { code }) }); - expect(screen.getByRole("alert")).toHaveTextContent(copy); - expect(screen.getByLabelText("What should this workflow do?")).toBeEnabled(); - }); - - it("adds the retry hint when the server is busy", () => { - composer({ - error: new ApiError("Busy", 429, "Too Many Requests", { - code: "generation_busy", - }), - }); - const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent(/Another draft is being generated/); - expect(alert).toHaveTextContent(/Your description is kept/); - }); - - it("falls back to the server's own message for a code this build does not know", () => { - composer({ - error: new ApiError("Out of quota", 400, "Bad Request", { - code: "generation_quota", - }), - }); - expect(screen.getByRole("alert")).toHaveTextContent("Out of quota"); - }); - - it("says nothing about failures while everything is quiet", () => { - composer(); - expect(screen.queryByRole("alert")).toBeNull(); - }); -}); diff --git a/packages/views/graphs/graph-ai-composer.tsx b/packages/views/graphs/graph-ai-composer.tsx deleted file mode 100644 index 5da8be4a5e5..00000000000 --- a/packages/views/graphs/graph-ai-composer.tsx +++ /dev/null @@ -1,314 +0,0 @@ -"use client"; - -import { - ArrowRight, - Bot, - CheckCheck, - GitBranch, - Loader2, - PenLine, - Search, - Sparkles, -} from "lucide-react"; -import type { Agent, MemberWithUser } from "@multica/core/types"; -import type { GraphGenerationMode } from "@multica/core/graphs"; -import { errorCode } from "@multica/core/api/client"; -import { Button } from "@multica/ui/components/ui/button"; -import { Textarea } from "@multica/ui/components/ui/textarea"; -import { useT } from "../i18n"; -import { Field } from "./shared"; -import { GraphActorPicker } from "./graph-actor-picker"; - -/** - * The one-shot form: describe the workflow, hand it to one agent, get one - * proposal back. - * - * This is the path a deployment without a builder still uses — the server - * generates from its own LLM client and the result arrives as a proposal to - * apply or discard. Where a builder exists the editor never renders this, and - * the conversation replaces both the form and the apply step. - */ -export function GraphAiComposer({ - prompt, - onPromptChange, - mode, - defaultAgentId, - onDefaultAgentChange, - approverId, - onApproverChange, - agents, - members, - loading, - disabled, - pending, - error, - hasDraft, - onGenerate, - onManual, -}: { - prompt: string; - onPromptChange: (value: string) => void; - mode: GraphGenerationMode; - defaultAgentId: string; - onDefaultAgentChange: (value: string) => void; - approverId: string; - onApproverChange: (value: string) => void; - agents: Agent[]; - members: MemberWithUser[]; - /** The generation-options query is still in flight. */ - loading: boolean; - /** The editor cannot start authoring right now. */ - disabled?: boolean; - /** The request is in flight. */ - pending: boolean; - /** The last rejected request, from before a task existed. */ - error: unknown; - hasDraft: boolean; - onGenerate: () => void; - onManual: () => void; -}) { - const { t } = useT("graphs"); - const examples = [ - t(($) => $.studio.example_research), - t(($) => $.studio.example_release), - t(($) => $.studio.example_content), - ]; - const agentOptions = agents.map((agent) => ({ - value: agent.id, - label: agent.name, - avatarUrl: agent.avatar_url, - })); - const locked = pending; - // The request is the only source of failure on this path: one synchronous - // call, so there is no task whose status could also report one. - const requestCode = errorCode(error); - const failureCode = requestCode; - const failureText = - error - ? (() => { - switch (failureCode) { - case "generation_timeout": - return t(($) => $.studio.error_timeout); - case "generation_invalid": - return t(($) => $.studio.error_invalid); - case "generation_busy": - return t(($) => $.studio.error_busy); - case "generation_unavailable": - return t(($) => $.studio.unavailable); - case "capability_missing": - return t(($) => $.studio.error_capability); - case "agent_unavailable": - return t(($) => $.studio.error_agent); - case "forbidden": - return t(($) => $.studio.error_forbidden); - case "generation_failed": - return t(($) => $.studio.error_failed); - default: - // A newer backend can report a code this build does not know. - // Show what the server said rather than inventing copy for it. - return ( - (error instanceof Error ? error.message : "") || - t(($) => $.studio.error_failed) - ); - } - })() - : ""; - return ( -
-
-
- - {t(($) => $.studio.eyebrow)} -
-

- {t(($) => $.studio.title)} -

-

- {t(($) => $.studio.description)} -

-
-
-
-
-
- $.studio.prompt)}> - {(id) => ( -