fix: session persistence, multi-turn memory, OCR pipeline, download UX (v7)

- graph.stream() state fix: agent_state now properly accumulates node updates

- atomic session save (tempfile + os.replace)

- uploaded_file_path injection for OcrExtractor + annotation_detector

- download section always visible; refreshFromApi auto-reloads after generation

- node_start/complete unfiltered for full progress visibility

- modification_request without status=='pass' check
This commit is contained in:
2026-05-22 11:13:25 +08:00
parent 4dfc418fc5
commit 1144a86d02
6 changed files with 147 additions and 119 deletions
+39
View File
@@ -262,3 +262,42 @@ validation_service/ (FastAPI, 端口 8001) — 不变
- `ProcessSection.vue` 渲染步骤编号/标签/耗时/内容(XML 代码高亮) - `ProcessSection.vue` 渲染步骤编号/标签/耗时/内容(XML 代码高亮)
**Fix 5 — 消息耗时显示**: `api_server.py` `agent_complete` 事件新增 `total_duration_ms``SummaryCard.vue` 显示总耗时,`chat.ts` 暴露 `lastDurationMs` + `formatDuration()` **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`,确保修改意图的请求能携带完整上下文。
+25 -14
View File
@@ -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): def _run_graph_sync(agent_state: AgentState, event_q: queue.Queue):
"""在后台线程中运行 graph.stream(),将所有事件推入队列。""" """在后台线程中运行 graph.stream(),将所有事件推入队列。
graph.stream() 只产出事件,不修改传入的 agent_state。
因此需要手动收集每个节点的返回并合并到 agent_state。
"""
try: try:
for event in _graph.stream(agent_state, stream_mode=["updates", "custom"]): for event in _graph.stream(agent_state, stream_mode=["updates", "custom"]):
event_q.put(event) 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"})) event_q.put(("done", {"reason": "graph_completed"}))
except Exception as exc: except Exception as exc:
event_q.put(("error", { 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 字符串。""" """SSE 事件生成器 —— 在后台线程运行图,异步产出 SSE 字符串。"""
global _current_event_queue, _step_counter global _current_event_queue, _step_counter
@@ -205,6 +216,8 @@ async def _sse_generator(agent_state: AgentState) -> str:
if kind == "done": if kind == "done":
_current_event_queue = None _current_event_queue = None
total_ms = round((time.time() - t_start) * 1000) total_ms = round((time.time() - t_start) * 1000)
if session_id:
save_session(session_id, agent_state)
yield _sse_line("agent_complete", { yield _sse_line("agent_complete", {
"reason": "done", "reason": "done",
"intent": agent_state.get("intent", ""), "intent": agent_state.get("intent", ""),
@@ -233,13 +246,12 @@ async def _sse_generator(agent_state: AgentState) -> str:
mode, data = item mode, data = item
if mode == "updates": if mode == "updates":
for node_name, node_state in data.items(): for node_name, node_state in data.items():
if node_name not in SKIP_NODES: detail = _extract_detail(node_name, node_state)
detail = _extract_detail(node_name, node_state) yield _sse_line("node_complete", {
yield _sse_line("node_complete", { "node": node_name,
"node": node_name, "label": NODE_LABELS.get(node_name, node_name),
"label": NODE_LABELS.get(node_name, node_name), "detail": detail,
"detail": detail, })
})
elif mode == "custom": elif mode == "custom":
cd = data cd = data
if cd.get("type") == "stream": 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()] for line in file_result["ocr_text"].split("\n") if line.strip()]
if ocr_rows: if ocr_rows:
agent_state["ocr_elements"] = 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_modification_request"] = full_prompt
agent_state["user_input"] = full_prompt agent_state["user_input"] = full_prompt
@@ -519,12 +533,9 @@ async def chat(session_id: str, payload: dict):
# ── 返回 SSE 流 ── # ── 返回 SSE 流 ──
async def stream_and_save(): async def stream_and_save():
final_state = None 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 yield sse_chunk
# 图执行完成后保存会话状态
save_session(session_id, agent_state)
return StreamingResponse( return StreamingResponse(
stream_and_save(), stream_and_save(),
media_type="text/event-stream", media_type="text/event-stream",
+15 -3
View File
@@ -6,6 +6,7 @@
import json import json
import os import os
import uuid import uuid
import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional 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 = ""): def save_session(session_id: str, agent_state: dict, session_name: str = ""):
"""将会话状态保存(更新)至磁盘""" """将会话状态原子保存至磁盘(temp file + rename,避免崩溃时截断)"""
_ensure_dir() _ensure_dir()
fp = _session_path(session_id) fp = _session_path(session_id)
data = {} data = {}
@@ -82,8 +83,19 @@ def save_session(session_id: str, agent_state: dict, session_name: str = ""):
data["created_at"] = data["updated_at"] data["created_at"] = data["updated_at"]
data["agent_state"] = agent_state data["agent_state"] = agent_state
with open(fp, "w", encoding="utf-8") as f: # 原子写入:先写临时文件,再 replace,避免崩溃时截断 JSON
json.dump(data, f, ensure_ascii=False, indent=2) 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]: def get_session_state(session_id: str) -> Optional[dict]:
+2 -2
View File
@@ -98,12 +98,12 @@ async function handleSend(text: string, files: File[]) {
} }
// Refresh session sidebar data after a short delay // Refresh session sidebar data after a short delay
setTimeout(() => session.refreshFromState({}), 500) setTimeout(() => session.refreshFromApi(), 500)
}, },
onAgentError(data) { onAgentError(data) {
chat.setError(data.error) chat.setError(data.error)
chat.addMessage({ role: 'assistant', content: `执行异常: ${data.error}`, type: 'error' }) chat.addMessage({ role: 'assistant', content: `执行异常: ${data.error}`, type: 'error' })
setTimeout(() => session.refreshFromState({}), 500) setTimeout(() => session.refreshFromApi(), 500)
}, },
}) })
} catch (e: any) { } catch (e: any) {
+52 -99
View File
@@ -99,7 +99,7 @@ async function handleDelete() {
</div> </div>
</div> </div>
<div class="sidebar-section" v-if="session.currentJrxml || session.versions.length > 0"> <div class="sidebar-section">
<div class="section-title">下载</div> <div class="section-title">下载</div>
<a <a
v-if="session.currentJrxml" v-if="session.currentJrxml"
@@ -109,6 +109,7 @@ async function handleDelete() {
> >
下载最新 JRXML 下载最新 JRXML
</a> </a>
<div v-else class="btn-download disabled">暂无下载文件</div>
<div v-if="session.versions.length > 1" class="version-list"> <div v-if="session.versions.length > 1" class="version-list">
<div class="version-list-title">历史版本</div> <div class="version-list-title">历史版本</div>
<a <a
@@ -181,178 +182,130 @@ async function handleDelete() {
cursor: pointer; cursor: pointer;
font-size: 16px; font-size: 16px;
line-height: 1; line-height: 1;
display: flex;
align-items: center;
justify-content: center;
} }
.btn-icon:hover { background: #45475a; }
.btn-icon:hover {
background: #45475a;
}
.session-list { .session-list {
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
padding: 0 8px;
} }
.session-item { .session-item {
padding: 8px 16px;
cursor: pointer;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 13px; padding: 8px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
} }
.session-item:hover { background: #313244; }
.session-item:hover { .session-item.active { background: #45475a; }
background: #313244;
}
.session-item.active {
background: #45475a;
border-left: 3px solid #cba6f7;
padding-left: 13px;
}
.session-name { .session-name {
font-size: 13px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 150px; flex: 1;
} }
.session-time { .session-time {
font-size: 11px; font-size: 11px;
color: #6c7086; color: #6c7086;
margin-left: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.btn-delete { .btn-delete {
display: block; display: block;
width: calc(100% - 32px); width: calc(100% - 16px);
margin: 8px 16px 0; margin: 8px 8px 0;
padding: 6px 12px; padding: 6px 0;
border: 1px solid #f38ba8; border: 1px solid #45475a;
background: transparent; background: none;
color: #f38ba8; color: #f38ba8;
border-radius: 6px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
text-align: center;
} }
.btn-delete:hover { background: #45475a; }
.btn-delete:hover {
background: #f38ba8;
color: #1e1e2e;
}
.quick-actions { .quick-actions {
display: flex; display: flex;
gap: 6px; gap: 8px;
padding: 0 16px; padding: 0 16px;
} }
.btn-action { .btn-action {
flex: 1; flex: 1;
padding: 6px 8px; padding: 6px 0;
border: 1px solid #45475a; border: 1px solid #45475a;
background: #313244; border-radius: 4px;
color: #cdd6f4;
border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
transition: background 0.15s, border-color 0.15s; color: #cdd6f4;
} background: none;
transition: background 0.15s;
.btn-action:hover:not(:disabled) {
background: #45475a;
}
.btn-action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-preview {
border-color: #89b4fa;
}
.btn-preview:hover:not(:disabled) {
background: #89b4fa;
color: #1e1e2e;
}
.btn-undo {
border-color: #f9e2af;
}
.btn-undo:hover:not(:disabled) {
background: #f9e2af;
color: #1e1e2e;
}
.btn-reset {
border-color: #f38ba8;
}
.btn-reset:hover:not(:disabled) {
background: #f38ba8;
color: #1e1e2e;
} }
.btn-action:hover:not(:disabled) { background: #45475a; }
.btn-action:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-preview { border-color: #a6e3a1; color: #a6e3a1; }
.btn-undo { border-color: #f9e2af; color: #f9e2af; }
.btn-reset { border-color: #f38ba8; color: #f38ba8; }
.btn-download { .btn-download {
display: block; display: block;
padding: 8px 16px; margin: 4px 16px;
color: #a6e3a1; padding: 8px 0;
background: #cba6f7;
color: #1e1e2e;
text-align: center;
border-radius: 4px;
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 13px;
font-weight: 600;
cursor: pointer;
} }
.btn-download:hover { background: #b4befe; }
.btn-download:hover { .btn-download.disabled {
background: #313244; background: #313244;
color: #6c7086;
cursor: not-allowed;
font-weight: 400;
} }
.version-list { .version-list {
padding: 4px 16px 8px; margin-top: 8px;
padding: 0 16px;
} }
.version-list-title { .version-list-title {
font-size: 11px; font-size: 11px;
color: #6c7086; color: #6c7086;
margin-bottom: 4px; margin-bottom: 4px;
padding-top: 8px;
border-top: 1px solid #313244;
} }
.version-item { .version-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
padding: 4px 0; padding: 4px 0;
font-size: 12px; font-size: 12px;
color: #a6adc8; color: #a6adc8;
text-decoration: none; text-decoration: none;
} }
.version-item:hover { color: #cba6f7; }
.version-item:hover { .version-time { font-size: 11px; color: #6c7086; }
color: #cdd6f4;
}
.version-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-time {
font-size: 11px;
color: #6c7086;
flex-shrink: 0;
margin-left: 8px;
}
.sidebar-footer { .sidebar-footer {
margin-top: auto; margin-top: auto;
padding: 12px 16px; padding: 12px 16px;
font-size: 11px; font-size: 11px;
color: #6c7086; color: #585b70;
border-top: 1px solid #313244; border-top: 1px solid #313244;
} }
</style> </style>
+14 -1
View File
@@ -63,6 +63,19 @@ export const useSessionStore = defineStore('session', () => {
versions.value = [] versions.value = []
} }
async function refreshFromApi() {
if (!currentId.value) return
try {
const data = await api.getSession(currentId.value)
const state = data.agent_state
currentJrxml.value = state.current_jrxml || ''
versions.value = state.jrxml_versions || []
historyStates.value = state.history_states || []
} catch (e) {
console.error('刷新会话状态失败:', e)
}
}
function refreshFromState(agentState: Record<string, any>) { function refreshFromState(agentState: Record<string, any>) {
currentJrxml.value = agentState.current_jrxml || currentJrxml.value currentJrxml.value = agentState.current_jrxml || currentJrxml.value
versions.value = agentState.jrxml_versions || versions.value versions.value = agentState.jrxml_versions || versions.value
@@ -72,6 +85,6 @@ export const useSessionStore = defineStore('session', () => {
return { return {
sessions, currentId, currentName, versions, historyStates, currentJrxml, sessions, currentId, currentName, versions, historyStates, currentJrxml,
hasJrxml, hasHistory, sortedSessions, currentSession, hasJrxml, hasHistory, sortedSessions, currentSession,
loadSessions, createSession, switchSession, deleteCurrent, refreshFromState, loadSessions, createSession, switchSession, deleteCurrent, refreshFromState, refreshFromApi,
} }
}) })