Compare commits

...

6 Commits

Author SHA1 Message Date
panda a364e1de81 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
2026-05-21 23:43:21 +08:00
panda 60e2f520ba fix: image files silently falling to text parser due to suffix dot mismatch
api_server.py passed "jpg" (no dot) from rsplit, but file_parser.py
parser dict keys all have dots (".jpg"), causing image files to fall
through to _parse_text() which fails on binary data, skipping ALL OCR
and layout analysis. Every image upload was affected.

- file_parser.py: normalize file_type to always have leading dot
- api_server.py: use Path.suffix instead of manual rsplit
2026-05-21 23:05:27 +08:00
panda 83c7da7517 fix: system env vars silently overriding .env — load_dotenv(override=True)
Root cause: load_dotenv() default override=False meant system-level
ANTHROPIC_BASE_URL (https://api.deepseek.com/anthropic) took precedence
over .env's OPENAI_BASE_URL (https://api.minimaxi.com/anthropic). All
Anthropic API calls went to DeepSeek with a MiniMax key, causing 401.

Changes:
- backend/llm.py: load_dotenv(override=True) — .env always wins
- .env.example: add explicit ANTHROPIC_API_KEY + ANTHROPIC_BASE_URL
- CLAUDE.md: document env var priority pitfall
2026-05-21 22:36:43 +08:00
panda aa1d8a6c52 fix: logging KeyError with reserved 'filename' key, pytest return-not-none warnings
- api_server.py: rename 'filename' to 'file_name' in upload_file log extra
  dict to avoid collision with Python logging's reserved LogRecord attribute
- test_e2e_ocr.py: replace return statements with assert in test functions
  to fix PytestReturnNotNoneWarning
2026-05-21 22:28:07 +08:00
panda 960312b088 fix: start.bat nested quote parsing with path containing spaces
cmd /k "cd /d "%~dp0" && ..." breaks because inner quotes around
%~dp0 close the outer quoted string prematurely when the path
contains spaces (D:\Idea Project\...). Fix: remove outer quotes,
escape && as ^&^& so it passes through to the new cmd instance.
2026-05-21 22:14:17 +08:00
panda 7c1aa7d934 docs: update architecture docs for Vue 3 + FastAPI separation, add one-click start.bat
- CLAUDE.md: remove duplicate architecture section, fix MAX_RETRY 5→3
- README.md: update architecture diagram to 3-tier, add start.bat instructions
- ROADMAP.md: add 阶段六 layered generation v5 (items 16-20)
- start.bat: one-click startup with auto port-kill and path-with-spaces fix
- package-lock.json: updated from npm install
2026-05-21 22:10:22 +08:00
17 changed files with 611 additions and 92 deletions
+8 -3
View File
@@ -2,12 +2,17 @@
LLM_BACKEND=cloud
# 云端提供商:openai 或 anthropic
LLM_PROVIDER=openai
LLM_PROVIDER=anthropic
# 云端配置(OpenAI 兼容
# Anthropic 兼容 APIMiniMax 等,优先使用
ANTHROPIC_API_KEY=sk-xxxx
ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
# OpenAI 兼容 APIfallback,当 ANTHROPIC_* 未设置时使用)
OPENAI_API_KEY=sk-xxxx
OPENAI_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o
LLM_MODEL=MiniMax-M2.7
# 本地大语言模型(Ollama
LOCAL_LLM_MODEL=qwen2.5-coder:7b
+28 -21
View File
@@ -4,20 +4,7 @@
一个**本地桌面应用**,通过自然语言多轮对话帮助非技术用户创建 JasperReports 模板(JRXML 文件)。核心技术栈:Vue 3 前端 + FastAPI SSE 后端 + LangGraph 状态机 + LLM 生成/修改 + 自动验证修正循环。
**一句话**:用户用中文描述报表需求 → LLM 生成 JRXML → 自动验证 → 失败则自动修正(最多5次) → 重试耗尽后失败上下文自动注入下一轮 → 返回可编译的 JRXML 文件。
## 架构
```
前端 (Vue 3 + Vite, 端口 5173)
│ 聊天界面 + 统一输入框 + 流式显示 + 文件上传/粘贴/拖拽
▼ HTTP + SSE (Server-Sent Events)
后端 API (FastAPI, 端口 8000)
│ REST 接口 + SSE 流式推送
│ 包装 LangGraph Agent 不变
▼ HTTP
验证服务 (FastAPI, 端口 8001) — 不变
```
**一句话**:用户用中文描述报表需求 → LLM 生成 JRXML → 自动验证 → 失败则自动修正(最多3次) → 重试耗尽后失败上下文自动注入下一轮 → 返回可编译的 JRXML 文件。
## 启动命令
@@ -47,7 +34,7 @@ cd frontend && npm run dev
- **向量库**: ChromaDB 持久化在 `./db/chroma`
- **验证服务**: FastAPI `localhost:8001`
- **日志**: JSON 格式化,`logs/app.log` + `logs/llm.log`,中国时区 (UTC+8)
- **MAX_RETRY**: 5
- **MAX_RETRY**: 3
## 架构
@@ -58,12 +45,11 @@ cd frontend && npm run dev
│ ├── stores/chat.ts Pinia: 消息/流式/节点进度
│ ├── stores/session.ts Pinia: 会话管理
│ ├── components/
│ │ ├── Sidebar.vue 会话列表 + 下载
│ │ ├── Sidebar.vue 会话列表 + 下载 + 历史版本
│ │ ├── ChatMessages.vue 消息列表渲染
│ │ ├── StreamingMessage.vue 流式文本展示
│ │ ├── UnifiedInput.vue 统一输入框(文本+文件拖拽/粘贴)
│ │ ── NodeProgress.vue 节点进度指示
│ │ └── SummaryCard.vue 结果摘要卡片
│ │ ├── ProcessSection.vue 过程折叠区(替代 StreamingMessage + NodeProgress
│ │ ├── UnifiedInput.vue 统一输入框(文本+文件拖拽/粘贴/芯片
│ │ ── SummaryCard.vue 结果摘要卡片(含耗时)
│ └── utils/format.ts 工具函数
▼ HTTP + SSE (Server-Sent Events)
@@ -236,6 +222,7 @@ validation_service/ (FastAPI, 端口 8001) — 不变
## 已知注意点
- **环境变量优先级**: `backend/llm.py` 使用 `load_dotenv(override=True)` 确保 `.env` 值**始终覆盖**系统环境变量。曾因系统级 `ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic` 覆盖 `.env` 中的 MiniMax URL,导致 401 认证失败。新增 LLM 相关环境变量时,必须在 `.env` 中显式设置 `ANTHROPIC_*` 变量(而非仅设 `OPENAI_*` fallback),否则残留的系统环境变量会污染请求目标。
- **Anthropic SDK**: 使用原始 `anthropic` 包(非 `langchain-anthropic`),因为需要直连 MiniMax 兼容端点。API Key 优先读 `ANTHROPIC_API_KEY`fallback `OPENAI_API_KEY`。Anthropic SDK 会自动将 key 放入 `x-api-key` header。
- **MiniMax 模型名称**: `MiniMax-M2.7`(不是 `minimax-2.7`),大小写敏感。
- **Streamlit headless**: Windows 下必须设 `STREAMLIT_SERVER_HEADLESS=true` 跳过邮箱采集提示。
@@ -246,7 +233,7 @@ validation_service/ (FastAPI, 端口 8001) — 不变
- **OCR 引擎**: 优先 PaddleOCR 2.9.x(精确识别,`pip install paddleocr`),回退 EasyOCR 1.7+。两者均未安装时仅返回图片元信息。PaddlePaddle 3.x 在 Windows 上有 ONEDNN bug,固定在 2.6.x。
- **OCR 字段提取**: `process_input` 自动检测上传图片,调用 `OcrExtractor` 提取常见中文字段(发票代码/号码/金额/日期等),提取结果自动注入 LLM 上下文。
- **会话持久化**: `session_id` 现已包含在 `save_session_node` 的持久化字段中,避免切换会话时因 `session_id` 丢失导致的无限 rerun bug。`create_session` 存盘前强制写入 `agent_state["session_id"] = sid``load_session_node` 不从磁盘覆盖 `session_id`。切换会话增加 `_last_switched_to` 哨兵防止重复触发。
- **MAX_RETRY**: 默认 5 次。重试耗尽后 `pending_failure_context` 记录失败信息,下次用户输入时自动注入。
- **MAX_RETRY**: 默认 3 次。重试耗尽后 `pending_failure_context` 记录失败信息,下次用户输入时自动注入。
- **验证最小内容检查**: 验证服务额外检查至少 1 个 `<band>` + 1 个 `<textField>``<staticText>`,拦截空壳 JRXML。
- **XLSX 支持 (v3)**: 需要 `openpyxl>=3.1.0`(已加入 requirements.txt)。表格按工作表逐行读取,单元格用 `|` 分隔。
- **粘贴功能限制**: 文件以 base64 编码在 sessionStorage 中传递,单文件上限 20MB。大文件建议使用 file_uploader 按钮。
@@ -255,3 +242,23 @@ validation_service/ (FastAPI, 端口 8001) — 不变
- **st-multimodal-chatinput**: Streamlit 聊天输入增强组件,替代 `st.chat_input`,支持粘贴/拖拽文件。返回 base64 编码文件内容。
- **xlwt**: 仅在测试中使用(生成 .xls 测试文件)。
- **分层精确生成**: 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()`
+35 -23
View File
@@ -18,18 +18,20 @@
## 架构
```
Streamlit 界面 (app.py)
|
LangGraph 代理 (agent/)
|-- retrieve (Chroma/embeddings)
|-- generate / generate_skeleton → refine_layout → map_fields (分层生成)
|-- validate (FastAPI service)
|-- explain + correct (auto-fix loop)
|-- modify (multi-turn edits)
|
前端 (Vue 3 + Vite, 端口 5173)
│ 聊天界面 + SSE 流式显示 + 文件上传/粘贴/拖拽
▼ HTTP + SSE
后端 API (FastAPI, 端口 8000)
│ REST 接口 + SSE 流式推送
│ 包装 LangGraph Agent ──► agent/
├─ retrieve (Chroma/embeddings)
├─ generate / generate_skeleton → refine_layout → map_fields (分层生成)
│ ├─ validate (FastAPI service)
│ ├─ explain + correct (auto-fix loop)
│ └─ modify (multi-turn edits)
FastAPI 验证服务 (:8001)
|-- Structural checks (field references, SQL, page dimensions)
|-- XSD schema validation (if jasperreport.xsd available)
└─ Structural checks + XSD schema validation
```
## 前置要求
@@ -60,21 +62,24 @@ cp .env.example .env
python scripts/init_kb.py
```
### 4. 启动验证服务
### 4. 启动服务
**一键启动(推荐)**:双击 `start.bat`,自动启动验证服务、后端 API、前端开发服务器。停止用 `stop.bat`
**手动启动**(需要三个终端):
在一个终端中运行:
```bash
python -m uvicorn validation_service.main:app --port 8001
# 终端 1 — 验证服务(必须先启动)
python -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0
# 终端 2 — 后端 APISSE + REST
python -m uvicorn api_server:app --port 8000 --host 0.0.0.0
# 终端 3 — 前端开发服务器
cd frontend && npm install && npm run dev
```
### 5. 启动 Streamlit 界面
在另一个终端中运行:
```bash
streamlit run app.py
```
在浏览器中打开 http://localhost:8501。
在浏览器中打开 http://localhost:5173。
## 使用示例
@@ -110,7 +115,14 @@ pytest tests/ -v
```
jrxml-agent/
app.py Streamlit 聊天界面(多模态输入
api_server.py FastAPI SSE 后端(REST + 流式推送
start.bat 一键启动脚本
stop.bat 一键停止脚本
frontend/ Vue 3 + Vite 前端(聊天 UI
src/
api/client.ts SSE 客户端 + fetch 封装
stores/ Pinia 状态管理(chat + session
components/ 聊天界面组件(6 个)
agent/
state.py AgentState 定义(28 字段)
nodes.py 图节点(generate, generate_skeleton, refine_layout 等,18 节点)
+2 -2
View File
@@ -82,11 +82,11 @@
- [x] `backend/llm.py``_LLMLoggingWrapper` 包装所有 LLM 后端
- [x] 记录每次 invoke/stream 的请求 prompt、响应内容、耗时、模型、调用来源
- [x] 异常时也记录完整 prompt
- [x] `agent/nodes.py``@log_node` 装饰器覆盖 17 个节点
- [x] `agent/nodes.py``@log_node` 装饰器覆盖 18 个节点
- [x] 入口/出口/异常三个阶段的日志
- [x] 自动记录 state 关键字段摘要(session_id、intent、status、jrxml_length 等)
- [x] 每个节点耗时(duration_ms
- [x] `agent/graph.py``@_log_route` 装饰器覆盖 8 个路由函数
- [x] `agent/graph.py``@_log_route` 装饰器覆盖 9 个路由函数
- [x] 记录每次路由决策(来源 → 目标)
- [x] `app.py` — 用户交互日志
- [x] 收到用户输入(含上传文件信息)
+73
View File
@@ -176,6 +176,9 @@ def process_input(state: AgentState) -> Dict:
state["ocr_extraction_result"] = {"error": str(e)}
state["uploaded_file_path"] = ""
# ── OCR 两层日志:内容层 + 位置层 ──
_log_ocr_layers(state)
# 重置本轮请求字段
state["retry_count"] = 0
state["user_modification_request"] = user_input
@@ -532,6 +535,76 @@ def _format_ocr_context(state: AgentState) -> str:
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")
def retrieve(state: AgentState) -> Dict:
"""在 ChromaDB + 错误知识库中搜索相关的 JRXML 模板和组件。"""
+11 -3
View File
@@ -103,15 +103,19 @@ UPLOADS_DIR = Path(os.getenv("UPLOADS_DIR", "./uploads"))
# 当前请求的事件队列(单个用户桌面应用,无并发问题)
_current_event_queue: Optional[queue.Queue] = None
_step_counter: int = 0
def _on_node_start(node_name: str):
"""全局 node_start 回调 — 将事件推入当前请求的事件队列。"""
global _step_counter
q = _current_event_queue
if q is not None:
_step_counter += 1
q.put(("node_start", {
"node": 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:
"""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()
_current_event_queue = event_q
@@ -198,6 +204,7 @@ async def _sse_generator(agent_state: AgentState) -> str:
kind = item[0]
if kind == "done":
_current_event_queue = None
total_ms = round((time.time() - t_start) * 1000)
yield _sse_line("agent_complete", {
"reason": "done",
"intent": agent_state.get("intent", ""),
@@ -206,6 +213,7 @@ async def _sse_generator(agent_state: AgentState) -> str:
"error_msg": agent_state.get("error_msg", ""),
"natural_explanation": agent_state.get("natural_explanation", ""),
"retry_count": agent_state.get("retry_count", 0),
"total_duration_ms": total_ms,
"ocr_extraction_result": agent_state.get("ocr_extraction_result", {}),
})
await future
@@ -361,7 +369,7 @@ async def upload_file(file: UploadFile = File(...), session_id: str = ""):
}
_api_log.info("文件上传", extra={
"file_id": file_id, "filename": safe_name, "size": len(content),
"file_id": file_id, "file_name": safe_name, "size": len(content),
})
return {
@@ -400,7 +408,7 @@ def _process_files(file_ids: list[str], session_id: str) -> dict:
file_path = info["path"]
uploaded_paths.append(file_path)
parsed = parse_file(file_path, info["filename"].rsplit(".", 1)[-1] if "." in info["filename"] else "")
parsed = parse_file(file_path, Path(info["filename"]).suffix)
if parsed.get("error"):
parts.append(f"[文件: {info['filename']}]\n解析失败: {parsed['error']}")
continue
+3 -1
View File
@@ -41,7 +41,9 @@ def parse_file(file_path: str, file_type: str = "") -> dict:
if not path.exists():
return {"text": "", "file_type": file_type, "method": "none", "error": "文件不存在"}
suffix = file_type or path.suffix.lower()
suffix = path.suffix.lower()
if file_type:
suffix = file_type if file_type.startswith(".") else f".{file_type}"
parsers = {
".png": _parse_image,
+1 -1
View File
@@ -8,7 +8,7 @@ from dotenv import load_dotenv
from backend.logger import get_logger
load_dotenv()
load_dotenv(override=True)
_llm_log = get_logger("llm")
+23 -5
View File
@@ -66,6 +66,29 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -393,7 +416,6 @@
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1054,7 +1076,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1212,7 +1233,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -1234,7 +1254,6 @@
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -1319,7 +1338,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-sfc": "3.5.34",
+4 -5
View File
@@ -5,8 +5,7 @@ import { useSessionStore } from './stores/session'
import { api } from './api/client'
import Sidebar from './components/Sidebar.vue'
import ChatMessages from './components/ChatMessages.vue'
import StreamingMessage from './components/StreamingMessage.vue'
import NodeProgress from './components/NodeProgress.vue'
import ProcessSection from './components/ProcessSection.vue'
import SummaryCard from './components/SummaryCard.vue'
import UnifiedInput from './components/UnifiedInput.vue'
@@ -55,7 +54,7 @@ async function handleSend(text: string, files: File[]) {
try {
await api.chat(session.currentId, text, remoteIds, {
onNodeStart(data) {
chat.addNode(data)
chat.addNode({ node: data.node, label: data.label, step_index: data.step_index })
},
onNodeComplete(data) {
chat.completeNode(data)
@@ -72,6 +71,7 @@ async function handleSend(text: string, files: File[]) {
error_msg: data.error_msg,
natural_explanation: data.natural_explanation,
retry_count: data.retry_count,
total_duration_ms: data.total_duration_ms,
ocr_extraction_result: data.ocr_extraction_result,
})
@@ -119,8 +119,7 @@ async function handleSend(text: string, files: File[]) {
<main class="main-area">
<div class="chat-container" ref="chatContainer">
<ChatMessages />
<StreamingMessage />
<NodeProgress />
<ProcessSection />
<SummaryCard />
</div>
+2 -1
View File
@@ -28,11 +28,12 @@ export interface AgentCompleteData {
error_msg: string
natural_explanation: string
retry_count: number
total_duration_ms: number
ocr_extraction_result: any
}
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
onStreamToken?: (data: { text: string; type: string }) => void
onAgentComplete?: (data: AgentCompleteData) => void
+209
View File
@@ -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>
+54 -1
View File
@@ -63,15 +63,29 @@ async function handleDelete() {
</button>
</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>
<a
v-if="session.currentJrxml"
:href="`/api/sessions/${session.currentId}/download/latest`"
class="btn-download"
download
>
下载最新 JRXML
</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 class="sidebar-footer">
@@ -205,6 +219,45 @@ async function handleDelete() {
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 {
margin-top: auto;
padding: 12px 16px;
+14 -1
View File
@@ -21,13 +21,21 @@ function downloadLatest() {
<div v-if="visible" class="summary-card">
<div v-if="chat.summary.status === 'pass'" class="card card-success">
<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>
</div>
<div v-else class="card card-error">
<div class="card-title">
经过 {{ chat.summary.retry_count }} 次重试后仍失败
<span v-if="chat.lastDurationMs > 0" class="card-duration">
· {{ chat.formatDuration(chat.lastDurationMs) }}
</span>
</div>
<div class="card-text">{{ chat.summary.error_msg }}</div>
<div v-if="chat.summary.natural_explanation" class="card-reason">
@@ -79,6 +87,11 @@ function downloadLatest() {
margin-bottom: 4px;
}
.card-duration {
color: #6c7086;
font-size: 12px;
}
.card-reason {
font-size: 12px;
color: #a6adc8;
+103 -7
View File
@@ -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 { ref } from 'vue'
import { ref, computed } from 'vue'
export interface Message {
id: string
@@ -18,6 +18,18 @@ export interface NodeProgress {
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 {
intent: string
status: string
@@ -27,18 +39,45 @@ export interface AgentSummary {
retry_count: number
}
export interface UploadedFile {
file_id: string
filename: string
content_type: string
size: number
preview?: string
}
export const useChatStore = defineStore('chat', () => {
const messages = ref<Message[]>([])
const streaming = ref(false)
const lastDurationMs = ref(0)
const streamText = ref('')
const nodes = ref<NodeProgress[]>([])
const sections = ref<ProcessSection[]>([])
const error = ref<string>('')
const ocrResult = ref<any>(null)
const uploadedFiles = ref<UploadedFile[]>([])
const summary = ref<AgentSummary>({
intent: '', status: '', jrxml_length: 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'>) {
messages.value.push({
...msg,
@@ -49,8 +88,10 @@ export const useChatStore = defineStore('chat', () => {
function startStreaming() {
streaming.value = true
lastDurationMs.value = 0
streamText.value = ''
nodes.value = []
sections.value = []
error.value = ''
summary.value = {
intent: '', status: '', jrxml_length: 0,
@@ -60,10 +101,31 @@ export const useChatStore = defineStore('chat', () => {
function appendStreamToken(text: string) {
streamText.value += text
const active = sections.value.find(s => s.status === 'running')
if (active) {
active.content += text
}
}
function addNode(node: { node: string; label: string }) {
nodes.value.push({ ...node, status: 'running' })
function addNode(node: { node: string; label: string; step_index?: number }) {
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 }) {
@@ -72,16 +134,30 @@ export const useChatStore = defineStore('chat', () => {
existing.status = 'done'
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?: {
intent?: string; status?: string; jrxml_length?: 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
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) {
lastDurationMs.value = data.total_duration_ms || 0
summary.value = {
intent: data.intent || '',
status: data.status || '',
@@ -99,15 +175,33 @@ export const useChatStore = defineStore('chat', () => {
function setError(err: string) {
error.value = err
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() {
messages.value = []
streamText.value = ''
nodes.value = []
sections.value = []
error.value = ''
streaming.value = false
ocrResult.value = null
uploadedFiles.value = []
summary.value = {
intent: '', status: '', jrxml_length: 0,
error_msg: '', natural_explanation: '', retry_count: 0,
@@ -115,8 +209,10 @@ export const useChatStore = defineStore('chat', () => {
}
return {
messages, streaming, streamText, nodes, error, ocrResult, summary,
messages, streaming, lastDurationMs, streamText, nodes, sections, error, ocrResult,
uploadedFiles, summary, totalDurationMs,
addMessage, startStreaming, appendStreamToken, addNode, completeNode,
finishStreaming, setError, reset,
finishStreaming, setError, toggleSection, reset, formatDuration,
addUploadedFile, removeUploadedFile,
}
})
+38 -16
View File
@@ -1,31 +1,53 @@
@echo off
echo ============================================
echo JRXML 代理 - 全自动启动 (验证 + API + UI)
echo ============================================
setlocal enabledelayedexpansion
echo ============================================
echo JRXML Agent - One-Click Start
echo ============================================
echo.
echo [1/3] 启动验证服务 (端口 8001)...
start "JRXML 验证服务" cmd /c "cd /d %~dp0 && .venv\Scripts\python -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0"
REM ========== Kill processes on ports ==========
echo [Pre-check] Cleaning up occupied ports...
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8001.*LISTENING" 2^>nul') do (
echo Killing PID %%a on port 8001...
taskkill /PID %%a /F 2>nul
)
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8000.*LISTENING" 2^>nul') do (
echo Killing PID %%a on port 8000...
taskkill /PID %%a /F 2>nul
)
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":5173.*LISTENING" 2^>nul') do (
echo Killing PID %%a on port 5173...
taskkill /PID %%a /F 2>nul
)
echo.
REM ========== Detect Python ==========
set PYTHON=python
if exist "%~dp0.venv\Scripts\python.exe" set "PYTHON=%~dp0.venv\Scripts\python.exe"
echo Using Python: %PYTHON%
echo.
REM ========== Start services ==========
echo [1/3] Starting validation service on port 8001...
start "JRXML-Validator" cmd /k cd /d "%~dp0" ^&^& "%PYTHON%" -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0
timeout /t 3 /nobreak >nul
echo [2/3] 启动后端 API (端口 8000)...
start "JRXML API" cmd /c "cd /d %~dp0 && .venv\Scripts\python -m uvicorn api_server:app --port 8000 --host 0.0.0.0"
echo [2/3] Starting backend API on port 8000...
start "JRXML-API" cmd /k cd /d "%~dp0" ^&^& "%PYTHON%" -m uvicorn api_server:app --port 8000 --host 0.0.0.0
timeout /t 3 /nobreak >nul
echo [3/3] 启动前端开发服务器 (端口 5173)...
start "JRXML Frontend" cmd /c "cd /d %~dp0\frontend && npm run dev"
echo [3/3] Starting frontend dev server on port 5173...
start "JRXML-Frontend" cmd /k cd /d "%~dp0frontend" ^&^& npm run dev
timeout /t 3 /nobreak >nul
echo.
echo ============================================
echo 启动完成
echo 验证服务: http://localhost:8001
echo 后端 API: http://localhost:8000
echo 前端界面: http://localhost:5173
echo All services started!
echo Frontend : http://localhost:5173
echo Backend : http://localhost:8000
echo Validator : http://localhost:8001
echo ============================================
echo.
echo 关闭此窗口不会停止服务。关闭服务窗口或运行 stop.bat 停止。
echo Close the service windows or run stop.bat to stop.
pause
+3 -2
View File
@@ -85,7 +85,8 @@ def test_ocr_extraction_pipeline():
print(f" 值: {fields[0].get('field_value', 'N/A')}")
print(f" 坐标: {fields[0].get('bbox', 'N/A')}")
return all_ok
# OCR field extraction is informational; verify we got a valid response
assert extraction.get("ocr_available") is not None
def test_validation_service():
@@ -94,7 +95,7 @@ def test_validation_service():
result = validate_jrxml("<jasperReport/>")
print(f" 状态: {'OK' if result else 'FAIL'}")
print(f" 响应: {result}")
return True
assert result is not None
def test_ocr_fallback():