mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-05 22:14:12 +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'
|
||||
| 'note'
|
||||
| '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 {
|
||||
name?: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'reactflow/dist/style.css';
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Code2,
|
||||
Database,
|
||||
Flag,
|
||||
GitBranch,
|
||||
@@ -64,10 +65,20 @@ import { getToolDisplayName } from '../../utils/toolUtils';
|
||||
import AgentPageHeader from '../AgentPageHeader';
|
||||
import { Agent } from '../types';
|
||||
import { ConditionCase, WorkflowNode } from '../types/workflow';
|
||||
import {
|
||||
createDefaultCodeConfig,
|
||||
normalizeCodeConfig,
|
||||
parseCodeInputs,
|
||||
parseCodeJsonSchemaDraft,
|
||||
serializeCodeConfig,
|
||||
stringifyCodeInputs,
|
||||
validateCodeJsonSchema,
|
||||
} from './codeNodeConfig';
|
||||
import MobileBlocker from './components/MobileBlocker';
|
||||
import PromptTextArea from './components/PromptTextArea';
|
||||
import {
|
||||
AgentNode,
|
||||
CodeNode,
|
||||
ConditionNode,
|
||||
EndNode,
|
||||
NoteNode,
|
||||
@@ -283,15 +294,18 @@ function createWorkflowPayload(
|
||||
| 'agent'
|
||||
| 'note'
|
||||
| 'state'
|
||||
| 'condition',
|
||||
| 'condition'
|
||||
| 'code',
|
||||
title: node.data.title || node.data.label || node.type,
|
||||
position: node.position,
|
||||
data:
|
||||
node.type === 'agent' ||
|
||||
node.type === 'condition' ||
|
||||
node.type === 'state'
|
||||
? node.data.config
|
||||
: node.data,
|
||||
node.type === 'code'
|
||||
? serializeCodeConfig(node.data.config)
|
||||
: node.type === 'agent' ||
|
||||
node.type === 'condition' ||
|
||||
node.type === 'state'
|
||||
? node.data.config
|
||||
: node.data,
|
||||
})),
|
||||
edges: workflowEdges.map((edge) => ({
|
||||
id: edge.id,
|
||||
@@ -310,6 +324,7 @@ const NODE_TYPES: NodeTypes = {
|
||||
note: NoteNode,
|
||||
state: SetStateNode,
|
||||
condition: ConditionNode,
|
||||
code: CodeNode,
|
||||
};
|
||||
|
||||
function WorkflowBuilderInner() {
|
||||
@@ -515,6 +530,10 @@ function WorkflowBuilderInner() {
|
||||
mode: 'simple',
|
||||
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') {
|
||||
baseNode.data.title = 'Note';
|
||||
baseNode.data.label = 'Note';
|
||||
@@ -615,6 +634,27 @@ function WorkflowBuilderInner() {
|
||||
[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[]) => {
|
||||
if (files && files.length > 0) {
|
||||
setImageFile(files[0]);
|
||||
@@ -781,6 +821,29 @@ function WorkflowBuilderInner() {
|
||||
});
|
||||
}, [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(() => {
|
||||
const loadAgentDetails = async () => {
|
||||
if (!agentId) return;
|
||||
@@ -834,6 +897,10 @@ function WorkflowBuilderInner() {
|
||||
};
|
||||
} else if (n.type === 'state' && 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) {
|
||||
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;
|
||||
}, [workflowName, nodes, edges, agentJsonSchemaErrors, availableModels]);
|
||||
|
||||
@@ -1367,6 +1455,31 @@ function WorkflowBuilderInner() {
|
||||
return selectedModel.supports_structured_output;
|
||||
}, [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 (
|
||||
<>
|
||||
<MobileBlocker />
|
||||
@@ -1724,6 +1837,23 @@ function WorkflowBuilderInner() {
|
||||
</span>
|
||||
</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>
|
||||
@@ -1765,6 +1895,7 @@ function WorkflowBuilderInner() {
|
||||
{selectedNode.type === 'note' && 'Note'}
|
||||
{selectedNode.type === 'state' && 'Set global variables'}
|
||||
{selectedNode.type === 'condition' && 'If / Else'}
|
||||
{selectedNode.type === 'code' && 'Code'}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -2592,6 +2723,148 @@ function WorkflowBuilderInner() {
|
||||
</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>
|
||||
@@ -2629,10 +2902,20 @@ function WorkflowBuilderInner() {
|
||||
.filter((n) => n.type !== 'note')
|
||||
.map((n) => ({
|
||||
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,
|
||||
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) => ({
|
||||
id: e.id,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Code2,
|
||||
Database,
|
||||
Flag,
|
||||
GitBranch,
|
||||
@@ -56,6 +57,7 @@ const NODE_ICONS: Record<string, React.ReactNode> = {
|
||||
note: <StickyNote className="h-3 w-3" />,
|
||||
state: <Database 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> = {
|
||||
@@ -65,6 +67,7 @@ const NODE_COLORS: Record<string, string> = {
|
||||
note: 'text-yellow-600 dark:text-yellow-400',
|
||||
state: 'text-blue-600 dark:text-blue-400',
|
||||
condition: 'text-orange-600 dark:text-orange-400',
|
||||
code: 'text-indigo-600 dark:text-indigo-400',
|
||||
};
|
||||
|
||||
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;
|
||||
children?: ReactNode;
|
||||
selected?: boolean;
|
||||
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition';
|
||||
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition' | 'code';
|
||||
icon?: ReactNode;
|
||||
handles?: {
|
||||
source?: boolean;
|
||||
@@ -42,6 +42,9 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
|
||||
} else if (type === 'condition') {
|
||||
iconBg = 'bg-orange-100 dark:bg-orange-900/30';
|
||||
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 (
|
||||
|
||||
@@ -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 { BaseNode } from './BaseNode';
|
||||
import CodeNode from './CodeNode';
|
||||
import ConditionNode from './ConditionNode';
|
||||
import SetStateNode from './SetStateNode';
|
||||
|
||||
@@ -145,3 +146,4 @@ export const NoteNode = memo(function NoteNode({
|
||||
|
||||
export { SetStateNode };
|
||||
export { ConditionNode };
|
||||
export { CodeNode };
|
||||
Reference in new issue
Block a user