feat: 小财管家意图规划与报销提交编排增强

- 完善管家意图识别、模型计划构建与规划器调度
- 重构差旅报销提交编排器与管家计划流程前端交互
- 优化报销消息项样式与文档中心视图
- 新增小财管家与附件上传风险前置复核设计文档
- 补充管家规划器与文档中心测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 14:25:14 +08:00
parent 1cbf3fee44
commit f60cebadb8
19 changed files with 2337 additions and 196 deletions

View File

@@ -0,0 +1,390 @@
# 小财管家
## 功能一句话
小财管家是首页统一财务任务入口,负责把用户的自然语言和附件拆解为多个可确认、可追踪、可分派的申请与报销任务,再调用现有申请助手和报销助手完成执行闭环。
## 背景与问题
当前个人工作台已经提供首页输入框,并能通过本体解析把一句话路由到申请、报销、预算或知识等单一会话。这个能力适合单意图,但用户真实表达经常是多任务组合,例如同时包含出差申请、昨日交通费报销、历史出差费用报销以及多张附件。
现有问题:
- 首页输入框当前会收敛为一个 `sessionType`,无法保留多任务计划。
- 申请助手和报销助手已经具备单任务核对能力,但缺少上层任务拆解、归集和跨助手分派。
- 附件上传后主要进入当前会话,缺少面向多任务的自动归集建议。
- 财务动作需要确认后才能入库、绑定或提交,不能让大模型直接执行高风险动作。
- 新增字段必须尊重本体字段,不能因为小财管家新增一套业务字段。
## 目标与非目标
### 目标
- 首页输入框定位为“小财管家”,作为用户默认财务任务入口。
- 用户提交自然语言和附件后,先展示小财管家的任务识别与附件归集过程。
- 支持把一句话拆成多个任务,第一版只验证费用申请和费用报销。
- 支持多附件按费用场景、时间、地点、任务线索形成归集建议。
- 遇到创建申请单、创建报销草稿、附件绑定、提交审批等动作时必须等待用户确认。
- 保留现有申请助手和报销助手能力,小财管家只做上层编排和分派。
- 外层意图识别必须优先使用大模型 function calling 输出结构化任务计划,规则逻辑只作为模型不可用或结构不合法时的兜底。
- 前端展示“意图识别智能体”过程气泡,并用流式状态逐步呈现,不暴露模型内部推理链。
- 所有业务字段先进入本体字段归一化,再进入下游助手、草稿、风险规则和持久化。
### 非目标
- 第一版不做万能智能体,不覆盖预算、审批、知识问答等全部场景。
- 第一版不引入 LangChain 或 LangGraph先复用项目内运行时模型配置和 OpenAI-compatible function calling 契约。
- 第一版不自动提交审批,不绕过用户确认。
- 第一版不新增业务语义字段;只新增任务编排态字段。
- 第一版不重写申请助手、报销助手和现有 Orchestrator。
## 用户与场景
### 目标用户
- 普通员工:在首页一次性描述申请、报销和附件处理诉求。
- 财务人员:查看任务拆解、附件归集和用户确认链路是否可追溯。
- 审批/管理角色:后续可扩展为审批待办和预算提醒编排,但不进入第一版。
### 核心场景
用户在首页输入:
```text
我想要申请7月2日去北京出差辅助北京供电局的税务审核任务并且我要报销昨天的交通费还需要报销6月3日出差去上海的费用
```
系统处理:
1. 小财管家识别到三条候选任务。
2. 将“昨天”按客户端日期解析为明确日期,例如 2026-06-03。
3. 将“7月2日去北京出差”归为费用申请任务。
4. 将“昨天的交通费”和“6月3日去上海出差费用”归为费用报销任务。
5. 如果用户同时上传附件,系统先识别附件场景,再建议归集到对应任务。
6. 需要创建申请单或报销草稿时,向用户展示核对摘要和确认动作。
## 功能能力
### 1. 任务识别与拆分
任务识别主链路是“小财管家意图识别智能体”:
1. 后端读取系统设置中的主模型/备模型运行时配置。
2. 将用户话术、客户端日期、附件元信息、上下文和 canonical ontology field 列表传入模型。
3. 通过强制 function calling 调用 `submit_steward_intent_plan`
4. 模型只能返回结构化参数:`thinking_events``tasks``attachment_groups`
5. 服务端再次校验:任务类型只能是 `expense_application` / `reimbursement`,业务字段只能是 canonical ontology fields附件名必须来自本次上传。
6. 如果模型未配置、调用失败、未返回工具调用或结构不合法,才切换到规则兜底,并在过程摘要中标记兜底原因。
输入:
- 用户自然语言 `message`
- 附件元信息 `attachments`
- 当前用户、部门、角色、客户端时间
- 已有会话上下文,可选
输出:
- `plan_id`:本次小财管家计划 ID
- `tasks`:多个任务条目
- `thinking_events`:面向用户展示的过程摘要
- `confirmation_groups`:需要用户确认的动作集合
- `attachment_groups`:附件归集建议
任务条目包含:
- `task_id`:编排态任务 ID
- `task_type``expense_application``reimbursement`
- `assigned_agent``application_assistant``reimbursement_assistant`
- `title`:任务标题
- `summary`:任务摘要
- `status``planned``needs_confirmation``ready_to_delegate``delegated``completed``blocked`
- `confidence`:识别置信度
- `ontology_fields`:归一化后的本体字段
- `missing_fields`:缺失字段
- `confirmation_required`:是否需要确认后执行
### 2. 附件归集
附件归集基于以下信号:
- 附件类型:发票、火车票、机票、酒店票、付款截图、招待票据等。
- 费用场景:差旅、交通、招待、住宿、其他。
- 日期:票据日期是否匹配任务时间。
- 地点:票据地点是否匹配任务地点。
- 金额:是否能参与报销草稿。
- 置信度:低置信度必须提示用户核对。
输出示例:
```json
{
"group_id": "ag_travel_001",
"target_task_id": "task_reim_002",
"scene": "travel",
"scene_label": "差旅费用",
"attachment_names": ["上海高铁票.jpg", "上海酒店发票.pdf", "出租车票.png"],
"excluded_attachment_names": ["客户招待发票.jpg"],
"confidence": 0.86,
"confirmation_required": true
}
```
### 3. 用户确认
必须确认的动作:
- 创建费用申请单。
- 创建报销草稿。
- 将附件归集并绑定到某一任务。
- 将附件关联到已有报销草稿。
- 提交审批。
- 修改已有草稿字段。
小财管家的确认动作采用“下一步优先”策略:
- 同一个计划里同时存在申请和报销时,前端只展示当前下一步主动作,不一次性摊开全部确认按钮。
- 下一步优先级为:费用申请单创建 > 报销单填写 > 附件归集确认。
- 小财管家先思考和分析,说明下一步将要做的行为;用户输入“确定”或点击确认后,才进入行动。
- 行动完成后重新检查剩余任务队列,继续进入“思考 -> 分析 -> 等待确认 -> 行动”的循环。
- 申请任务完成后,再把剩余报销任务作为后续任务引导到报销助手。
- 附件归集不作为第一屏主动作抢占申请流程;当进入报销任务时,相关附件随报销上下文带入。
可以自动执行的动作:
- 任务拆解。
- 本体字段归一化。
- 附件分类。
- 缺失字段检查。
- 风险和规则预审。
- 生成确认摘要。
### 4. 流式过程摘要
前端展示的是“意图识别智能体”的过程摘要,不是模型内部推理链;过程摘要必须独立于最终回复正文展示。
示例:
```text
正在识别用户输入中的财务事项...
已识别到 3 个候选任务。
正在按时间、地点和费用场景核对附件...
发现 3 张附件疑似属于差旅费用1 张附件需要单独处理。
等待你确认后,我再创建申请单或报销草稿。
```
第一版通过 `POST /steward/plans/stream` 返回 `application/x-ndjson` 流式事件:
- `thinking`:逐条追加到系统回复气泡上方的独立“意图识别智能体”折叠气泡。
- `plan`:返回完整任务计划后,再渲染最终正文、任务卡片、附件归集和确认动作。
流式接口必须在模型 function calling 完成前先返回首个 `thinking` 事件,告知用户“意图识别智能体接管”。后续模型返回后再追加结构化拆解、字段映射、附件归集等过程摘要。
前端收到 `thinking` 事件后,也必须以 typewriter 方式逐字展示过程摘要,不能把一条完整思考事件一次性塞进折叠气泡。多条 `thinking` 事件应排队顺序输出,上一条内容打完后再输出下一条。
前端流式超时必须区分“首包等待”和“流式空闲等待”:首包应快速返回,收到首包后不能再用固定总时长中断仍在思考的模型调用,只能在长时间没有任何新事件时判定空闲超时。
流式过程中正文区域不输出任务结论;计划完成后意图识别气泡默认折叠,正文只保留用户需要确认和执行的信息。
计划完成后的最终正文也必须流式输出。前端不能把完整正文一次性替换到消息气泡里,而应进入 `typing` 状态按字符逐步追加正文;正文输出完成后,再把状态改为“等待用户确认”并展示确认按钮。
用户确认当前步骤后,小财管家隐式委派给申请能力或报销能力时,也必须保持同一套流式体验:先在系统气泡上方的小财管家思考折叠气泡中逐字展示“接收确认、协调能力、等待结果”等过程摘要;拿到申请核对表或报销核对结果后,再逐字输出正文。结构化表格、核对卡片、确认按钮可以在正文输出完成后一次性展示,但正文不能一次性替换进消息气泡。
小财管家委派期间不得打开右侧单助手执行流程面板,也不得把“申请助手 / 报销助手”的执行步骤显示成独立助手思考框。用户可见身份保持“小财管家”,具体调用哪个能力只作为小财管家自己的过程摘要,不切换为“财务助手”或单独助手会话。
### 5. 用户可见结果展示
小财管家的第一屏最终正文必须采用适中信息量的分段结构:让用户看懂系统理解了哪些财务事项、先后顺序是什么、每一步会交给哪个助手做什么;但不要把任务摘要、置信度、字段缺口和附件判断提前摊开。
第一屏推荐结构:
1. `我会这样推进`:说明识别到几个财务事项,以及会逐步处理。
2. 顺序列表:说明先做什么、后做什么,每步附一句负责助手和动作边界。
3. 确认提示:请用户回复“确定”后开始第一步,并说明具体缺口会在对应步骤里再判断。
最终正文必须使用 Markdown 块结构渲染,至少包含标题、段落和顺序列表;标题与段落之间必须保留空行,并通过 `steward-plan-markdown` 专属样式拉开块间距。不能只依赖普通换行拼接文本,因为普通换行在对话气泡里会显得拥挤。
第一屏不展示任务详情卡片里的“还需要补充”,也不展示字段缺口说明。用户确认开始后,进入当前步骤的申请助手或报销助手,再由具体助手基于当前任务判断需要补充什么。
后续步骤如果需要展示“还需要补充”,必须是结构化列表,每个待补充项独立成行,包含字段业务名称和填写说明;不得把多个待补充项拼接成一行连续文本。
任务卡片和正文不得直接暴露本体字段名,例如 `transport_mode``amount``attachments`。本体字段只允许作为内部结构化数据进入后端、助手委派和持久化链路;用户界面必须翻译为业务中文,并提供可理解的填写说明:
- `transport_mode` 展示为“出行方式”,说明可填写高铁、飞机、自驾、出租车等。
- `amount` 在申请任务中展示为“预计金额”,在报销任务中展示为“报销金额”。
- `attachments` 展示为“附件/凭证”,说明可上传发票、行程单、付款截图或其他证明材料。
- `merchant_name` 展示为“商户/开票方”。
- `customer_name` 展示为“客户或项目对象”。
## 本体字段约束
业务字段必须使用本体 canonical field
- `expense_type`
- `time_range`
- `location`
- `reason`
- `amount`
- `transport_mode`
- `attachments`
- `customer_name`
- `merchant_name`
- `department_name`
- `employee_name`
- `employee_no`
兼容字段只能作为输入别名,例如:
- `occurred_date` -> `time_range`
- `business_time` -> `time_range`
- `reason_value` -> `reason`
- `transport_type` -> `transport_mode`
- `application_transport_mode` -> `transport_mode`
小财管家的编排态字段不进入业务语义本体:
- `plan_id`
- `task_id`
- `planning_source`
- `model_call_traces`
- `task_status`
- `assigned_agent`
- `confirmation_status`
- `attachment_group_id`
- `thinking_event_id`
这些字段只用于编排、展示和审计,不参与费用规则判断。
## 方案设计
### 后端
新增小财管家规划服务:
- `schemas/steward.py`:定义请求、任务计划、附件归集、确认动作等契约。
- `services/runtime_chat.py`:新增 `complete_with_tool_call`,复用主/备模型配置发送 `tools``tool_choice`
- `services/steward_intent_agent.py`:负责构造 `submit_steward_intent_plan` function schema 与模型调用。
- `services/steward_model_plan_builder.py`:负责把模型工具参数转换为服务端可校验计划。
- `services/steward_planner.py`:负责“大模型 function calling 优先、规则兜底”的编排和本体字段归一化。
- `api/v1/endpoints/steward.py`:提供 `POST /steward/plans``POST /steward/plans/stream`
后端第一版不直接落库业务单据,只返回计划和确认动作。确认后的执行仍走现有申请助手、报销助手和 Orchestrator。
### 前端
新增或改造能力:
- 首页输入框标题和提示文案改为“小财管家”。
- 工作台打开时默认使用 `sessionType=steward`
- 小财管家模式下隐藏“智能体切换”工具条。
- 小财管家模式下不展示欢迎界面。
- 小财管家模式下使用专属底部输入框,仅保留附件、自然语言输入和发送动作。
- 小财管家模式下先流式渲染独立过程摘要,再渲染任务计划正文。
- 用户确认当前下一步后,再切换/分派到申请助手或报销助手执行;多任务按顺序推进,不把所有任务动作一次性展示给用户。
### 执行流
```text
首页输入
小财管家计划接口
意图识别智能体 function calling
思考过程流式输出 + 任务分析 + 下一步动作说明
等待用户输入“确定”或点击确认
小财管家隐式调用申请助手创建申请单核对结果
申请动作完成后重新思考剩余队列
继续等待确认并隐式调用报销助手填写报销单
执行结果汇总
```
## 算法与公式
第一版主路径不以关键词规则定义“意图”,而是使用大模型 function calling 生成结构化计划。
模型输出后由服务端做确定性校验、字段归一化和确认动作生成。
规则置信度评分仅用于模型不可用或模型返回结构不可用时的兜底路径。
任务置信度:
$$
confidence = \min(1, 0.35s_i + 0.25s_t + 0.2s_l + 0.2s_a)
$$
变量说明:
- `s_i`:意图关键词得分,命中申请/报销核心动词。
- `s_t`:时间得分,识别到明确日期、相对日期或时间范围。
- `s_l`:地点得分,识别到城市、客户或业务对象。
- `s_a`:附件/费用场景得分,识别到票据、交通、住宿、招待等费用线索。
附件归集置信度:
$$
group\_score = 0.4m_s + 0.3m_t + 0.2m_l + 0.1m_n
$$
变量说明:
- `m_s`:附件场景与任务场景匹配度。
- `m_t`:附件日期与任务日期匹配度。
- `m_l`:附件地点与任务地点匹配度。
- `m_n`:附件名称和任务关键词匹配度。
## 测试方案
### 后端单元测试
- function calling 路径能把模型工具参数转换为 `planning_source=llm_function_call` 的任务计划。
- 模型返回 `occurred_date``transport_type``reason_value` 等别名时,服务端仍只输出 canonical 字段。
- 一句话中同时包含申请和报销时,返回多个任务。
- “昨天”能根据 `client_now_iso` 解析为明确日期。
- `occurred_date``transport_type``reason_value` 等兼容字段不会作为业务 canonical 字段输出。
- 多附件能生成差旅归集建议和排除项。
- 创建/绑定/提交类动作必须带 `confirmation_required=true`
### 前端测试
- 首页输入复杂话术后打开小财管家模式。
- 小财管家模式标题显示“小财管家”,不展示智能体切换。
- 过程摘要按步骤渐进展示。
- 任务计划卡片展示申请任务和报销任务。
- 附件归集建议展示包含项、排除项和确认按钮。
### 容器验证
后端测试必须在 `x-financial-main` 容器内执行:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_planner.py
```
前端构建优先使用宿主机 `npm.cmd` 或项目既有脚本,并设置合理超时。
## 指标与验收
- 输入包含 3 个任务的示例话术时,至少识别出 1 个申请任务和 2 个报销任务。
- 输入“明天出差北京3天支撑国网仿生产部署并且报销昨天业务招待费”时必须识别出 1 个申请任务和 1 个报销任务。
- 模型可用时,小财管家计划响应包含 `planning_source=llm_function_call`
- 小财管家计划响应中业务字段只出现 canonical ontology fields。
- 附件场景混合时,能区分差旅相关附件和非差旅附件。
- 前端弹窗标题为“小财管家”,并隐藏智能体切换。
- 前端确认区只展示当前下一步主动作;存在申请任务时,第一步必须是“先创建申请单”。
- 意图识别折叠气泡不得宽于正文气泡,且流式首包必须先于最终计划到达。
- 用户未确认前,不创建申请单、不创建报销草稿、不绑定附件、不提交审批。
- 后端定向测试通过。
## 风险与开放问题
- 模型供应商对 tools/function calling 的兼容度可能不同;第一版保留规则兜底和主备模型 failover。
- 规则兜底无法覆盖所有自然语言,需要保留人工确认和低置信度提示。
- 附件真实 OCR 归集依赖现有票据识别质量;第一版先使用附件名称和已有 OCR 摘要做轻量归集。
- NDJSON 流式输出展示的是过程摘要,不是模型内部推理链。
- 多任务之间可能共享日期、地点、申请单上下文,需要后续完善任务图依赖。
- 如果未来接入 LangGraph应基于当前计划契约迁移而不是推翻现有申请/报销助手。

View File

@@ -0,0 +1,58 @@
# 小财管家 TODO
## 阶段一:调研与契约
- [x] 盘点首页输入框、工作台弹窗、会话路由和本体字段注册表。[CONCEPT: 背景与问题] 证据:已确认 `PersonalWorkbench.vue``useAppShell.js``TravelReimbursementCreateView.vue``ontology_field_registry.py`
- [x] 定义第一版只覆盖申请助手和报销助手,不引入 LangChain但外层意图识别使用大模型 function calling。[CONCEPT: 目标与非目标] 证据:`CONCEPT.md` 已明确 MVP 边界和 function calling 主链路。
- [x] 明确小财管家业务字段必须走 ontology canonical fields编排字段不得进入业务本体。[CONCEPT: 本体字段约束] 证据:`CONCEPT.md` 已列出 canonical 字段与编排态字段。
## 阶段二:后端规划服务
- [x] 新增 `schemas/steward.py`,定义计划请求、任务、附件归集、确认动作和响应模型。[CONCEPT: 后端] 证据:`StewardPlanRequest``StewardTask``StewardAttachmentGroup``StewardConfirmationAction` 已新增。
- [x] 扩展 `services/runtime_chat.py`,支持 OpenAI-compatible / Azure OpenAI 的 `tools``tool_choice` function calling。[CONCEPT: 后端] 证据:新增 `complete_with_tool_call``RuntimeChatToolCall` 和工具调用解析。
- [x] 新增 `services/steward_intent_agent.py`,定义 `submit_steward_intent_plan` function schema 并调用系统主/备模型。[CONCEPT: 任务识别与拆分] 证据:模型调用入口已从 `StewardPlannerService` 注入。
- [x] 新增 `services/steward_model_plan_builder.py`,将模型工具参数转换为服务端可校验计划。[CONCEPT: 后端] 证据模型返回后仍会校验任务类型、canonical 字段和附件名。
- [x] 改造 `services/steward_planner.py`,实现大模型 function calling 优先、规则规划兜底。[CONCEPT: 任务识别与拆分] 证据:`planning_source` 区分 `llm_function_call``rule_fallback`
- [x] 新增 `api/v1/endpoints/steward.py`,提供 `POST /steward/plans`。[CONCEPT: 后端] 证据:容器内接口 smoke 返回 `task_count=3`
- [x] 新增 `POST /steward/plans/stream`,以 NDJSON 返回 `thinking` 和最终 `plan` 事件。[CONCEPT: 流式过程摘要] 证据:真实接口 smoke 返回事件序列 `thinking,thinking,thinking,thinking,plan`
- [x] 调整 `POST /steward/plans/stream`,确保模型 function calling 完成前先返回首个 `thinking` 事件。[CONCEPT: 流式过程摘要] 证据live smoke 首个事件为 `thinking/stream_start`
- [x]`api/v1/router.py` 注册小财管家接口。[CONCEPT: 后端] 证据:运行中 `/api/v1/steward/plans` 返回 200。
- [x] 保证所有输出到 `ontology_fields` 的业务字段只使用 canonical ontology fields。[CONCEPT: 本体字段约束] 证据:测试断言 `occurred_date``transport_type``reason_value` 不进入输出字段。
- [x] 强化模型提示词和规则兜底,确保“未来出差/去某地几天/部署支撑”即使未出现“申请”也识别为费用申请。[CONCEPT: 任务识别与拆分] 证据live smoke 将“明天出差北京3天...”拆为 `expense_application,reimbursement`
## 阶段三:前端入口和弹窗
- [x] 将首页输入区主文案调整为“小财管家”,提示语体现可处理多任务。[CONCEPT: 前端] 证据:`PersonalWorkbench.vue` 标题和 placeholder 已更新。
- [x] 增加 `steward` 会话类型,首页复杂输入默认进入小财管家模式。[CONCEPT: 前端] 证据:`SESSION_TYPE_STEWARD` 与首页 `sessionType` 已接入。
- [x] 小财管家模式下隐藏“智能体切换”工具条。[CONCEPT: 前端] 证据:`shortcuts``isStewardSession` 下返回空数组。
- [x] 小财管家模式下标题显示“小财管家”,副标题说明“统一财务任务编排入口”。[CONCEPT: 前端] 证据:`assistantHeaderTitle``assistantHeaderDescription` 已按 steward 分支处理。
- [x] 小财管家模式下不展示欢迎界面。[CONCEPT: 前端] 证据:`useTravelReimbursementSessionState.js` 对 steward 空会话返回空消息,并过滤旧欢迎消息快照。
- [x] 小财管家模式下使用专属底部输入框,不展示日期选择、差旅计算器和业务时间标签。[CONCEPT: 前端] 证据:`TravelReimbursementCreateView.vue``isStewardSession` 渲染 `steward-composer-row`
- [x] 新增前端小财管家计划服务,调用 `POST /steward/plans`。[CONCEPT: 后端] 证据:`web/src/services/steward.js` 已新增 `fetchStewardPlan`
- [x] 新增小财管家视图模型,生成过程摘要、任务计划卡片和附件归集卡片。[CONCEPT: 流式过程摘要] 证据:`stewardPlanModel.js``TravelReimbursementMessageItem.vue` 已接入 `stewardPlan`
- [x] 支持后端 `thinking` 事件真流式呈现为折叠式意图识别气泡。[CONCEPT: 流式过程摘要] 证据:`useStewardPlanFlow.js` 通过 `fetchStewardPlanStream` 接收 thinking 事件,并用 `typeStewardThinkingEvent` 将每条过程摘要逐字输出到折叠气泡。
- [x] 支持小财管家最终正文逐字流式输出,正文完成前不展示确认按钮。[CONCEPT: 流式过程摘要] 证据:`useStewardPlanFlow.js` 新增 `typeStewardPlanText`,计划完成后进入 `typing` 状态逐字追加正文,完成后再注入 `suggestedActions`
- [x] 意图识别过程放在系统回复气泡上方,作为不同颜色的独立折叠气泡,完成后默认折叠。[CONCEPT: 流式过程摘要] 证据:`TravelReimbursementMessageItem.vue``steward-intent-bubble` 放在 `message-bubble` 上方,`steward-plan-block` 只渲染任务和附件结果。
- [x] 统一小财管家思考折叠气泡与正文气泡宽度,避免思考气泡长于正文框。[CONCEPT: 流式过程摘要] 证据:`has-steward-plan` 消息栈统一为 760px思考气泡和正文气泡同宽。
- [x] 优化小财管家最终正文和任务卡片层次,用户可见内容不直接展示本体字段名。[CONCEPT: 用户可见结果展示] 证据:`stewardPlanModel.js` 第一屏使用 Markdown 标题、段落和顺序列表说明“先做什么、后做什么、交给哪个助手做什么”,但不展示置信度和字段缺口;`useStewardPlanFlow.js` 将第一屏标记为 `initialSummaryOnly``TravelReimbursementMessageItem.vue` 不再渲染第一屏任务详情卡片;后续步骤如需展示待补充项,再按独立列表行展示业务名称和填写说明。
## 阶段四:确认与分派
- [x] 为每个创建/绑定/提交类动作生成确认按钮,不确认不执行。[CONCEPT: 用户确认] 证据:接口返回 `confirmation_count=4`,前端转为 suggested actions。
- [x] 将小财管家确认区改为“只展示当前下一步主动作”,存在申请任务时优先进入申请助手。[CONCEPT: 用户确认] 证据:`buildStewardSuggestedActions` 只返回下一步动作,优先 `confirm_create_application`
- [x] 支持用户输入“确定”触发小财管家的下一步动作,而不是重新生成计划。[CONCEPT: 用户确认] 证据:`useStewardPlanFlow` 会查找待确认的小财管家动作并执行。
- [x] 支持小财管家隐藏委派申请/报销能力,执行后保留小财管家会话并继续引导剩余任务。[CONCEPT: 执行流] 证据:`sessionTypeOverride``stewardContinuation` 已接入前端提交流程。
- [x] 支持小财管家确认后的隐式委派继续流式输出,正文完成后再展示申请核对表、报销核对卡片和确认按钮。[CONCEPT: 流式过程摘要] 证据:`useTravelReimbursementSubmitComposer.js` 新增 `typeStewardDelegatedMessage`,申请预览与 orchestrator 结果均先流式思考、再逐字正文、最后挂载结构化 payload`npm.cmd --prefix web run build` 成功。
- [x] 小财管家委派申请/报销能力期间不打开右侧单助手执行流程面板,用户可见身份保持“小财管家”。[CONCEPT: 流式过程摘要] 证据:`stewardDelegated` 分支跳过 flow step 与 review panel scope并在最终消息设置 `assistantName: '小财管家'``stewardPlanModel.js` 助手标签兜底不再显示“财务助手”。
- [x] 确认申请任务后,将任务摘要分派到现有申请助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=application``auto_submit=true`
- [x] 确认报销任务后,将任务摘要和附件带入现有报销助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=expense``carry_files=true``auto_submit=true`
- [x] 附件归集建议确认后,将选中附件作为报销助手上下文继续处理。[CONCEPT: 附件归集] 证据:附件归集确认动作携带归集附件名称和排除附件名称。
## 阶段五:测试与验证
- [x] 新增 `server/tests/test_steward_planner.py`,覆盖多任务拆解、相对日期、附件归集、确认动作和流式事件。[CONCEPT: 测试方案] 证据:新增 4 个后端定向测试。
- [x] 补充 function calling 定向测试覆盖模型工具参数、canonical 字段清洗、附件归集和规则兜底。[CONCEPT: 后端单元测试] 证据:`test_steward_planner.py` 新增 fake function calling 路径,`test_runtime_chat_service.py` 新增 tools payload 用例。
- [x] 后端测试在 Docker `x-financial-main:/app` 内执行,超时控制在 60s 内。[CONCEPT: 容器验证] 证据:`pytest -q server/tests/test_steward_planner.py server/tests/test_runtime_chat_service.py` 结果 `13 passed`
- [ ] 新增或更新前端定向测试,覆盖小财管家标题、隐藏智能体切换和计划展示。[CONCEPT: 前端测试]
- [x] 运行前端构建或定向测试,确认 UI 编译通过。[CONCEPT: 测试方案] 证据:`npm.cmd run build` 成功。
- [x] 通过接口或页面可见结果证明用户最终看到小财管家计划和确认点。[CONCEPT: 指标与验收] 证据:容器接口返回 3 个任务、3 份归集附件、1 份排除附件和 4 个确认动作。

View File

@@ -0,0 +1,86 @@
# 附件上传风险前置复核
## 功能一句话
报销附件上传并完成 OCR 识别后立即执行完整风险复核,提交审批时只做轻量最终校验、预算占用和流程流转。
## 背景与问题
当前报销单提交阶段会同步执行较重的风险检查,包括附件风险汇总、差旅规则、场景规则、规则中心风险、历史行为统计和风险观测写入。用户在点击提交后会等待较长时间,容易误认为页面卡住。
风险的主要依据来自已上传票据、OCR 识别结果、费用明细、关联申请单和员工历史行为。这些数据在附件上传完成后已经基本具备,因此完整风险复核应前移到上传完成阶段。
## 目标与非目标
目标:
- 附件上传成功后自动刷新费用明细、附件风险、差旅/场景/规则中心风险和 AI 预审标识。
- 风险复核结果写回 `claim.risk_flags_json`,并持久化规则中心风险观测。
- 提交阶段不再重复跑完整 `_run_ai_submission_review()`
- 提交阶段只保留草稿完整性校验、预算占用、未处理阻断风险判断、状态流转、审计日志和助手会话清理。
非目标:
- 不新增业务字段。
- 不改变现有风险规则语义。
- 不把提交改成真正的后端异步任务队列。
## 用户与场景
- 报销申请人:上传票据后立即看到风险建议和需补充说明,不必等到提交时才发现问题。
- 直属领导和财务人员:收到单据时可看到提交前已生成的风险提示和用户处理结果。
- 系统管理员:风险观测仍可进入后台统计。
## 功能能力
上传完成后:
- 根据 OCR 结果回填费用明细类型、日期、金额、事由等已有字段。
- 刷新附件级 `attachment_analysis` 风险。
- 执行报销级风险复核,并生成 `ai_pre_review` 状态。
- 对规则中心命中的风险写入 `risk_observations`
提交审批时:
- 如果存在高风险且用户未处理,继续阻止提交或要求说明/按职级测算。
- 如果风险已处理,只做预算和流程流转。
- 不再重复生成一套提交阶段风险。
## 方案设计
后端:
-`ExpenseClaimService.upload_claim_item_attachment()`OCR、附件分析和 `_sync_claim_from_items()` 完成后,调用上传后风险复核 helper。
- 新增 helper 复用现有 `_run_ai_submission_review()``_replace_ai_pre_review_flag()`,但保持单据状态为草稿。
- 提交阶段读取既有风险结果,只做最终阻断风险判断,不重复调用 `_run_ai_submission_review()`
前端:
- 继续使用当前附件识别中的状态条。
- 上传完成后通过接口返回的 `claim_risk_flags` 更新 AI 建议区和风险标识。
- 提交时只显示轻量后台提交流程提示。
## 算法与公式
当前功能不涉及新的显式数学公式。风险评分和风险等级沿用现有规则中心、附件分析、差旅政策和风险观测逻辑。
## 测试方案
- 后端单元测试:附件上传后写入 `ai_pre_review``submission_review` 风险。
- 后端单元测试:提交阶段不再调用完整 `_run_ai_submission_review()`
- 后端单元测试:上传后规则中心风险可写入 `risk_observations`
- 前端静态回归:提交确认仍为后台提交,不恢复阻塞弹窗。
- 构建验证:`npm.cmd --prefix web run build`
## 指标与验收
- 上传附件后,接口响应的 `claim_risk_flags` 包含最新复核结果。
- 提交接口耗时不再包含完整风险复核耗时。
- 提交后审批人仍能看到已前置生成的风险提示。
- 后端和前端相关回归测试通过。
## 风险与开放问题
- 如果用户上传后又修改费用明细,现有 `update_claim_item()` 需要继续刷新附件风险和报销级风险。
- 如果用户没有上传附件直接提交,提交阶段仍需要保留兜底风险复核或阻断提示。
- 未来可进一步把上传后复核做成真正后台任务,但本次先保持同步接口返回最新风险结果。

View File

@@ -0,0 +1,28 @@
# 附件上传风险前置复核 TODO
## 调研与契约
- [x] 盘点附件上传、预审、提交链路,确认完整风险复核当前在提交阶段重复执行。[CONCEPT: 背景与问题]
- [x] 明确上传后复核 helper 的输入输出契约,不新增业务字段。[CONCEPT: 方案设计] 证据:新增 `_refresh_claim_pre_review_flags()` 复用现有风险字段。
## 后端实现
- [x] 在附件上传完成后触发报销级风险复核,并保持单据状态为草稿。[CONCEPT: 功能能力] 证据:`upload_claim_item_attachment()` 调用 `_refresh_claim_pre_review_flags()`
- [x] 上传后风险复核写回 `ai_pre_review``submission_review` 风险结果。[CONCEPT: 功能能力] 证据:`test_upload_attachment_refreshes_claim_pre_review` 通过。
- [x] 规则中心风险在上传后写入 `risk_observations`,避免提交阶段集中写入。[CONCEPT: 方案设计] 证据:上传后复核复用 `_run_ai_submission_review()`,平台风险仍调用 `RiskObservationService.upsert_platform_risk_flags()`
- [x] 提交阶段改为读取既有风险结果,只做最终校验、预算占用和流转。[CONCEPT: 目标与非目标] 证据:`submit_claim()` 仅在缺少 `ai_pre_review` 时兜底复核。
- [x] 保留“无附件直接提交”的兜底检查,避免绕过风险复核。[CONCEPT: 风险与开放问题] 证据:`test_submit_claim_runs_ai_review_and_routes_to_direct_manager` 通过。
## 前端实现
- [x] 确认上传完成后 UI 使用接口返回的 `claim_risk_flags` 刷新 AI 建议与行风险标识。[CONCEPT: 前端] 证据:`travel-request-detail-risk-advice.test.mjs` 通过。
- [x] 确认提交阶段不恢复阻塞弹窗,只显示轻量后台提交提示。[CONCEPT: 前端] 证据:`travel-request-detail-submit-confirm.test.mjs` 通过。
## 测试与验证
- [x] 后端测试:附件上传后自动生成预审风险结果。[CONCEPT: 测试方案] 证据:`test_upload_attachment_refreshes_claim_pre_review` 通过。
- [x] 后端测试:提交阶段不重复调用完整风险复核。[CONCEPT: 测试方案] 证据:`test_submit_claim_reuses_upload_pre_review_without_rerunning_review` 通过。
- [x] 后端测试:风险观测仍被持久化。[CONCEPT: 测试方案] 证据:`test_risk_observation_storage_ready_is_cached_per_bind` 通过。
- [x] 前端回归测试通过。[CONCEPT: 测试方案] 证据54 个详情页风险/提交测试通过。
- [x] `npm.cmd --prefix web run build` 通过。[CONCEPT: 测试方案] 证据:前端生产构建通过,仅保留既有 Rollup 注释与 chunk size 警告。
- [x] Docker `x-financial-main` 容器内后端定向测试通过。[CONCEPT: 测试方案] 证据:核心上传前置复核、提交复用预审、申请/报销风险回归测试通过。

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse from app.schemas.steward import StewardPlanRequest, StewardPlanResponse, StewardThinkingEvent
from app.services.runtime_chat import RuntimeChatService from app.services.runtime_chat import RuntimeChatService
from app.services.steward_intent_agent import StewardIntentAgent from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_planner import StewardPlannerService from app.services.steward_planner import StewardPlannerService
@@ -55,6 +55,18 @@ async def _iter_steward_plan_events(
payload: StewardPlanRequest, payload: StewardPlanRequest,
planner: StewardPlannerService, planner: StewardPlannerService,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
yield _encode_stream_event(
"thinking",
StewardThinkingEvent(
event_id="intent_agent_stream_start",
stage="stream_start",
title="意图识别智能体接管",
content="已收到任务描述,正在调用小财管家意图识别智能体拆解申请、报销和附件线索。",
status="running",
).model_dump(mode="json"),
)
await asyncio.sleep(0)
try: try:
plan = planner.build_plan(payload) plan = planner.build_plan(payload)
except ValueError as exc: except ValueError as exc:

View File

@@ -42,7 +42,7 @@ class StewardIntentAgent:
}, },
max_tokens=1800, max_tokens=1800,
temperature=0.1, temperature=0.1,
timeout_seconds=18, timeout_seconds=45,
max_attempts=1, max_attempts=1,
) )
self.last_call_traces = result.calls_as_dicts() self.last_call_traces = result.calls_as_dicts()
@@ -105,6 +105,9 @@ class StewardIntentAgent:
"你必须通过 function calling 输出结构化计划,不能只返回普通文本。" "你必须通过 function calling 输出结构化计划,不能只返回普通文本。"
"当前版本只支持 expense_application 和 reimbursement 两类任务;" "当前版本只支持 expense_application 和 reimbursement 两类任务;"
"你只做识别、拆解、归集和确认点规划,不能执行入库、绑定附件或提交审批。" "你只做识别、拆解、归集和确认点规划,不能执行入库、绑定附件或提交审批。"
"用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时,"
"即使没有出现“申请”两个字,也必须优先识别为 expense_application。"
"用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。"
"所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields" "所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields"
"如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。" "如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。"
"相对日期必须以 base_date 为准转换为明确日期。" "相对日期必须以 base_date 为准转换为明确日期。"

View File

@@ -307,7 +307,7 @@ class StewardModelPlanBuilder:
return "travel" return "travel"
if normalized in {"transport", "traffic", "交通", "交通费", "打车", "出租车"}: if normalized in {"transport", "traffic", "交通", "交通费", "打车", "出租车"}:
return "transport" return "transport"
if normalized in {"entertainment", "meal", "招待", "接待", "餐饮", "业务招待"}: if normalized in {"entertainment", "meal", "招待", "招待费", "接待", "接待费", "餐饮", "业务招待", "业务招待费"}:
return "entertainment" return "entertainment"
if normalized in {"office", "办公", "办公用品"}: if normalized in {"office", "办公", "办公用品"}:
return "office" return "office"

View File

@@ -148,7 +148,7 @@ class StewardPlannerService:
drafts: list[PlannedTaskDraft] = [] drafts: list[PlannedTaskDraft] = []
first_reimbursement = self._find_first_reimbursement_index(message) first_reimbursement = self._find_first_reimbursement_index(message)
application_source = message[:first_reimbursement] if first_reimbursement >= 0 else message application_source = message[:first_reimbursement] if first_reimbursement >= 0 else message
if self._looks_like_application(application_source): if self._looks_like_application(application_source) or self._looks_like_future_travel_application(application_source):
drafts.append( drafts.append(
PlannedTaskDraft( PlannedTaskDraft(
task_type="expense_application", task_type="expense_application",
@@ -180,6 +180,31 @@ class StewardPlannerService:
compact = re.sub(r"\s+", "", text) compact = re.sub(r"\s+", "", text)
return bool(compact) and "申请" in compact and bool(re.search(r"出差|差旅|费用|交通|住宿|采购|会务|会议", compact)) return bool(compact) and "申请" in compact and bool(re.search(r"出差|差旅|费用|交通|住宿|采购|会务|会议", compact))
@staticmethod
def _looks_like_future_travel_application(text: str) -> bool:
compact = re.sub(r"\s+", "", text)
if not compact or "报销" in compact:
return False
business_signal = re.search(
r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收",
compact,
)
route_signal = re.search(
fr"(?:去|到|赴|前往)({'|'.join(CITY_NAMES)})",
compact,
)
time_signal = re.search(
r"明天|后天|下周|下月|近期|月底|\d{1,2}月\d{1,2}(?:日|号)?|"
r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?|[0-9一二两三四五六七八九十]+天",
compact,
)
planned_route_signal = re.search(
r"(?:去|到|赴|前往).{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|"
r"(?:出差|差旅).{0,24}(?:[0-9一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)",
compact,
)
return bool((business_signal or route_signal) and (time_signal or planned_route_signal))
def _build_task( def _build_task(
self, self,
draft: PlannedTaskDraft, draft: PlannedTaskDraft,
@@ -352,7 +377,12 @@ class StewardPlannerService:
@staticmethod @staticmethod
def _resolve_task_confidence(segment: str, fields: dict[str, str], task_type: str) -> float: def _resolve_task_confidence(segment: str, fields: dict[str, str], task_type: str) -> float:
compact = re.sub(r"\s+", "", segment) compact = re.sub(r"\s+", "", segment)
intent_score = 1.0 if ("申请" in compact if task_type == "expense_application" else "报销" in compact) else 0.45 if task_type == "expense_application":
intent_score = 1.0 if (
"申请" in compact or StewardPlannerService._looks_like_future_travel_application(compact)
) else 0.45
else:
intent_score = 1.0 if "报销" in compact else 0.45
time_score = 1.0 if fields.get("time_range") else 0.0 time_score = 1.0 if fields.get("time_range") else 0.0
location_score = 1.0 if fields.get("location") else 0.2 location_score = 1.0 if fields.get("location") else 0.2
scene_score = 1.0 if fields.get("expense_type") and fields["expense_type"] != "other" else 0.35 scene_score = 1.0 if fields.get("expense_type") and fields["expense_type"] != "other" else 0.35

View File

@@ -68,6 +68,31 @@ class EmptyFunctionCallingIntentAgent:
return None return None
class EntertainmentFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return StewardIntentAgentResult(
payload={
"thinking_events": [],
"tasks": [
{
"task_type": "reimbursement",
"title": "业务招待费报销",
"summary": "报销昨天业务招待费。",
"confidence": 0.9,
"ontology_fields": {
"time_range": "昨天",
"expense_type": "业务招待费",
"reason": "业务招待",
},
"missing_fields": [],
}
],
"attachment_groups": [],
},
model_call_traces=[],
)
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None: def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="我要报销昨天客户现场沟通的交通费", message="我要报销昨天客户现场沟通的交通费",
@@ -98,6 +123,19 @@ def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None
assert result.thinking_events[0].stage == "llm_function_call" assert result.thinking_events[0].stage == "llm_function_call"
def test_steward_planner_normalizes_llm_business_entertainment_expense_type() -> None:
payload = StewardPlanRequest(
message="报销昨天业务招待费",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=EntertainmentFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "llm_function_call"
assert result.tasks[0].ontology_fields["expense_type"] == "entertainment"
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None: def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="我要报销昨天的交通费", message="我要报销昨天的交通费",
@@ -141,6 +179,29 @@ def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
assert all(action.status == "pending" for action in result.confirmation_groups) assert all(action.status == "pending" for action in result.confirmation_groups)
def test_steward_planner_treats_future_travel_without_apply_word_as_application() -> None:
payload = StewardPlanRequest(
message="明天出差北京3天支撑国网仿生产部署并且报销昨天业务招待费",
user_id="u001",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService().build_plan(payload)
assert [task.task_type for task in result.tasks] == [
"expense_application",
"reimbursement",
]
assert result.tasks[0].assigned_agent == "application_assistant"
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-05"
assert result.tasks[0].ontology_fields["location"] == "北京"
assert result.tasks[0].ontology_fields["expense_type"] == "travel"
assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署"
assert result.tasks[1].assigned_agent == "reimbursement_assistant"
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
assert result.tasks[1].ontology_fields["expense_type"] == "entertainment"
def test_steward_planner_outputs_only_canonical_ontology_fields() -> None: def test_steward_planner_outputs_only_canonical_ontology_fields() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="我要报销昨天的交通费", message="我要报销昨天的交通费",
@@ -210,5 +271,6 @@ def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
] ]
assert [event["event"] for event in events][:2] == ["thinking", "thinking"] assert [event["event"] for event in events][:2] == ["thinking", "thinking"]
assert events[0]["data"]["stage"] == "stream_start"
assert events[-1]["event"] == "plan" assert events[-1]["event"] == "plan"
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03" assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"

View File

@@ -29,6 +29,10 @@
gap: 8px; gap: 8px;
} }
.message-row.has-steward-plan .message-stack {
width: min(100%, 760px);
}
.message-row.user .message-stack { .message-row.user .message-stack {
order: 1; order: 1;
justify-items: end; justify-items: end;
@@ -64,8 +68,15 @@
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14); box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14);
} }
.message-row.has-steward-plan .message-bubble {
width: 100%;
max-width: none;
box-sizing: border-box;
}
.steward-intent-bubble { .steward-intent-bubble {
width: min(100%, 680px); width: 100%;
box-sizing: border-box;
border: 1px solid #c9ddea; border: 1px solid #c9ddea;
border-radius: 4px; border-radius: 4px;
background: #eef6fb; background: #eef6fb;
@@ -144,6 +155,17 @@
line-height: 1.55; line-height: 1.55;
} }
.steward-intent-event-list li span.typing::after {
content: "";
width: 5px;
height: 12px;
display: inline-block;
margin-left: 3px;
vertical-align: -2px;
background: #3a7ca5;
animation: steward-plan-caret 900ms steps(1, end) infinite;
}
.steward-intent-empty { .steward-intent-empty {
margin: 0; margin: 0;
padding: 0 12px 12px; padding: 0 12px 12px;
@@ -231,6 +253,229 @@
font-weight: 850; font-weight: 850;
} }
.steward-plan-markdown {
display: block;
}
.steward-plan-markdown :deep(h2) {
margin: 0 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid #dbe4ee;
color: #0f172a;
font-size: 15px;
font-weight: 860;
line-height: 1.42;
}
.steward-plan-markdown :deep(hr) {
margin: 10px 0 14px;
border: 0;
border-top: 1px solid #e2e8f0;
}
.steward-plan-markdown :deep(h3) {
margin: 16px 0 8px;
padding-left: 8px;
border-left: 3px solid var(--theme-primary, #3a7ca5);
color: #183247;
font-size: 13px;
font-weight: 840;
line-height: 1.45;
}
.steward-plan-markdown :deep(h3:first-child) {
margin-top: 0;
}
.steward-plan-markdown :deep(p) {
margin: 0 0 10px;
color: #334155;
line-height: 1.72;
}
.steward-plan-markdown :deep(ul) {
margin: 0 0 12px;
padding-left: 18px;
display: grid;
gap: 7px;
}
.steward-plan-markdown :deep(li) {
margin: 0;
color: #334155;
line-height: 1.65;
}
.steward-plan-typing::after {
content: "";
width: 6px;
height: 15px;
display: inline-block;
margin-left: 3px;
vertical-align: -2px;
background: var(--theme-primary, #3a7ca5);
animation: steward-plan-caret 900ms steps(1, end) infinite;
}
@keyframes steward-plan-caret {
0%,
45% {
opacity: 1;
}
46%,
100% {
opacity: 0;
}
}
.steward-plan-block {
margin-top: 14px;
display: grid;
gap: 12px;
}
.steward-task-list,
.steward-attachment-list {
display: grid;
gap: 10px;
}
.steward-task-card,
.steward-attachment-card {
padding: 12px;
border: 1px solid #dbe7f2;
border-radius: 4px;
background: #f8fbfe;
}
.steward-task-header,
.steward-attachment-card header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.steward-task-type,
.steward-attachment-card header span {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border: 1px solid #c7deef;
border-radius: 4px;
background: #eef7fc;
color: #24618a;
font-size: 12px;
font-weight: 780;
}
.steward-task-agent,
.steward-attachment-card header small {
color: #64748b;
font-size: 12px;
font-weight: 720;
}
.steward-task-body {
display: grid;
gap: 5px;
}
.steward-task-title {
display: block;
color: #172a3a;
font-size: 14px;
font-weight: 850;
line-height: 1.45;
}
.steward-task-summary,
.steward-attachment-card p {
margin: 0;
color: #5c7185;
font-size: 12px;
line-height: 1.62;
}
.steward-task-meta,
.steward-attachment-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
}
.steward-task-meta span,
.steward-attachment-chip {
border: 1px solid #d5e2ee;
border-radius: 4px;
background: #ffffff;
color: #49677f;
font-size: 12px;
line-height: 1.4;
padding: 3px 7px;
}
.steward-task-missing {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #d5e2ee;
display: grid;
gap: 8px;
}
.steward-task-missing-label {
color: #425c72;
font-size: 12px;
font-weight: 820;
}
.steward-task-missing-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.steward-task-missing-list li {
display: grid;
grid-template-columns: minmax(86px, 120px) minmax(0, 1fr);
align-items: start;
gap: 8px;
padding: 7px 8px;
border: 1px solid #e1e8f0;
border-radius: 4px;
background: #ffffff;
}
.steward-task-missing-list strong {
color: #1f3448;
font-size: 12px;
font-weight: 820;
line-height: 1.45;
}
.steward-task-missing-list small {
color: #6b7f92;
font-size: 12px;
line-height: 1.45;
}
.steward-attachment-chip.include {
border-color: #c7deef;
background: #eef7fc;
color: #24618a;
}
.steward-attachment-chip.exclude {
border-color: #ecd6c4;
background: #fff8f2;
color: #8a5a24;
}
.message-answer-markdown :deep(.markdown-table-wrap) { .message-answer-markdown :deep(.markdown-table-wrap) {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@@ -811,4 +1056,9 @@
max-width: 100%; max-width: 100%;
border-radius: 12px; border-radius: 12px;
} }
.steward-task-missing-list li {
grid-template-columns: 1fr;
gap: 3px;
}
} }

View File

@@ -141,6 +141,42 @@
padding: 3px 7px; padding: 3px 7px;
} }
.steward-task-missing {
margin-top: 9px;
padding-top: 9px;
border-top: 1px dashed #d5e2ee;
display: grid;
gap: 6px;
}
.steward-task-missing-label {
color: #5d7489;
font-size: 12px;
font-weight: 780;
}
.steward-task-missing-chip {
display: grid;
gap: 2px;
padding: 7px 8px;
border: 1px solid #e1e8f0;
border-radius: 4px;
background: #ffffff;
}
.steward-task-missing-chip strong {
color: #1f3448;
font-size: 12px;
font-weight: 820;
line-height: 1.35;
}
.steward-task-missing-chip small {
color: #6b7f92;
font-size: 11px;
line-height: 1.45;
}
.steward-attachment-chip.include { .steward-attachment-chip.include {
border-color: #c7deef; border-color: #c7deef;
background: #eef7fc; background: #eef7fc;

View File

@@ -1,7 +1,7 @@
<template> <template>
<article <article
class="message-row" class="message-row"
:class="message.role" :class="[message.role, { 'has-steward-plan': message.stewardPlan }]"
> >
<span class="message-avatar"> <span class="message-avatar">
<img <img
@@ -31,7 +31,7 @@
:key="`${message.id}-${event.eventId}`" :key="`${message.id}-${event.eventId}`"
> >
<strong>{{ event.title }}</strong> <strong>{{ event.title }}</strong>
<span>{{ event.content }}</span> <span :class="{ 'typing': event.status === 'running' }">{{ event.content }}</span>
</li> </li>
</ol> </ol>
<p v-else class="steward-intent-empty">正在建立任务上下文...</p> <p v-else class="steward-intent-empty">正在建立任务上下文...</p>
@@ -62,7 +62,14 @@
<div <div
v-else-if="message.text && message.role === 'assistant'" v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown" :class="[
'message-answer-content',
'message-answer-markdown',
{
'steward-plan-markdown': message.stewardPlan,
'steward-plan-typing': message.stewardPlan?.streamStatus === 'typing'
}
]"
v-html="ui.renderMarkdown(message.text)" v-html="ui.renderMarkdown(message.text)"
@click="ui.handleAssistantMarkdownClick($event, message)" @click="ui.handleAssistantMarkdownClick($event, message)"
></div> ></div>
@@ -73,7 +80,7 @@
/> />
<div <div
v-if="message.role === 'assistant' && message.stewardPlan && message.stewardPlan.streamStatus !== 'streaming'" v-if="message.role === 'assistant' && message.stewardPlan && message.stewardPlan.streamStatus !== 'streaming' && !message.stewardPlan.initialSummaryOnly"
class="steward-plan-block" class="steward-plan-block"
role="group" role="group"
aria-label="小财管家任务计划" aria-label="小财管家任务计划"
@@ -84,16 +91,32 @@
:key="`${message.id}-${task.taskId}`" :key="`${message.id}-${task.taskId}`"
class="steward-task-card" class="steward-task-card"
> >
<header> <header class="steward-task-header">
<span>{{ task.taskTypeLabel }}</span> <span class="steward-task-type">{{ task.taskTypeLabel }}</span>
<small>{{ task.assignedAgentLabel }}</small> <span class="steward-task-agent">{{ task.assignedAgentLabel }}</span>
</header> </header>
<strong>{{ task.title }}</strong> <div class="steward-task-body">
<p>{{ task.summary }}</p> <strong class="steward-task-title">{{ task.title }}</strong>
<p class="steward-task-summary">{{ task.summary }}</p>
</div>
<div class="steward-task-meta"> <div class="steward-task-meta">
<span>置信度 {{ Math.round((task.confidence || 0) * 100) }}%</span> <span>置信度 {{ Math.round((task.confidence || 0) * 100) }}%</span>
<span v-if="task.missingFields?.length">待补充 {{ task.missingFields.join('') }}</span> <span v-if="!ui.resolveStewardMissingFieldItems(task).length">信息已基本齐备</span>
<span v-else>字段已齐备</span> </div>
<div
v-if="ui.resolveStewardMissingFieldItems(task).length"
class="steward-task-missing"
>
<span class="steward-task-missing-label">还需要补充</span>
<ul class="steward-task-missing-list">
<li
v-for="field in ui.resolveStewardMissingFieldItems(task)"
:key="`${task.taskId}-missing-${field.key}`"
>
<strong>{{ field.label }}</strong>
<small v-if="field.hint">{{ field.hint }}</small>
</li>
</ul>
</div> </div>
</article> </article>
</div> </div>

View File

@@ -11,12 +11,28 @@ export function fetchStewardPlan(payload, options = {}) {
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) { export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
const { const {
timeoutMs = 0, timeoutMs = 0,
idleTimeoutMs = 90000,
timeoutMessage = '小财管家任务规划超时,请稍后重试。' timeoutMessage = '小财管家任务规划超时,请稍后重试。'
} = options } = options
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
const timeoutId = controller && Number(timeoutMs) > 0 let timeoutId = 0
? globalThis.setTimeout(() => controller.abort(), Number(timeoutMs)) const armAbortTimer = (delayMs) => {
: 0 if (!controller) return
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
timeoutId = 0
}
if (Number(delayMs) > 0) {
timeoutId = globalThis.setTimeout(() => controller.abort(), Number(delayMs))
}
}
const clearAbortTimer = () => {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
timeoutId = 0
}
}
armAbortTimer(timeoutMs)
let response let response
try { try {
@@ -29,9 +45,7 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
signal: controller?.signal signal: controller?.signal
}) })
} catch (error) { } catch (error) {
if (timeoutId) { clearAbortTimer()
globalThis.clearTimeout(timeoutId)
}
if (error?.name === 'AbortError') { if (error?.name === 'AbortError') {
throw new Error(timeoutMessage) throw new Error(timeoutMessage)
} }
@@ -39,17 +53,13 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
} }
if (!response.ok) { if (!response.ok) {
if (timeoutId) { clearAbortTimer()
globalThis.clearTimeout(timeoutId)
}
throw new Error(await resolveStreamError(response)) throw new Error(await resolveStreamError(response))
} }
if (!response.body?.getReader) { if (!response.body?.getReader) {
const text = await response.text() const text = await response.text()
if (timeoutId) { clearAbortTimer()
globalThis.clearTimeout(timeoutId)
}
return consumeNdjsonText(text, handlers) return consumeNdjsonText(text, handlers)
} }
@@ -59,11 +69,13 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
let finalPlan = null let finalPlan = null
try { try {
armAbortTimer(idleTimeoutMs)
while (true) { while (true) {
const { value, done } = await reader.read() const { value, done } = await reader.read()
if (done) { if (done) {
break break
} }
armAbortTimer(idleTimeoutMs)
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n') const lines = buffer.split('\n')
buffer = lines.pop() || '' buffer = lines.pop() || ''
@@ -87,9 +99,7 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
} }
throw error throw error
} finally { } finally {
if (timeoutId) { clearAbortTimer()
globalThis.clearTimeout(timeoutId)
}
} }
if (!finalPlan) { if (!finalPlan) {

View File

@@ -562,9 +562,17 @@ const filteredRows = computed(() => {
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value))) const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const pageSummary = computed(() => `${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value}`) const pageSummary = computed(() => `${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
function resolveLiveDocumentRow(row) {
return {
...row,
isNewDocument: isNewDocument(row, viewedDocumentKeys.value)
}
}
const visibleRows = computed(() => { const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value) return filteredRows.value.slice(start, start + pageSize.value).map(resolveLiveDocumentRow)
}) })
const documentLoadingSource = computed(() => (props.loading || supportingLoading.value) && !visibleRows.value.length) const documentLoadingSource = computed(() => (props.loading || supportingLoading.value) && !visibleRows.value.length)

View File

@@ -16,6 +16,11 @@ import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSu
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js' import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js' import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { useStewardPlanFlow } from './useStewardPlanFlow.js' import { useStewardPlanFlow } from './useStewardPlanFlow.js'
import {
buildStewardFieldItems,
formatStewardMissingFieldList,
formatStewardOntologyFields
} from './stewardPlanModel.js'
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js' import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import { import {
buildOperationFeedbackPayload, buildOperationFeedbackPayload,
@@ -202,6 +207,10 @@ import {
userAvatar userAvatar
} from './travelReimbursementConversationModel.js' } from './travelReimbursementConversationModel.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 14
const REVIEW_RISK_LEVEL_META = { const REVIEW_RISK_LEVEL_META = {
high: { high: {
label: '高风险', label: '高风险',
@@ -1546,6 +1555,7 @@ export default {
replaceMessage, replaceMessage,
scrollToBottom, scrollToBottom,
adjustComposerTextareaHeight, adjustComposerTextareaHeight,
executeStewardSuggestedAction: (message, action) => handleSuggestedAction(message, action),
submitting, submitting,
reviewActionBusy, reviewActionBusy,
sessionSwitchBusy, sessionSwitchBusy,
@@ -1695,6 +1705,30 @@ export default {
const carryText = String(actionPayload.carry_text || '').trim() const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : [] const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return if (!lockSuggestedActionMessage(message, action)) return
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText
await submitComposerInternal({
rawText: carryText,
userText: action.label || '确定',
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
? '小财管家正在调用申请助手生成申请单核对结果...'
: '小财管家正在调用报销助手整理报销核对结果...',
files: carryFiles,
skipScopeGuard: true,
skipStewardPlan: true,
skipUserMessage: confirmedByText,
sessionTypeOverride: targetSessionType,
stewardContinuation: {
planId: String(actionPayload.steward_plan_id || '').trim(),
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
? actionPayload.steward_remaining_tasks
: []
}
})
return
}
await switchSessionType(targetSessionType) await switchSessionType(targetSessionType)
if (carryText) { if (carryText) {
composerDraft.value = carryText composerDraft.value = carryText
@@ -2161,6 +2195,195 @@ export default {
return String(request.claimId || request.claim_id || '').trim() return String(request.claimId || request.claim_id || '').trim()
} }
function buildStewardContinuationAfterAction(message, completedLabel = '当前动作已完成') {
const continuation = message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
if (!remainingTasks.length) {
return null
}
const nextTask = remainingTasks[0]
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const targetSessionType = nextTaskType === 'expense_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION
? '继续创建申请单'
: '继续填写报销单'
const restTasks = remainingTasks.slice(1)
return createMessage(
'assistant',
[
`**${completedLabel}。**`,
'',
'我会重新检查剩余任务队列。',
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}`,
'请回复“确定”,我再继续执行。'
].join('\n'),
[],
{
assistantName: '小财管家',
meta: ['小财管家', '等待用户确认'],
suggestedActions: [
{
label: nextLabel,
description: '确认后小财管家继续调用对应助手完成下一步。',
icon: targetSessionType === SESSION_TYPE_APPLICATION
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
auto_submit: true,
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
steward_remaining_tasks: restTasks
}
}
]
}
)
}
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
return {
planId: planId || `steward-followup-${Date.now()}`,
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function buildStewardFollowupThinkingEvents() {
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
return [
{
eventId: `${eventPrefix}-review`,
title: '复盘结果',
content: '当前动作已完成,小财管家正在检查剩余任务队列。'
},
{
eventId: `${eventPrefix}-next`,
title: '选择下一步',
content: '我会继续保持一步一步推进,先说明下一步,再等你确认后执行。'
}
]
}
function waitStewardFollowupTick(intervalMs) {
return new Promise((resolve) => {
window.setTimeout(resolve, intervalMs)
})
}
async function pushStewardContinuationMessage(finalMessage) {
if (!finalMessage) {
return
}
const finalText = String(finalMessage.text || '')
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const finalActions = Array.isArray(finalMessage.suggestedActions)
? finalMessage.suggestedActions
: []
finalMessage.text = ''
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
finalMessage.suggestedActions = []
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
messages.value.push(finalMessage)
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
for (const eventData of buildStewardFollowupThinkingEvents()) {
const event = {
eventId: eventData.eventId,
stage: 'steward_followup',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(eventData.content)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
event.content = chars.slice(0, index + 1).join('')
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
event.content = eventData.content
event.status = 'completed'
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
}
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
const chars = Array.from(finalText)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
finalMessage.text = chars.slice(0, index + 1).join('')
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
finalMessage.text = finalText
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
finalMessage.suggestedActions = finalActions
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
}
function buildStewardContinuationCarryText(task, restTasks = []) {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
const missingFields = formatStewardMissingFieldList(task?.missing_fields || task?.missingFields || [], taskType)
const lines = [
taskType === 'expense_application'
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}`
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields ? `已识别信息:${fields}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
'请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
]
if (restTasks.length) {
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
restTasks.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
})
}
return lines.filter(Boolean).join('\n')
}
function resolveStewardMissingFieldItems(task) {
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) {
return task.missingFieldItems
}
const fields = task?.missingFields || task?.missing_fields || []
const taskType = String(task?.taskType || task?.task_type || '').trim()
return buildStewardFieldItems(fields, taskType)
}
async function confirmApplicationSubmit() { async function confirmApplicationSubmit() {
const message = applicationSubmitConfirmDialog.value.message const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) { if (!message || submitting.value || reviewActionBusy.value) {
@@ -2185,6 +2408,8 @@ export default {
pendingText: '正在提交费用申请...', pendingText: '正在提交费用申请...',
systemGenerated: true, systemGenerated: true,
skipScopeGuard: true, skipScopeGuard: true,
skipStewardPlan: true,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application', feedbackOperationType: 'submit_application',
extraContext: { extraContext: {
application_preview: applicationPreview, application_preview: applicationPreview,
@@ -2229,6 +2454,10 @@ export default {
persistSessionState() persistSessionState()
nextTick(scrollToBottom) nextTick(scrollToBottom)
} }
const stewardFollowup = buildStewardContinuationAfterAction(message, '申请单已完成')
if (stewardFollowup) {
await pushStewardContinuationMessage(stewardFollowup)
}
} finally { } finally {
reviewActionBusy.value = false reviewActionBusy.value = false
} }
@@ -2449,7 +2678,7 @@ export default {
// submitting.value = true // submitting.value = true
// recognizeOcrFiles(files) // recognizeOcrFiles(files)
// submitting.value = false // submitting.value = false
if (isStewardSession.value && await submitStewardPlan(options)) { if (isStewardSession.value && !options.skipStewardPlan && await submitStewardPlan(options)) {
return null return null
} }
if (await handleGuidedComposerSubmit(options)) { if (await handleGuidedComposerSubmit(options)) {
@@ -2570,6 +2799,7 @@ export default {
sessionSwitchBusy: sessionSwitchBusy.value, sessionSwitchBusy: sessionSwitchBusy.value,
applicationPreviewEditor: applicationPreviewEditor.value, applicationPreviewEditor: applicationPreviewEditor.value,
buildMessageBubbleClass, buildMessageBubbleClass,
resolveStewardMissingFieldItems,
buildReviewMainMessageText, buildReviewMainMessageText,
renderMarkdown, renderMarkdown,
handleAssistantMarkdownClick, handleAssistantMarkdownClick,

View File

@@ -13,7 +13,70 @@ const TASK_TYPE_LABELS = {
const AGENT_LABELS = { const AGENT_LABELS = {
application_assistant: '申请助手', application_assistant: '申请助手',
reimbursement_assistant: '报销助手' application: '申请助手',
expense_application: '申请助手',
reimbursement_assistant: '报销助手',
reimbursement: '报销助手',
expense: '报销助手'
}
const FIELD_DISPLAY_CONFIG = {
expense_type: {
label: '费用类型',
hint: '例如差旅、交通、住宿、业务招待'
},
time_range: {
label: '发生时间',
hint: '申请时填出差起止日期,报销时填费用发生日期'
},
location: {
label: '地点',
hint: '出差城市或费用发生地点'
},
reason: {
label: '事由',
hint: '出差、报销或业务活动的具体原因'
},
amount: {
label: '金额',
hint: '申请时为预计金额,报销时为实际报销金额'
},
transport_mode: {
label: '出行方式',
hint: '例如高铁、飞机、自驾、出租车'
},
attachments: {
label: '附件/凭证',
hint: '发票、行程单、付款截图或其他证明材料'
},
customer_name: {
label: '客户或项目对象',
hint: '涉及的客户、单位或项目名称'
},
merchant_name: {
label: '商户/开票方',
hint: '发票或付款凭证上的商户名称'
},
department_name: {
label: '所属部门',
hint: '申请人或费用归属部门'
},
employee_name: {
label: '申请人',
hint: '发起申请或报销的员工姓名'
},
employee_no: {
label: '员工编号',
hint: '公司内部员工编号'
}
}
const FIELD_ALIASES = {
occurred_date: 'time_range',
business_time: 'time_range',
reason_value: 'reason',
transport_type: 'transport_mode',
application_transport_mode: 'transport_mode'
} }
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) { export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
@@ -47,6 +110,7 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
planStatus: String(rawPlan.plan_status || rawPlan.planStatus || ''), planStatus: String(rawPlan.plan_status || rawPlan.planStatus || ''),
summary: String(rawPlan.summary || ''), summary: String(rawPlan.summary || ''),
visibleThinkingEventCount, visibleThinkingEventCount,
initialSummaryOnly: Boolean(rawPlan.initial_summary_only || rawPlan.initialSummaryOnly || options.initialSummaryOnly),
thinkingEvents: Array.isArray(rawPlan.thinking_events) thinkingEvents: Array.isArray(rawPlan.thinking_events)
? rawPlan.thinking_events.map((item) => ({ ? rawPlan.thinking_events.map((item) => ({
eventId: String(item.event_id || item.eventId || ''), eventId: String(item.event_id || item.eventId || ''),
@@ -57,22 +121,30 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
})) }))
: [], : [],
tasks: Array.isArray(rawPlan.tasks) tasks: Array.isArray(rawPlan.tasks)
? rawPlan.tasks.map((item) => ({ ? rawPlan.tasks.map((item) => {
taskId: String(item.task_id || item.taskId || ''), const taskType = String(item.task_type || item.taskType || '')
taskType: String(item.task_type || item.taskType || ''), const missingFields = Array.isArray(item.missing_fields || item.missingFields)
taskTypeLabel: TASK_TYPE_LABELS[String(item.task_type || item.taskType || '')] || '财务任务',
assignedAgent: String(item.assigned_agent || item.assignedAgent || ''),
assignedAgentLabel: AGENT_LABELS[String(item.assigned_agent || item.assignedAgent || '')] || '财务助手',
title: String(item.title || ''),
summary: String(item.summary || ''),
status: String(item.status || ''),
confidence: Number(item.confidence || 0),
ontologyFields: item.ontology_fields || item.ontologyFields || {},
missingFields: Array.isArray(item.missing_fields || item.missingFields)
? item.missing_fields || item.missingFields ? item.missing_fields || item.missingFields
: [], : []
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true return {
})) taskId: String(item.task_id || item.taskId || ''),
taskType,
taskTypeLabel: TASK_TYPE_LABELS[taskType] || '财务任务',
assignedAgent: String(item.assigned_agent || item.assignedAgent || ''),
assignedAgentLabel:
AGENT_LABELS[String(item.assigned_agent || item.assignedAgent || '')] ||
AGENT_LABELS[taskType] ||
'小财管家',
title: String(item.title || ''),
summary: String(item.summary || ''),
status: String(item.status || ''),
confidence: Number(item.confidence || 0),
ontologyFields: item.ontology_fields || item.ontologyFields || {},
missingFields,
missingFieldItems: buildStewardFieldItems(missingFields, taskType),
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
}
})
: [], : [],
attachmentGroups: Array.isArray(rawPlan.attachment_groups) attachmentGroups: Array.isArray(rawPlan.attachment_groups)
? rawPlan.attachment_groups.map((item) => ({ ? rawPlan.attachment_groups.map((item) => ({
@@ -99,34 +171,67 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
export function buildStewardPlanMessageText(plan) { export function buildStewardPlanMessageText(plan) {
const normalized = normalizeStewardPlan(plan) const normalized = normalizeStewardPlan(plan)
const taskLines = normalized.tasks.map((task, index) => const nextContext = resolveNextActionContext(normalized)
`${index + 1}. ${task.title || task.taskTypeLabel},交给${task.assignedAgentLabel}` const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
const taskLines = orderedTasks.map((task, index) =>
`${index + 1}. **${buildTaskOrderVerb(index)}${buildTaskOrderTarget(task)}**\n - ${buildTaskOrderActionDescription(task)}`
) )
return [ return [
'**小财管家已完成任务拆解。**', '### 我会这样推进',
'', '',
normalized.summary || `我识别到 ${normalized.tasks.length}待处理任务,请确认后继续执行。`, `我识别到 **${normalized.tasks.length}财务事项**,会按顺序逐步处理,不会一次性把所有动作都执行`,
'', '',
...taskLines ...taskLines,
].join('\n') '',
'如果这个顺序没问题,请回复 **确定**。我会先进入第一步,并在具体步骤里再判断需要你补充哪些信息。'
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
}
export function buildStewardFieldItems(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const seen = new Set()
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field)) {
return false
}
seen.add(field)
return true
})
.map((field) => resolveFieldDisplay(field, taskType))
}
export function formatStewardMissingFieldList(fields = [], taskType = '') {
return buildStewardFieldItems(fields, taskType)
.map((item) => item.hint ? `${item.label}${item.hint}` : item.label)
.join('、')
}
export function formatStewardOntologyFields(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return `${field.label}${value}`
})
.join('')
} }
export function buildStewardSuggestedActions(plan) { export function buildStewardSuggestedActions(plan) {
const normalized = normalizeStewardPlan(plan) const normalized = normalizeStewardPlan(plan)
const taskById = new Map(normalized.tasks.map((task) => [task.taskId, task])) const nextContext = resolveNextActionContext(normalized)
const groupById = new Map(normalized.attachmentGroups.map((group) => [group.groupId, group])) if (!nextContext) {
return normalized.confirmationGroups.map((action) => { return []
const actionType = String(action.action_type || action.actionType || '').trim() }
const taskId = String(action.target_task_id || action.targetTaskId || '').trim() const { action, actionType, task, group } = nextContext
const groupId = String(action.attachment_group_id || action.attachmentGroupId || '').trim() const targetSessionType = actionType === 'confirm_create_application'
const task = taskById.get(taskId) ? SESSION_TYPE_APPLICATION
const group = groupById.get(groupId) : SESSION_TYPE_EXPENSE
const targetSessionType = actionType === 'confirm_create_application' return [
? SESSION_TYPE_APPLICATION {
: SESSION_TYPE_EXPENSE label: buildNextActionLabel(actionType),
return { description: buildNextActionDescription(actionType, normalized, task, group),
label: String(action.label || '确认继续处理'),
description: String(action.description || ''),
icon: actionType === 'confirm_create_application' icon: actionType === 'confirm_create_application'
? 'mdi mdi-file-plus-outline' ? 'mdi mdi-file-plus-outline'
: actionType === 'confirm_attachment_group' : actionType === 'confirm_attachment_group'
@@ -135,17 +240,198 @@ export function buildStewardSuggestedActions(plan) {
action_type: ASSISTANT_SCOPE_ACTION_SWITCH, action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: { payload: {
session_type: targetSessionType, session_type: targetSessionType,
carry_text: buildStewardCarryText(actionType, task, group), carry_text: buildStewardCarryText(actionType, task, group, normalized),
carry_files: actionType !== 'confirm_create_application', carry_files: actionType !== 'confirm_create_application',
auto_submit: true, auto_submit: true,
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''), steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
steward_plan_id: normalized.planId steward_plan_id: normalized.planId,
steward_next_task_id: task?.taskId || '',
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
} }
} }
}) ]
} }
function buildStewardCarryText(actionType, task, group) { function resolveNextActionContext(normalized) {
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
const applicationAction = applicationTask
? findConfirmationAction(normalized, 'confirm_create_application', applicationTask.taskId)
: null
if (applicationAction) {
return {
action: applicationAction,
actionType: 'confirm_create_application',
task: applicationTask,
group: null
}
}
const reimbursementTask = normalized.tasks.find((task) => task.taskType === 'reimbursement')
const reimbursementAction = reimbursementTask
? findConfirmationAction(normalized, 'confirm_create_reimbursement_draft', reimbursementTask.taskId)
: null
if (reimbursementAction) {
return {
action: reimbursementAction,
actionType: 'confirm_create_reimbursement_draft',
task: reimbursementTask,
group: findAttachmentGroupForTask(normalized, reimbursementTask.taskId)
}
}
const attachmentAction = normalized.confirmationGroups.find((action) =>
normalizeActionType(action) === 'confirm_attachment_group'
)
if (attachmentAction) {
const groupId = String(attachmentAction.attachment_group_id || attachmentAction.attachmentGroupId || '').trim()
const group = normalized.attachmentGroups.find((item) => item.groupId === groupId)
const task = normalized.tasks.find((item) => item.taskId === group?.targetTaskId)
return {
action: attachmentAction,
actionType: 'confirm_attachment_group',
task,
group
}
}
const fallbackAction = normalized.confirmationGroups[0]
if (!fallbackAction) {
return null
}
const actionType = normalizeActionType(fallbackAction)
const taskId = String(fallbackAction.target_task_id || fallbackAction.targetTaskId || '').trim()
return {
action: fallbackAction,
actionType,
task: normalized.tasks.find((task) => task.taskId === taskId),
group: null
}
}
function findConfirmationAction(normalized, actionType, taskId) {
return normalized.confirmationGroups.find((action) =>
normalizeActionType(action) === actionType
&& String(action.target_task_id || action.targetTaskId || '').trim() === taskId
) || normalized.confirmationGroups.find((action) => normalizeActionType(action) === actionType)
}
function findAttachmentGroupForTask(normalized, taskId) {
return normalized.attachmentGroups.find((group) => group.targetTaskId === taskId)
|| normalized.attachmentGroups[0]
|| null
}
function normalizeActionType(action) {
return String(action?.action_type || action?.actionType || '').trim()
}
function buildStewardExecutionSummary(normalized) {
const attachmentCount = normalized.attachmentGroups
.reduce((total, group) => total + group.attachmentNames.length, 0)
const summary = [`我识别到 **${normalized.tasks.length} 个待处理任务**`]
if (attachmentCount) {
summary.push(`并形成 ${attachmentCount} 份附件的归集建议`)
}
summary.push(`${buildTaskOrderDescription(normalized)}`)
return summary.join('')
}
function buildOrderedStewardTasks(normalized, nextTask = null) {
if (!nextTask?.taskId) {
return normalized.tasks
}
return [
nextTask,
...normalized.tasks.filter((task) => task.taskId !== nextTask.taskId)
]
}
function buildTaskOrderVerb(index) {
if (index === 0) {
return '先'
}
if (index === 1) {
return '再'
}
return '然后'
}
function buildTaskOrderTarget(task) {
const title = task.title || task.taskTypeLabel
if (task.taskType === 'expense_application') {
return `创建“${title}`
}
if (task.taskType === 'reimbursement') {
return `处理“${title}`
}
return `处理“${title}`
}
function buildTaskOrderActionDescription(task) {
const agent = task.assignedAgentLabel || '对应助手'
if (task.taskType === 'expense_application') {
return `交给${agent}生成申请单核对结果,确认无误后再进入后续动作。`
}
if (task.taskType === 'reimbursement') {
return `交给${agent}整理报销核对结果,等前一步完成后再继续推进。`
}
return `交给${agent}处理,执行前会先让你确认。`
}
function buildTaskOrderDescription(normalized) {
const hasApplication = normalized.tasks.some((task) => task.taskType === 'expense_application')
const hasReimbursement = normalized.tasks.some((task) => task.taskType === 'reimbursement')
if (hasApplication && hasReimbursement) {
return '处理顺序是:先创建申请单,再引导填写报销单。'
}
if (hasApplication) {
return '我会先引导创建申请单并等待你确认。'
}
if (hasReimbursement) {
return '我会引导填写报销单并等待你确认。'
}
return '我会按识别顺序逐项推进,并在执行前等待你确认。'
}
function buildNextTaskLead(task) {
if (task.taskType === 'expense_application') {
return `先创建“${task.title || task.taskTypeLabel}`
}
if (task.taskType === 'reimbursement') {
return `继续填写“${task.title || task.taskTypeLabel}`
}
return `处理“${task.title || task.taskTypeLabel}`
}
function buildNextActionLabel(actionType) {
if (actionType === 'confirm_create_application') {
return '确定,先创建申请单'
}
if (actionType === 'confirm_attachment_group') {
return '确定,确认附件归集'
}
return '确定,继续填写报销单'
}
function buildNextActionDescription(actionType, normalized, task, group) {
const remainingCount = normalized.tasks.filter((item) => item.taskId !== task?.taskId).length
if (actionType === 'confirm_create_application') {
return remainingCount > 0
? '申请助手会先生成申请单核对结果,完成后再继续引导后续报销。'
: '申请助手会生成申请单核对结果,入库前仍需确认。'
}
if (actionType === 'confirm_attachment_group') {
return group?.attachmentNames?.length
? `先归集 ${group.attachmentNames.length} 份附件,再进入报销核对。`
: '先确认附件归集,再进入报销核对。'
}
return group?.attachmentNames?.length
? `报销助手会带入 ${group.attachmentNames.length} 份相关附件生成核对结果。`
: '报销助手会根据当前任务生成报销核对结果。'
}
function buildStewardCarryText(actionType, task, group, normalized = null) {
if (actionType === 'confirm_attachment_group' && group) { if (actionType === 'confirm_attachment_group' && group) {
return [ return [
`我确认将以下附件归集为${group.sceneLabel || '当前报销任务'},请继续整理报销核对信息。`, `我确认将以下附件归集为${group.sceneLabel || '当前报销任务'},请继续整理报销核对信息。`,
@@ -160,14 +446,79 @@ function buildStewardCarryText(actionType, task, group) {
return '我确认继续处理这项财务任务,请按现有流程核对信息。' return '我确认继续处理这项财务任务,请按现有流程核对信息。'
} }
const fields = Object.entries(task.ontologyFields || {}) const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
.filter(([, value]) => String(value || '').trim()) const missingFields = formatStewardMissingFieldList(task.missingFields || [], task.taskType)
.map(([key, value]) => `${key}: ${value}`) const lines = [
return [ actionType === 'confirm_create_application'
`我确认处理“小财管家”识别的任务${task.title || task.taskTypeLabel}`, ? `小财管家已完成意图识别,请先创建申请单${task.title || task.taskTypeLabel}`
: `小财管家已完成意图识别,请继续填写报销单:${task.title || task.taskTypeLabel}`,
task.summary ? `任务摘要:${task.summary}` : '', task.summary ? `任务摘要:${task.summary}` : '',
fields.length ? `本体字段${fields.join('')}` : '', fields ? `已识别信息${fields}` : '',
task.missingFields.length ? `待补充字段${task.missingFields.join('、')}` : '', group?.attachmentNames?.length ? `相关附件${group.attachmentNames.join('、')}` : '',
'请按现有流程生成核对结果,并在需要入库、绑定附件或提交审批前让我再次确认。' group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
].filter(Boolean).join('\n') missingFields ? `还需要补充:${missingFields}` : '',
actionType === 'confirm_create_application'
? '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
]
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
if (remainingTaskText) {
lines.push(remainingTaskText)
}
return lines.filter(Boolean).join('\n')
}
function normalizeFieldKey(field) {
const key = String(field || '').trim()
return FIELD_ALIASES[key] || key
}
function resolveFieldDisplay(field, taskType = '') {
const key = normalizeFieldKey(field)
const config = FIELD_DISPLAY_CONFIG[key] || {
label: key.replace(/_/g, ' '),
hint: ''
}
if (key === 'amount') {
return {
key,
label: taskType === 'expense_application' ? '预计金额' : '报销金额',
hint: taskType === 'expense_application'
? '本次申请预计发生的费用'
: '本次需要报销的实际金额'
}
}
return {
key,
label: config.label,
hint: config.hint
}
}
function buildRemainingTaskText(normalized, currentTaskId) {
const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId)
if (!remainingTasks.length) {
return ''
}
const taskLines = remainingTasks.map((task, index) =>
`${index + 1}. ${task.title || task.taskTypeLabel}${task.assignedAgentLabel}${task.summary || '待继续核对'}`
)
return [
'当前步骤完成后,请继续引导我处理后续任务:',
...taskLines
].join('\n')
}
function buildRemainingTaskPayload(normalized, currentTaskId) {
return normalized.tasks
.filter((task) => task.taskId !== currentTaskId)
.map((task) => ({
task_id: task.taskId,
task_type: task.taskType,
title: task.title,
summary: task.summary,
assigned_agent: task.assignedAgent,
ontology_fields: task.ontologyFields || {},
missing_fields: task.missingFields || []
}))
} }

View File

@@ -6,6 +6,9 @@ import {
} from './stewardPlanModel.js' } from './stewardPlanModel.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js' import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
const STEWARD_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 14
export function useStewardPlanFlow({ export function useStewardPlanFlow({
activeSessionType, activeSessionType,
attachedFiles, attachedFiles,
@@ -21,17 +24,28 @@ export function useStewardPlanFlow({
replaceMessage, replaceMessage,
scrollToBottom, scrollToBottom,
adjustComposerTextareaHeight, adjustComposerTextareaHeight,
executeStewardSuggestedAction,
submitting, submitting,
reviewActionBusy, reviewActionBusy,
sessionSwitchBusy, sessionSwitchBusy,
toast toast
}) { }) {
const stewardTypewriterTimers = new Map()
let stewardTypewriterRunId = 0
let stewardThinkingQueue = Promise.resolve()
function isStewardSession() { function isStewardSession() {
return String(activeSessionType.value || '').trim() === SESSION_TYPE_STEWARD return String(activeSessionType.value || '').trim() === SESSION_TYPE_STEWARD
} }
function clearStewardThinkingTimers() { function clearStewardThinkingTimers() {
// 保留给页面卸载调用;流式版不再使用前端延时器。 stewardTypewriterRunId += 1
stewardThinkingQueue = Promise.resolve()
for (const [timerId, resolve] of stewardTypewriterTimers.entries()) {
globalThis.clearTimeout(timerId)
resolve()
}
stewardTypewriterTimers.clear()
} }
async function submitStewardPlan(options = {}) { async function submitStewardPlan(options = {}) {
@@ -44,7 +58,26 @@ export function useStewardPlanFlow({
const fileNames = files.map((file) => file.name).filter(Boolean) const fileNames = files.map((file) => file.name).filter(Boolean)
const userText = String(options.userText || rawText || `我上传了 ${fileNames.length} 份附件,请小财管家先归集任务。`).trim() const userText = String(options.userText || rawText || `我上传了 ${fileNames.length} 份附件,请小财管家先归集任务。`).trim()
if (isStewardConfirmationText(rawText) && !files.length) {
const pendingContext = findPendingStewardAction()
if (pendingContext) {
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText))
}
pendingContext.action.confirmedByText = true
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
await executeStewardSuggestedAction?.(pendingContext.message, pendingContext.action)
return true
}
}
submitting.value = true submitting.value = true
const streamRunId = beginStewardStreamRun()
if (!options.skipUserMessage) { if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames)) messages.value.push(createMessage('user', userText, fileNames))
@@ -75,23 +108,34 @@ export function useStewardPlanFlow({
files, files,
currentUser: currentUser.value || {} currentUser: currentUser.value || {}
}) })
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload) const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload, streamRunId)
await waitForStewardThinkingQueue(streamRunId)
const typedThinkingEvents = resolveStewardThinkingEvents(pendingMessage.id)
const normalizedPlan = normalizeStewardPlan(plan, { const normalizedPlan = normalizeStewardPlan(plan, {
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true
}) })
replaceMessage(pendingMessage.id, createMessage('assistant', buildStewardPlanMessageText(plan), [], { if (typedThinkingEvents.length) {
normalizedPlan.thinkingEvents = typedThinkingEvents
normalizedPlan.visibleThinkingEventCount = Number.MAX_SAFE_INTEGER
}
const finalText = buildStewardPlanMessageText(plan)
const suggestedActions = buildStewardSuggestedActions(plan)
replaceMessage(pendingMessage.id, createMessage('assistant', '', [], {
id: pendingMessage.id,
assistantName: '小财管家', assistantName: '小财管家',
meta: ['小财管家', '等待确认'], meta: ['小财管家', '输出中'],
stewardPlan: { stewardPlan: {
...normalizedPlan, ...normalizedPlan,
streamStatus: 'completed' streamStatus: 'typing'
}, }
suggestedActions: buildStewardSuggestedActions(plan)
})) }))
await typeStewardPlanText(pendingMessage.id, finalText, normalizedPlan, suggestedActions, streamRunId)
persistSessionState() persistSessionState()
nextTick(scrollToBottom) nextTick(scrollToBottom)
} catch (error) { } catch (error) {
replaceMessage(pendingMessage.id, createMessage('assistant', error?.message || '小财管家规划失败,请稍后重试。', [], { replaceMessage(pendingMessage.id, createMessage('assistant', error?.message || '小财管家规划失败,请稍后重试。', [], {
id: pendingMessage.id,
assistantName: '小财管家', assistantName: '小财管家',
meta: ['小财管家', '规划失败'] meta: ['小财管家', '规划失败']
})) }))
@@ -110,12 +154,76 @@ export function useStewardPlanFlow({
return true return true
} }
function fetchPlanWithStreaming(messageId, requestPayload) { function beginStewardStreamRun() {
const runId = stewardTypewriterRunId + 1
stewardTypewriterRunId = runId
stewardThinkingQueue = Promise.resolve()
return runId
}
async function typeStewardPlanText(messageId, finalText, normalizedPlan, suggestedActions = [], runId = stewardTypewriterRunId) {
const chars = Array.from(String(finalText || ''))
const total = chars.length
let index = 0
while (index < total) {
if (runId !== stewardTypewriterRunId) {
return
}
await waitStewardTypewriterTick(STEWARD_TYPEWRITER_INTERVAL_MS)
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
}
message.text = chars.slice(0, index).join('')
message.meta = ['小财管家', '输出中']
message.stewardPlan = {
...(message.stewardPlan || normalizedPlan),
...normalizedPlan,
streamStatus: 'typing'
}
if (index % 4 === 0 || index === total) {
nextTick(scrollToBottom)
}
}
const message = messages.value.find((item) => item.id === messageId)
if (!message || runId !== stewardTypewriterRunId) {
return
}
message.text = finalText
message.meta = ['小财管家', '等待用户确认']
message.stewardPlan = {
...(message.stewardPlan || normalizedPlan),
...normalizedPlan,
streamStatus: 'completed'
}
message.suggestedActions = suggestedActions
persistSessionState()
nextTick(scrollToBottom)
}
function waitStewardTypewriterTick(intervalMs = STEWARD_TYPEWRITER_INTERVAL_MS) {
return new Promise((resolve) => {
const timerId = globalThis.setTimeout(() => {
stewardTypewriterTimers.delete(timerId)
resolve()
}, intervalMs)
stewardTypewriterTimers.set(timerId, resolve)
})
}
function fetchPlanWithStreaming(messageId, requestPayload, runId) {
if (typeof fetchStewardPlanStream === 'function') { if (typeof fetchStewardPlanStream === 'function') {
return fetchStewardPlanStream(requestPayload, { return fetchStewardPlanStream(requestPayload, {
onEvent: (event) => handleStreamEvent(messageId, event) onEvent: (event) => handleStreamEvent(messageId, event, runId)
}, { }, {
timeoutMs: 20000, timeoutMs: 12000,
idleTimeoutMs: 120000,
timeoutMessage: '小财管家任务规划超时,请稍后重试。' timeoutMessage: '小财管家任务规划超时,请稍后重试。'
}) })
} }
@@ -126,10 +234,68 @@ export function useStewardPlanFlow({
}) })
} }
function handleStreamEvent(messageId, event) { function handleStreamEvent(messageId, event, runId = stewardTypewriterRunId) {
if (event.event !== 'thinking') { if (event.event !== 'thinking') {
return return
} }
stewardThinkingQueue = stewardThinkingQueue
.then(() => typeStewardThinkingEvent(messageId, event.data, runId))
.catch(() => {})
}
async function typeStewardThinkingEvent(messageId, eventData, runId) {
if (runId !== stewardTypewriterRunId) {
return
}
const eventId = String(eventData?.event_id || eventData?.eventId || `thinking-${Date.now()}`).trim()
const title = String(eventData?.title || '').trim()
const fullContent = String(eventData?.content || '').trim()
appendStewardThinkingEvent(messageId, {
...eventData,
event_id: eventId,
eventId,
title,
content: '',
status: 'running'
}, runId)
const chars = Array.from(fullContent)
let index = 0
while (index < chars.length) {
if (runId !== stewardTypewriterRunId) {
return
}
await waitStewardTypewriterTick(STEWARD_THINKING_TYPEWRITER_INTERVAL_MS)
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId)
}
updateStewardThinkingEvent(messageId, eventId, fullContent, 'completed', runId)
persistSessionState()
}
function waitForStewardThinkingQueue(runId) {
return stewardThinkingQueue.then(() => {
if (runId !== stewardTypewriterRunId) {
return
}
})
}
function resolveStewardThinkingEvents(messageId) {
const message = messages.value.find((item) => item.id === messageId)
return Array.isArray(message?.stewardPlan?.thinkingEvents)
? message.stewardPlan.thinkingEvents
: []
}
function appendStewardThinkingEvent(messageId, eventData, runId) {
if (runId !== stewardTypewriterRunId) {
return
}
const message = messages.value.find((item) => item.id === messageId) const message = messages.value.find((item) => item.id === messageId)
if (!message?.stewardPlan) return if (!message?.stewardPlan) return
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents) const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
@@ -137,7 +303,7 @@ export function useStewardPlanFlow({
: [] : []
const normalizedPlan = normalizeStewardPlan({ const normalizedPlan = normalizeStewardPlan({
...message.stewardPlan, ...message.stewardPlan,
thinking_events: [...existingEvents, event.data] thinking_events: [...existingEvents, eventData]
}, { }, {
visibleThinkingEventCount: existingEvents.length + 1 visibleThinkingEventCount: existingEvents.length + 1
}) })
@@ -150,6 +316,76 @@ export function useStewardPlanFlow({
nextTick(scrollToBottom) nextTick(scrollToBottom)
} }
function updateStewardThinkingEvent(messageId, eventId, content, status, runId) {
if (runId !== stewardTypewriterRunId) {
return
}
const message = messages.value.find((item) => item.id === messageId)
if (!message?.stewardPlan) return
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
? message.stewardPlan.thinkingEvents
: []
const nextEvents = existingEvents.map((item) => (
String(item.eventId || item.event_id || '').trim() === eventId
? {
...item,
content,
status
}
: item
))
const normalizedPlan = normalizeStewardPlan({
...message.stewardPlan,
thinking_events: nextEvents
}, {
visibleThinkingEventCount: nextEvents.length
})
message.stewardPlan = {
...message.stewardPlan,
...normalizedPlan,
streamStatus: 'streaming'
}
if (content.length % 4 === 0 || status === 'completed') {
nextTick(scrollToBottom)
}
}
function findPendingStewardAction() {
for (const message of [...messages.value].reverse()) {
if (
message?.role === 'assistant'
&& isPendingStewardActionMessage(message)
&& !message.suggestedActionsLocked
&& Array.isArray(message.suggestedActions)
&& message.suggestedActions.length
) {
return {
message,
action: message.suggestedActions[0]
}
}
}
return null
}
function isPendingStewardActionMessage(message) {
if (message?.stewardPlan) {
return message.stewardPlan.streamStatus !== 'streaming'
}
return (
String(message?.assistantName || '').trim() === '小财管家'
&& Array.isArray(message?.suggestedActions)
&& message.suggestedActions.some((action) =>
String(action?.payload?.steward_plan_id || '').trim()
)
)
}
function isStewardConfirmationText(value) {
const normalized = String(value || '').replace(/\s+/g, '')
return /^(确定|确认|可以|好的|好|继续|继续执行|执行|开始执行|没问题|同意)$/.test(normalized)
}
return { return {
isStewardSession, isStewardSession,
submitStewardPlan, submitStewardPlan,

View File

@@ -5,6 +5,7 @@ import {
} from './travelReimbursementAttachmentModel.js' } from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js' import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import { import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
applyApplicationBusinessTimeContext, applyApplicationBusinessTimeContext,
applyApplicationPolicyEstimateError, applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult, applyApplicationPolicyEstimateResult,
@@ -12,6 +13,7 @@ import {
buildLocalApplicationPreview, buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage, buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview, buildModelRefinedApplicationPreview,
normalizeApplicationPreview,
shouldUseLocalApplicationPreview shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js' } from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js' import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
@@ -24,6 +26,11 @@ import {
shouldUseBudgetCompileReport shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js' } from './budgetAssistantReportModel.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
export function useTravelReimbursementSubmitComposer(ctx) { export function useTravelReimbursementSubmitComposer(ctx) {
const { const {
MAX_ATTACHMENTS, MAX_ATTACHMENTS,
@@ -108,6 +115,228 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const pendingAttachmentAssociations = new Map() const pendingAttachmentAssociations = new Map()
function isStewardDelegatedRun(options = {}) {
return Boolean(options?.stewardContinuation && typeof options.stewardContinuation === 'object')
}
function resolveStewardDelegatedActionLabel(sessionType = '') {
return String(sessionType || '').trim() === 'application'
? '申请单核对'
: '报销单核对'
}
function buildStewardDelegatedPlan(continuation = null, thinkingEvents = [], streamStatus = 'streaming') {
return {
planId: String(continuation?.planId || continuation?.plan_id || 'steward_delegation').trim(),
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function resolveApplicationPreviewMissingFieldsForSteward(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
}
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview)
if (!missingFields.includes('出行方式')) {
return []
}
const iconMap = {
火车: 'mdi mdi-train',
飞机: 'mdi mdi-airplane',
轮船: 'mdi mdi-ferry'
}
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label: mode,
description: `选择${mode}作为本次出行方式,并同步费用测算。`,
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
payload: {
field_key: 'transportMode',
field_label: '出行方式',
value: mode
}
}))
}
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
if (!missingFields.length) {
return fallbackText
}
if (missingFields.includes('出行方式')) {
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'',
`**还需要你补充:${missingFields.join('、')}。**`,
'',
`请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。`
].join('\n')
}
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
const events = [
{
eventId: `${eventPrefix}-confirm`,
title: '接收确认',
content: '已收到你的确认,小财管家继续推进当前任务。'
},
{
eventId: `${eventPrefix}-coordinate`,
title: '协调能力',
content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。`
}
]
const applicationMissingFields = context.applicationPreview
? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview)
: []
if (applicationMissingFields.length) {
events.push({
eventId: `${eventPrefix}-gap`,
title: '识别缺口',
content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。`
})
}
events.push(
{
eventId: `${eventPrefix}-output`,
title: '准备输出',
content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。'
}
)
return events
}
function resolveStewardDelegatedFinalMeta(finalExtras = {}) {
const sourceMeta = Array.isArray(finalExtras.meta) ? finalExtras.meta : []
const sourceLabel = sourceMeta.find((item) =>
String(item || '').trim() && String(item || '').trim() !== STEWARD_ASSISTANT_NAME
)
const requiresConfirmation = Boolean(
finalExtras.applicationPreview ||
finalExtras.reviewPayload ||
(Array.isArray(finalExtras.suggestedActions) && finalExtras.suggestedActions.length)
)
return [
STEWARD_ASSISTANT_NAME,
requiresConfirmation ? '等待用户确认' : '已完成',
sourceLabel || ''
].filter(Boolean).slice(0, 3)
}
function waitStewardDelegatedTick(intervalMs) {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, intervalMs)
})
}
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
}
message.text = ''
message.assistantName = STEWARD_ASSISTANT_NAME
message.meta = [STEWARD_ASSISTANT_NAME, '思考中']
message.suggestedActions = []
message.stewardContinuation = continuation
message.stewardPlan = buildStewardDelegatedPlan(continuation, [], 'streaming')
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
const thinkingEvents = buildStewardDelegatedThinkingEvents(context.sessionType, continuation, context)
for (const eventData of thinkingEvents) {
const event = {
eventId: eventData.eventId,
stage: 'delegated_action',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(String(eventData.content || ''))
for (let index = 0; index < chars.length; index += 1) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
event.content = chars.slice(0, index + 1).join('')
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
event.content = String(eventData.content || '')
event.status = 'completed'
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
persistSessionState()
}
const text = String(finalText || '')
message.text = ''
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
const chars = Array.from(text)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
message.text = chars.slice(0, index + 1).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
Object.assign(message, finalExtras, {
id: messageId,
text,
assistantName: STEWARD_ASSISTANT_NAME,
meta: resolveStewardDelegatedFinalMeta(finalExtras),
stewardContinuation: continuation,
stewardPlan: buildStewardDelegatedPlan(continuation, [...typedEvents], 'completed')
})
persistSessionState()
nextTick(scrollToBottom)
}
function resetStewardDelegatedInsightState() {
resetFlowRun({ startedAt: 0, openDrawer: false })
insightPanelCollapsed.value = true
currentInsight.value = {
intent: 'welcome',
agent: null
}
}
function isSubmittedApplicationDraftPayload(draftPayload) { function isSubmittedApplicationDraftPayload(draftPayload) {
return ( return (
String(draftPayload?.draft_type || '').trim() === 'expense_application' String(draftPayload?.draft_type || '').trim() === 'expense_application'
@@ -376,16 +605,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}) })
} }
function buildBackendMessage(rawText, fileNames, ocrSummary = '') { function buildBackendMessage(rawText, fileNames, ocrSummary = '', sessionTypeOverride = '') {
const parts = [] const parts = []
const normalizedText = String(rawText || '').trim() const normalizedText = String(rawText || '').trim()
const sessionType = String(activeSessionType.value || '').trim() const sessionType = String(sessionTypeOverride || activeSessionType.value || '').trim()
const isKnowledgeMessage = sessionType === 'knowledge'
if (normalizedText) { if (normalizedText) {
parts.push(normalizedText) parts.push(normalizedText)
} else if (fileNames.length) { } else if (fileNames.length) {
parts.push( parts.push(
isKnowledgeSession.value isKnowledgeMessage
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。` ? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
: sessionType === 'application' : sessionType === 'application'
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。` ? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
@@ -440,7 +670,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return currentUser.value || user return currentUser.value || user
} }
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null) { async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
const user = await resolveApplicationPreviewUser() const user = await resolveApplicationPreviewUser()
const localPreview = applyApplicationBusinessTimeContext( const localPreview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(rawText, user), buildLocalApplicationPreview(rawText, user),
@@ -474,7 +704,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
user_id: user.username || user.name || 'anonymous', user_id: user.username || user.name || 'anonymous',
context_json: { context_json: {
...buildExpenseApplicationOntologyContext(user), ...buildExpenseApplicationOntologyContext(user),
session_type: activeSessionType.value, session_type: String(sessionTypeOverride || activeSessionType.value || '').trim(),
entry_source: props.entrySource, entry_source: props.entrySource,
user_input_text: rawText user_input_text: rawText
} }
@@ -516,7 +746,10 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const rawText = resolveComposerSubmitText(options.rawText).trim() const rawText = resolveComposerSubmitText(options.rawText).trim()
const systemGenerated = Boolean(options.systemGenerated) const systemGenerated = Boolean(options.systemGenerated)
const appendToCurrentFlow = Boolean(options.appendToCurrentFlow) const appendToCurrentFlow = Boolean(options.appendToCurrentFlow)
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value) const effectiveSessionType = String(options.sessionTypeOverride || activeSessionType.value || '').trim()
const stewardDelegated = isStewardDelegatedRun(options)
const effectiveIsKnowledgeSession = effectiveSessionType === 'knowledge'
const normalizedFiles = effectiveIsKnowledgeSession ? [] : Array.from(options.files ?? attachedFiles.value)
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS) const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
const files = fileMergeResult.files const files = fileMergeResult.files
const detailScopedClaimId = resolveDetailScopedClaimId() const detailScopedClaimId = resolveDetailScopedClaimId()
@@ -551,8 +784,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
detail_scope_claim_id: detailScopedClaimId detail_scope_claim_id: detailScopedClaimId
} }
: optionExtraContext : optionExtraContext
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext() const selectedBusinessTimeContext = effectiveIsKnowledgeSession ? null : buildComposerBusinessTimeContext()
const extraContext = isKnowledgeSession.value const extraContext = effectiveIsKnowledgeSession
? initialExtraContext ? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext) : mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim() const reviewAction = String(extraContext.review_action || '').trim()
@@ -569,15 +802,15 @@ export function useTravelReimbursementSubmitComposer(ctx) {
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim() String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
) )
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed) const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, { const waitForExpenseIntentConfirmation = !stewardDelegated && shouldRequestExpenseIntentConfirmation(rawText, {
sessionType: activeSessionType.value, sessionType: effectiveSessionType,
attachmentCount: files.length, attachmentCount: files.length,
reviewAction, reviewAction,
hasSelectedExpenseType, hasSelectedExpenseType,
hasConfirmedExpenseIntent hasConfirmedExpenseIntent
}) })
const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, { const waitForExpenseSceneSelection = !stewardDelegated && !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
sessionType: activeSessionType.value, sessionType: effectiveSessionType,
attachmentCount: files.length, attachmentCount: files.length,
reviewAction, reviewAction,
hasSelectedExpenseType hasSelectedExpenseType
@@ -587,7 +820,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
String(options.userText || '').trim() || String(options.userText || '').trim() ||
resolveComposerDisplaySubmitText(rawText) || resolveComposerDisplaySubmitText(rawText) ||
rawText || rawText ||
(isKnowledgeSession.value (effectiveIsKnowledgeSession
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。` ? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
: resolvedUploadDisposition === 'continue_existing' : resolvedUploadDisposition === 'continue_existing'
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。` ? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
@@ -596,7 +829,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`) : `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (shouldUseBudgetCompileReport(rawText, { if (shouldUseBudgetCompileReport(rawText, {
sessionType: activeSessionType.value, sessionType: effectiveSessionType,
entrySource: props.entrySource, entrySource: props.entrySource,
budgetContext: props.initialBudgetContext budgetContext: props.initialBudgetContext
}) && !reviewAction) { }) && !reviewAction) {
@@ -627,7 +860,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}) })
} }
const scopeGuard = resolveAssistantScopeGuard(rawText, activeSessionType.value, { const scopeGuard = resolveAssistantScopeGuard(rawText, effectiveSessionType, {
attachmentCount: files.length, attachmentCount: files.length,
hasActiveReviewPayload: Boolean(activeReviewPayload.value), hasActiveReviewPayload: Boolean(activeReviewPayload.value),
reviewAction reviewAction
@@ -652,34 +885,45 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
if (shouldUseLocalApplicationPreview(rawText, { if (shouldUseLocalApplicationPreview(rawText, {
sessionType: activeSessionType.value, sessionType: effectiveSessionType,
attachmentCount: files.length, attachmentCount: files.length,
reviewAction, reviewAction,
systemGenerated systemGenerated
})) { })) {
const intentStartedAt = Date.now() const intentStartedAt = Date.now()
const reviewStartedAt = intentStartedAt const reviewStartedAt = intentStartedAt
resetFlowRun() if (stewardDelegated) {
startFlowStep('intent', { resetStewardDelegatedInsightState()
title: '业务意图识别', } else {
tool: 'ontology.intent_detection', resetFlowRun()
detail: '正在识别是否为费用申请事项...' startFlowStep('intent', {
}) title: '业务意图识别',
startFlowStep('application-review-preview', { tool: 'ontology.intent_detection',
title: '申请信息核对', detail: '正在识别是否为费用申请事项...'
tool: 'ontology.application_review', })
detail: '正在复核申请信息,并查询交通票价...' startFlowStep('application-review-preview', {
}) title: '申请信息核对',
tool: 'ontology.application_review',
detail: '正在复核申请信息,并查询交通票价...'
})
}
if (!options.skipUserMessage) { if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames)) messages.value.push(createMessage('user', userText, fileNames))
} }
const pendingMessage = createMessage( const pendingMessage = createMessage(
'assistant', 'assistant',
'正在复核申请信息,并查询交通票价,请稍候。', stewardDelegated ? '' : '正在复核申请信息,并查询交通票价,请稍候。',
[], [],
{ stewardDelegated
meta: ['模型复核中'] ? {
} assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
stewardContinuation: options.stewardContinuation || null,
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
}
: {
meta: ['模型复核中']
}
) )
messages.value.push(pendingMessage) messages.value.push(pendingMessage)
composerDraft.value = '' composerDraft.value = ''
@@ -697,27 +941,50 @@ export function useTravelReimbursementSubmitComposer(ctx) {
submitting.value = true submitting.value = true
try { try {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText, selectedBusinessTimeContext) const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
const reviewStatus = String(meta?.[1] || '').trim() rawText,
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt) selectedBusinessTimeContext,
completeFlowStep( effectiveSessionType
'application-review-preview',
reviewStatus === '模型复核完成'
? '模型复核完成,已生成申请核对表'
: reviewStatus === '模型复核失败'
? '模型复核失败,已生成临时核对表'
: '模型未返回稳定结果,已完成规则兜底核对',
Date.now() - reviewStartedAt
) )
replaceMessage(pendingMessage.id, createMessage( const reviewStatus = String(meta?.[1] || '').trim()
'assistant', if (!stewardDelegated) {
buildLocalApplicationPreviewMessage(applicationPreview), completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
[], completeFlowStep(
{ 'application-review-preview',
meta, reviewStatus === '模型复核完成'
applicationPreview ? '模型复核完成,已生成申请核对表'
} : reviewStatus === '模型复核失败'
)) ? '模型复核失败,已生成临时核对表'
: '模型未返回稳定结果,已完成规则兜底核对',
Date.now() - reviewStartedAt
)
}
if (stewardDelegated) {
await typeStewardDelegatedMessage(
pendingMessage.id,
buildLocalApplicationPreviewMessage(applicationPreview),
{
meta,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
stewardContinuation: options.stewardContinuation || null
}
)
} else {
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildLocalApplicationPreviewMessage(applicationPreview),
[],
{
meta,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
))
}
persistSessionState() persistSessionState()
nextTick(scrollToBottom) nextTick(scrollToBottom)
} finally { } finally {
@@ -726,7 +993,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return null return null
} }
if (await promptUnlinkedReceiptFolderIfNeeded({ if (!stewardDelegated && await promptUnlinkedReceiptFolderIfNeeded({
detailScopedClaimId, detailScopedClaimId,
files, files,
fileNames, fileNames,
@@ -741,7 +1008,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
const hasUnsavedReviewDraft = Boolean( const hasUnsavedReviewDraft = Boolean(
!isKnowledgeSession.value && !stewardDelegated &&
!effectiveIsKnowledgeSession &&
files.length && files.length &&
activeReviewPayload.value && activeReviewPayload.value &&
!String(draftClaimId.value || '').trim() && !String(draftClaimId.value || '').trim() &&
@@ -782,7 +1050,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
if ( if (
!isKnowledgeSession.value && !stewardDelegated &&
!effectiveIsKnowledgeSession &&
files.length && files.length &&
!resolvedUploadDisposition && !resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt && !options.skipDraftAssociationPrompt &&
@@ -836,18 +1105,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
} }
if (!appendToCurrentFlow) { if (stewardDelegated) {
resetStewardDelegatedInsightState()
} else if (!appendToCurrentFlow) {
resetFlowRun() resetFlowRun()
} else { } else {
clearFlowSimulationTimers() clearFlowSimulationTimers()
} }
if (isApplicationSubmitOperation) { if (!stewardDelegated && isApplicationSubmitOperation) {
startFlowStep('application-submit-success', { startFlowStep('application-submit-success', {
title: '申请单提交成功', title: '申请单提交成功',
tool: 'ApplicationSubmit', tool: 'ApplicationSubmit',
detail: '正在提交费用申请...' detail: '正在提交费用申请...'
}) })
} else if (rawText && !reviewAction) { } else if (!stewardDelegated && rawText && !reviewAction) {
startFlowStep('intent', '正在识别业务意图...') startFlowStep('intent', '正在识别业务意图...')
if (waitForExpenseIntentConfirmation) { if (waitForExpenseIntentConfirmation) {
startExpenseIntentConfirmationFlowPreview(rawText) startExpenseIntentConfirmationFlowPreview(rawText)
@@ -906,19 +1177,26 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const pendingMessage = createMessage( const pendingMessage = createMessage(
'assistant', 'assistant',
options.pendingText || ( stewardDelegated ? '' : options.pendingText || (
isKnowledgeSession.value effectiveIsKnowledgeSession
? '正在整理财务知识答案...' ? '正在整理财务知识答案...'
: activeSessionType.value === 'application' : effectiveSessionType === 'application'
? '正在识别申请信息并查询交通票价...' ? '正在识别申请信息并查询交通票价...'
: activeSessionType.value === 'approval' : effectiveSessionType === 'approval'
? '正在查询审核上下文并整理风险提示...' ? '正在查询审核上下文并整理风险提示...'
: '正在识别并整理右侧核对信息...' : '正在识别并整理右侧核对信息...'
), ),
[], [],
{ stewardDelegated
meta: ['处理中'] ? {
} assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
stewardContinuation: options.stewardContinuation || null,
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
}
: {
meta: ['处理中']
}
) )
messages.value.push(pendingMessage) messages.value.push(pendingMessage)
@@ -946,14 +1224,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
if (files.length) { if (files.length) {
const ocrStartedAt = Date.now() const ocrStartedAt = Date.now()
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt }) if (!stewardDelegated) {
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
}
if (recognizedAttachmentData) { if (recognizedAttachmentData) {
ocrPayload = recognizedAttachmentData.ocrPayload ocrPayload = recognizedAttachmentData.ocrPayload
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments) ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
ocrDocuments = [...recognizedAttachmentData.ocrDocuments] ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews] ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
rememberFilePreviews(ocrFilePreviews) rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt) if (!stewardDelegated) {
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
}
} else { } else {
try { try {
ocrPayload = await recognizeOcrFiles(files, { ocrPayload = await recognizeOcrFiles(files, {
@@ -964,14 +1246,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
ocrDocuments = normalizeOcrDocuments(ocrPayload) ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload) ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
rememberFilePreviews(ocrFilePreviews) rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt) if (!stewardDelegated) {
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
}
} catch (error) { } catch (error) {
console.warn('OCR request failed:', error) console.warn('OCR request failed:', error)
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt) if (!stewardDelegated) {
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
}
} }
} }
if (resolvedUploadDisposition === 'continue_existing') { if (!stewardDelegated && resolvedUploadDisposition === 'continue_existing') {
replaceMessage(pendingMessage.id, { replaceMessage(pendingMessage.id, {
...pendingMessage, ...pendingMessage,
text: attachmentAssociationConfirmed text: attachmentAssociationConfirmed
@@ -1069,15 +1355,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
extraContext.review_action = 'create_new_claim_from_documents' extraContext.review_action = 'create_new_claim_from_documents'
} }
if (!isApplicationSubmitOperation) { if (!isApplicationSubmitOperation && !stewardDelegated) {
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), { startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
attachmentCount: effectiveFileNames.length, attachmentCount: effectiveFileNames.length,
waitForSceneSelection: waitForExpenseSceneSelection waitForSceneSelection: waitForExpenseSceneSelection
}) })
} }
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary) const backendMessage = buildBackendMessage(
const orchestratorOptions = isKnowledgeSession.value rawText,
effectiveFileNames,
effectiveOcrSummary,
effectiveSessionType
)
const orchestratorOptions = effectiveIsKnowledgeSession
? { ? {
timeoutMs: 75000, timeoutMs: 75000,
timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。' timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。'
@@ -1117,22 +1408,22 @@ export function useTravelReimbursementSubmitComposer(ctx) {
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '', finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {}, employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
...buildClientTimeContext(), ...buildClientTimeContext(),
session_type: activeSessionType.value, session_type: effectiveSessionType,
entry_source: props.entrySource, entry_source: props.entrySource,
user_input_text: systemGenerated ? '' : rawText, user_input_text: systemGenerated ? '' : rawText,
attachment_names: effectiveFileNames, attachment_names: effectiveFileNames,
attachment_count: effectiveFileNames.length, attachment_count: effectiveFileNames.length,
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined, draft_claim_id: effectiveIsKnowledgeSession ? undefined : draftClaimId.value || undefined,
ocr_summary: effectiveOcrSummary, ocr_summary: effectiveOcrSummary,
ocr_documents: effectiveOcrDocuments, ocr_documents: effectiveOcrDocuments,
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}), ...(linkedRequest.value && !effectiveIsKnowledgeSession ? { request_context: linkedRequest.value } : {}),
...extraContext ...extraContext
} }
}, },
orchestratorOptions orchestratorOptions
) )
responsePayload = payload responsePayload = payload
flowRunId.value = String(payload?.run_id || '').trim() flowRunId.value = stewardDelegated ? '' : String(payload?.run_id || '').trim()
let flowRunDetail = null let flowRunDetail = null
if (flowRunId.value) { if (flowRunId.value) {
flowRunDetail = await refreshFlowRunDetail() flowRunDetail = await refreshFlowRunDetail()
@@ -1140,7 +1431,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
draftClaimId.value = draftClaimId.value =
isKnowledgeSession.value effectiveIsKnowledgeSession
? '' ? ''
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value : String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
@@ -1163,33 +1454,54 @@ export function useTravelReimbursementSubmitComposer(ctx) {
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload), queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null, draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null, reviewPayload: payload?.result?.review_payload || null,
reviewPanelScope: resolveReviewPanelScope({ reviewPanelScope: stewardDelegated
reviewPayload: payload?.result?.review_payload || null, ? ''
reviewAction: reviewActionResult, : resolveReviewPanelScope({
fileCount: files.length, reviewPayload: payload?.result?.review_payload || null,
rawText reviewAction: reviewActionResult,
}), fileCount: files.length,
rawText
}),
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [], riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
operationFeedback: buildOperationFeedbackState(operationFeedbackContext) operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
stewardContinuation: options.stewardContinuation || null
}) })
replaceMessage(pendingMessage.id, assistantMessage) if (stewardDelegated) {
const nextInsight = buildAgentInsight( await typeStewardDelegatedMessage(
payload, pendingMessage.id,
effectiveFileNames, assistantMessage.text,
mergeFilePreviews(filePreviews, ocrFilePreviews) {
) ...assistantMessage,
if (nextInsight.agent) { id: pendingMessage.id,
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope reviewPanelScope: ''
},
{
sessionType: effectiveSessionType,
stewardContinuation: options.stewardContinuation || null
}
)
resetStewardDelegatedInsightState()
} else {
replaceMessage(pendingMessage.id, assistantMessage)
const nextInsight = buildAgentInsight(
payload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
if (nextInsight.agent) {
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
}
currentInsight.value = nextInsight
completeFlowResult(payload, flowRunDetail)
} }
currentInsight.value = nextInsight
completeFlowResult(payload, flowRunDetail)
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewActionResult)) { if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewActionResult)) {
emitSavedDraftRefresh(payload?.result?.draft_payload || null) emitSavedDraftRefresh(payload?.result?.draft_payload || null)
} }
persistSessionState() persistSessionState()
nextTick(scrollToBottom) nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim() const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) { if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
const persistComposerFilesToDraft = async () => { const persistComposerFilesToDraft = async () => {
try { try {
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files) const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
@@ -1216,19 +1528,31 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
} catch (error) { } catch (error) {
clearFlowSimulationTimers() clearFlowSimulationTimers()
failCurrentFlowStep(error) if (!stewardDelegated) {
failCurrentFlowStep(error)
}
replaceMessage( replaceMessage(
pendingMessage.id, pendingMessage.id,
createMessage( createMessage(
'assistant', 'assistant',
error?.message || '无法连接后端 Orchestrator请稍后重试。', error?.message || '无法连接后端 Orchestrator请稍后重试。',
[], [],
{ stewardDelegated
meta: ['调用失败'] ? {
} assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '调用失败'],
stewardContinuation: options.stewardContinuation || null
}
: {
meta: ['调用失败']
}
) )
) )
currentInsight.value = buildErrorInsight(error, fileNames) if (stewardDelegated) {
resetStewardDelegatedInsightState()
} else {
currentInsight.value = buildErrorInsight(error, fileNames)
}
persistSessionState() persistSessionState()
} finally { } finally {
submitting.value = false submitting.value = false

View File

@@ -222,6 +222,10 @@ test('documents center can mark all unread documents as read from toolbar', () =
documentsCenterView, documentsCenterView,
/function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/ /function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/
) )
assert.match(
documentsCenterView,
/function resolveLiveDocumentRow\(row\) \{[\s\S]*isNewDocument: isNewDocument\(row, viewedDocumentKeys\.value\)[\s\S]*const visibleRows = computed\(\(\) => \{[\s\S]*\.map\(resolveLiveDocumentRow\)/
)
assert.match(documentListSharedStyles, /\.mark-read-btn\s*\{[\s\S]*border:\s*1px solid #fecaca;/) assert.match(documentListSharedStyles, /\.mark-read-btn\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
}) })