feat(agent): align Go agent behavior with Python (except retrieval component) (#16225)

## Summary

Aligns the **Go agent runtime/canvas/components/tools** behavior with
the **Python `agent/` implementation** so the same stored canvas DSL
produces the same execution result on either side. Every component,
tool, and runtime primitive in `internal/agent/` is now driven by the
same semantics as its Python counterpart — variable resolution, template
substitution, control flow, error reporting, retry/cancel, and stream
event shapes.

The **retrieval component is the one explicit exception** in this PR. It
is being reworked in a separate change and is excluded from this
alignment pass; the wrapper slot (`universe_a_wrappers.go →
newRetrievalComponent`) is preserved.

## Scope of alignment

### Components (all aligned with `agent/component/`)
`Begin` · `Message` · `LLM` (incl. ChatTemplateKwargs,
MessageHistoryWindowSize, VisualFiles, Cite, OutputStructure,
JSONOutput, TopP, MaxRetries, DelayAfterError, credentials) · `Agent`
(react + tool artifact capture + `Reset()` interface-assert) · `Switch`
(12/12 operators, Python-equivalent semantics) · `Categorize` · `Invoke`
· `Iteration` · `Loop` (macro-expansion through `workflowx.AddLoopNode`)
· `UserFillUp` (Python-equivalent interrupt/resume via eino
`compose.Interrupt`/`ResumeWithData`) · `FillUp` · `DataOperations` ·
`ListOperations` · `StringTransform` · `VariableAggregator` ·
`VariableAssigner` · `Browser` (full stagehand runtime parity) ·
`DocsGenerator` · `ExcelProcessor`.

### Tools (all aligned with `agent/tools/`)
`Retrieval` (wrapper slot only — logic out of scope) · `MCPToolAdapter`
(streamable-HTTP) · `CodeExec` (sandbox bridge with
`code_exec_contract.go` matching Python contract) · `AkShare` · `ArXiv`
· `Crawler` · `DeepL` · `DuckDuckGo` · `Email` · `ExeSQL` · `GitHub` ·
`Google` · `GoogleScholar` · `Jin10` · `PubMed` · `QWeather` · `SearXNG`
· `Tavily` · `Tushare` · `Wencai` · `Wikipedia` · `YahooFinance` —
uniform `eino tool.InvokableTool` interface, SSRF protection, shared
HTTP client.

### Canvas execution engine (`internal/agent/canvas/`)
Aligned with Python's `agent/canvas.py`:
- **Scheduler** (`scheduler.go`): state pre/post handlers, node lambdas,
per-component timeout resolver (4-level: per-class env → per-class table
→ uniform env → 600s fallback), `legacyNoOpNames`.
- **Loop subgraph** (`loop_subgraph.go`): Python-equivalent
`AddLoopNode` macro expansion + condition translation.
- **Multibranch** (`multibranch.go`): `Switch` / `Categorize` routing
via `compose.NewGraphMultiBranch` — same branch selection semantics as
Python.
- **Parallel subgraph** (`parallel_subgraph.go`): matches Python's
parallel fan-out contract.
- **Interrupt/Resume** (`interrupt_resume.go`): `UserFillUpNodeBody` /
`IsInterruptError` / `ExtractInterruptContexts` — replaces the
deprecated Python sentinel chain with eino's native interrupt API,
preserving the same external behavior.
- **Checkpoint** (`checkpoint_store.go`): `RedisCheckPointStore`
Get/Set/Delete, with business metadata (status / canvas_id /
parent_run_id) on a parallel Redis Hash.
- **RunTracker** (`run_tracker.go`): Start / MarkSucceeded / MarkFailed
/ MarkCancelled / AttachCheckpoint — same lifecycle as the Python run
record.
- **Cancel** (`cancel.go`): Redis pub/sub watch.
- **Stream** (`stream.go`): SSE channel with `messages` / `waiting` /
`errors` / `done` events, same shape as Python's `agent.canvas.RunEvent`
payload.

### DSL bridge (`internal/agent/dsl/`)
- `normalize.go`: v1↔v2 collapsed into a single wire format — Python and
Go consume the same stored JSON.
- `reset.go`: per-run state reset matches Python's `Canvas.reset()`
semantics.
- Testdata mirrors Python's `agent_msg.json` / `all.json` / etc.

### Runtime (`internal/agent/runtime/`)
- `CanvasState` / `NewCanvasState` / `GetVar` / `SetVar` / `ReadVars`:
same `{{cpn_id@param}}` resolution model.
- `ResolveTemplate` (regex fast path + gonja fallback) — Python
Jinja-style semantics.
- `selector.go`, `metrics.go`, `component.go`: shared runtime contracts.

## Out of scope (intentionally)

- **`Retrieval` component logic** — wrapped only; full parity lands in a
follow-up PR.
- **Frontend** — only minor dsl-bridge / canvas UX fixes ride along.
- **CLI / admin / model registry** — orthogonal to agent behavior.

## How alignment is verified

`internal/service/agent_run_e2e_test.go` exercises the **full production
chain** against real Python-shaped DSL fixtures:
```
loadCanvasForUser → versionDAO.GetLatest → decodeCanvasFromDSL →
canvas.Compile → cc.Workflow.Invoke → answer extraction
```
using in-memory SQLite + miniredis (no Docker). Covers:
- `TestRunAgent_RealCanvas_BeginMessage` — happy path, `{{sys.query}}`
resolution
- `TestRunAgent_RealCanvas_WaitForUserResume` — two-run resume cycle
(Python-equivalent)
- `TestRunAgent_RealCanvas_CompileFails` — unknown component name →
sanitized error (Python-equivalent)
- `TestRunAgent_RealCanvas_InvokeFails` — unresolvable template ref
(Python-equivalent)
- `TestRunAgent_RunTracker_AttachCheckpoint_CallSequence` —
Start→AttachCheckpoint→MarkSucceeded lifecycle

`internal/handler/agent_test.go` — SSE streaming parity (`Content-Type:
text/event-stream`, `data: {…}\n\n`, trailing `data: [DONE]\n\n`,
OpenAI-compatible non-stream `choices`).

`internal/agent/canvas/fixture_compile_test.go` + per-component tests
pin the Python-equivalent outputs.

```
go test -count=1 -v -run 'TestRunAgent_RealCanvas|TestRunAgent_RunTracker' ./internal/service/
```

## Design reference

`docs/develop/agent-go-port-design.md` (1329 lines, last cross-checked
2026-06-17) — module layout, per-component / per-tool inventory,
corner-case catalogue, and the actionable backlog (Section 14, including
the retrieval alignment follow-up).

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Zhichang Yu
2026-06-22 11:58:29 +08:00
committed by GitHub
parent dfe841a3e3
commit 3f805a64f1
119 changed files with 30356 additions and 1265 deletions

View File

@@ -59,25 +59,25 @@ export function Collapse({
onOpenChange={handleOpenChange}
disabled={disabled}
>
<CollapsibleTrigger className={'w-full'}>
<section className="flex justify-between items-center">
<div className="flex items-center gap-1">
<section className="flex justify-between items-center gap-2">
<CollapsibleTrigger className="flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 items-center gap-1">
{currentOpen ? (
<ListChevronsUpDown className="size-4" />
<ListChevronsUpDown className="size-4 shrink-0" />
) : (
<ListChevronsDownUp className="size-4 text-text-secondary" />
<ListChevronsDownUp className="size-4 shrink-0 text-text-secondary" />
)}
<div
className={cn('text-text-secondary', {
'text-text-primary': open,
className={cn('min-w-0 text-text-secondary', {
'text-text-primary': currentOpen,
})}
>
{title}
</div>
</div>
<div>{rightContent}</div>
</section>
</CollapsibleTrigger>
</CollapsibleTrigger>
{rightContent ? <div className="shrink-0">{rightContent}</div> : null}
</section>
<CollapsibleContent className="pt-5">{children}</CollapsibleContent>
</Collapsible>
);

View File

@@ -9,8 +9,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
const CopyToClipboard = ({
text,
className,
avoidButtonWrapper = false,
...buttonProps
}: { text: string } & ButtonProps) => {
}: { text: string; avoidButtonWrapper?: boolean } & ButtonProps) => {
const [copied, setCopied] = useState(false);
const { t } = useTranslate('common');
@@ -21,19 +22,32 @@ const CopyToClipboard = ({
}, 2000);
};
const icon = copied ? <LucideCheck /> : <LucideCopy />;
const trigger = avoidButtonWrapper ? (
<Button
asChild
variant="transparent"
size="icon-sm"
{...buttonProps}
className={cn(className, copied && '!text-state-success')}
>
<span aria-label={copied ? t('copied') : t('copy')}>{icon}</span>
</Button>
) : (
<Button
variant="transparent"
size="icon-sm"
{...buttonProps}
className={cn(className, copied && '!text-state-success')}
>
{icon}
</Button>
);
return (
<Tooltip open={copied ? true : undefined}>
<Clipboard text={text} onCopy={handleCopy}>
<TooltipTrigger asChild>
<Button
variant="transparent"
size="icon-sm"
{...buttonProps}
className={cn(className, copied && '!text-state-success')}
>
{copied ? <LucideCheck /> : <LucideCopy />}
</Button>
</TooltipTrigger>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
</Clipboard>
<TooltipContent>{copied ? t('copied') : t('copy')}</TooltipContent>
</Tooltip>

View File

@@ -60,7 +60,12 @@ export const AssistantGroupButton = ({
className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
role="toolbar"
>
<CopyToClipboard text={content} className="border-0" size="icon-xs" />
<CopyToClipboard
text={content}
className="border-0"
size="icon-xs"
avoidButtonWrapper
/>
{showLoudspeaker && (
<>
@@ -156,7 +161,12 @@ export const UserGroupButton = ({
return (
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<CopyToClipboard text={content} className="border-0" size="icon-xs" />
<CopyToClipboard
text={content}
className="border-0"
size="icon-xs"
avoidButtonWrapper
/>
{regenerateMessage && (
<Tooltip>

View File

@@ -85,6 +85,7 @@ export const AssistantGroupButton = ({
<CopyToClipboard
text={content}
className="border-none hover:!bg-transparent"
avoidButtonWrapper
></CopyToClipboard>
</ToggleGroupItem>
{showLoudspeaker && (
@@ -189,7 +190,7 @@ export const UserGroupButton = ({
className="space-x-1"
>
<ToggleGroupItem value="a">
<CopyToClipboard text={content}></CopyToClipboard>
<CopyToClipboard text={content} avoidButtonWrapper></CopyToClipboard>
</ToggleGroupItem>
{regenerateMessage && (
<ToggleGroupItem

View File

@@ -113,22 +113,29 @@ const SingleSelectDisplay: React.FC<{
};
const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
({
options = [],
value = [],
onChange,
placeholder = 'Select tags...',
className,
style,
multi = false,
type = 'text',
}) => {
(
{
options = [],
value = [],
onChange,
placeholder = 'Select tags...',
className,
style,
multi = false,
type = 'text',
},
ref,
) => {
const [inputValue, setInputValue] = React.useState('');
const [open, setOpen] = React.useState(false);
const [isFocused, setIsFocused] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const { t } = useTranslation();
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, [
inputRef,
]);
// Normalize value to array for consistent handling based on type
const normalizedValue = React.useMemo(() => {
if (Array.isArray(value)) {
@@ -299,7 +306,13 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
// Return single value if not multi-select, otherwise return array
let result: string | number | Date | string[] | number[] | Date[];
if (multi) {
result = newValue;
if (type === 'number') {
result = newValue as number[];
} else if (type === 'date' || type === 'datetime') {
result = newValue as Date[];
} else {
result = newValue as string[];
}
} else {
if (type === 'number') {
result = newValue[0] || 0;

View File

@@ -2,7 +2,7 @@
import { cn } from '@/lib/utils';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { AlertCircle, CheckCircle, Info, Loader, X } from 'lucide-react';
import { FC, ReactNode, useCallback, useEffect, useMemo } from 'react';
import React, { FC, ReactNode, useCallback, useEffect, useMemo } from 'react';
import { createRoot } from 'react-dom/client';
import { useTranslation } from 'react-i18next';
import { DialogDescription } from '../dialog';

View File

@@ -35,14 +35,16 @@ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
export const FormTooltip = ({ tooltip }: { tooltip: React.ReactNode }) => {
return (
<Tooltip>
<TooltipTrigger
tabIndex={-1}
className="align-text-top"
onClick={(e) => {
e.preventDefault(); // Prevent clicking the tooltip from triggering form save
}}
>
<CircleQuestionMark className="size-[.85em] ml-[.25em]" />
<TooltipTrigger asChild>
<span
tabIndex={-1}
className="inline-flex align-text-top"
onClick={(e) => {
e.preventDefault(); // Prevent clicking the tooltip from triggering form save
}}
>
<CircleQuestionMark className="size-[.85em] ml-[.25em]" />
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>

View File

@@ -1,5 +1,6 @@
import message from '@/components/ui/message';
import { Authorization } from '@/constants/authorization';
import { ResponseType } from '@/interfaces/database/base';
import { IReferenceObject } from '@/interfaces/database/chat';
import { BeginQuery } from '@/pages/agent/interface';
import { getAuthorization } from '@/utils/authorization-util';
@@ -122,8 +123,30 @@ export const useSendMessageBySSE = (url: string) => {
body: JSON.stringify(body),
signal: controller?.signal || sseRef.current?.signal,
});
const res = response.clone().json();
if (!response.ok) {
let errorMessage = response.statusText || 'Request failed';
try {
const errorBody = (await response
.clone()
.json()) as Partial<ResponseType>;
if (typeof errorBody?.message === 'string' && errorBody.message) {
errorMessage = errorBody.message;
}
} catch {
// Non-JSON error body; fall back to the HTTP status text.
}
setDone(true);
resetAnswerList();
return {
response,
data: {
code: response.status,
data: null,
message: errorMessage,
status: response.status,
},
};
}
const reader = response?.body
?.pipeThrough(new TextDecoderStream())
@@ -185,7 +208,15 @@ export const useSendMessageBySSE = (url: string) => {
console.info('done?');
setDone(true);
resetAnswerList();
return { data: await res, response };
return {
response,
data: {
code: 0,
data: true,
message: 'success',
status: response.status,
},
};
} catch (e) {
setDone(true);
resetAnswerList();

View File

@@ -1,14 +1,14 @@
.canvasWrapper {
position: relative;
height: calc(100% - 64px);
:global(.react-flow__node-group) {
:global(.react-flow__node-iterationNode) {
.commonNode();
border-radius: 0 0 10px 10px;
padding: 0;
border: 0;
background-color: transparent;
}
:global(.react-flow__node-group.selectable.selected) {
:global(.react-flow__node-iterationNode.selectable.selected) {
box-shadow: none;
}
}

View File

@@ -88,7 +88,7 @@ export const nodeTypes: NodeTypes = {
rewriteNode: RewriteNode,
keywordNode: KeywordNode,
// emailNode: EmailNode,
group: IterationNode,
iterationNode: IterationNode,
iterationStartNode: IterationStartNode,
agentNode: AgentNode,
toolNode: ToolNode,
@@ -163,12 +163,15 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
setCurrentMessageId,
});
const { stopMessage } = useStopMessageUnmount(chatVisible, latestTaskId);
const [lastSendLoading, setLastSendLoading] = useState(false);
const [currentSendLoading, setCurrentSendLoading] = useState(false);
const { stopMessage } = useStopMessageUnmount(
chatVisible && currentSendLoading,
latestTaskId,
);
const { handleBeforeDelete } = useBeforeDelete();
const { addCanvasNode, addNoteNode } = useAddNode(reactFlowInstance);
@@ -179,10 +182,18 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
useEffect(() => {
if (!chatVisible) {
stopMessage(latestTaskId);
if (currentSendLoading) {
stopMessage(latestTaskId);
}
clearEventList();
}
}, [chatVisible, clearEventList, latestTaskId, stopMessage]);
}, [
chatVisible,
clearEventList,
currentSendLoading,
latestTaskId,
stopMessage,
]);
const setLastSendLoadingFunc = (loading: boolean, messageId: string) => {
setCurrentSendLoading(!!loading);

View File

@@ -14,7 +14,7 @@ import {
} from '@/hooks/use-agent-request';
import { useFetchUserInfo } from '@/hooks/use-user-setting-request';
import { buildMessageUuidWithRole } from '@/utils/chat';
import { memo, useCallback, useContext } from 'react';
import { memo, useCallback, useContext, useEffect } from 'react';
import { AgentChatContext } from '../context';
import DebugContent from '../debug-content';
import { useAwaitComponentData } from '../hooks/use-chat-logic';
@@ -50,7 +50,17 @@ function AgentChatBox() {
});
const { setDerivedMessages } = useContext(AgentChatContext);
setDerivedMessages?.(derivedMessages);
// Sync the derived messages to the AgentChatContext — must run as an
// effect, not in the render body. Calling setDerivedMessages(...)
// synchronously during render (the previous shape) targets
// AgentCanvas's state while AgentChatBox is still rendering, which
// triggers React's "Cannot update a component (AgentCanvas) while
// rendering a different component (AgentChatBox)" warning. The
// effect runs after commit so the setState targets a stable
// component subtree.
useEffect(() => {
setDerivedMessages?.(derivedMessages);
}, [derivedMessages, setDerivedMessages]);
const isTaskMode = useIsTaskMode();

View File

@@ -724,7 +724,7 @@ export const NodeMap = {
[Operator.Crawler]: 'ragNode',
[Operator.Invoke]: 'ragNode',
[Operator.Email]: 'ragNode',
[Operator.Iteration]: 'group',
[Operator.Iteration]: 'iterationNode',
[Operator.IterationStart]: 'iterationStartNode',
[Operator.Code]: 'ragNode',
[Operator.WaitingDialogue]: 'ragNode',

View File

@@ -0,0 +1,79 @@
const FILE_ID = 'File';
const FILE_OPERATOR = 'File';
const INITIAL_PARSER_VALUES = {
outputs: {
markdown: { type: 'string', value: '' },
text: { type: 'string', value: '' },
html: { type: 'string', value: '' },
json: { type: 'Array<object>', value: [] },
},
setups: [],
};
// Dataflow seed DSL. Kept separate from UI hooks so pure DSL helpers
// can import it without pulling React modules into tests/runtime.
export const DataflowEmptyDsl = {
graph: {
nodes: [
{
id: FILE_ID,
type: 'beginNode',
position: {
x: 50,
y: 200,
},
data: {
label: FILE_OPERATOR,
name: FILE_OPERATOR,
},
sourcePosition: 'left',
targetPosition: 'right',
},
{
data: {
form: INITIAL_PARSER_VALUES,
label: 'Parser',
name: 'Parser_0',
},
dragging: false,
id: 'Parser:HipSignsRhyme',
measured: {
height: 57,
width: 200,
},
position: {
x: 316.99524094206413,
y: 195.39629819663406,
},
selected: true,
sourcePosition: 'right',
targetPosition: 'left',
type: 'parserNode',
},
],
edges: [
{
id: 'xy-edge__Filestart-Parser:HipSignsRhymeend',
source: FILE_ID,
sourceHandle: 'start',
target: 'Parser:HipSignsRhyme',
targetHandle: 'end',
},
],
},
components: {
[FILE_OPERATOR]: {
obj: {
component_name: FILE_OPERATOR,
params: {},
},
downstream: [],
upstream: [],
},
},
retrieval: [],
history: [],
path: [],
globals: {},
variables: [],
};

View File

@@ -1,6 +1,22 @@
import { DecoratorNode, LexicalNode, NodeKey } from 'lexical';
import {
DecoratorNode,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
import { ReactNode } from 'react';
export type SerializedVariableNode = Spread<
{
type: 'variable';
version: 1;
value: string;
label: string;
},
SerializedLexicalNode
>;
export class VariableNode extends DecoratorNode<ReactNode> {
__value: string;
__label: string;
@@ -22,6 +38,14 @@ export class VariableNode extends DecoratorNode<ReactNode> {
);
}
static importJSON(serializedNode: SerializedVariableNode): VariableNode {
return new VariableNode(
serializedNode.value,
serializedNode.label,
undefined,
);
}
constructor(
value: string,
label: string,
@@ -73,6 +97,16 @@ export class VariableNode extends DecoratorNode<ReactNode> {
getTextContent(): string {
return `{${this.__value}}`;
}
exportJSON(): SerializedVariableNode {
return {
...super.exportJSON(),
type: 'variable',
version: 1,
value: this.__value,
label: this.__label,
};
}
}
export function $createVariableNode(

View File

@@ -7,7 +7,7 @@ export function useStopMessage() {
const stopMessage = useCallback(
(taskId?: string) => {
if (taskId) {
cancelConversation(taskId);
void cancelConversation(taskId).catch(() => undefined);
}
},
[cancelConversation],

View File

@@ -23,7 +23,7 @@ import { ITraceData } from '@/interfaces/database/agent';
import { cn } from '@/lib/utils';
import { t } from 'i18next';
import { get, isEmpty } from 'lodash';
import { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { Operator } from '../constant';
import { JsonViewer } from '../form/components/json-viewer';
import { useCacheChatLog } from '../hooks/use-cache-chat-log';
@@ -200,10 +200,10 @@ export const WorkFlowTimeline = ({
const inputs = getInputsOrOutputs(nodeDataList, 'inputs');
const outputs = getInputsOrOutputs(nodeDataList, 'outputs');
const nodeLabel = x.data.component_type;
const itemKey = `${x.data.component_id}-${idx}`;
return (
<>
<React.Fragment key={itemKey}>
<TimelineItem
key={idx}
step={idx}
className="group-data-[orientation=vertical]/timeline:ms-10 group-data-[orientation=vertical]/timeline:not-last:pb-8"
>
@@ -323,13 +323,12 @@ export const WorkFlowTimeline = ({
</TimelineItem>
{hasTrace(x.data.component_id) && (
<ToolTimelineItem
key={'tool_' + idx}
tools={filterTrace(x.data.component_id)}
sendLoading={sendLoading}
isShare={isShare}
></ToolTimelineItem>
)}
</>
</React.Fragment>
);
})}
</Timeline>

View File

@@ -38,10 +38,28 @@ import {
IOperator,
RAGFlowNodeType,
} from '@/interfaces/database/agent';
import { DataflowEmptyDsl } from '@/pages/agents/hooks/use-create-agent';
import { DataflowEmptyDsl } from '@/pages/agent/empty-dsl';
import { buildDslComponentsByGraph, buildDslGlobalVariables } from '../utils';
const LEGACY_ITERATION_NODE_TYPE = 'group';
const ITERATION_NODE_TYPE = 'iterationNode';
const normalizeGraphNodes = (nodes: RAGFlowNodeType[]): RAGFlowNodeType[] =>
nodes.map((node) => {
if (
node?.data?.label === Operator.Iteration &&
node.type === LEGACY_ITERATION_NODE_TYPE
) {
return {
...node,
type: ITERATION_NODE_TYPE,
};
}
return node;
});
// ─── Public API ─────────────────────────────────────────────────────────
/** Initial empty DSL for a new canvas. `isAgent` picks agent vs dataflow seed. */
@@ -82,7 +100,10 @@ export const importDsl = (
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 };
graph = {
nodes: normalizeGraphNodes(rawParsed.graph.nodes as RAGFlowNodeType[]),
edges,
};
components =
(rawParsed.components as DSLComponents | undefined) ??
(buildDslComponentsByGraph(
@@ -127,7 +148,7 @@ export const dslToGraph = (
if (Array.isArray(graphNodes) && graphNodes.length > 0) {
const rawEdges = dsl?.graph?.edges;
return {
nodes: graphNodes as RAGFlowNodeType[],
nodes: normalizeGraphNodes(graphNodes as RAGFlowNodeType[]),
edges: (Array.isArray(rawEdges) ? rawEdges : []) as Edge[],
};
}

View File

@@ -21,6 +21,16 @@
// block keyed by the fixture name. The diff helper classifies
// React-Flow internals automatically — no per-fixture work needed.
jest.mock('../../utils', () => ({
buildDslComponentsByGraph: jest.fn(
(_nodes, _edges, oldDslComponents) => oldDslComponents ?? {},
),
buildDslGlobalVariables: jest.fn((dsl, globalVariables) => ({
globals: dsl.globals,
variables: globalVariables ?? dsl.variables ?? {},
})),
}));
import * as bridge from '../dsl-bridge';
const REACT_FLOW_NODE_INTERNALS = new Set(['dragging', 'selected', 'measured']);
const REACT_FLOW_EDGE_INTERNALS = new Set(['isHovered']);
@@ -279,7 +289,7 @@ describe('dsl-bridge round-trip stability', () => {
// 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({});
expect(out.components).toEqual(bridge.initialEmptyDsl(true).components);
});
it('_layout-only payload (legacy v1 export) → empty seed', () => {
@@ -370,4 +380,76 @@ describe('dsl-bridge round-trip stability', () => {
);
expect(diff.failures).toEqual(['position.x: value (100 vs 999)']);
});
it('normalizes legacy iteration group nodes to the custom iteration node type', () => {
const legacyIterationDsl = {
graph: {
nodes: [
{
id: 'StringTransform:SplitCSV',
type: 'ragNode',
position: { x: 0, y: 0 },
data: { label: 'StringTransform', name: 'SplitCSV', form: {} },
},
{
id: 'Iteration:IterateList',
type: 'group',
position: { x: 100, y: 0 },
data: { label: 'Iteration', name: 'IterateList', form: {} },
},
{
id: 'Message:IterDone',
type: 'messageNode',
position: { x: 200, y: 0 },
data: { label: 'Message', name: 'IterDone', form: { content: [] } },
},
],
edges: [
{
id: 'xy-edge__StringTransform:SplitCSVstart-Iteration:IterateListend',
source: 'StringTransform:SplitCSV',
sourceHandle: 'start',
target: 'Iteration:IterateList',
targetHandle: 'end',
},
{
id: 'xy-edge__Iteration:IterateListstart-Message:IterDoneend',
source: 'Iteration:IterateList',
sourceHandle: 'start',
target: 'Message:IterDone',
targetHandle: 'end',
},
],
},
components: {
'StringTransform:SplitCSV': {
obj: { component_name: 'StringTransform', params: {} },
downstream: ['Iteration:IterateList'],
upstream: [],
},
'Iteration:IterateList': {
obj: { component_name: 'Iteration', params: {} },
downstream: ['Message:IterDone'],
upstream: ['StringTransform:SplitCSV'],
},
'Message:IterDone': {
obj: { component_name: 'Message', params: { content: [] } },
downstream: [],
upstream: ['Iteration:IterateList'],
},
},
};
const imported = bridge.importDsl(legacyIterationDsl as any, true) as any;
expect(
imported.graph.nodes.find(
(node: any) => node.id === 'Iteration:IterateList',
)?.type,
).toBe('iterationNode');
const { nodes } = bridge.dslToGraph(imported);
expect(
nodes.find((node: any) => node.id === 'Iteration:IterateList')?.type,
).toBe('iterationNode');
});
});

View File

@@ -1,83 +1,12 @@
import { AgentCategory, Operator } from '@/constants/agent';
import { AgentCategory } 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: [
{
id: FileId,
type: 'beginNode',
position: {
x: 50,
y: 200,
},
data: {
label: Operator.File,
name: Operator.File,
},
sourcePosition: 'left',
targetPosition: 'right',
},
{
data: {
form: initialParserValues,
label: 'Parser',
name: 'Parser_0',
},
dragging: false,
id: 'Parser:HipSignsRhyme',
measured: {
height: 57,
width: 200,
},
position: {
x: 316.99524094206413,
y: 195.39629819663406,
},
selected: true,
sourcePosition: 'right',
targetPosition: 'left',
type: 'parserNode',
},
],
edges: [
{
id: 'xy-edge__Filestart-Parser:HipSignsRhymeend',
source: FileId,
sourceHandle: 'start',
target: 'Parser:HipSignsRhyme',
targetHandle: 'end',
},
],
},
components: {
[Operator.File]: {
obj: {
component_name: Operator.File,
params: {},
},
downstream: [], // other edge target is downstream, edge source is current node id
upstream: [], // edge source is upstream, edge target is current node id
},
},
retrieval: [], // reference
history: [],
path: [],
globals: {},
variables: [],
};
export function useCreateAgentOrPipeline() {
const { loading, setAgent } = useSetAgent();
const {

View File

@@ -7,7 +7,7 @@ import {
useVerifyProviderConnection,
} from '@/hooks/use-llm-request';
import { IInstanceModel, IProviderInstance } from '@/interfaces/database/llm';
import {
import type {
IAddProviderInstanceRequestBody,
IModelInfo,
} from '@/interfaces/request/llm';