diff --git a/REFACTOR.md b/REFACTOR.md index 350b1caf..047aa220 100644 --- a/REFACTOR.md +++ b/REFACTOR.md @@ -30,11 +30,11 @@ - [x] 子智能体的优化,参考 PR 的方案。 - [x] 附件上传能够支持转换为 PDF,待办:查看 OCR 模型的状态,样式优化,保存的文件名不对 - [x] 参考 PR,实现内置 Dashscope 的 Embedding 和 rerank 的方法 -- [ ] 优化知识库的 API 接口设计,使用 /{db_id}/xxx 的形式,整合 mindmap / eval 接口 +- [x] 优化知识库的 API 接口设计,使用 /{db_id}/xxx 的形式,整合 mindmap / eval 接口 - [x] 前端新增基于 ID 的随机像素头像生成机制(类似 GitHub 方块头像),用于替代默认图片 - [x] 子智能体右上角功能增强:任务按钮常驻,展开后展示已激活子智能体、附件、state 等信息 -- [ ] 流式输出混排修复:ongoing 流式返回时按 thread ID 区分主/子智能体内容,子智能体内容单独可视化渲染 -- [ ] 子智能体中间过程可查看:流式结束后,通过子智能体自身 thread ID 获取并渲染中间调用过程 +- [x] 流式输出混排修复:ongoing 流式返回时按 thread ID 区分主/子智能体内容,子智能体内容单独可视化渲染 +- [x] 子智能体中间过程可查看:流式结束后,通过子智能体自身 thread ID 获取并渲染中间调用过程 - [x] allow multi-hop qa generate - [x] 在工作区的文件编辑的时候,保存和取消的按钮应该是悬浮在编辑框的右上角,而不是在 header 上面 - [x] default enable all build in tools / kbs / skills / mcps / subagents @@ -44,7 +44,7 @@ - [ ] 考虑如何将知识库更好的挂载到沙盒,是不是可以使用一个别的后端,但是使用别的后端是否还能读取到数据?应该不能 - [x] 智能体体系改进。改进子智能体,我觉得子智能体并不是一个子级的智能体,应该是 - [ ] RAG 中的文件的 metadata 包含那些内容?然后 Find 和 Read 的时候要支持展示 -- [ ] 检查前端的这些 store 的使用,是否有需要调整优化收敛的地方 +- [x] 检查前端的这些 store 的使用,是否有需要调整优化收敛的地方 - [x] parser 从plugins 移动到 knowledge 里面,guard 移动到services 里面 - [x] neo4j 相关的服务,可以移动到 storage 里面 - [ ] 点开对话的时候要能够自动定位到尾部,而不是最开始。 diff --git a/backend/package/yuxi/agents/base.py b/backend/package/yuxi/agents/base.py index de7a70cb..dd610e38 100644 --- a/backend/package/yuxi/agents/base.py +++ b/backend/package/yuxi/agents/base.py @@ -7,9 +7,11 @@ from contextlib import suppress from pathlib import Path from typing import Any +from langchain_core.messages import ToolMessage from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver, aiosqlite from langgraph.graph.state import CompiledStateGraph +from langgraph.types import Command from yuxi import config as sys_config from yuxi.agents.context import BaseContext, resolve_agent_resource_options @@ -30,6 +32,29 @@ def _json_safe(value: Any) -> Any: return str(value) +def _normalize_tool_event_data(data: Any) -> Any: + """规整 tools 流事件:write_todos / task 等返回 Command 的工具,其 tool-finished + output 是 Command 对象,_json_safe 只能退化成 repr 字符串,前端无法关联结果。 + 这里从 Command.update["messages"] 取出真正的 ToolMessage,使其与普通工具一致。""" + if not isinstance(data, dict) or data.get("event") != "tool-finished": + return data + output = data.get("output") + if not isinstance(output, Command): + return data + update = output.update if isinstance(output.update, dict) else {} + messages = update.get("messages") + if not isinstance(messages, list): + return data + tool_call_id = data.get("tool_call_id") + tool_message = next( + (m for m in messages if isinstance(m, ToolMessage) and m.tool_call_id == tool_call_id), + next((m for m in messages if isinstance(m, ToolMessage)), None), + ) + if tool_message is None: + return data + return {**data, "output": tool_message} + + def _metadata_thread_id(value: Any) -> str | None: if not isinstance(value, dict): return None @@ -235,6 +260,8 @@ class BaseAgent: elif method == "values" and not namespace: yield "values", data elif method in {"tasks", "tools", "lifecycle"}: + if method == "tools": + data = _normalize_tool_event_data(data) event_payload = { "method": method, "namespace": namespace, diff --git a/backend/test/unit/agents/test_base_tool_event_normalize.py b/backend/test/unit/agents/test_base_tool_event_normalize.py new file mode 100644 index 00000000..48d8375c --- /dev/null +++ b/backend/test/unit/agents/test_base_tool_event_normalize.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from langchain_core.messages import ToolMessage +from langgraph.types import Command + +from yuxi.agents.base import _json_safe, _normalize_tool_event_data + + +def _command_tool_finished(tool_call_id: str) -> dict: + """模拟 write_todos / task 这类返回 Command 的工具的 tool-finished 事件。""" + tool_message = ToolMessage( + content="Updated todo list to [{'content': '步骤一', 'status': 'in_progress'}]", + tool_call_id=tool_call_id, + ) + command = Command(update={"todos": [{"content": "步骤一", "status": "in_progress"}], "messages": [tool_message]}) + return {"event": "tool-finished", "tool_call_id": tool_call_id, "output": command} + + +def test_command_tool_finished_extracts_tool_message_for_frontend_association(): + tool_call_id = "call_abc" + data = _normalize_tool_event_data(_command_tool_finished(tool_call_id)) + safe = _json_safe(data) + output = safe["output"] + + # 前端按 tool_call_id 关联结果,并要求 output 是对象(dict),否则会被丢弃。 + assert isinstance(output, dict) + assert output["tool_call_id"] == tool_call_id + assert output["type"] == "tool" + assert "步骤一" in output["content"] + + +def test_command_tool_finished_prefers_message_matching_tool_call_id(): + other = ToolMessage(content="别的工具结果", tool_call_id="call_other") + target = ToolMessage(content="目标结果", tool_call_id="call_target") + data = { + "event": "tool-finished", + "tool_call_id": "call_target", + "output": Command(update={"messages": [other, target]}), + } + + output = _normalize_tool_event_data(data)["output"] + assert isinstance(output, ToolMessage) + assert output.tool_call_id == "call_target" + assert output.content == "目标结果" + + +def test_regular_dict_output_is_left_untouched(): + data = {"event": "tool-finished", "tool_call_id": "call_x", "output": {"content": "plain", "type": "tool"}} + assert _normalize_tool_event_data(data)["output"] == {"content": "plain", "type": "tool"} + + +def test_tool_started_event_is_left_untouched(): + data = {"event": "tool-started", "tool_call_id": "call_x", "output": None} + assert _normalize_tool_event_data(data) is data + + +def test_command_without_tool_message_is_left_untouched(): + command = Command(update={"todos": [{"content": "无消息", "status": "pending"}]}) + data = {"event": "tool-finished", "tool_call_id": "call_x", "output": command} + assert _normalize_tool_event_data(data)["output"] is command diff --git a/web/src/composables/useAgentStreamHandler.js b/web/src/composables/useAgentStreamHandler.js index c5a644ba..1fb6895d 100644 --- a/web/src/composables/useAgentStreamHandler.js +++ b/web/src/composables/useAgentStreamHandler.js @@ -59,6 +59,23 @@ const loadingMessageChunk = (chunk) => { return msg || null } +// 工具结果不走 messages 流,而是以 method=tools 的 stream_event 事件返回(tool-started/tool-finished)。 +// 取出 tool-finished 的 output(一条 ToolMessage 字典),交给 msgChunks 与 AI 消息按 tool_call_id 关联。 +const toolFinishedMessage = (chunk) => { + const streamEvent = chunk?.event + if (!streamEvent || streamEvent.method !== 'tools') return null + + const data = streamEvent.data + if (!data || data.event !== 'tool-finished') return null + + const output = data.output + if (!output || typeof output !== 'object') return null + + const id = output.id || output.tool_call_id || data.tool_call_id + if (!id) return null + return { ...output, type: 'tool', id } +} + export function useAgentStreamHandler({ getThreadState, processApprovalInStream, @@ -119,6 +136,20 @@ export function useAgentStreamHandler({ } return false + case 'stream_event': + { + // 工具结果需立即落地(不经平滑层),写入 msgChunks 后由 convertToolResultToMessages + // 按 tool_call_id 关联到对应 AI 消息的 tool_call,驱动其完成态。 + const toolMessage = toolFinishedMessage(chunk) + if (toolMessage) { + if (!threadState.onGoingConv.msgChunks[toolMessage.id]) { + threadState.onGoingConv.msgChunks[toolMessage.id] = [] + } + threadState.onGoingConv.msgChunks[toolMessage.id].push(toolMessage) + } + } + return false + case 'error': streamSmoother?.flushThread(threadId) handleChatError({ message: chunkMessage }, 'stream')