feat: 5-issue fix — OCR image parse bug + Vue frontend feature parity + streaming UX
Fix 1 (CRITICAL): file_parser.py suffix normalization ".jpg", api_server.py Path.suffix Fix 2: Sidebar version history download, ProcessSection replaces old components Fix 3: OCR content/position layer structured logging in agent/nodes.py Fix 4: collapsible process sections with per-section stream routing + auto-fold Fix 5: agent_complete total_duration_ms, SummaryCard duration display - backend/file_parser.py: normalize suffix to always include leading dot - api_server.py: step_index in node_start, total_duration_ms in agent_complete - agent/nodes.py: _log_ocr_layers() for [内容层]/[位置层]/[合并] logging - frontend: ProcessSection.vue (NEW), chat.ts sections model, Sidebar versions - CLAUDE.md: updated component list and v6 changelog
This commit is contained in:
@@ -45,12 +45,11 @@ cd frontend && npm run dev
|
|||||||
│ ├── stores/chat.ts Pinia: 消息/流式/节点进度
|
│ ├── stores/chat.ts Pinia: 消息/流式/节点进度
|
||||||
│ ├── stores/session.ts Pinia: 会话管理
|
│ ├── stores/session.ts Pinia: 会话管理
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── Sidebar.vue 会话列表 + 下载
|
│ │ ├── Sidebar.vue 会话列表 + 下载 + 历史版本
|
||||||
│ │ ├── ChatMessages.vue 消息列表渲染
|
│ │ ├── ChatMessages.vue 消息列表渲染
|
||||||
│ │ ├── StreamingMessage.vue 流式文本展示
|
│ │ ├── ProcessSection.vue 过程折叠区(替代 StreamingMessage + NodeProgress)
|
||||||
│ │ ├── UnifiedInput.vue 统一输入框(文本+文件拖拽/粘贴)
|
│ │ ├── UnifiedInput.vue 统一输入框(文本+文件拖拽/粘贴/芯片)
|
||||||
│ │ ├── NodeProgress.vue 节点进度指示
|
│ │ └── SummaryCard.vue 结果摘要卡片(含耗时)
|
||||||
│ │ └── SummaryCard.vue 结果摘要卡片
|
|
||||||
│ └── utils/format.ts 工具函数
|
│ └── utils/format.ts 工具函数
|
||||||
│
|
│
|
||||||
▼ HTTP + SSE (Server-Sent Events)
|
▼ HTTP + SSE (Server-Sent Events)
|
||||||
@@ -243,3 +242,23 @@ validation_service/ (FastAPI, 端口 8001) — 不变
|
|||||||
- **st-multimodal-chatinput**: Streamlit 聊天输入增强组件,替代 `st.chat_input`,支持粘贴/拖拽文件。返回 base64 编码文件内容。
|
- **st-multimodal-chatinput**: Streamlit 聊天输入增强组件,替代 `st.chat_input`,支持粘贴/拖拽文件。返回 base64 编码文件内容。
|
||||||
- **xlwt**: 仅在测试中使用(生成 .xls 测试文件)。
|
- **xlwt**: 仅在测试中使用(生成 .xls 测试文件)。
|
||||||
- **分层精确生成**: 3 阶段管线仅在 `layout_schema.total_rows > 0` 时触发。文本请求和 `modify_report` 等意图不受影响,走原有 `generate` 节点。中间阶段(骨架/精调)跳过验证,只有最终 mapped 结果进入 `validate`。
|
- **分层精确生成**: 3 阶段管线仅在 `layout_schema.total_rows > 0` 时触发。文本请求和 `modify_report` 等意图不受影响,走原有 `generate` 节点。中间阶段(骨架/精调)跳过验证,只有最终 mapped 结果进入 `validate`。
|
||||||
|
|
||||||
|
## 新增功能 (v6)
|
||||||
|
|
||||||
|
### 5-Issue Fix — 图片解析 Bug + 前端功能补全
|
||||||
|
|
||||||
|
**Fix 1 — 图片后缀 dot 缺失**: `file_parser.py` 后缀规范化(`"jpg"` → `".jpg"`),`api_server.py` 使用 `Path.suffix` 替代 `rsplit`。所有图片上传之前均因后缀不匹配回退到文本解析器,OCR/布局分析从未实际触发。
|
||||||
|
|
||||||
|
**Fix 2 — Vue 前端功能补全**:
|
||||||
|
- `ProcessSection.vue` 替代 `StreamingMessage.vue` + `NodeProgress.vue`,使用 `<details>`/`<summary>` 原生可折叠区域
|
||||||
|
- `Sidebar.vue` 新增历史版本下载列表(`jrxml_versions` 索引下载)
|
||||||
|
- `UnifiedInput.vue` 已集成文件拖拽/粘贴/芯片/移除(v5 已完成)
|
||||||
|
|
||||||
|
**Fix 3 — OCR 两层日志**: `agent/nodes.py` 新增 `_log_ocr_layers()` — `[内容层]` OCR 文本+字段提取,`[位置层]` 布局 schema 列×行+区域分类,`[合并]` 管线选择(3阶段 vs 单阶段)
|
||||||
|
|
||||||
|
**Fix 4 — 全过程流式输出+自动折叠**:
|
||||||
|
- `api_server.py` `node_start` 事件携带 `step_index`
|
||||||
|
- `chat.ts` 新增 `ProcessSection[]` 模型:per-section stream routing、完成自动折叠、运行中自动展开
|
||||||
|
- `ProcessSection.vue` 渲染步骤编号/标签/耗时/内容(XML 代码高亮)
|
||||||
|
|
||||||
|
**Fix 5 — 消息耗时显示**: `api_server.py` `agent_complete` 事件新增 `total_duration_ms`,`SummaryCard.vue` 显示总耗时,`chat.ts` 暴露 `lastDurationMs` + `formatDuration()`
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ def process_input(state: AgentState) -> Dict:
|
|||||||
state["ocr_extraction_result"] = {"error": str(e)}
|
state["ocr_extraction_result"] = {"error": str(e)}
|
||||||
state["uploaded_file_path"] = ""
|
state["uploaded_file_path"] = ""
|
||||||
|
|
||||||
|
# ── OCR 两层日志:内容层 + 位置层 ──
|
||||||
|
_log_ocr_layers(state)
|
||||||
|
|
||||||
# 重置本轮请求字段
|
# 重置本轮请求字段
|
||||||
state["retry_count"] = 0
|
state["retry_count"] = 0
|
||||||
state["user_modification_request"] = user_input
|
state["user_modification_request"] = user_input
|
||||||
@@ -532,6 +535,76 @@ def _format_ocr_context(state: AgentState) -> str:
|
|||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_ocr_layers(state: AgentState) -> None:
|
||||||
|
"""记录 OCR 两层分离日志:内容层(文本/字段)+ 位置层(布局/坐标)。"""
|
||||||
|
# ── 内容层:OCR 文本元素 + 提取的字段 ──
|
||||||
|
ocr_result = state.get("ocr_extraction_result")
|
||||||
|
ocr_elements = state.get("ocr_elements", [])
|
||||||
|
|
||||||
|
content_parts = []
|
||||||
|
if isinstance(ocr_result, dict) and not ocr_result.get("error"):
|
||||||
|
total = ocr_result.get("total_elements", 0)
|
||||||
|
fields = ocr_result.get("fields", [])
|
||||||
|
non_empty = [f for f in fields if f.get("field_value")]
|
||||||
|
if total or non_empty:
|
||||||
|
content_parts.append(
|
||||||
|
f"OCR 提取: {total} 个文本元素, {len(non_empty)} 个有效字段"
|
||||||
|
)
|
||||||
|
if isinstance(ocr_elements, list) and ocr_elements:
|
||||||
|
elem_count = sum(len(row.get("elements", [])) for row in ocr_elements)
|
||||||
|
content_parts.append(
|
||||||
|
f"API 注入 OCR 元素: {len(ocr_elements)} 行, {elem_count} 个文本"
|
||||||
|
)
|
||||||
|
|
||||||
|
if content_parts:
|
||||||
|
_node_log.info(
|
||||||
|
"[内容层] " + " | ".join(content_parts),
|
||||||
|
extra={"layer": "content", "phase": "ocr_extraction"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 位置层:布局 schema(行/列/区域)──
|
||||||
|
layout = state.get("layout_schema")
|
||||||
|
if isinstance(layout, dict) and layout.get("total_rows", 0) > 0:
|
||||||
|
regions = layout.get("regions", {})
|
||||||
|
region_names = list(regions.keys()) if regions else []
|
||||||
|
cols = layout.get("total_columns", 0)
|
||||||
|
rows = layout.get("total_rows", 0)
|
||||||
|
regions_label = ", ".join(region_names) if region_names else "标题/表头/数据/表尾"
|
||||||
|
_node_log.info(
|
||||||
|
f"[位置层] 布局 schema: {cols} 列 × {rows} 行, 区域: {regions_label}",
|
||||||
|
extra={
|
||||||
|
"layer": "position",
|
||||||
|
"phase": "layout_analysis",
|
||||||
|
"columns": cols,
|
||||||
|
"rows": rows,
|
||||||
|
"regions": region_names,
|
||||||
|
"a4_confidence": layout.get("a4_confidence", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 合并:两阶段处理总结 ──
|
||||||
|
has_content = (isinstance(ocr_result, dict) and not ocr_result.get("error")) or \
|
||||||
|
(isinstance(ocr_elements, list) and ocr_elements)
|
||||||
|
has_layout = isinstance(layout, dict) and layout.get("total_rows", 0) > 0
|
||||||
|
|
||||||
|
if has_content and has_layout:
|
||||||
|
_node_log.info(
|
||||||
|
"[合并] 内容层 + 位置层均已就绪 — "
|
||||||
|
"注入 prompt: 骨架生成 → 精调布局 → 字段映射",
|
||||||
|
extra={"layer": "merge", "pipeline": "skeleton→refine→map_fields"},
|
||||||
|
)
|
||||||
|
elif has_content and not has_layout:
|
||||||
|
_node_log.info(
|
||||||
|
"[合并] 仅有内容层 — 使用单阶段 generate(无布局 schema)",
|
||||||
|
extra={"layer": "merge", "pipeline": "generate_only"},
|
||||||
|
)
|
||||||
|
elif has_layout and not has_content:
|
||||||
|
_node_log.info(
|
||||||
|
"[合并] 仅有位置层 — 使用布局 schema 指导生成",
|
||||||
|
extra={"layer": "merge", "pipeline": "layout_only"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@log_node("retrieve")
|
@log_node("retrieve")
|
||||||
def retrieve(state: AgentState) -> Dict:
|
def retrieve(state: AgentState) -> Dict:
|
||||||
"""在 ChromaDB + 错误知识库中搜索相关的 JRXML 模板和组件。"""
|
"""在 ChromaDB + 错误知识库中搜索相关的 JRXML 模板和组件。"""
|
||||||
|
|||||||
+9
-1
@@ -103,15 +103,19 @@ UPLOADS_DIR = Path(os.getenv("UPLOADS_DIR", "./uploads"))
|
|||||||
|
|
||||||
# 当前请求的事件队列(单个用户桌面应用,无并发问题)
|
# 当前请求的事件队列(单个用户桌面应用,无并发问题)
|
||||||
_current_event_queue: Optional[queue.Queue] = None
|
_current_event_queue: Optional[queue.Queue] = None
|
||||||
|
_step_counter: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _on_node_start(node_name: str):
|
def _on_node_start(node_name: str):
|
||||||
"""全局 node_start 回调 — 将事件推入当前请求的事件队列。"""
|
"""全局 node_start 回调 — 将事件推入当前请求的事件队列。"""
|
||||||
|
global _step_counter
|
||||||
q = _current_event_queue
|
q = _current_event_queue
|
||||||
if q is not None:
|
if q is not None:
|
||||||
|
_step_counter += 1
|
||||||
q.put(("node_start", {
|
q.put(("node_start", {
|
||||||
"node": node_name,
|
"node": node_name,
|
||||||
"label": NODE_LABELS.get(node_name, node_name),
|
"label": NODE_LABELS.get(node_name, node_name),
|
||||||
|
"step_index": _step_counter,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
@@ -176,8 +180,10 @@ 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) -> str:
|
||||||
"""SSE 事件生成器 —— 在后台线程运行图,异步产出 SSE 字符串。"""
|
"""SSE 事件生成器 —— 在后台线程运行图,异步产出 SSE 字符串。"""
|
||||||
global _current_event_queue
|
global _current_event_queue, _step_counter
|
||||||
|
|
||||||
|
_step_counter = 0
|
||||||
|
t_start = time.time()
|
||||||
event_q: queue.Queue = queue.Queue()
|
event_q: queue.Queue = queue.Queue()
|
||||||
_current_event_queue = event_q
|
_current_event_queue = event_q
|
||||||
|
|
||||||
@@ -198,6 +204,7 @@ async def _sse_generator(agent_state: AgentState) -> str:
|
|||||||
kind = item[0]
|
kind = item[0]
|
||||||
if kind == "done":
|
if kind == "done":
|
||||||
_current_event_queue = None
|
_current_event_queue = None
|
||||||
|
total_ms = round((time.time() - t_start) * 1000)
|
||||||
yield _sse_line("agent_complete", {
|
yield _sse_line("agent_complete", {
|
||||||
"reason": "done",
|
"reason": "done",
|
||||||
"intent": agent_state.get("intent", ""),
|
"intent": agent_state.get("intent", ""),
|
||||||
@@ -206,6 +213,7 @@ async def _sse_generator(agent_state: AgentState) -> str:
|
|||||||
"error_msg": agent_state.get("error_msg", ""),
|
"error_msg": agent_state.get("error_msg", ""),
|
||||||
"natural_explanation": agent_state.get("natural_explanation", ""),
|
"natural_explanation": agent_state.get("natural_explanation", ""),
|
||||||
"retry_count": agent_state.get("retry_count", 0),
|
"retry_count": agent_state.get("retry_count", 0),
|
||||||
|
"total_duration_ms": total_ms,
|
||||||
"ocr_extraction_result": agent_state.get("ocr_extraction_result", {}),
|
"ocr_extraction_result": agent_state.get("ocr_extraction_result", {}),
|
||||||
})
|
})
|
||||||
await future
|
await future
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { useSessionStore } from './stores/session'
|
|||||||
import { api } from './api/client'
|
import { api } from './api/client'
|
||||||
import Sidebar from './components/Sidebar.vue'
|
import Sidebar from './components/Sidebar.vue'
|
||||||
import ChatMessages from './components/ChatMessages.vue'
|
import ChatMessages from './components/ChatMessages.vue'
|
||||||
import StreamingMessage from './components/StreamingMessage.vue'
|
import ProcessSection from './components/ProcessSection.vue'
|
||||||
import NodeProgress from './components/NodeProgress.vue'
|
|
||||||
import SummaryCard from './components/SummaryCard.vue'
|
import SummaryCard from './components/SummaryCard.vue'
|
||||||
import UnifiedInput from './components/UnifiedInput.vue'
|
import UnifiedInput from './components/UnifiedInput.vue'
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ async function handleSend(text: string, files: File[]) {
|
|||||||
try {
|
try {
|
||||||
await api.chat(session.currentId, text, remoteIds, {
|
await api.chat(session.currentId, text, remoteIds, {
|
||||||
onNodeStart(data) {
|
onNodeStart(data) {
|
||||||
chat.addNode(data)
|
chat.addNode({ node: data.node, label: data.label, step_index: data.step_index })
|
||||||
},
|
},
|
||||||
onNodeComplete(data) {
|
onNodeComplete(data) {
|
||||||
chat.completeNode(data)
|
chat.completeNode(data)
|
||||||
@@ -72,6 +71,7 @@ async function handleSend(text: string, files: File[]) {
|
|||||||
error_msg: data.error_msg,
|
error_msg: data.error_msg,
|
||||||
natural_explanation: data.natural_explanation,
|
natural_explanation: data.natural_explanation,
|
||||||
retry_count: data.retry_count,
|
retry_count: data.retry_count,
|
||||||
|
total_duration_ms: data.total_duration_ms,
|
||||||
ocr_extraction_result: data.ocr_extraction_result,
|
ocr_extraction_result: data.ocr_extraction_result,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,8 +119,7 @@ async function handleSend(text: string, files: File[]) {
|
|||||||
<main class="main-area">
|
<main class="main-area">
|
||||||
<div class="chat-container" ref="chatContainer">
|
<div class="chat-container" ref="chatContainer">
|
||||||
<ChatMessages />
|
<ChatMessages />
|
||||||
<StreamingMessage />
|
<ProcessSection />
|
||||||
<NodeProgress />
|
|
||||||
<SummaryCard />
|
<SummaryCard />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ export interface AgentCompleteData {
|
|||||||
error_msg: string
|
error_msg: string
|
||||||
natural_explanation: string
|
natural_explanation: string
|
||||||
retry_count: number
|
retry_count: number
|
||||||
|
total_duration_ms: number
|
||||||
ocr_extraction_result: any
|
ocr_extraction_result: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSECallbacks {
|
export interface SSECallbacks {
|
||||||
onNodeStart?: (data: { node: string; label: string }) => void
|
onNodeStart?: (data: { node: string; label: string; step_index: number }) => void
|
||||||
onNodeComplete?: (data: { node: string; label: string; detail: string }) => void
|
onNodeComplete?: (data: { node: string; label: string; detail: string }) => void
|
||||||
onStreamToken?: (data: { text: string; type: string }) => void
|
onStreamToken?: (data: { text: string; type: string }) => void
|
||||||
onAgentComplete?: (data: AgentCompleteData) => void
|
onAgentComplete?: (data: AgentCompleteData) => void
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useChatStore, type ProcessSection } from '../stores/chat'
|
||||||
|
|
||||||
|
const chat = useChatStore()
|
||||||
|
|
||||||
|
function sectionClass(s: ProcessSection): string {
|
||||||
|
if (s.status === 'running') return 'section-running'
|
||||||
|
if (s.content) return 'section-done'
|
||||||
|
return 'section-internal'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isXmlLike(text: string): boolean {
|
||||||
|
return text.includes('<?xml') || text.includes('<jasperReport')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="chat.streaming && chat.sections.length > 0" class="process-sections">
|
||||||
|
<div class="sections-header">
|
||||||
|
<span class="pulse-dot"></span>
|
||||||
|
处理中 · {{ chat.formatDuration(chat.totalDurationMs) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details
|
||||||
|
v-for="s in chat.sections"
|
||||||
|
:key="s.node"
|
||||||
|
:open="s.expanded"
|
||||||
|
class="process-section"
|
||||||
|
:class="sectionClass(s)"
|
||||||
|
@toggle="(e: Event) => { const d = e.target as HTMLDetailsElement; s.expanded = d.open }"
|
||||||
|
>
|
||||||
|
<summary class="section-summary">
|
||||||
|
<span class="step-badge">{{ s.stepIndex }}</span>
|
||||||
|
<span class="step-label">{{ s.label }}</span>
|
||||||
|
<span v-if="s.status === 'running'" class="step-spinner">...</span>
|
||||||
|
<span v-else class="step-check">OK</span>
|
||||||
|
<span class="step-duration" v-if="s.durationMs > 0">
|
||||||
|
{{ chat.formatDuration(s.durationMs) }}
|
||||||
|
</span>
|
||||||
|
<span class="step-detail-short" v-if="s.status === 'done' && s.detail">
|
||||||
|
{{ s.detail }}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div class="section-content" v-if="s.content">
|
||||||
|
<pre v-if="isXmlLike(s.content)" class="xml-content">{{ s.content }}</pre>
|
||||||
|
<div v-else class="text-content">{{ s.content }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="s.status === 'running'" class="section-waiting">
|
||||||
|
等待生成...
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.process-sections {
|
||||||
|
margin: 0 24px 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sections-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #89b4fa;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #89b4fa;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-section {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
background: #181825;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-section.section-running {
|
||||||
|
border-color: #45475a;
|
||||||
|
background: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-section.section-internal {
|
||||||
|
opacity: 0.65;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-summary {
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-badge {
|
||||||
|
background: #313244;
|
||||||
|
color: #6c7086;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-running .step-badge {
|
||||||
|
background: #45475a;
|
||||||
|
color: #cba6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-done .step-badge {
|
||||||
|
background: #313244;
|
||||||
|
color: #a6e3a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-internal .step-label {
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-spinner {
|
||||||
|
color: #f9e2af;
|
||||||
|
font-weight: bold;
|
||||||
|
animation: pulse-text 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-text {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-check {
|
||||||
|
color: #a6e3a1;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-duration {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c7086;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-detail-short {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c7086;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
padding: 0 10px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xml-content {
|
||||||
|
background: #11111b;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: #a6e3a1;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content {
|
||||||
|
color: #bac2de;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-waiting {
|
||||||
|
padding: 8px 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c7086;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -63,15 +63,29 @@ async function handleDelete() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section" v-if="session.currentJrxml">
|
<div class="sidebar-section" v-if="session.currentJrxml || session.versions.length > 0">
|
||||||
<div class="section-title">下载</div>
|
<div class="section-title">下载</div>
|
||||||
<a
|
<a
|
||||||
|
v-if="session.currentJrxml"
|
||||||
:href="`/api/sessions/${session.currentId}/download/latest`"
|
:href="`/api/sessions/${session.currentId}/download/latest`"
|
||||||
class="btn-download"
|
class="btn-download"
|
||||||
download
|
download
|
||||||
>
|
>
|
||||||
下载最新 JRXML
|
下载最新 JRXML
|
||||||
</a>
|
</a>
|
||||||
|
<div v-if="session.versions.length > 1" class="version-list">
|
||||||
|
<div class="version-list-title">历史版本</div>
|
||||||
|
<a
|
||||||
|
v-for="(v, i) in session.versions"
|
||||||
|
:key="i"
|
||||||
|
:href="`/api/sessions/${session.currentId}/download/${i}`"
|
||||||
|
class="version-item"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<span class="version-label">{{ v.label || `版本 ${i + 1}` }}</span>
|
||||||
|
<span class="version-time">{{ v.ts?.slice(0, 16)?.replace('T', ' ') }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -205,6 +219,45 @@ async function handleDelete() {
|
|||||||
background: #313244;
|
background: #313244;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
padding: 4px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
|||||||
@@ -21,13 +21,21 @@ function downloadLatest() {
|
|||||||
<div v-if="visible" class="summary-card">
|
<div v-if="visible" class="summary-card">
|
||||||
<div v-if="chat.summary.status === 'pass'" class="card card-success">
|
<div v-if="chat.summary.status === 'pass'" class="card card-success">
|
||||||
<div class="card-title">JRXML 生成成功</div>
|
<div class="card-title">JRXML 生成成功</div>
|
||||||
<div class="card-text">生成 {{ chat.summary.jrxml_length }} 字符</div>
|
<div class="card-text">
|
||||||
|
生成 {{ chat.summary.jrxml_length }} 字符
|
||||||
|
<span v-if="chat.lastDurationMs > 0" class="card-duration">
|
||||||
|
· {{ chat.formatDuration(chat.lastDurationMs) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button class="card-btn" @click="downloadLatest">下载 JRXML</button>
|
<button class="card-btn" @click="downloadLatest">下载 JRXML</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="card card-error">
|
<div v-else class="card card-error">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
经过 {{ chat.summary.retry_count }} 次重试后仍失败
|
经过 {{ chat.summary.retry_count }} 次重试后仍失败
|
||||||
|
<span v-if="chat.lastDurationMs > 0" class="card-duration">
|
||||||
|
· {{ chat.formatDuration(chat.lastDurationMs) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-text">{{ chat.summary.error_msg }}</div>
|
<div class="card-text">{{ chat.summary.error_msg }}</div>
|
||||||
<div v-if="chat.summary.natural_explanation" class="card-reason">
|
<div v-if="chat.summary.natural_explanation" class="card-reason">
|
||||||
@@ -79,6 +87,11 @@ function downloadLatest() {
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-duration {
|
||||||
|
color: #6c7086;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-reason {
|
.card-reason {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #a6adc8;
|
color: #a6adc8;
|
||||||
|
|||||||
+103
-7
@@ -1,7 +1,7 @@
|
|||||||
/** Pinia store — chat messages + streaming state. */
|
/** Pinia store — chat messages + streaming state with per-section tracking. */
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string
|
id: string
|
||||||
@@ -18,6 +18,18 @@ export interface NodeProgress {
|
|||||||
status: 'running' | 'done'
|
status: 'running' | 'done'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProcessSection {
|
||||||
|
node: string
|
||||||
|
label: string
|
||||||
|
stepIndex: number
|
||||||
|
detail: string
|
||||||
|
content: string
|
||||||
|
status: 'running' | 'done'
|
||||||
|
expanded: boolean
|
||||||
|
durationMs: number
|
||||||
|
startTime: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentSummary {
|
export interface AgentSummary {
|
||||||
intent: string
|
intent: string
|
||||||
status: string
|
status: string
|
||||||
@@ -27,18 +39,45 @@ export interface AgentSummary {
|
|||||||
retry_count: number
|
retry_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadedFile {
|
||||||
|
file_id: string
|
||||||
|
filename: string
|
||||||
|
content_type: string
|
||||||
|
size: number
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const useChatStore = defineStore('chat', () => {
|
export const useChatStore = defineStore('chat', () => {
|
||||||
const messages = ref<Message[]>([])
|
const messages = ref<Message[]>([])
|
||||||
const streaming = ref(false)
|
const streaming = ref(false)
|
||||||
|
const lastDurationMs = ref(0)
|
||||||
const streamText = ref('')
|
const streamText = ref('')
|
||||||
const nodes = ref<NodeProgress[]>([])
|
const nodes = ref<NodeProgress[]>([])
|
||||||
|
const sections = ref<ProcessSection[]>([])
|
||||||
const error = ref<string>('')
|
const error = ref<string>('')
|
||||||
const ocrResult = ref<any>(null)
|
const ocrResult = ref<any>(null)
|
||||||
|
const uploadedFiles = ref<UploadedFile[]>([])
|
||||||
const summary = ref<AgentSummary>({
|
const summary = ref<AgentSummary>({
|
||||||
intent: '', status: '', jrxml_length: 0,
|
intent: '', status: '', jrxml_length: 0,
|
||||||
error_msg: '', natural_explanation: '', retry_count: 0,
|
error_msg: '', natural_explanation: '', retry_count: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const totalDurationMs = computed(() => {
|
||||||
|
if (sections.value.length === 0) return 0
|
||||||
|
const last = sections.value[sections.value.length - 1]
|
||||||
|
return last.status === 'done'
|
||||||
|
? last.startTime + last.durationMs - sections.value[0].startTime
|
||||||
|
: Date.now() - sections.value[0].startTime
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
const m = Math.floor(ms / 60000)
|
||||||
|
const s = Math.round((ms % 60000) / 1000)
|
||||||
|
return `${m}m${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
function addMessage(msg: Omit<Message, 'id' | 'timestamp'>) {
|
function addMessage(msg: Omit<Message, 'id' | 'timestamp'>) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
...msg,
|
...msg,
|
||||||
@@ -49,8 +88,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
function startStreaming() {
|
function startStreaming() {
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
|
lastDurationMs.value = 0
|
||||||
streamText.value = ''
|
streamText.value = ''
|
||||||
nodes.value = []
|
nodes.value = []
|
||||||
|
sections.value = []
|
||||||
error.value = ''
|
error.value = ''
|
||||||
summary.value = {
|
summary.value = {
|
||||||
intent: '', status: '', jrxml_length: 0,
|
intent: '', status: '', jrxml_length: 0,
|
||||||
@@ -60,10 +101,31 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
function appendStreamToken(text: string) {
|
function appendStreamToken(text: string) {
|
||||||
streamText.value += text
|
streamText.value += text
|
||||||
|
const active = sections.value.find(s => s.status === 'running')
|
||||||
|
if (active) {
|
||||||
|
active.content += text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addNode(node: { node: string; label: string }) {
|
function addNode(node: { node: string; label: string; step_index?: number }) {
|
||||||
nodes.value.push({ ...node, status: 'running' })
|
nodes.value.push({ node: node.node, label: node.label, status: 'running' })
|
||||||
|
const prev = sections.value.find(s => s.status === 'running')
|
||||||
|
if (prev) {
|
||||||
|
prev.status = 'done'
|
||||||
|
prev.durationMs = Date.now() - prev.startTime
|
||||||
|
prev.expanded = false
|
||||||
|
}
|
||||||
|
sections.value.push({
|
||||||
|
node: node.node,
|
||||||
|
label: node.label,
|
||||||
|
stepIndex: node.step_index || sections.value.length + 1,
|
||||||
|
detail: '',
|
||||||
|
content: '',
|
||||||
|
status: 'running',
|
||||||
|
expanded: true,
|
||||||
|
durationMs: 0,
|
||||||
|
startTime: Date.now(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeNode(node: { node: string; label: string; detail: string }) {
|
function completeNode(node: { node: string; label: string; detail: string }) {
|
||||||
@@ -72,16 +134,30 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
existing.status = 'done'
|
existing.status = 'done'
|
||||||
existing.detail = node.detail
|
existing.detail = node.detail
|
||||||
}
|
}
|
||||||
|
const sec = sections.value.find(s => s.node === node.node && s.status === 'running')
|
||||||
|
if (sec) {
|
||||||
|
sec.detail = node.detail
|
||||||
|
sec.status = 'done'
|
||||||
|
sec.durationMs = Date.now() - sec.startTime
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishStreaming(data?: {
|
function finishStreaming(data?: {
|
||||||
intent?: string; status?: string; jrxml_length?: number
|
intent?: string; status?: string; jrxml_length?: number
|
||||||
error_msg?: string; natural_explanation?: string; retry_count?: number
|
error_msg?: string; natural_explanation?: string; retry_count?: number
|
||||||
ocr_extraction_result?: any
|
total_duration_ms?: number; ocr_extraction_result?: any
|
||||||
}) {
|
}) {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
nodes.value.forEach(n => { n.status = 'done' })
|
nodes.value.forEach(n => { n.status = 'done' })
|
||||||
|
sections.value.forEach(s => {
|
||||||
|
if (s.status === 'running') {
|
||||||
|
s.status = 'done'
|
||||||
|
s.durationMs = Date.now() - s.startTime
|
||||||
|
}
|
||||||
|
s.expanded = false
|
||||||
|
})
|
||||||
if (data) {
|
if (data) {
|
||||||
|
lastDurationMs.value = data.total_duration_ms || 0
|
||||||
summary.value = {
|
summary.value = {
|
||||||
intent: data.intent || '',
|
intent: data.intent || '',
|
||||||
status: data.status || '',
|
status: data.status || '',
|
||||||
@@ -99,15 +175,33 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
function setError(err: string) {
|
function setError(err: string) {
|
||||||
error.value = err
|
error.value = err
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
|
sections.value.forEach(s => { s.status = 'done'; s.expanded = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(node: string) {
|
||||||
|
const sec = sections.value.find(s => s.node === node)
|
||||||
|
if (sec) {
|
||||||
|
sec.expanded = !sec.expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUploadedFile(file: UploadedFile) {
|
||||||
|
uploadedFiles.value.push(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUploadedFile(fileId: string) {
|
||||||
|
uploadedFiles.value = uploadedFiles.value.filter(f => f.file_id !== fileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
messages.value = []
|
messages.value = []
|
||||||
streamText.value = ''
|
streamText.value = ''
|
||||||
nodes.value = []
|
nodes.value = []
|
||||||
|
sections.value = []
|
||||||
error.value = ''
|
error.value = ''
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
ocrResult.value = null
|
ocrResult.value = null
|
||||||
|
uploadedFiles.value = []
|
||||||
summary.value = {
|
summary.value = {
|
||||||
intent: '', status: '', jrxml_length: 0,
|
intent: '', status: '', jrxml_length: 0,
|
||||||
error_msg: '', natural_explanation: '', retry_count: 0,
|
error_msg: '', natural_explanation: '', retry_count: 0,
|
||||||
@@ -115,8 +209,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages, streaming, streamText, nodes, error, ocrResult, summary,
|
messages, streaming, lastDurationMs, streamText, nodes, sections, error, ocrResult,
|
||||||
|
uploadedFiles, summary, totalDurationMs,
|
||||||
addMessage, startStreaming, appendStreamToken, addNode, completeNode,
|
addMessage, startStreaming, appendStreamToken, addNode, completeNode,
|
||||||
finishStreaming, setError, reset,
|
finishStreaming, setError, toggleSection, reset, formatDuration,
|
||||||
|
addUploadedFile, removeUploadedFile,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user