diff --git a/internal/ai/workflow/dsl/types.go b/internal/ai/workflow/dsl/types.go index d193648..5f0cc57 100644 --- a/internal/ai/workflow/dsl/types.go +++ b/internal/ai/workflow/dsl/types.go @@ -7,6 +7,7 @@ const SchemaVersion = 2 type Definition struct { SchemaVersion int `json:"schemaVersion"` Nodes []Node `json:"nodes"` + Annotations []Node `json:"annotations,omitempty"` Edges []Edge `json:"edges"` } @@ -187,3 +188,22 @@ func (d *NodeData) UnmarshalJSON(data []byte) error { } return nil } + +func (d NodeData) MarshalJSON() ([]byte, error) { + type alias NodeData + base, err := json.Marshal(alias(d)) + if err != nil { + return nil, err + } + values := make(map[string]json.RawMessage) + if err := json.Unmarshal(base, &values); err != nil { + return nil, err + } + for key, value := range d.Extra { + if _, exists := values[key]; exists { + continue + } + values[key] = value + } + return json.Marshal(values) +} diff --git a/internal/ai/workflow/dsl/types_test.go b/internal/ai/workflow/dsl/types_test.go index 2b6f30e..e1e4b7c 100644 --- a/internal/ai/workflow/dsl/types_test.go +++ b/internal/ai/workflow/dsl/types_test.go @@ -2,6 +2,7 @@ package dsl_test import ( "encoding/json" + "strings" "testing" "agent-desk/internal/ai/workflow/dsl" @@ -81,3 +82,32 @@ func TestDefinitionUnmarshalsFlowGramStyleSchema(t *testing.T) { t.Fatalf("unexpected edge: %#v", edge) } } + +func TestDefinitionPreservesCanvasAnnotations(t *testing.T) { + var def dsl.Definition + err := json.Unmarshal([]byte(`{ + "schemaVersion": 2, + "nodes": [], + "annotations": [{ + "id": "comment_1", + "type": "comment", + "meta": {"position": {"x": 12, "y": 34}}, + "data": {"note": "check this branch", "size": {"width": 240, "height": 150}} + }], + "edges": [] + }`), &def) + if err != nil { + t.Fatalf("unmarshal definition: %v", err) + } + if len(def.Annotations) != 1 || def.Annotations[0].ID != "comment_1" { + t.Fatalf("expected annotation to be preserved, got %#v", def.Annotations) + } + encoded, err := json.Marshal(def) + if err != nil { + t.Fatalf("marshal definition: %v", err) + } + if !strings.Contains(string(encoded), `"annotations"`) || + !strings.Contains(string(encoded), `"check this branch"`) { + t.Fatalf("expected annotation JSON to round trip, got %s", encoded) + } +} diff --git a/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx b/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx index 1d457f3..183e4af 100644 --- a/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx +++ b/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react" -import { EditorRenderer, FreeLayoutEditorProvider } from "@flowgram.ai/free-layout-editor" import { AlertTriangleIcon, CheckCircle2Icon, TimerIcon } from "lucide-react" import { JsonTreeViewer } from "@/components/json-tree-viewer" @@ -11,26 +10,21 @@ import { ScrollArea } from "@/components/ui/scroll-area" import type { AIWorkflowNodeRun, AIWorkflowRun } from "@/lib/api/admin" import { cn } from "@/lib/utils" -import { useFlowgramEditorProps } from "../../ai-workflows/_components/flowgram-editor-provider" +import { WorkflowReadonlyCanvas } from "../../ai-workflows/_components/editor/workflow-editor" export function WorkflowRunAuditGraph({ run }: { run: AIWorkflowRun }) { - const nodeRuns = run.nodes ?? [] + const nodeRuns = useMemo(() => run.nodes ?? [], [run.nodes]) const firstNodeId = nodeRuns[0]?.nodeId ?? run.definition?.nodes?.[0]?.id ?? "" const [selectedNodeId, setSelectedNodeId] = useState(firstNodeId) const selectedNodeRun = nodeRuns.find((item) => item.nodeId === selectedNodeId) ?? nodeRuns[0] const executedNodeIds = useMemo(() => new Set(nodeRuns.map((item) => item.nodeId)), [nodeRuns]) - const editorProps = useFlowgramEditorProps({ - definition: run.definition ?? { schemaVersion: 2, nodes: [], edges: [] }, - nodeSpecs: [], - readonly: true, - }) return (
- - - +
diff --git a/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx b/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx new file mode 100644 index 0000000..f681a3e --- /dev/null +++ b/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx @@ -0,0 +1,76 @@ +"use client" + +/* eslint-disable react-hooks/refs -- FlowGram useNodeRender exposes reactive render state through a ref-backed adapter. */ + +import { useLayoutEffect, useState } from "react" + +import { + WorkflowPortRender, + type WorkflowNodeProps, + useNodeRender, +} from "@flowgram.ai/free-layout-editor" +import { usePanelManager } from "@flowgram.ai/panel-manager-plugin" + +import { cn } from "@/lib/utils" + +import { WorkflowEditorSurfaceProvider } from "./editor-context" +import { usePortClick } from "./use-port-click" + +export const NODE_FORM_PANEL = "workflow-node-form" + +export function BaseNode(props: WorkflowNodeProps) { + const render = useNodeRender(props.node) + const panelManager = usePanelManager() + const onPortClick = usePortClick() + const [dragging, setDragging] = useState(false) + + useLayoutEffect(() => { + if (String(render.node.flowNodeType) !== "condition") return + const frame = window.requestAnimationFrame(() => { + render.node.ports.updateDynamicPorts() + }) + return () => window.cancelAnimationFrame(frame) + }, [render.data, render.node]) + + return ( + +
{ + render.startDrag(event) + setDragging(true) + }} + onTouchStart={(event) => { + render.startDrag(event as unknown as React.MouseEvent) + setDragging(true) + }} + onMouseUp={() => setDragging(false)} + onClick={(event) => { + render.selectNode(event) + if (!render.readonly && !dragging) { + panelManager.open(NODE_FORM_PANEL, "docked-right", { + props: { nodeId: render.node.id }, + }) + } + }} + onFocus={render.onFocus} + onBlur={render.onBlur} + > + {render.form?.render()} +
+ {render.ports.map((port) => ( + + ))} +
+ ) +} diff --git a/web/app/dashboard/ai-workflows/_components/editor/comment-node.tsx b/web/app/dashboard/ai-workflows/_components/editor/comment-node.tsx new file mode 100644 index 0000000..9e31e03 --- /dev/null +++ b/web/app/dashboard/ai-workflows/_components/editor/comment-node.tsx @@ -0,0 +1,89 @@ +"use client" + +/* eslint-disable react-hooks/refs -- FlowGram useNodeRender exposes reactive render state through a ref-backed adapter. */ + +import { useLayoutEffect } from "react" + +import { + Field, + FlowNodeFormData, + Form, + type FormModelV2, + type WorkflowNodeProps, + useNodeRender, +} from "@flowgram.ai/free-layout-editor" +import { Trash2Icon } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +type CommentSize = { + width?: number + height?: number +} + +export function CommentNode(props: WorkflowNodeProps) { + const render = useNodeRender(props.node) + const formModel = render.node + .getData(FlowNodeFormData) + .getFormModel() + const size = (formModel?.getValueIn("size") ?? {}) as CommentSize + const width = Math.max(120, Number(size.width) || 240) + const height = Math.max(80, Number(size.height) || 150) + + useLayoutEffect(() => { + render.node.transform.update({ + size: { width, height }, + }) + }, [height, render.node, width]) + + return ( +
+
+ {!render.readonly ? ( + + ) : null} +
+ name="note"> + {({ field }) => ( +