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:
@@ -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`,确保修改意图的请求能携带完整上下文。
|
||||
|
||||
+19
-8
@@ -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,7 +246,6 @@ 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,
|
||||
@@ -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",
|
||||
|
||||
+15
-3
@@ -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]:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -99,7 +99,7 @@ async function handleDelete() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section" v-if="session.currentJrxml || session.versions.length > 0">
|
||||
<div class="sidebar-section">
|
||||
<div class="section-title">下载</div>
|
||||
<a
|
||||
v-if="session.currentJrxml"
|
||||
@@ -109,6 +109,7 @@ async function handleDelete() {
|
||||
>
|
||||
下载最新 JRXML
|
||||
</a>
|
||||
<div v-else class="btn-download disabled">暂无下载文件</div>
|
||||
<div v-if="session.versions.length > 1" class="version-list">
|
||||
<div class="version-list-title">历史版本</div>
|
||||
<a
|
||||
@@ -181,178 +182,130 @@ async function handleDelete() {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #45475a;
|
||||
}
|
||||
.btn-icon:hover { background: #45475a; }
|
||||
|
||||
.session-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
padding: 8px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
background: #313244;
|
||||
}
|
||||
|
||||
.session-item.active {
|
||||
background: #45475a;
|
||||
border-left: 3px solid #cba6f7;
|
||||
padding-left: 13px;
|
||||
}
|
||||
.session-item:hover { background: #313244; }
|
||||
.session-item.active { background: #45475a; }
|
||||
|
||||
.session-name {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.session-time {
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
display: block;
|
||||
width: calc(100% - 32px);
|
||||
margin: 8px 16px 0;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #f38ba8;
|
||||
background: transparent;
|
||||
width: calc(100% - 16px);
|
||||
margin: 8px 8px 0;
|
||||
padding: 6px 0;
|
||||
border: 1px solid #45475a;
|
||||
background: none;
|
||||
color: #f38ba8;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #f38ba8;
|
||||
color: #1e1e2e;
|
||||
}
|
||||
.btn-delete:hover { background: #45475a; }
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
padding: 6px 0;
|
||||
border: 1px solid #45475a;
|
||||
background: #313244;
|
||||
color: #cdd6f4;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.15s, border-color 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;
|
||||
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: #a6e3a1; color: #a6e3a1; }
|
||||
.btn-undo { border-color: #f9e2af; color: #f9e2af; }
|
||||
.btn-reset { border-color: #f38ba8; color: #f38ba8; }
|
||||
|
||||
.btn-download {
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
color: #a6e3a1;
|
||||
margin: 4px 16px;
|
||||
padding: 8px 0;
|
||||
background: #cba6f7;
|
||||
color: #1e1e2e;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
.btn-download:hover { background: #b4befe; }
|
||||
.btn-download.disabled {
|
||||
background: #313244;
|
||||
color: #6c7086;
|
||||
cursor: not-allowed;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
padding: 4px 16px 8px;
|
||||
margin-top: 8px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.version-list-title {
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
margin-bottom: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #313244;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
color: #a6adc8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
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;
|
||||
}
|
||||
.version-item:hover { color: #cba6f7; }
|
||||
.version-time { font-size: 11px; color: #6c7086; }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 12px 16px;
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
color: #585b70;
|
||||
border-top: 1px solid #313244;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,6 +63,19 @@ export const useSessionStore = defineStore('session', () => {
|
||||
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>) {
|
||||
currentJrxml.value = agentState.current_jrxml || currentJrxml.value
|
||||
versions.value = agentState.jrxml_versions || versions.value
|
||||
@@ -72,6 +85,6 @@ export const useSessionStore = defineStore('session', () => {
|
||||
return {
|
||||
sessions, currentId, currentName, versions, historyStates, currentJrxml,
|
||||
hasJrxml, hasHistory, sortedSessions, currentSession,
|
||||
loadSessions, createSession, switchSession, deleteCurrent, refreshFromState,
|
||||
loadSessions, createSession, switchSession, deleteCurrent, refreshFromState, refreshFromApi,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user