diff --git a/CLAUDE.md b/CLAUDE.md index 5cae8e8..b005add 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,3 +262,42 @@ validation_service/ (FastAPI, 端口 8001) — 不变 - `ProcessSection.vue` 渲染步骤编号/标签/耗时/内容(XML 代码高亮) **Fix 5 — 消息耗时显示**: `api_server.py` `agent_complete` 事件新增 `total_duration_ms`,`SummaryCard.vue` 显示总耗时,`chat.ts` 暴露 `lastDurationMs` + `formatDuration()` + + +## 更新 (v7 — 2026-05-22) + +### 会话持久化 & 多轮对话记忆修复 + +**原子写入** (`backend/session.py`): `save_session` 改用 tempfile + os.replace 原子写入,防止进程崩溃时 JSON 截断导致会话损坏。 + +**graph.stream 状态修复** (`api_server.py`): LangGraph 的 `graph.stream()` +只产出事件,不修改传入的 `agent_state`。`_run_graph_sync` 改为手动收集每个节点的 +返回 dict 并 `agent_state.update()`,确保 done 事件到达时 agent_state 已是完整状态。 +此修复解决了第二次请求时 `current_jrxml` 为空、导致多轮对话"失忆"的问题。 + +**save_session 调用时机**: 从 `stream_and_save` 末尾移至 `_sse_generator` 中 done 分支 +(yield `agent_complete` 之前),消除前端 `refreshFromApi()` 的竞态。 + +### OCR 管线打通 + +**uploaded_file_path 传递** (`api_server.py`): `_process_files` 返回的 `uploaded_paths` +注入 `agent_state["uploaded_file_path"]`,使 `process_input` 节点的 `OcrExtractor` 字段 +精确提取和 `annotation_detector` 批注检测得以触发。此前 `uploaded_file_path` 始终为空, +第二层 OCR 从未执行。 + +### 前端体验改进 + +**下载区常驻** (`Sidebar.vue`): 下载区域始终可见,无文件时显示灰色"暂无下载文件", +生成完成后自动出现下载链接。 + +**侧边栏自动刷新** (`stores/session.ts`, `App.vue`): 新增 `refreshFromApi()` 方法, +`agent_complete` 后自动从 API 重新加载会话状态,下载按钮无需手动刷新即可出现。 + +**节点进度完整展示** (`api_server.py`): 移除 `node_complete` 事件的 SKIP_NODES 过滤, +所有节点(包括加载会话等内部节点)的 start/complete 事件均正常发送,前端可看到 +完整流转(running → done)。 + +### modification_request 宽松化 + +原有 `status == "pass"` 条件去除:只要 `current_jrxml` 存在即设置 +`user_modification_request`,确保修改意图的请求能携带完整上下文。 diff --git a/api_server.py b/api_server.py index fe95cf7..61c2796 100644 --- a/api_server.py +++ b/api_server.py @@ -166,10 +166,21 @@ def _extract_detail(node_name: str, node_state: dict) -> str: def _run_graph_sync(agent_state: AgentState, event_q: queue.Queue): - """在后台线程中运行 graph.stream(),将所有事件推入队列。""" + """在后台线程中运行 graph.stream(),将所有事件推入队列。 + + graph.stream() 只产出事件,不修改传入的 agent_state。 + 因此需要手动收集每个节点的返回并合并到 agent_state。 + """ try: for event in _graph.stream(agent_state, stream_mode=["updates", "custom"]): event_q.put(event) + # 将节点更新合并到 agent_state + if isinstance(event, tuple) and len(event) == 2: + mode, data = event + if mode == "updates" and isinstance(data, dict): + for node_state in data.values(): + if isinstance(node_state, dict): + agent_state.update(node_state) event_q.put(("done", {"reason": "graph_completed"})) except Exception as exc: event_q.put(("error", { @@ -178,7 +189,7 @@ def _run_graph_sync(agent_state: AgentState, event_q: queue.Queue): })) -async def _sse_generator(agent_state: AgentState) -> str: +async def _sse_generator(agent_state: AgentState, session_id: str = "") -> str: """SSE 事件生成器 —— 在后台线程运行图,异步产出 SSE 字符串。""" global _current_event_queue, _step_counter @@ -205,6 +216,8 @@ async def _sse_generator(agent_state: AgentState) -> str: if kind == "done": _current_event_queue = None total_ms = round((time.time() - t_start) * 1000) + if session_id: + save_session(session_id, agent_state) yield _sse_line("agent_complete", { "reason": "done", "intent": agent_state.get("intent", ""), @@ -233,13 +246,12 @@ async def _sse_generator(agent_state: AgentState) -> str: mode, data = item if mode == "updates": for node_name, node_state in data.items(): - if node_name not in SKIP_NODES: - detail = _extract_detail(node_name, node_state) - yield _sse_line("node_complete", { - "node": node_name, - "label": NODE_LABELS.get(node_name, node_name), - "detail": detail, - }) + detail = _extract_detail(node_name, node_state) + yield _sse_line("node_complete", { + "node": node_name, + "label": NODE_LABELS.get(node_name, node_name), + "detail": detail, + }) elif mode == "custom": cd = data if cd.get("type") == "stream": @@ -500,9 +512,11 @@ async def chat(session_id: str, payload: dict): for line in file_result["ocr_text"].split("\n") if line.strip()] if ocr_rows: agent_state["ocr_elements"] = ocr_rows + if file_result.get("uploaded_paths"): + agent_state["uploaded_file_path"] = file_result["uploaded_paths"][0] # ── 设置本轮输入 ── - if agent_state.get("current_jrxml") and agent_state.get("status") == "pass": + if agent_state.get("current_jrxml"): agent_state["user_modification_request"] = full_prompt agent_state["user_input"] = full_prompt @@ -519,12 +533,9 @@ async def chat(session_id: str, payload: dict): # ── 返回 SSE 流 ── async def stream_and_save(): final_state = None - async for sse_chunk in _sse_generator(agent_state): + async for sse_chunk in _sse_generator(agent_state, session_id): yield sse_chunk - # 图执行完成后保存会话状态 - save_session(session_id, agent_state) - return StreamingResponse( stream_and_save(), media_type="text/event-stream", diff --git a/backend/session.py b/backend/session.py index 1c8c8f6..63cf1eb 100644 --- a/backend/session.py +++ b/backend/session.py @@ -6,6 +6,7 @@ import json import os import uuid +import tempfile from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -64,7 +65,7 @@ def load_session(session_id: str) -> Optional[dict]: def save_session(session_id: str, agent_state: dict, session_name: str = ""): - """将会话状态保存(更新)至磁盘。""" + """将会话状态原子保存至磁盘(temp file + rename,避免崩溃时截断)。""" _ensure_dir() fp = _session_path(session_id) data = {} @@ -82,8 +83,19 @@ def save_session(session_id: str, agent_state: dict, session_name: str = ""): data["created_at"] = data["updated_at"] data["agent_state"] = agent_state - with open(fp, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + # 原子写入:先写临时文件,再 replace,避免崩溃时截断 JSON + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, + dir=SESSIONS_DIR, encoding="utf-8", + ) + try: + json.dump(data, tmp, ensure_ascii=False, indent=2) + tmp.close() + os.replace(tmp.name, str(fp)) + except Exception: + tmp.close() + Path(tmp.name).unlink(missing_ok=True) + raise def get_session_state(session_id: str) -> Optional[dict]: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5afe3bc..7193cb8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -98,12 +98,12 @@ async function handleSend(text: string, files: File[]) { } // Refresh session sidebar data after a short delay - setTimeout(() => session.refreshFromState({}), 500) + setTimeout(() => session.refreshFromApi(), 500) }, onAgentError(data) { chat.setError(data.error) chat.addMessage({ role: 'assistant', content: `执行异常: ${data.error}`, type: 'error' }) - setTimeout(() => session.refreshFromState({}), 500) + setTimeout(() => session.refreshFromApi(), 500) }, }) } catch (e: any) { diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index df1a5ee..73979e5 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -99,7 +99,7 @@ async function handleDelete() { -