mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-16 04:37:21 +08:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
79
web/src/pages/agent/empty-dsl.ts
Normal file
79
web/src/pages/agent/empty-dsl.ts
Normal 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: [],
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useStopMessage() {
|
||||
const stopMessage = useCallback(
|
||||
(taskId?: string) => {
|
||||
if (taskId) {
|
||||
cancelConversation(taskId);
|
||||
void cancelConversation(taskId).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[cancelConversation],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user