实现Agent对话,合格自动提交,不合格补充材料的能力

This commit is contained in:
wandering
2026-06-14 12:56:33 +08:00
parent 8dd91df3b9
commit 46305fdebb
68 changed files with 8914 additions and 1908 deletions

View File

@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-11
last_reviewed: 2026-06-13
---
# src/web 模块设计说明
@@ -11,20 +11,47 @@ last_reviewed: 2026-06-11
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5不引入构建工具保持单页应用轻量可维护。
- **统一文件上传**2026-06-12 改造,将 PDF 和图片上传入口合并为单一上传区,用户通过一个入口上传所有文件类型。
## 变更历史
| 日期 | 变更 |
|------|------|
| 2026-06-13 | LLM 流式思考过程展示:后端 SSE 推送 `llm_stream` 事件start/chunk/end/error前端聊天气泡实时展示 AI 思考过程 |
| 2026-06-12 | 文件进度实时反馈:后端 SSE 推送 `file_progress` 事件processing/done/cached/error前端文件消息实时更新状态 + 展示提取摘要 |
| 2026-06-12 | 配置交互改为逐项引导config.json 缺失字段时 AI 逐个提示用户通过聊天输入,全部完成后自动开始处理 |
| 2026-06-12 | 聊天窗口精简:移除 SSE 日志流显示仅保留关键状态消息config.json 配置不完整时 AI 主动提示缺失字段 |
| 2026-06-12 | 移除开始处理按钮改为自动触发config.json 解析完成且配置完整username/password且有发票文件时自动开始处理 |
| 2026-06-12 | 文件上传通知改为逐条消息:每个文件单独一条聊天气泡,上传框 flex 居中、固定高度 |
| 2026-06-12 | 文件上传反馈移至聊天窗口:上传/拖拽/同步文件后以聊天气泡通知,上传框固定高度不再显示文件标签 |
| 2026-06-12 | 修复聊天窗口消息覆盖问题:将消息区域与输入区域分离,消息区独立滚动,新增用户文本输入功能 |
| 2026-06-12 | 合并 PDF/图片上传入口,`/api/files` 返回统一文件列表,前端使用 `allFiles` 单一数组管理 |
| 2026-06-12 | 移除配置表单config.json 通过统一上传入口自动解析,配置存入 `sessionConfig` 对象,前端不再展示配置输入框 |
| 2026-06-12 | 暗色日志窗口替换为 AI 聊天风格窗口SSE 日志以聊天气泡形式展示,支持打字指示器动画 |
## 文件结构
```
src/web/
├── app.py # Flask 应用入口,路由、管道编排、日志收集
├── sse_handler.py # SSE 日志收集器、日志转义工具
├── routes.py # 路由定义、SSE 端点
├── templates/
│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交)
│ ├── index.html # PC 端主界面(上传、处理、编辑、提交)
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
└── static/
├── css/
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
└── js/
── index.js # 前端逻辑上传、SSE 日志、表格编辑、二维码同步)
── index.js # 入口:初始化 App 全局状态、绑定事件
├── state.js # 全局状态管理App 对象)
├── chat.js # 聊天气泡渲染、文件消息、LLM 流式气泡
├── agent.js # Agent 事件处理、用户输入管理
├── process.js # SSE 连接、事件路由、管道启动
├── config.js # 配置解析、逐项引导
├── upload.js # 文件上传、拖拽处理
├── sync.js # 移动端同步
└── utils.js # HTML 转义等工具函数
```
## 数据流
@@ -64,8 +91,8 @@ graph TD
| GET | `/` | 主界面 |
| POST | `/api/session` | 创建会话,返回 session_id |
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
| GET | `/api/files/<sid>` | 列出会话文件 |
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
| GET | `/api/files/<sid>` | 列出会话文件(返回统一 `files` 列表,含 `name``type``size` 字段;旧字段 `pdfs`/`images` 保留向后兼容) |
| POST | `/api/agent/process/<sid>` | 启动 Agent 管道(后台线程) |
| GET | `/api/logs/<sid>` | SSE 日志流 |
| GET | `/api/data/<sid>` | 获取发票数据 JSON |
| POST | `/api/save/<sid>` | 保存前端编辑的发票数据 |
@@ -77,7 +104,7 @@ graph TD
### 日志收集
`_SSELogHandler` 将管道日志写入 `session.log`SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
`SSELogHandler` 将管道日志写入 `session.log`SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
### 发票类型分流
@@ -97,8 +124,216 @@ Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`
### 移动端同步
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的文件通过 `syncFiles()` 轮询同步到 PC 端内存中的 `allFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
### 配置管理
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端通过统一上传入口接收 `config.json`,自动解析到 `sessionConfig` 对象,不再展示配置输入表单。
### 聊天气泡消息系统
聊天窗口的所有消息通过 **事件文件 + SSE 轮询** 机制传输。后端不直接推送消息,而是将事件追加到 session 目录下的日志文件SSE 端点以 0.5 秒间隔轮询文件增量,再通过 EventSource 推送到前端。
#### 事件文件总览
session 目录下有四个事件文件:
| 文件 | 用途 | 写入方 | 读取方 |
|------|------|--------|--------|
| `llm_stream.log` | LLM 流式输出(思考过程 + 正式回答) | `llm_extractor` 模块 | SSE 端点 |
| `file_events.log` | 文件处理进度 | `extractor` 模块 | SSE 端点 |
| `agent_events.log` | Agent 状态变更、请求补充等 | `orchestrator` 模块 | SSE 端点 |
| `session.log` | 普通 INFO 日志 | `SSELogHandler` | SSE 端点(当前仅保留,前端已不做处理) |
#### 后端发送事件
所有事件文件遵循相同的写入协议:每行一个 JSON 对象,写入后 flush。
**`llm_stream.log`** — 由 `_emit_llm_stream(source_dir, phase, ...)` 写入:
```python
# 开始 LLM 调用(必需)
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
# 流式文本片段(可选,有内容时发)
_emit_llm_stream(source_dir, "chunk", text="让我来分析...")
# 思考过程片段(可选,模型支持时发)
_emit_llm_stream(source_dir, "reasoning", text="根据发票信息...")
# 调用完成(必需)
_emit_llm_stream(source_dir, "end", label="分析完成")
# 调用失败(异常时发)
_emit_llm_stream(source_dir, "error", error="连接超时")
```
**`agent_events.log`** — 由 `_emit_agent_event(session_dir, event_type, ...)` 写入:
```python
# 状态变更
_emit_agent_event(session_dir, "agent_state_change", state="extracting", message="正在分析文件...")
# 请求补充材料
_emit_agent_event(session_dir, "agent_request_supplement", message="请上传返程车票...")
# 信息完整,可以提交
_emit_agent_event(session_dir, "agent_ready")
# 错误
_emit_agent_event(session_dir, "agent_error", message="LLM 提取失败")
```
**`file_events.log`** — 由 `_emit_file_event(source_dir, ...)` 写入:
```python
# 文件开始处理
_emit_file_event(source_dir, filename, "processing")
# 文件处理完成(带摘要)
_emit_file_event(source_dir, filename, "done", summary={"invoice_number": "...", ...})
# 使用缓存
_emit_file_event(source_dir, filename, "cached")
# 处理失败
_emit_file_event(source_dir, filename, "error", error="PDF 解析失败")
```
**重要约束:`start` 和 `end` 事件不可省略。** 前端 `llmStreamState` 状态机依赖 `start` 创建气泡 DOM没有 `start` 时后续的 `chunk``reasoning` 会因守卫条件直接返回。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`
#### SSE 传输层
`/api/logs/<sid>` 端点(`routes.py`)的轮询逻辑:
```
每 0.5 秒:
1. 读取 session.log 增量 → yield "data: <日志行>"
2. 读取 file_events.log 增量 → 逐行 yield "data: <JSON>"
3. 读取 llm_stream.log 增量 → 逐行 yield "data: <JSON>"
4. 读取 agent_events.log 增量 → 逐行 yield "data: <JSON>"
5. 检查 result.json 是否存在 → yield "data: {type: 'done'}" 后退出
```
#### 前端事件路由
`process.js``EventSource` 监听器按 `msg.type` 分发:
```
msg.type === 'file_progress' → setFileProcessing / setFileDone / setFileCached / setFileError
msg.type === 'llm_stream' → handleLLMStream()
msg.type 以 'agent_' 开头 → handleAgentEvent()
msg.type === 'done' → 关闭 EventSource展示结果
```
#### 前端聊天气泡渲染
**LLM 流式气泡**`chat.js`
```
start → _createLLMStreamBubble()
├─ 创建 <div class="chat-bubble processing llm-stream-bubble">
├─ 创建 label 元素(显示 label 文本)
├─ 创建 <details> 可折叠区域(思考过程)
└─ 创建 textContent 元素(正式回答)
└─ 注册到 llmStreamState
reasoning → _appendLLMStreamReasoning()
└─ 追加到 reasoningContent.textContent
chunk → _appendLLMStreamChunk()
└─ 追加到 textContent.textContent
end → _closeLLMStreamBubble()
├─ 气泡 class 从 processing 变为 done
└─ 清空 llmStreamState
error → _errorLLMStreamBubble()
├─ 气泡 class 变为 error
└─ 清空 llmStreamState
```
**Agent 状态气泡**`agent.js`
```
agent_state_change → _handleAgentStateChange()
└─ 更新最后一条状态消息(不追加新气泡)
agent_request_supplement → _handleAgentRequestSupplement()
└─ 追加请求补充的气泡
agent_ready → _handleAgentReady()
└─ 追加完成状态气泡
agent_error → _handleAgentError()
└─ 追加错误气泡
```
**文件进度气泡**`chat.js`
```
file_progress (processing) → setFileProcessing() → 三点动画
file_progress (done) → setFileDone() → 提取摘要
file_progress (cached) → setFileCached() → 缓存标识
file_progress (error) → setFileError() → 错误信息
```
#### 完整数据流
```mermaid
sequenceDiagram
participant Pipe as 后台线程<br/>(管道)
participant Files as 事件文件<br/>(session 目录)
participant SSE as Flask SSE<br/>(routes.py)
participant ES as EventSource<br/>(process.js)
participant Chat as chat.js
participant Agent as agent.js
Note over Pipe: 启动管道
Pipe->>Files: 追加 llm_stream start
Pipe->>Files: 追加 agent_events state_change
Note over SSE: 0.5s 轮询
SSE->>Files: seek(last_size) + read()
SSE->>ES: yield "data: start"
SSE->>ES: yield "data: state_change"
ES->>Chat: handleLLMStream({phase:"start"})
Chat->>Chat: 创建流式气泡
ES->>Agent: handleAgentEvent({type:"agent_state_change"})
Agent->>Agent: 显示状态消息
Note over Pipe: LLM 流式输出
Pipe->>Files: 追加 llm_stream reasoning
Pipe->>Files: 追加 llm_stream chunk
SSE->>Files: seek(last_size) + read()
SSE->>ES: yield "data: reasoning"
SSE->>ES: yield "data: chunk"
ES->>Chat: handleLLMStream({phase:"reasoning"})
Chat->>Chat: 追加思考内容
ES->>Chat: handleLLMStream({phase:"chunk"})
Chat->>Chat: 追加正式回答
Note over Pipe: 完成
Pipe->>Files: 追加 llm_stream end
Pipe->>Files: 写入 result.json
SSE->>Files: seek(last_size) + read()
SSE->>ES: yield "data: end"
ES->>Chat: handleLLMStream({phase:"end"})
Chat->>Chat: 气泡变完成状态
SSE->>Files: 检测 result.json
SSE->>ES: yield "data: {type:'done'}"
ES->>ES: 关闭连接
```
#### 添加新消息类型的步骤
1. 在对应模块定义 `_emit_xxx()` 函数,写入 session 目录的 JSON 文件
2.`routes.py``stream_logs()` 轮询循环中新增对该文件的轮询
3.`process.js` 的 EventSource 监听器中按 `msg.type` 路由到新处理器
4.`chat.js``agent.js` 中实现渲染逻辑
5. 确保 `start``end` 事件成对出现(如果是流式气泡)

5
src/web/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""
Web 模块
提供财务报销系统的 Web 界面功能。
"""

View File

@@ -12,709 +12,39 @@
访问: http://localhost:5000
"""
import json
import logging
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Any, cast
from urllib.parse import quote
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
# 确保项目根目录在 sys.path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from src import get_logger # noqa: E402, I001
from src.config import load_config as load_project_config # noqa: E402, I001
from src.doc.extractor import ( # noqa: E402, I001
extract_invoices,
)
from src.doc.fill_consumable_doc import ( # noqa: E402, I001
CONSUMABLE_DOC_FILENAME,
fill_consumable_from_template,
)
from src.doc.invoice import ( # noqa: E402, I001
load_csv,
load_invoice_csv,
save_csv as save_payment_csv,
save_invoice_csv,
save_application_json,
)
from flask import Flask # noqa: E402, I001
fill_log = get_logger("fill_consumable_doc")
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
from src.web import pipeline_web, routes # noqa: E402, I001
from src.doc.fill_consumable_doc import CONSUMABLE_DOC_FILENAME # noqa: E402, I001
app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
SESSION_LOG_FILE = "session.log"
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
"""保存发票分类结果到 session 目录的 JSON 文件"""
groups_path = session_dir / INVOICE_GROUPS_FILE
# 只保存各组的数量统计,避免重复存储完整发票数据
data = {
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def create_app() -> Flask:
"""应用工厂:初始化配置并注册路由"""
# 设置出库单模板路径
pipeline_web.set_consumable_template(PROJECT_ROOT / CONSUMABLE_DOC_FILENAME)
# 初始化路由配置
routes.init_routes(UPLOAD_BASE)
def _load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
"""从 session 目录加载发票分类统计"""
groups_path = session_dir / INVOICE_GROUPS_FILE
if not groups_path.exists():
return None
try:
with open(groups_path, encoding="utf-8") as f:
return cast(dict[str, int] | None, json.load(f))
except Exception:
return None
# 注册 Blueprint
app.register_blueprint(routes.web_bp)
return app
class _SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)"""
def __init__(self, log_path: Path):
super().__init__()
self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord) -> None:
try:
msg = self.format(record) + "\n"
with self._lock:
self._file.write(msg)
self._file.flush()
except Exception:
pass
def close_file(self) -> None:
try:
self._file.close()
except Exception:
pass
def _install_log_collector(session_dir: Path) -> _SSELogHandler:
"""安装日志收集器到 app.* 模块"""
log_path = session_dir / SESSION_LOG_FILE
fmt = logging.Formatter(
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
"%Y-%m-%d %H:%M:%S",
)
handler = _SSELogHandler(log_path)
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
return handler
def _remove_log_collector(handler: _SSELogHandler) -> None:
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
logging.getLogger(name).removeHandler(handler)
handler.close_file()
# ================================================================
# 出库单填写
# ================================================================
def _load_session_config(session_dir: Path) -> dict[str, Any]:
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
return config
def _resolve_payment_csv(session_dir: Path) -> Path | None:
"""查找支付记录 CSVpayment_records.csv"""
csv_path = session_dir / "payment_records.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
"""查找发票级别 CSVinvoice_summary.csv"""
csv_path = session_dir / "invoice_summary.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
# ================================================================
# 出库单填写
# ================================================================
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""根据 CSV 填写易耗品出库单,供会话目录下载。
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
"""
if not CONSUMABLE_TEMPLATE.exists():
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
# 从统一的分类结果读取,避免重复解析 CSV/JSON
groups = _load_invoice_groups(session_dir)
if groups is None:
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
if not groups.get("general_count", 0):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
csv_path = _resolve_payment_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
try:
fill_log.info("开始填写出库单: %s", out_doc.name)
fill_consumable_from_template(csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config)
fill_log.info("出库单填写完成")
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
except ImportError:
fill_log.error("填写出库单需要 pywin32请执行: pip install pywin32")
return {"ok": False, "error": "服务器未安装 pywin32无法生成 Word 出库单"}
except Exception as e:
fill_log.exception("填写出库单失败: %s", e)
return {"ok": False, "error": str(e)}
def _append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
result["doc_ok"] = True
elif doc_fill.get("skipped"):
# 差旅发票,跳过出库单生成(不是错误)
result["doc_ok"] = None
result["doc_skipped"] = True
result["doc_message"] = doc_fill.get("error", "")
else:
result["doc_ok"] = False
result["doc_error"] = doc_fill.get("error", "未知错误")
# ================================================================
# 管道入口
# ================================================================
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票无需额外提取normal_info.json 待实现)
"""
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 保存 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 保存分类结果(供后续步骤统一读取)
_save_invoice_groups(session_dir, groups)
# ---- Step 2: 差旅/普通信息提取 ----
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
if is_travel:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("travel_info"):
fill_log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(travel_info, f, ensure_ascii=False, indent=2)
fill_log.info("差旅信息已保存到缓存")
else:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("normal_info"):
fill_log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(normal_info, f, ensure_ascii=False, indent=2)
fill_log.info("普通发票信息已保存到缓存")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": invoice_count,
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
# 发票类型统计
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
}
doc_fill = _try_fill_consumable_doc(session_dir, config)
_append_doc_download(result, session_dir.name, doc_fill)
return result
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
- 纯差旅发票差旅报销模式TODO
- 含普通发票:普通报销模式
"""
csv_path = session_dir / "payment_records.csv"
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.bot import run_bot_web
# 从统一的分类结果读取,避免重复解析
groups = _load_invoice_groups(session_dir)
if groups:
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
# TODO: 差旅报销填报流程
else:
fill_log.info("检测到普通发票,使用普通报销模式")
run_bot_web(config, session_dir)
return {"ok": True}
# ================================================================
# Flask 路由
# ================================================================
@app.route("/")
def index() -> Any:
return render_template("index.html")
@app.route("/api/session", methods=["POST"])
def create_session() -> Any:
"""创建上传会话,返回 session_id"""
sid = uuid.uuid4().hex[:12]
session_dir = UPLOAD_BASE / sid
session_dir.mkdir(parents=True, exist_ok=True)
return jsonify({"session_id": sid})
@app.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str) -> Any:
"""上传 PDF 或图片"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@app.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str) -> Any:
"""列出会话目录中的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
imgs = sorted(f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} for f in session_dir.glob(f"*{ext}"))
return jsonify({"pdfs": pdfs, "images": imgs})
@app.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str) -> Any:
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
# 读取配置
config = _build_web_config(body)
# 写入配置到会话目录
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
# 在后台线程执行
handler = _install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
result = run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
except Exception:
pass
_remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@app.route("/api/logs/<session_id>")
def stream_logs(session_id: str) -> Any:
"""SSE 日志流"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
def generate() -> Any:
# 先发送已有日志
log_file = session_dir / SESSION_LOG_FILE
last_size = 0
start_time = time.time()
timeout = 600 # 10 分钟超时
while time.time() - start_time < timeout:
if log_file.exists():
current_size = log_file.stat().st_size
if current_size > last_size:
with open(log_file, encoding="utf-8", errors="replace") as f:
f.seek(last_size)
chunk = f.read()
if chunk:
yield f"data: {_escape_sse(chunk)}\n\n"
last_size = current_size
# 也通过队列发送实时日志
# 检查是否完成
result_file = session_dir / SESSION_RESULT_FILE
if result_file.exists():
with open(result_file, encoding="utf-8") as f:
result = json.load(f)
yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
break
time.sleep(0.5)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.route("/api/download/<session_id>/<filename>")
def download_file(session_id: str, filename: str) -> Any:
"""下载生成的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 防止路径穿越
safe_name = Path(filename).name
filepath = session_dir / safe_name
if not filepath.exists():
return jsonify({"error": "文件不存在"}), 404
if safe_name.endswith(".doc"):
mimetype = "application/msword"
elif safe_name.endswith(".csv"):
mimetype = "text/csv; charset=utf-8"
else:
mimetype = "application/octet-stream"
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
return Response(
filepath.read_bytes(),
mimetype=mimetype,
headers={"Content-Disposition": disposition},
)
@app.route("/api/config/<session_id>", methods=["GET"])
def get_session_config(session_id: str) -> Any:
"""获取当前会话的配置(供前端回填表单)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
# 只返回前端需要的字段
return jsonify(
{
"username": config.get("username", ""),
"password": "", # 不返回密码
"default_name": config.get("default_name", ""),
"default_card_no": config.get("default_card_no", ""),
"default_person_id": config.get("default_person_id", ""),
"consumable_storage": config.get("consumable_storage", ""),
}
)
@app.route("/api/data/<session_id>", methods=["GET"])
def get_invoice_data(session_id: str) -> Any:
"""读取发票数据并返回 JSON供前端表格编辑"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 优先读取支付记录 CSV
payment_csv = session_dir / "payment_records.csv"
if payment_csv.exists():
rows = load_csv(payment_csv)
if rows is not None:
data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry: dict[str, Any] = dict(row)
entry["__row"] = i
data.append(entry)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
# 回退到发票级别 CSV
invoice_csv = session_dir / "invoice_summary.csv"
if invoice_csv.exists():
rows = load_invoice_csv(invoice_csv)
if rows is not None:
invoice_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry2: dict[str, Any] = dict(row)
entry2["__row"] = i
invoice_data.append(entry2)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
# 最后尝试任意 CSV
csv_files = list(session_dir.glob("*.csv"))
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
if csv_files:
csv_path = csv_files[0]
rows = load_csv(csv_path)
if rows is None:
rows = load_invoice_csv(csv_path)
if rows is not None:
fallback_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry3: dict[str, Any] = dict(row)
entry3["__row"] = i
fallback_data.append(entry3)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
return jsonify({"error": "未找到发票数据,请先处理"}), 404
@app.route("/api/save/<session_id>", methods=["POST"])
def save_invoice_data(session_id: str) -> Any:
"""保存前端编辑后的发票数据到 CSV"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
data = body.get("data", [])
csv_filename = body.get("csv_filename", "invoice_summary.csv")
csv_path = session_dir / csv_filename
if not csv_path.exists():
return jsonify({"error": "CSV 文件不存在"}), 404
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys
original_rows = load_csv(csv_path)
if original_rows is None or len(original_rows) == 0:
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
# 从原数据中获取字段顺序(去掉内部字段)
fieldnames = list(original_rows[0].keys())
import csv as csv_module
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for entry in data:
row = {k: entry.get(k, "") for k in fieldnames}
writer.writerow(row)
resp: dict[str, str | bool | None] = {"ok": True}
config = _load_session_config(session_dir)
doc_fill = _try_fill_consumable_doc(session_dir, config)
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
resp["doc_ok"] = True
elif doc_fill.get("skipped"):
resp["doc_ok"] = None
resp["doc_skipped"] = True
else:
resp["doc_ok"] = False
resp["doc_error"] = doc_fill.get("error") or ""
return jsonify(resp)
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
def submit_financial(session_id: str) -> Any:
"""手动触发财务系统填报"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 读取配置
config_path = session_dir / "config.json"
if not config_path.exists():
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
# 清除上次处理留下的结果文件,避免 SSE 误判为已完成
result_file = session_dir / SESSION_RESULT_FILE
if result_file.exists():
result_file.unlink()
# 在后台线程执行提交
handler = _install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
submit_result = run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {"ok": True}
else:
result = submit_result
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
f,
ensure_ascii=False,
)
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
except Exception:
pass
_remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
# ================================================================
# 辅助函数
# ================================================================
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
session_dir = UPLOAD_BASE / session_id
if not session_dir.exists():
return jsonify({"error": "会话不存在"}), 404
return session_dir
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
"""从请求体构建配置"""
config = load_project_config()
for key in (
"username",
"password",
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
):
if body.get(key):
config[key] = body[key]
return config
def _escape_sse(text: str) -> str:
"""SSE 数据转义,同时处理 Windows 行尾 \\r\\n"""
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
@app.route("/mobile/<session_id>")
def mobile_upload(session_id: str) -> Any:
"""移动端上传页面"""
session_dir = UPLOAD_BASE / session_id
if not session_dir.exists():
return render_template("mobile_upload.html", error="会话不存在"), 404
return render_template("mobile_upload.html", session_id=session_id)
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
def mobile_upload_file(session_id: str) -> Any:
"""移动端上传图片(复用 PC 上传逻辑)"""
return upload_file(session_id)
# 直接使用 app 实例(保持向后兼容)
create_app()
if __name__ == "__main__":
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)

259
src/web/pipeline_web.py Normal file
View File

@@ -0,0 +1,259 @@
"""
Web 管道逻辑
负责:
- 发票提取管道编排
- 财务系统填报触发
- 易耗品出库单生成
- 发票分类数据持久化
"""
import json
import time
from pathlib import Path
from typing import Any, cast
from urllib.parse import quote
# 延迟导入,避免循环引用
from src import get_logger # noqa: F401
from src.config import load_config as load_project_config
from src.doc.fill_consumable_doc import (
CONSUMABLE_DOC_FILENAME,
fill_consumable_from_template,
)
from src.doc.invoice import (
save_application_json,
save_invoice_csv,
)
from src.doc.invoice import (
save_csv as save_payment_csv,
)
fill_log = get_logger("fill_consumable_doc")
# 文件常量
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
"""保存发票分类结果到 session 目录的 JSON 文件"""
groups_path = session_dir / INVOICE_GROUPS_FILE
data = {
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
"""从 session 目录加载发票分类统计"""
groups_path = session_dir / INVOICE_GROUPS_FILE
if not groups_path.exists():
return None
try:
with open(groups_path, encoding="utf-8") as f:
return cast(dict[str, int] | None, json.load(f))
except Exception:
return None
def load_session_config(session_dir: Path) -> dict[str, Any]:
"""加载会话配置,合并项目全局配置与会话级配置"""
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
return config
def resolve_payment_csv(session_dir: Path) -> Path | None:
"""查找支付记录 CSVpayment_records.csv"""
csv_path = session_dir / "payment_records.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
def resolve_invoice_csv(session_dir: Path) -> Path | None:
"""查找发票级别 CSVinvoice_summary.csv"""
csv_path = session_dir / "invoice_summary.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
# ================================================================
# 出库单填写
# ================================================================
# 在模块加载时确定模板路径(由 app.py 传入 PROJECT_ROOT
_consumable_template: Path | None = None
def set_consumable_template(template_path: Path) -> None:
"""设置出库单模板路径(由 app.py 在启动时调用)"""
global _consumable_template
_consumable_template = template_path
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""根据 CSV 填写易耗品出库单,供会话目录下载。
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
"""
template = _consumable_template
if template is None or not template.exists():
fill_log.warning("出库单模板不存在: %s", template)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
groups = load_invoice_groups(session_dir)
if groups is None:
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
if not groups.get("general_count", 0):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
csv_path = resolve_payment_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
try:
fill_log.info("开始填写出库单: %s", out_doc.name)
fill_consumable_from_template(csv_path, template, out_doc, config=config)
fill_log.info("出库单填写完成")
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
except ImportError:
fill_log.error("填写出库单需要 pywin32请执行: pip install pywin32")
return {"ok": False, "error": "服务器未安装 pywin32无法生成 Word 出库单"}
except Exception as e:
fill_log.exception("填写出库单失败: %s", e)
return {"ok": False, "error": str(e)}
def append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
"""将出库单下载信息追加到结果字典"""
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
result["doc_ok"] = True
elif doc_fill.get("skipped"):
result["doc_ok"] = None
result["doc_skipped"] = True
result["doc_message"] = doc_fill.get("error", "")
else:
result["doc_ok"] = False
result["doc_error"] = doc_fill.get("error", "未知错误")
# ================================================================
# 管道入口
# ================================================================
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票无需额外提取normal_info.json 待实现)
"""
from src.doc.extractor import extract_invoices
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 保存 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 保存分类结果(供后续步骤统一读取)
save_invoice_groups(session_dir, groups)
# ---- Step 2: 差旅/普通信息提取 ----
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
if is_travel:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
cache_map = load_cache(session_dir)
if not cache_map.get("travel_info"):
fill_log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(travel_info, f, ensure_ascii=False, indent=2)
fill_log.info("差旅信息已保存到缓存")
else:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
cache_map = load_cache(session_dir)
if not cache_map.get("normal_info"):
fill_log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(normal_info, f, ensure_ascii=False, indent=2)
fill_log.info("普通发票信息已保存到缓存")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": invoice_count,
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
}
doc_fill = _try_fill_consumable_doc(session_dir, config)
append_doc_download(result, session_dir.name, doc_fill)
return result
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
- 纯差旅发票差旅报销模式TODO
- 含普通发票:普通报销模式
"""
csv_path = session_dir / "payment_records.csv"
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.bot import run_bot_web
groups = load_invoice_groups(session_dir)
if groups:
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
else:
fill_log.info("检测到普通发票,使用普通报销模式")
run_bot_web(config, session_dir)
return {"ok": True}

917
src/web/routes.py Normal file
View File

@@ -0,0 +1,917 @@
"""
Flask 路由定义
所有 Web 端点的路由注册,不包含业务逻辑(业务逻辑在 pipeline_web 和 sse_handler 中)。
使用 Blueprint 模式,支持延迟注册到 Flask app。
"""
import csv as csv_module
import json
import threading
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import quote
from flask import Blueprint, Response, jsonify, render_template, request, stream_with_context
from src.config import load_config as load_project_config
from src.doc.invoice import load_csv, load_invoice_csv
from . import pipeline_web, sse_handler
# 在模块加载时确定(由 init_routes 传入)
_UPLOAD_BASE: Path | None = None
web_bp = Blueprint("web", __name__)
def init_routes(upload_base: Path) -> None:
"""初始化路由配置,传入上传目录"""
global _UPLOAD_BASE
_UPLOAD_BASE = upload_base
# ================================================================
# 辅助函数
# ================================================================
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
"""验证会话 ID 并返回会话目录"""
if _UPLOAD_BASE is None:
return jsonify({"error": "服务未初始化"}), 500
session_dir = _UPLOAD_BASE / session_id
if not session_dir.exists():
return jsonify({"error": "会话不存在"}), 404
return session_dir
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
"""从请求体构建配置"""
config = load_project_config()
for key in (
"username",
"password",
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
):
if body.get(key):
config[key] = body[key]
return config
def _emit_ready_and_submit(
session_dir: Path,
agent_session: Any,
config: dict[str, Any],
) -> None:
"""Agent 校验通过后,直接触发财务提交(不依赖前端)"""
from src.agent import _emit_agent_event
# 发射 agent_ready 事件,前端 SSE 会收到
_emit_agent_event(
session_dir,
"agent_ready",
round=agent_session.rounds,
message="信息完整,可以提交",
)
# 直接执行财务提交
try:
submit_result = pipeline_web.run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {
"ok": True,
"agent_ready": True,
"submit_ok": True,
"round": agent_session.rounds,
"message": "信息完整,已自动提交到财务系统",
}
else:
result = {
"ok": True,
"agent_ready": True,
"submit_ok": False,
"submit_error": submit_result.get("error"),
"round": agent_session.rounds,
"message": "校验通过但提交失败",
}
except Exception as e:
result = {
"ok": True,
"agent_ready": True,
"submit_ok": False,
"submit_error": str(e),
"round": agent_session.rounds,
"message": f"校验通过但提交异常: {e}",
}
# 写入 result 文件SSE done 事件会读取
try:
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
# ================================================================
# 页面路由
# ================================================================
@web_bp.route("/")
def index() -> Any:
return render_template("index.html")
@web_bp.route("/mobile/<session_id>")
def mobile_upload(session_id: str) -> Any:
"""移动端上传页面"""
if _UPLOAD_BASE is None:
return render_template("mobile_upload.html", error="服务未初始化"), 500
session_dir = _UPLOAD_BASE / session_id
if not session_dir.exists():
return render_template("mobile_upload.html", error="会话不存在"), 404
return render_template("mobile_upload.html", session_id=session_id)
# ================================================================
# 会话管理
# ================================================================
@web_bp.route("/api/session", methods=["POST"])
def create_session() -> Any:
"""创建上传会话,返回 session_id"""
if _UPLOAD_BASE is None:
return jsonify({"error": "服务未初始化"}), 500
sid = uuid.uuid4().hex[:12]
session_dir = _UPLOAD_BASE / sid
session_dir.mkdir(parents=True, exist_ok=True)
return jsonify({"session_id": sid})
# ================================================================
# 文件上传与下载
# ================================================================
@web_bp.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str) -> Any:
"""上传 PDF 或图片"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@web_bp.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str) -> Any:
"""列出会话目录中的文件(统一列表)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
pdf_exts = {".pdf"}
img_exts = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
files = []
for f in sorted(session_dir.iterdir()):
if not f.is_file():
continue
ext = f.suffix.lower()
if ext in pdf_exts:
files.append({"name": f.name, "type": "pdf", "size": f.stat().st_size})
elif ext in img_exts:
files.append({"name": f.name, "type": "image", "size": f.stat().st_size})
return jsonify(
{
"files": files,
"pdfs": [item["name"] for item in files if item["type"] == "pdf"],
"images": [item["name"] for item in files if item["type"] == "image"],
}
)
@web_bp.route("/api/download/<session_id>/<filename>")
def download_file(session_id: str, filename: str) -> Any:
"""下载生成的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
safe_name = Path(filename).name
filepath = session_dir / safe_name
if not filepath.exists():
return jsonify({"error": "文件不存在"}), 404
if safe_name.endswith(".doc"):
mimetype = "application/msword"
elif safe_name.endswith(".csv"):
mimetype = "text/csv; charset=utf-8"
else:
mimetype = "application/octet-stream"
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
return Response(
filepath.read_bytes(),
mimetype=mimetype,
headers={"Content-Disposition": disposition},
)
@web_bp.route("/api/mobile-upload/<session_id>", methods=["POST"])
def mobile_upload_file(session_id: str) -> Any:
"""移动端上传图片(复用 PC 上传逻辑)"""
return upload_file(session_id)
# ================================================================
# 配置
# ================================================================
@web_bp.route("/api/config/<session_id>", methods=["GET"])
def get_session_config(session_id: str) -> Any:
"""获取当前会话的配置(供前端回填表单)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
return jsonify(
{
"username": config.get("username", ""),
"password": "",
"default_name": config.get("default_name", ""),
"default_card_no": config.get("default_card_no", ""),
"default_person_id": config.get("default_person_id", ""),
"consumable_storage": config.get("consumable_storage", ""),
}
)
# ================================================================
# 发票数据处理
# ================================================================
@web_bp.route("/api/data/<session_id>", methods=["GET"])
def get_invoice_data(session_id: str) -> Any:
"""读取发票数据并返回 JSON供前端表格编辑"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 优先读取支付记录 CSV
payment_csv = session_dir / "payment_records.csv"
if payment_csv.exists():
rows = load_csv(payment_csv)
if rows is not None:
data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry: dict[str, Any] = dict(row)
entry["__row"] = i
data.append(entry)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
# 回退到发票级别 CSV
invoice_csv = session_dir / "invoice_summary.csv"
if invoice_csv.exists():
rows = load_invoice_csv(invoice_csv)
if rows is not None:
invoice_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry2: dict[str, Any] = dict(row)
entry2["__row"] = i
invoice_data.append(entry2)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
# 最后尝试任意 CSV
csv_files = list(session_dir.glob("*.csv"))
csv_files = [f for f in csv_files if f.name != pipeline_web.SESSION_RESULT_FILE]
if csv_files:
csv_path = csv_files[0]
rows = load_csv(csv_path)
if rows is None:
rows = load_invoice_csv(csv_path)
if rows is not None:
fallback_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry3: dict[str, Any] = dict(row)
entry3["__row"] = i
fallback_data.append(entry3)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
return jsonify({"error": "未找到发票数据,请先处理"}), 404
@web_bp.route("/api/save/<session_id>", methods=["POST"])
def save_invoice_data(session_id: str) -> Any:
"""保存前端编辑后的发票数据到 CSV"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
data = body.get("data", [])
csv_filename = body.get("csv_filename", "invoice_summary.csv")
csv_path = session_dir / csv_filename
if not csv_path.exists():
return jsonify({"error": "CSV 文件不存在"}), 404
original_rows = load_csv(csv_path)
if original_rows is None or len(original_rows) == 0:
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
fieldnames = list(original_rows[0].keys())
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for entry in data:
row = {k: entry.get(k, "") for k in fieldnames}
writer.writerow(row)
resp: dict[str, str | bool | None] = {"ok": True}
config = pipeline_web.load_session_config(session_dir)
doc_fill = pipeline_web._try_fill_consumable_doc(session_dir, config)
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
resp["doc_ok"] = True
elif doc_fill.get("skipped"):
resp["doc_ok"] = None
resp["doc_skipped"] = True
else:
resp["doc_ok"] = False
resp["doc_error"] = doc_fill.get("error") or ""
return jsonify(resp)
# ================================================================
# 管道处理
# ================================================================
@web_bp.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str) -> Any:
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
config = _build_web_config(body)
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
result = pipeline_web.run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@web_bp.route("/api/logs/<session_id>")
def stream_logs(session_id: str) -> Any:
"""SSE 日志流"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
def generate() -> Any:
log_file = session_dir / sse_handler.SESSION_LOG_FILE
last_size = 0
file_events_file = session_dir / "file_events.log"
last_events_size = 0
last_stream_size = 0
state = {"agent_size": 0}
start_time = time.time()
timeout = 600
while time.time() - start_time < timeout:
# 轮询普通日志
if log_file.exists():
current_size = log_file.stat().st_size
if current_size > last_size:
with open(log_file, encoding="utf-8", errors="replace") as f:
f.seek(last_size)
chunk = f.read()
if chunk:
yield f"data: {sse_handler.escape_sse(chunk)}\n\n"
last_size = current_size
# 轮询文件进度事件
if file_events_file.exists():
events_size = file_events_file.stat().st_size
if events_size > last_events_size:
with open(file_events_file, encoding="utf-8", errors="replace") as f:
f.seek(last_events_size)
new_events = f.read()
if new_events:
for line in new_events.strip().split("\n"):
line = line.strip()
if line:
yield f"data: {line}\n\n"
last_events_size = events_size
# 轮询 LLM 流式事件
llm_stream_file = session_dir / "llm_stream.log"
if llm_stream_file.exists():
stream_size = llm_stream_file.stat().st_size
if stream_size > last_stream_size:
with open(llm_stream_file, encoding="utf-8", errors="replace") as f:
f.seek(last_stream_size)
new_chunks = f.read()
if new_chunks:
for line in new_chunks.strip().split("\n"):
line = line.strip()
if line:
yield f"data: {line}\n\n"
last_stream_size = stream_size
# 轮询 Agent 事件
agent_events_file = session_dir / "agent_events.log"
last_agent_events_size = state["agent_size"]
if agent_events_file.exists():
agent_size = agent_events_file.stat().st_size
if agent_size > last_agent_events_size:
with open(agent_events_file, encoding="utf-8", errors="replace") as f:
f.seek(last_agent_events_size)
new_agent_events = f.read()
if new_agent_events:
for line in new_agent_events.strip().split("\n"):
line = line.strip()
if line:
yield f"data: {line}\n\n"
state["agent_size"] = agent_size
# 检查是否完成
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if result_file.exists():
with open(result_file, encoding="utf-8") as f:
result = json.load(f)
yield f"data: {sse_handler.escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
break
time.sleep(0.5)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@web_bp.route("/api/submit-financial/<session_id>", methods=["POST"])
def submit_financial(session_id: str) -> Any:
"""手动触发财务系统填报"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
config_path = session_dir / "config.json"
if not config_path.exists():
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if result_file.exists():
result_file.unlink()
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
submit_result = pipeline_web.run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {"ok": True}
else:
result = submit_result
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
f,
ensure_ascii=False,
)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
# ================================================================
# Agent 交互 API
# ================================================================
@web_bp.route("/api/agent/state/<session_id>", methods=["GET"])
def get_agent_state(session_id: str) -> Any:
"""获取 Agent 会话状态"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
from src.agent import load_agent_state
session = load_agent_state(session_dir)
if session is None:
return jsonify({"error": "未找到 Agent 状态,请先处理"}), 404
return jsonify(session.to_dict())
@web_bp.route("/api/agent/process/<session_id>", methods=["POST"])
def agent_process(session_id: str) -> Any:
"""启动 Agent 多轮处理流程"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
config = _build_web_config(body)
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
from src.agent import (
AgentSession,
AgentState,
load_agent_state,
run_agent_round,
save_agent_state,
)
from src.doc.extractor import extract_invoices
from src.doc.invoice import (
save_application_json,
save_invoice_csv,
)
from src.doc.invoice import (
save_csv as save_payment_csv,
)
agent_session = load_agent_state(session_dir)
if agent_session is None:
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
result = {"ok": False, "error": "未提取到任何发票数据"}
return
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
pipeline_web.save_invoice_groups(session_dir, groups)
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
agent_session = AgentSession(
session_id=session_id,
invoice_type="travel" if is_travel else "normal",
)
save_agent_state(session_dir, agent_session)
agent_session = run_agent_round(session_dir, agent_session)
if agent_session.state == AgentState.READY:
# Agent 校验通过,直接触发财务提交
_emit_ready_and_submit(session_dir, agent_session, config)
elif agent_session.state == AgentState.AWAITING_SUPPLEMENT:
result = {
"ok": True,
"agent_ready": False,
"agent_state": agent_session.state.value,
"round": agent_session.rounds,
"waiting_for_supplement": True,
}
elif agent_session.state == AgentState.ERROR:
result = {
"ok": False,
"error": agent_session.error_message,
}
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if not result_file.exists():
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@web_bp.route("/api/agent/supplement/<session_id>", methods=["POST"])
def agent_supplement(session_id: str) -> Any:
"""用户补充文件后触发新一轮 Agent 处理"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
filenames = body.get("files", [])
if not filenames:
return jsonify({"error": "未指定补充文件"}), 400
from src.agent import (
AgentState,
add_supplement,
load_agent_state,
run_agent_round,
)
agent_session = load_agent_state(session_dir)
if agent_session is None:
return jsonify({"error": "未找到 Agent 状态"}), 404
agent_session = add_supplement(session_dir, agent_session, filenames)
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
from src.doc.extractor import extract_invoices
from src.doc.invoice import (
save_application_json,
save_invoice_csv,
)
from src.doc.invoice import (
save_csv as save_payment_csv,
)
invoices, applications, groups = extract_invoices(str(session_dir))
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
pipeline_web.save_invoice_groups(session_dir, groups)
new_session = run_agent_round(session_dir, agent_session, new_files=filenames)
if new_session.state == AgentState.READY:
# Agent 校验通过,直接触发财务提交
config_path = session_dir / "config.json"
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
_emit_ready_and_submit(session_dir, new_session, config)
elif new_session.state == AgentState.AWAITING_SUPPLEMENT:
result = {
"ok": True,
"agent_ready": False,
"agent_state": new_session.state.value,
"round": new_session.rounds,
"waiting_for_supplement": True,
}
elif new_session.state == AgentState.ERROR:
result = {
"ok": False,
"error": new_session.error_message,
}
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if result.get("error") and not result_file.exists():
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@web_bp.route("/api/agent/user-supplement/<session_id>", methods=["POST"])
def agent_user_supplement(session_id: str) -> Any:
"""用户通过文字补充信息LLM 分析后更新 JSON重新校验"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
user_text = body.get("text", "").strip()
if not user_text:
return jsonify({"error": "请输入补充信息"}), 400
from src.agent import (
AgentState,
load_agent_state,
process_user_text_supplement,
)
agent_session = load_agent_state(session_dir)
if agent_session is None:
return jsonify({"error": "未找到 Agent 状态"}), 404
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
new_session = process_user_text_supplement(session_dir, agent_session, user_text)
if new_session.state == AgentState.READY:
# Agent 校验通过,直接触发财务提交
config_path = session_dir / "config.json"
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
_emit_ready_and_submit(session_dir, new_session, config)
elif new_session.state == AgentState.AWAITING_SUPPLEMENT:
result = {
"ok": True,
"agent_ready": False,
"agent_state": new_session.state.value,
"round": new_session.rounds,
"waiting_for_supplement": True,
}
elif new_session.state == AgentState.ERROR:
result = {
"ok": False,
"error": new_session.error_message,
}
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if not result_file.exists():
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@web_bp.route("/api/agent/force-submit/<session_id>", methods=["POST"])
def agent_force_submit(session_id: str) -> Any:
"""用户强制提交,跳过校验"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
from src.agent import (
force_submit,
load_agent_state,
)
agent_session = load_agent_state(session_dir)
if agent_session is None:
return jsonify({"error": "未找到 Agent 状态"}), 404
agent_session = force_submit(session_dir, agent_session)
config_path = session_dir / "config.json"
if not config_path.exists():
return jsonify({"error": "未找到配置"}), 400
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if result_file.exists():
result_file.unlink()
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
submit_result = pipeline_web.run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {"ok": True}
else:
result = submit_result
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
f,
ensure_ascii=False,
)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})

84
src/web/sse_handler.py Normal file
View File

@@ -0,0 +1,84 @@
"""
SSE 日志流处理
负责:
- 日志收集器安装/卸载
- SSE 数据转义
"""
import logging
import threading
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
SESSION_LOG_FILE = "session.log"
# 需要监控日志的模块名称列表
LOG_TARGET_NAMES = [
"extractor",
"llm_extractor",
"matcher",
"pipeline",
"bot",
"fill_consumable_doc",
"agent",
"validator",
]
class SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)"""
def __init__(self, log_path: Path):
super().__init__()
self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord) -> None:
try:
msg = self.format(record) + "\n"
with self._lock:
self._file.write(msg)
self._file.flush()
except Exception:
pass
def close_file(self) -> None:
try:
self._file.close()
except Exception:
pass
def install_log_collector(session_dir: Path) -> SSELogHandler:
"""安装日志收集器到各模块"""
log_path = session_dir / SESSION_LOG_FILE
fmt = logging.Formatter(
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
"%Y-%m-%d %H:%M:%S",
)
handler = SSELogHandler(log_path)
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
for name in LOG_TARGET_NAMES:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
return handler
def remove_log_collector(handler: SSELogHandler) -> None:
"""卸载日志收集器"""
for name in LOG_TARGET_NAMES:
logging.getLogger(name).removeHandler(handler)
handler.close_file()
def escape_sse(text: str) -> str:
"""SSE 数据转义,同时处理 Windows 行尾 \r\n"""
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")

View File

@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-11
last_reviewed: 2026-06-12
---
# src/web/static — 静态资源目录
@@ -15,4 +15,12 @@ last_reviewed: 2026-06-11
## 技术栈
原生 JavaScript + Bootstrap 5无构建工具保持单页应用轻量可维护。
原生 JavaScript + Bootstrap 5无构建工具保持单页应用轻量可维护。
## 变更记录
- **2026-06-12**:修复配置收集流程的异步时序问题
- `parseConfigFile` 改为返回 Promise`handleFiles` 和拖拽 `drop` 处理器改为 `async/await`,确保 config.json 解析完成后再执行检查,消息显示顺序正确
- `sendUserMessage` 修复变量名错误(`pendingConfigKeys``pendingConfigKey`),修复了配置收集卡死的问题
- 拆分 `checkAutoStart` 为两个函数:`checkAutoStart` 仅做静默检查(由 `syncFiles` 轮询调用),`promptMissingConfig` 负责配置提示(由用户主动上传完成后调用),避免轮询提前触发配置提示导致消息乱序
- 移除所有聊天消息的删除逻辑,聊天窗口保留完整历史(欢迎消息、文件通知、配置交互、处理结果均不删除)

View File

@@ -1,16 +1,241 @@
body { background: #f5f7fa; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
body {
background: #f5f7fa;
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
}
.main-container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.upload-zone {
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
cursor: pointer; transition: all .2s; background: #fff; height: 100px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
.upload-zone .icon {
width: 48px;
height: 48px;
margin-bottom: 8px;
}
.upload-zone .icon img {
width: 100%;
height: 100%;
}
/* 可折叠上传面板(在文档流中,向上推挤聊天窗口) */
.upload-panel {
margin-top: 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 12px;
overflow: hidden;
transition: box-shadow 0.2s;
}
.upload-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
cursor: pointer;
user-select: none;
font-size: 14px;
font-weight: 600;
color: #333;
background: #fafbfc;
transition: background 0.2s;
}
.upload-panel-header:hover {
background: #f0f2ff;
}
.upload-panel-toggle {
font-size: 10px;
color: #999;
transition: transform 0.3s;
}
.upload-panel-toggle.collapsed {
transform: rotate(180deg);
}
.upload-panel-body {
padding: 16px;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
max-height: 400px;
opacity: 1;
overflow: hidden;
}
.upload-panel-body.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.log-container .empty { color: #666; font-style: italic; }
.chat-container {
background: #fff;
border-radius: 12px;
padding: 16px;
flex: 1 1 0;
min-height: 300px;
overflow: hidden;
border: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
gap: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
scroll-behavior: smooth;
padding-bottom: 8px;
}
.chat-input-area {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e8e8e8;
flex-shrink: 0;
}
.chat-input-area input {
flex: 1;
border: 1px solid #ddd;
border-radius: 20px;
padding: 8px 16px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.chat-input-area input:focus {
border-color: #667eea;
}
.chat-input-area button {
border-radius: 20px;
padding: 8px 20px;
font-size: 13px;
white-space: nowrap;
}
.chat-message {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
animation: fadeIn 0.3s ease;
}
.chat-message.user {
flex-direction: row-reverse;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.ai-avatar img {
width: 60%;
height: 60%;
}
.chat-bubble {
max-width: 85%;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.chat-bubble.system {
background: #f0f2f5;
color: #333;
border-bottom-left-radius: 4px;
}
.chat-bubble.processing {
background: #e8f0fe;
color: #1a73e8;
border-bottom-left-radius: 4px;
}
.chat-bubble.success {
background: #e6f4ea;
color: #137333;
border-bottom-left-radius: 4px;
}
.chat-bubble.error {
background: #fce8e6;
color: #c5221f;
border-bottom-left-radius: 4px;
}
.chat-bubble.done {
background: #e6f4ea;
color: #137333;
border-bottom-left-radius: 4px;
font-weight: 500;
}
.chat-bubble.user {
background: #667eea;
color: #fff;
border-bottom-right-radius: 4px;
}
/* 打字指示器 */
.typing-indicator {
display: inline-flex;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #667eea;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
.btn-process { font-size: 17px; padding: 10px 40px; }
.status-badge { font-size: 13px; }
@@ -21,4 +246,260 @@ body { background: #f5f7fa; }
.table-editable td { vertical-align: middle; }
.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; }
.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; }
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
/* ================================================================ */
/* 文件消息样式 */
/* ================================================================ */
.file-bubble {
background: #fafbfc;
border: 1px solid #e8e8e8;
border-bottom-left-radius: 4px;
transition: border-color 0.3s, background 0.3s;
}
.file-bubble.file-processing {
background: #f0f4ff;
border-color: #667eea;
}
.file-bubble.file-done {
background: #f0fff4;
border-color: #28a745;
}
.file-bubble.file-error {
background: #fff5f5;
border-color: #c5221f;
}
.file-name-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.file-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.file-icon img {
width: 100%;
height: 100%;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: #333;
word-break: break-all;
}
.file-status {
display: inline-flex;
align-items: center;
margin-left: 4px;
}
.file-status-icon {
font-size: 14px;
}
/* 文件消息的三点动画(与文件名同一行) */
.file-typing-indicator {
display: inline-flex;
gap: 3px;
align-items: center;
margin-left: 2px;
}
.file-typing-indicator span {
width: 5px;
height: 5px;
border-radius: 50%;
background: #667eea;
animation: fileTyping 1.4s infinite ease-in-out;
}
.file-typing-indicator span:nth-child(1) { animation-delay: 0s; }
.file-typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.file-typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes fileTyping {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* 文件详情区域 */
.file-detail {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #ddd;
font-size: 12px;
line-height: 1.8;
color: #555;
}
.detail-label {
font-weight: 600;
color: #667eea;
}
.detail-value {
color: #333;
}
.detail-value.error-text {
color: #c5221f;
}
/* ================================================================ */
/* LLM 流式聊天气泡样式 */
/* ================================================================ */
.llm-stream-bubble {
background: #f0f4ff;
border: 1px solid #667eea;
border-bottom-left-radius: 4px;
}
.llm-stream-bubble.done {
background: #e6f4ea;
border-color: #28a745;
}
.llm-stream-bubble.error {
background: #fce8e6;
border-color: #c5221f;
}
.llm-stream-label {
font-size: 12px;
font-weight: 600;
color: #667eea;
margin-bottom: 6px;
}
.llm-stream-text {
font-size: 13px;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
/* 可折叠的思考过程区域 */
.llm-reasoning-section {
margin: 6px 0;
padding: 4px 0;
border-top: 1px dashed #ddd;
border-bottom: 1px dashed #ddd;
}
.llm-reasoning-summary {
font-size: 12px;
font-weight: 600;
color: #888;
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 4px;
}
.llm-reasoning-summary::-webkit-details-marker {
display: none;
}
.llm-reasoning-summary::before {
content: '▶';
font-size: 8px;
display: inline-block;
transition: transform 0.2s;
}
.llm-reasoning-section[open] > .llm-reasoning-summary::before {
transform: rotate(90deg);
}
.llm-reasoning-text {
font-size: 12px;
line-height: 1.5;
color: #777;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
padding: 4px 8px;
margin-top: 4px;
background: #fafbfc;
border-radius: 4px;
}
/* ================================================================ */
/* Agent 请求面板样式 */
/* ================================================================ */
.agent-request-panel {
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border: 1px solid #ffc107;
border-radius: 12px;
padding: 14px 16px;
margin: 8px 0;
animation: fadeIn 0.3s ease;
}
.agent-request-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #856404;
margin-bottom: 10px;
}
.agent-request-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.agent-request-icon img {
width: 100%;
height: 100%;
}
.agent-request-materials {
font-size: 13px;
color: #856404;
margin-bottom: 12px;
line-height: 1.6;
}
.material-tag {
display: inline-block;
background: #ffc107;
color: #000;
border-radius: 4px;
padding: 2px 10px;
margin: 2px 4px 2px 0;
font-size: 12px;
font-weight: 500;
}
.agent-request-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.agent-request-actions .btn {
font-size: 13px;
padding: 6px 16px;
}

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319741857" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1691" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M690.185 275.766c88.365 0 160 71.634 160 160v239.488c0 88.365-71.635 160-160 160H339.766c-88.366 0-160-71.635-160-160V435.766c0-88.366 71.634-160 160-160h350.419z m-350.419 64c-53.02 0-96 42.98-96 96v239.488c0 53.019 42.98 96 96 96h350.419c53.019 0 96-42.981 96-96V435.766c0-53.02-42.981-96-96-96H339.766z m56.28 120.882c52.056 0.001 94.256 42.201 94.256 94.257 0 52.057-42.2 94.256-94.256 94.256s-94.257-42.199-94.257-94.256c0-52.056 42.2-94.257 94.257-94.257z m240.348 0c52.056 0.001 94.255 42.201 94.255 94.257 0 52.057-42.199 94.256-94.255 94.256-52.057 0-94.257-42.199-94.257-94.256 0-52.056 42.2-94.257 94.257-94.257zM128 481.562c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m769.113 0c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m-501.067 43.086c-16.71 0-30.257 13.547-30.257 30.257s13.547 30.256 30.257 30.256 30.256-13.546 30.256-30.256-13.546-30.256-30.256-30.257z m240.348 0c-16.711 0-30.257 13.547-30.257 30.257s13.546 30.256 30.257 30.256c16.71 0 30.255-13.546 30.255-30.256s-13.545-30.256-30.255-30.257zM556.931 192c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32h-83.908c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32h83.908z" fill="#1296db" p-id="1692"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319878861" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10502" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M716.8 704h-51.2v-25.6h38.4V140.8H320v38.4h-25.6v-51.2a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8zM806.4 614.4h-51.2v-25.6h38.4V230.4h25.6v371.2a12.8 12.8 0 0 1-12.8 12.8zM793.6 179.2h25.6v25.6h-25.6z" fill="#1296db" p-id="10503"></path><path d="M819.2 153.6h-25.6V51.2H409.6v38.4h-25.6V38.4a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v115.2zM627.2 793.6H217.6a12.8 12.8 0 0 1-12.8-12.8V217.6a12.8 12.8 0 0 1 12.8-12.8h243.2v25.6H230.4v537.6h384V230.4h-76.8v-25.6h89.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8z" fill="#1296db" p-id="10504"></path><path d="M486.4 204.8h25.6v25.6h-25.6zM268.8 448h307.2v25.6H268.8zM268.8 524.8h307.2v25.6H268.8zM512 601.6h64v25.6h-64zM460.8 601.6h25.6v25.6h-25.6zM268.8 601.6h166.4v25.6H268.8zM268.8 678.4h307.2v25.6H268.8zM268.8 294.4h307.2v25.6H268.8zM409.6 371.2h166.4v25.6H409.6zM358.4 371.2h25.6v25.6h-25.6zM268.8 371.2h64v25.6h-64z" fill="#1296db" p-id="10505"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319817389" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6095" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M912 208H427.872l-50.368-94.176A63.936 63.936 0 0 0 321.056 80H112c-35.296 0-64 28.704-64 64v736c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-608c0-35.296-28.704-64-64-64z m-800-64h209.056l68.448 128H912v97.984c-0.416 0-0.8-0.128-1.216-0.128H113.248c-0.416 0-0.8 0.128-1.248 0.128V144z m0 736v-96l1.248-350.144 798.752 1.216V784h0.064v96H112z" fill="#1296db" p-id="6096"></path></svg>

After

Width:  |  Height:  |  Size: 727 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319921047" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14940" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M746.6496 198.4a129.6384 129.6384 0 0 1 129.536 124.0064l0.1024 5.632v367.9232a129.6384 129.6384 0 0 1-124.0064 129.536l-5.632 0.1024H306.9184a129.6384 129.6384 0 0 1-129.536-124.0064l-0.128-5.632v-57.9328c0-7.4752 2.6368-14.6944 7.3728-20.4032l2.5344-2.7136 131.84-126.0544a32 32 0 0 1 35.4304-5.9648l3.1488 1.664 116.0704 69.5808 74.7008-66.0992a32 32 0 0 1 35.072-4.864l3.072 1.6896 177.1008 110.6688a32 32 0 0 1-30.848 55.9616l-3.072-1.6896-156.8256-97.9968-74.3424 65.792a32 32 0 0 1-34.6112 5.12l-3.072-1.6384-115.2512-69.0944-104.32 99.712v44.2624a65.6384 65.6384 0 0 0 61.4912 65.5104l4.1728 0.128h439.7312a65.6384 65.6384 0 0 0 65.5104-61.4912l0.128-4.1472V328.0384a65.6384 65.6384 0 0 0-61.4912-65.5104l-4.1472-0.128H306.9184a65.6384 65.6384 0 0 0-65.536 61.4912l-0.128 4.1472v134.3488a32 32 0 0 1-63.8208 3.2768l-0.1792-3.2768v-134.3488a129.6384 129.6384 0 0 1 124.032-129.536l5.632-0.1024h439.7312z" fill="#FB553C" p-id="14941"></path><path d="M692.5312 398.3104m-47.4112 0a47.4112 47.4112 0 1 0 94.8224 0 47.4112 47.4112 0 1 0-94.8224 0Z" fill="#FB553C" p-id="14942"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319971806" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19425" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M820.409449 797.228346q0 25.19685-10.07874 46.866142t-27.716535 38.299213-41.322835 26.204724-50.897638 9.574803l-357.795276 0q-27.212598 0-50.897638-9.574803t-41.322835-26.204724-27.716535-38.299213-10.07874-46.866142l0-675.275591q0-25.19685 10.07874-47.370079t27.716535-38.80315 41.322835-26.204724 50.897638-9.574803l357.795276 0q27.212598 0 50.897638 9.574803t41.322835 26.204724 27.716535 38.80315 10.07874 47.370079l0 675.275591zM738.771654 170.330709l-455.559055 0 0 577.511811 455.559055 0 0-577.511811zM510.992126 776.062992q-21.165354 0-36.787402 15.11811t-15.622047 37.291339q0 21.165354 15.622047 36.787402t36.787402 15.622047q22.173228 0 37.291339-15.622047t15.11811-36.787402q0-22.173228-15.11811-37.291339t-37.291339-15.11811zM591.622047 84.661417q0-8.062992-5.03937-12.598425t-11.086614-4.535433l-128 0q-5.03937 0-10.582677 4.535433t-5.543307 12.598425 5.03937 12.598425 11.086614 4.535433l128 0q6.047244 0 11.086614-4.535433t5.03937-12.598425z" p-id="19426" fill="#1296db"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

186
src/web/static/js/README.md Normal file
View File

@@ -0,0 +1,186 @@
---
last_reviewed: 2026-06-16
---
# src/web/static/js — 前端逻辑模块
## 概述
财务报销自动化系统的前端入口脚本,按功能拆分为多个模块文件,通过 `App` 共享状态对象通信。采用原生 JavaScript 编写,通过 HTTP 请求与 Flask 后端通信,使用 SSE 接收处理进度。
## 文件结构
| 文件 | 职责 |
|------|------|
| `state.js` | 全局状态管理 (`App` 对象,含处理状态机) |
| `utils.js` | 工具函数 (HTML 转义、会话管理) |
| `chat.js` | 聊天窗口消息管理含文件消息、处理进度、三点动画、LLM 流式气泡) |
| `upload.js` | 文件上传含拖拽、config.json 解析) |
| `config.js` | 配置收集与校验(基于状态机的自动触发) |
| `process.js` | 发票处理流程与 SSE 监听(含 file_progress、llm_stream 事件处理) |
| `agent.js` | Agent 交互(含 supplement 流程、强制提交、用户文字补充、自动提交) |
| `sync.js` | 移动端同步轮询(状态感知模式) |
| `index.js` | 入口初始化 |
## 加载顺序
```
state.js → utils.js → chat.js → upload.js → config.js → process.js → sync.js → index.js
```
## 模块依赖关系
```
state.js (无依赖)
├── utils.js ────────────────┐
│ │
├── chat.js ─────────────────┐ │
│ │ │
├── upload.js ──────────────┐ │ │
│ │ │ │
├── config.js ─────────────┐ │ │ │
│ │ │ │ │
├── process.js ───────────┐ │ │ │ │
│ │ │ │ │ │
├── sync.js ──────────┐ │ │ │ │ │
│ │ │ │ │ │ │
└── index.js ────────┘ │ │ │ │ │ │
```
## 处理状态机
`App.processState` 控制何时触发自动处理,避免重复执行:
```mermaid
stateDiagram-v2
[*] --> idle
idle --> processing: 配置完整 + 有文件
processing --> done: 处理完成
processing --> awaiting_supplement: Agent 要求补充材料
done --> idle: 有新文件 / forceStart = true
done --> done: 无新文件(保持,不重复)
awaiting_supplement --> idle: 用户补充文件(只上传新文件)
idle --> processing: idle + newFilenames > 0走 supplement 流程)
idle --> awaiting_files: AI 判断文件不足(未来功能)
awaiting_files --> idle: 用户补充文件
awaiting_files --> processing: 用户说"直接开始" (forceStart = true)
```
状态字段说明:
| 字段 | 类型 | 说明 |
|------|------|------|
| `processState` | string | 当前处理状态idle / processing / done / awaiting_files / awaiting_supplement |
| `lastProcessedFileCount` | number | 上次处理时的文件数,用于检测是否有新文件 |
| `forceStart` | boolean | 用户强制开始标记(兼容"文件不足也要处理"的场景) |
| `newFilenames` | array | 待补充上传的文件名列表,用于区分首次处理和补充处理 |
## 核心流程
### 1. 文件上传
```
用户上传文件 → addFileMessage() → 注册到 App.fileMessageMap → promptMissingConfig()
→ processState 为 done 时重置为 idle支持增量上传后重新处理
```
### 2. 配置收集
```
promptMissingConfig() → 检查 CONFIG_FIELDS → 有缺失则提示 → 用户输入 → sendUserMessage() → 递归直到完整
```
### 3. 自动触发检查 (checkAutoStart)
```
checkAutoStart() → 状态机判断:
- processing → 跳过
- done + 无新文件 → 跳过(修复重复执行问题)
- done + 有新文件 → 走 supplement 流程(不再走 startProcess
- idle + newFilenames > 0 → 走 supplement 流程
- idle + 配置完整 + 有文件 → 走 startProcess首次处理
- awaiting_files + forceStart → 触发
```
### 4. 发票处理(首次)
```
startProcess() → ensureSession → 上传全部文件 → /api/agent/process → SSE 监听
→ 收到 file_progress(processing) → setFileProcessing()(三点动画)
→ 收到 file_progress(done) → setFileDone()(展示提取摘要)
→ 收到 file_progress(cached) → setFileCached()
→ 收到 file_progress(error) → setFileError()
→ 收到 llm_stream(start) → handleLLMStream() 创建流式气泡
→ 收到 llm_stream(chunk) → handleLLMStream() 逐字追加 AI 输出
→ 收到 llm_stream(end) → handleLLMStream() 关闭流式气泡
→ 收到 done → 设置 processState = 'done' + 记录文件数 → 显示汇总结果
```
### 5. 补充文件处理(新增)
当 Agent 返回 `awaiting_supplement` 状态时,用户补充文件的流程:
```
用户上传补充文件
→ handleFiles() 检测 processState === 'awaiting_supplement'
→ _uploadNewFiles() 只上传新文件(不再重新上传所有文件)
→ App.newFilenames = [新文件名列表]
→ App.processState = 'idle'
→ syncFiles() 3 秒后触发 checkAutoStart()
→ checkAutoStart() 检测到 idle + newFilenames > 0
→ handleSupplementUpload(filenames)
→ POST /api/agent/supplement/(后端会重新 extract_invoices旧文件走缓存
→ 新文件走 LLM 提取,旧文件命中 JSON 缓存直接返回
→ run_agent_round() 重新分析
→ SSE 返回 done → 根据结果进入 awaiting_supplement 或 done 状态
```
**关键区别**:补充文件走 `/api/agent/supplement/` 而非 `/api/agent/process/`,后者在已有 agent_session 时会跳过提取阶段。
## 数据流
### 配置数据流
```
config.json → parseConfigFile → App.sessionConfig → startProcess → /api/agent/process
```
### 发票数据流(首次处理)
```
发票文件 → startProcess → /api/upload → /api/agent/process → SSE file_events.log → 文件消息实时更新
```
### 发票数据流(补充文件)
```
新发票文件 → _uploadNewFiles() → /api/upload → /api/agent/supplement
→ extract_invoices()(旧文件走缓存,新文件走 LLM
→ run_agent_round() → SSE file_events.log → 文件消息实时更新
```
### 文件消息状态机
```
addFileMessage(filename)
→ file_progress(processing) → 三点动画(同一行)
→ file_progress(done/cached/error) → 详情展开 + 状态图标
```
## 变更记录
- **2026-06-16**:修复补充文件重复上传问题 — 新增 `newFilenames` 状态字段,补充文件时只上传新文件并走 `/api/agent/supplement/` 接口。`upload.js` 新增 `_uploadNewFiles()` 辅助函数。`config.js``checkAutoStart()``done``idle` 状态下有 `newFilenames` 时走 supplement 流程而非 `startProcess()`
- **2026-06-16**:新增 LLM 流式思考展示 — `chat.js` 新增 `handleLLMStream` 状态机start/chunk/end/error 四阶段),`process.js` 的 SSE 监听增加 `llm_stream` 事件分支AI 思考过程以聊天气泡逐字展示
- **2026-06-16**:引入处理状态机 — 新增 `processState` / `lastProcessedFileCount` / `forceStart` 状态字段,修复 `syncFiles` 轮询每 3 秒重复触发处理的 bug。`checkAutoStart` 改为基于状态机判断done 状态下只有检测到新文件才重新触发。为未来 AI 判断文件是否充足预留了 `awaiting_files` 状态和 `forceStart` 标记
- **2026-06-12**:上传区域可折叠 — 新增 `toggleUploadPanel()` 函数,配合 CSS `max-height` 动画实现面板收起/展开
- **2026-06-12**:文件消息框对齐到用户侧 — `addFileMessage` 的 wrapper 新增 `user` 类,上传的文件消息显示在右侧(用户侧),与 agent 消息区分
- **2026-06-12**:配置完成增加互动提示 — `promptMissingConfig` 在配置完整时发送系统消息告知用户即将开始处理发票
- **2026-06-14**:新增文件进度实时反馈 — 后端 extractor 在每处理一个文件时产生 SSE 进度事件,前端根据文件名定位 DOM 并更新状态(处理中三点动画、完成摘要展示、缓存/错误状态)
- **2026-06-14**:移除发票编辑和财务提交流程 — 删除 table.js、financial.js 及相关 HTML 区域,后续开发不再支持手动编辑发票信息
- **2026-06-12**:模块拆分重构 — 将 index.js (625 行) 拆分为 10 个独立模块,引入 `App` 共享状态对象
- **2026-06-12**:修复配置收集流程的异步时序问题 — parseConfigFile 改为 Promise拆分 checkAutoStart 为静默检查/配置提示两个函数

439
src/web/static/js/agent.js Normal file
View File

@@ -0,0 +1,439 @@
/**
* Agent 交互模块
*/
import { App } from './state.js';
import { escapeHtml } from './utils.js';
import { addChatMessage, showStatus } from './chat.js';
// ================================================================
// Agent 事件处理
// ================================================================
/**
* 处理 Agent SSE 事件
*
* @param {Object} msg - SSE 传来的 agent 事件对象
*/
export function handleAgentEvent(msg) {
switch (msg.type) {
case 'agent_state_change':
_handleAgentStateChange(msg);
break;
case 'agent_request_supplement':
_handleAgentRequestSupplement(msg);
break;
case 'agent_ready':
_handleAgentReady(msg);
break;
case 'agent_error':
_handleAgentError(msg);
break;
case 'agent_supplement_received':
_handleSupplementReceived(msg);
break;
case 'agent_force_submit':
_handleForceSubmit(msg);
break;
}
}
/**
* 状态变更通知 — 使用瞬态消息,状态变化时更新而非追加
*/
function _handleAgentStateChange(msg) {
const stateLabels = {
'extracting': '正在分析文件...',
'validating': '正在校验信息完整性...',
'awaiting_supplement': '等待补充材料',
'ready': '信息完整,可以提交',
'submitting': '正在提交...',
};
const label = stateLabels[msg.state] || msg.message || '处理中...';
if (msg.state === 'ready') {
showStatus(label, 'done');
} else {
showStatus(label);
}
}
/**
* 请求补充材料 — 仅更新瞬态状态,持久提示消息由 done 事件分支负责
*/
export function _handleAgentRequestSupplement(msg) {
const suggestion = msg.suggestion || '请补充上传相关材料';
showStatus(suggestion);
}
/**
* 信息完整 — 已由 process.js 的 done 处理覆盖,此处不再重复显示
*/
function _handleAgentReady(msg) {
// agent_ready 事件的信息展示统一由 process.js done 分支处理
}
/**
* 自动触发财务系统提交(由 done 事件调用)
*/
export async function handleAutoSubmit() {
if (!App.sessionId) {
addChatMessage('请先上传文件并处理', 'error');
return;
}
if (App.agentEventSource) {
App.agentEventSource.close();
App.agentEventSource = null;
}
App.isProcessing = true;
App.processState = 'submitting';
try {
showStatus('正在自动提交到财务系统...');
const response = await fetch(`/api/submit-financial/${App.sessionId}`, {
method: 'POST',
});
const result = await response.json();
if (result.status === 'started') {
const es = new EventSource(`/api/logs/${App.sessionId}`);
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
App.isProcessing = false;
App.processState = 'done';
if (msg.result.submit_ok) {
addChatMessage('提交完成!', 'done');
} else {
addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
}
}
} catch (err) {
// 普通日志行
}
});
es.onerror = () => {
es.close();
App.isProcessing = false;
App.processState = 'done';
addChatMessage('连接中断', 'error');
};
} else if (result.error) {
App.isProcessing = false;
App.processState = 'done';
addChatMessage(`提交失败:${result.error}`, 'error');
}
} catch (e) {
App.isProcessing = false;
App.processState = 'done';
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
/**
* Agent 错误
*/
function _handleAgentError(msg) {
addChatMessage(msg.message || 'Agent 处理出错', 'error');
App.isProcessing = false;
App.processState = 'done';
}
/**
* 收到补充文件
*/
function _handleSupplementReceived(msg) {
const fileNames = (msg.files || []).join('、');
addChatMessage(`已收到补充文件:${fileNames}`, 'done');
}
/**
* 强制提交
*/
function _handleForceSubmit(msg) {
showStatus('已跳过校验,开始提交到财务系统');
}
// ================================================================
// Agent 请求面板
// ================================================================
/**
* 显示 Agent 请求补充材料的 UI
*/
export function showAgentRequest(data) {
const existing = document.getElementById('agent-request-panel');
if (existing) existing.remove();
const chatMessages = document.getElementById('chat-messages');
if (!chatMessages) return;
const panel = document.createElement('div');
panel.id = 'agent-request-panel';
panel.className = 'agent-request-panel';
let materialsHtml = '';
if (data.missing_materials && data.missing_materials.length > 0) {
materialsHtml = `
<div class="agent-request-materials">
<strong>需要补充:</strong>
${data.missing_materials.map(m => `<span class="material-tag">${escapeHtml(m)}</span>`).join(' ')}
</div>
`;
}
panel.innerHTML = `
<div class="agent-request-header">
<span class="agent-request-icon"><img src="/static/icon/file.svg" alt=""></span>
<span>第 ${data.round || 1} 轮分析 - 信息不完整</span>
</div>
${materialsHtml}
<div class="agent-request-actions">
<button class="btn btn-sm btn-primary" onclick="document.getElementById('file-input').click()">
上传补充材料
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="handleForceSubmit()">
直接提交
</button>
</div>
`;
chatMessages.appendChild(panel);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
/**
* 移除 Agent 请求面板
*/
export function hideAgentRequest() {
const panel = document.getElementById('agent-request-panel');
if (panel) panel.remove();
}
// ================================================================
// 用户文字补充
// ================================================================
/**
* 处理用户通过输入框补充的文字信息
*
* @param {string} text - 用户输入的文字
*/
export async function handleUserSupplement(text) {
if (!App.sessionId) return;
try {
showStatus('正在解析补充信息...');
const response = await fetch(`/api/agent/user-supplement/${App.sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text }),
});
const result = await response.json();
if (result.status === 'started') {
showStatus('补充信息已收到,正在重新分析...');
const es = new EventSource(`/api/logs/${App.sessionId}`);
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type && msg.type.startsWith('agent_')) {
handleAgentEvent(msg);
return;
}
if (msg.type === 'done') {
es.close();
App.isProcessing = false;
if (msg.result.waiting_for_supplement) {
App.processState = 'awaiting_supplement';
_handleAgentRequestSupplement(msg.result);
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
} else {
App.processState = 'done';
if (msg.result.ok) {
if (msg.result.submit_ok !== false) {
addChatMessage('信息完整,已自动提交到财务系统', 'done');
} else {
addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
}
} else {
addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error');
}
}
}
} catch (err) {
// 普通日志
}
});
es.onerror = () => {
es.close();
App.isProcessing = false;
App.processState = 'done';
addChatMessage('连接中断', 'error');
};
} else if (result.error) {
addChatMessage(`处理失败:${result.error}`, 'error');
}
} catch (e) {
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
// ================================================================
// 用户输入处理
// ================================================================
/**
* 处理强制提交
*/
export async function handleForceSubmit() {
if (!App.sessionId) {
addChatMessage('请先上传文件并处理', 'error');
return;
}
if (App.agentEventSource) {
App.agentEventSource.close();
App.agentEventSource = null;
}
App.forceSubmitting = true;
try {
showStatus('正在跳过校验并提交...');
hideAgentRequest();
const response = await fetch(`/api/agent/force-submit/${App.sessionId}`, {
method: 'POST',
});
const result = await response.json();
if (result.status === 'started') {
showStatus('已跳过校验,开始提交到财务系统');
App.isProcessing = true;
App.processState = 'submitting';
const es = new EventSource(`/api/logs/${App.sessionId}`);
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
App.isProcessing = false;
App.processState = 'done';
App.forceSubmitting = false;
App.lastProcessedFileCount = App.allFiles.length;
if (msg.result.ok) {
addChatMessage('提交完成!', 'done');
} else {
addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
}
}
} catch (err) {
// 普通日志行
}
});
es.onerror = () => {
es.close();
App.isProcessing = false;
App.processState = 'done';
App.forceSubmitting = false;
App.lastProcessedFileCount = App.allFiles.length;
addChatMessage('连接中断', 'error');
};
}
} catch (e) {
App.forceSubmitting = false;
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
// ================================================================
// 补充文件处理
// ================================================================
/**
* 处理补充文件上传完成后的重新分析
*
* @param {Array<string>} filenames - 新上传的文件名列表
*/
export async function handleSupplementUpload(filenames) {
if (!App.sessionId) return;
try {
showStatus('收到补充文件,正在重新分析...');
hideAgentRequest();
const response = await fetch(`/api/agent/supplement/${App.sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: filenames }),
});
const result = await response.json();
if (result.status === 'started') {
const es = new EventSource(`/api/logs/${App.sessionId}`);
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type && msg.type.startsWith('agent_')) {
handleAgentEvent(msg);
return;
}
if (msg.type === 'done') {
es.close();
App.isProcessing = false;
if (msg.result.waiting_for_supplement) {
App.processState = 'awaiting_supplement';
showStatus('信息不完整,请补充上传相关材料');
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
} else {
App.processState = 'done';
if (msg.result.ok) {
// 后端已自动提交,根据 submit_ok 显示结果
if (msg.result.submit_ok !== false) {
addChatMessage('信息完整,已自动提交到财务系统', 'done');
} else {
addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
}
} else {
addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error');
}
}
}
} catch (err) {
// 普通日志
}
});
es.onerror = () => {
es.close();
App.isProcessing = false;
App.processState = 'done';
addChatMessage('连接中断', 'error');
};
}
} catch (e) {
addChatMessage(`请求失败:${e.message}`, 'error');
}
}

28
src/web/static/js/chat.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* 聊天窗口模块入口
*
* 从 chat/ 子模块重新导出所有公开函数,保持向后兼容。
* 外部模块通过 import 方式使用HTML 中的 script 标签直接加载本文件。
*/
export {
addChatMessage,
addTypingIndicator,
removeTypingIndicator,
addFileMessage,
setFileProcessing,
setFileDone,
setFileCached,
setFileError,
} from './chat/persistent.js';
// 瞬态消息
export {
showStatus,
clearStatus,
} from './chat/ephemeral.js';
// LLM 流式消息
export {
handleLLMStream,
} from './chat/stream.js';

View File

@@ -0,0 +1,198 @@
# chat/ 模块说明
## 目录
- [架构概览](#架构概览)
- [模块职责](#模块职责)
- [消息类型](#消息类型)
- [数据流](#数据流)
- [状态管理](#状态管理)
- [注意事项](#注意事项)
---
## 架构概览
```
chat/
├── renderer.js # 底层 DOM 渲染工具
├── persistent.js # 持久消息(永久保留在聊天历史中)
├── ephemeral.js # 瞬态消息(只显示最新一条,新状态覆盖旧状态)
└── stream.js # LLM 流式消息(处理中显示思考过程,结束后固化)
chat.js # 入口文件,统一 re-export 所有公开函数
```
设计原则:将**持久消息**和**瞬态消息**分离,避免聊天历史被中间状态消息堆积。
---
## 模块职责
### renderer.js
最底层渲染工具,不维护任何状态。提供:
- `getChatMessages()` — 获取 `#chat-messages` 容器
- `scrollToBottom()` — 滚动到底部
- `createChatBubble(text, type, isUser)` — 创建消息气泡 DOM
- `createSystemBubble(text, type)` — 创建系统气泡 DOM
- `appendMessage(wrapper)` — 将消息追加到容器并滚动
### persistent.js
管理永久保留在聊天历史中的消息:
- `addChatMessage(text, type, isUser)` — 添加普通聊天消息
- `addTypingIndicator()` / `removeTypingIndicator()` — 加载三点动画
- `addFileMessage(filename)` — 添加文件上传消息
- `setFileProcessing(filename)` — 文件处理中状态
- `setFileDone(filename, summary)` — 文件处理完成,展开提取摘要
- `setFileCached(filename)` — 文件使用缓存
- `setFileError(filename, errorMsg)` — 文件处理错误
依赖 `App.fileMessageMap`(定义在 `state.js`)维护文件名到 DOM 元素的映射。
### ephemeral.js
管理瞬态状态消息,同一时刻只显示最新一条:
- `showStatus(text, type)` — 显示/更新状态消息
- `clearStatus()` — 清除当前状态消息
内部维护 `ephemeralState` 对象记录当前活跃的状态气泡引用。重复调用 `showStatus` 时直接更新已有气泡的文本和样式,不创建新 DOM。
### stream.js
管理 LLM 流式响应的气泡生命周期:
- `handleLLMStream(msg)` — 根据 SSE 事件阶段分发处理
支持的阶段:
| phase | 说明 |
|-------|------|
| `start` | 创建流式气泡,初始化思考过程区域(默认展开) |
| `reasoning` | 追加思考过程文本 |
| `chunk` | 追加正式回复文本 |
| `end` | 关闭气泡,切换为 `done` 样式,折叠思考过程区域,气泡保留在历史中 |
| `error` | 切换为 `error` 样式,显示错误信息 |
内部维护 `llmStreamState` 对象记录当前活跃的流式气泡引用。
---
## 消息类型
### 持久消息
永久保留在聊天历史中,不会被自动清除:
- 用户输入的文字
- 文件上传记录及其处理状态
- LLM 流式响应的最终结果(`end` 阶段后固化)
- 系统通知(如"信息完整,可以提交"
- 错误消息
调用 `addChatMessage()``addFileMessage()` 创建。
### 瞬态消息
只显示最新一条,新状态覆盖旧状态:
- "正在分析文件..."
- "正在校验信息完整性..."
- "请输入登录账号:"
- "配置信息已完整,开始处理发票……"
- "收到补充文件,正在重新分析..."
调用 `showStatus()` 创建/更新,调用 `clearStatus()` 清除。
---
## 数据流
```
外部模块 (agent.js / config.js / process.js / upload.js / sync.js)
├── import { addChatMessage, addFileMessage, ... } from './chat.js'
├── import { showStatus, clearStatus } from './chat.js'
└── import { handleLLMStream } from './chat.js'
chat.js (re-export)
├── → chat/persistent.js ──→ chat/renderer.js
├── → chat/ephemeral.js ──→ chat/renderer.js
└── → chat/stream.js ──→ chat/renderer.js
```
- 外部模块统一从 `chat.js` 导入函数
- `chat.js` 只做 re-export不引入循环依赖
- 三个子模块通过 `renderer.js` 共享底层 DOM 操作
- `persistent.js` 额外依赖 `state.js``App.fileMessageMap`
---
## 状态管理
### ephemeralState (ephemeral.js)
```js
{
wrapper: HTMLElement | null, // 状态消息的 wrapper 元素
bubble: HTMLElement | null, // 状态气泡元素
}
```
- 初始为 `null`
- `showStatus` 首次调用时创建并记录引用
- 后续调用直接更新 `bubble.textContent``bubble.className`
- `clearStatus` 时移除 DOM 并重置为 `null`
### llmStreamState (stream.js)
```js
{
wrapper: HTMLElement | null, // 流式消息 wrapper
bubble: HTMLElement | null, // 流式气泡
textContent: HTMLElement | null, // 正式文本容器
accumulated: string, // 累积的正式文本
reasoningContent: HTMLElement | null, // 思考过程容器
reasoningAccumulated: string, // 累积的思考文本
}
```
- `start` 阶段创建并记录引用
- `chunk` / `reasoning` 阶段追加文本
- `end` / `error` 阶段重置为 `null`DOM 保留在历史中)
### App.fileMessageMap (state.js)
```js
Map<string, { wrapper, bubble, nameRow, detailRow }>
```
- 文件名 → DOM 元素映射
- `addFileMessage` 创建时写入
- `setFileProcessing` / `setFileDone` / `setFileCached` / `setFileError` 读取并更新对应文件的状态
---
## 注意事项
1. **不要直接操作 `#chat-messages` 容器**。所有消息创建都通过本模块的 API 进行。
2. **瞬态消息和持久消息不要混用**。中间处理状态用 `showStatus`,最终结果用 `addChatMessage`
3. **`showStatus` 不需要手动清除**。调用 `showStatus` 显示新状态时会自动覆盖旧状态;在处理流程结束时,后续的消息或状态会自然覆盖。
4. **LLM 流式气泡在 `end` 阶段后变为持久消息**。不需要额外调用 `addChatMessage` 来保留结果。
5. **SSE 事件必须包含 `start` 和 `end` 阶段**。缺少 `start` 会导致气泡未创建,缺少 `end` 会导致气泡一直处于处理中状态。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`
6. **`handleLLMStream` 不处理普通日志行**。SSE 的 `message` 事件中,只有 `type === 'llm_stream'` 的事件才会被转发到此模块。
7. **文件消息的 DOM 生命周期由 `fileMessageMap` 管理**。文件处理完成后,摘要信息会展开显示在文件气泡下方。
8. **所有模块通过 `chat.js` 统一导入**,不要直接从 `chat/` 子目录导入(外部模块层面)。子模块之间的内部导入不受此限制。

View File

@@ -0,0 +1,62 @@
/**
* 瞬态消息管理器
*
* 瞬态消息只保留最新一条,新状态覆盖旧状态,不会堆积在聊天历史中。
*/
import { escapeHtml } from '../utils.js';
import { getChatMessages, scrollToBottom } from './renderer.js';
/**
* 当前活跃的瞬态消息 DOM 引用
*/
let ephemeralState = {
wrapper: null,
bubble: null,
};
/**
* 显示瞬态状态消息
*
* 同一时刻只保留一条状态消息,调用此函数会更新已有消息或创建新消息。
*
* @param {string} text - 状态文本
* @param {string} [type='processing'] - 消息类型
*/
export function showStatus(text, type) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
if (ephemeralState.bubble) {
ephemeralState.bubble.textContent = escapeHtml(text);
ephemeralState.bubble.className = 'chat-bubble ' + (type || 'processing');
scrollToBottom();
return;
}
const wrapper = document.createElement('div');
wrapper.className = 'chat-message';
wrapper.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble ${type || 'processing'}">${escapeHtml(text)}</div>
`;
chatMessages.appendChild(wrapper);
scrollToBottom();
ephemeralState = {
wrapper,
bubble: wrapper.querySelector('.chat-bubble'),
};
}
/**
* 清除瞬态状态消息
*/
export function clearStatus() {
if (ephemeralState.wrapper) {
ephemeralState.wrapper.remove();
}
ephemeralState = {
wrapper: null,
bubble: null,
};
}

View File

@@ -0,0 +1,246 @@
/**
* 持久消息管理器
*
* 持久消息永久保留在聊天历史中,包括:
* - 普通聊天消息
* - 文件上传消息及其状态
* - 加载指示器
*/
import { App } from '../state.js';
import { escapeHtml } from '../utils.js';
import { createChatBubble, appendMessage, getChatMessages, scrollToBottom } from './renderer.js';
// ================================================================
// 普通聊天消息
// ================================================================
/**
* 添加聊天消息
*
* @param {string} text - 消息内容
* @param {string} [type='processing'] - 消息类型
* @param {boolean} [isUser=false] - 是否为用户消息
*/
export function addChatMessage(text, type, isUser) {
const wrapper = createChatBubble(text, type, isUser);
appendMessage(wrapper);
}
// ================================================================
// 加载指示器
// ================================================================
/**
* 显示加载指示器(三点动画)
*/
export function addTypingIndicator() {
const chatMessages = getChatMessages();
if (!chatMessages) return;
const indicator = document.createElement('div');
indicator.className = 'chat-message';
indicator.id = 'typing-indicator';
indicator.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble processing">
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
`;
chatMessages.appendChild(indicator);
scrollToBottom();
}
/**
* 移除加载指示器
*/
export function removeTypingIndicator() {
const indicator = document.getElementById('typing-indicator');
if (indicator) indicator.remove();
}
// ================================================================
// 文件消息管理
// ================================================================
/**
* 添加文件消息(上传文件时调用)
*
* @param {string} filename - 文件名
*/
export function addFileMessage(filename) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
const wrapper = document.createElement('div');
wrapper.className = 'chat-message user file-message';
wrapper.dataset.file = filename;
const bubble = document.createElement('div');
bubble.className = 'chat-bubble file-bubble';
const nameRow = document.createElement('div');
nameRow.className = 'file-name-row';
nameRow.innerHTML = `
<span class="file-icon"><img src="/static/icon/file.svg" alt=""></span>
<span class="file-name">${escapeHtml(filename)}</span>
<span class="file-status" style="display:none"></span>
`;
const detailRow = document.createElement('div');
detailRow.className = 'file-detail';
detailRow.style.display = 'none';
bubble.appendChild(nameRow);
bubble.appendChild(detailRow);
wrapper.appendChild(bubble);
chatMessages.appendChild(wrapper);
scrollToBottom();
App.fileMessageMap.set(filename, { wrapper, bubble, nameRow, detailRow });
}
/**
* 设置文件处理中状态
*
* @param {string} filename - 文件名
*/
export function setFileProcessing(filename) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-done', 'file-error');
bubble.classList.add('file-processing');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline-flex';
statusEl.innerHTML = `
<span class="file-typing-indicator">
<span></span><span></span><span></span>
</span>
`;
detailRow.style.display = 'none';
}
/**
* 更新文件消息为完成状态
*
* @param {string} filename - 文件名
* @param {Object} summary - 提取摘要对象
*/
export function setFileDone(filename, summary) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-done');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">OK</span>';
const labelMap = {
card_date: '刷卡日期',
person_id: '人员编号',
person_name: '人员姓名',
invoice_type_label: '类型',
invoice_number: '发票号码',
invoice_date: '开票日期',
ride_date: '乘车日期',
departure: '出发站',
arrival: '到达站',
seat_class: '座位等级',
train_no: '车次',
total_amount: '金额',
amount: '金额',
seller_name: '销售方',
buyer_name: '购买方',
goods_name: '货物/服务',
remark: '备注',
card_no: '卡号',
card_amount: '刷卡金额',
pay_date: '支付日期',
pay_time: '支付时间',
pay_channel: '支付渠道',
transaction_no: '交易单号',
merchant_name: '商户名称',
order_no: '订单号',
project_name: '项目名称',
purpose: '出差事由',
start_date: '开始日期',
end_date: '结束日期',
person_info: '随行人员',
travel_purpose: '出差事由',
travel_from: '出发地',
travel_to: '目的地',
travel_start: '出发日期',
travel_end: '返回日期',
departure_place: '出发地',
arrival_place: '目的地',
hotel_name: '酒店名称',
checkin_date: '入住日期',
checkout_date: '退房日期',
room_type: '房型',
days: '天数',
attachments: '附件',
subsidy_list: '补助清单',
};
let lines = [];
for (const [key, value] of Object.entries(summary)) {
const label = labelMap[key] || key;
const displayValue = escapeHtml(String(value)).replace(/\n/g, '<br>');
lines.push(`<span class="detail-label">${label}:</span><span class="detail-value">${displayValue}</span>`);
}
detailRow.innerHTML = lines.join('<br>');
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
/**
* 更新文件消息为缓存状态
*
* @param {string} filename - 文件名
*/
export function setFileCached(filename) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-done');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">CACHE</span>';
detailRow.innerHTML = '<span class="detail-label">状态:</span><span class="detail-value">使用缓存(已提取)</span>';
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
/**
* 更新文件消息为错误状态
*
* @param {string} filename - 文件名
* @param {string} [errorMsg] - 错误信息
*/
export function setFileError(filename, errorMsg) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-error');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">ERR</span>';
detailRow.innerHTML = `<span class="detail-label">错误:</span><span class="detail-value error-text">${escapeHtml(errorMsg || '提取失败')}</span>`;
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

View File

@@ -0,0 +1,64 @@
/**
* 聊天消息渲染器
*
* 提供通用的气泡创建、DOM 操作和滚动功能。
*/
import { escapeHtml } from '../utils.js';
/**
* 获取聊天消息容器
*/
export function getChatMessages() {
return document.getElementById('chat-messages');
}
/**
* 滚动聊天容器到底部
*/
export function scrollToBottom() {
const chatMessages = getChatMessages();
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
/**
* 创建聊天消息气泡
*
* @param {string} text - 消息内容
* @param {string} type - 消息类型(影响气泡样式)
* @param {boolean} isUser - 是否为用户消息
* @returns {HTMLElement} wrapper 元素
*/
export function createChatBubble(text, type, isUser) {
const wrapper = document.createElement('div');
wrapper.className = `chat-message${isUser ? ' user' : ''}`;
wrapper.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/${isUser ? 'user' : 'agent'}.svg" alt=""></div>
<div class="chat-bubble ${isUser ? 'user' : (type || 'processing')}">${escapeHtml(text)}</div>
`;
return wrapper;
}
/**
* 创建带系统头像的气泡
*
* @param {string} text - 消息内容
* @param {string} type - 消息类型
* @returns {HTMLElement} wrapper 元素
*/
export function createSystemBubble(text, type) {
return createChatBubble(text, type, false);
}
/**
* 将消息追加到聊天容器
*
* @param {HTMLElement} wrapper - 消息 wrapper 元素
*/
export function appendMessage(wrapper) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
chatMessages.appendChild(wrapper);
scrollToBottom();
}

View File

@@ -0,0 +1,199 @@
/**
* LLM 流式聊天气泡管理器
*
* 流式消息在处理时显示思考过程和逐字输出,结束后固化为持久消息。
*/
import { escapeHtml } from '../utils.js';
import { getChatMessages, scrollToBottom } from './renderer.js';
/**
* 当前活跃的 LLM 流式气泡状态
*/
let llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
/**
* 处理 LLM 流式事件
*
* @param {Object} msg - SSE 传来的 llm_stream 事件对象
*/
export function handleLLMStream(msg) {
switch (msg.phase) {
case 'start':
_createLLMStreamBubble(msg.label || '正在分析中...');
break;
case 'reasoning':
_appendLLMStreamReasoning(msg.text || '');
break;
case 'chunk':
_appendLLMStreamChunk(msg.text || '');
break;
case 'end':
_closeLLMStreamBubble(msg.label || '分析完成');
break;
case 'error':
_errorLLMStreamBubble(msg.error || '分析失败');
break;
}
}
/**
* 创建 LLM 流式气泡start 阶段)
*/
function _createLLMStreamBubble(label) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
if (llmStreamState.wrapper) {
_closeLLMStreamBubbleSilent();
}
const wrapper = document.createElement('div');
wrapper.className = 'chat-message';
const bubble = document.createElement('div');
bubble.className = 'chat-bubble processing llm-stream-bubble';
const labelEl = document.createElement('div');
labelEl.className = 'llm-stream-label';
labelEl.textContent = label;
const reasoningSection = document.createElement('details');
reasoningSection.className = 'llm-reasoning-section';
reasoningSection.open = true;
const reasoningSummary = document.createElement('summary');
reasoningSummary.className = 'llm-reasoning-summary';
reasoningSummary.textContent = '思考过程(点击折叠)';
const reasoningText = document.createElement('div');
reasoningText.className = 'llm-reasoning-text';
reasoningText.textContent = '';
reasoningSection.appendChild(reasoningSummary);
reasoningSection.appendChild(reasoningText);
const textEl = document.createElement('div');
textEl.className = 'llm-stream-text';
textEl.textContent = '';
bubble.appendChild(labelEl);
bubble.appendChild(reasoningSection);
bubble.appendChild(textEl);
wrapper.appendChild(bubble);
chatMessages.appendChild(wrapper);
scrollToBottom();
llmStreamState = {
wrapper,
bubble,
textContent: textEl,
accumulated: '',
reasoningContent: reasoningText,
reasoningAccumulated: '',
};
}
/**
* 追加流式文本片段chunk 阶段)
*/
function _appendLLMStreamChunk(text) {
if (!llmStreamState.textContent) return;
llmStreamState.accumulated += text;
llmStreamState.textContent.textContent = llmStreamState.accumulated;
scrollToBottom();
}
/**
* 追加思考过程片段reasoning 阶段)
*/
function _appendLLMStreamReasoning(text) {
if (!llmStreamState.reasoningContent) return;
llmStreamState.reasoningAccumulated += text;
llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated;
scrollToBottom();
}
/**
* 关闭流式气泡end 阶段)— 气泡保留在聊天历史中作为持久消息
*/
function _closeLLMStreamBubble(label) {
if (!llmStreamState.bubble) return;
llmStreamState.bubble.classList.remove('processing');
llmStreamState.bubble.classList.add('done');
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
if (labelEl) {
labelEl.textContent = label;
}
const reasoningSection = llmStreamState.bubble.querySelector('.llm-reasoning-section');
if (reasoningSection) {
reasoningSection.open = false;
}
scrollToBottom();
llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
}
/**
* 静默关闭流式气泡(不改变样式,用于 start 前清理)
*/
function _closeLLMStreamBubbleSilent() {
if (llmStreamState.wrapper) {
llmStreamState.wrapper.remove();
}
llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
}
/**
* 错误状态error 阶段)
*/
function _errorLLMStreamBubble(errorMsg) {
if (!llmStreamState.bubble) return;
llmStreamState.bubble.classList.remove('processing');
llmStreamState.bubble.classList.add('error');
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
if (labelEl) {
labelEl.textContent = '分析失败';
}
if (llmStreamState.textContent) {
llmStreamState.textContent.textContent = escapeHtml(errorMsg);
}
llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
}

148
src/web/static/js/config.js Normal file
View File

@@ -0,0 +1,148 @@
/**
* 配置收集模块
*/
import { App } from './state.js';
import { addChatMessage, showStatus } from './chat.js';
import { handleUserSupplement, handleForceSubmit, handleSupplementUpload } from './agent.js';
import { startProcess } from './process.js';
export const CONFIG_FIELDS = {
username: '登录账号',
password: '登录密码',
default_name: '报销人姓名',
default_card_no: '公务卡号',
default_person_id: '报销人工号',
consumable_storage: '易耗品存放地点',
};
/**
* 静默检查:仅当配置完整且有文件时自动开始处理。
*/
export function checkAutoStart() {
if (App.isProcessing) return;
if (App.processState === 'processing') return;
if (App.processState === 'done') {
const hasNewFiles = App.allFiles.length > App.lastProcessedFileCount;
if (!hasNewFiles && !App.forceStart) return;
App.forceStart = false;
// done 状态下有新文件时,走 supplement 流程
if (hasNewFiles) {
App.isProcessing = true;
App.processState = 'processing';
if (App.newFilenames.length > 0) {
const filenames = App.newFilenames;
App.newFilenames = [];
handleSupplementUpload(filenames);
}
return;
}
}
if (App.processState === 'awaiting_files') {
if (!App.forceStart) return;
App.forceStart = false;
}
if (App.processState === 'awaiting_supplement') {
return;
}
// idle 状态下有补充文件时,走 supplement 流程
if (App.processState === 'idle' && App.newFilenames.length > 0) {
App.isProcessing = true;
App.processState = 'processing';
const filenames = App.newFilenames;
App.newFilenames = [];
handleSupplementUpload(filenames);
return;
}
const allKeys = Object.keys(CONFIG_FIELDS);
const missing = allKeys.filter(k => !App.sessionConfig[k]);
if (missing.length) return;
App.pendingConfigKey = null;
if (!App.allFiles.length) return;
startProcess();
}
/**
* 配置提示:在所有文件加载完成后调用,逐个提示缺失的配置项。
*/
export function promptMissingConfig() {
if (App.isProcessing) return;
const allKeys = Object.keys(CONFIG_FIELDS);
const missing = allKeys.filter(k => !App.sessionConfig[k]);
if (missing.length) {
if (App.pendingConfigKey === null) {
App.pendingConfigKey = missing[0];
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}`, 'system');
} else if (!missing.includes(App.pendingConfigKey)) {
App.pendingConfigKey = missing[0];
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}`, 'system');
}
return;
}
App.pendingConfigKey = null;
if (App.allFiles.length) {
showStatus('配置信息已完整,开始处理发票……');
startProcess();
}
}
/**
* 用户输入处理入口
*/
export async function sendUserMessage() {
const input = document.getElementById('chat-input');
if (!input) return;
const text = input.value.trim();
if (!text) return;
// 优先处理配置收集阶段
if (App.pendingConfigKey) {
App.sessionConfig[App.pendingConfigKey] = text;
addChatMessage(text, 'user', true);
addChatMessage(`${CONFIG_FIELDS[App.pendingConfigKey]} 已设置`, 'system');
input.value = '';
promptMissingConfig();
return;
}
addChatMessage(text, 'user', true);
input.value = '';
// 检查是否是强制提交指令
if (text.includes('直接提交') || text.includes('强制提交') || text.includes('继续提交')) {
await handleForceSubmit();
return;
}
// 检查是否包含文件上传指令
if (text.includes('上传') || text.includes('补充')) {
document.getElementById('file-input').click();
return;
}
// awaiting_supplement 状态下,将用户输入发给后端 LLM 处理
if (App.processState === 'awaiting_supplement') {
await handleUserSupplement(text);
return;
}
showStatus('请通过上传区补充文件,或输入"直接提交"跳过校验');
}
export function handleChatKey(event) {
if (event.key === 'Enter') {
sendUserMessage();
}
}

View File

@@ -1,483 +1,22 @@
let sessionId = null;
const pdfFiles = [], imgFiles = [];
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
let csvFilename = ''; // 当前 CSV 文件名
let lastDownloadUrls = {}; // 最近一次可下载文件链接
/**
* 入口文件 — ES Module 入口
*
* 所有模块通过 import 加载,依赖顺序由 import 自动解决。
* 需要 HTML 内联事件调用的函数挂载到 window。
*/
import { initDragDrop, toggleUploadPanel, handleFiles } from './upload.js';
import { sendUserMessage, handleChatKey } from './config.js';
import { generateQr, startSync } from './sync.js';
import { handleForceSubmit } from './agent.js';
// ---- Session ----
async function ensureSession() {
if (sessionId) return sessionId;
const r = await fetch('/api/session', { method: 'POST' });
const d = await r.json();
sessionId = d.session_id;
return sessionId;
}
// ---- 上传 ----
function handleFiles(input, type) {
const files = Array.from(input.files);
const list = type === 'pdf' ? pdfFiles : imgFiles;
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
files.forEach(f => {
if (!list.find(x => x.name === f.name)) {
f.__source = 'local'; // 标记为本地手动选择
list.push(f);
}
});
renderFileList(type);
document.getElementById(zoneId).classList.add('active');
input.value = '';
}
function removeFile(type, index) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
list.splice(index, 1);
renderFileList(type);
if (list.length === 0) {
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
}
}
function renderFileList(type) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
box.innerHTML = list.map((f, i) =>
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">&times;</span></span>`
).join('');
}
// ---- 配置上传 ----
function handleConfigUpload(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const cfg = JSON.parse(e.target.result);
const map = {
'cfg-username': cfg.username,
'cfg-password': cfg.password,
'cfg-name': cfg.default_name,
'cfg-card': cfg.default_card_no,
'cfg-person-id': cfg.default_person_id,
'cfg-storage': cfg.consumable_storage,
};
for (const [id, val] of Object.entries(map)) {
if (val) document.getElementById(id).value = val;
}
// 同步 config.json 中的工号和公务卡号到表格
if (cfg.username) syncConfigToTable('工号');
if (cfg.default_card_no) syncConfigToTable('公务卡号');
alert('配置已加载');
} catch (err) {
alert('config.json 解析失败: ' + err.message);
}
};
reader.readAsText(file);
input.value = '';
}
// ---- 拖拽 ----
['pdf','img'].forEach(type => {
const zone = document.getElementById(type + '-zone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files).filter(f => {
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
});
if (files.length) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
files.forEach(f => {
f.__source = 'local'; // 标记为本地拖拽
if (!list.find(x => x.name === f.name)) list.push(f);
});
renderFileList(type);
zone.classList.add('active');
}
});
});
// ---- 处理 ----
async function startProcess() {
if (!pdfFiles.length && !imgFiles.length) {
alert('请先上传文件或图片');
return;
}
const btn = document.getElementById('btn-start');
btn.disabled = true;
btn.textContent = '处理中...';
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
document.getElementById('log-box').innerHTML = '';
document.getElementById('edit-section').style.display = 'none';
document.getElementById('download-section').style.display = 'none';
try {
await ensureSession();
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
for (const {f} of allFiles) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
}
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
const cfg = {
username: document.getElementById('cfg-username').value,
password: document.getElementById('cfg-password').value,
default_name: document.getElementById('cfg-name').value,
default_card_no: document.getElementById('cfg-card').value,
default_person_id: document.getElementById('cfg-person-id').value,
consumable_storage: document.getElementById('cfg-storage').value,
};
await fetch(`/api/process/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
// 监听 SSE 日志
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
const result = msg.result;
document.getElementById('status').innerHTML = result.ok
? '<span class="badge bg-success status-badge">完成</span>'
: '<span class="badge bg-danger status-badge">失败</span>';
btn.disabled = false;
btn.textContent = '开始处理';
if (result.ok) {
showDownloadLinks(result);
loadInvoiceData(); // 加载可编辑数据
} else {
alert('处理失败: ' + (result.error || '未知错误'));
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
btn.disabled = false;
btn.textContent = '开始处理';
};
} catch (e) {
alert('请求失败: ' + (e.message || '未知错误'));
btn.disabled = false;
btn.textContent = '开始处理';
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
}
}
// ---- 下载链接 ----
function showDownloadLinks(result) {
const section = document.getElementById('download-section');
const box = document.getElementById('download-links');
const warn = document.getElementById('doc-fill-warning');
if (!section || !box) return;
const items = [];
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
lastDownloadUrls = {};
items.forEach(it => { lastDownloadUrls[it.label] = it.url; });
box.innerHTML = items.map(it =>
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
).join('');
if (warn) {
if (result.doc_skipped) {
warn.style.display = 'block';
warn.style.color = '#0d6efd';
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
} else if (result.doc_ok === false && result.doc_error) {
warn.style.display = 'block';
warn.style.color = '';
warn.textContent = '出库单未生成:' + result.doc_error;
} else {
warn.style.display = 'none';
warn.textContent = '';
}
}
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
}
// ---- 发票数据编辑 ----
async function loadInvoiceData() {
try {
const r = await fetch(`/api/data/${sessionId}`);
const d = await r.json();
if (d.error) return;
csvFilename = d.csv_filename || 'invoice_summary.csv';
invoiceData = d.data || [];
const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__'));
renderTable(invoiceData, fields);
// 渲染后自动将配置区的工号/公务卡号同步到表格
syncConfigToTable('工号');
syncConfigToTable('公务卡号');
} catch (e) { /* ignore */ }
}
// 配置区 → 表格的同步映射:工号 ↔ cfg-username公务卡号 ↔ cfg-card
const CONFIG_SYNC = {
'工号': 'cfg-username',
'公务卡号': 'cfg-card',
};
// 从配置区输入框的值更新到所有表格行
function syncConfigToTable(field) {
const inputId = CONFIG_SYNC[field];
if (!inputId) return;
const val = document.getElementById(inputId).value || '';
invoiceData.forEach((row, i) => {
row[field] = val;
});
// 更新表格中对应单元格的显示
const tbody = document.getElementById('invoice-tbody');
if (!tbody) return;
const rows = tbody.querySelectorAll('tr');
rows.forEach((tr, i) => {
const inputs = tr.querySelectorAll('input');
fieldsCache.forEach((f, colIdx) => {
if (f === field && inputs[colIdx]) {
inputs[colIdx].value = val;
}
});
});
}
let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引
function renderTable(data, fields) {
const section = document.getElementById('edit-section');
if (!data.length) { section.style.display = 'none'; return; }
section.style.display = 'block';
// 使用后端返回的字段顺序, fallback 到 Object.keys
if (!fields || !fields.length) {
fields = Object.keys(data[0]).filter(k => !k.startsWith('__'));
}
fieldsCache = fields;
// 表头
document.getElementById('invoice-thead').innerHTML = `
<tr>
<th style="width:40px">#</th>
${fields.map(f => `<th>${f}</th>`).join('')}
</tr>
`;
// 表体:每行首列为序号,其后为各字段 input
const rows = data.map((row, i) => {
const cells = fields.map(f => {
let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`;
// 如果该字段参与配置区同步,额外调用 syncTableToConfig
if (CONFIG_SYNC[f]) {
onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`;
}
return `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
onchange="${onChangeStr}"></td>`;
}).join('');
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
}).join('');
document.getElementById('invoice-tbody').innerHTML = rows;
}
// 从表格修改同步回配置区
function syncTableToConfig(field, value) {
const inputId = CONFIG_SYNC[field];
if (inputId) {
document.getElementById(inputId).value = value;
}
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 将当前表格编辑内容写回服务器(内部调用)
async function saveInvoiceData() {
try {
const r = await fetch(`/api/save/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }),
});
const d = await r.json();
if (d.doc_url || d.doc_ok === false) {
showDownloadLinks({
csv_url: lastDownloadUrls['invoice_summary.csv'],
doc_url: d.doc_url,
doc_ok: d.doc_ok,
doc_error: d.doc_error,
});
}
} catch (e) {
console.warn('自动保存失败,继续提交:', e);
}
}
// ---- 提交到财务系统 ----
async function submitFinancial() {
const btn = document.getElementById('btn-submit');
btn.disabled = true;
btn.textContent = '提交中...';
document.getElementById('log-box').innerHTML = '';
try {
// 提交前先自动保存当前表格编辑内容
await saveInvoiceData();
await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' });
// 监听日志流(复用 SSE
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
btn.disabled = false;
const result = msg.result;
if (result && result.submit_ok) {
btn.textContent = '✅ 提交完成';
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000);
} else {
const errorMsg = result?.submit_error || result?.error || '未知错误';
btn.textContent = '❌ 提交失败';
logBox.innerHTML += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
logBox.scrollTop = logBox.scrollHeight;
console.warn('提交失败:', errorMsg);
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000);
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
btn.disabled = false;
btn.textContent = '🚀 提交到财务系统';
};
} catch (e) {
alert('提交失败: ' + e.message);
btn.disabled = false;
btn.textContent = '🚀 提交到财务系统';
}
}
// ---- 二维码 / 手机扫码上传 ----
let qrGenerated = false;
let syncTimer = null;
async function generateQr() {
await ensureSession();
const mobileUrl = window.location.origin + '/mobile/' + sessionId;
const box = document.getElementById('qrcode');
box.innerHTML = '';
new QRCode(box, {
text: mobileUrl,
width: 120,
height: 120,
colorDark: '#333',
colorLight: '#fff',
});
qrGenerated = true;
}
// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ----
async function startSync() {
if (syncTimer) return;
await syncFiles();
syncTimer = setInterval(syncFiles, 3000);
}
function stopSync() {
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
}
async function syncFiles() {
if (!sessionId) return;
try {
const r = await fetch(`/api/files/${sessionId}`);
const d = await r.json();
const serverNames = new Set(d.images || []);
const localNames = new Set(imgFiles.map(f => f.name));
for (const name of serverNames) {
if (!localNames.has(name)) {
const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`);
const blob = await resp.blob();
const file = new File([blob], name, { type: blob.type });
file.__source = 'server'; // 标记为扫码上传
imgFiles.push(file);
}
}
// 只清理来自服务器但已不存在的文件,保留本地手动选择的文件
for (const f of [...imgFiles]) {
if (!serverNames.has(f.name) && f.__source !== 'local') {
const idx = imgFiles.indexOf(f);
if (idx > -1) imgFiles.splice(idx, 1);
}
}
renderFileList('img');
if (imgFiles.length) {
document.getElementById('img-zone').classList.add('active');
} else {
document.getElementById('img-zone').classList.remove('active');
}
} catch (e) { /* ignore */ }
}
// 将需要 HTML 内联事件调用的函数挂载到 window
window.handleChatKey = handleChatKey;
window.sendUserMessage = sendUserMessage;
window.initDragDrop = initDragDrop;
window.toggleUploadPanel = toggleUploadPanel;
window.handleFiles = handleFiles;
window.handleForceSubmit = handleForceSubmit;
initDragDrop();
generateQr();
startSync();
// ---- 配置区 → 表格的双向同步绑定 ----
Object.values(CONFIG_SYNC).forEach(inputId => {
const el = document.getElementById(inputId);
if (el) {
el.addEventListener('input', () => {
// 根据 inputId 反查对应的字段名
const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0];
if (field) syncConfigToTable(field);
});
}
});
startSync();

View File

@@ -0,0 +1,148 @@
/**
* 发票处理模块
*/
import { App } from './state.js';
import { ensureSession } from './utils.js';
import {
addTypingIndicator,
removeTypingIndicator,
setFileProcessing,
setFileDone,
setFileCached,
setFileError,
handleLLMStream,
addChatMessage,
showStatus,
} from './chat.js';
import { handleAgentEvent, handleAutoSubmit } from './agent.js';
export async function startProcess() {
if (!App.allFiles.length) {
alert('请先上传文件或图片');
return;
}
App.isProcessing = true;
App.processState = 'processing';
addTypingIndicator();
try {
await ensureSession();
for (const f of App.allFiles) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
}
removeTypingIndicator();
addTypingIndicator();
const cfg = {
username: App.sessionConfig.username || '',
password: App.sessionConfig.password || '',
default_name: App.sessionConfig.default_name || '',
default_card_no: App.sessionConfig.default_card_no || '',
default_person_id: App.sessionConfig.default_person_id || '',
consumable_storage: App.sessionConfig.consumable_storage || '',
};
await fetch(`/api/agent/process/${App.sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
const es = new EventSource(`/api/logs/${App.sessionId}`);
App.agentEventSource = es;
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'file_progress') {
const filename = msg.file;
switch (msg.status) {
case 'processing':
setFileProcessing(filename);
break;
case 'done':
setFileDone(filename, msg.summary || {});
break;
case 'cached':
setFileCached(filename);
break;
case 'error':
setFileError(filename, msg.error);
break;
}
return;
}
if (msg.type === 'llm_stream') {
handleLLMStream(msg);
return;
}
if (msg.type && msg.type.startsWith('agent_')) {
handleAgentEvent(msg);
return;
}
if (msg.type === 'done') {
es.close();
App.agentEventSource = null;
removeTypingIndicator();
const result = msg.result;
App.isProcessing = false;
if (App.forceSubmitting) {
return;
}
if (result.waiting_for_supplement) {
App.processState = 'awaiting_supplement';
showStatus('信息不完整,请补充上传相关材料');
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
} else {
App.processState = 'done';
App.lastProcessedFileCount = App.allFiles.length;
if (result.ok) {
if (result.agent_ready) {
if (result.submit_ok !== false) {
addChatMessage('信息完整,已自动提交到财务系统', 'done');
} else {
addChatMessage(`校验通过但提交失败:${result.submit_error || '未知错误'}`, 'error');
}
} else if (result.invoice_count) {
addChatMessage(`处理完成!共提取 ${result.invoice_count} 张发票数据`, 'done');
}
} else {
addChatMessage(`处理失败:${result.error || '未知错误'}`, 'error');
}
}
return;
}
} catch (err) {
// 普通日志行
}
});
es.onerror = () => {
es.close();
removeTypingIndicator();
App.isProcessing = false;
App.processState = 'done';
addChatMessage('连接中断,请刷新页面重试', 'error');
};
} catch (e) {
removeTypingIndicator();
alert('请求失败: ' + (e.message || '未知错误'));
App.isProcessing = false;
App.processState = 'idle';
addChatMessage(`请求失败:${e.message || '未知错误'}`, 'error');
}
}

View File

@@ -0,0 +1,24 @@
/**
* 全局状态管理
*
* 所有模块通过 App 对象共享状态,避免全局变量污染。
*/
export const App = {
sessionId: null,
allFiles: [],
sessionConfig: {},
invoiceData: [],
csvFilename: '',
lastDownloadUrls: {},
isProcessing: false,
pendingConfigKey: null,
fieldsCache: [],
syncTimer: null,
fileMessageMap: new Map(),
processState: 'idle',
newFilenames: [],
lastProcessedFileCount: 0,
forceStart: false,
agentEventSource: null,
forceSubmitting: false,
};

80
src/web/static/js/sync.js Normal file
View File

@@ -0,0 +1,80 @@
/**
* 移动端同步模块
*/
import { App } from './state.js';
import { ensureSession } from './utils.js';
import { addFileMessage } from './chat.js';
import { checkAutoStart, promptMissingConfig } from './config.js';
export async function generateQr() {
await ensureSession();
const mobileUrl = window.location.origin + '/mobile/' + App.sessionId;
const box = document.getElementById('qrcode');
box.innerHTML = '';
new QRCode(box, {
text: mobileUrl,
width: 120,
height: 120,
colorDark: '#333',
colorLight: '#fff',
});
}
export async function startSync() {
if (App.syncTimer) return;
await syncFiles();
App.syncTimer = setInterval(syncFiles, 3000);
}
export function stopSync() {
if (App.syncTimer) { clearInterval(App.syncTimer); App.syncTimer = null; }
}
async function syncFiles() {
if (!App.sessionId) return;
try {
const r = await fetch(`/api/files/${App.sessionId}`);
const d = await r.json();
const serverFiles = d.files || [];
const serverNames = new Set(serverFiles.map(f => f.name));
const localNames = new Set(App.allFiles.map(f => f.name));
const addedFiles = [];
for (const serverFile of serverFiles) {
if (!localNames.has(serverFile.name)) {
const resp = await fetch(`/api/download/${App.sessionId}/${encodeURIComponent(serverFile.name)}`);
const blob = await resp.blob();
const file = new File([blob], serverFile.name, { type: blob.type });
file.__source = 'server';
App.allFiles.push(file);
addedFiles.push(serverFile.name);
}
}
for (const f of [...App.allFiles]) {
if (!serverNames.has(f.name) && f.__source !== 'local') {
const idx = App.allFiles.indexOf(f);
if (idx > -1) App.allFiles.splice(idx, 1);
}
}
if (addedFiles.length) {
addedFiles.forEach(name => addFileMessage(name));
if (App.processState === 'done') {
App.processState = 'idle';
App.newFilenames = addedFiles;
} else if (App.processState === 'awaiting_supplement') {
App.processState = 'idle';
App.newFilenames = addedFiles;
}
}
if (App.allFiles.length) {
document.getElementById('file-zone').classList.add('active');
} else {
document.getElementById('file-zone').classList.remove('active');
}
checkAutoStart();
} catch (e) { /* ignore */ }
}

141
src/web/static/js/upload.js Normal file
View File

@@ -0,0 +1,141 @@
/**
* 文件上传模块
*/
import { App } from './state.js';
import { addFileMessage, addChatMessage } from './chat.js';
import { promptMissingConfig, checkAutoStart } from './config.js';
import { handleSupplementUpload, hideAgentRequest } from './agent.js';
import { ensureSession } from './utils.js';
export async function handleFiles(input) {
const files = Array.from(input.files);
const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i;
let hasNewFiles = false;
const newFilenames = [];
for (const f of files) {
if (/\.json$/i.test(f.name)) {
await parseConfigFile(f);
continue;
}
if (!App.allFiles.find(x => x.name === f.name) && validExts.test(f.name)) {
f.__source = 'local';
App.allFiles.push(f);
document.getElementById('file-zone').classList.add('active');
addFileMessage(f.name);
hasNewFiles = true;
newFilenames.push(f.name);
}
}
if (hasNewFiles && App.processState === 'done') {
App.processState = 'idle';
App.newFilenames = newFilenames;
}
if (hasNewFiles && App.processState === 'awaiting_supplement') {
hideAgentRequest();
await _uploadNewFiles(newFilenames);
App.newFilenames = newFilenames;
App.processState = 'idle';
checkAutoStart();
return;
}
promptMissingConfig();
input.value = '';
}
async function _uploadNewFiles(filenames) {
await ensureSession();
for (const f of App.allFiles) {
if (filenames.includes(f.name)) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
}
}
}
async function parseConfigFile(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = function (e) {
try {
const cfg = JSON.parse(e.target.result);
Object.assign(App.sessionConfig, {
username: cfg.username || '',
password: cfg.password || '',
default_name: cfg.default_name || '',
default_card_no: cfg.default_card_no || '',
default_person_id: cfg.default_person_id || '',
consumable_storage: cfg.consumable_storage || '',
});
addChatMessage(`config.json 已加载`, 'system');
resolve(true);
} catch (err) {
console.warn('config.json 解析失败:', err.message);
resolve(false);
}
};
reader.readAsText(file);
});
}
export function initDragDrop() {
const zone = document.getElementById('file-zone');
if (!zone) return;
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', async e => {
e.preventDefault();
zone.classList.remove('dragover');
const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i;
const droppedFiles = Array.from(e.dataTransfer.files);
let hasNewFiles = false;
const newFilenames = [];
for (const f of droppedFiles) {
if (/\.json$/i.test(f.name)) {
await parseConfigFile(f);
continue;
}
if (validExts.test(f.name) && !App.allFiles.find(x => x.name === f.name)) {
f.__source = 'local';
App.allFiles.push(f);
zone.classList.add('active');
addFileMessage(f.name);
hasNewFiles = true;
newFilenames.push(f.name);
}
}
if (hasNewFiles && App.processState === 'done') {
App.processState = 'idle';
}
// 在 awaiting_supplement 状态下,只上传新文件并走 supplement 流程
if (hasNewFiles && App.processState === 'awaiting_supplement') {
hideAgentRequest();
await _uploadNewFiles(newFilenames);
App.newFilenames = newFilenames;
App.processState = 'idle';
checkAutoStart();
return;
}
promptMissingConfig();
});
}
/**
* 切换上传面板折叠/展开状态
*/
export function toggleUploadPanel() {
const body = document.getElementById('upload-panel-body');
const icon = document.getElementById('upload-toggle-icon');
if (!body || !icon) return;
body.classList.toggle('collapsed');
icon.classList.toggle('collapsed');
}

View File

@@ -0,0 +1,18 @@
/**
* 工具函数模块
*
* 提供 HTML 转义和会话管理等基础功能。
*/
import { App } from './state.js';
export function escapeHtml(s) {
return s.replace(/\&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export async function ensureSession() {
if (App.sessionId) return App.sessionId;
const r = await fetch('/api/session', { method: 'POST' });
const d = await r.json();
App.sessionId = d.session_id;
return App.sessionId;
}

View File

@@ -9,119 +9,49 @@
</head>
<body>
<div class="header text-center mb-4">
<h3>财务报销自动化</h3>
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图自动提取、LLM 识别并填报</p>
</div>
<div class="container" style="max-width:960px">
<!-- 上传区域 -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
<div class="icon">📁</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
<div id="pdf-list" class="mt-2"></div>
<div class="main-container" style="max-width:960px">
<!-- 聊天窗口 -->
<div class="chat-container" id="chat-box">
<div class="chat-messages" id="chat-messages">
<div class="chat-welcome">
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble system">你好!我是财务报销助手。请先上传 <b>config.json</b> 配置文件,然后上传发票 PDF 或支付截图,我将自动为您处理。</div>
</div>
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
</div>
<div class="col-md-4">
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
<div class="icon">🖼️</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
<div id="img-list" class="mt-2"></div>
</div>
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
</div>
<div class="col-md-4 text-center">
<div class="section-title">📱 手机扫码上传</div>
<div id="qrcode" style="display:inline-block"></div>
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
<div class="chat-input-area">
<input type="text" id="chat-input" placeholder="输入消息..." onkeydown="handleChatKey(event)" />
<button class="btn btn-primary" onclick="sendUserMessage()">发送</button>
</div>
</div>
<!-- 配置表单 -->
<div class="card mb-4">
<div class="card-body">
<div class="section-title">⚙️ 配置
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
📤 上传 config.json
</span>
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
</div>
<div class="row g-2">
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
<!-- 上传区域(可折叠) -->
<div class="upload-panel">
<div class="upload-panel-header" onclick="toggleUploadPanel()">
<span><img src="/static/icon/file.svg" alt="" style="width:14px;height:14px;vertical-align:middle;margin-right:4px">文件上传</span>
<span class="upload-panel-toggle" id="upload-toggle-icon"></span>
</div>
<div class="upload-panel-body" id="upload-panel-body">
<div class="row g-3">
<div class="col-md-8">
<div class="upload-zone" id="file-zone" onclick="event.stopPropagation(); document.getElementById('file-input').click()">
<div class="icon"><img src="/static/icon/folder.svg" alt=""></div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传文件(支持 PDF、图片</div>
</div>
<input type="file" id="file-input" accept=".pdf,image/*,.json" multiple hidden onchange="handleFiles(this)">
</div>
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
</div>
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
</div>
<div class="col-sm-3">
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
</div>
<div class="col-sm-3">
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
</div>
<div class="col-sm-6">
<label class="form-label" style="font-size:12px;margin-bottom:2px">存放地点(出库单)</label>
<input type="text" class="form-control form-control-sm" id="cfg-storage" placeholder="新工科楼">
<div class="col-md-4 text-center">
<div class="section-title"><img src="/static/icon/phone.svg" alt="" style="width:16px;height:16px;vertical-align:middle;margin-right:4px">手机扫码上传</div>
<div id="qrcode" style="display:inline-block"></div>
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="text-center mb-4">
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
<span id="status" class="ms-3"></span>
</div>
<!-- 下载区 -->
<div class="card mb-4" id="download-section" style="display:none">
<div class="card-body py-3">
<div class="section-title mb-2">📥 下载文件</div>
<div id="download-links" class="d-flex flex-wrap gap-2"></div>
<div id="doc-fill-warning" class="text-warning mt-2" style="font-size:13px;display:none"></div>
</div>
</div>
<!-- 发票数据编辑区 -->
<div class="card mb-4" id="edit-section" style="display:none">
<div class="card-body">
<div class="section-title d-flex justify-content-between align-items-center">
<span>📝 付款记录(可编辑)</span>
<div class="d-flex justify-content-end align-items-center">
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
</div>
</div>
<p class="edit-note">直接点击单元格即可编辑,提交时自动保存当前修改。</p>
<div class="table-wrapper">
<table class="table table-sm table-bordered table-editable mb-0" id="invoice-table">
<thead id="invoice-thead"></thead>
<tbody id="invoice-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- 日志 -->
<div class="section-title">📋 处理日志</div>
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="/static/js/index.js"></script>
<script type="module" src="/static/js/index.js"></script>
</body>
</html>

View File

@@ -25,7 +25,7 @@ body { background: #f5f7fa; padding-bottom: 40px; }
{% else %}
<div class="header">
<h5>📸 支付截图上传</h5>
<h5>支付截图上传</h5>
<p class="mb-0 opacity-75" style="font-size:13px">拍照或从相册选择图片</p>
</div>
@@ -35,7 +35,8 @@ body { background: #f5f7fa; padding-bottom: 40px; }
<input type="file" id="file-input" accept="image/*" multiple hidden onchange="handleSelect(this)">
<button class="btn btn-primary upload-btn" onclick="document.getElementById('file-input').click()">
📷 选择图片 / 拍照
<img src="/static/icon/gallery.svg" alt="" style="width:20px;height:20px;vertical-align:middle;margin-right:6px">
选择图片 / 拍照
</button>
<!-- 上传进度 -->
@@ -99,7 +100,7 @@ async function handleSelect(input) {
}
if (done === files.length) {
status.innerHTML = `<div class="text-success">${done} 张图片上传成功</div>`;
status.innerHTML = `<div class="text-success">${done} 张图片上传成功</div>`;
} else {
status.innerHTML = `<div class="text-warning">${done}/${files.length} 张上传成功</div>`;
}