--- last_reviewed: 2026-06-13 --- # src/web 模块设计说明 ## 设计思路 `src/web` 是一个基于 Flask 的轻量级 Web 界面,为财务报销自动化管道提供可视化操作入口。核心设计原则: - **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads//` 目录下,避免并发冲突。 - **异步处理**:耗时的 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 转义等工具函数 ``` ## 数据流 ```mermaid graph TD A[用户上传文件] --> B[创建 session] B --> C[文件写入 uploads//] 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/` | 上传 PDF/图片 | | GET | `/api/files/` | 列出会话文件(返回统一 `files` 列表,含 `name`、`type`、`size` 字段;旧字段 `pdfs`/`images` 保留向后兼容) | | POST | `/api/agent/process/` | 启动 Agent 管道(后台线程) | | GET | `/api/logs/` | SSE 日志流 | | GET | `/api/data/` | 获取发票数据 JSON | | POST | `/api/save/` | 保存前端编辑的发票数据 | | GET | `/api/download//` | 下载生成文件 | | POST | `/api/submit-financial/` | 手动触发财务系统填报 | | GET | `/mobile/` | 移动端上传页面 | ## 关键机制 ### 日志收集 `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/`,手机端上传的文件通过 `syncFiles()` 轮询同步到 PC 端内存中的 `allFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。 ### 配置管理 配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads//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/` 端点(`routes.py`)的轮询逻辑: ``` 每 0.5 秒: 1. 读取 session.log 增量 → yield "data: <日志行>" 2. 读取 file_events.log 增量 → 逐行 yield "data: " 3. 读取 llm_stream.log 增量 → 逐行 yield "data: " 4. 读取 agent_events.log 增量 → 逐行 yield "data: " 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() ├─ 创建
├─ 创建 label 元素(显示 label 文本) ├─ 创建
可折叠区域(思考过程) └─ 创建 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 后台线程
(管道) participant Files as 事件文件
(session 目录) participant SSE as Flask SSE
(routes.py) participant ES as EventSource
(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` 事件成对出现(如果是流式气泡)