last_reviewed
| last_reviewed |
|---|
| 2026-06-13 |
src/web 模块设计说明
设计思路
src/web 是一个基于 Flask 的轻量级 Web 界面,为财务报销自动化管道提供可视化操作入口。核心设计原则:
- 会话隔离:每次上传生成独立
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 端主界面(上传、处理、编辑、提交)
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
└── static/
├── css/
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
└── js/
├── index.js # 入口:初始化 App 全局状态、绑定事件
├── state.js # 全局状态管理(App 对象)
├── chat.js # 聊天气泡渲染、文件消息、LLM 流式气泡
├── agent.js # Agent 事件处理、用户输入管理
├── process.js # SSE 连接、事件路由、管道启动
├── config.js # 配置解析、逐项引导
├── upload.js # 文件上传、拖拽处理
├── sync.js # 移动端同步
└── utils.js # HTML 转义等工具函数
数据流
graph TD
A[用户上传文件] --> B[创建 session]
B --> C[文件写入 uploads/<sid>/]
C --> D[后台线程执行管道]
D --> E["extract_invoices()"]
E --> F["enrich_with_llm()"]
F --> G["save_csv()"]
G --> H["结果写入 session 目录"]
H --> I["invoice_summary.csv"]
H --> J["invoice_groups.json"]
H --> K["result.json"]
H --> L["session.log"]
E --> M{"判断报销类型"}
M -->|差旅| N["extract_travel_info()"]
M -->|普通| O["extract_normal_info()"]
N --> P["travel_info.json"]
O --> Q["normal_info.json"]
K --> R["前端 SSE 轮询 → 显示完成状态"]
R --> S["前端加载 CSV → 可编辑表格"]
S --> T["用户修改后保存"]
T --> U["用户点击提交"]
U --> V["run_financial_submit()"]
V --> W["bot 自动填报财务系统"]
W --> X["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
W --> Y["普通模式: normal_info.json → 基本信息 → 录入明细 → 支付信息 → 上传附件"]
API 路由
| 方法 | 路径 | 功能 |
|---|---|---|
| GET | / |
主界面 |
| POST | /api/session |
创建会话,返回 session_id |
| POST | /api/upload/<sid> |
上传 PDF/图片 |
| 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> |
保存前端编辑的发票数据 |
| GET | /api/download/<sid>/<filename> |
下载生成文件 |
| POST | /api/submit-financial/<sid> |
手动触发财务系统填报 |
| GET | /mobile/<sid> |
移动端上传页面 |
关键机制
日志收集
SSELogHandler 将管道日志写入 session.log,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
发票类型分流
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- 普通发票:生成易耗品出库单(Word 文档),走普通报销流程
extract_invoices() 在提取阶段完成分类,结果保存为 invoice_groups.json(包含 travel_count、general_count、application_count)。后续步骤(出库单生成、财务填报)统一从该文件读取分类结果,避免重复解析 CSV 和 JSON 字段。
LLM 信息提取(2026-06-11)
发票提取和匹配完成后,根据类型分流调用 LLM 提取结构化报销信息:
- 差旅发票:调用
extract_travel_info(),提取出差事由、地点、时间、交通/住宿明细、补助清单、支付方式、附件清单,缓存为travel_info.json。 - 普通发票:调用
extract_normal_info(),提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为normal_info.json。
Bot 填报时优先使用 LLM 提取的信息(travel_info/normal_info),降级时从缓存加载原始发票数据。
移动端同步
PC 端生成二维码指向 /mobile/<sid>,手机端上传的文件通过 syncFiles() 轮询同步到 PC 端内存中的 allFiles 列表,实现跨设备协作。文件来源标记(__source)区分本地选择和服务器同步,避免重复。
配置管理
配置分两层:项目级 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, ...) 写入:
# 开始 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, ...) 写入:
# 状态变更
_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, ...) 写入:
# 文件开始处理
_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() → 错误信息
完整数据流
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: 关闭连接
添加新消息类型的步骤
- 在对应模块定义
_emit_xxx()函数,写入 session 目录的 JSON 文件 - 在
routes.py的stream_logs()轮询循环中新增对该文件的轮询 - 在
process.js的 EventSource 监听器中按msg.type路由到新处理器 - 在
chat.js或agent.js中实现渲染逻辑 - 确保
start和end事件成对出现(如果是流式气泡)