Merge branch 'refs/heads/develop' into fix/memory_bug_fix

This commit is contained in:
lixinyue
2026-01-27 14:31:28 +08:00
27 changed files with 873 additions and 144 deletions

View File

@@ -0,0 +1,3 @@
from app.core.workflow.nodes.code.node import CodeNode
__all__ = ["CodeNode"]

View File

@@ -0,0 +1,50 @@
from typing import Literal
from pydantic import Field, BaseModel
from app.core.workflow.nodes.base_config import BaseNodeConfig, VariableType
class InputVariable(BaseModel):
name: str = Field(
...,
description="variable name"
)
variable: str = Field(
...,
description="variable selector"
)
class OutputVariable(BaseModel):
name: str = Field(
...,
description="variable name"
)
type: VariableType = Field(
...,
description="variable selector"
)
class CodeNodeConfig(BaseNodeConfig):
input_variables: list[InputVariable] = Field(
default_factory=list,
description="input variables"
)
output_variables: list[OutputVariable] = Field(
default_factory=list,
description="output variables"
)
code: str = Field(
default="",
description="code content"
)
language: Literal['python3', 'nodejs'] = Field(
...,
description="language"
)

View File

@@ -0,0 +1,122 @@
import base64
import json
import logging
import re
from string import Template
from textwrap import dedent
from typing import Any
import httpx
from sympy.physics.vector import vlatex
from app.core.workflow.nodes import BaseNode, WorkflowState
from app.core.workflow.nodes.base_config import VariableType
from app.core.workflow.nodes.code.config import CodeNodeConfig
logger = logging.getLogger(__name__)
SCRIPT_TEMPLATE = Template(dedent("""
$code
import json
from base64 import b64decode
# decode and prepare input dict
inputs_obj = json.loads(b64decode('$inputs_variable').decode('utf-8'))
# execute main function
output_obj = main(**inputs_obj)
# convert output to json and print
output_json = json.dumps(output_obj, indent=4)
result = "<<RESULT>>" + output_json + "<<RESULT>>"
print(result)
"""))
class CodeNode(BaseNode):
def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]):
super().__init__(node_config, workflow_config)
self.typed_config: CodeNodeConfig | None = None
def extract_result(self, content: str):
match = re.search(r'<<RESULT>>(.*?)<<RESULT>>', content, re.DOTALL)
if match:
extracted = match.group(1)
exec_result = json.loads(extracted)
result = {}
for output in self.typed_config.output_variables:
value = exec_result.get(output.name)
if value is None:
raise RuntimeError(f"Return value {output.name} does not exist")
match output.type:
case VariableType.STRING:
if not isinstance(value, str):
raise RuntimeError(f"Return value {output.name} should be a string")
case VariableType.BOOLEAN:
if not isinstance(value, bool):
raise RuntimeError(f"Return value {output.name} should be a boolean")
case VariableType.NUMBER:
if not isinstance(value, (int, float)):
raise RuntimeError(f"Return value {output.name} should be a number")
case VariableType.OBJECT:
if not isinstance(value, dict):
raise RuntimeError(f"Return value {output.name} should be a dictionary")
case VariableType.ARRAY_STRING:
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
raise RuntimeError(f"Return value {output.name} should be a list of strings")
case VariableType.ARRAY_NUMBER:
if not isinstance(value, list) or not all(isinstance(v, (int, float)) for v in value):
raise RuntimeError(f"Return value {output.name} should be a list of numbers")
case VariableType.ARRAY_OBJECT:
if not isinstance(value, list) or not all(isinstance(v, dict) for v in value):
raise RuntimeError(f"Return value {output.name} should be a list of dictionaries")
case VariableType.ARRAY_BOOLEAN:
if not isinstance(value, list) or not all(isinstance(v, bool) for v in value):
raise RuntimeError(f"Return value {output.name} should be a list of booleans")
result[output.name] = value
return result
else:
raise RuntimeError("The output of main must be a dictionary")
async def execute(self, state: WorkflowState) -> Any:
self.typed_config = CodeNodeConfig(**self.config)
input_variable_dict = {}
for input_variable in self.typed_config.input_variables:
input_variable_dict[input_variable.name] = self.get_variable(input_variable.variable, state)
code = base64.b64decode(
self.typed_config.code
).decode("utf-8")
input_variable_dict = base64.b64encode(
json.dumps(input_variable_dict).encode("utf-8")
).decode("utf-8")
final_script = SCRIPT_TEMPLATE.substitute(
code=code,
inputs_variable=input_variable_dict,
)
async with httpx.AsyncClient() as client:
response = await client.post(
"http://sandbox:8194/v1/sandbox/run",
headers={
"x-api-key": 'redbear-sandbox'
},
json={
"language": self.typed_config.language,
"code": base64.b64encode(final_script.encode("utf-8")).decode("utf-8"),
"options": {
"enable_network": True
}
}
)
resp = response.json()
match resp['code']:
case 31:
raise RuntimeError("Operation not permitted")
case 0:
return self.extract_result(resp["data"]["stdout"])
case _:
raise Exception(resp["message"])

View File

@@ -10,21 +10,22 @@ from app.core.workflow.nodes.base_config import (
VariableDefinition,
VariableType,
)
from app.core.workflow.nodes.code.config import CodeNodeConfig
from app.core.workflow.nodes.cycle_graph.config import LoopNodeConfig, IterationNodeConfig
from app.core.workflow.nodes.end.config import EndNodeConfig
from app.core.workflow.nodes.http_request.config import HttpRequestNodeConfig
from app.core.workflow.nodes.if_else.config import IfElseNodeConfig
from app.core.workflow.nodes.jinja_render.config import JinjaRenderNodeConfig
from app.core.workflow.nodes.knowledge.config import KnowledgeRetrievalNodeConfig
from app.core.workflow.nodes.llm.config import LLMNodeConfig, MessageConfig
from app.core.workflow.nodes.start.config import StartNodeConfig
from app.core.workflow.nodes.transform.config import TransformNodeConfig
from app.core.workflow.nodes.variable_aggregator.config import VariableAggregatorNodeConfig
from app.core.workflow.nodes.memory.config import MemoryReadNodeConfig, MemoryWriteNodeConfig
from app.core.workflow.nodes.parameter_extractor.config import ParameterExtractorNodeConfig
from app.core.workflow.nodes.question_classifier.config import QuestionClassifierNodeConfig
from app.core.workflow.nodes.start.config import StartNodeConfig
from app.core.workflow.nodes.tool.config import ToolNodeConfig
from app.core.workflow.nodes.memory.config import MemoryReadNodeConfig, MemoryWriteNodeConfig
from app.core.workflow.nodes.transform.config import TransformNodeConfig
from app.core.workflow.nodes.variable_aggregator.config import VariableAggregatorNodeConfig
from app.core.workflow.nodes.cycle_graph.config import LoopNodeConfig, IterationNodeConfig
__all__ = [
# 基础类
"BaseNodeConfig",
@@ -49,5 +50,6 @@ __all__ = [
"QuestionClassifierNodeConfig",
"ToolNodeConfig",
"MemoryReadNodeConfig",
"MemoryWriteNodeConfig"
"MemoryWriteNodeConfig",
"CodeNodeConfig"
]

View File

@@ -10,6 +10,7 @@ from typing import Any, Union
from app.core.workflow.nodes.agent import AgentNode
from app.core.workflow.nodes.assigner import AssignerNode
from app.core.workflow.nodes.base_node import BaseNode
from app.core.workflow.nodes.code import CodeNode
from app.core.workflow.nodes.cycle_graph.node import CycleGraphNode
from app.core.workflow.nodes.end import EndNode
from app.core.workflow.nodes.enums import NodeType
@@ -49,7 +50,8 @@ WorkflowNode = Union[
QuestionClassifierNode,
ToolNode,
MemoryReadNode,
MemoryWriteNode
MemoryWriteNode,
CodeNode
]
@@ -81,6 +83,7 @@ class NodeFactory:
NodeType.TOOL: ToolNode,
NodeType.MEMORY_READ: MemoryReadNode,
NodeType.MEMORY_WRITE: MemoryWriteNode,
NodeType.CODE: CodeNode,
}
@classmethod

View File

@@ -15,7 +15,6 @@ class ExecutionResult:
self.stdout = stdout
self.stderr = stderr
self.exit_code = exit_code
self.error = error
class CodeExecutor(ABC):

View File

@@ -9,12 +9,15 @@ from app.config import SANDBOX_USER_ID, SANDBOX_GROUP_ID, get_config
from app.core.encryption import generate_key, encrypt_code
from app.core.executor import CodeExecutor, ExecutionResult
from app.core.runners.python.settings import check_lib_avaiable, release_lib_binary, LIB_PATH
from app.logger import get_logger
from app.models import RunnerOptions
# Python sandbox prescript template
with open("app/core/runners/python/prescript.py") as f:
PYTHON_PRESCRIPT = f.read()
logger = get_logger()
class PythonRunner(CodeExecutor):
"""Python code runner with security isolation"""
@@ -106,6 +109,7 @@ class PythonRunner(CodeExecutor):
env["ALLOWED_SYSCALLS"] = ",".join(map(str, config.allowed_syscalls))
# Execute with Python interpreter
logger.info(encoded_key)
process = await asyncio.create_subprocess_exec(
config.python_path,
@@ -143,7 +147,6 @@ class PythonRunner(CodeExecutor):
stdout="",
stderr="Execution timeout",
exit_code=-1,
error="Execution timeout"
)
finally:

View File

@@ -37,8 +37,8 @@ async def run_python_code(code: str, preload: str, options: RunnerOptions):
if result.exit_code == -signal.SIGSYS:
return error_response(31, "sandbox security policy violation")
if result.error:
return error_response(-500, result.error)
if result.stderr and result.exit_code != 0:
return error_response(500, result.stderr)
return success_response(RunCodeResponse(
stdout=result.stdout,

View File

@@ -10,6 +10,7 @@ pub static ALLOW_SYSCALLS: &[i32] = &[
libc::SYS_ioctl as i32,
libc::SYS_lseek as i32,
libc::SYS_getdents64 as i32,
libc::SYS_fstat as i32,
// thread
libc::SYS_futex as i32,
@@ -77,7 +78,6 @@ pub static ALLOW_NETWORK_SYSCALLS: &[i32] = &[
libc::SYS_sendmsg as i32,
libc::SYS_sendmmsg as i32,
libc::SYS_getsockopt as i32,
libc::SYS_fstat as i32,
libc::SYS_fcntl as i32,
libc::SYS_fstatfs as i32,
libc::SYS_poll as i32,

View File

@@ -866,7 +866,7 @@ export const en = {
minimumRetention: 'Minimum retention (λ_time)',
minimumRetentionDesc: 'Controls the minimum retention threshold of memory retention',
forgettingRate: 'Forgetting rate (λ_mem)',
forgettingRate: 'Forgetting rate (λ_mem)',
forgettingRateDesc: 'Control the speed of memory forgetting, the higher the value, the faster the forgetting',
offset: 'Offset (offset)',
offsetDesc: 'The offset of the minimum preservation degree',
@@ -934,7 +934,7 @@ export const en = {
number: 'Number',
checkbox: 'Checkbox',
apiVariable: 'API Variable',
displayName: 'Display Name',
maxLength: 'Max Length',
required: 'Required',
@@ -1765,7 +1765,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
externalInteraction: 'External Interaction',
"http-request": 'HTTP Request',
tool: 'Tools',
code_execution: 'Code Execution',
code: 'Code Execution',
"jinja-render": 'Template Rendering',
cognitiveUpgrading: 'Cognitive Upgrading (Innovation)',
'memory-read': 'Memory Retrieval',
@@ -1858,6 +1858,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
'array[number]': 'Array[Number]',
'array[boolean]': 'Array[Boolean]',
'array[object]': 'Array[Object]',
'object': 'Object',
addParams: 'Add Extract Variable',
promptPlaceholder: 'Write prompts here, type "{" to insert variables, type "insert" to insert',
},
@@ -1962,6 +1963,12 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
config_id: 'Memory Configuration',
search_switch: 'Search Mode',
},
'code': {
input_variables: 'Input Variables',
output_variables: 'Output Variables',
refreshTip: '同步函数签名至代码',
},
name: 'Key',
type: 'Type',
value: 'Value',

View File

@@ -1609,11 +1609,6 @@ export const zh = {
loadingEmpty: '内容正在加载中…',
loadingEmptyDesc: '您的内容正在火箭运输中!很快就会降落在您的屏幕上'
},
count: '计数: {{count}}',
increment: '增加',
decrement: '减少',
reset: '重置',
switchLanguage: '切换语言',
home: {
title: '首页',
@@ -1858,7 +1853,7 @@ export const zh = {
externalInteraction: '外部交互',
"http-request": 'HTTP请求',
tool: '工具 (Tool)',
code_execution: '代码执行',
code: '代码执行',
"jinja-render": '模板渲染',
cognitiveUpgrading: '认知升级(创新)',
'memory-read': '记忆提取',
@@ -1952,6 +1947,7 @@ export const zh = {
'array[number]': 'Array[Number]',
'array[boolean]': 'Array[Boolean]',
'array[object]': 'Array[Object]',
'object': 'Object',
addParams: '添加提取变量',
promptPlaceholder: '在此处编写提示,输入“{”插入变量输入“insert”插入',
},
@@ -2056,6 +2052,12 @@ export const zh = {
config_id: '记忆配置',
search_switch: '检索模式',
},
'code': {
input_variables: '输入变量',
output_variables: '输出变量',
refreshTip: '同步函数签名至代码',
},
name: '键',
type: '类型',
value: '值',

View File

@@ -15,22 +15,24 @@ import CharacterCountPlugin from './plugin/CharacterCountPlugin'
import InitialValuePlugin from './plugin/InitialValuePlugin';
import CommandPlugin from './plugin/CommandPlugin';
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
import Python3HighlightPlugin from './plugin/Python3HighlightPlugin';
import JavaScriptHighlightPlugin from './plugin/JavaScriptHighlightPlugin';
import LineNumberPlugin from './plugin/LineNumberPlugin';
import BlurPlugin from './plugin/BlurPlugin';
import { VariableNode } from './nodes/VariableNode'
interface LexicalEditorProps {
export interface LexicalEditorProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
options: Suggestion[];
options?: Suggestion[];
variant?: 'outlined' | 'borderless';
height?: number;
fontSize?: number;
lineHeight?: number;
enableJinja2?: boolean;
size?: 'default' | 'small';
type?: 'input' | 'textarea'
type?: 'input' | 'textarea',
language?: 'string' | 'jinja2' | 'python3' | 'javascript'
}
const theme = {
@@ -54,20 +56,25 @@ const Editor: FC<LexicalEditorProps> =({
placeholder = "请输入内容...",
value = "",
onChange,
options,
options = [],
variant = 'borderless',
enableJinja2 = false,
size = 'default',
type = 'textarea'
type = 'textarea',
language = 'string'
}) => {
const [_count, setCount] = useState(0);
const [enableJinja2, setEnableJinja2] = useState(false)
const [enableLineNumbers, setEnableLineNumbers] = useState(false)
useEffect(() => {
if (enableJinja2) {
const styleId = 'jinja2-styles';
const needsLineNumbers = language === 'jinja2' || language === 'python3' || language === 'javascript';
setEnableJinja2(language === 'jinja2');
setEnableLineNumbers(needsLineNumbers);
if (needsLineNumbers) {
const styleId = 'code-editor-styles';
let existingStyle = document.getElementById(styleId);
if (!existingStyle) {
const style = document.createElement('style');
style.id = styleId;
@@ -119,6 +126,7 @@ const Editor: FC<LexicalEditorProps> =({
}
.editor-content-with-numbers {
white-space: pre-wrap;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.editor-content-with-numbers p {
margin: 0;
@@ -128,7 +136,8 @@ const Editor: FC<LexicalEditorProps> =({
document.head.appendChild(style);
}
}
}, [enableJinja2]);
}, [language])
const initialConfig = {
namespace: 'AutocompleteEditor',
theme: enableJinja2 ? jinja2Theme : theme,
@@ -168,7 +177,7 @@ const Editor: FC<LexicalEditorProps> =({
<div style={{ position: 'relative' }}>
<RichTextPlugin
contentEditable={
enableJinja2 ? (
enableLineNumbers ? (
<div className="editor-with-line-numbers" style={{
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
borderRadius: '6px',
@@ -212,8 +221,8 @@ const Editor: FC<LexicalEditorProps> =({
style={{
minHeight: placeHolderMinheight,
position: 'absolute',
top: enableJinja2 ? '4px' : variant === 'borderless' ? '0' : '6px',
left: enableJinja2 ? '16px' : (variant === 'borderless' ? '0' : '11px'),
top: enableLineNumbers ? '4px' : variant === 'borderless' ? '0' : '6px',
left: enableLineNumbers ? '16px' : (variant === 'borderless' ? '0' : '11px'),
color: '#A8A9AA',
fontSize: fontSize,
lineHeight: placeHolderMinheight,
@@ -227,12 +236,14 @@ const Editor: FC<LexicalEditorProps> =({
/>
<HistoryPlugin />
<CommandPlugin />
{enableJinja2 && <Jinja2HighlightPlugin />}
{enableJinja2 && <LineNumberPlugin />}
{language === 'jinja2' && <Jinja2HighlightPlugin />}
{language === 'python3' && <Python3HighlightPlugin />}
{language === 'javascript' && <JavaScriptHighlightPlugin />}
{enableLineNumbers && <LineNumberPlugin />}
<AutocompletePlugin options={options} enableJinja2={enableJinja2} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<InitialValuePlugin value={value} options={options} enableJinja2={enableJinja2} />
{enableJinja2 && <BlurPlugin />}
{enableLineNumbers && <BlurPlugin />}
</div>
</LexicalComposer>
);

View File

@@ -0,0 +1,164 @@
import { useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { TextNode, $createTextNode, $getSelection, $isRangeSelection } from 'lexical';
const JS_KEYWORDS = new Set([
'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import',
'in', 'instanceof', 'let', 'new', 'return', 'super', 'switch', 'this', 'throw', 'try',
'typeof', 'var', 'void', 'while', 'with', 'yield', 'true', 'false', 'null', 'undefined'
]);
const JavaScriptHighlightPlugin = () => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerNodeTransform(TextNode, (textNode: TextNode) => {
const text = textNode.getTextContent();
if (textNode.hasFormat('code')) return;
if (!needsHighlight(text)) return;
const parent = textNode.getParent();
if (!parent) return;
const selection = $getSelection();
let selectionOffset = null;
if ($isRangeSelection(selection)) {
const anchor = selection.anchor;
if (anchor.getNode() === textNode) {
selectionOffset = anchor.offset;
}
}
const tokens = tokenizeJavaScript(text);
if (tokens.length <= 1) return;
const newNodes = tokens.map(token => {
const newNode = $createTextNode(token.text);
newNode.toggleFormat('code');
switch (token.type) {
case 'keyword':
newNode.setStyle('color: #d73a49; font-weight: 600;');
break;
case 'string':
newNode.setStyle('color: #032f62;');
break;
case 'comment':
newNode.setStyle('color: #6a737d; font-style: italic;');
break;
case 'number':
newNode.setStyle('color: #005cc5; font-weight: 500;');
break;
case 'function':
newNode.setStyle('color: #6f42c1; font-weight: 500;');
break;
}
return newNode;
});
if (newNodes.length > 1) {
textNode.replace(newNodes[0]);
for (let i = 1; i < newNodes.length; i++) {
newNodes[i - 1].insertAfter(newNodes[i]);
}
if (selectionOffset !== null && $isRangeSelection(selection)) {
let currentOffset = 0;
for (const node of newNodes) {
const nodeLength = node.getTextContent().length;
if (currentOffset + nodeLength >= selectionOffset) {
node.select(selectionOffset - currentOffset, selectionOffset - currentOffset);
break;
}
currentOffset += nodeLength;
}
}
}
});
}, [editor]);
return null;
};
function needsHighlight(text: string): boolean {
return /[a-zA-Z0-9_/"'`]/.test(text);
}
function tokenizeJavaScript(text: string): Array<{text: string, type: string}> {
const tokens: Array<{text: string, type: string}> = [];
let i = 0;
while (i < text.length) {
// Single-line comments
if (text.slice(i, i + 2) === '//') {
let start = i;
while (i < text.length && text[i] !== '\n') i++;
tokens.push({ text: text.slice(start, i), type: 'comment' });
continue;
}
// Multi-line comments
if (text.slice(i, i + 2) === '/*') {
let start = i;
i += 2;
while (i < text.length && text.slice(i, i + 2) !== '*/') i++;
if (i < text.length) i += 2;
tokens.push({ text: text.slice(start, i), type: 'comment' });
continue;
}
// Strings
if (text[i] === '"' || text[i] === "'" || text[i] === '`') {
const quote = text[i];
let start = i++;
while (i < text.length) {
if (text[i] === quote && text[i - 1] !== '\\') {
i++;
break;
}
i++;
}
tokens.push({ text: text.slice(start, i), type: 'string' });
continue;
}
// Numbers
if (/\d/.test(text[i])) {
let start = i;
while (i < text.length && /[\d.]/.test(text[i])) i++;
tokens.push({ text: text.slice(start, i), type: 'number' });
continue;
}
// Keywords and identifiers
if (/[a-zA-Z_$]/.test(text[i])) {
let start = i;
while (i < text.length && /[a-zA-Z0-9_$]/.test(text[i])) i++;
const word = text.slice(start, i);
if (JS_KEYWORDS.has(word)) {
tokens.push({ text: word, type: 'keyword' });
} else if (i < text.length && text[i] === '(') {
tokens.push({ text: word, type: 'function' });
} else {
tokens.push({ text: word, type: 'text' });
}
continue;
}
// Other characters
let start = i;
while (i < text.length && !/[a-zA-Z0-9_$/"'`]/.test(text[i])) i++;
if (start < i) {
tokens.push({ text: text.slice(start, i), type: 'text' });
}
}
return tokens;
}
export default JavaScriptHighlightPlugin;

View File

@@ -0,0 +1,159 @@
import { useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { TextNode, $createTextNode, $getSelection, $isRangeSelection } from 'lexical';
const PYTHON_KEYWORDS = new Set([
'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue',
'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import',
'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while',
'with', 'yield'
]);
const Python3HighlightPlugin = () => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerNodeTransform(TextNode, (textNode: TextNode) => {
const text = textNode.getTextContent();
if (textNode.hasFormat('code')) return;
if (!needsHighlight(text)) return;
const parent = textNode.getParent();
if (!parent) return;
const selection = $getSelection();
let selectionOffset = null;
if ($isRangeSelection(selection)) {
const anchor = selection.anchor;
if (anchor.getNode() === textNode) {
selectionOffset = anchor.offset;
}
}
const tokens = tokenizePython(text);
if (tokens.length <= 1) return;
const newNodes = tokens.map(token => {
const newNode = $createTextNode(token.text);
newNode.toggleFormat('code');
switch (token.type) {
case 'keyword':
newNode.setStyle('color: #d73a49; font-weight: 600;');
break;
case 'string':
newNode.setStyle('color: #032f62;');
break;
case 'comment':
newNode.setStyle('color: #6a737d; font-style: italic;');
break;
case 'number':
newNode.setStyle('color: #005cc5; font-weight: 500;');
break;
case 'function':
newNode.setStyle('color: #6f42c1; font-weight: 500;');
break;
}
return newNode;
});
if (newNodes.length > 1) {
textNode.replace(newNodes[0]);
for (let i = 1; i < newNodes.length; i++) {
newNodes[i - 1].insertAfter(newNodes[i]);
}
if (selectionOffset !== null && $isRangeSelection(selection)) {
let currentOffset = 0;
for (const node of newNodes) {
const nodeLength = node.getTextContent().length;
if (currentOffset + nodeLength >= selectionOffset) {
node.select(selectionOffset - currentOffset, selectionOffset - currentOffset);
break;
}
currentOffset += nodeLength;
}
}
}
});
}, [editor]);
return null;
};
function needsHighlight(text: string): boolean {
return /[a-zA-Z0-9_#"']/.test(text);
}
function tokenizePython(text: string): Array<{text: string, type: string}> {
const tokens: Array<{text: string, type: string}> = [];
let i = 0;
while (i < text.length) {
// Comments
if (text[i] === '#') {
let start = i;
while (i < text.length && text[i] !== '\n') i++;
tokens.push({ text: text.slice(start, i), type: 'comment' });
continue;
}
// Strings
if (text[i] === '"' || text[i] === "'") {
const quote = text[i];
let start = i++;
const isTriple = text.slice(start, start + 3) === quote.repeat(3);
if (isTriple) i += 2;
while (i < text.length) {
if (isTriple && text.slice(i, i + 3) === quote.repeat(3)) {
i += 3;
break;
} else if (!isTriple && text[i] === quote && text[i - 1] !== '\\') {
i++;
break;
}
i++;
}
tokens.push({ text: text.slice(start, i), type: 'string' });
continue;
}
// Numbers
if (/\d/.test(text[i])) {
let start = i;
while (i < text.length && /[\d.]/.test(text[i])) i++;
tokens.push({ text: text.slice(start, i), type: 'number' });
continue;
}
// Keywords and identifiers
if (/[a-zA-Z_]/.test(text[i])) {
let start = i;
while (i < text.length && /[a-zA-Z0-9_]/.test(text[i])) i++;
const word = text.slice(start, i);
if (PYTHON_KEYWORDS.has(word)) {
tokens.push({ text: word, type: 'keyword' });
} else if (i < text.length && text[i] === '(') {
tokens.push({ text: word, type: 'function' });
} else {
tokens.push({ text: word, type: 'text' });
}
continue;
}
// Other characters
let start = i;
while (i < text.length && !/[a-zA-Z0-9_#"']/.test(text[i])) i++;
if (start < i) {
tokens.push({ text: text.slice(start, i), type: 'text' });
}
}
return tokens;
}
export default Python3HighlightPlugin;

View File

@@ -0,0 +1,86 @@
import { type FC, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'
import { Button, Form, Input, Divider, Space, Select } from 'antd';
interface OutputListProps {
label: string;
name: string;
extra?: ReactNode;
}
const types = [
'string',
'number',
'boolean',
'array[string]',
'array[number]',
'array[boolean]',
'array[object]',
'object'
]
const OutputList: FC<OutputListProps> = ({ label, name, extra }) => {
const { t } = useTranslation()
return (
<>
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-2">
<div className="rb:text-[12px] rb:font-medium rb:leading-4.5">
{label}
</div>
<Space size={8}>
{extra}
<Button
onClick={() => add({ type: 'string' })}
className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small"
>
+ {t('workflow.config.addVariable')}
</Button>
</Space>
</div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} className="rb:flex rb:items-center rb:gap-1 rb:mb-2">
<Form.Item
{...restField}
name={[name, 'name']}
noStyle
>
<Input
placeholder={t('common.pleaseEnter')}
size="small"
className="rb:w-45!"
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, 'type']}
noStyle
>
<Select
placeholder={t('common.pleaseSelect')}
options={types.map(key => ({
value: key,
label: t(`workflow.config.parameter-extractor.${key}`),
}))}
size="small"
popupMatchSelectWidth={false}
className="rb:w-22!"
/>
</Form.Item>
<div
className="rb:ml-1 rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
onClick={() => remove(name)}
></div>
</div>
))}
</>
)}
</Form.List>
</>
)
};
export default OutputList;

View File

@@ -0,0 +1,128 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Form, Select, Space, Row, Col, Divider, Button, Tooltip } from 'antd'
import { Node } from '@antv/x6'
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
import MappingList from '../MappingList'
import Editor from '../../Editor'
import OutputList from './OutputList'
interface MappingItem {
name?: string
value?: string
}
interface CodeExecutionProps {
options: Suggestion[]
selectedNode: Node
}
const codeTemplate = {
python3: `def main(arg1: str, arg2: str):
return {
"result": arg1 + arg2,
}`,
javascript: `function main({arg1, arg2}) {
return {
result: arg1 + arg2
}
}`
}
const CodeExecution: FC<CodeExecutionProps> = ({ options }) => {
const { t } = useTranslation()
const form = Form.useFormInstance()
const values = Form.useWatch([], form) || {}
const handleRefresh = () => {
const code = form.getFieldValue('code') || ''
const language = form.getFieldValue('language') || 'javascript'
const currentInput = form.getFieldValue('input_variables') || []
// Get input_variables names to replace in code
const inputNames = currentInput.map((item: MappingItem) => item.name).filter(Boolean).join(', ')
let newTemplate = code
if (language === 'javascript') {
// Replace function parameters: function name({arg1, arg2}) or function name(arg1, arg2)
newTemplate = code.replace(
/function(\s+\w+\s*\(\s*)(\{?)([^})]*)\}?(\s*\))/,
(_match: string, prefix: string, brace: string, _params: string, suffix: string) => {
return `function${prefix}${brace}${inputNames}${brace ? '}' : ''}${suffix}`
}
)
} else if (language === 'python3') {
// Replace Python function parameters: def name(arg1, arg2):
newTemplate = code.replace(
/def(\s+\w+\s*\()([^)]*)(\))/,
(_match: string, prefix: string, _params: string, suffix: string) => {
return `def${prefix}${inputNames}${suffix}`
}
)
}
form.setFieldValue('code', newTemplate)
}
const handleChangeLanguage = (value: string) => {
form.setFieldValue('code', codeTemplate[value as keyof typeof codeTemplate])
form.setFieldsValue({
input_variables: [{ name: 'arg1' }, { name: 'arg2' }],
code: codeTemplate[value as keyof typeof codeTemplate]
})
}
return (
<>
<Form.Item name="input_variables" noStyle>
<MappingList
label={t('workflow.config.code.input_variables')}
name="input_variables"
options={options}
valueKey="variable"
extra={<Tooltip title={t('workflow.config.code.refreshTip')}>
<Button
onClick={handleRefresh}
className="rb:py-0! rb:px-1.5! rb:text-[12px]! rb:group"
size="small"
>
<div onClick={handleRefresh} className="rb:size-3 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/refresh.svg')] rb:group-hover:bg-[url('@/assets/images/refresh_hover.svg')]"></div>
</Button>
</Tooltip>}
/>
</Form.Item>
<Space size={8} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5">
<Row>
<Col span={12}>
<Form.Item name="language" noStyle>
<Select
options={[
{ label: 'PYTHON3', value: 'python3' },
{ label: 'JAVASCRIPT', value: 'javascript' }
]}
popupMatchSelectWidth={false}
className="rb:font-medium!"
onChange={handleChangeLanguage}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="code" noStyle>
<Editor size="small" language={values.language} />
</Form.Item>
</Space>
<Divider />
<Form.Item name="output_variables" noStyle>
<OutputList
label={t('workflow.config.code.output_variables')}
name="output_variables"
/>
</Form.Item>
</>
)
}
export default CodeExecution

View File

@@ -144,6 +144,7 @@ const EditableTable: React.FC<EditableTableProps> = ({
icon={block ? undefined : <PlusOutlined />}
onClick={() => add(createNewRow())}
size="small"
block={block}
className={block ? "rb:mt-1 rb:text-[12px]! rb:bg-transparent!" : "rb:text-[12px]!"}
>
{block && `+${t('common.add')}`}
@@ -155,7 +156,7 @@ const EditableTable: React.FC<EditableTableProps> = ({
{title && (
<div className="rb:flex rb:items-center rb:mb-2 rb:justify-between">
<div className="rb:font-medium rb:text-[12px] rb:leading-4.5">{title}</div>
<AddButton block={true} />
<AddButton block={false} />
</div>
)}

View File

@@ -196,6 +196,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType.includes('file'))}
filterBooleanType={true}
size="small"
/>
</Form.Item>
}

View File

@@ -175,7 +175,7 @@ const JinjaRender: FC<JinjaRenderProps> = ({ selectedNode, options, templateOpti
return (
<>
<Form.Item name="mapping" noStyle>
<MappingList name="mapping" options={options} />
<MappingList label={t('workflow.config.jinja-render.mapping')} name="mapping" options={options} />
</Form.Item>
<Form.Item name="template">
@@ -184,7 +184,7 @@ const JinjaRender: FC<JinjaRenderProps> = ({ selectedNode, options, templateOpti
title={t('workflow.config.jinja-render.template')}
isArray={false}
parentName="template"
enableJinja2={true}
language="jinja2"
options={templateOptions}
titleVariant="borderless"
size="small"

View File

@@ -1,14 +1,17 @@
import React from 'react';
import { type FC, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'
import { Button, Form, Input, Divider } from 'antd';
import { Button, Form, Input, Divider, Space } from 'antd';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
import VariableSelect from '../VariableSelect'
interface MappingListProps {
label: string;
name: string;
options: Suggestion[];
extra?: ReactNode;
valueKey?: string;
}
const MappingList: React.FC<MappingListProps> = ({ name, options }) => {
const MappingList: FC<MappingListProps> = ({ label, name, options, extra, valueKey = 'value' }) => {
const { t } = useTranslation()
return (
<>
@@ -17,16 +20,19 @@ const MappingList: React.FC<MappingListProps> = ({ name, options }) => {
<>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-2">
<div className="rb:text-[12px] rb:font-medium rb:leading-4.5">
{t('workflow.config.jinja-render.mapping')}
{label}
</div>
<Button
onClick={() => add()}
className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small"
>
+ {t('workflow.config.addVariable')}
</Button>
<Space size={8}>
{extra}
<Button
onClick={() => add()}
className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small"
>
+ {t('workflow.config.addVariable')}
</Button>
</Space>
</div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} className="rb:flex rb:items-center rb:gap-1 rb:mb-2">
@@ -43,7 +49,7 @@ const MappingList: React.FC<MappingListProps> = ({ name, options }) => {
</Form.Item>
<Form.Item
{...restField}
name={[name, 'value']}
name={[name, valueKey]}
noStyle
>
<VariableSelect

View File

@@ -1,20 +1,20 @@
import { type FC, useMemo } from 'react';
import { type FC, type ReactNode, useMemo } from 'react';
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { Input, Form, Space, Button, Row, Col, Select, type FormListOperation } from 'antd';
import Editor from '../Editor'
import Editor, { type LexicalEditorProps } from '../Editor'
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
interface MessageEditor {
options: Suggestion[];
title?: string;
options?: Suggestion[];
title?: string | ReactNode;
titleVariant?: 'outlined' | 'borderless';
isArray?: boolean;
parentName?: string | string[];
label?: string;
placeholder?: string;
value?: string;
enableJinja2?: boolean;
language?: LexicalEditorProps['language'];
onChange?: (value?: string) => void;
size?: 'small' | 'default'
}
@@ -29,8 +29,8 @@ const MessageEditor: FC<MessageEditor> = ({
isArray = true,
parentName = 'messages',
placeholder,
options,
enableJinja2 = false,
options = [],
language,
size = 'default'
}) => {
const { t } = useTranslation()
@@ -81,13 +81,15 @@ const MessageEditor: FC<MessageEditor> = ({
<Space size={8} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5" data-editor-type={parentName === 'template' ? 'template' : undefined}>
<Row>
<Col span={12}>
<div className={clsx("rb:text-[12px] rb:font-medium rb:py-1 rb:leading-2", {
{typeof title === 'string'
? <div className={clsx("rb:text-[12px] rb:font-medium rb:py-1 rb:leading-2", {
'rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-sm rb:px-2': titleVariant === 'outlined'
})}>{title ?? t('workflow.answerDesc')}</div>
: title}
</Col>
</Row>
<Form.Item name={parentName} noStyle>
<Editor size={size} enableJinja2={enableJinja2} placeholder={placeholder} options={processedOptions} />
<Editor size={size} language={language} placeholder={placeholder} options={processedOptions} />
</Form.Item>
</Space>
);
@@ -132,7 +134,7 @@ const MessageEditor: FC<MessageEditor> = ({
)}
</Row>
<Form.Item {...restField} name={[name, 'content']} noStyle>
<Editor size={size} enableJinja2={enableJinja2} placeholder={placeholder} options={processedOptions} />
<Editor size={size} language={language} placeholder={placeholder} options={processedOptions} />
</Form.Item>
</Space>
);

View File

@@ -68,7 +68,7 @@ const processNodeVariables = (
if (p?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${p.name}`, p.name, p.type || 'string', `${dataNodeId}.${p.name}`, nodeData);
});
break;
case 'var-aggregator':
if (config.group.defaultValue) {
(config.group_variables.defaultValue || []).forEach((gv: any) => {
@@ -106,6 +106,11 @@ const processNodeVariables = (
if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData);
});
break;
case 'code':
(config.output_variables.defaultValue || []).forEach((cv: any) => {
if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData);
});
break;
}
};

View File

@@ -26,9 +26,10 @@ import MemoryConfig from './MemoryConfig'
import VariableList from './VariableList'
import { useVariableList, getCurrentNodeVariables, getChildNodeVariables } from './hooks/useVariableList'
import styles from './properties.module.css'
import Editor from "../Editor";
import Editor, { type LexicalEditorProps } from "../Editor";
import RbSlider from './RbSlider'
import JinjaRender from './JinjaRender'
import CodeExecution from './CodeExecution'
interface PropertiesProps {
selectedNode?: Node | null;
@@ -364,6 +365,11 @@ const Properties: FC<PropertiesProps> = ({
options={getFilteredVariableList(selectedNode?.data?.type, 'mapping')}
templateOptions={getFilteredVariableList(selectedNode?.data?.type, 'template')}
/>
: selectedNode?.data?.type === 'code'
? <CodeExecution
selectedNode={selectedNode}
options={getFilteredVariableList(selectedNode?.data?.type, 'mapping')}
/>
: configs && Object.keys(configs).length > 0 && Object.keys(configs).map((key) => {
const config = configs[key] || {}
@@ -438,7 +444,7 @@ const Properties: FC<PropertiesProps> = ({
title={t(`workflow.config.${selectedNode?.data?.type}.${key}`)}
isArray={!!config.isArray}
parentName={key}
enableJinja2={config.enableJinja2 as boolean}
language={config.language as LexicalEditorProps['language']}
options={getFilteredVariableList(selectedNode?.data?.type, key)}
titleVariant={config.titleVariant}
size="small"

View File

@@ -87,4 +87,7 @@
.properties :global(.ant-select .ant-select-arrow) {
font-size: 10px;
inset-inline-end: 6px;
}
.properties :global(.ant-input-sm) {
padding: 3.6px 7px;
}

View File

@@ -284,7 +284,7 @@ export const nodeLibrary: NodeLibrary[] = [
config: {
input: {
type: 'variableList',
filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop', 'parameter-extractor'],
filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop', 'parameter-extractor', 'code'],
filterVariableNames: ['message']
},
parallel: {
@@ -431,7 +431,32 @@ export const nodeLibrary: NodeLibrary[] = [
}
}
},
// { type: "code_execution", icon: codeExecutionIcon },
{ type: "code", icon: codeExecutionIcon,
config: {
input_variables: {
type: 'inputList',
defaultValue: [{ name: 'arg1' }, { name: 'arg2' }]
},
language: {
type: 'select',
defaultValue: 'python3'
},
code: {
type: 'messageEditor',
isArray: false,
language: ['python3', 'javascript'],
titleVariant: 'borderless',
defaultValue: `def main(arg1: str, arg2: str):
return {
"result": arg1 + arg2,
}`
},
output_variables: {
type: 'outputList',
defaultValue: [{name: 'result', type: 'string'}]
},
}
},
{ type: "jinja-render", icon: templateRenderingIcon,
config: {
mapping: {
@@ -441,12 +466,12 @@ export const nodeLibrary: NodeLibrary[] = [
template: {
type: 'messageEditor',
isArray: false,
enableJinja2: true,
language: 'jinja2',
titleVariant: 'borderless',
defaultValue: "{{arg1}}"
},
}
}
},
]
},
// {

View File

@@ -109,6 +109,12 @@ export const useWorkflowGraph = ({
: group_variables
} else if (type === 'http-request' && (key === 'headers' || key === 'params') && config[key] && typeof config[key] === 'object' && !Array.isArray(config[key]) && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([name, value]) => ({ name, value }))
} else if (type === 'code' && key === 'code' && config[key] && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
try {
nodeLibraryConfig.config[key].defaultValue = atob(config[key] as string)
} catch {
nodeLibraryConfig.config[key].defaultValue = config[key]
}
} else if (nodeLibraryConfig.config && nodeLibraryConfig.config[key] && config[key]) {
nodeLibraryConfig.config[key].defaultValue = config[key]
}
@@ -588,77 +594,6 @@ export const useWorkflowGraph = ({
graphRef.current.resize(containerRef.current.offsetWidth, containerRef.current.offsetHeight);
}
};
const nodeChangePosition = ({ node, options }: { node: Node; options: { skipParentHandler?: boolean } }) => {
const embedPadding = 50; // Define the embed padding constant
if (options.skipParentHandler) {
return
}
const children = node.getChildren()
if (children && children.length) {
node.prop('originPosition', node.getPosition())
}
const parent = node.getParent()
if (parent && parent.isNode()) {
let originSize = parent.prop('originSize')
if (originSize == null) {
originSize = parent.getSize()
parent.prop('originSize', originSize)
}
let originPosition = parent.prop('originPosition')
if (originPosition == null) {
originPosition = parent.getPosition()
parent.prop('originPosition', originPosition)
}
let x = originPosition.x
let y = originPosition.y
let cornerX = originPosition.x + originSize.width
let cornerY = originPosition.y + originSize.height
let hasChange = false
const children = parent.getChildren()
if (children) {
children.forEach((child) => {
const bbox = child.getBBox().inflate(embedPadding)
const corner = bbox.getCorner()
if (bbox.x < x) {
x = bbox.x
hasChange = true
}
if (bbox.y < y) {
y = bbox.y
hasChange = true
}
if (corner.x > cornerX) {
cornerX = corner.x
hasChange = true
}
if (corner.y > cornerY) {
cornerY = corner.y
hasChange = true
}
})
}
if (hasChange) {
parent.prop(
{
position: { x, y },
size: { width: cornerX - x, height: cornerY - y },
},
{ skipParentHandler: true },
)
}
}
}
// 初始化
const init = () => {
@@ -912,7 +847,13 @@ export const useWorkflowGraph = ({
if (data.config) {
Object.keys(data.config).forEach(key => {
if (key === 'memory' && data.config[key] && 'defaultValue' in data.config[key]) {
if (data.type === 'code' && key === 'code' && data.config[key] && 'defaultValue' in data.config[key]) {
const code = data.config[key].defaultValue || ''
itemConfig = {
...itemConfig,
code: btoa(code || '')
}
} else if (key === 'memory' && data.config[key] && 'defaultValue' in data.config[key]) {
const { messages, ...rest } = data.config[key].defaultValue
let memoryMessage = { role: 'USER', content: data.config[key].defaultValue.messages }
itemConfig = {