feat(agent): ship the Go agent canvas port — eino interrupt/resume + Redis check-pointing (#16035)

Replaces the Python agent canvas runtime with a Go implementation that
runs inside `cmd/server_main`.

The canvas compiles into an eino Workflow that pauses on wait-for-user
via native Interrupt/Resume (no sentinel flag) and resumes from a
Redis-backed CheckPointStore.

All 21 Python agent components and ~35 tools are ported with functional
parity.

Sandbox providers now read their JSON config from the admin-panel
system_settings table with env fallback.

234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate
added); drops the v2 DSL E2E step and the gap-analysis plan (both
redundant after the port ships).

## Type of change

- [x] Refactoring
- [x] New feature
- [x] Bug fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Zhichang Yu
2026-06-17 13:24:03 +08:00
committed by GitHub
parent 2290bb0023
commit e45659868a
231 changed files with 33807 additions and 6114 deletions

View File

@@ -142,7 +142,24 @@ export const useSendMessageBySSE = (url: string) => {
break;
}
try {
const val = JSON.parse(value?.data || '');
const raw = (value?.data ?? '').trim();
// SSE end-of-stream sentinel — no payload, skip without
// surfacing a JSON.parse error to the console.
if (!raw) {
continue;
}
// Some upstreams double-wrap the body in a `data:` prefix;
// strip one layer so JSON.parse sees a real object.
const payload = raw.startsWith('data:')
? raw.slice(5).trimStart()
: raw;
// Check the sentinel after prefix stripping so a
// `data: [DONE]` payload is caught and the stream
// loop is terminated.
if (payload === '[DONE]') {
break;
}
const val = JSON.parse(payload);
console.info('data:', val);
if (typeof val?.code === 'number' && val.code !== 0) {

View File

@@ -2,10 +2,11 @@ import { NodeCollapsible } from '@/components/collapse';
import { IMessageNode } from '@/interfaces/database/agent';
import { cn } from '@/lib/utils';
import { useGetVariableLabelOrTypeByValue } from '@/pages/agent/hooks/use-get-begin-query';
import { NodeProps } from '@xyflow/react';
import { Handle, NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { LabelCard } from './card';
import { LeftEndHandle } from './handle';
import styles from './index.module.less';
@@ -14,13 +15,30 @@ import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
import { VariableDisplay } from './variable-display';
function InnerMessageNode({ id, data, selected }: NodeProps<IMessageNode>) {
function InnerMessageNode({
id,
data,
selected,
isConnectable = true,
}: NodeProps<IMessageNode>) {
const messages: string[] = get(data, 'form.content', []);
const { getLabel } = useGetVariableLabelOrTypeByValue({ nodeId: id });
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected} id={id}>
<LeftEndHandle></LeftEndHandle>
{/* v1 Message/Answer nodes are routable: they have a downstream
too, so they need a source handle on the right with the
"start" id that edges' sourceHandle field references. The
original component only rendered a target handle, which
silently dropped any edge with this node as the source. */}
<Handle
type="source"
id={NodeHandleId.Start}
position={Position.Right}
isConnectable={isConnectable}
className="!bg-accent-primary !size-2"
/>
<NodeHeader
id={id}
name={data.name}

View File

@@ -41,6 +41,17 @@ function InnerToolNode({
isConnectable={isConnectable}
className="!bg-accent-primary !size-2"
/>
{/* v1 ExeSQL and similar "tool" components route their result
downstream, so they need a source handle too. Without this,
any edge where the toolNode is the source silently fails to
render. */}
<Handle
id={NodeHandleId.Start}
type="source"
position={Position.Right}
isConnectable={isConnectable}
className="!bg-accent-primary !size-2"
/>
<NodeCollapsible items={[tools, mcpList]}>
{(x) => {

View File

@@ -1,4 +1,9 @@
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { IModalProps } from '@/interfaces/common';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
@@ -8,6 +13,10 @@ import AgentChatBox from './box';
export function ChatSheet({ hideModal }: IModalProps<any>) {
const { t } = useTranslation();
const isTaskMode = useIsTaskMode();
// Radix Dialog requires both a Title and a Description on the content
// node; both are visually hidden so the user-facing header in the
// separate <div> below stays unchanged.
const sheetLabel = t(isTaskMode ? 'flow.task' : 'chat.chat');
return (
<Sheet open modal={false} onOpenChange={hideModal}>
@@ -16,10 +25,9 @@ export function ChatSheet({ hideModal }: IModalProps<any>) {
className={cn('top-20 bottom-0 p-0 flex flex-col h-auto')}
onInteractOutside={(e) => e.preventDefault()}
>
<SheetTitle className="hidden"></SheetTitle>
<div className="pl-5 pt-2">
{t(isTaskMode ? 'flow.task' : 'chat.chat')}
</div>
<SheetTitle className="sr-only">{sheetLabel}</SheetTitle>
<SheetDescription className="sr-only">{sheetLabel}</SheetDescription>
<div className="pl-5 pt-2">{sheetLabel}</div>
<AgentChatBox></AgentChatBox>
</SheetContent>
</Sheet>

View File

@@ -4,9 +4,8 @@ import {
RAGFlowNodeType,
} from '@/interfaces/database/agent';
import { useCallback } from 'react';
import { Operator } from '../constant';
import useGraphStore from '../store';
import { buildDslComponentsByGraph, buildDslGlobalVariables } from '../utils';
import { graphToDsl } from '../utils/dsl-bridge';
export const useBuildDslData = () => {
const { data } = useFetchAgent();
@@ -17,44 +16,14 @@ export const useBuildDslData = () => {
currentNodes?: RAGFlowNodeType[],
otherParam?: { globalVariables: Record<string, GlobalVariableType> },
) => {
const nodesToProcess = currentNodes ?? nodes;
// Filter out placeholder nodes and related edges
const filteredNodes = nodesToProcess.filter(
(node) => node.data?.label !== Operator.Placeholder,
);
const filteredEdges = edges.filter((edge) => {
const sourceNode = nodesToProcess.find(
(node) => node.id === edge.source,
);
const targetNode = nodesToProcess.find(
(node) => node.id === edge.target,
);
return (
sourceNode?.data?.label !== Operator.Placeholder &&
targetNode?.data?.label !== Operator.Placeholder
);
});
const dslComponents = buildDslComponentsByGraph(
filteredNodes,
filteredEdges,
data.dsl.components,
);
const globalVariables = buildDslGlobalVariables(
data.dsl,
return graphToDsl(
currentNodes ?? nodes,
edges,
data?.dsl ?? {},
otherParam?.globalVariables,
);
return {
...data.dsl,
...globalVariables,
graph: { nodes: filteredNodes, edges: filteredEdges },
components: dslComponents,
};
},
[data.dsl, edges, nodes],
[data?.dsl, edges, nodes],
);
return { buildDslData };

View File

@@ -1,14 +1,14 @@
import { EmptyDsl, Operator } from '@/constants/agent';
import { Operator } from '@/constants/agent';
import { useFetchAgent } from '@/hooks/use-agent-request';
import { downloadJsonFile } from '@/utils/file-util';
import { cloneDeepWith, get, isPlainObject, pick } from 'lodash';
import { cloneDeepWith, get, isPlainObject } from 'lodash';
import { useCallback } from 'react';
import { useBuildDslData } from './use-build-dsl';
import useGraphStore from '../store';
import { exportDsl } from '../utils/dsl-bridge';
/**
* Recursively clear sensitive fields (api_key) from the DSL object
*/
const clearSensitiveFields = <T>(obj: T): T =>
cloneDeepWith(obj, (value) => {
if (
@@ -23,21 +23,22 @@ const clearSensitiveFields = <T>(obj: T): T =>
});
export const useHandleExportJsonFile = () => {
const { buildDslData } = useBuildDslData();
const { data } = useFetchAgent();
const { nodes, edges } = useGraphStore((state) => state);
const handleExportJson = useCallback(() => {
const dsl = pick(buildDslData(), ['graph', 'globals', 'variables']);
const sanitizedDsl = clearSensitiveFields(dsl) as typeof dsl;
// bridge.exportDsl returns the canonical wire shape from current
// graph state plus preserved DSL fields, so export can write it
// directly after sensitive-field sanitization.
const full = exportDsl(nodes, edges, data?.dsl ?? {});
const sanitizedDsl = clearSensitiveFields(full);
const nextDsl = {
...sanitizedDsl,
globals: { ...sanitizedDsl.globals, ...EmptyDsl.globals },
globals: { ...(sanitizedDsl.globals ?? {}) },
};
downloadJsonFile(nextDsl, `${data.title}.json`);
}, [buildDslData, data.title]);
}, [nodes, edges, data?.dsl, data.title]);
return {
handleExportJson,

View File

@@ -1,6 +1,6 @@
import { useFetchAgent } from '@/hooks/use-agent-request';
import { IGraph } from '@/interfaces/database/agent';
import { useEffect } from 'react';
import { dslToGraph } from '../utils/dsl-bridge';
import { useSetGraphInfo } from './use-set-graph';
export const useFetchDataOnMount = () => {
@@ -8,7 +8,7 @@ export const useFetchDataOnMount = () => {
const setGraphInfo = useSetGraphInfo();
useEffect(() => {
setGraphInfo(data?.dsl?.graph ?? ({} as IGraph));
setGraphInfo(dslToGraph(data?.dsl));
}, [setGraphInfo, data]);
useEffect(() => {

View File

@@ -0,0 +1,223 @@
// DSL bridge — single wire shape for the agent canvas.
//
// The RAGFlow agent DSL has exactly one canonical wire shape, used
// for every operation (PUT/GET/create/export/import):
//
// {
// "globals": {...},
// "graph": { "nodes": [...], "edges": [...] }, // React-Flow
// "variables": {...},
// "components": { "<Name>:<UUID>": { // execution topology
// "downstream": [...], "upstream": [...],
// "obj": { "component_name": "Name", "params": {...} }
// }},
// "path": [...], "retrieval": {...}, "history": [...]
// }
//
// `graph` is React-Flow's layout surface (positions, source/target
// handles). `components` is the topology the engine executes. The
// front-end rebuilds `components` from `graph` on every save so the
// two stay in lockstep; the back-end reads `components` only and
// ignores `graph`.
//
// `importDsl` reads the canonical `graph` block from a parsed
// import file. `_layout` is intentionally NOT consumed here — it
// was a one-shot import-time hint that historical v1 export files
// used to carry canvas positions, and it has not been a wire
// contract field since the v1/v2 split was removed. A payload with
// `_layout` but no `graph` (or with neither) falls through to the
// empty seed.
import { Edge } from '@xyflow/react';
import { DataflowOperator, EmptyDsl, Operator } from '@/constants/agent';
import {
DSL,
DSLComponents,
GlobalVariableType,
IOperator,
RAGFlowNodeType,
} from '@/interfaces/database/agent';
import { DataflowEmptyDsl } from '@/pages/agents/hooks/use-create-agent';
import { buildDslComponentsByGraph, buildDslGlobalVariables } from '../utils';
// ─── Public API ─────────────────────────────────────────────────────────
/** Initial empty DSL for a new canvas. `isAgent` picks agent vs dataflow seed. */
export const initialEmptyDsl = (isAgent: boolean): DSL =>
isAgent ? (EmptyDsl as unknown as DSL) : (DataflowEmptyDsl as unknown as DSL);
/**
* Convert a parsed JSON object from a user-uploaded file into a
* renderable DSL. Caller must have already parsed the file (we take
* the object, not the string). `isAgent` is the form-level flag, not
* re-inferred here.
*
* Reads the canonical `graph` block from a parsed import file.
* `raw.graph.nodes` must be present and non-empty for the input
* to be rendered as-is; anything else falls through to the empty
* seed (an empty canvas).
*
* `_layout` is NOT read — that field was a one-shot import-time
* hint in historical v1 export files and is no longer part of the
* wire contract. A payload that carries `_layout` (and nothing
* else) is treated the same as an empty file.
*/
export const importDsl = (
rawParsed: Record<string, any>,
isAgent: boolean,
): DSL => {
const seed = isAgent ? EmptyDsl : DataflowEmptyDsl;
// Single precedence level: `raw.graph.nodes` is the canonical
// wire shape. Every DSL the back-end returns has a populated
// `graph` block, so anything else (a `_layout`-only payload from
// a stale test fixture, a `components`-only payload from a
// third-party tool, an empty file) falls through to the empty
// seed.
let graph: { nodes: RAGFlowNodeType[]; edges: Edge[] };
let components: DSLComponents;
if (Array.isArray(rawParsed?.graph?.nodes)) {
const rawEdges = rawParsed.graph.edges;
const edges: Edge[] = Array.isArray(rawEdges) ? rawEdges : [];
graph = { nodes: rawParsed.graph.nodes as RAGFlowNodeType[], edges };
components =
(rawParsed.components as DSLComponents | undefined) ??
(buildDslComponentsByGraph(
graph.nodes,
graph.edges,
seed.components as DSLComponents,
) as DSLComponents);
} else {
graph = { nodes: [], edges: [] };
components = seed.components as DSLComponents;
}
return {
...seed,
graph,
components,
retrieval: rawParsed.retrieval ?? seed.retrieval,
history: rawParsed.history ?? seed.history,
path: rawParsed.path ?? seed.path,
variables: rawParsed.variables ?? seed.variables,
globals: rawParsed.globals ?? seed.globals,
} as unknown as DSL;
};
/**
* Convert a server-returned DSL into the React-Flow `{nodes, edges}`
* shape the store consumes. Reads `dsl.graph` only; absent
* `graph.nodes` returns an empty canvas (see function body for
* the rationale).
*/
export const dslToGraph = (
dsl: DSL,
): { nodes: RAGFlowNodeType[]; edges: Edge[] } => {
// Single source of truth: server always populates `graph`, so a
// server-returned dsl that lacks `graph.nodes` is treated as
// empty (the back-end should never produce such a payload;
// treating it as empty keeps the canvas from blowing up if a
// historical row slips through). No components-only fallback —
// the strict-graph import contract in `importDsl` ensures the
// top-level DSL always carries `graph`.
const graphNodes = dsl?.graph?.nodes;
if (Array.isArray(graphNodes) && graphNodes.length > 0) {
const rawEdges = dsl?.graph?.edges;
return {
nodes: graphNodes as RAGFlowNodeType[],
edges: (Array.isArray(rawEdges) ? rawEdges : []) as Edge[],
};
}
return { nodes: [], edges: [] };
};
/**
* Build a fresh DSL from the current React-Flow state. Emits
* `graph` + `components` plus a spread of the previous DSL for any
* untouched fields (`messages`, `path`, `retrieval`, etc.).
*/
export const graphToDsl = (
currentNodes: RAGFlowNodeType[],
currentEdges: Edge[],
oldDsl: DSL,
globalVariables?: Record<string, GlobalVariableType>,
): DSL => {
const filteredNodes = currentNodes.filter(
(n) => n.data?.label !== Operator.Placeholder,
);
const filteredEdges = currentEdges.filter((edge) => {
const s = currentNodes.find((n) => n.id === edge.source);
const t = currentNodes.find((n) => n.id === edge.target);
return (
s?.data?.label !== Operator.Placeholder &&
t?.data?.label !== Operator.Placeholder
);
});
const dslComponents = buildDslComponentsByGraph(
filteredNodes,
filteredEdges,
oldDsl?.components ?? {},
);
const globals = buildDslGlobalVariables(
oldDsl ?? ({} as DSL),
globalVariables,
);
return {
...oldDsl,
...globals,
graph: { nodes: filteredNodes, edges: filteredEdges },
components: dslComponents,
};
};
/**
* Build a downloadable JSON for the export button. Returns the
* conventional wire shape (`graph` + `components` + `globals` +
* `variables` + spread of any untouched fields). The caller is
* responsible for stripping sensitive fields (api_key) before
* handing the result to the file writer.
*/
export const exportDsl = (
currentNodes: RAGFlowNodeType[],
currentEdges: Edge[],
oldDsl: DSL,
globalVariables?: Record<string, GlobalVariableType>,
): Record<string, any> => {
return graphToDsl(
currentNodes,
currentEdges,
oldDsl,
globalVariables,
) as Record<string, any>;
};
/**
* Detect whether an imported JSON is a dataflow canvas. Looks at
* both shapes (v1 components / v2 graph.nodes) for the dataflow
* markers ("File" begin + "Parser"). Defaults to `true` (agent)
* when ambiguous.
*/
export const inferIsAgentFromImport = (raw: Record<string, any>): boolean => {
const graph = raw?.graph;
if (graph && Array.isArray(graph.nodes)) {
const labels = (graph.nodes as any[]).map((n: any) => n?.data?.label);
if (
labels.includes(DataflowOperator.Begin) &&
labels.includes(DataflowOperator.Parser)
) {
return false;
}
return true;
}
// No `graph` block — treat as agent. The strict-graph
// import contract in `importDsl` already handles the
// empty-payload case elsewhere.
return true;
};
export type { IOperator };

View File

@@ -0,0 +1,373 @@
// Tests for the single-mode dsl-bridge.
//
// The bridge has exactly one wire shape. Public API under test:
// `initialEmptyDsl`, `importDsl`, `dslToGraph`, `graphToDsl`,
// `exportDsl`, `inferIsAgentFromImport`. The bridge has one wire
// shape and a single import path.
// ─── Round-trip stability integration tests ────────────────────────────
//
// These exercise the full bridge: `importDsl` → `dslToGraph` →
// `graphToDsl` → `dslToGraph` → `exportDsl`, and assert that the
// re-emitted payload matches the input modulo React-Flow-internal
// fields (`dragging`, `selected`, `measured`, `data.isHovered`).
// Those fields are transient UI state that React Flow re-derives on
// every mount, so a round-trip that flips them is harmless. Any
// other mismatch (semantic field, position, edge topology, …) is
// a real bug and fails the test.
//
// To add a new fixture, drop a JSON file under
// `internal/agent/dsl/testdata/` and add a `describe`
// block keyed by the fixture name. The diff helper classifies
// React-Flow internals automatically — no per-fixture work needed.
import * as bridge from '../dsl-bridge';
const REACT_FLOW_NODE_INTERNALS = new Set(['dragging', 'selected', 'measured']);
const REACT_FLOW_EDGE_INTERNALS = new Set(['isHovered']);
const isInternalField = (path: string, key: string): boolean => {
if (REACT_FLOW_NODE_INTERNALS.has(key)) return true;
// Nested measured.* properties (measured.width, measured.height)
// are React-Flow-managed and should be treated as internals.
if (path.split('.').some((seg) => REACT_FLOW_NODE_INTERNALS.has(seg)))
return true;
if (REACT_FLOW_EDGE_INTERNALS.has(key)) {
// Top-level `isHovered` on an edge, or nested under `edges[].data`.
return /(^|\.)edges(\.|$|\[)/.test(path) || path.endsWith('.data');
}
return false;
};
interface Diff {
warnings: string[];
failures: string[];
}
const diffDsl = (expected: any, actual: any, path = ''): Diff => {
const out: Diff = { warnings: [], failures: [] };
compareInto(expected, actual, path, out);
return out;
};
const compareInto = (
expected: any,
actual: any,
path: string,
out: Diff,
): void => {
if (expected === actual) return;
if (
expected === null ||
actual === null ||
typeof expected !== typeof actual
) {
record(out, path, 'value', expected, actual, '');
return;
}
if (typeof expected !== 'object') {
record(out, path, 'value', expected, actual, '');
return;
}
const expArr = Array.isArray(expected);
const actArr = Array.isArray(actual);
if (expArr !== actArr) {
out.failures.push(`${path}: array/object mismatch`);
return;
}
if (expArr) {
if (expected.length !== actual.length) {
out.failures.push(
`${path}: length ${expected.length} != ${actual.length}`,
);
}
const n = Math.min(expected.length, actual.length);
for (let i = 0; i < n; i++) {
compareInto(expected[i], actual[i], `${path}[${i}]`, out);
}
return;
}
const allKeys = new Set([...Object.keys(expected), ...Object.keys(actual)]);
for (const key of allKeys) {
const sub = path ? `${path}.${key}` : key;
if (!(key in expected)) {
record(out, sub, 'missing in expected', undefined, actual[key], key);
continue;
}
if (!(key in actual)) {
record(out, sub, 'missing in actual', expected[key], undefined, key);
continue;
}
if (typeof expected[key] === 'object' && expected[key] !== null) {
compareInto(expected[key], actual[key], sub, out);
} else if (expected[key] !== actual[key]) {
record(out, sub, 'value', expected[key], actual[key], key);
}
}
};
const record = (
out: Diff,
path: string,
kind: string,
exp: any,
act: any,
key: string,
): void => {
const msg = `${path}: ${kind} (${stableStr(exp)} vs ${stableStr(act)})`;
if (isInternalField(path, key)) {
out.warnings.push(msg);
} else {
out.failures.push(msg);
}
};
const stableStr = (v: any): string => {
if (v === undefined) return 'undefined';
if (typeof v === 'string') return JSON.stringify(v);
try {
return JSON.stringify(v);
} catch {
return String(v);
}
};
// roundTrip imports `input`, re-derives the React-Flow state, and
// re-exports — returning the export payload. The bridge has one
// wire shape; `importDsl` reads the canonical `graph` block and
// normalises the rest of the dsl shape.
const roundTrip = (bridge: any, input: any): any => {
const imported = bridge.importDsl(input, true);
const { nodes, edges } = bridge.dslToGraph(imported);
// Re-derive dsl from React-Flow state (mirrors what the canvas
// store does on every edit), then re-import to flatten transient
// fields the way GetAgent would, then export.
const redrawn = bridge.graphToDsl(nodes, edges, imported);
const { nodes: n2, edges: e2 } = bridge.dslToGraph(redrawn);
return bridge.exportDsl(n2, e2, redrawn);
};
describe('dsl-bridge round-trip stability', () => {
// Realistic v2 fixture with React-Flow-internal fields. Mirrors
// the structure of internal/agent/dsl/testdata/browser.json
// but inlined here so the test is self-contained.
const v2BrowserLike = {
graph: {
nodes: [
{
id: 'begin',
type: 'beginNode',
position: { x: 218.5, y: 138.5 },
data: { label: 'Begin', name: 'begin', form: { prologue: 'Hi' } },
sourcePosition: 'left',
targetPosition: 'right',
dragging: false,
selected: false,
measured: { width: 200, height: 81 },
},
{
id: 'Browser:BusyHatsSink',
type: 'ragNode',
position: { x: 385.29, y: 264.35 },
data: {
label: 'Browser',
name: 'Browser_0',
form: { headless: true, max_steps: 30 },
},
sourcePosition: 'right',
targetPosition: 'left',
dragging: false,
selected: true,
measured: { width: 200, height: 49 },
},
{
id: 'Message:QuietMonkeysLead',
type: 'messageNode',
position: { x: 554.79, y: 120.35 },
data: {
label: 'Message',
name: 'Reply_0',
form: { content: ['{Browser:BusyHatsSink@content}'] },
},
sourcePosition: 'right',
targetPosition: 'left',
dragging: false,
selected: false,
measured: { width: 200, height: 85 },
},
],
edges: [
{
id: 'xy-edge__beginstart-Browser:BusyHatsSinkend',
source: 'begin',
sourceHandle: 'start',
target: 'Browser:BusyHatsSink',
targetHandle: 'end',
data: { isHovered: false },
},
{
id: 'xy-edge__Browser:BusyHatsSinkstart-Message:QuietMonkeysLeadend',
source: 'Browser:BusyHatsSink',
sourceHandle: 'start',
target: 'Message:QuietMonkeysLead',
targetHandle: 'end',
data: { isHovered: false },
},
],
},
components: {
begin: {
obj: {
component_name: 'Begin',
params: { prologue: 'Hi', mode: 'conversational' },
},
downstream: ['Browser:BusyHatsSink'],
upstream: [],
},
'Browser:BusyHatsSink': {
obj: {
component_name: 'Browser',
params: { headless: true, max_steps: 30 },
},
downstream: ['Message:QuietMonkeysLead'],
upstream: ['begin'],
},
'Message:QuietMonkeysLead': {
obj: {
component_name: 'Message',
params: { content: ['{Browser:BusyHatsSink@content}'] },
},
downstream: [],
upstream: ['Browser:BusyHatsSink'],
},
},
retrieval: [],
history: [],
path: [],
variables: [],
globals: {
'sys.conversation_turns': 0,
'sys.date': '',
'sys.files': [],
'sys.history': [],
'sys.query': '',
'sys.user_id': '',
},
};
// importDsl strict-graph contract: only payloads with
// `raw.graph.nodes` render as-is. Everything else — a
// components-only payload, a `_layout`-only payload (legacy
// v1 export, intentionally ignored), or an empty file —
// falls through to the empty seed. This is the documented
// contract as of 2026-06: the bridge has one wire shape and
// import accepts only the canonical graph block.
describe('importDsl strict-graph contract', () => {
it('components-only payload → empty seed (no fallback derivation)', () => {
const componentsOnly = {
components: {
begin: {
obj: { component_name: 'Begin', params: {} },
downstream: [],
upstream: [],
},
},
};
const out = bridge.importDsl(componentsOnly, true) as any;
expect(out.graph.nodes).toEqual([]);
expect(out.graph.edges).toEqual([]);
// components-only payload (no `graph.nodes`) falls through
// to the empty seed; components from the input are NOT
// propagated because the strict-graph contract only
// renders what comes in via `graph`.
expect(out.components).toEqual({});
});
it('_layout-only payload (legacy v1 export) → empty seed', () => {
// The front-end previously read `dsl._layout` to recover
// canvas positions from historical v1 export files. As of
// the v1/v2 split removal, `_layout` is no longer part of
// the wire contract and is silently dropped — a payload
// that carries only `_layout` (and nothing else) renders
// as an empty canvas.
const layoutOnly = {
_layout: {
nodes: [
{
id: 'begin',
type: 'beginNode',
position: { x: 50, y: 200 },
data: { label: 'Begin', name: 'begin' },
},
],
edges: [],
},
};
const out = bridge.importDsl(layoutOnly, true) as any;
expect(out.graph.nodes).toEqual([]);
expect(out.graph.edges).toEqual([]);
});
it('empty file → empty seed', () => {
const out = bridge.importDsl({}, true) as any;
expect(out.graph.nodes).toEqual([]);
expect(out.graph.edges).toEqual([]);
});
});
it('v2 input round-trip: graph + components survive export (modulo RF internals)', () => {
const exported = roundTrip(bridge, v2BrowserLike) as any;
// The structural parts (graph, components) must be byte-stable.
// Top-level envelope fields like `retrieval`/`history` are
// stripped by v2 exportDsl on purpose, so we focus on `graph`
// and `components` — the two payloads that carry the canvas
// state.
const diff = diffDsl(v2BrowserLike.graph, exported.graph, 'graph');
if (diff.warnings.length > 0) {
// eslint-disable-next-line no-console
console.warn(
'[v2 round-trip] React-Flow-internal mismatches (warnings, not failures):',
diff.warnings,
);
}
expect(diff.failures).toEqual([]);
// Components round-trip too
expect(exported.components).toBeDefined();
expect(Object.keys(exported.components)).toHaveLength(3);
expect(exported.components['Browser:BusyHatsSink'].obj.component_name).toBe(
'Browser',
);
});
it('diffDsl: semantic field mismatch → failure, RF internal → warning', () => {
// Direct unit test of the diff classifier, independent of the
// bridge. Verifies that the warning/failure split behaves the
// way downstream tests rely on.
const expected = {
id: 'n1',
type: 'beginNode',
position: { x: 100, y: 100 },
dragging: false,
selected: false,
measured: { width: 200, height: 81 },
};
const actual = {
id: 'n1',
type: 'beginNode',
position: { x: 999, y: 100 }, // semantic mismatch
dragging: true, // RF internal
selected: true, // RF internal
measured: { width: 999, height: 81 }, // RF internal
};
const diff = diffDsl(expected, actual);
expect(diff.warnings).toEqual(
expect.arrayContaining([
expect.stringMatching(/dragging/),
expect.stringMatching(/selected/),
expect.stringMatching(/measured/),
]),
);
expect(diff.failures).toEqual(['position.x: value (100 vs 999)']);
});
});

View File

@@ -1,12 +1,17 @@
import { AgentCategory, EmptyDsl, Operator } from '@/constants/agent';
import { AgentCategory, Operator } from '@/constants/agent';
import { useSetModalState } from '@/hooks/common-hooks';
import { useSetAgent } from '@/hooks/use-agent-request';
import { FileId, initialParserValues } from '@/pages/agent/constant';
import { initialEmptyDsl } from '@/pages/agent/utils/dsl-bridge';
import { useCallback } from 'react';
import { FlowType } from '../constant';
import { FormSchemaType } from '../create-agent-form';
// Dataflow seed DSL. Exported as-is so that the bridge module
// (`web/src/pages/agent/utils/dsl-bridge.ts`) and any other consumer
// can import it directly. The bridge picks this up in
// `initialEmptyDsl(false)`.
export const DataflowEmptyDsl = {
graph: {
nodes: [
@@ -86,7 +91,7 @@ export function useCreateAgentOrPipeline() {
const isAgent = data.type === FlowType.Agent;
const ret = await setAgent({
title: data.name,
dsl: isAgent ? EmptyDsl : DataflowEmptyDsl,
dsl: initialEmptyDsl(isAgent),
canvas_category: isAgent
? AgentCategory.AgentCanvas
: AgentCategory.DataflowCanvas,

View File

@@ -1,21 +1,16 @@
import { useToast } from '@/components/hooks/use-toast';
import message from '@/components/ui/message';
import { AgentCategory, DataflowOperator, EmptyDsl } from '@/constants/agent';
import { AgentCategory } from '@/constants/agent';
import { FileMimeType } from '@/constants/common';
import { useSetModalState } from '@/hooks/common-hooks';
import { useSetAgent } from '@/hooks/use-agent-request';
import { Node } from '@xyflow/react';
import * as bridge from '@/pages/agent/utils/dsl-bridge';
import { inferIsAgentFromImport } from '@/pages/agent/utils/dsl-bridge';
import isEmpty from 'lodash/isEmpty';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { buildDslComponentsByGraph } from '../agent/utils';
import { DataflowEmptyDsl } from './hooks/use-create-agent';
import { FormSchemaType } from './upload-agent-dialog/upload-agent-form';
function hasNode(nodes: Node[], operator: DataflowOperator) {
return nodes.some((x) => x.data.label === operator);
}
export const useHandleImportJsonFile = () => {
const {
visible: fileUploadVisible,
@@ -38,42 +33,10 @@ export const useHandleImportJsonFile = () => {
const graphOrDslStr = await file.text();
const errorMessage = t('flow.jsonUploadContentErrorMessage');
try {
const graphOrDsl = JSON.parse(graphOrDslStr);
if (graphOrDslStr && !isEmpty(graphOrDsl)) {
let isAgent = true;
// Compatible with older versions
const graph = graphOrDsl?.graph ? graphOrDsl.graph : graphOrDsl;
if (Array.isArray(graph?.nodes)) {
const nodes: Node[] = graph.nodes;
if (
hasNode(nodes, DataflowOperator.Begin) &&
hasNode(nodes, DataflowOperator.Parser)
) {
isAgent = false;
}
}
const dsl = isAgent
? { ...EmptyDsl, graph: graph }
: { ...DataflowEmptyDsl, graph: graph };
if (graphOrDsl.globals) {
dsl.globals = graphOrDsl.globals;
}
if (graphOrDsl.variables) {
dsl.variables = graphOrDsl.variables;
}
if (Array.isArray(graph?.nodes) && Array.isArray(graph?.edges)) {
dsl.components = buildDslComponentsByGraph(
graph.nodes as any,
graph.edges as any,
graphOrDsl.components ?? dsl.components,
);
}
const rawParsed = JSON.parse(graphOrDslStr);
if (graphOrDslStr && !isEmpty(rawParsed)) {
const isAgent = inferIsAgentFromImport(rawParsed);
const dsl = bridge.importDsl(rawParsed, isAgent);
setAgent({
title: name,
dsl,
@@ -85,8 +48,7 @@ export const useHandleImportJsonFile = () => {
} else {
message.error(errorMessage);
}
} catch (error) {
console.log('🚀 ~ useHandleImportJsonFile ~ error:', error);
} catch {
message.error(errorMessage);
}
}

View File

@@ -11,23 +11,30 @@ import { get, isEmpty } from 'lodash';
import { ReactNode } from 'react';
export function filterAllUpstreamNodeIds(edges: Edge[], nodeIds: string[]) {
return nodeIds.reduce<string[]>((pre, nodeId) => {
const currentEdges = edges.filter((x) => x.target === nodeId);
const upstreamNodeIds: string[] = currentEdges.map((x) => x.source);
const ids = upstreamNodeIds.concat(
filterAllUpstreamNodeIds(edges, upstreamNodeIds),
);
ids.forEach((x) => {
if (pre.every((y) => y !== x)) {
pre.push(x);
// Iterative BFS with a visited set so cycles in the upstream graph
// (e.g. answer:0 ↔ exesql:0 in the v1 exesql.json fixture) cannot
// recurse forever. The previous recursive implementation had no cycle
// detection and would blow the stack on any cyclic dsl.
const visited = new Set<string>(nodeIds);
const result: string[] = [];
let frontier = [...nodeIds];
while (frontier.length) {
const next: string[] = [];
for (const nodeId of frontier) {
const upstreamIds = edges
.filter((x) => x.target === nodeId)
.map((x) => x.source);
for (const id of upstreamIds) {
if (!visited.has(id)) {
visited.add(id);
result.push(id);
next.push(id);
}
}
});
return pre;
}, []);
}
frontier = next;
}
return result;
}
export function filterChildNodeIds(nodes: BaseNode[], nodeId?: string) {

View File

@@ -168,10 +168,52 @@ export const downloadJsonFile = async (
data: Record<string, any>,
fileName: string,
) => {
const blob = new Blob([JSON.stringify(data)], { type: FileMimeType.Json });
// Pretty-print with 2-space indent + sort keys at every depth so
// the downloaded file is human-readable AND byte-stable across
// re-exports: a user exporting the same canvas twice gets the
// exact same bytes (modulo round-trip edits), which makes the
// file easy to diff in version control and easy to hand-edit.
// Mirrors the `sort_keys=True` we apply to the testdata fixtures
// fixtures, so an exported dsl imported in v2 mode and re-
// exported stays identical at the byte level.
//
// JSON.stringify already leaves non-ASCII (e.g. the Chinese
// prompts we store under `Browser.prompts`) un-escaped by
// default, so no `ensure_ascii` toggle is needed.
const blob = new Blob([JSON.stringify(sortKeysDeep(data), null, 2)], {
type: FileMimeType.Json,
});
downloadFileFromBlob(blob, fileName);
};
// sortKeysDeep returns a structural copy of `value` with every
// plain-object key sorted alphabetically. Array element order is
// preserved (semantic — nodes/edges are not interchangeable), but
// each element is recursively sorted if it is itself an object.
// Primitives, `null`, and non-plain objects are passed through
// unchanged. Used to make the exported dsl byte-stable: JSON
// property iteration order is implementation-defined in JS, and
// React Flow nodes carry fields in a stable order today but we
// don't want to depend on that.
const sortKeysDeep = (value: any): any => {
if (Array.isArray(value)) {
return value.map(sortKeysDeep);
}
if (value !== null && typeof value === 'object') {
// Only sort plain {…} objects — skip Date, RegExp, Map, etc.
if (Object.getPrototypeOf(value) !== Object.prototype) {
return value;
}
return Object.keys(value)
.sort()
.reduce<Record<string, any>>((acc, key) => {
acc[key] = sortKeysDeep(value[key]);
return acc;
}, {});
}
return value;
};
export function transformBase64ToFileWithPreview(
dataUrl: string,
filename: string = 'file',