From 6ffbb29ec7b4aa22f1167592edcb3add1f660f79 Mon Sep 17 00:00:00 2001 From: ViperEkura <3081035982@qq.com> Date: Thu, 26 Mar 2026 21:17:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E4=BC=A0=E9=80=92=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/models.py | 11 +- backend/routes/projects.py | 2 +- backend/services/chat.py | 143 ++++++++++++++-------- backend/utils/helpers.py | 14 ++- docs/Design.md | 96 ++++++++++++--- frontend/src/App.vue | 77 +++++++++--- frontend/src/api/index.js | 5 +- frontend/src/components/ChatView.vue | 5 +- frontend/src/components/MessageBubble.vue | 25 ++-- frontend/src/components/ProcessBlock.vue | 88 ++++++------- frontend/src/components/Sidebar.vue | 34 ++++- 11 files changed, 349 insertions(+), 151 deletions(-) diff --git a/backend/models.py b/backend/models.py index e7d4e39..052bea5 100644 --- a/backend/models.py +++ b/backend/models.py @@ -96,7 +96,16 @@ class Message(db.Model): role = db.Column(db.String(16), nullable=False) # user, assistant, system, tool # Unified JSON structure: # User: {"text": "...", "attachments": [{"name": "a.py", "extension": "py", "content": "..."}]} - # Assistant: {"text": "...", "thinking": "...", "tool_calls": [{"id": "...", "name": "...", "arguments": "...", "result": "..."}]} + # Assistant: { + # "text": "...", + # "tool_calls": [...], // legacy flat structure + # "steps": [ // ordered steps for rendering (primary source of truth) + # {"id": "step-0", "index": 0, "type": "thinking", "content": "..."}, + # {"id": "step-1", "index": 1, "type": "text", "content": "..."}, + # {"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_xxx", "name": "...", "arguments": "..."}, + # {"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_xxx", "name": "...", "content": "..."}, + # ] + # } content = db.Column(LongText, default="") token_count = db.Column(db.Integer, default=0) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True) diff --git a/backend/routes/projects.py b/backend/routes/projects.py index acfa0fe..e70785c 100644 --- a/backend/routes/projects.py +++ b/backend/routes/projects.py @@ -224,7 +224,7 @@ def upload_project_folder(): # Create project record project = Project( id=str(uuid.uuid4()), - user_id=user_id, + user_id=user.id, name=project_name, path=relative_path, description=description diff --git a/backend/services/chat.py b/backend/services/chat.py index 9858793..6061469 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -53,8 +53,9 @@ class ChatService: messages = list(initial_messages) all_tool_calls = [] all_tool_results = [] + all_steps = [] # Collect all ordered steps for DB storage (thinking/text/tool_call/tool_result) step_index = 0 # Track global step index for ordering - + for iteration in range(self.MAX_ITERATIONS): full_content = "" full_thinking = "" @@ -62,10 +63,10 @@ class ChatService: prompt_tokens = 0 msg_id = str(uuid.uuid4()) tool_calls_list = [] - + # Send thinking_start event to clear previous thinking in frontend yield f"event: thinking_start\ndata: {{}}\n\n" - + try: with app.app_context(): active_conv = db.session.get(Conversation, conv_id) @@ -79,7 +80,8 @@ class ChatService: stream=True, ) resp.raise_for_status() - + + # Stream LLM response chunk by chunk for line in resp.iter_lines(): if not line: continue @@ -93,76 +95,109 @@ class ChatService: chunk = json.loads(data_str) except json.JSONDecodeError: continue - + delta = chunk["choices"][0].get("delta", {}) - - # Process thinking - send as process_step + + # Accumulate thinking content for this iteration reasoning = delta.get("reasoning_content", "") if reasoning: full_thinking += reasoning - # Still send thinking event for backward compatibility yield f"event: thinking\ndata: {json.dumps({'content': reasoning}, ensure_ascii=False)}\n\n" - - # Process text + + # Accumulate text content for this iteration text = delta.get("content", "") if text: full_content += text yield f"event: message\ndata: {json.dumps({'content': text}, ensure_ascii=False)}\n\n" - - # Process tool calls + + # Accumulate tool calls from streaming deltas tool_calls_list = self._process_tool_calls_delta(delta, tool_calls_list) - + usage = chunk.get("usage", {}) if usage: token_count = usage.get("completion_tokens", 0) prompt_tokens = usage.get("prompt_tokens", 0) - + except Exception as e: yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n" return - - # Tool calls exist - execute and continue + + # --- Tool calls exist: emit finalized steps, execute tools, continue loop --- if tool_calls_list: all_tool_calls.extend(tool_calls_list) - - # Send thinking as a complete step if exists + + # Record thinking as a finalized step (preserves order) if full_thinking: - yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n" + step_data = { + 'id': f'step-{step_index}', + 'index': step_index, + 'type': 'thinking', + 'content': full_thinking, + } + all_steps.append(step_data) + yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" step_index += 1 - - # Send text as a step if exists (text before tool calls) + + # Record text as a finalized step (text that preceded tool calls) if full_content: - yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'text', 'content': full_content}, ensure_ascii=False)}\n\n" + step_data = { + 'id': f'step-{step_index}', + 'index': step_index, + 'type': 'text', + 'content': full_content, + } + all_steps.append(step_data) + yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" step_index += 1 - - # Also send legacy tool_calls event for backward compatibility + + # Legacy tool_calls event for backward compatibility yield f"event: tool_calls\ndata: {json.dumps({'calls': tool_calls_list}, ensure_ascii=False)}\n\n" - - # Process each tool call one by one, send result immediately + + # Execute each tool call, emit tool_call + tool_result as paired steps tool_results = [] for tc in tool_calls_list: - # Send tool call step - yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'tool_call', 'id': tc['id'], 'name': tc['function']['name'], 'arguments': tc['function']['arguments']}, ensure_ascii=False)}\n\n" + # Emit tool_call step (before execution) + call_step = { + 'id': f'step-{step_index}', + 'index': step_index, + 'type': 'tool_call', + 'id_ref': tc['id'], + 'name': tc['function']['name'], + 'arguments': tc['function']['arguments'], + } + all_steps.append(call_step) + yield f"event: process_step\ndata: {json.dumps(call_step, ensure_ascii=False)}\n\n" step_index += 1 - - # Execute this single tool call (needs app context for db access) + + # Execute the tool with app.app_context(): single_result = self.executor.process_tool_calls([tc], context) tool_results.extend(single_result) - - # Send tool result step immediately + + # Emit tool_result step (after execution) tr = single_result[0] try: result_content = json.loads(tr["content"]) skipped = result_content.get("skipped", False) except: skipped = False - yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'tool_result', 'id': tr['tool_call_id'], 'name': tr['name'], 'content': tr['content'], 'skipped': skipped}, ensure_ascii=False)}\n\n" + result_step = { + 'id': f'step-{step_index}', + 'index': step_index, + 'type': 'tool_result', + 'id_ref': tr['tool_call_id'], + 'name': tr['name'], + 'content': tr['content'], + 'skipped': skipped, + } + all_steps.append(result_step) + yield f"event: process_step\ndata: {json.dumps(result_step, ensure_ascii=False)}\n\n" step_index += 1 - - # Also send legacy tool_result event + + # Legacy tool_result event for backward compatibility yield f"event: tool_result\ndata: {json.dumps({'id': tr['tool_call_id'], 'name': tr['name'], 'content': tr['content'], 'skipped': skipped}, ensure_ascii=False)}\n\n" - + + # Append assistant message + tool results for the next iteration messages.append({ "role": "assistant", "content": full_content or None, @@ -171,28 +206,41 @@ class ChatService: messages.extend(tool_results) all_tool_results.extend(tool_results) continue - - # No tool calls - finish - # Send thinking as a step if exists + + # --- No tool calls: final iteration — emit remaining steps and save --- if full_thinking: - yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n" + step_data = { + 'id': f'step-{step_index}', + 'index': step_index, + 'type': 'thinking', + 'content': full_thinking, + } + all_steps.append(step_data) + yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" step_index += 1 - # Send text as a step if exists if full_content: - yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'text', 'content': full_content}, ensure_ascii=False)}\n\n" + step_data = { + 'id': f'step-{step_index}', + 'index': step_index, + 'type': 'text', + 'content': full_content, + } + all_steps.append(step_data) + yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" step_index += 1 suggested_title = None with app.app_context(): - # Build content JSON + # Build content JSON with ordered steps array for DB storage. + # 'steps' is the single source of truth for rendering order. content_json = { "text": full_content, } - if full_thinking: - content_json["thinking"] = full_thinking if all_tool_calls: content_json["tool_calls"] = self._build_tool_calls_json(all_tool_calls, all_tool_results) + # Store ordered steps — the single source of truth for rendering order + content_json["steps"] = all_steps msg = Message( id=msg_id, @@ -208,15 +256,13 @@ class ChatService: if user: record_token_usage(user.id, conv_model, prompt_tokens, token_count) - # Check if we need to set title (first message in conversation) + # Auto-generate title from first user message if needed conv = db.session.get(Conversation, conv_id) if conv and (not conv.title or conv.title == "新对话"): - # Get user message content user_msg = Message.query.filter_by( conversation_id=conv_id, role="user" ).order_by(Message.created_at.asc()).first() if user_msg and user_msg.content: - # Parse content JSON to get text try: content_data = json.loads(user_msg.content) title_text = content_data.get("text", "")[:30] @@ -226,7 +272,6 @@ class ChatService: suggested_title = title_text else: suggested_title = "新对话" - # Refresh conv to avoid stale state db.session.refresh(conv) conv.title = suggested_title db.session.commit() diff --git a/backend/utils/helpers.py b/backend/utils/helpers.py index f523d7a..a9761b4 100644 --- a/backend/utils/helpers.py +++ b/backend/utils/helpers.py @@ -63,7 +63,12 @@ def to_dict(inst, **extra): def message_to_dict(msg: Message) -> dict: - """Convert message to dict, parsing JSON content""" + """Convert message to dict, parsing JSON content. + + For assistant messages, extracts the 'steps' array which preserves the + ordered sequence of thinking/text/tool_call/tool_result steps, so the + frontend can render them in the correct interleaved order. + """ result = to_dict(msg) # Parse content JSON @@ -71,16 +76,15 @@ def message_to_dict(msg: Message) -> dict: try: content_data = json.loads(msg.content) if isinstance(content_data, dict): - # Extract all fields from JSON result["text"] = content_data.get("text", "") if content_data.get("attachments"): result["attachments"] = content_data["attachments"] - if content_data.get("thinking"): - result["thinking"] = content_data["thinking"] if content_data.get("tool_calls"): result["tool_calls"] = content_data["tool_calls"] + # Extract ordered steps array for correct rendering order + if content_data.get("steps"): + result["process_steps"] = content_data["steps"] else: - # Fallback: plain text result["text"] = msg.content except (json.JSONDecodeError, TypeError): result["text"] = msg.content diff --git a/docs/Design.md b/docs/Design.md index 5ca0f80..6518862 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -175,7 +175,6 @@ classDiagram ```json { "text": "AI 回复的文本内容", - "thinking": "思考过程(可选)", "tool_calls": [ { "id": "call_xxx", @@ -189,10 +188,55 @@ classDiagram "skipped": false, "execution_time": 0.5 } + ], + "steps": [ + { + "id": "step-0", + "index": 0, + "type": "thinking", + "content": "第一轮思考过程..." + }, + { + "id": "step-1", + "index": 1, + "type": "text", + "content": "工具调用前的文本..." + }, + { + "id": "step-2", + "index": 2, + "type": "tool_call", + "id_ref": "call_abc123", + "name": "web_search", + "arguments": "{\"query\": \"...\"}" + }, + { + "id": "step-3", + "index": 3, + "type": "tool_result", + "id_ref": "call_abc123", + "name": "web_search", + "content": "{\"success\": true, ...}", + "skipped": false + }, + { + "id": "step-4", + "index": 4, + "type": "thinking", + "content": "第二轮思考过程..." + }, + { + "id": "step-5", + "index": 5, + "type": "text", + "content": "最终回复文本..." + } ] } ``` +`steps` 字段是**渲染顺序的唯一数据源**,按 `index` 顺序排列。thinking、text、tool_call、tool_result 可以在多轮迭代中穿插出现。`id_ref` 用于 tool_call 和 tool_result 步骤之间的匹配(对应 LLM 返回的工具调用 ID)。`tool_calls` 字段保留用于向后兼容旧版前端。 + ### 服务层 ```mermaid @@ -426,33 +470,53 @@ def process_tool_calls(self, tool_calls, context=None): | `message` | 回复内容的增量片段 | | `tool_calls` | 工具调用信息 | | `tool_result` | 工具执行结果 | -| `process_step` | 处理步骤(按顺序:thinking/text/tool_call/tool_result),支持穿插显示 | +| `process_step` | 有序处理步骤(thinking/text/tool_call/tool_result),支持穿插显示。携带 `id`、`index` 确保渲染顺序 | | `error` | 错误信息 | | `done` | 回复结束,携带 message_id 和 token_count | ### process_step 事件格式 +每个 `process_step` 事件携带一个带 `id`、`index` 和 `type` 的步骤对象。步骤按 `index` 顺序排列,确保前端可以正确渲染穿插的思考、文本和工具调用。 + ```json // 思考过程 -{"index": 0, "type": "thinking", "content": "完整思考内容..."} +{"id": "step-0", "index": 0, "type": "thinking", "content": "完整思考内容..."} // 回复文本(可穿插在任意步骤之间) -{"index": 1, "type": "text", "content": "回复文本内容..."} +{"id": "step-1", "index": 1, "type": "text", "content": "回复文本内容..."} -// 工具调用 -{"index": 2, "type": "tool_call", "id": "call_abc123", "name": "web_search", "arguments": "{\"query\": \"...\"}"} +// 工具调用(id_ref 存储工具调用 ID,用于与 tool_result 匹配) +{"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_abc123", "name": "web_search", "arguments": "{\"query\": \"...\"}"} -// 工具返回 -{"index": 3, "type": "tool_result", "id": "call_abc123", "name": "web_search", "content": "{\"success\": true, ...}", "skipped": false} +// 工具返回(id_ref 与 tool_call 的 id_ref 匹配) +{"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_abc123", "name": "web_search", "content": "{\"success\": true, ...}", "skipped": false} ``` 字段说明: -- `index`: 步骤序号,确保按正确顺序显示 -- `type`: 步骤类型(thinking/tool_call/tool_result) -- `id`: 工具调用唯一标识,用于匹配工具调用和返回结果 -- `name`: 工具名称 -- `content`: 内容或结果 -- `skipped`: 工具是否被跳过(失败后跳过) + +| 字段 | 说明 | +|------|------| +| `id` | 步骤唯一标识(格式 `step-{index}`),用于前端 key | +| `index` | 步骤序号,确保按正确顺序显示 | +| `type` | 步骤类型:`thinking` / `text` / `tool_call` / `tool_result` | +| `id_ref` | 工具调用引用 ID(仅 tool_call/tool_result),用于匹配调用与结果 | +| `name` | 工具名称(仅 tool_call/tool_result) | +| `arguments` | 工具调用参数 JSON 字符串(仅 tool_call) | +| `content` | 内容(thinking 的思考内容、text 的文本、tool_result 的返回结果) | +| `skipped` | 工具是否被跳过(仅 tool_result) | + +### 多轮迭代中的步骤顺序 + +一次完整的 LLM 交互可能经历多轮工具调用循环,每轮产生的步骤按以下顺序追加: + +``` +迭代 1: thinking → text → tool_call → tool_result +迭代 2: thinking → text → tool_call → tool_result +... +最终轮: thinking → text(无工具调用,结束) +``` + +所有步骤通过全局递增的 `index` 保证顺序。后端在完成所有迭代后,将这些步骤存入 `content_json["steps"]` 数组写入数据库。前端页面刷新时从 API 加载消息,`message_to_dict` 提取 `steps` 字段映射为 `process_steps` 返回,ProcessBlock 组件按 `index` 顺序渲染。 --- @@ -509,10 +573,12 @@ def process_tool_calls(self, tool_calls, context=None): | `id` | String(64) | UUID 主键 | | `conversation_id` | String(64) | 外键关联 Conversation | | `role` | String(16) | user/assistant/system/tool | -| `content` | LongText | JSON 格式内容(见上方结构说明) | +| `content` | LongText | JSON 格式内容(见上方结构说明),assistant 消息包含 `steps` 有序步骤数组 | | `token_count` | Integer | Token 数量 | | `created_at` | DateTime | 创建时间 | +`message_to_dict()` 辅助函数负责解析 `content` JSON,并提取 `steps` 字段映射为 `process_steps` 返回给前端,确保页面刷新后仍能按正确顺序渲染穿插的思考、文本和工具调用。 + ### TokenUsage(Token 使用统计) | 字段 | 类型 | 说明 | diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 42e2a1f..e3ce5ed 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,6 +2,7 @@