mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-29 04:08:12 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
223
web/src/pages/agent/utils/dsl-bridge.ts
Normal file
223
web/src/pages/agent/utils/dsl-bridge.ts
Normal 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 };
|
||||
373
web/src/pages/agent/utils/tests/dsl-bridge.test.ts
Normal file
373
web/src/pages/agent/utils/tests/dsl-bridge.test.ts
Normal 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)']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user