|
13 | 13 |
|
14 | 14 | # coding=utf-8 |
15 | 15 | import pickle |
| 16 | +import queue |
16 | 17 | import tempfile |
| 18 | +import time |
17 | 19 | import zipfile |
18 | 20 | from functools import reduce |
19 | 21 | from typing import Dict, List |
20 | 22 |
|
21 | 23 | import requests |
22 | 24 | import uuid_utils.compat as uuid |
23 | | -from application.flow.common import Workflow, WorkflowMode |
24 | | -from application.flow.i_step_node import ToolWorkflowPostHandler |
25 | | -from application.flow.tool_workflow_manage import ToolWorkflowManage |
26 | | -from application.models import ChatRecord |
| 25 | +from application.flow.tools import to_stream_response_simple |
| 26 | +from application.workflow.common import WorkflowType, new_instance |
| 27 | +from application.workflow.message.aggregator import AggregationManager |
| 28 | +from application.workflow.nodes import get_node_class |
| 29 | +from application.workflow.status import Status |
| 30 | +from application.workflow.workflow_manage import CallBack, WorkflowManage |
27 | 31 | from application.serializers.application import ( |
28 | 32 | McpServersSerializer, |
29 | 33 | get_mcp_tools, |
30 | 34 | validate_bound_tool_permissions, |
31 | 35 | ) |
32 | | -from application.serializers.common import ToolExecute |
| 36 | +from common.constants.cache_version import Cache_Version |
33 | 37 | from common.database_model_manage.database_model_manage import DatabaseModelManage |
| 38 | +from common.handle.impl.response.system_to_response import SystemToResponse |
34 | 39 | from common.exception.app_exception import AppApiException |
35 | 40 | from common.field.common import UploadedFileField |
36 | 41 | from common.result import result |
37 | 42 | from common.utils.common import bytes_to_uploaded_file, generate_uuid, restricted_loads |
38 | 43 | from common.utils.logger import maxkb_logger |
39 | 44 | from common.utils.tool_code import ToolExecutor |
40 | 45 | from common.utils.url_validator import ALLOWED_CALLBACK_HOSTS, ALLOWED_DOWNLOAD_HOSTS, validate_trusted_url |
| 46 | +from django.core.cache import cache |
41 | 47 | from django.db import transaction |
42 | 48 | from django.db.models import Q, QuerySet |
43 | 49 | from django.http import HttpResponse |
44 | 50 | from django.utils import timezone |
45 | 51 | from django.utils.translation import gettext_lazy as _, gettext |
46 | 52 | from knowledge.models import Knowledge, KnowledgeScope, KnowledgeWorkflow |
| 53 | +from knowledge.models.knowledge_action import State |
47 | 54 | from knowledge.serializers.knowledge import KnowledgeModelSerializer, KnowledgeSerializer |
48 | 55 | from maxkb.const import CONFIG |
49 | 56 | from rest_framework import serializers, status |
@@ -164,42 +171,175 @@ def debug(self, instance: Dict, user, with_valid=True): |
164 | 171 | tool_workflow = QuerySet(ToolWorkflow).filter(tool_id=self.data.get("tool_id")).first() |
165 | 172 | workspace_id = tool_workflow.workspace_id |
166 | 173 | tool_record_id = instance.get("chat_record_id") or str(uuid.uuid7()) |
167 | | - took_execute = ToolExecute(self.data.get("tool_id"), tool_record_id, workspace_id, None, None, True) |
168 | | - record = took_execute.get_record() |
| 174 | + # 表单节点等断点续跑:position 指向要从其恢复执行的节点,机制与 chat 一致 |
| 175 | + position = instance.get("position") |
169 | 176 | # 运行身份取自认证上下文(DB 工作空间 + 登录用户),请求体不得覆盖, |
170 | | - # 防止低权限用户伪造 workspace_id/user_id 绕过工具引用授权 |
171 | | - identity_keys = {"workspace_id", "user_id", "chat_user_id", "chat_user_type"} |
172 | | - run_params = { |
173 | | - "chat_record_id": tool_record_id, |
| 177 | + # 防止低权限用户伪造 workspace_id/user_id 绕过工具引用授权; |
| 178 | + # chat_record_id 仅用于沿用同一条执行记录,工具工作流本身不作为运行参数 |
| 179 | + identity_keys = {"workspace_id", "user_id", "chat_user_id", "chat_user_type", "chat_record_id"} |
| 180 | + # 对齐旧引擎 get_body():输入字段值 + 执行身份,不含对话语义字段(question/chat_record_id) |
| 181 | + parameters = { |
174 | 182 | "tool_id": self.data.get("tool_id"), |
175 | 183 | "stream": True, |
| 184 | + "debug": True, |
176 | 185 | "workspace_id": workspace_id, |
177 | 186 | "user_id": self.data.get("user_id"), |
178 | 187 | **{k: v for k, v in instance.items() if k not in identity_keys}, |
179 | 188 | } |
180 | | - work_flow_manage = ToolWorkflowManage( |
181 | | - Workflow.new_instance(tool_workflow.work_flow, WorkflowMode.TOOL), |
182 | | - run_params, |
183 | | - ToolWorkflowPostHandler(took_execute, self.data.get("tool_id")), |
184 | | - is_the_task_interrupted=lambda: False, |
185 | | - child_node=instance.get("child_node"), |
186 | | - start_node_id=instance.get("runtime_node_id"), |
187 | | - start_node_data=instance.get("node_data"), |
188 | | - chat_record=self.to_chat_record(record), |
189 | | - ) |
190 | 189 |
|
191 | | - r = work_flow_manage.run() |
192 | | - return r |
| 190 | + workflow = new_instance(tool_workflow.work_flow, WorkflowType.TOOL) |
| 191 | + aggregation = AggregationManager() |
| 192 | + result_queue = queue.Queue() |
| 193 | + base_to_response = SystemToResponse() |
| 194 | + start_time = time.time() |
| 195 | + |
| 196 | + def on_next(wf_manage, content): |
| 197 | + aggregation.aggregate(content) |
| 198 | + result_queue.put(("chunk", content.to_dict())) |
| 199 | + |
| 200 | + def on_complete(wf_manage, error): |
| 201 | + try: |
| 202 | + self.save_tool_record( |
| 203 | + tool_record_id, |
| 204 | + self.data.get("tool_id"), |
| 205 | + workspace_id, |
| 206 | + wf_manage, |
| 207 | + aggregation, |
| 208 | + parameters, |
| 209 | + start_time, |
| 210 | + error, |
| 211 | + position, |
| 212 | + ) |
| 213 | + finally: |
| 214 | + result_queue.put(("error", error) if error else ("done", None)) |
| 215 | + |
| 216 | + call_back = CallBack(on_next, on_complete) |
| 217 | + |
| 218 | + def get_node_parameters(node): |
| 219 | + return node.properties.get("node_data", {}) |
| 220 | + |
| 221 | + def get_start_node_fn(wf, wm): |
| 222 | + # 有 position:从指定节点续跑(表单节点等),与 chat 的 position 机制一致 |
| 223 | + if position and position.get("id"): |
| 224 | + node = wf.get_node(position.get("id")) |
| 225 | + if node: |
| 226 | + node_class = get_node_class(node.type, WorkflowType.TOOL) |
| 227 | + return node_class(node, wm, get_node_parameters) |
| 228 | + # 默认从工具起始节点开始 |
| 229 | + start_node = wf.get_node("tool-start-node") |
| 230 | + if start_node is None: |
| 231 | + raise AppApiException(500, gettext("The start node does not exist")) |
| 232 | + node_class = get_node_class(start_node.type, WorkflowType.TOOL) |
| 233 | + return node_class(start_node, wm, get_node_parameters) |
| 234 | + |
| 235 | + # 有 position 且有记录 id:从历史 context 恢复(position 机制与 chat 一致);恢复失败回退为全新执行。 |
| 236 | + # 工具无 ChatRecord,context 来源是 debug 专用缓存——通过 get_context 回调提供,from_context 只负责重建 |
| 237 | + if position and instance.get("chat_record_id"): |
| 238 | + |
| 239 | + def get_tool_context(): |
| 240 | + return cache.get(Cache_Version.DEBUG_WORKFLOW_CONTEXT.get_key(chat_record_id=str(tool_record_id))) |
| 241 | + |
| 242 | + work_flow_manage = WorkflowManage.from_context( |
| 243 | + get_context=get_tool_context, |
| 244 | + workflow=workflow, |
| 245 | + parameters=parameters, |
| 246 | + workflow_type=WorkflowType.TOOL, |
| 247 | + call_back=call_back, |
| 248 | + get_start_node=get_start_node_fn, |
| 249 | + ) |
| 250 | + if work_flow_manage is None: |
| 251 | + work_flow_manage = WorkflowManage( |
| 252 | + workflow, parameters, WorkflowType.TOOL, call_back, get_start_node_fn |
| 253 | + ) |
| 254 | + else: |
| 255 | + work_flow_manage = WorkflowManage(workflow, parameters, WorkflowType.TOOL, call_back, get_start_node_fn) |
| 256 | + work_flow_manage.start_node.workflow_manage = work_flow_manage |
| 257 | + |
| 258 | + def generate(): |
| 259 | + work_flow_manage.run() |
| 260 | + while True: |
| 261 | + msg_type, data = result_queue.get() |
| 262 | + if msg_type == "done": |
| 263 | + yield "data: [DONE]\n\n" |
| 264 | + break |
| 265 | + if msg_type == "error": |
| 266 | + error_block = {"id": str(uuid.uuid7()), "type": "FAILURE", "content": str(data)} |
| 267 | + frame = base_to_response.to_stream(tool_record_id, tool_record_id, error_block) |
| 268 | + if frame is not None: |
| 269 | + yield "data: " + frame + "\n\n" |
| 270 | + yield "data: [DONE]\n\n" |
| 271 | + break |
| 272 | + if msg_type == "chunk": |
| 273 | + frame = base_to_response.to_stream(tool_record_id, tool_record_id, data) |
| 274 | + if frame is not None: |
| 275 | + yield "data: " + frame + "\n\n" |
| 276 | + |
| 277 | + return to_stream_response_simple(generate()) |
193 | 278 |
|
194 | 279 | @staticmethod |
195 | | - def to_chat_record(record): |
196 | | - if record is None: |
197 | | - return None |
198 | | - return ChatRecord( |
199 | | - answer_text_list=record.meta.get("answer_text_list"), |
200 | | - details=record.meta.get("details"), |
201 | | - answer_text="", |
| 280 | + def save_tool_record( |
| 281 | + tool_record_id, tool_id, workspace_id, wf_manage, aggregation, parameters, start_time, error, position=None |
| 282 | + ): |
| 283 | + """ |
| 284 | + 工具调试执行结束后写执行记录缓存(替代旧引擎 ToolWorkflowPostHandler)。 |
| 285 | + debug 只写 30 分钟 Redis 缓存、不落库,前端据此拉取 meta.output/details 展示; |
| 286 | + 缓存 shape 与 tool 记录查询端点(ToolSerializer...one)读取的字段保持一致。 |
| 287 | + 同时把运行 context 写入 DEBUG_WORKFLOW_CONTEXT,供下次 position 续跑时 from_context 恢复。 |
| 288 | + """ |
| 289 | + workflow = wf_manage.workflow |
| 290 | + base_node = workflow.get_node("tool-base-node") |
| 291 | + input_field_list = base_node.properties.get("user_input_field_list", []) if base_node else [] |
| 292 | + output_field_list = base_node.properties.get("user_output_field_list", []) if base_node else [] |
| 293 | + input_data = {f.get("field"): parameters.get(f.get("field")) for f in input_field_list} |
| 294 | + # 新引擎工具输出收口于全局 output 上下文(tool-start-node 初始化、变量赋值节点写入) |
| 295 | + output = wf_manage.context.get("output", {}) |
| 296 | + # 续跑(有 position):合并上一次的节点详情,与 chat 的 get_details 用法一致 |
| 297 | + old_details = None |
| 298 | + if position: |
| 299 | + prev_record = cache.get( |
| 300 | + Cache_Version.TOOL_WORKFLOW_EXECUTE.get_key(key=tool_record_id), |
| 301 | + version=Cache_Version.TOOL_WORKFLOW_EXECUTE.get_version(), |
| 302 | + ) |
| 303 | + if prev_record: |
| 304 | + old_details = (prev_record.get("meta") or {}).get("details") |
| 305 | + details = wf_manage.get_details(position=position, old_details=old_details) |
| 306 | + tool_record = { |
| 307 | + "id": tool_record_id, |
| 308 | + "tool_id": tool_id, |
| 309 | + "workspace_id": workspace_id, |
| 310 | + "source_type": None, |
| 311 | + "source_id": None, |
| 312 | + "state": ToolWorkflowSerializer.Operate.compute_tool_state(details, error), |
| 313 | + "run_time": time.time() - start_time, |
| 314 | + "meta": { |
| 315 | + "input_field_list": input_field_list, |
| 316 | + "output_field_list": output_field_list, |
| 317 | + "input": input_data, |
| 318 | + "output": output, |
| 319 | + "details": details, |
| 320 | + "answer_text_list": aggregation.get_contents(), |
| 321 | + }, |
| 322 | + } |
| 323 | + cache.set( |
| 324 | + Cache_Version.TOOL_WORKFLOW_EXECUTE.get_key(key=tool_record_id), |
| 325 | + tool_record, |
| 326 | + version=Cache_Version.TOOL_WORKFLOW_EXECUTE.get_version(), |
| 327 | + timeout=60 * 30, |
202 | 328 | ) |
| 329 | + # 持久化运行 context,供 position 续跑时恢复(工具无 ChatRecord,故写 debug 专用缓存)。 |
| 330 | + # 读写都用 cache.get/set(key) 不带 version,两侧须一致,否则 Django version 命名空间对不上会命中不到。 |
| 331 | + cache.set( |
| 332 | + Cache_Version.DEBUG_WORKFLOW_CONTEXT.get_key(chat_record_id=str(tool_record_id)), |
| 333 | + wf_manage.context, |
| 334 | + timeout=60 * 30, |
| 335 | + ) |
| 336 | + |
| 337 | + @staticmethod |
| 338 | + def compute_tool_state(details, error): |
| 339 | + if error: |
| 340 | + return State.FAILURE |
| 341 | + has_fail = any((d or {}).get("status") == Status.FAIL.value for d in (details or [])) |
| 342 | + return State.FAILURE if has_fail else State.SUCCESS |
203 | 343 |
|
204 | 344 | def publish(self, with_valid=True): |
205 | 345 | if with_valid: |
|
0 commit comments