mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-19 20:14:32 +00:00
Add the code node to the workflow builder
Expose the workflow code node in the builder UI: a draggable palette entry, a canvas node, and a config panel for the code, inputs, output variable, timeout, and an optional JSON schema (validated before save). The panel serializes to the same config the engine reads, so a code node built in the UI runs and produces an artifact reference; workflows without code nodes load unchanged.
This commit is contained in:
1 parent
e7f765b628
commit
d573bab4a0
8 files changed
+673
-10
No files matched your search
@@ -4,7 +4,17 @@ export type NodeType =
|
|||||||
| 'agent'
|
| 'agent'
|
||||||
| 'note'
|
| 'note'
|
||||||
| 'state'
|
| 'state'
|
||||||
| 'condition';
|
| 'condition'
|
||||||
|
| 'code';
|
||||||
|
|
||||||
|
export interface CodeNodeConfig {
|
||||||
|
code: string;
|
||||||
|
language: string;
|
||||||
|
inputs: string[];
|
||||||
|
output_variable?: string;
|
||||||
|
timeout?: number;
|
||||||
|
json_schema?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConditionCase {
|
export interface ConditionCase {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'reactflow/dist/style.css';
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Bot,
|
Bot,
|
||||||
|
Code2,
|
||||||
Database,
|
Database,
|
||||||
Flag,
|
Flag,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
@@ -64,10 +65,20 @@ import { getToolDisplayName } from '../../utils/toolUtils';
|
|||||||
import AgentPageHeader from '../AgentPageHeader';
|
import AgentPageHeader from '../AgentPageHeader';
|
||||||
import { Agent } from '../types';
|
import { Agent } from '../types';
|
||||||
import { ConditionCase, WorkflowNode } from '../types/workflow';
|
import { ConditionCase, WorkflowNode } from '../types/workflow';
|
||||||
|
import {
|
||||||
|
createDefaultCodeConfig,
|
||||||
|
normalizeCodeConfig,
|
||||||
|
parseCodeInputs,
|
||||||
|
parseCodeJsonSchemaDraft,
|
||||||
|
serializeCodeConfig,
|
||||||
|
stringifyCodeInputs,
|
||||||
|
validateCodeJsonSchema,
|
||||||
|
} from './codeNodeConfig';
|
||||||
import MobileBlocker from './components/MobileBlocker';
|
import MobileBlocker from './components/MobileBlocker';
|
||||||
import PromptTextArea from './components/PromptTextArea';
|
import PromptTextArea from './components/PromptTextArea';
|
||||||
import {
|
import {
|
||||||
AgentNode,
|
AgentNode,
|
||||||
|
CodeNode,
|
||||||
ConditionNode,
|
ConditionNode,
|
||||||
EndNode,
|
EndNode,
|
||||||
NoteNode,
|
NoteNode,
|
||||||
@@ -283,15 +294,18 @@ function createWorkflowPayload(
|
|||||||
| 'agent'
|
| 'agent'
|
||||||
| 'note'
|
| 'note'
|
||||||
| 'state'
|
| 'state'
|
||||||
| 'condition',
|
| 'condition'
|
||||||
|
| 'code',
|
||||||
title: node.data.title || node.data.label || node.type,
|
title: node.data.title || node.data.label || node.type,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
data:
|
data:
|
||||||
node.type === 'agent' ||
|
node.type === 'code'
|
||||||
node.type === 'condition' ||
|
? serializeCodeConfig(node.data.config)
|
||||||
node.type === 'state'
|
: node.type === 'agent' ||
|
||||||
? node.data.config
|
node.type === 'condition' ||
|
||||||
: node.data,
|
node.type === 'state'
|
||||||
|
? node.data.config
|
||||||
|
: node.data,
|
||||||
})),
|
})),
|
||||||
edges: workflowEdges.map((edge) => ({
|
edges: workflowEdges.map((edge) => ({
|
||||||
id: edge.id,
|
id: edge.id,
|
||||||
@@ -310,6 +324,7 @@ const NODE_TYPES: NodeTypes = {
|
|||||||
note: NoteNode,
|
note: NoteNode,
|
||||||
state: SetStateNode,
|
state: SetStateNode,
|
||||||
condition: ConditionNode,
|
condition: ConditionNode,
|
||||||
|
code: CodeNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
function WorkflowBuilderInner() {
|
function WorkflowBuilderInner() {
|
||||||
@@ -515,6 +530,10 @@ function WorkflowBuilderInner() {
|
|||||||
mode: 'simple',
|
mode: 'simple',
|
||||||
cases: [{ name: '', expression: '', sourceHandle: 'case_0' }],
|
cases: [{ name: '', expression: '', sourceHandle: 'case_0' }],
|
||||||
};
|
};
|
||||||
|
} else if (type === 'code') {
|
||||||
|
baseNode.data.title = 'Code';
|
||||||
|
baseNode.data.label = 'Code';
|
||||||
|
baseNode.data.config = createDefaultCodeConfig();
|
||||||
} else if (type === 'note') {
|
} else if (type === 'note') {
|
||||||
baseNode.data.title = 'Note';
|
baseNode.data.title = 'Note';
|
||||||
baseNode.data.label = 'Note';
|
baseNode.data.label = 'Note';
|
||||||
@@ -615,6 +634,27 @@ function WorkflowBuilderInner() {
|
|||||||
[handleUpdateNodeData, selectedNode],
|
[handleUpdateNodeData, selectedNode],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCodeJsonSchemaChange = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (!selectedNode || selectedNode.type !== 'code') return;
|
||||||
|
|
||||||
|
const nodeId = selectedNode.id;
|
||||||
|
setAgentJsonSchemaDrafts((prev) => ({ ...prev, [nodeId]: text }));
|
||||||
|
|
||||||
|
const { schema, error } = parseCodeJsonSchemaDraft(text);
|
||||||
|
setAgentJsonSchemaErrors((prev) => ({ ...prev, [nodeId]: error }));
|
||||||
|
if (!error) {
|
||||||
|
handleUpdateNodeData({
|
||||||
|
config: {
|
||||||
|
...(selectedNode.data.config || {}),
|
||||||
|
json_schema: schema,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleUpdateNodeData, selectedNode],
|
||||||
|
);
|
||||||
|
|
||||||
const handleUpload = useCallback((files: File[]) => {
|
const handleUpload = useCallback((files: File[]) => {
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
setImageFile(files[0]);
|
setImageFile(files[0]);
|
||||||
@@ -781,6 +821,29 @@ function WorkflowBuilderInner() {
|
|||||||
});
|
});
|
||||||
}, [selectedNode]);
|
}, [selectedNode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedNode || selectedNode.type !== 'code') return;
|
||||||
|
const nodeId = selectedNode.id;
|
||||||
|
const rawSchema = selectedNode.data.config?.json_schema;
|
||||||
|
|
||||||
|
setAgentJsonSchemaDrafts((prev) => {
|
||||||
|
if (prev[nodeId] !== undefined) return prev;
|
||||||
|
if (rawSchema === undefined || rawSchema === null) {
|
||||||
|
return { ...prev, [nodeId]: '' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { ...prev, [nodeId]: JSON.stringify(rawSchema, null, 2) };
|
||||||
|
} catch {
|
||||||
|
return { ...prev, [nodeId]: String(rawSchema) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAgentJsonSchemaErrors((prev) => {
|
||||||
|
if (prev[nodeId] !== undefined) return prev;
|
||||||
|
return { ...prev, [nodeId]: validateCodeJsonSchema(rawSchema) };
|
||||||
|
});
|
||||||
|
}, [selectedNode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAgentDetails = async () => {
|
const loadAgentDetails = async () => {
|
||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
@@ -834,6 +897,10 @@ function WorkflowBuilderInner() {
|
|||||||
};
|
};
|
||||||
} else if (n.type === 'state' && n.data) {
|
} else if (n.type === 'state' && n.data) {
|
||||||
nodeData.config = n.data;
|
nodeData.config = n.data;
|
||||||
|
} else if (n.type === 'code') {
|
||||||
|
nodeData.config = normalizeCodeConfig(
|
||||||
|
n.data as Record<string, unknown> | undefined,
|
||||||
|
);
|
||||||
} else if (n.data) {
|
} else if (n.data) {
|
||||||
Object.assign(nodeData, n.data);
|
Object.assign(nodeData, n.data);
|
||||||
}
|
}
|
||||||
@@ -1089,6 +1156,27 @@ function WorkflowBuilderInner() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const codeNodes = nodes.filter((n) => n.type === 'code');
|
||||||
|
codeNodes.forEach((node) => {
|
||||||
|
const codeTitle = node.data?.title || node.id;
|
||||||
|
const config = node.data?.config;
|
||||||
|
if (!(config?.code || '').trim()) {
|
||||||
|
errors.push(`Code node "${codeTitle}" must have code to run`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaValidationError = validateCodeJsonSchema(config?.json_schema);
|
||||||
|
const draftSchemaError = agentJsonSchemaErrors[node.id];
|
||||||
|
const effectiveSchemaError =
|
||||||
|
draftSchemaError !== undefined
|
||||||
|
? draftSchemaError
|
||||||
|
: schemaValidationError;
|
||||||
|
if (effectiveSchemaError) {
|
||||||
|
errors.push(
|
||||||
|
`Code node "${codeTitle}" JSON schema ${effectiveSchemaError}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}, [workflowName, nodes, edges, agentJsonSchemaErrors, availableModels]);
|
}, [workflowName, nodes, edges, agentJsonSchemaErrors, availableModels]);
|
||||||
|
|
||||||
@@ -1367,6 +1455,31 @@ function WorkflowBuilderInner() {
|
|||||||
return selectedModel.supports_structured_output;
|
return selectedModel.supports_structured_output;
|
||||||
}, [selectedNode, availableModels]);
|
}, [selectedNode, availableModels]);
|
||||||
|
|
||||||
|
const selectedCodeJsonSchemaText = useMemo(() => {
|
||||||
|
if (!selectedNode || selectedNode.type !== 'code') return '';
|
||||||
|
|
||||||
|
const draft = agentJsonSchemaDrafts[selectedNode.id];
|
||||||
|
if (draft !== undefined) return draft;
|
||||||
|
|
||||||
|
const schema = selectedNode.data.config?.json_schema;
|
||||||
|
if (schema === undefined || schema === null) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(schema, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(schema);
|
||||||
|
}
|
||||||
|
}, [selectedNode, agentJsonSchemaDrafts]);
|
||||||
|
|
||||||
|
const selectedCodeJsonSchemaError = useMemo(() => {
|
||||||
|
if (!selectedNode || selectedNode.type !== 'code') return null;
|
||||||
|
|
||||||
|
const cachedError = agentJsonSchemaErrors[selectedNode.id];
|
||||||
|
if (cachedError !== undefined) return cachedError;
|
||||||
|
|
||||||
|
return validateCodeJsonSchema(selectedNode.data.config?.json_schema);
|
||||||
|
}, [selectedNode, agentJsonSchemaErrors]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MobileBlocker />
|
<MobileBlocker />
|
||||||
@@ -1724,6 +1837,23 @@ function WorkflowBuilderInner() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="group border-border bg-card flex cursor-move items-center gap-3 rounded-full border px-4 py-3 shadow-sm transition-all hover:shadow-md"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleNodeDragStart(e, 'code')}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 transition-colors group-hover:bg-indigo-600 group-hover:text-white dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||||
|
<Code2 size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-foreground text-sm font-medium">
|
||||||
|
Code
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Run code in a sandbox
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1765,6 +1895,7 @@ function WorkflowBuilderInner() {
|
|||||||
{selectedNode.type === 'note' && 'Note'}
|
{selectedNode.type === 'note' && 'Note'}
|
||||||
{selectedNode.type === 'state' && 'Set global variables'}
|
{selectedNode.type === 'state' && 'Set global variables'}
|
||||||
{selectedNode.type === 'condition' && 'If / Else'}
|
{selectedNode.type === 'condition' && 'If / Else'}
|
||||||
|
{selectedNode.type === 'code' && 'Code'}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2592,6 +2723,148 @@ function WorkflowBuilderInner() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedNode.type === 'code' && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Run code in the workflow sandbox. Produced
|
||||||
|
files are saved as artifacts.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Code
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={selectedNode.data.config?.code ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateNodeData({
|
||||||
|
config: {
|
||||||
|
...(selectedNode.data.config || {}),
|
||||||
|
code: e.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||||
|
rows={10}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={'print("hello world")'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Inputs
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={stringifyCodeInputs(
|
||||||
|
selectedNode.data.config?.inputs,
|
||||||
|
)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateNodeData({
|
||||||
|
config: {
|
||||||
|
...(selectedNode.data.config || {}),
|
||||||
|
inputs: parseCodeInputs(
|
||||||
|
e.target.value,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||||
|
rows={2}
|
||||||
|
placeholder="One per line: state variable or artifact ref"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Artifact refs or state-variable names, one
|
||||||
|
per line.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Output Variable
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={
|
||||||
|
selectedNode.data.config
|
||||||
|
?.output_variable || ''
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateNodeData({
|
||||||
|
config: {
|
||||||
|
...(selectedNode.data.config || {}),
|
||||||
|
output_variable: e.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||||
|
placeholder="Variable name for output"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Timeout (seconds)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={
|
||||||
|
selectedNode.data.config?.timeout ?? ''
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
const parsed =
|
||||||
|
raw.trim() === ''
|
||||||
|
? undefined
|
||||||
|
: Number.parseInt(raw, 10);
|
||||||
|
handleUpdateNodeData({
|
||||||
|
config: {
|
||||||
|
...(selectedNode.data.config || {}),
|
||||||
|
timeout:
|
||||||
|
parsed !== undefined &&
|
||||||
|
Number.isFinite(parsed)
|
||||||
|
? parsed
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Structured Output (JSON Schema)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={selectedCodeJsonSchemaText}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCodeJsonSchemaChange(e.target.value)
|
||||||
|
}
|
||||||
|
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||||
|
rows={6}
|
||||||
|
placeholder={`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"result": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["result"]
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{selectedCodeJsonSchemaText.trim() !== '' && (
|
||||||
|
<p
|
||||||
|
className={`mt-2 text-xs ${
|
||||||
|
selectedCodeJsonSchemaError
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-green-600 dark:text-green-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedCodeJsonSchemaError
|
||||||
|
? `Invalid JSON schema: ${selectedCodeJsonSchemaError}`
|
||||||
|
: 'Valid JSON schema'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2629,10 +2902,20 @@ function WorkflowBuilderInner() {
|
|||||||
.filter((n) => n.type !== 'note')
|
.filter((n) => n.type !== 'note')
|
||||||
.map((n) => ({
|
.map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: n.type as 'start' | 'end' | 'agent' | 'state',
|
type: n.type as
|
||||||
|
| 'start'
|
||||||
|
| 'end'
|
||||||
|
| 'agent'
|
||||||
|
| 'state'
|
||||||
|
| 'code',
|
||||||
title: n.data.title || n.data.label || n.type,
|
title: n.data.title || n.data.label || n.type,
|
||||||
position: n.position,
|
position: n.position,
|
||||||
data: n.type === 'agent' ? n.data.config : n.data,
|
data:
|
||||||
|
n.type === 'code'
|
||||||
|
? serializeCodeConfig(n.data.config)
|
||||||
|
: n.type === 'agent'
|
||||||
|
? n.data.config
|
||||||
|
: n.data,
|
||||||
})),
|
})),
|
||||||
edges: edges.map((e) => ({
|
edges: edges.map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Circle,
|
Circle,
|
||||||
|
Code2,
|
||||||
Database,
|
Database,
|
||||||
Flag,
|
Flag,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
@@ -56,6 +57,7 @@ const NODE_ICONS: Record<string, React.ReactNode> = {
|
|||||||
note: <StickyNote className="h-3 w-3" />,
|
note: <StickyNote className="h-3 w-3" />,
|
||||||
state: <Database className="h-3 w-3" />,
|
state: <Database className="h-3 w-3" />,
|
||||||
condition: <GitBranch className="h-3 w-3" />,
|
condition: <GitBranch className="h-3 w-3" />,
|
||||||
|
code: <Code2 className="h-3 w-3" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NODE_COLORS: Record<string, string> = {
|
const NODE_COLORS: Record<string, string> = {
|
||||||
@@ -65,6 +67,7 @@ const NODE_COLORS: Record<string, string> = {
|
|||||||
note: 'text-yellow-600 dark:text-yellow-400',
|
note: 'text-yellow-600 dark:text-yellow-400',
|
||||||
state: 'text-blue-600 dark:text-blue-400',
|
state: 'text-blue-600 dark:text-blue-400',
|
||||||
condition: 'text-orange-600 dark:text-orange-400',
|
condition: 'text-orange-600 dark:text-orange-400',
|
||||||
|
code: 'text-indigo-600 dark:text-indigo-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
function ExecutionDetails({
|
function ExecutionDetails({
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDefaultCodeConfig,
|
||||||
|
normalizeCodeConfig,
|
||||||
|
parseCodeInputs,
|
||||||
|
parseCodeJsonSchemaDraft,
|
||||||
|
serializeCodeConfig,
|
||||||
|
stringifyCodeInputs,
|
||||||
|
validateCodeJsonSchema,
|
||||||
|
} from './codeNodeConfig';
|
||||||
|
|
||||||
|
describe('validateCodeJsonSchema', () => {
|
||||||
|
it('treats undefined/null as valid (no schema)', () => {
|
||||||
|
expect(validateCodeJsonSchema(undefined)).toBeNull();
|
||||||
|
expect(validateCodeJsonSchema(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects arrays and non-objects', () => {
|
||||||
|
expect(validateCodeJsonSchema([])).toMatch(/valid JSON object/);
|
||||||
|
expect(validateCodeJsonSchema('x')).toMatch(/valid JSON object/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a type or schema field', () => {
|
||||||
|
expect(validateCodeJsonSchema({})).toMatch(/"type" or "schema"/);
|
||||||
|
expect(validateCodeJsonSchema({ type: 'object' })).toBeNull();
|
||||||
|
expect(validateCodeJsonSchema({ schema: {} })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseCodeJsonSchemaDraft', () => {
|
||||||
|
it('returns undefined schema and no error for blank input', () => {
|
||||||
|
expect(parseCodeJsonSchemaDraft(' ')).toEqual({
|
||||||
|
schema: undefined,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags invalid JSON', () => {
|
||||||
|
const result = parseCodeJsonSchemaDraft('{ not json');
|
||||||
|
expect(result.schema).toBeUndefined();
|
||||||
|
expect(result.error).toMatch(/valid JSON/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags structurally invalid schema', () => {
|
||||||
|
const result = parseCodeJsonSchemaDraft('{"foo": 1}');
|
||||||
|
expect(result.schema).toBeUndefined();
|
||||||
|
expect(result.error).toMatch(/"type" or "schema"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a valid schema', () => {
|
||||||
|
const result = parseCodeJsonSchemaDraft(
|
||||||
|
'{"type":"object","properties":{}}',
|
||||||
|
);
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.schema).toEqual({ type: 'object', properties: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inputs (de)serialization', () => {
|
||||||
|
it('splits on newlines and commas and trims', () => {
|
||||||
|
expect(parseCodeInputs('A1\nfoo, bar\n\n baz ')).toEqual([
|
||||||
|
'A1',
|
||||||
|
'foo',
|
||||||
|
'bar',
|
||||||
|
'baz',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through stringify', () => {
|
||||||
|
const inputs = ['A1', 'state_var'];
|
||||||
|
expect(parseCodeInputs(stringifyCodeInputs(inputs))).toEqual(inputs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stringifies undefined to empty string', () => {
|
||||||
|
expect(stringifyCodeInputs(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDefaultCodeConfig', () => {
|
||||||
|
it('returns python defaults with empty code and inputs', () => {
|
||||||
|
expect(createDefaultCodeConfig()).toEqual({
|
||||||
|
code: '',
|
||||||
|
language: 'python',
|
||||||
|
inputs: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('serializeCodeConfig', () => {
|
||||||
|
it('omits optional fields when empty', () => {
|
||||||
|
const serialized = serializeCodeConfig({
|
||||||
|
code: 'print(1)',
|
||||||
|
language: 'python',
|
||||||
|
inputs: ['', ' '],
|
||||||
|
output_variable: ' ',
|
||||||
|
});
|
||||||
|
expect(serialized).toEqual({
|
||||||
|
code: 'print(1)',
|
||||||
|
language: 'python',
|
||||||
|
inputs: [],
|
||||||
|
});
|
||||||
|
expect('output_variable' in serialized).toBe(false);
|
||||||
|
expect('timeout' in serialized).toBe(false);
|
||||||
|
expect('json_schema' in serialized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps all CodeNodeConfig fields when present', () => {
|
||||||
|
const serialized = serializeCodeConfig({
|
||||||
|
code: 'print(1)',
|
||||||
|
language: 'python',
|
||||||
|
inputs: ['A1', 'state_var'],
|
||||||
|
output_variable: 'result',
|
||||||
|
timeout: 30,
|
||||||
|
json_schema: { type: 'object' },
|
||||||
|
});
|
||||||
|
expect(serialized).toEqual({
|
||||||
|
code: 'print(1)',
|
||||||
|
language: 'python',
|
||||||
|
inputs: ['A1', 'state_var'],
|
||||||
|
output_variable: 'result',
|
||||||
|
timeout: 30,
|
||||||
|
json_schema: { type: 'object' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to python defaults for an undefined config', () => {
|
||||||
|
expect(serializeCodeConfig(undefined)).toEqual({
|
||||||
|
code: '',
|
||||||
|
language: 'python',
|
||||||
|
inputs: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeCodeConfig', () => {
|
||||||
|
it('tolerates a missing/empty payload', () => {
|
||||||
|
expect(normalizeCodeConfig(undefined)).toEqual({
|
||||||
|
code: '',
|
||||||
|
language: 'python',
|
||||||
|
inputs: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces bad field types without throwing', () => {
|
||||||
|
const config = normalizeCodeConfig({
|
||||||
|
code: 42,
|
||||||
|
language: '',
|
||||||
|
inputs: ['A1', 5, 'B2'],
|
||||||
|
output_variable: '',
|
||||||
|
timeout: 'soon',
|
||||||
|
json_schema: [],
|
||||||
|
} as unknown as Record<string, unknown>);
|
||||||
|
expect(config).toEqual({
|
||||||
|
code: '',
|
||||||
|
language: 'python',
|
||||||
|
inputs: ['A1', 'B2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a fully populated saved config (round-trip with serialize)', () => {
|
||||||
|
const saved = {
|
||||||
|
code: 'print(1)',
|
||||||
|
language: 'python',
|
||||||
|
inputs: ['A1'],
|
||||||
|
output_variable: 'result',
|
||||||
|
timeout: 30,
|
||||||
|
json_schema: { type: 'object' },
|
||||||
|
};
|
||||||
|
const normalized = normalizeCodeConfig(saved);
|
||||||
|
expect(serializeCodeConfig(normalized)).toEqual(saved);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { CodeNodeConfig } from '../types/workflow';
|
||||||
|
|
||||||
|
const DEFAULT_LANGUAGE = 'python';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a parsed JSON schema object the same way the agent node does:
|
||||||
|
* it must be a non-array object that declares a "type" or "schema" field.
|
||||||
|
* Returns null when valid (or absent), otherwise a short error fragment.
|
||||||
|
*/
|
||||||
|
export function validateCodeJsonSchema(schema: unknown): string | null {
|
||||||
|
if (schema === undefined || schema === null) return null;
|
||||||
|
if (typeof schema !== 'object' || Array.isArray(schema)) {
|
||||||
|
return 'must be a valid JSON object';
|
||||||
|
}
|
||||||
|
const schemaObject = schema as Record<string, unknown>;
|
||||||
|
if (!('schema' in schemaObject) && !('type' in schemaObject)) {
|
||||||
|
return 'must include either a "type" or "schema" field';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the raw json_schema textarea content. Returns the parsed schema (or
|
||||||
|
* undefined when blank) and a validation error message when invalid.
|
||||||
|
*/
|
||||||
|
export function parseCodeJsonSchemaDraft(text: string): {
|
||||||
|
schema: Record<string, unknown> | undefined;
|
||||||
|
error: string | null;
|
||||||
|
} {
|
||||||
|
if (text.trim() === '') {
|
||||||
|
return { schema: undefined, error: null };
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { schema: undefined, error: 'must be valid JSON' };
|
||||||
|
}
|
||||||
|
const error = validateCodeJsonSchema(parsed);
|
||||||
|
if (error) {
|
||||||
|
return { schema: undefined, error };
|
||||||
|
}
|
||||||
|
return { schema: parsed as Record<string, unknown>, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a newline/comma separated inputs textarea into a clean ref list. */
|
||||||
|
export function parseCodeInputs(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render an inputs list back into the textarea (one ref per line). */
|
||||||
|
export function stringifyCodeInputs(inputs: string[] | undefined): string {
|
||||||
|
return (inputs || []).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default config applied when a fresh code node is dropped onto the canvas. */
|
||||||
|
export function createDefaultCodeConfig(): CodeNodeConfig {
|
||||||
|
return {
|
||||||
|
code: '',
|
||||||
|
language: DEFAULT_LANGUAGE,
|
||||||
|
inputs: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an arbitrary loaded config (from a saved workflow) into the editor
|
||||||
|
* shape, tolerating missing/extra fields so existing workflows keep loading.
|
||||||
|
*/
|
||||||
|
export function normalizeCodeConfig(
|
||||||
|
raw: Record<string, unknown> | undefined,
|
||||||
|
): CodeNodeConfig {
|
||||||
|
const source = raw || {};
|
||||||
|
const config: CodeNodeConfig = {
|
||||||
|
code: typeof source.code === 'string' ? source.code : '',
|
||||||
|
language:
|
||||||
|
typeof source.language === 'string' && source.language.trim() !== ''
|
||||||
|
? source.language
|
||||||
|
: DEFAULT_LANGUAGE,
|
||||||
|
inputs: Array.isArray(source.inputs)
|
||||||
|
? source.inputs.filter(
|
||||||
|
(entry): entry is string => typeof entry === 'string',
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
if (typeof source.output_variable === 'string' && source.output_variable) {
|
||||||
|
config.output_variable = source.output_variable;
|
||||||
|
}
|
||||||
|
if (typeof source.timeout === 'number' && Number.isFinite(source.timeout)) {
|
||||||
|
config.timeout = source.timeout;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
source.json_schema &&
|
||||||
|
typeof source.json_schema === 'object' &&
|
||||||
|
!Array.isArray(source.json_schema)
|
||||||
|
) {
|
||||||
|
config.json_schema = source.json_schema as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the editor config into the exact CodeNodeConfig shape the backend
|
||||||
|
* expects. Optional fields are omitted when empty so the payload stays clean.
|
||||||
|
*/
|
||||||
|
export function serializeCodeConfig(
|
||||||
|
config: CodeNodeConfig | undefined,
|
||||||
|
): CodeNodeConfig {
|
||||||
|
const source = config || createDefaultCodeConfig();
|
||||||
|
const serialized: CodeNodeConfig = {
|
||||||
|
code: source.code || '',
|
||||||
|
language: source.language || DEFAULT_LANGUAGE,
|
||||||
|
inputs: (source.inputs || []).filter(
|
||||||
|
(entry) => typeof entry === 'string' && entry.trim() !== '',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (source.output_variable && source.output_variable.trim() !== '') {
|
||||||
|
serialized.output_variable = source.output_variable.trim();
|
||||||
|
}
|
||||||
|
if (typeof source.timeout === 'number' && Number.isFinite(source.timeout)) {
|
||||||
|
serialized.timeout = source.timeout;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
source.json_schema &&
|
||||||
|
typeof source.json_schema === 'object' &&
|
||||||
|
!Array.isArray(source.json_schema)
|
||||||
|
) {
|
||||||
|
serialized.json_schema = source.json_schema;
|
||||||
|
}
|
||||||
|
return serialized;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ interface BaseNodeProps {
|
|||||||
title: string;
|
title: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition';
|
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition' | 'code';
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
handles?: {
|
handles?: {
|
||||||
source?: boolean;
|
source?: boolean;
|
||||||
@@ -42,6 +42,9 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
|
|||||||
} else if (type === 'condition') {
|
} else if (type === 'condition') {
|
||||||
iconBg = 'bg-orange-100 dark:bg-orange-900/30';
|
iconBg = 'bg-orange-100 dark:bg-orange-900/30';
|
||||||
iconColor = 'text-orange-600 dark:text-orange-400';
|
iconColor = 'text-orange-600 dark:text-orange-400';
|
||||||
|
} else if (type === 'code') {
|
||||||
|
iconBg = 'bg-indigo-100 dark:bg-indigo-900/30';
|
||||||
|
iconColor = 'text-indigo-600 dark:text-indigo-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Code2 } from 'lucide-react';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { NodeProps } from 'reactflow';
|
||||||
|
|
||||||
|
import { CodeNodeConfig } from '../../types/workflow';
|
||||||
|
import { BaseNode } from './BaseNode';
|
||||||
|
|
||||||
|
type CodeNodeData = {
|
||||||
|
title?: string;
|
||||||
|
label?: string;
|
||||||
|
config?: Partial<CodeNodeConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
|
||||||
|
const title = data.title || data.label || 'Code';
|
||||||
|
const config = data.config || {};
|
||||||
|
const code = (config.code || '').trim();
|
||||||
|
const firstLine = code.split('\n').find((line) => line.trim() !== '') || '';
|
||||||
|
const codeHint =
|
||||||
|
firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseNode
|
||||||
|
title={title}
|
||||||
|
type="code"
|
||||||
|
selected={selected}
|
||||||
|
icon={<Code2 size={16} />}
|
||||||
|
handles={{ source: true, target: true }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{codeHint ? (
|
||||||
|
<div
|
||||||
|
className="truncate font-mono text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
title={code}
|
||||||
|
>
|
||||||
|
{codeHint}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
No code yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.output_variable && (
|
||||||
|
<div
|
||||||
|
className="truncate text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
title={`Output: ${config.output_variable}`}
|
||||||
|
>
|
||||||
|
Output: {config.output_variable}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BaseNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(CodeNode);
|
||||||
@@ -2,6 +2,7 @@ import { Bot, Flag, Play, StickyNote } from 'lucide-react';
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { BaseNode } from './BaseNode';
|
import { BaseNode } from './BaseNode';
|
||||||
|
import CodeNode from './CodeNode';
|
||||||
import ConditionNode from './ConditionNode';
|
import ConditionNode from './ConditionNode';
|
||||||
import SetStateNode from './SetStateNode';
|
import SetStateNode from './SetStateNode';
|
||||||
|
|
||||||
@@ -145,3 +146,4 @@ export const NoteNode = memo(function NoteNode({
|
|||||||
|
|
||||||
export { SetStateNode };
|
export { SetStateNode };
|
||||||
export { ConditionNode };
|
export { ConditionNode };
|
||||||
|
export { CodeNode };
|
||||||
Reference in new issue
Block a user