From d269a0008ffb260c375c81f3128f4b8e5f290bac Mon Sep 17 00:00:00 2001 From: shaohuzhang1 Date: Fri, 13 Nov 2026 17:48:11 +0800 Subject: [PATCH] fix: Optimize knowledge base; AI-driven generation of tools and workflows --- .../tests/test_workflow_loop_variables.py | 146 +++++++ .../workflow/loop_workflow_manage.py | 86 +++- .../workflow/nodes/loop_node/loop_node.py | 27 +- .../nodes/loop_start_node/loop_start_node.py | 3 - .../variable_assign_node.py | 40 +- apps/application/workflow/workflow_manage.py | 19 +- ui/src/workflow-canvas/WORKFLOW_README.md | 23 +- ui/src/workflow-canvas/ai-generate/common.ts | 20 + .../ai-generate/create-agent.ts | 2 +- .../application/index.ts} | 85 +--- .../workflow-canvas/ai-generate/impl/index.ts | 20 + .../ai-generate/impl/knowledge/index.ts | 128 ++++++ .../ai-generate/impl/shared.ts | 89 ++++ .../ai-generate/impl/tool/index.ts | 111 +++++ .../ai-generate/panel/AiGeneratePanel.vue | 39 +- .../ai-generate/panel/activity.ts | 3 + .../ai-generate/tools/canvas.ts | 28 +- .../ai-generate/tools/index.ts | 393 +++++++++++++++--- .../workflow-canvas/ai-generate/tools/node.ts | 53 ++- ui/src/workflow-canvas/ai-generate/type.ts | 7 + .../nodes/data-source-local-node/agent.ts | 93 +++++ .../nodes/data-source-local-node/index.ts | 2 + .../nodes/data-source-web-node/agent.ts | 47 +++ .../nodes/data-source-web-node/index.ts | 9 +- .../nodes/document-extract-node/agent.ts | 50 ++- .../nodes/document-split-node/agent.ts | 154 +++++++ .../nodes/document-split-node/index.ts | 2 + .../nodes/knowledge-write-node/agent.ts | 132 ++++++ .../nodes/knowledge-write-node/index.ts | 2 + .../workflow-canvas/nodes/loop-node/agent.ts | 71 +++- .../component/LoopFieldTable.vue | 20 +- .../nodes/tool-base-node/agent.ts | 174 ++++++++ .../nodes/tool-base-node/index.ts | 3 +- .../nodes/tool-custom-node/agent.ts | 9 + .../nodes/variable-assign-node/agent.ts | 95 ++++- .../store/api/system-resource/index.ts | 9 + .../store/api/system-shared/index.ts | 9 + .../store/api/workspace/index.ts | 9 + ui/src/workflow-canvas/store/index.ts | 7 + 39 files changed, 1983 insertions(+), 236 deletions(-) create mode 100644 apps/application/tests/test_workflow_loop_variables.py rename ui/src/workflow-canvas/ai-generate/{application.ts => impl/application/index.ts} (65%) create mode 100644 ui/src/workflow-canvas/ai-generate/impl/index.ts create mode 100644 ui/src/workflow-canvas/ai-generate/impl/knowledge/index.ts create mode 100644 ui/src/workflow-canvas/ai-generate/impl/shared.ts create mode 100644 ui/src/workflow-canvas/ai-generate/impl/tool/index.ts create mode 100644 ui/src/workflow-canvas/nodes/data-source-local-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/data-source-web-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/document-split-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/knowledge-write-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/tool-base-node/agent.ts diff --git a/apps/application/tests/test_workflow_loop_variables.py b/apps/application/tests/test_workflow_loop_variables.py new file mode 100644 index 00000000000..7e94fd0ad4e --- /dev/null +++ b/apps/application/tests/test_workflow_loop_variables.py @@ -0,0 +1,146 @@ +import threading + +from django.test import SimpleTestCase + +from application.workflow.common import WorkflowType, new_instance +from application.workflow.nodes import get_node_class +from application.workflow.nodes.loop_node.loop_node import LoopNodeSerializer +from application.workflow.workflow_manage import CallBack, WorkflowManage +from common.exception.app_exception import AppApiException + + +def _node(node_id, node_type, node_data=None, **properties): + return { + "id": node_id, + "type": node_type, + "x": 0, + "y": 0, + "properties": {"stepName": node_id, "node_data": node_data or {}, **properties}, + } + + +def _edge(source, target): + return { + "id": f"{source}-{target}", + "type": "app-edge", + "sourceNodeId": source, + "targetNodeId": target, + "sourceAnchorId": f"{source}_right", + "targetAnchorId": f"{target}_left", + } + + +def _loop_start(*variables): + return _node( + "loop-start-node", + "loop-start-node", + stepName="循环开始", + config={"fields": [{"label": "下标", "value": "index"}, {"label": "循环元素", "value": "item"}]}, + loop_input_field_list=[{"field": field, "label": field} for field in variables], + ) + + +def _assign(node_id, *variables): + return _node( + node_id, + "variable-assign-node", + { + "variable_list": [ + {"name": ".".join(fields), "fields": fields, "source": "custom", "type": "string", "value": value} + for fields, value in variables + ] + }, + ) + + +def _loop(node_id, number, body_nodes, body_edges): + return _node( + node_id, + "loop-node", + {"loop_type": "NUMBER", "number": number, "loop_body": {"nodes": body_nodes, "edges": body_edges}}, + ) + + +def _run(loop_node): + """以循环节点作为起始节点跑完一个根工作流,返回根 WorkflowManage""" + workflow = new_instance({"nodes": [loop_node], "edges": []}) + finished = threading.Event() + errors = [] + + def on_complete(_manage, error): + errors.append(error) + finished.set() + + def get_start_node(wf, manage): + node = wf.get_node(loop_node["id"]) + node_class = get_node_class(node.type, WorkflowType.APPLICATION) + return node_class(node, manage, lambda n: n.properties.get("node_data", {})) + + def on_next(wf_manage, _content): + # 知识库工作流每收到一块输出就取一次详情,第一轮循环尚未结束时也必须可用 + wf_manage.get_details() + + manage = WorkflowManage(workflow, {}, WorkflowType.APPLICATION, CallBack(on_next, on_complete), get_start_node) + manage.run() + assert finished.wait(10), "workflow did not finish" + assert errors == [None], errors + return manage + + +class LoopVariableTests(SimpleTestCase): + def test_loop_variable_is_shared_across_iterations_and_readable_after_the_loop(self): + loop = _loop( + "loop1", + 3, + [ + _loop_start("total"), + _assign( + "assign", + (["loop", "total"], "{{loop.total}}{{循环开始.index}}"), + (["global", "last"], "{{循环变量.total}}"), + ), + ], + [_edge("loop-start-node", "assign")], + ) + + manage = _run(loop) + + # 循环变量存放在循环节点的上下文里,下游用 [循环节点ID, 变量名] 引用 + self.assertEqual(manage.get_reference_field("loop1", ["total"]), "012") + # 循环体内给全局变量赋值,写到根工作流 + self.assertEqual(manage.get_reference_field("global", ["last"]), "012") + self.assertNotIn("loop", manage.context["loop1"]["last_context"]) + self.assertNotIn("global", manage.context["loop1"]["last_context"]) + + def test_nested_loop_writes_reach_the_owning_loop_and_the_root_workflow(self): + inner = _loop( + "inner", + 2, + [ + _loop_start("cnt"), + _assign( + "assign", + (["loop", "acc"], "{{loop.acc}}x"), + (["loop", "cnt"], "{{loop.cnt}}y"), + (["global", "g"], "{{loop.acc}}|{{loop.cnt}}"), + ), + ], + [_edge("loop-start-node", "assign")], + ) + outer = _loop("outer", 2, [_loop_start("acc"), inner], [_edge("loop-start-node", "inner")]) + + manage = _run(outer) + + # 内层循环修改外层循环声明的变量 + self.assertEqual(manage.get_reference_field("outer", ["acc"]), "xxxx") + # 内层循环自己的变量存放在外层循环体的上下文里,且每次进入内层循环都从空开始 + self.assertEqual(manage.context["outer"]["last_context"]["inner"]["cnt"], "yy") + # 两层嵌套里给全局变量赋值,依然写到根工作流 + self.assertEqual(manage.context["global"], {"g": "xxxx|yy"}) + self.assertNotIn("global", manage.context["outer"]["last_context"]) + + def test_loop_variable_cannot_use_a_name_reserved_by_the_loop_node(self): + data = {"loop_type": "NUMBER", "number": 1, "loop_body": {"nodes": [_loop_start("last_context")], "edges": []}} + + with self.assertRaises(AppApiException): + LoopNodeSerializer(data=data).is_valid(raise_exception=True) diff --git a/apps/application/workflow/loop_workflow_manage.py b/apps/application/workflow/loop_workflow_manage.py index 9a3ef30351d..a05ee4d992d 100644 --- a/apps/application/workflow/loop_workflow_manage.py +++ b/apps/application/workflow/loop_workflow_manage.py @@ -7,15 +7,33 @@ @desc: """ -from typing import Dict, Callable +from typing import Dict, Callable, List from application.workflow.common import Workflow, WorkflowType from application.workflow.i_node import INode from application.workflow.workflow_manage import WorkflowManage, CallBack -from common.utils.prompt_template import render_prompt + + +def get_loop_variable_fields(loop_start_properties) -> List[str]: + """ + 从循环开始节点的属性里取出声明的循环变量名 + @param loop_start_properties: loop-start-node 的 properties + @return: 循环变量名列表 + """ + field_list = (loop_start_properties or {}).get("loop_input_field_list") or [] + fields = [item.get("field") or item.get("variable") for item in field_list if isinstance(item, dict)] + return [field for field in fields if field] class LoopWorkFlowManage(WorkflowManage): + """ + 循环体工作流。 + + 循环变量不存放在循环体自己的上下文里,而是存放在父工作流中“循环节点”的上下文下 + (parent.context[loop_node_id][变量名]):各轮共享、循环结束后下游可直接用 [循环节点ID, 变量名] 引用。 + 循环体内的 ["loop", 变量名] 只是它的别名。 + """ + def __init__( self, workflow: Workflow, @@ -24,8 +42,12 @@ def __init__( call_back: CallBack, get_start_node: Callable[[Workflow, WorkflowManage], INode], parent_workflow_manage: WorkflowManage, + loop_node_id: str = None, ): self.parent_workflow_manage = parent_workflow_manage + self.loop_node_id = loop_node_id + loop_start_node = workflow.get_node("loop-start-node") + self.loop_variable_fields = get_loop_variable_fields(loop_start_node.properties if loop_start_node else None) super().__init__(workflow, parameters, workflow_type, call_back, get_start_node) def get_parameters(self): @@ -34,11 +56,48 @@ def get_parameters(self): def get_parent_context(self, node_id, key): return self.parent_workflow_manage.get_context(node_id, key) - def generate_prompt(self, prompt): + def _loop_variable_owner(self, field): + """ + 找到循环变量所属的循环:优先是声明了该变量的最近一层循环,都没有声明则归当前循环 + @param field: 循环变量名 + @return: 所属循环的 LoopWorkFlowManage + """ + manage = self + while field not in manage.loop_variable_fields and isinstance( + manage.parent_workflow_manage, LoopWorkFlowManage + ): + manage = manage.parent_workflow_manage + return manage if field in manage.loop_variable_fields else self + + def write_context(self, node_id, key, value, append=False): + """ + 写入上下文:节点属于当前循环体就写自己,否则交给父工作流(父工作流是循环则继续向上)。 + 循环变量(node_id 为 loop)写到所属循环的循环节点上下文里。 + """ + if node_id == "loop" and self.loop_node_id: + owner = self._loop_variable_owner(key) + owner.parent_workflow_manage.write_context(owner.loop_node_id, key, value, append) + return + if self.workflow.get_node(node_id) is not None: + super().write_context(node_id, key, value, append) + return + self.parent_workflow_manage.write_context(node_id, key, value, append) + + def reset_prompt(self, prompt): input_template = self.workflow.reset_prompt(prompt) - input_template = self.parent_workflow_manage.workflow.reset_prompt(input_template) - context = {**self.context, **self.parent_workflow_manage.context} - return render_prompt(input_template, context) + if self.loop_node_id: + # 长的变量名先替换,避免 loop.ab 被 loop.a 截断 + for field in sorted(self.loop_variable_fields, key=len, reverse=True): + system_variable = f"(context.get('{self.loop_node_id}') or {{}}).get('{field}','')" + input_template = input_template.replace(f"循环变量.{field}", system_variable) + input_template = input_template.replace(f"loop.{field}", system_variable) + return self.parent_workflow_manage.reset_prompt(input_template) + + def get_merged_context(self): + """ + 祖先工作流上下文 + 自己的上下文,同名时以自己为准 + """ + return {**self.parent_workflow_manage.get_merged_context(), **self.context} def get_reference_field(self, node_id, fields): """ @@ -47,6 +106,10 @@ def get_reference_field(self, node_id, fields): @param fields: 字段 @return: 引用数据 """ + if node_id == "loop" and self.loop_node_id and fields: + owner = self._loop_variable_owner(fields[0]) + return owner.parent_workflow_manage.get_reference_field(owner.loop_node_id, fields) + # 先从当前工作流获取 result = super().get_reference_field(node_id, fields) if result is not None: @@ -57,7 +120,15 @@ def get_reference_field(self, node_id, fields): @classmethod def from_context( - cls, get_context, workflow, parameters, workflow_type, call_back, get_start_node, parent_workflow_manage=None + cls, + get_context, + workflow, + parameters, + workflow_type, + call_back, + get_start_node, + parent_workflow_manage=None, + loop_node_id=None, ): try: context = get_context() @@ -69,6 +140,7 @@ def from_context( call_back=call_back, get_start_node=get_start_node, parent_workflow_manage=parent_workflow_manage, + loop_node_id=loop_node_id, ) if context: instance.context = context diff --git a/apps/application/workflow/nodes/loop_node/loop_node.py b/apps/application/workflow/nodes/loop_node/loop_node.py index 32707ceffc3..85acb45d523 100644 --- a/apps/application/workflow/nodes/loop_node/loop_node.py +++ b/apps/application/workflow/nodes/loop_node/loop_node.py @@ -14,11 +14,24 @@ from application.workflow.common import WorkflowType, new_instance from application.workflow.i_node import INode, Signal +from application.workflow.loop_workflow_manage import LoopWorkFlowManage, get_loop_variable_fields from application.workflow.message.struct.content import Position from application.workflow.status import Status from common.exception.app_exception import AppApiException MAX_LOOP_COUNT = 500 +# 本节点自身写入上下文的 key;循环变量也存放在本节点的上下文里,不能与它们重名 +LOOP_RESERVED_FIELDS = ("index", "item", "last_context", "error_message", "exception_message") + + +def _get_loop_variable_fields(loop_body): + """ + 获取循环体声明的循环变量名(声明在循环体的 loop-start-node 上) + """ + for node in (loop_body or {}).get("nodes") or []: + if node.get("id") == "loop-start-node": + return get_loop_variable_fields(node.get("properties")) + return [] class LoopNodeSerializer(serializers.Serializer): @@ -40,6 +53,10 @@ def is_valid(self, *, raise_exception=False): if number is None: message = _("{field}, this field is required.", field="number") raise AppApiException(500, message) + # 循环变量存放在循环节点的上下文里,不能和循环节点自身的字段重名 + for field in _get_loop_variable_fields(self.data.get("loop_body")): + if field in LOOP_RESERVED_FIELDS: + raise AppApiException(500, _("Loop variable name is reserved: {field}").format(field=field)) def _generate_loop_number(number, start_index=0): @@ -92,6 +109,12 @@ def execute(self): self.data["params"] = {"loop_type": loop_type, "array": array, "number": number} + # 循环变量存放在本节点的上下文里;全新执行(非断点续跑)时清空,保证初始为空 + if position.get("id") != self.node.id: + node_context = self.workflow_manage.context.setdefault(self.node.id, {}) + for field in _get_loop_variable_fields(loop_body): + node_context.pop(field, None) + # 根据 start_index 构建迭代器 if loop_type == "ARRAY": iterator = _generate_loop_array(array, start_index=start_index) @@ -163,7 +186,6 @@ def on_complete(wf_manage, error): self._run_next() from application.workflow.workflow_manage import CallBack - from application.workflow.loop_workflow_manage import LoopWorkFlowManage from application.workflow.nodes import get_node_class call_back = CallBack(on_next, on_complete) @@ -210,6 +232,7 @@ def get_context(): call_back=call_back, get_start_node=get_start_node_fn, parent_workflow_manage=self.workflow_manage, + loop_node_id=self.node.id, get_context=get_context, ) if loop_manage: @@ -243,7 +266,7 @@ def get_details(self, index: int = 0, position: dict = None, old_details: dict = if position.get("children").get("id") == value.get("node_id"): position_index = index - for index, _loop_details in enumerate(self.data.get("loop_details_list")): + for index, _loop_details in enumerate(self.data.get("loop_details_list") or []): if position and index == 0: for inner_index, item in enumerate(_loop_details): if position is not None and inner_index == 0 and index == 0: diff --git a/apps/application/workflow/nodes/loop_start_node/loop_start_node.py b/apps/application/workflow/nodes/loop_start_node/loop_start_node.py index 0fccd83fa8e..86def0b6452 100644 --- a/apps/application/workflow/nodes/loop_start_node/loop_start_node.py +++ b/apps/application/workflow/nodes/loop_start_node/loop_start_node.py @@ -23,9 +23,6 @@ class LoopStartNode(INode): type = "loop-start-node" def execute(self): - loop = self.workflow_manage.context.get("loop") - if loop is None: - self.write_context("loop", {}) parameters = self.workflow_manage.get_parameters() if parameters is not None: index = parameters.get("index", 0) diff --git a/apps/application/workflow/nodes/variable_assign_node/variable_assign_node.py b/apps/application/workflow/nodes/variable_assign_node/variable_assign_node.py index ea54bb8cd65..512d5622622 100644 --- a/apps/application/workflow/nodes/variable_assign_node/variable_assign_node.py +++ b/apps/application/workflow/nodes/variable_assign_node/variable_assign_node.py @@ -14,7 +14,9 @@ from application.workflow.common import WorkflowType from application.workflow.i_node import INode -from application.workflow.loop_workflow_manage import LoopWorkFlowManage + +# 可赋值的变量分组:全局变量 / 会话变量 / 循环变量 / 输出变量 +ASSIGNABLE_GROUPS = ("global", "chat", "loop", "output") class VariableAssignNodeParamsSerializer(serializers.Serializer): @@ -37,41 +39,15 @@ def execute(self): if not variable.get("fields"): continue - field0 = variable["fields"][0] - if field0 == "global": - result = self.handle(variable, self.global_evaluation) - result_list.append(result) - elif field0 == "chat": - result = self.handle(variable, self.chat_evaluation) - result_list.append(result) - elif field0 == "loop": - result = self.handle(variable, self.loop_evaluation) - result_list.append(result) - elif field0 == "output": - result = self.handle(variable, self.output_evaluation) - result_list.append(result) + if variable["fields"][0] in ASSIGNABLE_GROUPS: + result_list.append(self.handle(variable, self.evaluation)) self.write_context("variable_list", node_params.get("variable_list", [])) self.write_context("result_list", result_list) - def _target_manage(self): - return ( - self.workflow_manage.parent_workflow_manage - if isinstance(self.workflow_manage, LoopWorkFlowManage) - else self.workflow_manage - ) - - def global_evaluation(self, variable, value): - self._target_manage().write_context("global", variable["fields"][1], value) - - def loop_evaluation(self, variable, value): - self.workflow_manage.write_context("loop", variable["fields"][1], value) - - def chat_evaluation(self, variable, value): - self._target_manage().write_context("chat", variable["fields"][1], value) - - def output_evaluation(self, variable, value): - self._target_manage().write_context("output", variable["fields"][1], value) + def evaluation(self, variable, value): + # 写到哪一层由 workflow_manage 决定:循环体内的 global/chat/output 会逐级交给根工作流,loop 写到所属循环 + self.workflow_manage.write_context(variable["fields"][0], variable["fields"][1], value) def handle(self, variable, evaluation: Callable): result = { diff --git a/apps/application/workflow/workflow_manage.py b/apps/application/workflow/workflow_manage.py index 1ea3e5bc3f7..d99f6af70a1 100644 --- a/apps/application/workflow/workflow_manage.py +++ b/apps/application/workflow/workflow_manage.py @@ -224,8 +224,23 @@ def generate_prompt(self, prompt): @param prompt: 提示词 @return: 处理后的提示词 """ - input_template = self.workflow.reset_prompt(prompt) - return render_prompt(input_template, self.context) + input_template = self.reset_prompt(prompt) + return render_prompt(input_template, self.get_merged_context()) + + def reset_prompt(self, prompt): + """ + 把提示词里的变量引用替换成对 context 的取值表达式 + @param prompt: 提示词 + @return: 替换后的模板 + """ + return self.workflow.reset_prompt(prompt) + + def get_merged_context(self): + """ + 获取当前工作流可见的全部上下文;子工作流(循环体)会把祖先工作流的上下文合并进来 + @return: 上下文 + """ + return self.context def get_reference_field(self, node_id, fields): """ diff --git a/ui/src/workflow-canvas/WORKFLOW_README.md b/ui/src/workflow-canvas/WORKFLOW_README.md index a909dfbea3e..b9d813322b6 100644 --- a/ui/src/workflow-canvas/WORKFLOW_README.md +++ b/ui/src/workflow-canvas/WORKFLOW_README.md @@ -547,7 +547,7 @@ MkFormList 的排序、增删均以 `cloneDeep` 回写;MkTable 保留普通行 ## AI 生成(`ai-generate/`) -当前用于应用工作流及循环体,根据用户需求规划、创建节点、连线与配置字段。 +当前用于应用、知识库、工具三类工作流及其循环体,根据用户需求规划、创建节点、连线与配置字段。 页面通过 WorkflowCanvas.getLogicFlow() 提供画布,生成结果只改图,不自动保存。 模型选择支持参数设置,不提供添加模型入口;优先沿用默认 LLM 的参数,切换模型加载对应默认参数。 首轮生成时固定模型及参数,父 agent 和委派的子 agent 共用,并通过 `model_params_setting` 传入补全请求;新建会话后可重新配置。 @@ -555,24 +555,37 @@ MkFormList 的排序、增删均以 `cloneDeep` 回写;MkTable 保留普通行 | 位置 | 职责 | | --------------- | -------------------------------------------------------- | | create-agent.ts | 流式补全、工具执行、错误回喂及级联取消 | -| application.ts | 应用工作流父 agent 与规划器 | +| common.ts | agent 装配、流式补全、委派与续聊的历史裁剪、调试日志 | +| impl/ | 按画布模式分发的父 agent 与规划器,shared.ts 为共用装配 | | tools/canvas.ts | 唯一直接读写 LogicFlow 的适配层 | | tools/result.ts | 工具结果及 agent 终态 | | tools/node.ts | 节点 agent 共用能力与配置校验 | | panel/ | 将用户需求、文本、思考、工具及委派事件转换为对话内容展示 | +可新增的节点以节点菜单(`node-menu/menu.ts` 的 `getMenuNodes`)为准:节点目录与 add_node 都按画布模式取菜单节点, +另含工具库节点(tool-lib-node);菜单新增节点后 AI 侧自动可用。数据源节点(`kind` 为 DataSource)是知识库工作流入口, +新增时不接上游,其余节点必须建即接。停止后续聊会重建父 agent,并沿用裁剪后的历史与首条需求。 + 节点配置 agent 位于节点目录的 agent.ts,通过 index.ts 默认导出的 agent 字段注册; -未注册的节点不进入 AI 可选目录。NodeAgentModule 使用 JSON Schema 定义 params、config、returns: +未注册的节点仍进入 AI 可选目录(说明取菜单文案,输出取模板 config.fields),可新增和连线,但不能 AI 配置。NodeAgentModule 使用 JSON Schema 定义 params、config、returns: params 描述运行时上游引用,config 描述建图时配置,returns 描述固定和动态输出。 x-bind 指定引用写入的 node_data 字段,x-from 描述动态输出来源,函数签名展示不暴露 x- 扩展。 -params 实参使用 `[节点ID, 字段]`;带 items 且无 x-bind 的数组形参可接多个引用。 +params 实参使用 `[节点ID, 字段]`;带 items 的数组形参可接多个引用,加 x-single 时 items 只描述引用值的元素结构、仍只接一个引用。 +带 properties 的对象 schema 在函数签名中逐字段展开,用于描述文档列表等嵌套结构。 +配置节点时,框架把已绑定引用的上游结构(argStructures)和直连下游节点的结构化形参(downstream)注入节点 agent, +使产出按下游需要的结构构造(如自定义代码直连知识库写入时返回文档列表);因此需要先连好链路再配置。 +节点 agent 的提示词带上节点现有 node_data,续聊修改在现状上改。画布信息分两层:get_canvas_detail 只给骨架(节点、连线、数据引用),get_node_detail 按节点 id 列表取完整配置。排查问题先诊断后修改, +知识库工作流另有 get_last_run 读最近一次执行详情(store 的 getLatestKnowledgeAction,值标注真实类型),定位到原因后只改出问题的节点。 +工具工作流的函数签名由基本信息节点(tool-base-node)的 agent 声明:写入输入 / 输出参数并同步开始节点的全局变量,固定节点可配置但不可增删;返回值只取变量赋值写入 `output` 的值, +validate 额外检查未写入的输出参数和对未声明参数的引用。指引按调用方(智能体 / 工作流节点 / 知识库工作流)设计签名,知识库文档结构取自 knowledge-write-node 的形参声明。 固定输出放在 returns.properties,动态输出由 additionalProperties 描述,无动态输出时设为 false。 父 agent 负责连线与实参有效性,节点 agent 负责配置;写入后调用节点 validate,并核对要求的返回字段, 通过后交付 config.fields。分支变更保留分支 ID 协议,删除分支同时清理出口连线。 画布使用 lf_id 标识,主画布为 main,循环体为循环节点 ID;循环体通过 getLoopLogicFlow 获取。 属性经 reactive(model) 写回,使节点表单同步;引用字段与 NodeCascader 一致,提示词变量使用 -`{{节点名称.字段}}` 与 `{{global.字段}}`。 +`{{节点名称.字段}}`,全局/会话/循环变量使用 `{{global.字段}}`、`{{chat.字段}}`、`{{loop.字段}}`。 +循环变量由循环节点 agent 的 variables 声明,写入循环体 loop-start-node 的 loop_input_field_list,并同步为循环节点的输出字段。 SuccessResult/ErrorResult 回喂模型并继续循环,AgentSuccessResult/AgentErrorResult 结束所在 agent; 同轮终态失败优先。普通值按成功结果处理,异常按可续修错误处理;停止父 agent 时级联取消委派任务。 diff --git a/ui/src/workflow-canvas/ai-generate/common.ts b/ui/src/workflow-canvas/ai-generate/common.ts index 4d2c1f4e2c5..1bae431d263 100644 --- a/ui/src/workflow-canvas/ai-generate/common.ts +++ b/ui/src/workflow-canvas/ai-generate/common.ts @@ -56,6 +56,26 @@ export function inheritMessages(bindings: AgentBindings): AgentMessage[] { return trimmed } +/** 停止后模型未回复时补的占位回复,避免续聊出现连续两条 user 消息 */ +const STOPPED_PLACEHOLDER = '(上一轮已被用户停止,未完成)' + +/** + * 从已停止 agent 的历史裁出续聊用的初始历史:去掉 system(重建时由 useAgentConfig 重新生成), + * 已完整回填的工具轮次原样保留,只裁掉末尾未回填完的那一轮;末尾是 user 时补一条占位回复。 + */ +export function resumeMessages(source: AgentMessage[]): AgentMessage[] { + const messages = source.filter((message) => message.role !== 'system') + let toolStart = messages.length + while (toolStart > 0 && messages[toolStart - 1]?.role === 'tool') toolStart-- + const caller = messages[toolStart - 1] + if (caller?.role === 'assistant' && caller.tool_calls && messages.length - toolStart < caller.tool_calls.length) { + messages.length = toolStart - 1 + if (caller.content) messages.push({ role: 'assistant', content: caller.content }) + } + if (messages.at(-1)?.role === 'user') messages.push({ role: 'assistant', content: STOPPED_PLACEHOLDER }) + return messages +} + export function toToolSchemas(tools: AgentTool[]): NonNullable { return tools.map((tool) => ({ type: 'function', diff --git a/ui/src/workflow-canvas/ai-generate/create-agent.ts b/ui/src/workflow-canvas/ai-generate/create-agent.ts index 3ce384f6d9c..2dd1759f849 100644 --- a/ui/src/workflow-canvas/ai-generate/create-agent.ts +++ b/ui/src/workflow-canvas/ai-generate/create-agent.ts @@ -117,7 +117,7 @@ export function createAgent(agent: RunnableAgent): AgentHandle { } } - return { run, stop } + return { run, stop, messages } } /** 跑一轮补全:把流式条目翻成事件下发,并返回本轮完整结果。 */ diff --git a/ui/src/workflow-canvas/ai-generate/application.ts b/ui/src/workflow-canvas/ai-generate/impl/application/index.ts similarity index 65% rename from ui/src/workflow-canvas/ai-generate/application.ts rename to ui/src/workflow-canvas/ai-generate/impl/application/index.ts index 31c777caf3b..099cd511cd9 100644 --- a/ui/src/workflow-canvas/ai-generate/application.ts +++ b/ui/src/workflow-canvas/ai-generate/impl/application/index.ts @@ -1,10 +1,4 @@ -import { getMenuNodes } from '@/workflow-canvas/node-menu/menu' -import { WorkflowNodeType } from '@/workflow-canvas/types' -import { loopModeOf } from './tools/canvas' -import { inheritMessages, useAgentConfig } from './common' -import { createAgent } from './create-agent' -import { ErrorResult } from './tools/result' -import { renderNodeFunction } from './tools/node' +import { useAgentConfig } from '@/workflow-canvas/ai-generate/common' import { addEdgeTool, addNodeTool, @@ -12,46 +6,28 @@ import { deleteEdgeTool, deleteNodeTool, getCanvasDetailTool, + getNodeDetailTool, getNodeAnchorsTool, - layoutTool, - nodeAgentFor, getToolsTool, + layoutTool, validateTool, -} from './tools' -import type { AgentBindings, AgentTool, RunnableAgent } from './type' +} from '@/workflow-canvas/ai-generate/tools' +import type { AgentBindings, RunnableAgent } from '@/workflow-canvas/ai-generate/type' +import { buildParentPrompt, createPlanTool, renderCatalog } from '../shared' /** * 应用工作流(Application)的父 agent:把每个节点当作一个函数(说明、形参、返回字段), * 先委派规划器据需求写出文字方案并经用户确认,再亲手建图(含循环体,经 lf_id 跨画布),字段配置委派给各节点配置 agent,最后校验。 */ -function nodeLine(type: string, label: string): string { - return renderNodeFunction(type, label, nodeAgentFor(type)!) -} - -/** 可用节点函数:只列已接入 AI 生成的节点,循环体专属节点单列 */ -function renderCatalog(bindings: AgentBindings): string { - const supported = (nodes: Array<{ type: string; label: string }>) => nodes.filter((node) => nodeAgentFor(node.type)) - const mainGroups = getMenuNodes(bindings.workflowMode).map((group) => ({ label: group.label, nodes: supported(group.list) })) - const mainTypes = new Set(mainGroups.flatMap((group) => group.nodes.map((node) => node.type))) - const loopOnly = supported(getMenuNodes(loopModeOf(bindings.workflowMode)).flatMap((group) => group.list)).filter( - (node) => !mainTypes.has(node.type), - ) - const sections = mainGroups - .filter((group) => group.nodes.length) - .map((group) => `【${group.label}】\n${group.nodes.map((node) => nodeLine(node.type, node.label)).join('\n\n')}`) - sections.push(`【工具】\n${nodeLine(WorkflowNodeType.ToolLib, '工具')}`) - if (loopOnly.length) - sections.push(`【仅循环体内可用(lf_id=所在循环节点 id)】\n${loopOnly.map((node) => nodeLine(node.type, node.label)).join('\n\n')}`) - return sections.join('\n\n') -} - const GUIDE = `你是 MaxKB「智能体工作流」的搭建助手,在 LogicFlow 画布上按用户需求用建图工具亲手把流程搭出来(含循环体)。下方「可用节点」把每个节点写成一个函数:注释为说明(做什么、何时用、与上下游怎么配合)与可配置项,形参为它需要上游提供的数据,返回值为可被 [节点ID, 字段] 引用的输出字段([字段名] 为按配置生成的动态字段)。搭画布就是把这些函数组合成一段程序:判断器 / 意图识别是分支,循环节点是 for,前一个节点的返回值是后一个节点的实参。 约定: - 画布固定节点:base-node(基本信息)与 start-node(开始,id 固定 "start-node"),不可新增/删除/配置。start-node 输出 question(用户问题);全局变量用 ["global", 字段] 引用。 - 引用格式:[节点ID, 字段];提示词里的变量写 {{节点名称.字段}}(如 {{开始.question}})。节点名称以 add_node 返回的 name 为准。 - 画布用 lf_id 标识:主画布 "main"(或省略);循环体 = 该循环节点 id。每个循环体固定有 loop-start-node(输出 index、item)。 +- 循环变量:要跨轮次累积 / 传递数据,或循环结束后还要用循环内算出的值时,configure_node 循环节点时在 requirement 写明要声明哪些循环变量、returns 列出变量名。循环体内用 ["loop", 变量名] 引用;循环变量初始为空,修改它只能在循环体内加变量赋值节点(variable-assign-node,target=["loop", 变量名]),其他节点的输出不会自动写入,所以每个要更新的循环变量都要在产出数据的节点之后接一个变量赋值节点;循环结束后下游用 [循环节点ID, 变量名] 引用最终值。 +- 目录里标注「暂不支持 AI 配置」的节点可以新增和连线,但不能 configure_node:有可配置的节点能满足需求时优先用后者;确实要用时建好接好,并在最后总结里提示用户手动配置。 【从零搭建】 1. 先调 plan:规划器据需求组合节点函数,写出文字方案 { plan }(用户已在「规划流程」中看到全文)。拿到方案后先不要建图:不复述全文,用两三句概括主链路与关键假设,请用户确认或提出修改,然后直接结束本轮回复(不调用任何工具)。 @@ -66,14 +42,11 @@ const GUIDE = `你是 MaxKB「智能体工作流」的搭建助手,在 LogicFl 5. 流程末端必须是能作为结束的节点(AI 对话、指定回复、表单等),面向用户的最终回答 is_result=true,中间加工步骤 is_result=false。 6. 全部完成后调 validate,problems 非空就按提示修复,直到 qualified=true,再调 layout 整理布局。 -【续聊修改】先 get_canvas_detail 看现状,再用 add_node / add_edge / delete_node / delete_edge / configure_node 就地修改(循环体内传对应 lf_id),改完同样 validate。 +【续聊修改】先 get_canvas_detail 看骨架(节点、连线、各节点的数据引用 references,不含配置内容),要改或要核对的节点再用 get_node_detail(nodeIds) 取完整配置(可一次传多个),然后用 add_node / add_edge / delete_node / delete_edge / configure_node 就地修改(循环体内传对应 lf_id),改完同样 validate。 +用户反馈运行报错或结果不对时先诊断、后修改:从报错节点沿骨架里的 references 往上找出整条数据链,用 get_node_detail 一次取回链上节点的完整配置逐个核对,定位到原因并向用户说明后,只改出问题的那个节点;没有报错信息又看不出原因时请用户提供报错,不要靠猜去改配置或删节点重建。validate 只检查连线和必填项,不能证明数据结构没问题。 完成后用一段话向用户总结搭了什么、还需要用户补充什么(如尚未创建知识库)即结束。` -function buildPrompt(bindings: AgentBindings): string { - return `${GUIDE}\n\n======== 可用节点 ========\n${renderCatalog(bindings)}` -} - const PLAN_GUIDE = `你是 MaxKB「智能体工作流」的规划器。下方【可用节点】把每个节点写成一个函数(说明、形参、返回字段)。根据用户需求,像写程序一样组合这些函数,写出一份文字方案:先给用户确认,再供搭建 agent 照着建。你只规划、不改画布。 思路: @@ -91,47 +64,15 @@ const PLAN_GUIDE = `你是 MaxKB「智能体工作流」的规划器。下方【 const PLAN_TASK = '结合用户这次的需求,组合可用节点函数,按方案格式写出文字方案。' -/** 规划委派:起规划器子 agent(继承父对话),产出文字方案后上抛给父 agent */ -function planTool(bindings: AgentBindings): AgentTool { - return { - name: 'plan', - description: '(从零搭建的第一步,或用户要求大改方案时)委派规划器据需求组合节点函数,写出文字方案,返回 { plan }。', - parameters: { type: 'object', properties: {} }, - apply: () => { - const plannerBindings: AgentBindings = { - ...bindings, - position: [...(bindings.position ?? []), 'plan'], - messages: inheritMessages(bindings), - maxIterations: 10, - } - const planner = createAgent( - useAgentConfig( - { - prompt: `${PLAN_GUIDE}\n\n======== 可用节点 ========\n${renderCatalog(bindings)}`, - tools: [getToolsTool(bindings)], - }, - plannerBindings, - ), - ) - // 规划器不调用工具即结束,data 为最后一段正文 - const promise = planner.run(PLAN_TASK).then((result) => { - const plan = typeof result.data === 'string' ? result.data.trim() : '' - if (result.status !== 'done' || !plan) return new ErrorResult(plan || '规划未产出方案') - return { plan } - }) - return { promise, cancel: planner.stop } - }, - } -} - /** 应用工作流父 agent 的装配工厂 */ export function useApplicationAgent(bindings: AgentBindings): RunnableAgent { return useAgentConfig( { - prompt: buildPrompt(bindings), + prompt: buildParentPrompt(GUIDE, bindings), tools: [ - planTool(bindings), + createPlanTool(bindings, { guide: `${PLAN_GUIDE}\n\n======== 可用节点 ========\n${renderCatalog(bindings)}`, task: PLAN_TASK, tools: [getToolsTool(bindings)] }), getCanvasDetailTool(bindings), + getNodeDetailTool(bindings), getNodeAnchorsTool(bindings), getToolsTool(bindings), addNodeTool(bindings), diff --git a/ui/src/workflow-canvas/ai-generate/impl/index.ts b/ui/src/workflow-canvas/ai-generate/impl/index.ts new file mode 100644 index 00000000000..25937d043bd --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/impl/index.ts @@ -0,0 +1,20 @@ +import { WorkflowMode } from '@/workflow-canvas/types' +import { useApplicationAgent } from '@/workflow-canvas/ai-generate/impl/application' +import { useKnowledgeAgent } from '@/workflow-canvas/ai-generate/impl/knowledge' +import { useToolAgent } from '@/workflow-canvas/ai-generate/impl/tool' +import type { AgentBindings, RunnableAgent } from '@/workflow-canvas/ai-generate/type' + +/** 主画布模式 → 父 agent 工厂;循环体随主画布归一到对应模式 */ +const agentByMode: Partial RunnableAgent>> = { + [WorkflowMode.Application]: useApplicationAgent, + [WorkflowMode.ApplicationLoop]: useApplicationAgent, + [WorkflowMode.Knowledge]: useKnowledgeAgent, + [WorkflowMode.KnowledgeLoop]: useKnowledgeAgent, + [WorkflowMode.Tool]: useToolAgent, + [WorkflowMode.ToolLoop]: useToolAgent, +} + +/** 按画布模式装配父 agent;未接入的模式兜底到应用工作流 */ +export const useAgent = (workflowMode: WorkflowMode, bindings: AgentBindings): RunnableAgent => { + return (agentByMode[workflowMode] ?? useApplicationAgent)(bindings) +} diff --git a/ui/src/workflow-canvas/ai-generate/impl/knowledge/index.ts b/ui/src/workflow-canvas/ai-generate/impl/knowledge/index.ts new file mode 100644 index 00000000000..28e40172265 --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/impl/knowledge/index.ts @@ -0,0 +1,128 @@ +import { useAgentConfig } from '@/workflow-canvas/ai-generate/common' +import { + addEdgeTool, + addNodeTool, + configureNodeTool, + deleteEdgeTool, + deleteNodeTool, + getCanvasDetailTool, + getLastRunTool, + getNodeDetailTool, + getNodeAnchorsTool, + getToolsTool, + layoutTool, + listDataSourceTool, + validateTool, +} from '@/workflow-canvas/ai-generate/tools' +import type { AgentBindings, RunnableAgent } from '@/workflow-canvas/ai-generate/type' +import { buildParentPrompt, createPlanTool, renderCatalog } from '../shared' + +/** + * 知识库工作流(Knowledge)的父 agent:节点即函数,先委派规划器写方案等用户确认,再建图(含循环体), + * 字段配置委派给各节点配置 agent,最后校验。与其它模式不同,知识库工作流没有固定的开始节点: + * 入口是数据源节点(kind=DataSource)——内置的本地文件 / Web 站点,或经 list_datasource 取得的数据源工具,可多个。 + */ + +const GUIDE = `你是 MaxKB「知识库工作流」的搭建助手,在 LogicFlow 画布上按用户需求用建图工具亲手把流程搭出来(含循环体)。知识库工作流用于把外部数据源(本地文件、Web 站点等)经文档分段 / 内容提取后写入知识库。 +下方「可用节点」把每个节点写成一个函数:注释为说明(做什么、何时用、与上下游怎么配合)与可配置项,形参为它需要上游提供的数据,返回值为可被 [节点ID, 字段] 引用的输出字段([字段名] 为按配置生成的动态字段)。搭画布就是把这些函数组合成一段程序:判断器 / 意图识别是分支,循环节点是 for,前一个节点的返回值是后一个节点的实参。 + +核心:整条流程都服务于末端的知识库写入。它的入参 documents 是一个文档列表 Array<{ name, paragraphs?, tags?, meta?, source_file_id? }>(逐字段结构见目录中 knowledge-write-node 的签名),规划和搭建都从这个结构倒推: +- 每个字段由谁产出:name / paragraphs / meta / source_file_id 来自文档分段的 paragraph_list;tags、分段上的 problem_list 等额外字段只能由加工链路补上。 +- 直接接到知识库写入的节点,输出必须就是这个列表本身:顶层是数组、每项是一篇文档;不能是单篇文档对象,也不能是 { 文档名: 内容 } 这样的字典。 +- configure_node 直连知识库写入的加工节点(自定义代码等)时,requirement 要写明「返回值是与 paragraph_list 同结构的文档列表,逐篇保留原字段并补上 xx 字段」;框架也会把下游结构告诉它。 + +约定: +- 画布固定节点:knowledge-base-node(知识库基本信息),不可新增/删除/配置。 +- 知识库工作流没有固定的开始节点:入口是数据源节点,可以有多个作为多个起点。数据源节点不接上游,add_node 时省略 source_anchor_id,其余节点都必须接上游。数据源有三种: + · data-source-local-node(本地文件):用户上传文档,输出 file_list(未解析内容)。新建的知识库工作流画布通常已自带一个,先 get_canvas_detail 查看,已有就直接复用,不要重复新建。 + · data-source-web-node(Web 站点):按根地址抓取网页,输出 document_list(已含内容),无配置项。 + · 数据源工具:用户自建的其他数据源,list_datasource 查询后用 add_node(type="tool-lib-node", tool_id, name) 新建。需求没有指明时优先用前两种内置数据源。 +- 典型链路:本地文件.file_list → 文档内容提取 → 文档分段 → 知识库写入;Web 站点.document_list → 文档分段 → 知识库写入。 +- 知识库写入只接受带分段的文档列表:configure_node 时 args 必须是 { documents: [文档分段节点ID, "paragraph_list"] }。文档内容提取、Web 站点、本地文件的输出都没有分段,不能直接接到知识库写入,中间一定要有文档分段。 +- 引用格式:[节点ID, 字段];提示词里的变量写 {{节点名称.字段}}(如 {{文档内容提取.content}})。节点名称以 add_node 返回的 name 为准;全局变量用 ["global", 字段] 引用。 +- 画布用 lf_id 标识:主画布 "main"(或省略);循环体 = 该循环节点 id。每个循环体固定有 loop-start-node(输出 index、item)。 +- 循环变量:要跨轮次累积 / 传递数据,或循环结束后还要用循环内算出的值时,configure_node 循环节点时在 requirement 写明要声明哪些循环变量、returns 列出变量名。循环体内用 ["loop", 变量名] 引用;循环变量初始为空,修改它只能在循环体内加变量赋值节点(variable-assign-node,target=["loop", 变量名]),其他节点的输出不会自动写入,所以每个要更新的循环变量都要在产出数据的节点之后接一个变量赋值节点;循环结束后下游用 [循环节点ID, 变量名] 引用最终值。 +- 目录里标注「暂不支持 AI 配置」的节点可以新增和连线,但不能 configure_node:有可配置的节点能满足需求时优先用后者;确实要用时建好接好,并在最后总结里提示用户手动配置。 + +【从零搭建】 +1. 先调 plan:规划器据需求组合节点函数,写出文字方案 { plan }(用户已在「规划流程」中看到全文)。拿到方案后先不要建图:不复述全文,用两三句概括主链路与关键假设,请用户确认或提出修改,然后直接结束本轮回复(不调用任何工具)。 + 用户确认后再照方案从第 2 步开始建;用户提出修改时,小改直接说明调整点后再次请用户确认,大改重新调 plan,同样确认后再建。 +2. 建入口数据源:先 get_canvas_detail 看画布已有的数据源;需要的数据源不在画布上时用 add_node 新建(不传 source_anchor_id)。本地文件有上传限制要求时才 configure_node,Web 站点无需配置,数据源工具按其参数 configure_node。画布上用不到的数据源用 delete_node 删掉。 +3. 用 add_node(type, source_anchor_id) 从数据源往下建节点:普通链式连接传上游节点 id;判断器 / 意图识别要先 configure_node 配好分支,再 get_node_anchors 取各分支出口接下游;多分支汇合用 add_edge 补入边。 +4. 循环:主画布 add_node 建 loop-node 并 configure_node 配循环方式;再带 lf_id=该循环节点 id 往循环体建(首节点 source_anchor_id="loop-start-node"),循环体内节点用 lf_id=该循环 id 配置;无限循环必须放 loop-break-node。 +5. 节点建好接好后 configure_node(nodeId, args, requirement, returns?) 配置,相当于调用这个函数: + - args 传实参:形参名 → 上游引用 [节点ID, 字段](多引用形参传列表),按方案中各节点的输入来源给,框架会校验并绑定; + - requirement 写配置要求(提示词只写要求、知识库只写意图),节点配置 agent 会生成内容并查出真实资源; + - 节点有 [字段名] 动态返回时,returns 列出要求产出的字段名,下游按这些字段引用。 + 同一轮可并发配置多个节点;但实参要引用上游的动态返回字段时,先配上游,按其交付的 outputs 再配下游。 +6. 流程末端应落到「知识库写入」,并且它的上游必须是文档分段;有多个数据源时,每条链路都要经过文档分段再写入(可以共用一个知识库写入节点,用 add_edge 汇入)。 +7. 全部完成后调 validate,problems 非空就按提示修复,直到 qualified=true,再调 layout 整理布局。 + +【写入前加工:标签 / 关联问题 / 元数据】 +需求要给文档打标签、补关联问题、改文档名或补元数据时,在文档分段与知识库写入之间加一段加工链路,由自定义代码(tool-custom-node)把分段结果改写成带这些字段的文档列表,知识库写入改为引用代码节点:documents ← [代码节点ID, "result"]。 +- 用 AI 生成的(如按内容打标签):文档分段 → AI 对话(ai-chat-node)→ 自定义代码 → 知识库写入。 + · AI 对话:materials 传文档分段的 paragraph_list,提示词要求只输出 JSON,不要多余文字,格式固定为 { "文档名": [{ "key": "标签名", "value": "标签值" }] },并写清标签的维度和取值范围。 + · 自定义代码:inputs 两个引用参数 documents(array)← 文档分段.paragraph_list、ai_output(string)← AI 对话.answer。代码解析 ai_output(先去掉 Markdown 代码块标记,解析失败时保留原文档不加标签),遍历 documents,按 name 把标签写进对应文档的 tags,原字段一律保留,返回整个 documents 列表。AI 输出的字典只是查找表,不是返回值。 + · 顺序:先 add_node 把 文档分段 → AI 对话 → 自定义代码 → 知识库写入 整条连好,再从上往下 configure_node,这样配置自定义代码时它已经知道下游知识库写入要的结构。 +- 规则能确定的(如固定标签、按文件名补标签):文档分段 → 自定义代码 → 知识库写入,代码里直接生成。 +- 字段格式:tags 为 [{ key: string(≤64 字), value: string(≤128 字)}],key / value 都不能为空;problem_list 写在每个分段上,为 string[];同一个 key 可以有多个 value。 + +【续聊修改】先 get_canvas_detail 看骨架(节点、连线、各节点的数据引用 references,不含配置内容),要改或要核对的节点再用 get_node_detail(nodeIds) 取完整配置(可一次传多个),然后用 add_node / add_edge / delete_node / delete_edge / configure_node 就地修改(循环体内传对应 lf_id),改完同样 validate。 + +【排查问题】用户说运行报错、结果不对或让你检查时,先诊断、后修改;没定位到原因之前不要动画布,更不要靠猜去改配置、删节点重建。 +1. 取证据:调 get_last_run 看最近一次执行——哪个节点失败、报错原文、各节点实际输入输出(值前标了真实类型 / / )。用户贴了报错以用户的为准。没有执行记录也没有报错信息时,请用户调试运行一次或提供报错,不要猜。 +2. 倒查数据链:报错的节点往往不是出问题的节点,它只是收到了错误的数据。从失败节点开始,按 get_canvas_detail 骨架里的 references 找出它引用的上游字段和整条数据链,用 get_node_detail 一次取回链上节点的完整配置(代码、提示词),结合它们在 get_last_run 里的实际输出,对照目录中的结构逐级往上核对,直到找到第一个输出不符合下游要求的节点。validate 只检查连线和必填项,查不出数据结构问题,不能用 validate 通过来证明没问题。 +3. 知识库写入失败时重点核对 documents 引用的上游实际输出是否为文档列表:顶层必须是 list,每项有 name 和 paragraphs。常见原因:自定义代码返回了 dict(单篇文档,或把 AI 输出的 { 文档名: 标签 } 查找表当成了返回值);代码解析 AI 输出失败后返回了别的结构;AI 对话没按约定只输出 JSON;写入直接接了没有分段的输出。 +4. 先向用户说明结论:哪个节点、什么原因、依据是什么(引用执行详情或配置原文)。然后只改出问题的那个节点:configure_node 的 args 保持原有引用,requirement 写清「现在的问题 + 要改成什么」,其余节点和连线不动。 +5. 改完 validate,并请用户重新调试验证;一次只修一个确定的原因,不要顺手改无关配置。 + +完成后用一段话向用户总结搭了什么、还需要用户补充什么(如尚未准备数据源、未建知识库)即结束。` + +const PLAN_GUIDE = `你是 MaxKB「知识库工作流」的规划器。下方【可用节点】把每个节点写成一个函数(说明、形参、返回字段)。知识库工作流把外部数据源经文档处理写入知识库;它没有固定开始节点,入口是数据源节点。根据用户需求,像写程序一样组合这些函数,写出一份文字方案:先给用户确认,再供搭建 agent 照着建。你只规划、不改画布。 + +思路: +- 先拆解需求:数据从哪来(本地文件 / Web 站点等数据源)、如何清洗(内容提取、分段)、写到哪(当前知识库)。 +- 入口数据源优先用内置的本地文件(data-source-local-node,画布通常已自带)或 Web 站点(data-source-web-node);只有需求指向其他来源时才 list_datasource 查数据源工具(tool-lib-node)。 +- 按数据依赖排顺序:本地文件.file_list → 文档内容提取 → 文档分段 → 知识库写入;Web 站点.document_list 已含内容,直接 → 文档分段 → 知识库写入。每个节点的形参都要有上游返回值来提供,类型要对得上。 +- 需要按条件 / 分类走不同处理时用分支(condition-node / intent-node),需要逐项处理一组数据时用循环(loop-node)。 +- 只放需求实际需要的节点;末端要落到知识库写入。从它的入参 documents(文档列表,每项 name / paragraphs / tags / meta 等)倒推:需求要的每个字段由哪个节点产出,直连写入的节点输出必须是整个文档列表,而不是单篇文档或字典。 +- 需求含糊时按最常见理解规划,并写明假设。 + +方案格式(Markdown,面向用户可读,同时足够搭建 agent 照着建): +- 按执行顺序逐个列节点:节点名称(类型)——做什么与配置要求(数据源抓取 / 上传方式、分段策略、写入哪);输入:形参 ← 上游节点.返回字段;有 [字段名] 动态返回时写出要产出的字段与类型。 +- 分支节点(判断器 / 意图识别)逐路写:条件或分类 → 该路的后续节点,最后一路为 else / 其他;循环节点写清循环方式与循环体内的节点。 +- 需求要给文档打标签、补关联问题或元数据时,在文档分段与知识库写入之间加加工链路:AI 生成的用 文档分段 → AI 对话(只输出 JSON)→ 自定义代码(合并进文档的 tags 等字段)→ 知识库写入;规则能确定的用 文档分段 → 自定义代码 → 知识库写入。方案里写清标签的维度和取值。 +- 最后写「关键假设」:需要用户事先准备或之后补充的内容(如数据源、知识库),以及对含糊需求的理解。 +方案写完即结束,之后不再调用任何工具。` + +const PLAN_TASK = '结合用户这次的需求,组合可用节点函数,按方案格式写出文字方案。' + +/** 知识库工作流父 agent 的装配工厂 */ +export function useKnowledgeAgent(bindings: AgentBindings): RunnableAgent { + return useAgentConfig( + { + prompt: buildParentPrompt(GUIDE, bindings), + tools: [ + createPlanTool(bindings, { + guide: `${PLAN_GUIDE}\n\n======== 可用节点 ========\n${renderCatalog(bindings)}`, + task: PLAN_TASK, + tools: [listDataSourceTool(bindings)], + }), + getCanvasDetailTool(bindings), + getNodeDetailTool(bindings), + getLastRunTool(bindings), + getNodeAnchorsTool(bindings), + listDataSourceTool(bindings), + getToolsTool(bindings), + addNodeTool(bindings), + addEdgeTool(bindings), + deleteNodeTool(bindings), + deleteEdgeTool(bindings), + configureNodeTool(bindings), + validateTool(bindings), + layoutTool(bindings), + ], + }, + bindings, + ) +} diff --git a/ui/src/workflow-canvas/ai-generate/impl/shared.ts b/ui/src/workflow-canvas/ai-generate/impl/shared.ts new file mode 100644 index 00000000000..e8d887d703e --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/impl/shared.ts @@ -0,0 +1,89 @@ +import { getMenuNodes } from '@/workflow-canvas/node-menu/menu' +import { WorkflowNodeType, type ShapeItem } from '@/workflow-canvas/types' +import { loopModeOf } from '@/workflow-canvas/ai-generate/tools/canvas' +import { inheritMessages, useAgentConfig } from '@/workflow-canvas/ai-generate/common' +import { createAgent } from '@/workflow-canvas/ai-generate/create-agent' +import { renderNodeFunction, renderPlainNodeFunction, schemaType } from '@/workflow-canvas/ai-generate/tools/node' +import { ErrorResult } from '@/workflow-canvas/ai-generate/tools/result' +import { nodeAgentFor } from '@/workflow-canvas/ai-generate/tools' +import type { AgentBindings, AgentTool } from '@/workflow-canvas/ai-generate/type' + +/** + * 各画布模式父 agent 共享的机械装配:可用节点目录渲染、规划器委派、提示词拼装。 + * 各 impl/*\/index.ts 各自持有本模式的 GUIDE / 规划器提示词与工具集,仅复用这里的纯装配逻辑。 + */ + +type CatalogNode = ShapeItem & { type: string; label: string } + +/** 节点 → 函数签名:已注册配置 agent 的按其声明渲染,其余按菜单文案与模板输出字段渲染 */ +export function nodeLine(node: CatalogNode): string { + const module = nodeAgentFor(node.type) + if (module) return renderNodeFunction(node.type, node.label, module) + const config = node.properties?.config as { fields?: Array<{ value: string; label: string }> } | undefined + return renderPlainNodeFunction(node.type, node.label, node.text ?? '', config?.fields ?? []) +} + +/** + * 可用节点函数目录:与节点菜单(node-menu)一致,按画布模式列出全部菜单节点,循环体专属节点单列; + * 末尾追加「工具」(tool-lib-node)函数,对应菜单的工具 / 数据源页签。 + */ +export function renderCatalog(bindings: AgentBindings): string { + const mainGroups = getMenuNodes(bindings.workflowMode) + const mainTypes = new Set(mainGroups.flatMap((group) => group.list.map((node) => node.type))) + const loopOnly = getMenuNodes(loopModeOf(bindings.workflowMode)) + .flatMap((group) => group.list) + .filter((node) => !mainTypes.has(node.type)) + const sections = mainGroups.filter((group) => group.list.length).map((group) => `【${group.label}】\n${group.list.map(nodeLine).join('\n\n')}`) + const toolLib = nodeAgentFor(WorkflowNodeType.ToolLib) + if (toolLib) sections.push(`【工具】\n${renderNodeFunction(WorkflowNodeType.ToolLib, '工具', toolLib)}`) + if (loopOnly.length) sections.push(`【仅循环体内可用(lf_id=所在循环节点 id)】\n${loopOnly.map(nodeLine).join('\n\n')}`) + return sections.join('\n\n') +} + +/** + * 知识库写入要求的文档列表结构,取自 knowledge-write-node 声明的形参 documents(唯一来源)。 + * 供目录里没有知识库写入节点的模式(如工具工作流)在产出要交给知识库工作流时引用。 + */ +export function knowledgeDocumentsStructure(): string { + return schemaType(nodeAgentFor(WorkflowNodeType.KnowledgeWriteNode)?.params.properties.documents) +} + +export interface PlannerConfig { + /** 规划器系统提示词(含本模式说明与可用节点) */ + guide: string + /** 规划器任务指令 */ + task: string + /** 规划器可用工具(如需查工具 / 数据源) */ + tools: AgentTool[] +} + +/** 规划委派:起规划器子 agent(继承父对话),产出文字方案后上抛给父 agent */ +export function createPlanTool(bindings: AgentBindings, planner: PlannerConfig): AgentTool { + return { + name: 'plan', + description: '(从零搭建的第一步,或用户要求大改方案时)委派规划器据需求组合节点函数,写出文字方案,返回 { plan }。', + parameters: { type: 'object', properties: {} }, + apply: () => { + const plannerBindings: AgentBindings = { + ...bindings, + position: [...(bindings.position ?? []), 'plan'], + messages: inheritMessages(bindings), + maxIterations: 10, + } + const agent = createAgent( + useAgentConfig({ prompt: planner.guide, tools: planner.tools }, plannerBindings), + ) + const promise = agent.run(planner.task).then((result) => { + const plan = typeof result.data === 'string' ? result.data.trim() : '' + if (result.status !== 'done' || !plan) return new ErrorResult(plan || '规划未产出方案') + return { plan } + }) + return { promise, cancel: agent.stop } + }, + } +} + +/** 拼装父 agent 提示词:模式说明 + 可用节点目录 */ +export function buildParentPrompt(guide: string, bindings: AgentBindings): string { + return `${guide}\n\n======== 可用节点 ========\n${renderCatalog(bindings)}` +} diff --git a/ui/src/workflow-canvas/ai-generate/impl/tool/index.ts b/ui/src/workflow-canvas/ai-generate/impl/tool/index.ts new file mode 100644 index 00000000000..48be99d42ad --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/impl/tool/index.ts @@ -0,0 +1,111 @@ +import { useAgentConfig } from '@/workflow-canvas/ai-generate/common' +import { + addEdgeTool, + addNodeTool, + configureNodeTool, + deleteEdgeTool, + deleteNodeTool, + getCanvasDetailTool, + getNodeDetailTool, + getNodeAnchorsTool, + getToolsTool, + layoutTool, + validateTool, +} from '@/workflow-canvas/ai-generate/tools' +import type { AgentBindings, RunnableAgent } from '@/workflow-canvas/ai-generate/type' +import { buildParentPrompt, createPlanTool, knowledgeDocumentsStructure, renderCatalog } from '../shared' + +/** + * 工具工作流(Tool)的父 agent:节点即函数,先委派规划器写方案等用户确认,再建图(含循环体), + * 字段配置委派给各节点配置 agent,最后校验。工具工作流就是一个函数:签名由基本信息的输入 / 输出参数声明 + *(configure_node 基本信息节点),输入在固定的 tool-start-node 提供为全局变量(global),返回值只取变量赋值写入 output 的值。 + */ + +/** 调用方说明:工具工作流的签名取决于谁来调用,父 agent 与规划器共用 */ +const callerGuide = () => `【工具给谁用】工具工作流发布后有两类调用方,签名要按调用方设计: +- 智能体(AI 对话节点挂载工具,由大模型按函数调用):模型只能看到工具名称、描述和每个输入参数的说明(desc),并自己生成参数值。输入参数要少而简单(string / int / float / boolean 为主),desc 写清含义、格式与示例;全部输出参数组成一个对象原样交给模型阅读,输出宜用可读的文本或简单结构。 +- 工作流节点(知识库工作流 / 应用工作流 / 其他工具工作流里的工具节点):输入参数由上游节点的输出按引用传入,每个输出参数成为可被下游引用的字段。输入、输出的类型和结构必须与调用它的流程上下游对得上,结构化数据用 array / dict 原样传递,不要转成文本。 +- 给知识库工作流用(加工待入库的文档,如打标签、补关联问题、改写分段):位置在「文档分段」与「知识库写入」之间。输入一个 array 参数接收文档分段输出的文档列表,输出一个参数返回补好字段的同一个文档列表,原字段一律保留,顶层必须是 list(哪怕只有一篇),这样知识库写入能直接引用。文档列表结构: + ${knowledgeDocumentsStructure()} + 要 AI 逐篇生成内容时,对文档列表用循环节点逐篇处理(或一次性交给 AI 对话输出 JSON),再由自定义代码按 name 合并回文档列表;AI 输出的 { 文档名: ... } 字典只用于查找,不能直接当返回值。 +- 需求里看得出调用方(如"知识库工作流用的…工具""给智能体调用")就按对应形态设计;看不出来时按智能体调用设计,并在关键假设里写明、请用户确认。` + +const guide = () => `你是 MaxKB「工具工作流」的搭建助手,在 LogicFlow 画布上按用户需求用建图工具亲手把流程搭出来(含循环体)。工具工作流就是一个函数,发布后被智能体或其他工作流调用:函数签名是「基本信息」里声明的输入参数和输出参数,由你 configure_node("tool-base-node") 声明;输入参数在开始节点提供为全局变量 ["global", 参数名];返回值只取流程里用变量赋值节点写入 ["output", 参数名] 的值,其他节点的输出不会自动返回给调用方。 + +${callerGuide()} + +下方「可用节点」把每个节点写成一个函数:注释为说明(做什么、何时用、与上下游怎么配合)与可配置项,形参为它需要上游提供的数据,返回值为可被 [节点ID, 字段] 引用的输出字段([字段名] 为按配置生成的动态字段)。搭画布就是把这些函数组合成一段程序:判断器 / 意图识别是分支,循环节点是 for,前一个节点的返回值是后一个节点的实参。 + +约定: +- 画布固定节点:tool-base-node(工具基本信息)与 tool-start-node(开始,id 固定 "tool-start-node"),都不可新增/删除;开始节点不可配置,基本信息节点用 configure_node("tool-base-node", requirement) 声明输入 / 输出参数(无需 args)。开始节点提供输入参数 ["global", 参数名];输出参数 ["output", 参数名] 是待写入的变量,初始为空。 +- 返回结果:每个输出参数都要在产出结果的节点之后接一个变量赋值节点(variable-assign-node),target=["output", 参数名],结果是数组 / 对象时用引用方式原样赋值;有分支时每条会结束的路径都要写入。 +- 引用格式:[节点ID, 字段];提示词里的变量写 {{节点名称.字段}},输入参数写 {{global.参数名}}。节点名称以 add_node 返回的 name 为准。 +- 画布用 lf_id 标识:主画布 "main"(或省略);循环体 = 该循环节点 id。每个循环体固定有 loop-start-node(输出 index、item)。 +- 循环变量:要跨轮次累积 / 传递数据,或循环结束后还要用循环内算出的值时,configure_node 循环节点时在 requirement 写明要声明哪些循环变量、returns 列出变量名。循环体内用 ["loop", 变量名] 引用;循环变量初始为空,修改它只能在循环体内加变量赋值节点(variable-assign-node,target=["loop", 变量名]),其他节点的输出不会自动写入,所以每个要更新的循环变量都要在产出数据的节点之后接一个变量赋值节点;循环结束后下游用 [循环节点ID, 变量名] 引用最终值。 +- 目录里标注「暂不支持 AI 配置」的节点可以新增和连线,但不能 configure_node:有可配置的节点能满足需求时优先用后者;确实要用时建好接好,并在最后总结里提示用户手动配置。 + +【从零搭建】 +1. 先调 plan:规划器据需求定出工具的签名(输入 / 输出参数)并组合节点函数,写出文字方案 { plan }(用户已在「规划流程」中看到全文)。拿到方案后先不要建图:不复述全文,用两三句概括工具的调用方、输入输出参数、主链路与关键假设,请用户确认或提出修改,然后直接结束本轮回复(不调用任何工具)。 + 用户确认后再照方案从第 2 步开始;用户提出修改时,小改直接说明调整点后再次请用户确认,大改重新调 plan,同样确认后再建。 +2. 先 configure_node("tool-base-node") 按方案声明输入参数与输出参数,requirement 里逐个写全:输入参数的参数名、显示名称、类型、是否必填、说明;输出参数的参数名、显示名称。它交付最新签名 { inputs, outputs },之后节点才能引用 ["global", 参数名]、变量赋值才能写入 ["output", 参数名]。 +3. 用 add_node(type, source_anchor_id) 建节点并接上游:第一个节点接 "tool-start-node";普通链式连接传上游节点 id;判断器 / 意图识别要先 configure_node 配好分支(requirement 写清分几路、各路条件或分类),再 get_node_anchors 取各分支出口接下游;多分支汇合用 add_edge 补入边。 +4. 循环:主画布 add_node 建 loop-node 并 configure_node 配循环方式;再带 lf_id=该循环节点 id 往循环体建(首节点 source_anchor_id="loop-start-node"),循环体内节点用 lf_id=该循环 id 配置;无限循环必须放 loop-break-node。 +5. 节点建好接好后 configure_node(nodeId, args, requirement, returns?) 配置,相当于调用这个函数: + - args 传实参:形参名 → 上游引用 [节点ID, 字段](多引用形参传列表),按方案中各节点的输入来源给,框架会校验并绑定; + - requirement 写配置要求(提示词只写要求、模型 / 知识库只写意图),节点配置 agent 会生成内容并查出真实资源; + - 节点有 [字段名] 动态返回时,returns 列出要求产出的字段名,下游按这些字段引用。 + 同一轮可并发配置多个节点;但实参要引用上游的动态返回字段时,先配上游,按其交付的 outputs 再配下游。 +6. 返回结果:在产出结果的节点之后 add_node 变量赋值节点并 configure_node,args 传 { values: [[结果节点ID, 字段]] },requirement 写明把它赋给 ["output", 参数名];每个输出参数都要写入。 +7. 全部完成后调 validate(会检查输出参数是否都被写入、是否引用了未声明的参数),problems 非空就按提示修复,直到 qualified=true,再调 layout 整理布局。 + +【续聊修改】先 get_canvas_detail 看骨架(tool_params 当前的输入 / 输出参数,节点、连线、各节点的数据引用 references,不含配置内容),要改或要核对的节点再用 get_node_detail(nodeIds) 取完整配置(可一次传多个),然后用 add_node / add_edge / delete_node / delete_edge / configure_node 就地修改(循环体内传对应 lf_id),要增删改输入 / 输出参数时再次 configure_node("tool-base-node"),requirement 写明要改哪些、其余保留;改名或删除参数后调 validate,按 problems 修正仍引用旧参数的节点。改完同样 validate。 +用户反馈运行报错或结果不对时先诊断、后修改:从报错节点沿骨架里的 references 往上找出整条数据链,用 get_node_detail 一次取回链上节点的完整配置逐个核对,定位到原因并向用户说明后,只改出问题的那个节点;没有报错信息又看不出原因时请用户提供报错,不要靠猜去改配置或删节点重建。validate 只检查连线和必填项,不能证明数据结构没问题。 + +完成后用一段话向用户总结搭了什么、工具的输入输出参数是什么、还需要用户补充什么(如尚未创建知识库、工具;工具要发布后才能被智能体或其他工作流调用)即结束。` + +const planGuide = () => `你是 MaxKB「工具工作流」的规划器。下方【可用节点】把每个节点写成一个函数(说明、形参、返回字段)。工具工作流就是一个函数,发布后被智能体或其他工作流调用:函数签名是基本信息里声明的输入参数和输出参数;输入参数在流程内用 ["global", 参数名] 引用;返回值只取变量赋值节点写入 ["output", 参数名] 的值,其他节点的输出不会自动返回。 + +${callerGuide()} + +根据用户需求,像写程序一样组合这些函数,写出一份文字方案:先给用户确认,再供搭建 agent 照着建。你只规划、不改画布。 + +思路: +- 先定签名:谁来调用这个工具,据此定输入参数(名称、类型、说明)和输出参数;再从输出参数倒推,为得到它需要哪些中间数据,每份数据由哪个节点函数的返回值提供。 +- 按数据依赖排顺序:每个节点的形参都要有上游返回值(或输入参数 global.参数名)来提供,类型要对得上(如循环的 array 需要上游返回 array)。 +- 需要按条件 / 分类走不同处理时用分支(condition-node / intent-node),需要逐项处理一组数据时用循环(loop-node)。 +- 只放需求实际需要的节点;每个输出参数都要有一个变量赋值节点(variable-assign-node)写入,有分支时每条会结束的路径都要写入。 +- 需要外部工具时先 get_tools 看有哪些可用工具。需求含糊时按最常见理解规划,并写明假设。 + +方案格式(Markdown,面向用户可读,同时足够搭建 agent 照着建): +- 开头写「调用方」(智能体 / 知识库工作流 / 应用工作流)与「工具签名」:输入参数逐个列 参数名(类型,是否必填)——说明;输出参数逐个列 参数名——含义与数据结构。 +- 按执行顺序逐个列节点:节点名称(类型)——做什么与配置要求(AI 对话的角色 / 目标 / 输出、检索什么、判断条件、表单收集哪些字段);输入:形参 ← 上游节点.返回字段(如 内容 ← global.参数名);有 [字段名] 动态返回时写出要产出的字段与类型。 +- 分支节点(判断器 / 意图识别)逐路写:条件或分类 → 该路的后续节点,最后一路为 else / 其他;循环节点写清循环方式与循环体内的节点。 +- 末尾的变量赋值节点写明:输出参数 ← 哪个节点的哪个字段。 +- 最后写「关键假设」:需要用户事先准备或之后补充的内容(如知识库、工具),以及对含糊需求的理解(包括调用方是按什么推断的)。 +写完方案直接结束,不调用任何工具。` + +const PLAN_TASK = '结合用户这次的需求,先定工具的调用方与签名,再组合可用节点函数,按方案格式写出文字方案。' + +/** 工具工作流父 agent 的装配工厂 */ +export function useToolAgent(bindings: AgentBindings): RunnableAgent { + return useAgentConfig( + { + prompt: buildParentPrompt(guide(), bindings), + tools: [ + createPlanTool(bindings, { guide: `${planGuide()}\n\n======== 可用节点 ========\n${renderCatalog(bindings)}`, task: PLAN_TASK, tools: [getToolsTool(bindings)] }), + getCanvasDetailTool(bindings), + getNodeDetailTool(bindings), + getNodeAnchorsTool(bindings), + getToolsTool(bindings), + addNodeTool(bindings), + addEdgeTool(bindings), + deleteNodeTool(bindings), + deleteEdgeTool(bindings), + configureNodeTool(bindings), + validateTool(bindings), + layoutTool(bindings), + ], + }, + bindings, + ) +} diff --git a/ui/src/workflow-canvas/ai-generate/panel/AiGeneratePanel.vue b/ui/src/workflow-canvas/ai-generate/panel/AiGeneratePanel.vue index 0cfa70db653..17e57357d8b 100644 --- a/ui/src/workflow-canvas/ai-generate/panel/AiGeneratePanel.vue +++ b/ui/src/workflow-canvas/ai-generate/panel/AiGeneratePanel.vue @@ -12,11 +12,11 @@ import { inputShortcut } from '@/conversation-panel/core/shortcuts' import { MsgError, MsgSuccess } from '@/utils/message' import { useWorkflowStore } from '@/workflow-canvas/store' import { WorkflowMode } from '@/workflow-canvas/types' -import { useApplicationAgent } from '../application' +import { useAgent } from '../impl' import { layoutCanvas } from '../tools/canvas' -import { clearDebugLog } from '../common' +import { clearDebugLog, resumeMessages } from '../common' import { createAgent } from '../create-agent' -import type { AgentEvent, AgentHandle } from '../type' +import type { AgentEvent, AgentHandle, AgentMessage } from '../type' import { createActivity } from './activity' import { VIEW_KEY,VIEW_TYPE } from '@/conversation-panel/view' provide(VIEW_KEY,VIEW_TYPE.DEBUG) @@ -62,6 +62,10 @@ function sendSuggestedRequirement(question: string) { } let agent: AgentHandle | null = null +/** 停止后保留的历史,重建 agent 时续上(已 abort 的 agent 不可复用) */ +let carriedMessages: AgentMessage[] | undefined +/** 本次会话的首条需求:停止后重建 agent 时仍作为整体目标背景,不被续聊文字覆盖 */ +let sessionGoal: string | undefined function loadModels() { Promise.all([store.getModelListWithShared({ model_type: 'LLM' }), store.getProviderList()]) @@ -102,29 +106,45 @@ function onEvent(event: AgentEvent) { scrollToBottom() } +/** 画布所属资源 id:按模式取路由上的应用 / 工具 / 知识库 id */ +function resourceIdOf(): string | undefined { + const key = props.workflowMode.startsWith('knowledge') + ? 'knowledgeId' + : props.workflowMode.startsWith('tool') + ? 'toolId' + : 'applicationId' + return typeof route.params[key] === 'string' ? (route.params[key] as string) : undefined +} + /** 首轮装配父 agent,后续续聊复用同一实例以保留上下文 */ async function run(text: string) { const lf = props.getLogicFlow() if (!lf) return if (!agent) { + sessionGoal ??= text agent = createAgent( - useApplicationAgent({ + useAgent(props.workflowMode, { rootLf: lf, modelId: modelId.value, modelParams: cloneDeep(modelParams.value), call: { onNext: onEvent }, workflowMode: props.workflowMode, resourceScope, - resourceId: typeof route.params.applicationId === 'string' ? route.params.applicationId : undefined, + resourceId: resourceIdOf(), position: [], maxIterations: 200, - context: text, + context: sessionGoal, + messages: carriedMessages, }), ) + carriedMessages = undefined } status.value = 'running' canvasChanged = false - const result = await agent.run(text) + const current = agent + const result = await current.run(text) + // 运行期间已新建会话:结果作废,不回写状态 + if (agent !== current) return if (result.status === 'done') { status.value = 'done' if (!canvasChanged) return @@ -136,6 +156,9 @@ async function run(text: string) { status.value = 'error' } else { status.value = 'stopped' + // 已 abort 的 agent 不能复用:保留可续聊的历史,丢弃实例,下次发送时重建续聊 + carriedMessages = resumeMessages(current.messages) + agent = null } } @@ -160,6 +183,8 @@ function stop() { function reset() { agent?.stop() agent = null + carriedMessages = undefined + sessionGoal = undefined activity.reset() status.value = 'idle' } diff --git a/ui/src/workflow-canvas/ai-generate/panel/activity.ts b/ui/src/workflow-canvas/ai-generate/panel/activity.ts index ff813b3c722..0a981bce097 100644 --- a/ui/src/workflow-canvas/ai-generate/panel/activity.ts +++ b/ui/src/workflow-canvas/ai-generate/panel/activity.ts @@ -22,6 +22,9 @@ export interface ContentItem { const TOOL_LABELS: Record = { plan: '规划流程', get_canvas_detail: '查看画布', + get_node_detail: '查看节点配置', + get_last_run: '查看最近一次执行', + list_datasource: '查询数据源', get_node_anchors: '查看节点出口', get_tools: '查询工具', add_node: '添加节点', diff --git a/ui/src/workflow-canvas/ai-generate/tools/canvas.ts b/ui/src/workflow-canvas/ai-generate/tools/canvas.ts index 23ca0800995..4dae325477a 100644 --- a/ui/src/workflow-canvas/ai-generate/tools/canvas.ts +++ b/ui/src/workflow-canvas/ai-generate/tools/canvas.ts @@ -22,6 +22,19 @@ export const FIXED_NODE_TYPES: string[] = [ WorkflowNodeType.LoopBodyNode, ] +/** 各模式额外固定节点:应用外的基本信息 / 开始节点也不同,均不可由 AI 新增或删除 */ +const MODE_FIXED_NODE_TYPES: Partial> = { + [WorkflowMode.Knowledge]: [WorkflowNodeType.KnowledgeBase], + [WorkflowMode.KnowledgeLoop]: [WorkflowNodeType.KnowledgeBase], + [WorkflowMode.Tool]: [WorkflowNodeType.ToolBaseNode, WorkflowNodeType.ToolStartNode], + [WorkflowMode.ToolLoop]: [WorkflowNodeType.ToolBaseNode, WorkflowNodeType.ToolStartNode], +} + +/** 当前画布模式的固定节点集合 */ +export function fixedNodeTypesOf(mode: WorkflowMode): string[] { + return [...FIXED_NODE_TYPES, ...(MODE_FIXED_NODE_TYPES[mode] ?? [])] +} + type LoopBodyModel = BaseNodeModel & { getLoopLogicFlow?: () => LogicFlow | null; loopLayout?: () => unknown } type BranchNodeData = { branch?: Array<{ id: string; type?: string; content?: string; isOther?: boolean }> } type DagreExtension = { dagre?: { layout?: () => Promise | unknown } } @@ -182,17 +195,17 @@ export function addEdge(lf: LogicFlow, sourceNodeId: string, sourceAnchorId: str return null } -/** 新节点放在上游右侧,按上游已有出边数纵向错开,避免重叠;最终由自动布局整理 */ +/** 新节点放在上游右侧,按上游已有出边数纵向错开,避免重叠;无上游(入口节点)放在左侧起始位;最终由自动布局整理 */ export function addNode( lf: LogicFlow, template: ShapeItem, sourceNodeId: string, propertiesOverride: Record = {}, ): WorkflowNodeModel { - const source = getNode(lf, sourceNodeId) + const source = sourceNodeId ? getNode(lf, sourceNodeId) : undefined const outgoingCount = source ? lf.graphModel.getNodeOutgoingEdge(sourceNodeId).length : 0 - const x = (source?.x ?? 400) + (source?.width ?? 320) / 2 + 400 - const y = (source?.y ?? 400) + outgoingCount * 420 + const x = source ? (source.x ?? 400) + (source.width ?? 320) / 2 + 400 : 240 + const y = source ? (source.y ?? 400) + outgoingCount * 420 : 240 const node = lf.graphModel.addNode({ type: String(template.type), properties: { ...cloneDeep(template.properties ?? {}), ...propertiesOverride }, @@ -265,10 +278,13 @@ export function validateReference(lf: LogicFlow, nodeId: string, reference: unkn return null } -/** 提示词变量:{{节点名称.字段}},全局变量为 {{global.字段}} */ +/** 变量分组(非节点):提示词里直接用分组名,如 {{global.字段}}、{{loop.字段}} */ +export const VARIABLE_GROUPS = ['global', 'chat', 'loop', 'output'] + +/** 提示词变量:{{节点名称.字段}},全局 / 会话 / 循环变量为 {{分组.字段}} */ export function promptVariablesOf(lf: LogicFlow, nodeId: string): string[] { return getFieldOptions(lf, nodeId).flatMap((group) => - group.fields.map((field) => (group.nodeId === 'global' ? `{{global.${field.value}}}` : `{{${group.name}.${field.value}}}`)), + group.fields.map((field) => (VARIABLE_GROUPS.includes(group.nodeId) ? `{{${group.nodeId}.${field.value}}}` : `{{${group.name}.${field.value}}}`)), ) } diff --git a/ui/src/workflow-canvas/ai-generate/tools/index.ts b/ui/src/workflow-canvas/ai-generate/tools/index.ts index 6c64d129c6d..76f46039be6 100644 --- a/ui/src/workflow-canvas/ai-generate/tools/index.ts +++ b/ui/src/workflow-canvas/ai-generate/tools/index.ts @@ -1,13 +1,16 @@ import { cloneDeep } from 'lodash' import { TOOL_TYPE } from '@/api/enums' +import type { ToolItem } from '@/api/types' import type LogicFlow from '@logicflow/core' -import { WorkFlowInstance } from '@/workflow-canvas/core/validate' +import type { BaseNodeModel } from '@logicflow/core' +import { WorkFlowInstance, KnowledgeWorkFlowInstance, ToolWorkFlowInstance } from '@/workflow-canvas/core/validate' import { toolLibNode } from '@/workflow-canvas/config/node-data' import { useWorkflowStore } from '@/workflow-canvas/store' -import { WorkflowMode, WorkflowNodeType, type ShapeItem } from '@/workflow-canvas/types' +import { WorkflowKind, WorkflowMode, WorkflowNodeType, type ShapeItem } from '@/workflow-canvas/types' import { - FIXED_NODE_TYPES, + fixedNodeTypesOf, MAIN_LF_ID, + VARIABLE_GROUPS, addEdge, addNode, findNodeCanvas, @@ -26,12 +29,12 @@ import { type CanvasRef, } from './canvas' import { createAgent } from '../create-agent' -import { formatValidationError, isReference, summarizeNodeData } from './node' +import { formatValidationError, isReference, isStructuredSchema, schemaType } from './node' import { ErrorResult } from './result' import type { AgentBindings, AgentTool, JsonSchema, NodeAgentModule, NodeReference } from '../type' -/** 节点配置 agent 取自各节点 index.ts 默认导出的 agent 字段;未提供的节点不进入 AI 可选目录 */ +/** 节点配置 agent 取自各节点 index.ts 默认导出的 agent 字段;未提供的节点可新增、连线,但不能 AI 配置 */ const nodeRegistryModules = import.meta.glob<{ default: { type: string; agent?: NodeAgentModule } }>('../../nodes/*/index.ts', { eager: true }) const nodeAgentByType = new Map( Object.values(nodeRegistryModules).flatMap(({ default: node }) => (node.agent ? [[node.type, node.agent] as const] : [])), @@ -49,17 +52,50 @@ export function nodeAgentFor(type: string): NodeAgentModule | undefined { const LF_ID_SCHEMA = { type: 'string', description: '画布:主画布 "main"(或省略);循环体传所属循环节点 id' } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -function describeCanvas(canvas: CanvasRef) { +/** 提示词等文本里的变量 {{节点名称.字段}} */ +const PROMPT_VARIABLE_PATTERN = /\{\{[^{}]+?\.[^{}]+?\}\}/g + +/** + * 节点的数据引用:扫描 node_data 里所有 [节点ID, 字段](首项须为画布上的节点或变量分组,避免把普通二元数组误判), + * x-bind 写入的字段标出对应形参名;文本里的 {{节点名称.字段}} 单独列出。 + */ +function referencesOf(node: BaseNodeModel, knownSources: Set) { + const params = nodeAgentFor(String(node.type))?.params.properties ?? {} + const paramByField = new Map(Object.entries(params).flatMap(([name, schema]) => (typeof schema['x-bind'] === 'string' ? [[schema['x-bind'], name] as const] : []))) + const references: Array<{ param?: string; field: string; ref: NodeReference }> = [] + const variables = new Set() + const visit = (value: unknown, path: string, topField: string) => { + if (typeof value === 'string') { + value.match(PROMPT_VARIABLE_PATTERN)?.forEach((variable) => variables.add(variable)) + } else if (isReference(value) && knownSources.has(value[0])) { + const param = paramByField.get(topField) + references.push({ ...(param ? { param } : {}), field: path, ref: [...value] }) + } else if (Array.isArray(value)) { + value.forEach((item, index) => visit(item, `${path}[${index}]`, topField)) + } else if (value && typeof value === 'object') { + Object.entries(value).forEach(([key, item]) => visit(item, path ? `${path}.${key}` : key, topField || key)) + } + } + visit(node.properties.node_data ?? {}, '', '') + return { references, prompt_variables: [...variables] } +} + +/** 画布骨架:节点(id / 类型 / 名称 / 输出字段 / 数据引用)与连线,不含配置内容(配置用 get_node_detail 取) */ +function describeCanvas(canvas: CanvasRef, knownSources: Set) { const nodes = canvas.lf.graphModel.nodes.filter((node) => String(node.type) !== WorkflowNodeType.LoopBodyNode) return { lf_id: canvas.lfId, - nodes: nodes.map((node) => ({ - id: node.id, - type: String(node.type), - name: nodeNameOf(node), - outputs: nodeOutputFields(node).map((field) => field.value), - node_data: summarizeNodeData(node.properties.node_data), - })), + nodes: nodes.map((node) => { + const { references, prompt_variables } = referencesOf(node, knownSources) + return { + id: node.id, + type: String(node.type), + name: nodeNameOf(node), + outputs: nodeOutputFields(node).map((field) => field.value), + ...(references.length ? { references } : {}), + ...(prompt_variables.length ? { prompt_variables } : {}), + } + }), edges: canvas.lf.graphModel.edges .filter((edge) => edge.type !== 'loop-edge') .map((edge) => { @@ -70,6 +106,11 @@ function describeCanvas(canvas: CanvasRef) { } } +/** 可被引用的来源:全部画布上的节点 id 与变量分组 */ +function knownSourcesOf(canvases: CanvasRef[]): Set { + return new Set([...VARIABLE_GROUPS, ...canvases.flatMap((canvas) => canvas.lf.graphModel.nodes.map((node) => node.id))]) +} + async function canvasOrError(bindings: AgentBindings, lfId: unknown) { return resolveCanvas(bindings.rootLf, bindings.workflowMode, typeof lfId === 'string' ? lfId : undefined) } @@ -77,17 +118,168 @@ async function canvasOrError(bindings: AgentBindings, lfId: unknown) { export function getCanvasDetailTool(bindings: AgentBindings): AgentTool { return { name: 'get_canvas_detail', - description: '查看整张工作流现状:主画布与各循环体(loopBodies[循环节点id])的节点(id/type/name/输出字段/配置摘要)与连线。', + description: + '查看整张工作流的骨架:主画布与各循环体(loopBodies[循环节点id])的节点(id / type / name / 输出字段 outputs)、连线 edges,' + + '以及每个节点的数据引用 references(param 形参名、field 写入的配置字段、ref 引用的 [节点ID, 字段])和文本里用到的变量 prompt_variables。' + + '不含节点配置内容(代码、提示词、阈值等),需要时用 get_node_detail 取。工具工作流另带 tool_params(基本信息声明的输入 / 输出参数)。', parameters: { type: 'object', properties: {} }, resultLimit: 20000, apply: async () => { const canvases = await listCanvases(bindings.rootLf, bindings.workflowMode) + const knownSources = knownSourcesOf(canvases) const [main, ...loops] = canvases - return { main: describeCanvas(main!), loopBodies: Object.fromEntries(loops.map((canvas) => [canvas.lfId, describeCanvas(canvas)])) } + return { + ...(isToolMode(bindings.workflowMode) ? { tool_params: toolParamsOf(bindings.rootLf) } : {}), + main: describeCanvas(main!, knownSources), + loopBodies: Object.fromEntries(loops.map((canvas) => [canvas.lfId, describeCanvas(canvas, knownSources)])), + } + }, + } +} + +export function getNodeDetailTool(bindings: AgentBindings): AgentTool { + return { + name: 'get_node_detail', + description: + '查看指定节点的完整配置,可一次传多个节点 id:完整 node_data(代码、提示词、引用字段等,不截断)、输出字段、数据引用与上下游节点。' + + '修改已有节点或排查问题前,先用它看清相关节点的现状;倒查一条链路时把链上节点一次传入。', + parameters: { + type: 'object', + properties: { nodeIds: { type: 'array', items: { type: 'string' }, description: '节点 id 列表(任意画布,含循环体内节点)' } }, + required: ['nodeIds'], + }, + resultLimit: 30000, + apply: async (args) => { + const nodeIds = (Array.isArray(args.nodeIds) ? args.nodeIds : [args.nodeIds ?? args.nodeId]).filter(Boolean).map(String) + if (!nodeIds.length) return new ErrorResult('nodeIds 至少传一个节点 id') + const knownSources = knownSourcesOf(await listCanvases(bindings.rootLf, bindings.workflowMode)) + const details = [] + for (const nodeId of nodeIds) { + const canvas = await findNodeCanvas(bindings.rootLf, bindings.workflowMode, nodeId) + const node = canvas ? getNode(canvas.lf, nodeId) : undefined + if (!canvas || !node) { + details.push({ id: nodeId, error: '节点不存在' }) + continue + } + const brief = (target: BaseNodeModel) => ({ id: target.id, type: String(target.type), name: nodeNameOf(target) }) + details.push({ + lf_id: canvas.lfId, + ...brief(node), + outputs: nodeOutputFields(node).map((field) => field.value), + ...referencesOf(node, knownSources), + upstream: canvas.lf.graphModel.getNodeIncomingNode(nodeId).map(brief), + downstream: canvas.lf.graphModel.getNodeOutgoingNode(nodeId).map(brief), + node_data: node.properties.node_data ?? {}, + }) + } + return { nodes: details } + }, + } +} + +/** 运行值的摘要:标出真实类型(dict / list 及长度),内容截断,供核对上下游数据结构 */ +function summarizeRunValue(value: unknown): unknown { + if (value === null || value === undefined || typeof value === 'number' || typeof value === 'boolean') return value + const kind = Array.isArray(value) ? `list(${value.length})` : typeof value === 'object' ? 'dict' : 'str' + const text = typeof value === 'string' ? value : JSON.stringify(value) + return `<${kind}> ${text.length > 400 ? `${text.slice(0, 400)}…` : text}` +} + +const RUN_BASE_FIELDS = ['node_id', 'name', 'type', 'index', 'status', 'run_time', 'error', 'err_message'] + +export function getLastRunTool(bindings: AgentBindings): AgentTool { + return { + name: 'get_last_run', + description: + '查看当前知识库工作流最近一次执行(调试或导入)的详情:整体状态,以及按执行顺序每个节点的状态、报错原文和实际输入输出(值前标注真实类型 / / ,内容截断)。' + + '用户反馈运行报错或结果不对时先调它定位,再决定改哪个节点。执行的是当时保存的画布,之后改过的节点以画布现状为准。', + parameters: { type: 'object', properties: {} }, + resultLimit: 20000, + apply: async () => { + if (!bindings.resourceId) return new ErrorResult('缺少知识库 id,无法查询执行记录') + const action = await useWorkflowStore(bindings.resourceScope).getLatestKnowledgeAction(bindings.resourceId) + if (!action) return { found: false, message: '没有执行记录:请用户先调试运行一次,或直接提供报错信息' } + const nodes = Object.values(action.details ?? {}) + .sort((left, right) => Number(left.index ?? 0) - Number(right.index ?? 0)) + .map((detail) => ({ + node_id: detail.node_id, + name: detail.name, + type: detail.type, + status: detail.status === 200 ? 'success' : detail.status === 500 ? 'error' : detail.status, + error: detail.err_message || detail.error || undefined, + data: Object.fromEntries( + Object.entries(detail) + .filter(([key, value]) => !RUN_BASE_FIELDS.includes(key) && value !== null && value !== undefined && value !== '') + .map(([key, value]) => [key, summarizeRunValue(value)]), + ), + })) + return { found: true, state: action.state, create_time: action.create_time, nodes } }, } } +// ============================================================ +// 工具工作流的函数签名(基本信息节点的输入 / 输出参数) +// ============================================================ + +type ToolParamField = { field: string; label?: string; type?: string; desc?: string; is_required?: boolean; default_value?: unknown } + +function isToolMode(mode: WorkflowMode): boolean { + return mode === WorkflowMode.Tool || mode === WorkflowMode.ToolLoop +} + +function toolParamFields(lf: LogicFlow, key: 'user_input_field_list' | 'user_output_field_list'): ToolParamField[] { + const fields = getNode(lf, WorkflowNodeType.ToolBaseNode)?.properties[key] + return Array.isArray(fields) ? (fields as ToolParamField[]) : [] +} + +/** 工具工作流的函数签名:输入参数(开始节点提供为 global)与输出参数(由变量赋值写入 output) */ +function toolParamsOf(lf: LogicFlow) { + return { + inputs: toolParamFields(lf, 'user_input_field_list').map((field) => ({ + field: field.field, + label: field.label, + type: field.type, + is_required: field.is_required, + desc: field.desc, + ref: ['global', field.field], + })), + outputs: toolParamFields(lf, 'user_output_field_list').map((field) => ({ field: field.field, label: field.label, ref: ['output', field.field] })), + } +} + +/** 引用了基本信息里不存在的输入 / 输出参数的位置(参数被改名或删除后会出现) */ +function brokenToolParamReferences(lf: LogicFlow, canvases: CanvasRef[]): string[] { + const declared: Record> = { + global: new Set(toolParamFields(lf, 'user_input_field_list').map((field) => field.field)), + output: new Set(toolParamFields(lf, 'user_output_field_list').map((field) => field.field)), + } + const knownSources = knownSourcesOf(canvases) + return canvases.flatMap((canvas) => + canvas.lf.graphModel.nodes.flatMap((node) => + referencesOf(node, knownSources) + .references.filter(({ ref }) => declared[ref[0]] && !declared[ref[0]]!.has(ref[1])) + .map(({ ref, field }) => `节点「${nodeNameOf(node)}」(${node.id}) 的 ${field} 引用了未声明的${ref[0] === 'global' ? '输入' : '输出'}参数 ${ref[0]}.${ref[1]}`), + ), + ) +} + +/** 没有任何变量赋值节点写入、也没有默认值的输出参数:调用方拿不到这个字段 */ +function unwrittenToolOutputs(lf: LogicFlow, canvases: CanvasRef[]): string[] { + const written = new Set( + canvases.flatMap((canvas) => + canvas.lf.graphModel.nodes + .filter((node) => String(node.type) === WorkflowNodeType.VariableAssignNode) + .flatMap((node) => (node.properties.node_data as { variable_list?: Array<{ fields?: string[] }> } | undefined)?.variable_list ?? []) + .filter((variable) => variable.fields?.[0] === 'output') + .map((variable) => String(variable.fields?.[1])), + ), + ) + return toolParamFields(lf, 'user_output_field_list') + .filter((field) => !written.has(field.field) && (field.default_value === undefined || field.default_value === null)) + .map((field) => field.field) +} + export function getNodeAnchorsTool(bindings: AgentBindings): AgentTool { return { name: 'get_node_anchors', @@ -108,23 +300,41 @@ export function getNodeAnchorsTool(bindings: AgentBindings): AgentTool { } } -/** 工具节点的模板:按工具详情补齐参数与名称,与资源菜单拖入的节点一致 */ +/** 工具节点的模板:按工具详情补齐参数与名称,与资源菜单拖入的节点一致;数据源工具额外写入 kind / condition */ async function toolNodeTemplate(bindings: AgentBindings, toolId: string): Promise { const tool = await useWorkflowStore(bindings.resourceScope) .force.getToolById(toolId) .catch(() => null) if (!tool?.id) return { error: `工具不存在: ${toolId}` } const inputFields = (tool.input_field_list ?? []).map((field) => ({ ...field, value: field.source === 'reference' ? [] : '' })) + const dataSource = tool.tool_type === TOOL_TYPE.DATA_SOURCE ? { kind: WorkflowKind.DataSource, condition: 'OR' } : {} return { ...cloneDeep(toolLibNode), properties: { ...cloneDeep(toolLibNode.properties), stepName: tool.name, + ...dataSource, node_data: { ...cloneDeep(tool), input_field_list: inputFields, tool_lib_id: tool.id }, }, } } +/** 工具列表 → 精简结构,供 get_tools / list_datasource 复用 */ +function summarizeTools(tools: ToolItem[]) { + return tools.map((tool) => ({ + id: tool.id, + name: tool.name, + desc: tool.desc, + inputs: (tool.input_field_list ?? []).map((field) => ({ + name: field.name, + type: field.type, + source: field.source, + is_required: field.is_required, + desc: field.desc, + })), + })) +} + /** 列出可在应用工作流中使用的自定义工具(父 agent 选型用) */ export function getToolsTool(bindings: AgentBindings): AgentTool { return { @@ -133,20 +343,21 @@ export function getToolsTool(bindings: AgentBindings): AgentTool { parameters: { type: 'object', properties: {} }, apply: async () => { const tools = await useWorkflowStore(bindings.resourceScope).getToolListWithShared({ tool_type: TOOL_TYPE.CUSTOM }) - return tools - .filter((tool) => tool.is_active && tool.tool_type === TOOL_TYPE.CUSTOM) - .map((tool) => ({ - id: tool.id, - name: tool.name, - desc: tool.desc, - inputs: (tool.input_field_list ?? []).map((field) => ({ - name: field.name, - type: field.type, - source: field.source, - is_required: field.is_required, - desc: field.desc, - })), - })) + return summarizeTools(tools.filter((tool) => tool.is_active && tool.tool_type === TOOL_TYPE.CUSTOM)) + }, + } +} + +/** 列出知识库工作流可用的数据源(Data source 工具);作为知识库工作流的入口节点 */ +export function listDataSourceTool(bindings: AgentBindings): AgentTool { + return { + name: 'list_datasource', + description: + '列出可用的数据源 [{id,name,desc,inputs:[...]}]。知识库工作流的入口是数据源节点,用 add_node(type="tool-lib-node", tool_id) 新建(无需 source_anchor_id)。', + parameters: { type: 'object', properties: {} }, + apply: async () => { + const tools = await useWorkflowStore(bindings.resourceScope).getToolListWithShared({ tool_type: TOOL_TYPE.DATA_SOURCE }) + return summarizeTools(tools.filter((tool) => tool.is_active && tool.tool_type === TOOL_TYPE.DATA_SOURCE)) }, } } @@ -155,45 +366,60 @@ export function addNodeTool(bindings: AgentBindings): AgentTool { return { name: 'add_node', description: - '新增节点并从上游出口接一条入边(建即接,杜绝孤立节点)。source_anchor_id:普通连接传上游节点 id;接判断器/意图识别某分支时传 get_node_anchors 返回的出口 id。' + - '循环体首个节点接 "loop-start-node"。tool-lib-node 需同时传 tool_id(get_tools 查询)。返回 { nodeId, name, outputs }。', + '新增节点并从上游出口接一条入边(建即接,杜绝孤立节点);可新增的节点与当前画布的节点菜单一致。' + + 'source_anchor_id:普通连接传上游节点 id;接判断器/意图识别某分支时传 get_node_anchors 返回的出口 id;循环体首个节点接 "loop-start-node"。' + + 'tool-lib-node 需同时传 tool_id(get_tools 或 list_datasource 查询)。' + + '数据源节点(本地文件 / Web 站点 / 数据源工具)是流程入口,不接上游,必须省略 source_anchor_id。' + + '返回 { nodeId, name, outputs };带 note 表示该节点暂不支持 AI 配置。', parameters: { type: 'object', properties: { type: { type: 'string', description: '节点类型(节点目录中的 type)' }, - source_anchor_id: { type: 'string', description: '上游节点 id 或出口 id' }, + source_anchor_id: { type: 'string', description: '上游节点 id 或出口 id;仅数据源节点省略' }, name: { type: 'string', description: '节点名称(可选,体现用途,如 "产品知识检索")' }, tool_id: { type: 'string', description: 'type=tool-lib-node 时必填' }, lf_id: LF_ID_SCHEMA, }, - required: ['type', 'source_anchor_id'], + required: ['type'], }, apply: async (args) => { const canvas = await canvasOrError(bindings, args.lf_id) if ('error' in canvas) return canvas const type = String(args.type) - if (FIXED_NODE_TYPES.includes(type)) return { error: `${type} 是画布固定节点,不能新增` } - if (!nodeAgentFor(type)) return { error: `暂不支持 AI 生成 ${type},请从节点目录中选择` } + if (fixedNodeTypesOf(canvas.mode).includes(type)) return new ErrorResult(`${type} 是画布固定节点,已存在且只能有一个,不能新增${nodeAgentFor(type) ? ';要修改它用 configure_node' : ''}`) let template: ShapeItem | { error: string } | undefined if (type === WorkflowNodeType.ToolLib) { - if (!args.tool_id) return { error: 'tool-lib-node 需要 tool_id,先 get_tools 查询' } + if (!args.tool_id) return { error: 'tool-lib-node 需要 tool_id,先 get_tools / list_datasource 查询' } template = await toolNodeTemplate(bindings, String(args.tool_id)) } else { template = findNodeTemplate(canvas.mode, type) } if (!template) { - return { - error: - canvas.mode === WorkflowMode.ApplicationLoop ? `循环体内不能使用 ${type}` : `主画布不能使用 ${type}(Break/Continue 只能建在循环体内)`, - } + const where = canvas.lfId === MAIN_LF_ID ? '主画布' : '循环体内' + return { error: `${where}不能使用 ${type}:不在该画布的节点菜单中,请从节点目录中选择(Break/Continue 只能建在循环体内)` } } if ('error' in template) return template - const source = resolveSourceAnchor(canvas.lf, String(args.source_anchor_id)) + const override = args.name ? { stepName: String(args.name) } : {} + const created = (node: ReturnType) => ({ + nodeId: node.id, + name: nodeNameOf(node), + lf_id: canvas.lfId, + outputs: nodeOutputFields(node), + ...(nodeAgentFor(type) ? {} : { note: '该节点暂不支持 AI 配置:不要对它 configure_node,完成后提示用户手动配置' }), + }) + // 数据源节点是流程入口,没有入口锚点:不接上游;其余节点必须建即接 + const sourceAnchor = typeof args.source_anchor_id === 'string' ? args.source_anchor_id.trim() : '' + if (template.properties?.kind === WorkflowKind.DataSource) { + if (sourceAnchor) return { error: '数据源节点是流程入口,不能接上游,请省略 source_anchor_id' } + return created(addNode(canvas.lf, template, '', override)) + } + if (!sourceAnchor) return { error: 'source_anchor_id 必填:只有数据源节点可以不接上游' } + const source = resolveSourceAnchor(canvas.lf, sourceAnchor) if ('error' in source) return source - const node = addNode(canvas.lf, template, source.node.id, args.name ? { stepName: String(args.name) } : {}) + const node = addNode(canvas.lf, template, source.node.id, override) const error = addEdge(canvas.lf, source.node.id, source.anchorId, node.id) if (error) return { error } - return { nodeId: node.id, name: nodeNameOf(node), lf_id: canvas.lfId, outputs: nodeOutputFields(node) } + return created(node) }, } } @@ -214,7 +440,8 @@ export function addEdgeTool(bindings: AgentBindings): AgentTool { if ('error' in source) return source const target = getNode(canvas.lf, String(args.target)) if (!target) return { error: `目标节点不在该画布: ${args.target}` } - if ([...FIXED_NODE_TYPES].includes(String(target.type))) return { error: '固定节点不能作为连线终点' } + if (fixedNodeTypesOf(canvas.mode).includes(String(target.type))) return { error: '固定节点不能作为连线终点' } + if (target.properties.kind === WorkflowKind.DataSource) return { error: '数据源节点是流程入口,不能作为连线终点' } const error = addEdge(canvas.lf, source.node.id, source.anchorId, target.id) return error ? { error } : { ok: true } }, @@ -231,7 +458,9 @@ export function deleteNodeTool(bindings: AgentBindings): AgentTool { if ('error' in canvas) return canvas const node = getNode(canvas.lf, String(args.nodeId)) if (!node) return { error: `节点不存在: ${args.nodeId}` } - if (FIXED_NODE_TYPES.includes(String(node.type))) return { error: '固定节点不能删除' } + if (fixedNodeTypesOf(canvas.mode).includes(String(node.type))) { + return new ErrorResult(`${node.type} 是画布固定节点,不能删除${nodeAgentFor(String(node.type)) ? ';要修改它用 configure_node' : ''}`) + } const downstream = canvas.lf.graphModel.getNodeOutgoingNode(node.id) canvas.lf.graphModel .getNodeOutgoingNode(node.id) @@ -295,8 +524,9 @@ function checkArgs( for (const [name, value] of Object.entries(input)) { const schema = (properties[name] ?? (typeof additionalProperties === 'object' ? additionalProperties : undefined)) as JsonSchema | undefined if (!schema) return { error: `没有入参 ${name},可用入参:${Object.keys(properties).join('、') || '无'}` } - // 带 items 的 array 形参可接收多个引用(如提示词素材、重排内容),其余形参只接收一个引用 - const multiple = schema.type === 'array' && schema.items !== undefined + // 带 items 的 array 形参可接收多个引用(如提示词素材、重排内容),其余形参只接收一个引用; + // x-single 表示 items 只描述引用值的元素结构,仍只接一个引用(如知识库写入的文档列表) + const multiple = schema.type === 'array' && schema.items !== undefined && schema['x-single'] !== true const references = isReference(value) ? [value] : multiple && Array.isArray(value) && value.every(isReference) ? (value as NodeReference[]) : undefined if (!references) return { error: multiple ? `入参 ${name} 应为引用 [节点ID, 字段] 或其列表` : `入参 ${name} 应为一个引用 [节点ID, 字段]` } // 多引用形参逐个按 items 类型比对,单引用形参按形参类型比对 @@ -314,6 +544,37 @@ function checkArgs( return { args } } +/** 已绑定引用的值结构:上游 returns 对该字段逐字段声明了结构时渲染出来,供配置 agent 按真实结构处理数据 */ +function argStructuresOf(lf: LogicFlow, args: Record): Record { + const structures: Record = {} + for (const value of Object.values(args)) { + for (const [refNodeId, field] of isReference(value) ? [value] : value) { + const refNode = getNode(lf, refNodeId) + const schema = refNode ? nodeAgentFor(String(refNode.type))?.returns.properties[field] : undefined + if (isStructuredSchema(schema)) structures[`${refNodeId}.${field}`] = schemaType(schema) + } + } + return structures +} + +/** + * 直连下游节点需要的结构化入参:本节点的输出通常就是它们的实参(如自定义代码 → 知识库写入的 documents), + * 配置时把目标结构告诉本节点,让产出服务于下游,而不是等下游绑定时才发现结构不符。 + */ +function downstreamContractsOf(lf: LogicFlow, nodeId: string): string[] { + return lf.graphModel.getNodeOutgoingNode(nodeId).flatMap((target) => { + const module = nodeAgentFor(String(target.type)) + if (!module) return [] + const name = nodeNameOf(target) + return Object.entries(module.params.properties) + .filter(([, schema]) => isStructuredSchema(schema)) + .map(([param, schema]) => { + const description = typeof schema.description === 'string' ? `(${schema.description})` : '' + return `- ${name}(${target.type})的入参 ${param}${description}:${schemaType(schema)}` + }) + }) +} + /** x-bind 形参:实参由框架直接写入 node_data 的对应字段 */ function writeBoundArgs(node: ReturnType, module: NodeAgentModule, args: Record) { if (!node) return @@ -353,7 +614,7 @@ export function configureNodeTool(bindings: AgentBindings): AgentTool { if (!canvas) return new ErrorResult(`节点不存在: ${nodeId}`, { nodeId }) const node = getNode(canvas.lf, nodeId)! const module = nodeAgentFor(String(node.type)) - if (!module) return new ErrorResult(`${node.type} 暂不支持 AI 配置`, { nodeId }) + if (!module) return new ErrorResult(`${node.type} 暂不支持 AI 配置,请在总结里提示用户手动配置`, { nodeId }) const checked = checkArgs(canvas.lf, nodeId, module, args.args) if ('error' in checked) return new ErrorResult(checked.error, { nodeId }) // 等待节点表单完成挂载与默认值初始化,再写入配置 @@ -368,7 +629,17 @@ export function configureNodeTool(bindings: AgentBindings): AgentTool { const requirement = typeof args.requirement === 'string' ? args.requirement.trim() : '' const expectedReturns = Array.isArray(args.returns) ? args.returns.map(String) : [] child = createAgent( - module.useAgent({ lf: canvas.lf, nodeId, workflowMode: canvas.mode, bindings: childBindings, args: checked.args, requirement, expectedReturns }), + module.useAgent({ + lf: canvas.lf, + nodeId, + workflowMode: canvas.mode, + bindings: childBindings, + args: checked.args, + requirement, + expectedReturns, + argStructures: argStructuresOf(canvas.lf, checked.args), + downstream: downstreamContractsOf(canvas.lf, nodeId), + }), ) const result = await child.run( requirement ? `请按以下要求配置本节点(生成内容的字段按描述生成完整内容,资源字段查出真实 id):\n${requirement}` : '请结合整体目标与已绑定入参,自行判断并配置本节点。', @@ -396,11 +667,23 @@ function graphOf(lf: LogicFlow) { } as unknown as ConstructorParameters[0] } +/** + * 按画布模式选择图结构校验器:工具 / 知识库主画布有各自的开始节点与流程规则。 + * 循环体与 loop-body-node 视图的校验一致:知识库循环用 KnowledgeWorkFlowInstance,其余(含工具循环)用 WorkFlowInstance, + * 工具循环体的开始节点是 loop-start-node,不能用按 tool-start-node 找开始节点的 ToolWorkFlowInstance。 + */ +function instanceOf(mode: WorkflowMode, graph: ReturnType): WorkFlowInstance { + if (mode === WorkflowMode.Tool) return new ToolWorkFlowInstance(graph, mode) + if (mode === WorkflowMode.Knowledge || mode === WorkflowMode.KnowledgeLoop) return new KnowledgeWorkFlowInstance(graph, mode) + return new WorkFlowInstance(graph, mode) +} + export function validateTool(bindings: AgentBindings): AgentTool { return { name: 'validate', description: - '校验整张工作流:连线完整性(未接入流程的节点、未连接的分支、不能作为结束的节点)、循环体结构与各节点表单必填项。返回 { qualified, problems }。', + '校验整张工作流:连线完整性(未接入流程的节点、未连接的分支、不能作为结束的节点)、循环体结构与各节点表单必填项;' + + '工具工作流还检查输出参数是否都有变量赋值写入、是否引用了未声明的输入 / 输出参数。返回 { qualified, problems }。', parameters: { type: 'object', properties: {} }, apply: async () => { const problems: string[] = [] @@ -408,7 +691,7 @@ export function validateTool(bindings: AgentBindings): AgentTool { for (const canvas of canvases) { const where = canvas.lfId === MAIN_LF_ID ? '主画布' : `循环体(${canvas.lfId})` try { - const instance = new WorkFlowInstance(graphOf(canvas.lf), canvas.mode) + const instance = instanceOf(canvas.mode, graphOf(canvas.lf)) if (canvas.lfId === MAIN_LF_ID) instance.is_valid() else { instance.is_loop_valid() @@ -429,6 +712,12 @@ export function validateTool(bindings: AgentBindings): AgentTool { if (result.status === 'rejected') problems.push(`${where}:${formatValidationError(result.reason)}`) }) } + if (isToolMode(bindings.workflowMode)) { + problems.push(...brokenToolParamReferences(bindings.rootLf, canvases)) + unwrittenToolOutputs(bindings.rootLf, canvases).forEach((field) => + problems.push(`输出参数 ${field} 没有被写入:需要在产出结果的节点之后接变量赋值节点,target 为 ["output", "${field}"],否则调用方拿不到该字段`), + ) + } return { qualified: problems.length === 0, problems } }, } diff --git a/ui/src/workflow-canvas/ai-generate/tools/node.ts b/ui/src/workflow-canvas/ai-generate/tools/node.ts index 19aa6241777..a84b8ee577c 100644 --- a/ui/src/workflow-canvas/ai-generate/tools/node.ts +++ b/ui/src/workflow-canvas/ai-generate/tools/node.ts @@ -7,6 +7,7 @@ import { WorkflowNodeType } from '@/workflow-canvas/types' import { useAgentConfig } from '../common' import type { AgentTool, JsonObjectSchema, JsonSchema, NodeAgentContext, NodeAgentModule, NodeReference, RunnableAgent } from '../type' import { + VARIABLE_GROUPS, getDefaultModelConfig, getFieldOptions, getNode, @@ -55,9 +56,9 @@ export function argReference(context: NodeAgentContext, name: string): NodeRefer return argReferences(context, name)[0] } -/** 引用在提示词里的写法:{{节点名称.字段}},全局变量为 {{global.字段}} */ +/** 引用在提示词里的写法:{{节点名称.字段}},全局 / 会话 / 循环变量为 {{分组.字段}} */ export function promptVariableOf(context: NodeAgentContext, [refNodeId, field]: NodeReference): string { - if (refNodeId === 'global') return `{{global.${field}}}` + if (VARIABLE_GROUPS.includes(refNodeId)) return `{{${refNodeId}.${field}}}` const group = getFieldOptions(context.lf, context.nodeId).find((item) => item.nodeId === refNodeId) return `{{${group?.name ?? refNodeId}.${field}}}` } @@ -65,7 +66,10 @@ export function promptVariableOf(context: NodeAgentContext, [refNodeId, field]: /** 已绑定实参的说明,写进节点配置 agent 的 prompt */ function describeArgs(context: NodeAgentContext): string { const lines = Object.keys(context.args).map((name) => { - const references = argReferences(context, name).map((reference) => `${JSON.stringify(reference)}(提示词写作 ${promptVariableOf(context, reference)})`) + const references = argReferences(context, name).map((reference) => { + const structure = context.argStructures[reference.join('.')] + return `${JSON.stringify(reference)}(提示词写作 ${promptVariableOf(context, reference)}${structure ? `;值结构:${structure}` : ''})` + }) return `- ${name} = ${references.join('、')}` }) return lines.length ? `已绑定入参(由父 agent 连线决定,需要引用上游数据时使用这些,不要另选):\n${lines.join('\n')}` : '本节点没有绑定入参。' @@ -92,11 +96,6 @@ export function formatValidationError(error: unknown): string { return prefix + (typeof error === 'string' ? error : JSON.stringify(errMessage ?? error)) } -export function summarizeNodeData(value: unknown): unknown { - const text = JSON.stringify(value ?? {}) - return text.length > 600 ? `${text.slice(0, 600)}…` : value -} - /** * 调用节点视图注册的 validate()(表单必填与引用有效性),与发布前校验同一套规则。 * 等两次 nextTick,让表单先按新写入的 node_data 渲染;节点未挂载表单时视为通过。 @@ -299,9 +298,15 @@ const NODE_GUIDE = `你是 MaxKB 工作流某个节点的配置专家,只配 export function useNodeAgent(context: NodeAgentContext, config: { prompt: string; tools: AgentTool[] }): RunnableAgent { const node = getNode(context.lf, context.nodeId) const expected = context.expectedReturns.length ? `\n要求产出的返回字段(字段名必须一致):${context.expectedReturns.join('、')}` : '' + const downstream = context.downstream.length + ? `\n下游要求(本节点直连的下游节点需要的入参结构;本节点的输出交给它时,返回值必须严格符合该结构,顶层是数组就返回列表,不要返回单个对象或以名称为键的字典):\n${context.downstream.join('\n')}` + : '' + // 带上节点现有配置:续聊修改时在现状上改,而不是不知情地整体重写 + const currentText = JSON.stringify(node?.properties.node_data ?? {}) + const current = currentText.length > 2 ? `\n当前配置(node_data;修改已有节点时在此基础上只改要求涉及的部分,其余保持):\n${currentText.length > 8000 ? `${currentText.slice(0, 8000)}…` : currentText}` : '' return useAgentConfig( { - prompt: `${NODE_GUIDE}\n\n${config.prompt}\n\n本节点:id=${context.nodeId},名称=${node ? nodeNameOf(node) : context.nodeId}\n${describeArgs(context)}${expected}`, + prompt: `${NODE_GUIDE}\n\n${config.prompt}\n\n本节点:id=${context.nodeId},名称=${node ? nodeNameOf(node) : context.nodeId}\n${describeArgs(context)}${expected}${downstream}${current}`, tools: [getFieldOptionsTool(context), ...config.tools], }, context.bindings, @@ -361,11 +366,25 @@ export function createLoopConditionAgent(type: WorkflowNodeType.LoopBreakNode | // 节点函数签名 // ============================================================ -/** JSON Schema 渲染成 TS 风格的类型 */ -function schemaType(schema: JsonSchema | undefined): string { +/** 带逐字段结构的 schema:有 properties 的对象,或元素为这种对象的数组 */ +export function isStructuredSchema(schema: JsonSchema | undefined): schema is JsonSchema { + if (!schema) return false + const target = schema.type === 'array' ? (schema.items as JsonSchema | undefined) : schema + return target?.type === 'object' && !!target.properties && typeof target.properties === 'object' +} + +/** JSON Schema 渲染成 TS 风格的类型;带 properties 的对象逐字段展开(含必填与说明),供描述嵌套结构 */ +export function schemaType(schema: JsonSchema | undefined): string { if (!schema) return 'any' if (Array.isArray(schema.enum)) return schema.enum.map((value) => JSON.stringify(value)).join(' | ') - if (schema.type === 'array') return `${schemaType(schema.items as JsonSchema | undefined)}[]` + if (schema.type === 'array') { + const item = schemaType(schema.items as JsonSchema | undefined) + return item.startsWith('{') ? `Array<${item}>` : `${item}[]` + } + if (schema.type === 'object' && schema.properties && typeof schema.properties === 'object') { + const fields = objectFields(schema as unknown as JsonObjectSchema, '字段名') + return fields.length ? `{ ${fields.join('; ')} }` : 'object' + } return typeof schema.type === 'string' ? schema.type : 'any' } @@ -397,7 +416,17 @@ function configSummary(schema: JsonObjectSchema): string { * // 名称:说明 * // 配置:配置项 * type(形参): { 返回字段 } + * 未注册节点配置 agent 的菜单节点用 renderPlainNodeFunction:说明取菜单文案,返回取模板的 config.fields。 */ +export function renderPlainNodeFunction(type: string, label: string, text: string, outputs: Array<{ value: string; label: string }>): string { + const returns = outputs.map((output) => `${output.value}: any /* ${output.label} */`) + return [ + `// ${label}:${text || label}`, + '// 配置:暂不支持 AI 配置(可新增并连线,不能 configure_node,需用户手动配置)', + `${type}(): ${returns.length ? `{ ${returns.join('; ')} }` : 'void'}`, + ].join('\n') +} + export function renderNodeFunction(type: string, label: string, module: NodeAgentModule): string { const params = objectFields(module.params, '参数名') const returns = objectFields(module.returns, '字段名') diff --git a/ui/src/workflow-canvas/ai-generate/type.ts b/ui/src/workflow-canvas/ai-generate/type.ts index c6470109898..247a144a92b 100644 --- a/ui/src/workflow-canvas/ai-generate/type.ts +++ b/ui/src/workflow-canvas/ai-generate/type.ts @@ -157,6 +157,8 @@ export interface AgentHandle { run: (requirement: string) => Promise /** 中断在途请求并级联停止委派中的子 agent */ stop: () => void + /** 本 agent 的消息历史(活引用);停止后重建 agent 时可传给 bindings.messages 续聊 */ + messages: AgentMessage[] } // ============================================================ @@ -170,6 +172,7 @@ export type JsonSchema = Record * 对象形 JSON Schema:节点的 params / config / returns 统一使用。 * 框架扩展字段(渲染给模型时去掉): * - params 属性上的 x-bind:实参由框架直接写入 node_data 的该字段;未声明则交由节点配置 agent 使用; + * - params 数组属性上的 x-single:items 只描述引用值的元素结构,仍只接一个引用(默认带 items 的数组形参可接多个引用); * - returns.additionalProperties 上的 x-from:动态返回字段来自哪一项配置。 */ export interface JsonObjectSchema { @@ -199,6 +202,10 @@ export interface NodeAgentContext { requirement: string /** 父 agent 要求产出的动态返回字段名 */ expectedReturns: string[] + /** 已绑定引用的值结构:键为 "节点ID.字段",取上游 returns 中逐字段声明的结构 */ + argStructures: Record + /** 直连下游节点需要的结构化入参(每行一个),本节点输出交给它们时须符合 */ + downstream: string[] } /** diff --git a/ui/src/workflow-canvas/nodes/data-source-local-node/agent.ts b/ui/src/workflow-canvas/nodes/data-source-local-node/agent.ts new file mode 100644 index 00000000000..7f9097ead53 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/data-source-local-node/agent.ts @@ -0,0 +1,93 @@ +import { getNode, readNodeData, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { createUpdateTool, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** data-source-local-node(本地文件数据源)的 AI 生成配置:流程入口,无入参,只有上传限制 */ + +const params: JsonObjectSchema = { type: 'object', properties: {} } + +const config: JsonObjectSchema = { + type: 'object', + properties: { + file_type_list: { + type: 'array', + items: { type: 'string' }, + description: '允许上传的文件格式(大写扩展名),默认 TXT、DOCX、PDF、HTML、XLS、XLSX、ZIP、CSV、MD', + }, + file_count_limit: { type: 'integer', description: '最多上传文件数,1–1000,默认 50' }, + file_size_limit: { type: 'integer', description: '单个文件大小上限(MB),1–1000,默认 100' }, + }, +} + +/** 返回 file_list 的结构,逐字段对应后端 DataSourceLocalNode 写入的数据(文件项原样展开后补 meta) */ +const FILE_SCHEMA = { + type: 'object', + properties: { + file_id: { type: 'string', description: '文件 id' }, + name: { type: 'string', description: '文件名,含扩展名' }, + meta: { type: 'object', description: '含 source_type="local"、source_file_id 及工作流来源信息' }, + }, + required: ['file_id', 'name', 'meta'], +} + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + file_list: { + type: 'array', + items: FILE_SCHEMA, + description: '用户运行时上传的文件列表,只有文件信息、没有文本内容,需接文档内容提取解析', + }, + }, + additionalProperties: false, +} + +/** 上限类配置:未传返回 undefined,非法返回错误文本 */ +function limitOf(value: unknown, label: string): number | string | undefined { + if (value === undefined || value === null) return undefined + const limit = Number(value) + return Number.isInteger(limit) && limit >= 1 && limit <= 1000 ? limit : `${label} 应为 1–1000 的整数` +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'data-source-local-node(本地文件)配置要点:', + '- 只有上传限制三项:文件格式、最多上传文件数、单个文件大小上限;没有特别要求时保持默认,直接调用 update(不传参数)完成校验。', + ].join('\n'), + tools: [ + createUpdateTool( + context, + config, + (args) => { + const node = getNode(context.lf, context.nodeId)! + const fileCountLimit = limitOf(args.file_count_limit, 'file_count_limit') + if (typeof fileCountLimit === 'string') return fileCountLimit + const fileSizeLimit = limitOf(args.file_size_limit, 'file_size_limit') + if (typeof fileSizeLimit === 'string') return fileSizeLimit + const fileTypes = Array.isArray(args.file_type_list) ? args.file_type_list.map((fileType) => String(fileType).trim().toUpperCase()).filter(Boolean) : undefined + if (fileTypes && !fileTypes.length) return 'file_type_list 至少包含一种文件格式' + writeNodeProperties(node, { + node_data: { + ...readNodeData>(node), + ...(fileTypes ? { file_type_list: fileTypes } : {}), + ...(fileCountLimit ? { file_count_limit: fileCountLimit } : {}), + ...(fileSizeLimit ? { file_size_limit: fileSizeLimit } : {}), + }, + }) + return null + }, + '配置当前本地文件数据源的上传限制,只传要修改的字段,其余保持不变。写入后自动校验,通过即配置完成并交付输出字段 file_list。', + ), + ], + }) +} + +export default { + description: + '知识库工作流的入口数据源:运行时由用户上传本地文档,输出文件列表(不解析内容);下游接文档内容提取。新增时不接上游(省略 source_anchor_id)', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/data-source-local-node/index.ts b/ui/src/workflow-canvas/nodes/data-source-local-node/index.ts index 5ef3b0abf9f..ef2d5ee08d6 100644 --- a/ui/src/workflow-canvas/nodes/data-source-local-node/index.ts +++ b/ui/src/workflow-canvas/nodes/data-source-local-node/index.ts @@ -1,4 +1,5 @@ import DataSourceLocalNode from './index.vue' +import agent from './agent' import DataSourceLocalNodeDetail from './details/index.vue' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -14,4 +15,5 @@ export default { model: WorkflowNodeModel, view: DataSourceLocalNodeView, details: DataSourceLocalNodeDetail, + agent, } diff --git a/ui/src/workflow-canvas/nodes/data-source-web-node/agent.ts b/ui/src/workflow-canvas/nodes/data-source-web-node/agent.ts new file mode 100644 index 00000000000..a554ea76838 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/data-source-web-node/agent.ts @@ -0,0 +1,47 @@ +import { createValidateTool, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** data-source-web-node(Web 站点数据源)的 AI 生成配置:流程入口,无入参也无配置项 */ + +const params: JsonObjectSchema = { type: 'object', properties: {} } + +const config: JsonObjectSchema = { type: 'object', properties: {} } + +/** 返回 document_list 的结构,逐字段对应后端 DataSourceWebNode 写入的数据 */ +const DOCUMENT_SCHEMA = { + type: 'object', + properties: { + name: { type: 'string', description: '文档名:链接文字,没有则为页面地址' }, + content: { type: 'string', description: '页面正文,已转为 Markdown' }, + meta: { type: 'object', description: '含 source_type="web"、source_url 页面地址、selector 选择器及工作流来源信息' }, + }, + required: ['name', 'content', 'meta'], +} + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + document_list: { + type: 'array', + items: DOCUMENT_SCHEMA, + description: '从根地址起逐层抓取到的页面列表,已含文本内容(没有 id / file_id),可直接作为文档分段的入参,不要再接文档内容提取', + }, + }, + additionalProperties: false, +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: 'data-source-web-node(Web 站点)没有配置项:根地址与选择器由用户在运行时填写,直接调用 validate 完成。', + tools: [createValidateTool(context)], + }) +} + +export default { + description: + '知识库工作流的入口数据源:运行时由用户输入根地址(可选选择器,默认 body),自动抓取 Web 页面(单链接对应单文档),输出含内容的文档列表;下游可直接接文档分段。新增时不接上游(省略 source_anchor_id),无配置项,无需 configure_node', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/data-source-web-node/index.ts b/ui/src/workflow-canvas/nodes/data-source-web-node/index.ts index a7e475e9a17..2ea42141e51 100644 --- a/ui/src/workflow-canvas/nodes/data-source-web-node/index.ts +++ b/ui/src/workflow-canvas/nodes/data-source-web-node/index.ts @@ -1,4 +1,5 @@ import DataSourceWebNode from './index.vue' +import agent from './agent' import DataSourceWebNodeDetail from './details/index.vue' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -9,4 +10,10 @@ class DataSourceWebNodeView extends WorkflowNodeView { } } -export default { type: WorkflowNodeType.DataSourceWebNode, model: WorkflowNodeModel, view: DataSourceWebNodeView, details: DataSourceWebNodeDetail } +export default { + type: WorkflowNodeType.DataSourceWebNode, + model: WorkflowNodeModel, + view: DataSourceWebNodeView, + details: DataSourceWebNodeDetail, + agent, +} diff --git a/ui/src/workflow-canvas/nodes/document-extract-node/agent.ts b/ui/src/workflow-canvas/nodes/document-extract-node/agent.ts index 45ef5bfd2a8..c428220f271 100644 --- a/ui/src/workflow-canvas/nodes/document-extract-node/agent.ts +++ b/ui/src/workflow-canvas/nodes/document-extract-node/agent.ts @@ -3,12 +3,45 @@ import { argReference, finishWithValidate, useNodeAgent } from '@/workflow-canva import { ErrorResult } from '@/workflow-canvas/ai-generate/tools/result' import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' -/** document-extract-node(文档内容提取)的 AI 生成配置:只有入参文档(documents),无其他可配置项 */ +/** document-extract-node(文档内容提取)的 AI 生成配置:只有入参文件列表(documents),无其他可配置项;返回拼接文本与逐篇文档列表 */ + +/** + * 入参 documents / 返回 document_list 的结构,逐字段对应后端 DocumentExtractNode 读写的数据。 + * x-single:items 只描述引用值的元素结构,这里只接一个引用。 + */ +const FILE_SCHEMA = { + type: 'object', + properties: { + file_id: { type: 'string', description: '文件 id,按它读取文件内容' }, + name: { type: 'string', description: '文件名,按扩展名选择解析方式' }, + meta: { type: 'object', description: '来源信息,原样带到输出的 meta' }, + }, + required: ['file_id', 'name'], +} + +const DOCUMENT_SCHEMA = { + type: 'object', + properties: { + id: { type: 'string', description: '源文件 id' }, + name: { type: 'string', description: '文档名;ZIP 会展开为包内每个文件各一项' }, + content: { type: 'string', description: '该文档全文' }, + meta: { type: 'object', description: '输入文件的 meta,并补 source_file_id' }, + }, + required: ['id', 'name', 'content'], +} const params: JsonObjectSchema = { type: 'object', properties: { - documents: { type: 'array', description: '要提取内容的文档,常来自开始节点用户上传的 document', 'x-bind': 'document_list' }, + documents: { + type: 'array', + items: FILE_SCHEMA, + 'x-single': true, + description: + '要提取内容的文件列表:应用工作流常来自开始节点用户上传的 document,知识库工作流来自本地文件数据源的 file_list;' + + '不要接已含文本的 document_list(如 Web 站点数据源的输出,没有 file_id)', + 'x-bind': 'document_list', + }, }, required: ['documents'], } @@ -18,8 +51,15 @@ const config: JsonObjectSchema = { type: 'object', properties: {} } const returns: JsonObjectSchema = { type: 'object', properties: { - content: { type: 'string', description: '提取出的文档文本,适合放进提示词' }, - document_list: { type: 'array', description: '文档列表' }, + content: { + type: 'string', + description: '全部文件的文本拼成的一个字符串:每个文件为 "### 文件名\\n正文",文件之间用分隔线隔开;适合放进提示词', + }, + document_list: { + type: 'array', + items: DOCUMENT_SCHEMA, + description: '逐篇的文档列表,可直接作为文档分段的入参', + }, }, additionalProperties: false, } @@ -52,7 +92,7 @@ function useAgent(context: NodeAgentContext) { } export default { - description: '提取用户上传文档的文本内容,供下游 AI 对话基于文档回答(如总结、问答)', + description: '提取用户上传文档的文本内容,供下游 AI 对话基于文档回答(如总结、问答);知识库工作流中接在本地文件数据源之后,document_list 供文档分段', params, config, returns, diff --git a/ui/src/workflow-canvas/nodes/document-split-node/agent.ts b/ui/src/workflow-canvas/nodes/document-split-node/agent.ts new file mode 100644 index 00000000000..524f78815fc --- /dev/null +++ b/ui/src/workflow-canvas/nodes/document-split-node/agent.ts @@ -0,0 +1,154 @@ +import { getNode, readNodeData, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { argReference, createUpdateTool, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' +import { splitPatternOptions } from './constant' + +/** document-split-node(文档分段)的 AI 生成配置:入参文档列表,配置分段策略及其参数(均按自定义常量写入),返回带分段的文档列表 */ + +const SPLIT_STRATEGIES = ['auto', 'custom', 'qa'] +const LENGTH_MIN = 50 +const LENGTH_MAX = 100000 + +/** + * 入参 documents / 返回 paragraph_list 的结构,逐字段对应后端 DocumentSplitNode 读写的数据。 + * x-single:items 只描述引用值的元素结构,这里只接一个引用。 + */ +const INPUT_DOCUMENT_SCHEMA = { + type: 'object', + properties: { + name: { type: 'string', description: '文档名;.xlsx / .xls 会按 sheet 拆成多篇' }, + content: { type: 'string', description: '文档全文,按分段策略切分' }, + id: { type: 'string', description: '源文件 id,写到输出的 source_file_id' }, + meta: { type: 'object', description: '来源信息,原样带到输出的 meta' }, + }, + required: ['name', 'content'], +} + +const PARAGRAPH_SCHEMA = { + type: 'object', + properties: { + title: { type: 'string', description: '分段标题(qa 策略为问题)' }, + content: { type: 'string', description: '分段内容' }, + problem_list: { type: 'array', items: { type: 'string' }, description: '关联问题:按配置加入分段标题 / 文档名称' }, + chunks: { type: 'array', items: { type: 'string' }, description: '按 chunk_size 切出的子分块' }, + is_active: { type: 'boolean', description: '固定为 true' }, + }, + required: ['content'], +} + +const OUTPUT_DOCUMENT_SCHEMA = { + type: 'object', + properties: { + name: { type: 'string', description: '文档名(Excel 为 sheet 名)' }, + paragraphs: { type: 'array', items: PARAGRAPH_SCHEMA, description: '分段列表' }, + meta: { type: 'object', description: '输入文档的 meta,并补 knowledge_id、source_file_id' }, + source_file_id: { type: 'string', description: '输入文档的 id,没有则为 null' }, + }, + required: ['name', 'paragraphs'], +} + +const params: JsonObjectSchema = { + type: 'object', + properties: { + documents: { + type: 'array', + items: INPUT_DOCUMENT_SCHEMA, + 'x-single': true, + description: '要分段的文档列表:文档内容提取或 Web 站点数据源的 document_list;不要直接接本地文件的 file_list(没有 content)', + 'x-bind': 'document_list', + }, + }, + required: ['documents'], +} + +const config: JsonObjectSchema = { + type: 'object', + properties: { + split_strategy: { type: 'string', enum: SPLIT_STRATEGIES, description: '分段策略:auto 智能分段(推荐,默认)/ custom 高级分段 / qa QA 问答对' }, + chunk_size: { type: 'integer', description: `子分块长度,${LENGTH_MIN}–${LENGTH_MAX},默认 256` }, + patterns: { + type: 'array', + items: { type: 'string' }, + description: `分段标识,仅 custom 策略:按先后顺序递归分割,可选 ${splitPatternOptions.map((pattern) => pattern.label).join('、')},也可传自定义正则`, + }, + limit: { type: 'integer', description: `分段长度,仅 custom 策略,${LENGTH_MIN}–${LENGTH_MAX},默认 4096` }, + with_filter: { type: 'boolean', description: '自动清洗,仅 custom 策略:去掉重复多余的符号、空格、空行、制表符' }, + paragraph_title_relate_problem: { type: 'boolean', description: '分段标题设置为分段的关联问题,qa 策略不适用,默认 false' }, + document_name_relate_problem: { type: 'boolean', description: '文档名称设置为分段的关联问题,默认 false' }, + }, +} + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + paragraph_list: { + type: 'array', + items: OUTPUT_DOCUMENT_SCHEMA, + description: '带分段的文档列表(不是扁平的分段数组),可直接作为知识库写入的入参', + }, + }, + additionalProperties: false, +} + +/** 长度类配置:未传返回 undefined,非法返回错误文本 */ +function lengthOf(value: unknown, label: string): number | string | undefined { + if (value === undefined || value === null) return undefined + const length = Number(value) + return Number.isInteger(length) && length >= LENGTH_MIN && length <= LENGTH_MAX ? length : `${label} 应为 ${LENGTH_MIN}–${LENGTH_MAX} 的整数` +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'document-split-node(文档分段)配置要点:', + '- 文档来自入参 documents(已绑定),无需另选。', + '- split_strategy:没有特别要求用 auto;要按指定符号 / 标题层级切分用 custom,并给 patterns(如按 Markdown 标题切分传 ["#", "##"])与 limit;内容本身是问答对时用 qa。', + '- 只传需要设置的字段,其余保持默认;写入的都是自定义常量。', + ].join('\n'), + tools: [ + createUpdateTool( + context, + config, + (args) => { + const node = getNode(context.lf, context.nodeId)! + const reference = argReference(context, 'documents') + if (!reference) return '缺少入参 documents:需要上游传入文档列表,请先把 documents 绑定到文档来源节点' + if (args.split_strategy !== undefined && !SPLIT_STRATEGIES.includes(String(args.split_strategy))) { + return `split_strategy 不合法:${args.split_strategy},可选 ${SPLIT_STRATEGIES.join(' / ')}` + } + const chunkSize = lengthOf(args.chunk_size, 'chunk_size') + if (typeof chunkSize === 'string') return chunkSize + const limit = lengthOf(args.limit, 'limit') + if (typeof limit === 'string') return limit + const patterns = Array.isArray(args.patterns) + ? args.patterns.map((pattern) => splitPatternOptions.find((option) => option.label === pattern)?.value ?? String(pattern)).filter(Boolean) + : undefined + const booleanFields = ['with_filter', 'paragraph_title_relate_problem', 'document_name_relate_problem'].filter( + (field) => typeof args[field] === 'boolean', + ) + writeNodeProperties(node, { + node_data: { + ...readNodeData>(node), + document_list: [...reference], + ...(args.split_strategy !== undefined ? { split_strategy: String(args.split_strategy) } : {}), + ...(chunkSize ? { chunk_size: chunkSize, chunk_size_type: 'custom' } : {}), + ...(limit ? { limit, limit_type: 'custom' } : {}), + ...(patterns ? { patterns, patterns_type: 'custom' } : {}), + ...Object.fromEntries(booleanFields.flatMap((field) => [[field, args[field]], [`${field}_type`, 'custom']])), + }, + }) + return null + }, + '配置当前文档分段节点:分段策略及其参数,只传要设置的字段。写入后自动校验,通过即配置完成并交付输出字段 paragraph_list。', + ), + ], + }) +} + +export default { + description: '按分段策略把文档内容拆成分段列表,供知识库写入;上游接文档内容提取(本地文件)或 Web 站点数据源', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/document-split-node/index.ts b/ui/src/workflow-canvas/nodes/document-split-node/index.ts index f0035a85e35..ac00b3cf196 100644 --- a/ui/src/workflow-canvas/nodes/document-split-node/index.ts +++ b/ui/src/workflow-canvas/nodes/document-split-node/index.ts @@ -1,4 +1,5 @@ import DocumentSplitNode from './index.vue' +import agent from './agent' import DocumentSplitNodeDetail from './details/index.vue' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -21,4 +22,5 @@ export default { model: DocumentSplitNodeModel, view: DocumentSplitNodeView, details: DocumentSplitNodeDetail, + agent, } diff --git a/ui/src/workflow-canvas/nodes/knowledge-write-node/agent.ts b/ui/src/workflow-canvas/nodes/knowledge-write-node/agent.ts new file mode 100644 index 00000000000..75c45f45efe --- /dev/null +++ b/ui/src/workflow-canvas/nodes/knowledge-write-node/agent.ts @@ -0,0 +1,132 @@ +import type LogicFlow from '@logicflow/core' +import { getNode, readNodeData, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { argReference, finishWithValidate, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import { AgentErrorResult, ErrorResult } from '@/workflow-canvas/ai-generate/tools/result' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule, NodeReference } from '@/workflow-canvas/ai-generate/type' +import { WorkflowKind, WorkflowNodeType } from '@/workflow-canvas/types' + +/** knowledge-write-node(知识库写入)的 AI 生成配置:只有入参 documents(带分段的文档列表),无其他可配置项 */ + +/** + * 入参 documents 的结构,逐字段对应后端 KnowledgeWriteParamSerializer / ParagraphInstanceSerializer / TagInstanceSerializer。 + * x-single:items 只描述引用值的元素结构,这里只接一个引用。 + */ +const PARAGRAPH_SCHEMA = { + type: 'object', + properties: { + content: { type: 'string', description: '分段内容,最长 102400 字' }, + title: { type: 'string', description: '分段标题,最长 256 字' }, + problem_list: { type: 'array', items: { type: 'string' }, description: '关联问题' }, + chunks: { type: 'array', items: { type: 'string' }, description: '子分块;不传按内容自动切分' }, + is_active: { type: 'boolean', description: '是否启用' }, + }, + required: ['content'], +} + +const TAG_SCHEMA = { + type: 'object', + properties: { + key: { type: 'string', description: '标签名,1–64 字' }, + value: { type: 'string', description: '标签值,1–128 字' }, + }, + required: ['key', 'value'], +} + +const DOCUMENT_SCHEMA = { + type: 'object', + properties: { + name: { type: 'string', description: '文档名,1–128 字' }, + paragraphs: { type: 'array', items: PARAGRAPH_SCHEMA, description: '分段列表;为空则写入空文档' }, + tags: { type: 'array', items: TAG_SCHEMA, description: '文档标签;知识库中不存在的标签会自动创建' }, + meta: { type: 'object', description: '文档元数据;写入时会合并工作流来源信息' }, + source_file_id: { type: 'string', description: '源文件 id(UUID),用于关联原文件' }, + doc_strategy: { type: 'object', description: '文档处理策略(split 分段 / visual 视觉 / index 索引),一般不传,缺省用默认策略' }, + }, + required: ['name'], +} + +const params: JsonObjectSchema = { + type: 'object', + properties: { + documents: { + type: 'array', + items: DOCUMENT_SCHEMA, + 'x-single': true, + description: + '要写入的文档列表:直接写入引用 [文档分段节点ID, "paragraph_list"];需要标签 / 关联问题 / 元数据时引用加工过的 [代码节点ID, "result"]。' + + '不要接文档内容提取 / Web 站点的 document_list 或本地文件的 file_list(没有 paragraphs,写入后文档为空)', + 'x-bind': 'document_list', + }, + }, + required: ['documents'], +} + +const config: JsonObjectSchema = { type: 'object', properties: {} } + +const returns: JsonObjectSchema = { type: 'object', properties: {}, additionalProperties: false } + +/** 只有文本、没有分段的上游:接到写入节点不会报错,但写入的文档为空,必须先经过文档分段 */ +const UNSPLIT_SOURCES: Record = { + [WorkflowNodeType.DocumentExtractNode]: '文档内容提取', + [WorkflowNodeType.DataSourceWebNode]: 'Web 站点', + [WorkflowNodeType.DataSourceLocalNode]: '本地文件', +} + +/** 引用的上游节点:循环体内可能引用父画布节点,当前画布找不到时视为无法判断 */ +function findSourceNode(lf: LogicFlow, nodeId: string) { + const graphModel = lf.graphModel as LogicFlow['graphModel'] & { get_parent_nodes?: () => Array<{ id: string; type: string; properties: Record }> } + return getNode(lf, nodeId) ?? graphModel.get_parent_nodes?.().find((node) => node.id === nodeId) +} + +/** 校验写入来源格式:文档分段的 paragraph_list 直接通过;明确没有分段的来源拒绝;其余(代码加工结果、工具输出等)交由运行时 */ +function checkSource(context: NodeAgentContext, [refNodeId, field]: NodeReference): string | null { + const source = findSourceNode(context.lf, refNodeId) + if (!source) return null + const type = String(source.type) + if (type === WorkflowNodeType.DocumentSplitNode) { + return field === 'paragraph_list' ? null : `文档分段节点只有 paragraph_list 能写入知识库,当前引用的是 ${field}` + } + const unsplit = UNSPLIT_SOURCES[type] ?? (source.properties.kind === WorkflowKind.DataSource ? '数据源' : undefined) + if (unsplit) { + return `${unsplit}的 ${field} 没有分段,写入后文档为空:请在它和知识库写入之间加文档分段节点,再把 documents 绑定到 [文档分段节点ID, "paragraph_list"](需要标签等字段时再经自定义代码加工)` + } + return null +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'knowledge-write-node(知识库写入)配置要点:', + '- 写入内容来自入参 documents(已绑定:文档分段的 paragraph_list,或加工过的自定义代码 result);无需其他配置,直接调用 update 完成校验。', + ].join('\n'), + tools: [ + { + name: 'update', + description: + '配置当前知识库写入节点:把入参 documents 绑定为写入内容,并校验来源是带分段的文档列表,其余字段无需配置。写入后自动校验,通过即配置完成;不通过返回 error。', + parameters: config, + apply: async () => { + const node = getNode(context.lf, context.nodeId) + if (!node) return new ErrorResult(`节点不存在: ${context.nodeId}`) + const reference = argReference(context, 'documents') + if (!reference) return new ErrorResult('缺少入参 documents:请把 documents 绑定到文档分段的 [文档分段节点ID, "paragraph_list"]') + const sourceError = checkSource(context, reference) + // 绑定由搭建 agent 决定,本节点改不了:直接结束并把原因交回,避免子 agent 反复重试 + if (sourceError) return new AgentErrorResult(sourceError) + writeNodeProperties(node, { node_data: { ...readNodeData>(node), document_list: [...reference] } }) + return finishWithValidate(context) + }, + }, + ], + }) +} + +export default { + description: + '把带分段的文档列表写入当前知识库并完成向量化,是知识库工作流的末端节点。入参 documents 引用文档分段的 paragraph_list;' + + '要给文档带上标签(tags)、关联问题、元数据时,先用自定义代码在 paragraph_list 上加工再写入。上游不能直接是文档内容提取或数据源', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/knowledge-write-node/index.ts b/ui/src/workflow-canvas/nodes/knowledge-write-node/index.ts index ae86ca33a8c..72bf25dff8c 100644 --- a/ui/src/workflow-canvas/nodes/knowledge-write-node/index.ts +++ b/ui/src/workflow-canvas/nodes/knowledge-write-node/index.ts @@ -1,4 +1,5 @@ import KnowledgeWriteNode from './index.vue' +import agent from './agent' import KnowledgeWriteNodeDetail from './details/index.vue' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -14,4 +15,5 @@ export default { model: WorkflowNodeModel, view: KnowledgeWriteNodeView, details: KnowledgeWriteNodeDetail, + agent, } diff --git a/ui/src/workflow-canvas/nodes/loop-node/agent.ts b/ui/src/workflow-canvas/nodes/loop-node/agent.ts index 2e8b63548ce..ca86207917f 100644 --- a/ui/src/workflow-canvas/nodes/loop-node/agent.ts +++ b/ui/src/workflow-canvas/nodes/loop-node/agent.ts @@ -1,4 +1,6 @@ -import { getNode, readNodeData, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { getLoopBodyLf, getNode, readNodeData, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { isLoopBuiltinField } from '@/workflow-canvas/nodes/loop-start-node/constant' +import { WorkflowNodeType } from '@/workflow-canvas/types' import { argReference, finishWithValidate, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' import { ErrorResult } from '@/workflow-canvas/ai-generate/tools/result' import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' @@ -17,23 +19,76 @@ const config: JsonObjectSchema = { properties: { loop_type: { type: 'string', enum: ['ARRAY', 'NUMBER', 'LOOP'], description: 'ARRAY 遍历入参 array,NUMBER 固定次数,LOOP 无限循环' }, number: { type: 'number', description: 'NUMBER 时的次数' }, + variables: { + type: 'array', + items: { + type: 'object', + properties: { + field: { type: 'string', description: '变量名,只能包含英文字母、数字、下划线,不能用内置的 index / item' }, + label: { type: 'string', description: '显示名称' }, + }, + required: ['field', 'label'], + }, + description: + '循环变量:声明在循环体的 loop-start-node 上,用于跨轮次累积 / 传递数据(如累加结果、收集每轮输出)。传入即整体替换,不传保持不变。' + + '循环体内用 ["loop", 变量名] 引用、提示词写 {{loop.变量名}};初始为空,只能由循环体内的变量赋值节点(target=["loop", 变量名])修改;循环结束后下游用 [循环节点ID, 变量名] 引用其最终值', + }, }, required: ['loop_type'], } -const returns: JsonObjectSchema = { type: 'object', properties: {}, additionalProperties: false } +const returns: JsonObjectSchema = { + type: 'object', + properties: {}, + additionalProperties: { type: 'any', description: '循环变量的最终值:循环结束后下游以 [循环节点ID, 变量名] 引用', 'x-from': 'variables' }, +} + +interface LoopVariable { + field: string + label: string +} + +/** 规整并校验循环变量;返回错误文本或规整后的列表 */ +function normalizeVariables(raw: unknown): LoopVariable[] | string { + if (!Array.isArray(raw)) return 'variables 应为数组' + const variables: LoopVariable[] = [] + for (const [index, item] of raw.entries()) { + const field = String((item as Partial | null)?.field ?? '').trim() + const label = String((item as Partial | null)?.label ?? '').trim() || field + if (!/^[a-zA-Z0-9_]+$/.test(field)) return `variables[${index}].field 只能包含英文字母、数字、下划线` + if (isLoopBuiltinField(field)) return `variables[${index}].field 不能使用循环内置参数 ${field}` + if (variables.some((variable) => variable.field === field)) return `循环变量重复:${field}` + variables.push({ field, label }) + } + return variables +} + +/** 循环变量存放在循环体 loop-start-node 的 loop_input_field_list,并同步为循环节点的输出字段(与循环开始节点表单一致) */ +async function writeLoopVariables(context: NodeAgentContext, variables: LoopVariable[]): Promise { + const node = getNode(context.lf, context.nodeId) + const bodyLf = await getLoopBodyLf(context.lf, context.nodeId) + const startNode = bodyLf ? getNode(bodyLf, WorkflowNodeType.LoopStartNode) : undefined + if (!node || !startNode) return '循环体尚未就绪,无法写入循环变量,请稍后重试' + writeNodeProperties(startNode, { loop_input_field_list: variables.map((variable) => ({ ...variable })) }) + startNode.clearNextNodeField(true) + const nodeConfig = (node.properties.config ?? {}) as Record + writeNodeProperties(node, { config: { ...nodeConfig, fields: variables.map((variable) => ({ label: variable.label, value: variable.field })) } }) + node.clearNextNodeField(true) + return null +} function useAgent(context: NodeAgentContext) { return useNodeAgent(context, { prompt: [ 'loop-node(循环)配置要点:只配循环本身,循环体由建图 agent 另行搭建。', '- loop_type:ARRAY 遍历已绑定入参 array;NUMBER 循环 number 次;LOOP 无限循环(循环体内必须放 Break)。', + '- variables:需要跨轮次累积 / 传递数据,或循环结束后还要用到循环内算出的值时,声明循环变量;要求了返回字段时,返回字段名就是循环变量名。不需要则不传。', ].join('\n'), tools: [ { name: 'update', description: - '配置当前循环节点:循环类型 loop_type 与次数 number。只传要设置的字段,其余保持不变。' + + '配置当前循环节点:循环类型 loop_type、次数 number 与循环变量 variables。只传要设置的字段,其余保持不变。' + '写入后自动校验,通过即配置完成;不通过返回 error 继续修。', parameters: config, apply: async (args) => { @@ -43,6 +98,8 @@ function useAgent(context: NodeAgentContext) { if (!['ARRAY', 'NUMBER', 'LOOP'].includes(loopType)) return new ErrorResult(`loop_type 不合法:${loopType}`) const array = argReference(context, 'array') if (loopType === 'ARRAY' && !array) return new ErrorResult('ARRAY 模式需要父节点传入入参 array,请改用 NUMBER / LOOP 或说明缺少该入参') + const variables = args.variables === undefined ? undefined : normalizeVariables(args.variables) + if (typeof variables === 'string') return new ErrorResult(variables) writeNodeProperties(node, { node_data: { ...readNodeData>(node), @@ -51,6 +108,10 @@ function useAgent(context: NodeAgentContext) { number: loopType === 'NUMBER' ? Math.max(1, Number(args.number ?? 1)) : 1, }, }) + if (variables) { + const error = await writeLoopVariables(context, variables) + if (error) return new ErrorResult(error) + } return finishWithValidate(context) }, }, @@ -61,7 +122,9 @@ function useAgent(context: NodeAgentContext) { export default { description: '相当于 for:ARRAY 遍历数组、NUMBER 固定次数、LOOP 无限循环(循环体内必须放 Break)。循环体在 lf_id=该循环节点 id 的画布中搭建,' + - '首节点接 loop-start-node,以 [loop-start-node, item] / [loop-start-node, index] 取当前元素与下标;要遍历的数组常由参数提取的 array 参数产出', + '首节点接 loop-start-node,以 [loop-start-node, item] / [loop-start-node, index] 取当前元素与下标;要遍历的数组常由参数提取的 array 参数产出。' + + '可声明循环变量(相当于循环外定义、循环内更新的变量):循环体内用 ["loop", 变量名] 读,各轮共享;修改它只能在循环体内放变量赋值节点(variable-assign-node),其他节点的输出不会自动写入;' + + '循环结束后其最终值作为本节点的返回字段,下游以 [循环节点ID, 变量名] 引用', params, config, returns, diff --git a/ui/src/workflow-canvas/nodes/loop-start-node/component/LoopFieldTable.vue b/ui/src/workflow-canvas/nodes/loop-start-node/component/LoopFieldTable.vue index d58d70dbab7..ca9c10481b4 100644 --- a/ui/src/workflow-canvas/nodes/loop-start-node/component/LoopFieldTable.vue +++ b/ui/src/workflow-canvas/nodes/loop-start-node/component/LoopFieldTable.vue @@ -34,7 +34,7 @@ diff --git a/ui/src/workflow-canvas/nodes/tool-base-node/agent.ts b/ui/src/workflow-canvas/nodes/tool-base-node/agent.ts new file mode 100644 index 00000000000..228805d553c --- /dev/null +++ b/ui/src/workflow-canvas/nodes/tool-base-node/agent.ts @@ -0,0 +1,174 @@ +import { cloneDeep } from 'lodash' +import { getNode, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import { AgentSuccessResult, ErrorResult } from '@/workflow-canvas/ai-generate/tools/result' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' +import { WorkflowNodeType } from '@/workflow-canvas/types' +import type { ToolInputField, ToolOutputField } from './types' + +/** + * tool-base-node(工具基本信息)的 AI 生成配置:声明工具工作流的函数签名。 + * 输入参数写入 properties.user_input_field_list(开始节点提供为全局变量 global), + * 输出参数写入 properties.user_output_field_list(流程内由变量赋值写入 output)。 + */ + +const INPUT_TYPES = ['string', 'int', 'float', 'boolean', 'dict', 'array'] +const FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/ +const FIELD_DESCRIPTION = '参数名:字母、数字、下划线,不以数字开头,最长 64' + +const params: JsonObjectSchema = { type: 'object', properties: {} } + +const config: JsonObjectSchema = { + type: 'object', + properties: { + inputs: { + type: 'array', + items: { + type: 'object', + properties: { + field: { type: 'string', description: FIELD_DESCRIPTION }, + label: { type: 'string', description: '显示名称(简短)' }, + type: { type: 'string', enum: INPUT_TYPES, description: '数据类型:文本 string,列表 array,对象 dict' }, + desc: { type: 'string', description: '参数说明,最长 128 字:含义、格式与示例。智能体调用工具时模型只靠它决定传什么' }, + is_required: { type: 'boolean', description: '是否必填,默认 true' }, + }, + required: ['field', 'label', 'type', 'desc'], + }, + description: '输入参数:调用方传入,流程内用 ["global", 参数名] 引用。整体替换,省略则保持不变', + }, + outputs: { + type: 'array', + items: { + type: 'object', + properties: { + field: { type: 'string', description: FIELD_DESCRIPTION }, + label: { type: 'string', description: '显示名称(简短)' }, + }, + required: ['field', 'label'], + }, + description: '输出参数:工具的返回字段,流程内由变量赋值节点写入 ["output", 参数名]。整体替换,省略则保持不变', + }, + }, +} + +const returns: JsonObjectSchema = { type: 'object', properties: {}, additionalProperties: false } + +function fieldsOf(context: NodeAgentContext, key: 'user_input_field_list' | 'user_output_field_list'): T[] { + const fields = getNode(context.lf, context.nodeId)?.properties[key] + return Array.isArray(fields) ? (fields as T[]) : [] +} + +/** 当前签名:交付给父 agent,也写进配置 agent 的提示词(参数存在 properties 顶层,不在 node_data 里) */ +function signatureOf(context: NodeAgentContext) { + return { + inputs: fieldsOf(context, 'user_input_field_list').map((field) => ({ + field: field.field, + label: field.label, + type: field.type, + is_required: field.is_required, + desc: field.desc, + ref: ['global', field.field], + })), + outputs: fieldsOf(context, 'user_output_field_list').map((field) => ({ field: field.field, label: field.label, ref: ['output', field.field] })), + } +} + +function parseFields(raw: unknown, label: string): Array> | string { + if (!Array.isArray(raw)) return `${label} 应为数组` + const names = new Set() + for (const [index, item] of raw.entries()) { + const field = String((item as Record | null)?.field ?? '') + if (!FIELD_PATTERN.test(field)) return `${label}[${index}].field 不合法:${field || '(空)'}。${FIELD_DESCRIPTION}` + if (names.has(field)) return `${label} 参数名重复:${field}` + names.add(field) + } + return raw as Array> +} + +const trimmed = (value: unknown) => String(value ?? '').trim() + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'tool-base-node(工具基本信息)配置要点:声明工具工作流的函数签名,即输入参数与输出参数。', + '- 严格按父 agent 的要求声明:参数名、类型、是否必填照要求写,不要自行增删参数或改名(流程里的节点按参数名引用)。', + '- inputs(输入参数):调用方传入的数据。type 按数据形态选:文本 string,数字 int / float,开关 boolean,列表 array,对象 dict。', + '- desc 是写给调用方的:智能体调用工具时,模型只能看到每个输入参数的 desc 并据此生成参数值,要写清含义、格式与示例,不超过 128 字;被工作流节点调用时写清需要什么结构的数据。', + '- outputs(输出参数):工具的返回字段,只需参数名与显示名称;它的值由流程里的变量赋值节点写入。', + '- inputs / outputs 各自整体替换:修改其中一项时要带上该侧要保留的其余参数;只改一侧时另一侧省略。', + `当前签名:${JSON.stringify(signatureOf(context))}`, + ].join('\n'), + tools: [ + { + name: 'update', + description: + '声明工具的输入参数与输出参数。inputs / outputs 各自整体替换(以传入的列表为准),省略的一侧保持不变。写入成功即配置完成,交付最新签名。', + parameters: config, + apply: (args) => { + const node = getNode(context.lf, context.nodeId) + if (!node) return new ErrorResult(`节点不存在: ${context.nodeId}`) + if (args.inputs === undefined && args.outputs === undefined) return new ErrorResult('inputs 与 outputs 至少传一个') + const properties: Record = {} + if (args.inputs !== undefined) { + const inputs = parseFields(args.inputs, 'inputs') + if (typeof inputs === 'string') return new ErrorResult(inputs) + for (const [index, input] of inputs.entries()) { + if (!INPUT_TYPES.includes(String(input.type))) return new ErrorResult(`inputs[${index}].type 只能是 ${INPUT_TYPES.join(' / ')}`) + if (!trimmed(input.label)) return new ErrorResult(`inputs[${index}].label 不能为空`) + if (trimmed(input.label).length > 128) return new ErrorResult(`inputs[${index}].label 超过 128 字`) + if (trimmed(input.desc).length > 128) return new ErrorResult(`inputs[${index}].desc 超过 128 字,请精简`) + } + properties.user_input_field_list = inputs.map( + (input): ToolInputField => ({ + field: String(input.field), + label: trimmed(input.label), + type: String(input.type), + desc: trimmed(input.desc), + is_required: input.is_required !== false, + }), + ) + } + if (args.outputs !== undefined) { + const outputs = parseFields(args.outputs, 'outputs') + if (typeof outputs === 'string') return new ErrorResult(outputs) + const existing = new Map(fieldsOf(context, 'user_output_field_list').map((field) => [field.field, field])) + properties.user_output_field_list = outputs.map( + (output): ToolOutputField => ({ + type: 'string', + is_required: true, + ...cloneDeep(existing.get(String(output.field)) ?? {}), + field: String(output.field), + label: trimmed(output.label).slice(0, 128) || String(output.field), + }), + ) + } + writeNodeProperties(node, properties) + // 开始节点的全局变量来自输入参数:直接同步,不依赖开始节点视图是否已挂载 + const startNode = getNode(context.lf, WorkflowNodeType.ToolStartNode) + if (startNode) { + writeNodeProperties(startNode, { + config: { + ...((startNode.properties.config as Record | undefined) ?? {}), + globalFields: fieldsOf(context, 'user_input_field_list').map((field) => ({ label: field.label || field.field, value: field.field })), + }, + }) + context.lf.graphModel.eventCenter.emit('refreshFieldList', undefined) + startNode.clearNextNodeField(true) + } + return new AgentSuccessResult({ + ...signatureOf(context), + note: '改名或删除过参数时,引用旧参数的节点需要重新配置;validate 会列出仍引用未声明参数的位置', + }) + }, + }, + ], + }) +} + +export default { + description: '工具工作流的函数签名:声明输入参数(调用方传入,流程内引用 ["global", 参数名])与输出参数(工具返回字段,由变量赋值写入 ["output", 参数名])', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/tool-base-node/index.ts b/ui/src/workflow-canvas/nodes/tool-base-node/index.ts index bcbecb9e211..d1b3d3f0f3c 100644 --- a/ui/src/workflow-canvas/nodes/tool-base-node/index.ts +++ b/ui/src/workflow-canvas/nodes/tool-base-node/index.ts @@ -1,4 +1,5 @@ import ToolBaseNode from './index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -15,4 +16,4 @@ class ToolBaseNodeModel extends WorkflowNodeModel { } } -export default { type: WorkflowNodeType.ToolBaseNode, model: ToolBaseNodeModel, view: ToolBaseNodeView } +export default { type: WorkflowNodeType.ToolBaseNode, model: ToolBaseNodeModel, view: ToolBaseNodeView, agent } diff --git a/ui/src/workflow-canvas/nodes/tool-custom-node/agent.ts b/ui/src/workflow-canvas/nodes/tool-custom-node/agent.ts index d40a09df250..3a79b0618f8 100644 --- a/ui/src/workflow-canvas/nodes/tool-custom-node/agent.ts +++ b/ui/src/workflow-canvas/nodes/tool-custom-node/agent.ts @@ -56,6 +56,15 @@ function useAgent(context: NodeAgentContext) { '- code:按需求写一段可直接运行的 Python,只定义一个函数,形参与 inputs 的 name 一一对应,return 结果;只用标准库。', '- inputs:source=reference 的参数由同名入参绑定,无需给 value;source=custom 的参数给常量 value。', '- 示例:def main(text: str, limit: int):\\n return text[:limit]', + '- 解析模型输出的 JSON 时先去掉 ```json 代码块标记并做容错(解析失败时返回原数据或空结果),不要让代码直接抛错。', + '- 返回值结构以「下游要求」为准:有下游要求时按它逐字段构造返回值;顶层是数组就 return 一个 list,哪怕只有一条也不能返回单个 dict,也不能返回 {名称: 内容} 这样的字典。', + '- 结果要给知识库写入(如补标签 tags、关联问题 problem_list、元数据 meta)时:遍历上游文档分段的 paragraph_list(list),逐篇复制后补字段,原字段一律保留,最后 return 整个 list;' + + 'AI 输出是 {文档名: [...]} 这样的字典时,只用它按 name 查找,不要把它当返回值;' + + 'tags 为 [{"key": str, "value": str}](key ≤64 字、value ≤128 字,均不能为空),problem_list 写在每个分段上,为 list[str]。', + '- 知识库写入的示例:\\ndef main(documents: list, ai_output: str):\\n import json, re\\n text = re.sub(r"^```(json)?|```$", "", (ai_output or "").strip(), flags=re.M).strip()\\n' + + ' try:\\n tags_by_name = json.loads(text)\\n except Exception:\\n tags_by_name = {}\\n if not isinstance(tags_by_name, dict):\\n tags_by_name = {}\\n' + + ' result = []\\n for document in documents or []:\\n tags = [t for t in tags_by_name.get(document.get("name"), []) if isinstance(t, dict) and t.get("key") and t.get("value")]\\n' + + ' result.append({**document, "tags": (document.get("tags") or []) + tags})\\n return result', ].join('\n'), tools: [ { diff --git a/ui/src/workflow-canvas/nodes/variable-assign-node/agent.ts b/ui/src/workflow-canvas/nodes/variable-assign-node/agent.ts index 7181c4441db..d0db5c02256 100644 --- a/ui/src/workflow-canvas/nodes/variable-assign-node/agent.ts +++ b/ui/src/workflow-canvas/nodes/variable-assign-node/agent.ts @@ -5,13 +5,18 @@ import { ErrorResult } from '@/workflow-canvas/ai-generate/tools/result' import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' const VALUE_TYPES = ['string', 'num', 'json', 'bool'] +const SOURCES = ['custom', 'referencing', 'null'] /** variable-assign-node(变量赋值)的 AI 生成配置 */ const params: JsonObjectSchema = { type: 'object', properties: { - values: { type: 'array', items: {}, description: '要赋给变量的上游数据(source=referencing 时引用)' }, + values: { + type: 'array', + items: {}, + description: '要赋给变量的上游数据:每个 source=referencing 的赋值项各需要一个引用 [节点ID, 字段],原样(保持类型)赋给目标变量', + }, }, } @@ -23,15 +28,34 @@ const config: JsonObjectSchema = { items: { type: 'object', properties: { - target: { type: 'array', items: { type: 'string' } }, - source: { type: 'string', enum: ['custom', 'referencing'] }, - type: { type: 'string', enum: VALUE_TYPES }, - value: { description: 'source=custom 时的值' }, - reference: { type: 'array', items: { type: 'string' } }, + target: { type: 'array', items: { type: 'string' }, description: '被赋值的变量 [分组, 变量名],分组为 global / chat / loop / output(output 为工具工作流的输出参数)' }, + source: { + type: 'string', + enum: SOURCES, + description: + '赋值方式:custom 自定义(写死的常量,由 type + value 给出)/ referencing 引用变量(把上游某个字段的值原样赋过来,由 reference 给出)/ null 置空', + }, + type: { + type: 'string', + enum: VALUE_TYPES, + description: '仅 custom:常量的类型。string 文本 / num 数字 / json 对象或数组 / bool 布尔,默认 string', + }, + value: { + description: + '仅 custom:常量值,须与 type 一致。string 传文本,可含 {{节点名称.字段}}、{{loop.变量名}} 等变量,运行时替换成文本;' + + 'num 传数字;json 传对象 / 数组(或其 JSON 字符串);bool 传 true / false', + }, + reference: { + type: 'array', + items: { type: 'string' }, + description: '仅 referencing:来源 [节点ID, 字段],必须是入参 values 里绑定的引用;值按原类型赋值(数组、对象不会被转成文本)', + }, }, required: ['target', 'source'], }, - description: '赋值列表:target 目标变量,source 常量 / 引用,type 值类型,value 常量值,reference 引用(取自入参 values)', + description: + '赋值列表,每项给一个变量赋值:target 目标变量 + source 赋值方式。' + + '值是搭建时就确定的常量用 custom;值来自上游节点的输出(尤其是数组、对象或需要保持类型时)用 referencing;清空变量用 null', }, }, required: ['variables'], @@ -39,19 +63,48 @@ const config: JsonObjectSchema = { const returns: JsonObjectSchema = { type: 'object', properties: {}, additionalProperties: false } +/** 自定义常量按 type 规整成节点表单的存储形式:string 文本、num 数字、json 为 JSON 字符串、bool 布尔 */ +function customValueOf(type: string, raw: unknown): { value: unknown } | { error: string } { + if (raw === undefined || raw === null || raw === '') return { error: '不能为空:自定义赋值需要给出常量值,要置空请用 source="null"' } + if (type === 'num') { + const number = typeof raw === 'number' ? raw : Number(raw) + return Number.isFinite(number) ? { value: number } : { error: `应为数字:${String(raw)}` } + } + if (type === 'bool') { + if (typeof raw === 'boolean') return { value: raw } + return raw === 'true' || raw === 'false' ? { value: raw === 'true' } : { error: `应为 true / false:${String(raw)}` } + } + if (type === 'json') { + if (typeof raw !== 'string') return { value: JSON.stringify(raw) } + try { + JSON.parse(raw) + return { value: raw } + } catch { + return { error: '不是合法的 JSON' } + } + } + return { value: typeof raw === 'string' ? raw : JSON.stringify(raw) } +} + function useAgent(context: NodeAgentContext) { return useNodeAgent(context, { prompt: [ 'variable-assign-node(变量赋值)配置要点:', - '- target:要赋值的变量 [分组, 变量名],分组为 global / chat / loop,必须是 get_field_options 里已存在的变量(无法新建变量)。', - '- source="custom":value 为常量(type 为 string 时可含 {{节点名称.字段}} 变量);source="referencing":reference 使用已绑定入参 values 中的引用。', - '- type:string / num / json / bool。', + '- target:要赋值的变量 [分组, 变量名],分组为 global / chat / loop / output,必须是 get_field_options 里已存在的变量(无法新建变量;循环变量需先在所属 loop-node 的 variables 里声明,工具工作流的输出参数需先在基本信息里声明)。', + '- source 三选一:', + ' · custom(自定义):值是写死的常量,用 type + value 给出。type=string 时 value 是文本,可内嵌 {{节点名称.字段}} / {{loop.变量名}} 等变量,运行时替换后得到的仍是文本;', + ' type=num 传数字;type=json 传对象或数组;type=bool 传 true / false。', + ' · referencing(引用变量):把上游某个字段的值原样赋给目标变量,reference 填已绑定入参 values 中的引用 [节点ID, 字段];数组、对象等会保持原类型。', + ' · null:把目标变量置空,不需要 type / value / reference。', + '- 怎么选:要保持上游数据的类型(数组、对象、数字)或整体搬运上游输出,用 referencing;要写固定值,或把几段文本 / 变量拼成一段文本,用 custom + string。', + '- 工具工作流的输出参数(target=["output", 参数名]):工具的返回值只取这里写入的值,其他节点的输出不会自动成为返回值;结果是数组 / 对象时用 referencing 原样赋值,不要用 string 常量拼成文本。', + '- 循环变量(target=["loop", 变量名]):本节点必须位于该循环体内。循环变量初始为空,只有本节点能修改它;要累积数据时,用 string 常量拼接旧值与新值(如 "{{loop.变量名}}\\n{{某节点.字段}}"),或先由上游节点算出新值再用 referencing 赋值。', ].join('\n'), tools: [ { name: 'update', description: - '配置当前变量赋值节点:给全局 / 会话 / 循环变量赋值。只传要设置的字段,其余保持不变。' + + '配置当前变量赋值节点:给全局 / 会话 / 循环变量或工具工作流的输出参数赋值。只传要设置的字段,其余保持不变。' + '写入后自动校验,通过即配置完成;不通过返回 error 继续修。', parameters: config, apply: async (args) => { @@ -63,26 +116,26 @@ function useAgent(context: NodeAgentContext) { const variableList = [] for (const [index, variable] of variables.entries()) { const target = Array.isArray(variable.target) ? variable.target.map(String) : [] - if (!['global', 'chat', 'loop', 'output'].includes(target[0] ?? '')) return new ErrorResult(`variables[${index}].target 只能是 global/chat/loop 变量`) + if (!['global', 'chat', 'loop', 'output'].includes(target[0] ?? '')) + return new ErrorResult(`variables[${index}].target 只能是 global/chat/loop/output 变量`) const error = validateReference(context.lf, context.nodeId, target, `variables[${index}].target`) if (error) return new ErrorResult(error) - const referencing = variable.source === 'referencing' + const source = SOURCES.includes(String(variable.source)) ? String(variable.source) : 'custom' + const referencing = source === 'referencing' if (referencing) { const referenceError = validateReference(context.lf, context.nodeId, variable.reference, `variables[${index}].reference`) if (referenceError) return new ErrorResult(referenceError) } const type = VALUE_TYPES.includes(String(variable.type)) ? String(variable.type) : 'string' + const value = source === 'custom' ? customValueOf(type, variable.value) : { value: null } + if ('error' in value) return new ErrorResult(`variables[${index}].value ${value.error}`) variableList.push({ id: randomId(), fields: target, name: groups.find((group) => group.nodeId === target[0])?.fields.find((field) => field.value === target[1])?.label ?? target[1], - source: referencing ? 'referencing' : 'custom', + source, type, - value: referencing - ? null - : type === 'json' && typeof variable.value !== 'string' - ? JSON.stringify(variable.value) - : (variable.value ?? ''), + value: value.value, reference: referencing ? (variable.reference as unknown[]).map(String) : [], }) } @@ -95,7 +148,9 @@ function useAgent(context: NodeAgentContext) { } export default { - description: '相当于赋值语句:给全局变量 / 会话变量 / 循环变量赋值,不产生输出', + description: + '相当于赋值语句:给全局变量 / 会话变量 / 循环变量 / 工具工作流的输出参数赋值,不产生输出。赋值方式有三种:自定义常量、引用上游某个字段的值(保持原类型)、置空。循环变量只能由循环体内的本节点修改:其他节点的输出不会自动写入循环变量,' + + '需要在产出数据的节点之后接本节点,把上游结果或常量赋给 ["loop", 变量名]。工具工作流的返回值同理:只有本节点写入 ["output", 参数名] 的值才会返回给调用方', params, config, returns, diff --git a/ui/src/workflow-canvas/store/api/system-resource/index.ts b/ui/src/workflow-canvas/store/api/system-resource/index.ts index de801281d7d..e7073e00602 100644 --- a/ui/src/workflow-canvas/store/api/system-resource/index.ts +++ b/ui/src/workflow-canvas/store/api/system-resource/index.ts @@ -5,6 +5,7 @@ import type { McpTool } from '@/workflow-canvas/store' import ModelApi from '@/api/admin/system/resource-management/model' import ToolApi from '@/api/admin/system/resource-management/tool/tool' import ProviderApi from '@/api/admin/model-provider' +import KnowledgeWorkflowApi from '@/api/admin/system/resource-management/knowledge/workflow' /** 查询当前系统范围的 MCP 服务工具定义。 */ const getMcpTools = (resourceType: string, resourceId: string, mcpServers: string) => @@ -13,7 +14,15 @@ const getMcpTools = (resourceType: string, resourceId: string, mcpServers: strin /** 查询当前系统范围内所选知识库的文档标签。 */ const getAllTags = (knowledgeIds: string[]) => get('/system/resource/knowledge/tags', { 'knowledge_ids[]': knowledgeIds }) +/** 知识库工作流最近一次执行(调试或导入)的详情,供 AI 生成排查运行报错。 */ +const getLatestKnowledgeAction = async (knowledgeId: string) => { + const page = await KnowledgeWorkflowApi.getKnowledgeExecutionRecordPage(knowledgeId, { currentPage: 1, pageSize: 1 }) + const record = page.records[0] + return record ? KnowledgeWorkflowApi.getKnowledgeWorkflowAction(knowledgeId, record.id) : null +} + export default { + getLatestKnowledgeAction, getAllApplication: ApplicationApi.getAllApplication, postPromptGenerate: ModelApi.postPromptGenerate, postChatCompletion: ModelApi.postChatCompletion, diff --git a/ui/src/workflow-canvas/store/api/system-shared/index.ts b/ui/src/workflow-canvas/store/api/system-shared/index.ts index 711ed5bca07..abe6122377d 100644 --- a/ui/src/workflow-canvas/store/api/system-shared/index.ts +++ b/ui/src/workflow-canvas/store/api/system-shared/index.ts @@ -4,6 +4,7 @@ import type { McpTool } from '@/workflow-canvas/store' import ModelApi from '@/api/admin/system/shared-resources/model' import ToolApi from '@/api/admin/system/shared-resources/tool/tool' import ProviderApi from '@/api/admin/model-provider' +import KnowledgeWorkflowApi from '@/api/admin/system/shared-resources/knowledge/workflow' /** 查询当前系统范围的 MCP 服务工具定义。 */ const getMcpTools = (resourceType: string, resourceId: string, mcpServers: string) => @@ -12,7 +13,15 @@ const getMcpTools = (resourceType: string, resourceId: string, mcpServers: strin /** 查询当前系统范围内所选知识库的文档标签。 */ const getAllTags = (knowledgeIds: string[]) => get('/system/shared/knowledge/tags', { 'knowledge_ids[]': knowledgeIds }) +/** 知识库工作流最近一次执行(调试或导入)的详情,供 AI 生成排查运行报错。 */ +const getLatestKnowledgeAction = async (knowledgeId: string) => { + const page = await KnowledgeWorkflowApi.getKnowledgeExecutionRecordPage(knowledgeId, { currentPage: 1, pageSize: 1 }) + const record = page.records[0] + return record ? KnowledgeWorkflowApi.getKnowledgeWorkflowAction(knowledgeId, record.id) : null +} + export default { + getLatestKnowledgeAction, postPromptGenerate: ModelApi.postPromptGenerate, postChatCompletion: ModelApi.postChatCompletion, getMcpTools, diff --git a/ui/src/workflow-canvas/store/api/workspace/index.ts b/ui/src/workflow-canvas/store/api/workspace/index.ts index 33335357d0b..665486f6359 100644 --- a/ui/src/workflow-canvas/store/api/workspace/index.ts +++ b/ui/src/workflow-canvas/store/api/workspace/index.ts @@ -6,6 +6,7 @@ import providerAPI from '@/api/admin/model-provider' import toolAPI from '@/api/admin/workspace/tool/tool' import type { McpTool } from '@/workflow-canvas/store' import { getWorkspaceId } from '@/utils/resource-context' +import KnowledgeWorkflowApi from '@/api/admin/workspace/knowledge/workflow' /** 获取 MCP 调用节点所需的 MCP 工具。 */ /** resourceType: application | tool | knowledge */ @@ -20,7 +21,15 @@ const getAllTags = (knowledgeIds: string[]) => { return get(`/workspace/${workspaceId}/knowledge/tags`, { 'knowledge_ids[]': knowledgeIds }) } +/** 知识库工作流最近一次执行(调试或导入)的详情,供 AI 生成排查运行报错。 */ +const getLatestKnowledgeAction = async (knowledgeId: string) => { + const page = await KnowledgeWorkflowApi.getKnowledgeExecutionRecordPage(knowledgeId, { currentPage: 1, pageSize: 1 }) + const record = page.records[0] + return record ? KnowledgeWorkflowApi.getKnowledgeWorkflowAction(knowledgeId, record.id) : null +} + export default { + getLatestKnowledgeAction, getAllApplication: ApplicationApi.getAllApplication, postPromptGenerate: modelAPI.postPromptGenerate, postChatCompletion: modelAPI.postChatCompletion, diff --git a/ui/src/workflow-canvas/store/index.ts b/ui/src/workflow-canvas/store/index.ts index 1537e264fbf..e2735d19c9c 100644 --- a/ui/src/workflow-canvas/store/index.ts +++ b/ui/src/workflow-canvas/store/index.ts @@ -4,6 +4,7 @@ import type { Dict, DynamicFormField, KnowledgeTagGroup, + KnowledgeWorkflowAction, ModelItem, ModelProviderItem, PromptGeneratePayload, @@ -28,6 +29,7 @@ type ApiModule = { getToolListWithShared?: (query?: Dict) => Promise getToolById?: (toolId: string) => Promise getKnowledgeTags?: (knowledgeIds: string[]) => Promise + getLatestKnowledgeAction?: (knowledgeId: string) => Promise postPromptGenerate: (modelId: string, payload: PromptGeneratePayload) => Promise postChatCompletion?: (modelId: string, payload: ChatCompletionPayload, signal?: AbortSignal) => Promise } @@ -43,6 +45,8 @@ export type WorkflowStoreApi = { getToolListWithShared: (query?: Dict) => Promise getToolById: (toolId: string) => Promise getKnowledgeTags: (knowledgeIds: string[]) => Promise + /** 知识库工作流最近一次执行(调试或导入)的详情,不缓存;当前范围不支持或没有记录时为 null */ + getLatestKnowledgeAction: (knowledgeId: string) => Promise postPromptGenerate: (modelId: string, payload: PromptGeneratePayload) => Promise postChatCompletion: (modelId: string, payload: ChatCompletionPayload, signal?: AbortSignal) => Promise } @@ -134,6 +138,9 @@ export function useWorkflowStore(resourceScope: string, getResourceWorkspaceId?: if (!requestApi.getToolById) return Promise.resolve({} as ToolItem) return withCache(`tool:${toolId}`, () => requestApi.getToolById!(toolId), force) }, + getLatestKnowledgeAction(knowledgeId: string): Promise { + return requestApi.getLatestKnowledgeAction ? requestApi.getLatestKnowledgeAction(knowledgeId) : Promise.resolve(null) + }, getKnowledgeTags(knowledgeIds: string[]): Promise { if (!requestApi.getKnowledgeTags) return Promise.resolve([]) return withCache(`knowledge-tags:${JSON.stringify(knowledgeIds)}`, () => requestApi.getKnowledgeTags!(knowledgeIds), force)