feat: 前后端分离架构 — FastAPI SSE后端 + Vue 3前端

将单体 Streamlit 应用拆分为三层架构:
- api_server.py: FastAPI SSE 流式后端 (端口 8000)
- frontend/: Vue 3 + Vite + Pinia 聊天前端 (端口 5173)
- agent/graph.py: 新增 node_start 回调支持
- 更新启动脚本为三服务模式

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 20:04:27 +08:00
parent 2befd44430
commit 74f3f03d2c
29 changed files with 3668 additions and 72 deletions
+54 -42
View File
@@ -2,24 +2,40 @@
## 项目概述
一个**本地桌面应用**,通过自然语言多轮对话帮助非技术用户创建 JasperReports 模板(JRXML 文件)。核心技术栈:Streamlit UI + LangGraph 状态机 + LLM 生成/修改 + 自动验证修正循环。
一个**本地桌面应用**,通过自然语言多轮对话帮助非技术用户创建 JasperReports 模板(JRXML 文件)。核心技术栈:Vue 3 前端 + FastAPI SSE 后端 + LangGraph 状态机 + LLM 生成/修改 + 自动验证修正循环。
**一句话**:用户用中文描述报表需求 → LLM 生成 JRXML → 自动验证 → 失败则自动修正(最多3次) → 重试耗尽后失败上下文自动注入下一轮 → 返回可编译的 JRXML 文件。
**一句话**:用户用中文描述报表需求 → LLM 生成 JRXML → 自动验证 → 失败则自动修正(最多5次) → 重试耗尽后失败上下文自动注入下一轮 → 返回可编译的 JRXML 文件。
## 架构
```
前端 (Vue 3 + Vite, 端口 5173)
│ 聊天界面 + 统一输入框 + 流式显示 + 文件上传/粘贴/拖拽
▼ HTTP + SSE (Server-Sent Events)
后端 API (FastAPI, 端口 8000)
│ REST 接口 + SSE 流式推送
│ 包装 LangGraph Agent 不变
▼ HTTP
验证服务 (FastAPI, 端口 8001) — 不变
```
## 启动命令
**方式 1 — 一键启动(Windows)**:双击 `start.bat`,自动打开个窗口分别运行验证服务和 UI。停止用 `stop.bat`
**方式 1 — 一键启动(Windows)**:双击 `start.bat`,自动打开个窗口分别运行验证服务、后端 API、前端开发服务器。停止用 `stop.bat`
**方式 2 — 手动启动**
```bash
# 终端 1 — 验证服务(必须先启动)
python -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0
# 终端 2 — Streamlit UI
STREAMLIT_SERVER_HEADLESS=true streamlit run app.py --server.port 8501
# 终端 2 — 后端 APISSE + REST
python -m uvicorn api_server:app --port 8000 --host 0.0.0.0
# 终端 3 — 前端开发服务器
cd frontend && npm run dev
```
浏览器打开 `http://localhost:8501`
浏览器打开 `http://localhost:5173`
## 当前配置(.env
@@ -31,53 +47,48 @@ STREAMLIT_SERVER_HEADLESS=true streamlit run app.py --server.port 8501
- **向量库**: ChromaDB 持久化在 `./db/chroma`
- **验证服务**: FastAPI `localhost:8001`
- **日志**: JSON 格式化,`logs/app.log` + `logs/llm.log`,中国时区 (UTC+8)
- **MAX_RETRY**: 3
- **MAX_RETRY**: 5
## 架构
```
app.py (Streamlit UI)
run_agent(user_input)
功能: 流式输出/节点平铺/文件上传/历史下载/预览/Ctrl+C修复
前端 (Vue 3 + Vite, 端口 5173)
src/
├── api/client.ts SSE 客户端 + fetch 封装
│ ├── stores/chat.ts Pinia: 消息/流式/节点进度
│ ├── stores/session.ts Pinia: 会话管理
│ ├── components/
│ │ ├── Sidebar.vue 会话列表 + 下载
│ │ ├── ChatMessages.vue 消息列表渲染
│ │ ├── StreamingMessage.vue 流式文本展示
│ │ ├── UnifiedInput.vue 统一输入框(文本+文件拖拽/粘贴)
│ │ ├── NodeProgress.vue 节点进度指示
│ │ └── SummaryCard.vue 结果摘要卡片
│ └── utils/format.ts 工具函数
▼ HTTP + SSE (Server-Sent Events)
api_server.py (FastAPI, 端口 8000)
│ POST /api/sessions/{id}/chat → SSE 流式响应
│ CRUD /api/sessions/... → 会话管理
│ POST /api/upload → 文件上传
│ GET /api/download/... → JRXML 下载
│ GET /api/health, /api/config
│ 包装 LangGraph Agent(不变)──► agent/
agent/graph.py (LangGraph 状态机)
│ 节点流程:
│ load_session → process_input → manage_context → save_state_snapshot
│ → classify_intent (8种意图路由)
│ ├─ retrieve → route_after_retrieve
│ ├─ [有布局schema] generate_skeleton → refine_layout → map_fields
│ └─ [无布局schema] generate
├─ generate/map_fields → save_session → validate → ... → finalize
│ ├─ modify_jrxml → save_session → validate → ... → finalize
│ ├─ handle_consult / handle_undo / handle_reset → finalize
│ └─ preview/export → save_session → finalize (跳过验证)
│ 验证修正循环: validate ─fail─► explain_error ─► correct_jrxml ─► validate
│ ▲ │
│ └──────── (retry < MAX_RETRY=3) ───────────────────┘
├──► prompts/loader.py Prompt 外部化:10 个 .md 文件热重载
├──► backend/llm.py LLM 工厂: Anthropic SDK / OpenAI / Ollama (统一 stream/invoke)
├──► backend/logger.py 集中日志: JSON + trace_id + llm.log/app.log 分离
├──► backend/rag_adapter.py 语义搜索: ChromaDB + SentenceTransformer
├──► backend/error_kb.py 错误知识库: 指纹去重 + ChromaDB 持久化
├──► backend/file_parser.py 文件解析: PDF/DOCX/XLSX/XLS/DOC/图片/文本
├──► backend/layout_analyzer.py A4布局分析: OCR + 行分组 + JRXML行匹配
├──► backend/ocr_extractor.py OCR字段精确提取: 4策略优先级 + 置信度
├──► backend/annotation_detector.py 批注检测: 圈选(HoughCircles) + 箭头(HoughLinesP) + OCR关联
├──► backend/validation.py HTTP 客户端: POST /validate
├──► backend/session.py 会话持久化: JSON 文件 CRUD
└──► validation_service/ 独立 FastAPI: 结构检查 + XSD 校验
validation_service/ (FastAPI, 端口 8001) — 不变
```
## 关键文件映射
| 文件 | 职责 | 修改频率 |
|------|------|---------|
| `app.py` | Streamlit UI 入口,聊天界面 + 对话文件上传(粘贴/拖拽) + 侧边栏 + 下载 | **高** |
| `agent/state.py` | AgentState 类型定义(~28 字段,含 layout_schema / annotation_result | 低 |
| `api_server.py` | FastAPI SSE 后端,REST API + 流式推送 | **高** |
| `frontend/src/` | Vue 3 聊天 UI(替代旧 app.py | **高** |
| `agent/state.py` | AgentState 类型定义(~28 字段) | 低 |
| `agent/nodes.py` | 18 个工作流节点 + 流式生成 + 错误记录 | **高** |
| `agent/graph.py` | 状态图编译 + 路由函数(预览跳过验证) | 中 |
| `agent/graph.py` | 状态图编译 + 路由函数 + node_start 回调 | 中 |
| `prompts/loader.py` | Prompt 加载器(从 .md 文件热重载) | 低 |
| `prompts/*.md` | 10 个独立 Prompt 模板 | **高** |
| `backend/llm.py` | LLM 工厂,统一 `_BaseLLM` 接口(invoke + stream+ `_LLMLoggingWrapper` | 中 |
@@ -93,6 +104,7 @@ agent/graph.py (LangGraph 状态机)
| `backend/session.py` | 会话 JSON 文件 CRUD | 低 |
| `validation_service/main.py` | FastAPI 验证服务 | 低 |
| `scripts/init_kb.py` | 知识库初始化/模型下载 | 低 |
| `app.py` | ~~旧 Streamlit UI~~(已由 api_server.py + frontend/ 替代) | 废弃 |
## 关键约定
@@ -234,7 +246,7 @@ agent/graph.py (LangGraph 状态机)
- **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**: 默认 3 次。重试耗尽后 `pending_failure_context` 记录失败信息,下次用户输入时自动注入。
- **MAX_RETRY**: 默认 5 次。重试耗尽后 `pending_failure_context` 记录失败信息,下次用户输入时自动注入。
- **验证最小内容检查**: 验证服务额外检查至少 1 个 `<band>` + 1 个 `<textField>``<staticText>`,拦截空壳 JRXML。
- **XLSX 支持 (v3)**: 需要 `openpyxl>=3.1.0`(已加入 requirements.txt)。表格按工作表逐行读取,单元格用 `|` 分隔。
- **粘贴功能限制**: 文件以 base64 编码在 sessionStorage 中传递,单文件上限 20MB。大文件建议使用 file_uploader 按钮。
+40 -22
View File
@@ -31,8 +31,8 @@ from agent.nodes import (
)
from backend.logger import get_logger
load_dotenv()
MAX_RETRY = int(os.getenv("MAX_RETRY", "3"))
load_dotenv(override=True)
MAX_RETRY = int(os.getenv("MAX_RETRY", "5"))
_graph_log = get_logger("agent")
@@ -147,33 +147,51 @@ def route_after_correct(state: AgentState) -> Literal["validate", "finalize"]:
# 图构建
# ============================================================
def build_graph() -> StateGraph:
def build_graph(on_node_start=None) -> StateGraph:
"""构建 LangGraph 状态图。
Args:
on_node_start: 可选回调,在每个节点开始执行时调用。
签名: on_node_start(node_name: str) -> None
用于 SSE 流式推送 node_start 事件。
"""
workflow = StateGraph(AgentState)
def _wrap(name, fn):
"""包装节点函数,在开始执行时触发 on_node_start 回调。"""
if on_node_start is None:
return fn
@functools.wraps(fn)
def wrapped(state, *args, **kwargs):
on_node_start(name)
return fn(state, *args, **kwargs)
return wrapped
# 现有节点
workflow.add_node("load_session", load_session_node)
workflow.add_node("process_input", process_input)
workflow.add_node("manage_context", manage_context)
workflow.add_node("save_session", save_session_node)
workflow.add_node("retrieve", retrieve)
workflow.add_node("generate", generate)
workflow.add_node("modify_jrxml", modify_jrxml)
workflow.add_node("validate", validate)
workflow.add_node("explain_error", explain_error)
workflow.add_node("correct_jrxml", correct_jrxml)
workflow.add_node("finalize", finalize)
workflow.add_node("load_session", _wrap("load_session", load_session_node))
workflow.add_node("process_input", _wrap("process_input", process_input))
workflow.add_node("manage_context", _wrap("manage_context", manage_context))
workflow.add_node("save_session", _wrap("save_session", save_session_node))
workflow.add_node("retrieve", _wrap("retrieve", retrieve))
workflow.add_node("generate", _wrap("generate", generate))
workflow.add_node("modify_jrxml", _wrap("modify_jrxml", modify_jrxml))
workflow.add_node("validate", _wrap("validate", validate))
workflow.add_node("explain_error", _wrap("explain_error", explain_error))
workflow.add_node("correct_jrxml", _wrap("correct_jrxml", correct_jrxml))
workflow.add_node("finalize", _wrap("finalize", finalize))
# 新增节点:意图识别
workflow.add_node("save_state_snapshot", save_state_snapshot)
workflow.add_node("classify_intent", classify_intent)
workflow.add_node("handle_consult", handle_consult)
workflow.add_node("handle_undo", handle_undo)
workflow.add_node("handle_reset", handle_reset)
workflow.add_node("save_state_snapshot", _wrap("save_state_snapshot", save_state_snapshot))
workflow.add_node("classify_intent", _wrap("classify_intent", classify_intent))
workflow.add_node("handle_consult", _wrap("handle_consult", handle_consult))
workflow.add_node("handle_undo", _wrap("handle_undo", handle_undo))
workflow.add_node("handle_reset", _wrap("handle_reset", handle_reset))
# 新增节点:分层精确生成(阶段一~三)
workflow.add_node("generate_skeleton", generate_skeleton)
workflow.add_node("refine_layout", refine_layout)
workflow.add_node("map_fields", map_fields)
workflow.add_node("generate_skeleton", _wrap("generate_skeleton", generate_skeleton))
workflow.add_node("refine_layout", _wrap("refine_layout", refine_layout))
workflow.add_node("map_fields", _wrap("map_fields", map_fields))
# ---- 入口和前置流程 ----
workflow.set_entry_point("load_session")
+606
View File
@@ -0,0 +1,606 @@
"""JRXML Agent API Server — FastAPI + SSE streaming.
Replaces the Streamlit UI (app.py) with a REST + SSE backend.
The LangGraph agent pipeline is wrapped unchanged.
SSE Event Types:
node_start — 节点开始执行
node_complete — 节点执行完成(含详情)
stream_token — LLM 逐字输出
agent_complete — 全图执行完成
agent_error — 执行异常
Usage:
python -m uvicorn api_server:app --host 0.0.0.0 --port 8000
"""
import asyncio
import base64
import json
import mimetypes
import os
import queue
import tempfile
import time
import traceback
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, FileResponse
load_dotenv(override=True)
from agent.graph import build_graph
from agent.state import AgentState
from backend.logger import get_logger, generate_trace_id, set_trace_id, get_trace_id
from backend.session import (
create_session,
load_session,
save_session,
list_all_sessions,
delete_session,
get_session_state,
SESSIONS_DIR,
)
from backend.file_parser import parse_file
from backend.layout_analyzer import analyze_layout, extract_layout_schema
# ─────────────────────────────────────────────
# 常量(从 app.py 迁移)
# ─────────────────────────────────────────────
NODE_LABELS = {
"load_session": "加载会话",
"process_input": "记录输入",
"manage_context": "管理上下文",
"save_state_snapshot": "保存快照",
"classify_intent": "识别意图",
"retrieve": "检索模板",
"generate": "生成 JRXML",
"modify_jrxml": "修改 JRXML",
"validate": "验证",
"explain_error": "分析错误",
"correct_jrxml": "自动修正",
"finalize": "完成",
"handle_consult": "咨询回答",
"handle_undo": "撤销操作",
"handle_reset": "重置会话",
"save_session": "保存会话",
"generate_skeleton": "生成骨架",
"refine_layout": "精调布局",
"map_fields": "映射字段",
}
INTENT_LABELS = {
"initial_generation": "新建报表",
"modify_report": "修改报表",
"preview_report": "预览报表",
"export_pdf": "导出 PDF",
"export_jrxml": "下载 JRXML",
"undo_modification": "撤销修改",
"consult_question": "咨询问题",
"reset_session": "重置会话",
}
SKIP_NODES = {"load_session", "process_input", "manage_context",
"save_state_snapshot", "save_session"}
# ─────────────────────────────────────────────
# 日志 & 路径
# ─────────────────────────────────────────────
_api_log = get_logger("api")
UPLOADS_DIR = Path(os.getenv("UPLOADS_DIR", "./uploads"))
# ─────────────────────────────────────────────
# 图编译(全局单例,带 node_start 回调)
# ─────────────────────────────────────────────
# 当前请求的事件队列(单个用户桌面应用,无并发问题)
_current_event_queue: Optional[queue.Queue] = None
def _on_node_start(node_name: str):
"""全局 node_start 回调 — 将事件推入当前请求的事件队列。"""
q = _current_event_queue
if q is not None:
q.put(("node_start", {
"node": node_name,
"label": NODE_LABELS.get(node_name, node_name),
}))
_graph = build_graph(on_node_start=_on_node_start)
# ─────────────────────────────────────────────
# 文件注册表(内存中,桌面应用级别可接受)
# ─────────────────────────────────────────────
_file_registry: dict[str, dict] = {} # file_id → {path, filename, content_type, size}
def _ensure_upload_dir(session_id: str = "") -> Path:
d = UPLOADS_DIR / session_id if session_id else UPLOADS_DIR
d.mkdir(parents=True, exist_ok=True)
return d
# ─────────────────────────────────────────────
# SSE 辅助
# ─────────────────────────────────────────────
def _extract_detail(node_name: str, node_state: dict) -> str:
"""从节点状态中提取详情文本(用于 node_complete 事件)。"""
if node_name == "classify_intent":
intent = node_state.get("intent", "")
return f"意图: {INTENT_LABELS.get(intent, intent)}"
elif node_name == "retrieve":
ctx = node_state.get("retrieved_context", "")
return f"找到 {len(ctx)} 字符参考模板" if ctx else "未匹配到模板"
elif node_name in ("generate", "modify_jrxml", "correct_jrxml",
"generate_skeleton", "refine_layout", "map_fields"):
jrxml = node_state.get("current_jrxml", "")
return f"生成 {len(jrxml)} 字符 JRXML"
elif node_name == "validate":
status = node_state.get("status", "")
if status == "pass":
return "验证通过 ✓"
err = node_state.get("error_msg", "")
return f"验证失败: {err[:80]}"
elif node_name == "explain_error":
expl = node_state.get("natural_explanation", "")
return expl[:120]
elif node_name == "handle_consult":
ans = node_state.get("consult_answer", "")
return ans[:150]
return ""
def _run_graph_sync(agent_state: AgentState, event_q: queue.Queue):
"""在后台线程中运行 graph.stream(),将所有事件推入队列。"""
try:
for event in _graph.stream(agent_state, stream_mode=["updates", "custom"]):
event_q.put(event)
event_q.put(("done", {"reason": "graph_completed"}))
except Exception as exc:
event_q.put(("error", {
"error": str(exc),
"traceback": traceback.format_exc(),
}))
async def _sse_generator(agent_state: AgentState) -> str:
"""SSE 事件生成器 —— 在后台线程运行图,异步产出 SSE 字符串。"""
global _current_event_queue
event_q: queue.Queue = queue.Queue()
_current_event_queue = event_q
loop = asyncio.get_running_loop()
future = loop.run_in_executor(None, _run_graph_sync, agent_state, event_q)
# 从队列读取事件,写 SSE(用 short sleep 做非阻塞轮询)
while True:
# 先排空队列中的所有事件
had_events = False
while True:
try:
item = event_q.get_nowait()
had_events = True
except queue.Empty:
break
kind = item[0]
if kind == "done":
_current_event_queue = None
yield _sse_line("agent_complete", {
"reason": "done",
"intent": agent_state.get("intent", ""),
"status": agent_state.get("status", ""),
"jrxml_length": len(agent_state.get("current_jrxml", "")),
"error_msg": agent_state.get("error_msg", ""),
"natural_explanation": agent_state.get("natural_explanation", ""),
"retry_count": agent_state.get("retry_count", 0),
"ocr_extraction_result": agent_state.get("ocr_extraction_result", {}),
})
await future
return
elif kind == "error":
_current_event_queue = None
yield _sse_line("agent_error", item[1])
await future
return
elif kind == "node_start":
yield _sse_line("node_start", item[1])
else:
# mode=data 来自 graph.stream()
mode, data = item
if mode == "updates":
for node_name, node_state in data.items():
if node_name not in SKIP_NODES:
detail = _extract_detail(node_name, node_state)
yield _sse_line("node_complete", {
"node": node_name,
"label": NODE_LABELS.get(node_name, node_name),
"detail": detail,
})
elif mode == "custom":
cd = data
if cd.get("type") == "stream":
yield _sse_line("stream_token", {
"text": cd.get("text", ""),
"type": "stream",
})
if not had_events:
await asyncio.sleep(0.05)
yield ": keepalive\n\n"
def _sse_line(event_type: str, data: dict) -> str:
"""构造单条 SSE 消息。"""
payload = json.dumps(data, ensure_ascii=False)
return f"event: {event_type}\ndata: {payload}\n\n"
# ─────────────────────────────────────────────
# FastAPI 应用
# ─────────────────────────────────────────────
app = FastAPI(
title="JRXML Agent API",
version="5.0",
description="JRXML 报表生成代理 — 前后端分离 API",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─────────────────────────────────────────────
# 健康检查 & 配置
# ─────────────────────────────────────────────
@app.get("/api/health")
async def health():
return {
"status": "ok",
"version": "5.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@app.get("/api/config")
async def config():
safe = {}
for key in ("LLM_PROVIDER", "OCR_ENGINE", "EMBEDDING_PROVIDER",
"MAX_RETRY", "CONTEXT_MAX_TOKENS", "CONTEXT_KEEP_RECENT"):
val = os.getenv(key, "")
safe[key] = val
return {"config": safe}
# ─────────────────────────────────────────────
# 会话 CRUD
# ─────────────────────────────────────────────
@app.post("/api/sessions")
async def create_new_session():
data = create_session()
return {
"session_id": data["session_id"],
"session_name": data["session_name"],
"created_at": data["created_at"],
"updated_at": data["updated_at"],
}
@app.get("/api/sessions")
async def list_sessions():
return {"sessions": list_all_sessions()}
@app.get("/api/sessions/{session_id}")
async def get_session(session_id: str):
data = get_session_state(session_id)
if data is None:
raise HTTPException(status_code=404, detail="会话不存在")
return {
"session_id": data.get("session_id"),
"session_name": data.get("session_name"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"agent_state": data.get("agent_state", {}),
}
@app.delete("/api/sessions/{session_id}")
async def remove_session(session_id: str):
ok = delete_session(session_id)
if not ok:
raise HTTPException(status_code=404, detail="会话不存在或已删除")
return {"status": "deleted", "session_id": session_id}
# ─────────────────────────────────────────────
# 文件上传
# ─────────────────────────────────────────────
@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...), session_id: str = ""):
file_id = uuid.uuid4().hex[:12]
_ensure_upload_dir(session_id)
# 保留原始文件名
safe_name = Path(file.filename or "upload.bin").name
dest = _ensure_upload_dir(session_id) / f"{file_id}_{safe_name}"
content = await file.read()
dest.write_bytes(content)
content_type = file.content_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
_file_registry[file_id] = {
"path": str(dest),
"filename": safe_name,
"content_type": content_type,
"size": len(content),
}
_api_log.info("文件上传", extra={
"file_id": file_id, "filename": safe_name, "size": len(content),
})
return {
"file_id": file_id,
"filename": safe_name,
"content_type": content_type,
"size": len(content),
}
# ─────────────────────────────────────────────
# 文件处理辅助
# ─────────────────────────────────────────────
def _process_files(file_ids: list[str], session_id: str) -> dict:
"""处理上传的文件:解析 → 布局分析 → 提取 schema 文本。
Returns:
{full_prompt_prefix, uploaded_paths, layout_schema, ocr_text}
"""
if not file_ids:
return {"full_prompt_prefix": "", "uploaded_paths": [],
"layout_schema": {}, "ocr_text": ""}
parts = []
uploaded_paths = []
layout_schema = {}
ocr_text = ""
for fid in file_ids:
info = _file_registry.get(fid)
if not info:
_api_log.warning("文件ID未注册", extra={"file_id": fid})
continue
file_path = info["path"]
uploaded_paths.append(file_path)
parsed = parse_file(file_path, info["filename"].rsplit(".", 1)[-1] if "." in info["filename"] else "")
if parsed.get("error"):
parts.append(f"[文件: {info['filename']}]\n解析失败: {parsed['error']}")
continue
parts.append(f"[文件: {info['filename']}]\n{parsed['text']}")
# 图片文件 → 布局分析
if info["content_type"] and info["content_type"].startswith("image/"):
layout = analyze_layout(file_path)
if layout.get("is_a4_template"):
parts.append(
f"\n[A4模板布局]\n"
f"表格行数: {layout.get('total_rows', 0)}, "
f"总元素: {layout.get('total_elements', 0)}, "
f"比例: {layout.get('a4_confidence', '')}"
)
if layout.get("description"):
parts.append(f"\n[布局描述]\n{layout['description']}")
schema = extract_layout_schema(layout)
if schema and schema.get("total_rows", 0) > 0:
layout_schema = schema
schema_text = schema.get("schema_text", "")
if schema_text:
parts.append(f"\n[布局Schema]\n{schema_text}")
# OCR 元素文本
ocr_elements = layout.get("rows", [])
if ocr_elements:
ocr_lines = []
for row in ocr_elements[:30]:
texts = [e.get("text", "") for e in row.get("elements", [])]
ocr_lines.append(" | ".join(texts))
ocr_text = "\n".join(ocr_lines)
if ocr_lines:
parts.append(f"\n[OCR 识别文本]\n{ocr_text}")
return {
"full_prompt_prefix": "\n\n".join(parts) if parts else "",
"uploaded_paths": uploaded_paths,
"layout_schema": layout_schema,
"ocr_text": ocr_text,
}
# ─────────────────────────────────────────────
# 核心:SSE 聊天端点
# ─────────────────────────────────────────────
@app.post("/api/sessions/{session_id}/chat")
async def chat(session_id: str, payload: dict):
"""发送消息并获取 SSE 流式响应。
Body:
{text: str, file_ids: [str, ...]}
Returns:
text/event-stream (SSE)
"""
text = payload.get("text", "").strip()
file_ids = payload.get("file_ids", [])
if not text and not file_ids:
raise HTTPException(status_code=400, detail="text 和 file_ids 均为空")
# ── 加载或创建会话 ──
trace_id = generate_trace_id()
set_trace_id(trace_id)
data = load_session(session_id)
if data is None:
data = create_session(session_id=session_id)
_api_log.info("自动创建会话", extra={"session_id": session_id, "trace_id": trace_id})
agent_state: AgentState = data.get("agent_state", {})
agent_state["session_id"] = session_id
# ── 处理文件 ──
file_result = _process_files(file_ids, session_id)
full_prompt = text
if file_result["full_prompt_prefix"]:
full_prompt = f"{file_result['full_prompt_prefix']}\n\n用户问题: {text}" if text else file_result["full_prompt_prefix"]
# ── 注入布局 schema(用于分层精确生成)──
if file_result.get("layout_schema"):
agent_state["layout_schema"] = file_result["layout_schema"]
if file_result.get("ocr_text"):
ocr_rows = [{"elements": [{"text": t} for t in line.split(" | ")]}
for line in file_result["ocr_text"].split("\n") if line.strip()]
if ocr_rows:
agent_state["ocr_elements"] = ocr_rows
# ── 设置本轮输入 ──
if agent_state.get("current_jrxml") and agent_state.get("status") == "pass":
agent_state["user_modification_request"] = full_prompt
agent_state["user_input"] = full_prompt
agent_state["retry_count"] = 0
_api_log.info("对话请求", extra={
"session_id": session_id,
"trace_id": trace_id,
"text_length": len(text),
"file_count": len(file_ids),
"prompt_total": len(full_prompt),
})
# ── 返回 SSE 流 ──
async def stream_and_save():
final_state = None
async for sse_chunk in _sse_generator(agent_state):
yield sse_chunk
# 图执行完成后保存会话状态
save_session(session_id, agent_state)
return StreamingResponse(
stream_and_save(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"X-Trace-Id": trace_id,
},
)
# ─────────────────────────────────────────────
# 下载
# ─────────────────────────────────────────────
@app.get("/api/sessions/{session_id}/download/latest")
async def download_latest(session_id: str):
"""下载最新 JRXML 文件。"""
data = load_session(session_id)
if data is None:
raise HTTPException(status_code=404, detail="会话不存在")
agent_state = data.get("agent_state", {})
jrxml = agent_state.get("current_jrxml", "")
if not jrxml:
raise HTTPException(status_code=404, detail="该会话暂无 JRXML")
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".jrxml", delete=False,
encoding="utf-8")
tmp.write(jrxml)
tmp.close()
return FileResponse(
tmp.name,
media_type="application/xml",
filename=f"report_{session_id}.jrxml",
)
@app.get("/api/sessions/{session_id}/download/{version}")
async def download_version(session_id: str, version: int):
"""下载指定版本的 JRXML 文件。"""
data = load_session(session_id)
if data is None:
raise HTTPException(status_code=404, detail="会话不存在")
versions = data.get("agent_state", {}).get("jrxml_versions", [])
if version < 0 or version >= len(versions):
raise HTTPException(status_code=404, detail="版本不存在")
jrxml = versions[version].get("jrxml", "")
if not jrxml:
raise HTTPException(status_code=404, detail="该版本内容为空")
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".jrxml", delete=False,
encoding="utf-8")
tmp.write(jrxml)
tmp.close()
return FileResponse(
tmp.name,
media_type="application/xml",
filename=f"report_{session_id}_v{version}.jrxml",
)
# ─────────────────────────────────────────────
# 下载上传文件
# ─────────────────────────────────────────────
@app.get("/api/files/{file_id}")
async def download_file(file_id: str):
info = _file_registry.get(file_id)
if not info:
raise HTTPException(status_code=404, detail="文件未找到")
return FileResponse(info["path"], filename=info["filename"])
# ─────────────────────────────────────────────
# 启动入口
# ─────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("API_PORT", "8000"))
uvicorn.run("api_server:app", host="0.0.0.0", port=port, reload=True)
+9
View File
@@ -86,6 +86,15 @@ def save_session(session_id: str, agent_state: dict, session_name: str = ""):
json.dump(data, f, ensure_ascii=False, indent=2)
def get_session_state(session_id: str) -> Optional[dict]:
"""获取会话的完整 agent_state,用于 REST API。
返回 dict 包含 session_id, session_name, created_at, updated_at, agent_state。
未找到则返回 None。
"""
return load_session(session_id)
def list_all_sessions() -> list[dict]:
"""列出所有历史会话(仅摘要,不含完整 agent_state)。"""
_ensure_dir()
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1357
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.34"
},
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+173
View File
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { watch, nextTick, ref } from 'vue'
import { useChatStore } from './stores/chat'
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 SummaryCard from './components/SummaryCard.vue'
import UnifiedInput from './components/UnifiedInput.vue'
const chat = useChatStore()
const session = useSessionStore()
const chatContainer = ref<HTMLElement | null>(null)
async function scrollToBottom() {
await nextTick()
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
watch(
() => [chat.messages.length, chat.streamText],
() => scrollToBottom(),
{ flush: 'post' }
)
async function handleSend(text: string, files: File[]) {
if (!session.currentId) {
const sid = await session.createSession()
await session.switchSession(sid)
}
// Upload files first
const remoteIds: string[] = []
for (const f of files) {
try {
const info = await api.uploadFile(f, session.currentId)
remoteIds.push(info.file_id)
} catch (e) {
console.error('文件上传失败:', e)
chat.setError('文件上传失败')
return
}
}
chat.addMessage({ role: 'user', content: text || '[附加文件]' })
scrollToBottom()
chat.startStreaming()
try {
await api.chat(session.currentId, text, remoteIds, {
onNodeStart(data) {
chat.addNode(data)
},
onNodeComplete(data) {
chat.completeNode(data)
},
onStreamToken(data) {
chat.appendStreamToken(data.text)
scrollToBottom()
},
onAgentComplete(data) {
chat.finishStreaming({
intent: data.intent,
status: data.status,
jrxml_length: data.jrxml_length,
error_msg: data.error_msg,
natural_explanation: data.natural_explanation,
retry_count: data.retry_count,
ocr_extraction_result: data.ocr_extraction_result,
})
const streamContent = chat.streamText
if (data.status === 'pass') {
if (streamContent) {
chat.addMessage({ role: 'assistant', content: streamContent, type: 'jrxml' })
}
chat.addMessage({ role: 'assistant', content: 'JRXML 生成成功!可从侧边栏下载。', type: 'success' })
} else if (data.status && data.status !== 'pass') {
chat.addMessage({
role: 'assistant',
content: `经过 ${data.retry_count} 次重试后失败。\n\n错误: ${data.error_msg}${data.natural_explanation ? '\n\n原因: ' + data.natural_explanation : ''}`,
type: 'error',
})
} else if (data.intent === 'consult_question') {
if (streamContent) {
chat.addMessage({ role: 'assistant', content: streamContent, type: 'consult' })
}
} else {
if (streamContent) {
chat.addMessage({ role: 'assistant', content: streamContent, type: 'jrxml' })
}
}
// Refresh session sidebar data after a short delay
setTimeout(() => session.refreshFromState({}), 500)
},
onAgentError(data) {
chat.setError(data.error)
chat.addMessage({ role: 'assistant', content: `执行异常: ${data.error}`, type: 'error' })
},
})
} catch (e: any) {
chat.setError(e.message || '网络请求失败')
chat.addMessage({ role: 'assistant', content: `请求失败: ${e.message}`, type: 'error' })
}
}
</script>
<template>
<div class="app-layout">
<Sidebar />
<main class="main-area">
<div class="chat-container" ref="chatContainer">
<ChatMessages />
<StreamingMessage />
<NodeProgress />
<SummaryCard />
</div>
<UnifiedInput
:disabled="chat.streaming"
@send="handleSend"
/>
</main>
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #11111b;
color: #cdd6f4;
}
#app {
width: 100vw;
height: 100vh;
}
.app-layout {
display: flex;
width: 100%;
height: 100%;
}
.main-area {
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
min-width: 0;
}
.chat-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
</style>
+145
View File
@@ -0,0 +1,145 @@
/** JSON fetch wrapper + SSE streaming helper. */
const BASE = '/api'
export interface SessionSummary {
session_id: string
session_name: string
created_at: string
updated_at: string
}
export interface SessionData extends SessionSummary {
agent_state: Record<string, any>
}
export interface FileInfo {
file_id: string
filename: string
content_type: string
size: number
}
export interface AgentCompleteData {
reason: string
intent: string
status: string
jrxml_length: number
error_msg: string
natural_explanation: string
retry_count: number
ocr_extraction_result: any
}
export interface SSECallbacks {
onNodeStart?: (data: { node: string; label: string }) => void
onNodeComplete?: (data: { node: string; label: string; detail: string }) => void
onStreamToken?: (data: { text: string; type: string }) => void
onAgentComplete?: (data: AgentCompleteData) => void
onAgentError?: (data: { error: string; traceback?: string }) => void
}
export const api = {
// ── Health ──
async health() {
const r = await fetch(`${BASE}/health`)
return r.json()
},
async config() {
const r = await fetch(`${BASE}/config`)
return r.json()
},
// ── Sessions ──
async createSession(): Promise<SessionSummary> {
const r = await fetch(`${BASE}/sessions`, { method: 'POST' })
return r.json()
},
async listSessions(): Promise<SessionSummary[]> {
const r = await fetch(`${BASE}/sessions`)
const data = await r.json()
return data.sessions
},
async getSession(sessionId: string): Promise<SessionData> {
const r = await fetch(`${BASE}/sessions/${sessionId}`)
if (!r.ok) throw new Error('会话不存在')
return r.json()
},
async deleteSession(sessionId: string): Promise<void> {
await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
},
// ── Upload ──
async uploadFile(file: File, sessionId: string): Promise<FileInfo> {
const form = new FormData()
form.append('file', file)
const r = await fetch(`${BASE}/upload?session_id=${encodeURIComponent(sessionId)}`, {
method: 'POST',
body: form,
})
if (!r.ok) throw new Error('上传失败')
return r.json()
},
// ── Chat (SSE) ──
async chat(
sessionId: string,
text: string,
fileIds: string[],
callbacks: SSECallbacks,
): Promise<void> {
const r = await fetch(`${BASE}/sessions/${sessionId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, file_ids: fileIds }),
})
if (!r.ok) {
const err = await r.json().catch(() => ({ detail: r.statusText }))
throw new Error(err.detail || '请求失败')
}
const reader = r.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const payload = JSON.parse(line.slice(6))
switch (currentEvent) {
case 'node_start':
callbacks.onNodeStart?.(payload)
break
case 'node_complete':
callbacks.onNodeComplete?.(payload)
break
case 'stream_token':
callbacks.onStreamToken?.(payload)
break
case 'agent_complete':
callbacks.onAgentComplete?.(payload)
break
case 'agent_error':
callbacks.onAgentError?.(payload)
break
}
}
}
}
},
}
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { useChatStore, type Message } from '../stores/chat'
import { formatTime } from '../utils/format'
const chat = useChatStore()
function renderContent(msg: Message): { text: string; isXml: boolean } {
if (msg.type === 'jrxml') {
return { text: msg.content, isXml: true }
}
return { text: msg.content, isXml: false }
}
</script>
<template>
<div class="chat-messages" ref="scrollRef">
<div v-if="chat.messages.length === 0 && !chat.streaming" class="empty-state">
<div class="empty-icon">📋</div>
<p>开始对话 描述您需要的报表</p>
</div>
<div
v-for="msg in chat.messages"
:key="msg.id"
class="message"
:class="`msg-${msg.role}`"
>
<div class="msg-header">
<span class="msg-role">{{ msg.role === 'user' ? '您' : 'AI' }}</span>
<span class="msg-time">{{ formatTime(msg.timestamp) }}</span>
</div>
<div class="msg-body">
<template v-if="renderContent(msg).isXml">
<pre class="code-block">{{ renderContent(msg).text }}</pre>
</template>
<template v-else>
<div class="markdown-body" v-text="renderContent(msg).text"></div>
</template>
</div>
<div v-if="msg.type === 'error'" class="msg-tag error-tag">错误</div>
<div v-if="msg.type === 'success'" class="msg-tag success-tag">成功</div>
</div>
</div>
</template>
<style scoped>
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #6c7086;
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-state p {
font-size: 14px;
}
.message {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 12px;
max-width: 85%;
}
.msg-user {
margin-left: auto;
background: #313244;
}
.msg-assistant {
margin-right: auto;
background: #1e1e2e;
border: 1px solid #313244;
}
.msg-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.msg-role {
font-size: 12px;
font-weight: 600;
color: #cba6f7;
}
.msg-time {
font-size: 11px;
color: #6c7086;
}
.msg-body {
font-size: 14px;
line-height: 1.6;
color: #cdd6f4;
word-break: break-word;
}
.code-block {
background: #11111b;
padding: 12px;
border-radius: 8px;
font-size: 12px;
overflow-x: auto;
white-space: pre;
color: #a6e3a1;
}
.markdown-body {
white-space: pre-wrap;
}
.msg-tag {
display: inline-block;
margin-top: 6px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.error-tag {
background: #f38ba8;
color: #1e1e2e;
}
.success-tag {
background: #a6e3a1;
color: #1e1e2e;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { useChatStore } from '../stores/chat'
import { computed } from 'vue'
const chat = useChatStore()
const visibleNodes = computed(() =>
chat.nodes.filter(n => n.node !== 'load_session'
&& n.node !== 'process_input'
&& n.node !== 'manage_context'
&& n.node !== 'save_state_snapshot'
&& n.node !== 'save_session')
)
const currentLabel = computed(() => {
const running = visibleNodes.value.find(n => n.status === 'running')
return running ? running.label : null
})
</script>
<template>
<div v-if="chat.streaming && visibleNodes.length > 0" class="node-progress">
<div class="progress-label">
{{ currentLabel || '处理中...' }}
</div>
<div class="progress-dots">
<span
v-for="n in visibleNodes"
:key="n.node"
class="dot"
:class="{ done: n.status === 'done', active: n.status === 'running' }"
></span>
</div>
<div class="progress-detail" v-if="visibleNodes[visibleNodes.length - 1]?.detail">
{{ visibleNodes[visibleNodes.length - 1].detail }}
</div>
</div>
</template>
<style scoped>
.node-progress {
padding: 8px 16px;
margin: 0 24px 8px;
background: #181825;
border-radius: 8px;
border: 1px solid #313244;
}
.progress-label {
font-size: 12px;
font-weight: 600;
color: #89b4fa;
margin-bottom: 4px;
}
.progress-dots {
display: flex;
gap: 4px;
margin-bottom: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #45475a;
}
.dot.active {
background: #f9e2af;
animation: pulse 1s infinite;
}
.dot.done {
background: #a6e3a1;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.progress-detail {
font-size: 11px;
color: #6c7086;
}
</style>
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useSessionStore } from '../stores/session'
import { useChatStore } from '../stores/chat'
const session = useSessionStore()
const chat = useChatStore()
onMounted(() => {
session.loadSessions()
})
async function handleNew() {
const sid = await session.createSession()
await session.switchSession(sid)
chat.reset()
}
async function handleSwitch(sid: string) {
await session.switchSession(sid)
chat.reset()
}
async function handleDelete() {
if (!session.currentId) return
if (!confirm('确定要删除当前会话吗?')) return
await session.deleteCurrent()
chat.reset()
}
</script>
<template>
<aside class="sidebar">
<div class="sidebar-header">
<h2>JRXML Agent</h2>
</div>
<div class="sidebar-section">
<div class="section-title">
<span>会话列表</span>
<button class="btn-icon" @click="handleNew" title="新建会话">+</button>
</div>
<div class="session-list">
<div
v-for="s in session.sortedSessions"
:key="s.session_id"
class="session-item"
:class="{ active: s.session_id === session.currentId }"
@click="handleSwitch(s.session_id)"
>
<span class="session-name">{{ s.session_name }}</span>
<span class="session-time">{{ s.updated_at?.slice(0, 10) }}</span>
</div>
</div>
<button
v-if="session.currentId"
class="btn-delete"
@click="handleDelete"
>
删除当前会话
</button>
</div>
<div class="sidebar-section" v-if="session.currentJrxml">
<div class="section-title">下载</div>
<a
:href="`/api/sessions/${session.currentId}/download/latest`"
class="btn-download"
download
>
下载最新 JRXML
</a>
</div>
<div class="sidebar-footer">
<span>v5.0</span>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 260px;
height: 100vh;
background: #1e1e2e;
color: #cdd6f4;
display: flex;
flex-direction: column;
border-right: 1px solid #313244;
flex-shrink: 0;
}
.sidebar-header {
padding: 20px 16px 12px;
border-bottom: 1px solid #313244;
}
.sidebar-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #cba6f7;
}
.sidebar-section {
padding: 12px 0;
border-bottom: 1px solid #313244;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: #a6adc8;
letter-spacing: 0.5px;
}
.btn-icon {
width: 24px;
height: 24px;
border: none;
background: #313244;
color: #cdd6f4;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.btn-icon:hover {
background: #45475a;
}
.session-list {
max-height: 300px;
overflow-y: auto;
}
.session-item {
padding: 8px 16px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
transition: background 0.15s;
}
.session-item:hover {
background: #313244;
}
.session-item.active {
background: #45475a;
border-left: 3px solid #cba6f7;
padding-left: 13px;
}
.session-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
}
.session-time {
font-size: 11px;
color: #6c7086;
flex-shrink: 0;
}
.btn-delete {
display: block;
width: calc(100% - 32px);
margin: 8px 16px 0;
padding: 6px 12px;
border: 1px solid #f38ba8;
background: transparent;
color: #f38ba8;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.btn-delete:hover {
background: #f38ba8;
color: #1e1e2e;
}
.btn-download {
display: block;
padding: 8px 16px;
color: #a6e3a1;
text-decoration: none;
font-size: 13px;
}
.btn-download:hover {
background: #313244;
}
.sidebar-footer {
margin-top: auto;
padding: 12px 16px;
font-size: 11px;
color: #6c7086;
border-top: 1px solid #313244;
}
</style>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useChatStore } from '../stores/chat'
const chat = useChatStore()
</script>
<template>
<div v-if="chat.streaming" class="streaming-message">
<div class="stream-header">
<span class="stream-label">AI 正在生成...</span>
</div>
<pre class="stream-code" v-if="chat.streamText">{{ chat.streamText }}</pre>
<div v-else class="stream-waiting">
<span class="dot-pulse"></span>
</div>
</div>
</template>
<style scoped>
.streaming-message {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 12px;
background: #1e1e2e;
border: 1px solid #45475a;
max-width: 85%;
}
.stream-header {
margin-bottom: 8px;
}
.stream-label {
font-size: 12px;
font-weight: 600;
color: #f9e2af;
}
.stream-code {
background: #11111b;
padding: 12px;
border-radius: 8px;
font-size: 12px;
overflow-x: auto;
white-space: pre-wrap;
color: #a6e3a1;
max-height: 400px;
overflow-y: auto;
}
.stream-waiting {
padding: 20px 0;
text-align: center;
}
.dot-pulse::after {
content: '...';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSessionStore } from '../stores/session'
import { useChatStore } from '../stores/chat'
const session = useSessionStore()
const chat = useChatStore()
const visible = computed(() =>
!chat.streaming && chat.summary.status !== ''
)
function downloadLatest() {
if (session.currentId) {
window.open(`/api/sessions/${session.currentId}/download/latest`, '_blank')
}
}
</script>
<template>
<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>
<button class="card-btn" @click="downloadLatest">下载 JRXML</button>
</div>
<div v-else class="card card-error">
<div class="card-title">
经过 {{ chat.summary.retry_count }} 次重试后仍失败
</div>
<div class="card-text">{{ chat.summary.error_msg }}</div>
<div v-if="chat.summary.natural_explanation" class="card-reason">
{{ chat.summary.natural_explanation }}
</div>
<div class="card-hint">请继续描述修改需求系统会自动加载失败上下文</div>
</div>
</div>
</template>
<style scoped>
.summary-card {
margin: 12px 24px;
}
.card {
padding: 12px 16px;
border-radius: 10px;
border: 1px solid;
}
.card-success {
background: #1e1e2e;
border-color: #a6e3a1;
}
.card-error {
background: #1e1e2e;
border-color: #f38ba8;
}
.card-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
}
.card-success .card-title {
color: #a6e3a1;
}
.card-error .card-title {
color: #f38ba8;
}
.card-text {
font-size: 13px;
color: #cdd6f4;
margin-bottom: 4px;
}
.card-reason {
font-size: 12px;
color: #a6adc8;
margin-bottom: 4px;
}
.card-hint {
font-size: 11px;
color: #6c7086;
margin-top: 8px;
}
.card-btn {
margin-top: 8px;
padding: 6px 16px;
background: #a6e3a1;
color: #1e1e2e;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.card-btn:hover {
background: #94e2d5;
}
</style>
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { fileIcon } from '../utils/format'
const props = defineProps<{
disabled: boolean
}>()
const emit = defineEmits<{
send: [text: string, files: File[]]
}>()
const text = ref('')
const attachedFiles = ref<{ id: string; name: string; file: File }[]>([])
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const canSend = computed(() =>
!props.disabled && (text.value.trim() || attachedFiles.value.length > 0)
)
function triggerFileInput() {
fileInputRef.value?.click()
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) {
for (const f of input.files) {
attachedFiles.value.push({ id: crypto.randomUUID(), name: f.name, file: f })
}
}
input.value = ''
}
function removeFile(id: string) {
attachedFiles.value = attachedFiles.value.filter(f => f.id !== id)
}
function handleSend() {
if (!canSend.value) return
emit('send', text.value, attachedFiles.value.map(f => f.file))
text.value = ''
attachedFiles.value = []
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
// Auto-resize textarea
function autoResize() {
const el = textareaRef.value
if (el) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
}
// Drag & drop
function handleDrop(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.files) {
for (const f of e.dataTransfer.files) {
attachedFiles.value.push({ id: crypto.randomUUID(), name: f.name, file: f })
}
}
}
function handleDragover(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy'
}
}
// Paste support
function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.kind === 'file') {
const f = item.getAsFile()
if (f) {
e.preventDefault()
attachedFiles.value.push({ id: crypto.randomUUID(), name: f.name || 'clipboard.png', file: f })
}
}
}
}
</script>
<template>
<div class="unified-input" @drop="handleDrop" @dragover="handleDragover">
<!-- File chips -->
<div v-if="attachedFiles.length > 0" class="file-chips">
<div v-for="f in attachedFiles" :key="f.id" class="chip">
<span class="chip-icon">{{ fileIcon(f.name) }}</span>
<span class="chip-name">{{ f.name }}</span>
<button class="chip-remove" @click="removeFile(f.id)">&times;</button>
</div>
</div>
<!-- Input row -->
<div class="input-row">
<button class="attach-btn" @click="triggerFileInput" title="附加文件">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<textarea
ref="textareaRef"
v-model="text"
class="text-input"
placeholder="描述您的报表需求... (Enter 发送, Shift+Enter 换行)"
:disabled="disabled"
@keydown="handleKeydown"
@input="autoResize"
@paste="handlePaste"
rows="1"
></textarea>
<button
class="send-btn"
:disabled="!canSend"
@click="handleSend"
title="发送"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</button>
</div>
<input
ref="fileInputRef"
type="file"
multiple
accept="image/*,.pdf,.docx,.doc,.xlsx,.xls,.txt,.csv"
style="display:none"
@change="handleFileSelect"
/>
</div>
</template>
<style scoped>
.unified-input {
border: 1px solid #45475a;
border-radius: 12px;
background: #1e1e2e;
margin: 0 24px 16px;
padding: 8px 12px;
transition: border-color 0.2s;
}
.unified-input:focus-within {
border-color: #cba6f7;
}
.file-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.chip {
display: flex;
align-items: center;
gap: 4px;
background: #313244;
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
}
.chip-icon {
font-size: 14px;
}
.chip-name {
color: #cdd6f4;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-remove {
background: none;
border: none;
color: #6c7086;
cursor: pointer;
font-size: 16px;
padding: 0;
line-height: 1;
}
.chip-remove:hover {
color: #f38ba8;
}
.input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.attach-btn {
flex-shrink: 0;
width: 36px;
height: 36px;
border: none;
background: #313244;
color: #a6adc8;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.attach-btn:hover {
background: #45475a;
color: #cdd6f4;
}
.text-input {
flex: 1;
background: transparent;
border: none;
color: #cdd6f4;
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
line-height: 1.5;
padding: 8px 0;
min-height: 36px;
max-height: 120px;
}
.text-input::placeholder {
color: #6c7086;
}
.send-btn {
flex-shrink: 0;
width: 36px;
height: 36px;
border: none;
background: #cba6f7;
color: #1e1e2e;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.send-btn:hover:not(:disabled) {
background: #b4befe;
}
.send-btn:disabled {
background: #45475a;
color: #6c7086;
cursor: not-allowed;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
+122
View File
@@ -0,0 +1,122 @@
/** Pinia store — chat messages + streaming state. */
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Message {
id: string
role: 'user' | 'assistant'
content: string
type?: 'text' | 'jrxml' | 'error' | 'success' | 'consult'
timestamp: string
}
export interface NodeProgress {
node: string
label: string
detail?: string
status: 'running' | 'done'
}
export interface AgentSummary {
intent: string
status: string
jrxml_length: number
error_msg: string
natural_explanation: string
retry_count: number
}
export const useChatStore = defineStore('chat', () => {
const messages = ref<Message[]>([])
const streaming = ref(false)
const streamText = ref('')
const nodes = ref<NodeProgress[]>([])
const error = ref<string>('')
const ocrResult = ref<any>(null)
const summary = ref<AgentSummary>({
intent: '', status: '', jrxml_length: 0,
error_msg: '', natural_explanation: '', retry_count: 0,
})
function addMessage(msg: Omit<Message, 'id' | 'timestamp'>) {
messages.value.push({
...msg,
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
})
}
function startStreaming() {
streaming.value = true
streamText.value = ''
nodes.value = []
error.value = ''
summary.value = {
intent: '', status: '', jrxml_length: 0,
error_msg: '', natural_explanation: '', retry_count: 0,
}
}
function appendStreamToken(text: string) {
streamText.value += text
}
function addNode(node: { node: string; label: string }) {
nodes.value.push({ ...node, status: 'running' })
}
function completeNode(node: { node: string; label: string; detail: string }) {
const existing = nodes.value.find(n => n.node === node.node)
if (existing) {
existing.status = 'done'
existing.detail = node.detail
}
}
function finishStreaming(data?: {
intent?: string; status?: string; jrxml_length?: number
error_msg?: string; natural_explanation?: string; retry_count?: number
ocr_extraction_result?: any
}) {
streaming.value = false
nodes.value.forEach(n => { n.status = 'done' })
if (data) {
summary.value = {
intent: data.intent || '',
status: data.status || '',
jrxml_length: data.jrxml_length || 0,
error_msg: data.error_msg || '',
natural_explanation: data.natural_explanation || '',
retry_count: data.retry_count || 0,
}
if (data.ocr_extraction_result) {
ocrResult.value = data.ocr_extraction_result
}
}
}
function setError(err: string) {
error.value = err
streaming.value = false
}
function reset() {
messages.value = []
streamText.value = ''
nodes.value = []
error.value = ''
streaming.value = false
ocrResult.value = null
summary.value = {
intent: '', status: '', jrxml_length: 0,
error_msg: '', natural_explanation: '', retry_count: 0,
}
}
return {
messages, streaming, streamText, nodes, error, ocrResult, summary,
addMessage, startStreaming, appendStreamToken, addNode, completeNode,
finishStreaming, setError, reset,
}
})
+71
View File
@@ -0,0 +1,71 @@
/** Pinia store — session management. */
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api, type SessionSummary } from '../api/client'
export const useSessionStore = defineStore('session', () => {
const sessions = ref<SessionSummary[]>([])
const currentId = ref<string>('')
const currentName = ref<string>('')
const versions = ref<any[]>([])
const currentJrxml = ref<string>('')
const sortedSessions = computed(() =>
[...sessions.value].sort((a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
)
)
const currentSession = computed(() =>
sessions.value.find(s => s.session_id === currentId.value)
)
async function loadSessions() {
try {
sessions.value = await api.listSessions()
} catch (e) {
console.error('加载会话列表失败:', e)
}
}
async function createSession() {
const s = await api.createSession()
sessions.value.unshift(s)
return s.session_id
}
async function switchSession(sessionId: string) {
currentId.value = sessionId
try {
const data = await api.getSession(sessionId)
currentName.value = data.session_name
const state = data.agent_state
currentJrxml.value = state.current_jrxml || ''
versions.value = state.jrxml_versions || []
} catch (e) {
console.error('加载会话失败:', e)
}
}
async function deleteCurrent() {
if (!currentId.value) return
await api.deleteSession(currentId.value)
sessions.value = sessions.value.filter(s => s.session_id !== currentId.value)
currentId.value = ''
currentName.value = ''
currentJrxml.value = ''
versions.value = []
}
function refreshFromState(agentState: Record<string, any>) {
currentJrxml.value = agentState.current_jrxml || currentJrxml.value
versions.value = agentState.jrxml_versions || versions.value
}
return {
sessions, currentId, currentName, versions, currentJrxml,
sortedSessions, currentSession,
loadSessions, createSession, switchSession, deleteCurrent, refreshFromState,
}
})
+24
View File
@@ -0,0 +1,24 @@
/** Date formatting and text utilities. */
export function formatTime(iso: string): string {
if (!iso) return ''
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function truncate(str: string, max: number): string {
if (!str) return ''
return str.length > max ? str.slice(0, max) + '...' : str
}
export function fileIcon(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || ''
const map: Record<string, string> = {
png: '🖼', jpg: '🖼', jpeg: '🖼', bmp: '🖼', webp: '🖼',
pdf: '📄', docx: '📝', doc: '📝',
xlsx: '📊', xls: '📊',
txt: '📃', csv: '📃',
}
return map[ext] || '📎'
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
+11 -7
View File
@@ -1,18 +1,21 @@
@echo off
echo ============================================
echo JRXML 代理 - 全自动启动 (验证服务 + UI)
echo JRXML 代理 - 全自动启动 (验证 + API + UI)
echo ============================================
set STREAMLIT_SERVER_HEADLESS=true
echo.
echo [1/2] 启动验证服务 (端口 8001)...
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"
timeout /t 3 /nobreak >nul
echo [2/2] 启动 Streamlit UI (端口 8501)...
start "JRXML UI" cmd /c "cd /d %~dp0 && .venv\Scripts\streamlit run app.py --server.port 8501"
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"
timeout /t 3 /nobreak >nul
echo [3/3] 启动前端开发服务器 (端口 5173)...
start "JRXML Frontend" cmd /c "cd /d %~dp0\frontend && npm run dev"
timeout /t 3 /nobreak >nul
@@ -20,7 +23,8 @@ echo.
echo ============================================
echo 启动完成
echo 验证服务: http://localhost:8001
echo UI 界面: http://localhost:8501
echo 后端 API: http://localhost:8000
echo 前端界面: http://localhost:5173
echo ============================================
echo.
echo 关闭此窗口不会停止服务。关闭服务窗口或运行 stop.bat 停止。
+2 -1
View File
@@ -2,7 +2,8 @@
echo 正在停止 JRXML 代理服务...
taskkill /fi "WINDOWTITLE eq JRXML 验证服务*" /f 2>nul
taskkill /fi "WINDOWTITLE eq JRXML UI*" /f 2>nul
taskkill /fi "WINDOWTITLE eq JRXML API*" /f 2>nul
taskkill /fi "WINDOWTITLE eq JRXML Frontend*" /f 2>nul
echo 已停止。
pause