20 Commits

Author SHA1 Message Date
caoxiaozhu
7ced9d93bc chore(rules): 更新公司通信费报销规则表 2026-06-30 11:41:09 +08:00
caoxiaozhu
876cf342ac docs(dev-log): 记录多 task task2 时序缺陷 bug 修复 2026-06-30 11:40:46 +08:00
caoxiaozhu
43779f8f2c fix(web): 多 task 串行推进 task2 不再被预览生成时提前触发
onPreviewReadyForNextTask 在 task1 申请核对表刚生成、用户还没操作时就
提前拉起 task2,与用户后续在 task1 上的保存草稿/提交操作互相打架,导致
task2 完全无反应。移除该提前推进回调,统一由 onApplicationActionCompleted
在 task1 真正完成后再推进 task2。

- useWorkbenchAiApplicationPreviewFlow: 删除预览生成时的提前推进分支
- usePersonalWorkbenchAiMode: startModelPlannedApplicationPreview 不再传 onPreviewReadyForNextTask
- useWorkbenchAiActionRouter: 低置信确认按钮分支同步删除该回调
- 新增时序回归测试:预览生成不提前推进、保存草稿后才推进
- 更新两处源码正则断言为 doesNotMatch
2026-06-30 11:40:31 +08:00
caoxiaozhu
08f023243e fix(web): 多 task 草稿自动保存后继续推进下一 task
useWorkbenchAiApplicationPreviewFlow 在自动保存草稿分支透传 onApplicationActionCompleted,
确保草稿/提交完成后仍能按 remaining tasks 推进,修复多 task 报销场景后续步骤不启动。
更新 application-context-submit/intent-planner-model 测试,补充 bug 日志与开发日志。
2026-06-29 20:17:36 +08:00
caoxiaozhu
6bdaeed6d4 chore: 忽略 .zcode 本地目录并更新规则表与开发日志
- .gitignore 新增 .zcode/(ZCode 工具本地配置,不入库)
- 更新交通/通信/差旅等财务规则表
- 补充 2026-06-25/26 开发日志(chat UI SaaS 化、主题企业 AI 风格、bug 日志)
2026-06-26 22:42:46 +08:00
caoxiaozhu
d5a8f84703 refactor(web): 应用外壳/差旅详情/报销创建视图适配主题与多 task
- AppShellRouteView/useAppShell 适配主题皮肤与会话入口
- TravelRequestDetailView/travelRequestDetailSetup 差旅详情适配,travel-request-detail-view.css 调整
- TravelReimbursementCreateView/useTravelReimbursementCreateViewLifecycle 创建视图适配
- 更新 app-shell-financial-assistant-entry/travel-request-detail-risk-advice 测试
2026-06-26 22:42:29 +08:00
caoxiaozhu
c4b5fcc067 feat(web): AI 工作台多 task 串行推进与会话适配
- useWorkbenchAiApplicationPreviewFlow/useWorkbenchAiActionRouter/useWorkbenchAiCommandIntents 支持 task1 完成后自动推进 task2,确认按钮直接拉起申请预览,草稿/提交成功后继续推进下一 task
- workbenchAiIntentPlannerModel/workbenchAiMessageModel/workbenchAiCommandIntentModel 适配多 task 意图规划与消息结构
- aiApplicationPreviewActions/aiApplicationPrecheckModel/aiExpenseDraftModel/aiWorkbenchConversationStore 草稿与会话存储适配
- PersonalWorkbenchAiMode 与样式适配,更新 preview-actions/expense-draft/conversation-store/fast-preview/action-router/command-intent/intent-planner 测试
2026-06-26 22:42:23 +08:00
caoxiaozhu
5753899eb3 feat(web): 主题皮肤系统与 LLM 设置面板重构
- useThemeSkin 重构主题皮肤应用逻辑,支持企业 AI 风格主题切换
- settingsModelHelper 新增主题与模型表字段映射,useSettings 适配
- LlmSettingsPanel/SettingsView 面板重构,支持多模型行编辑与主题区块
- settings-view.css 适配主题样式,新增 settings-theme-section 测试,更新 settings-llm-section 测试
2026-06-26 22:42:00 +08:00
caoxiaozhu
9c3fa80d22 feat(server): 设置持久化新增 LLM 模型表与主题字段
- SettingsLlmForm 新增 models 列表(SettingsModelRow:slot/provider/url/apiKey/modelId/type),支持多模型行持久化
- settings 服务读写模型表与主题相关字段,更新 test_settings_persistence 测试
2026-06-26 22:41:40 +08:00
caoxiaozhu
43c3ff860c fix(web): steward plan 确认按钮直接拉起申请预览,不丢失 remaining tasks
根因:steward plan 的'确认创建申请单'按钮 action_type 是
ASSISTANT_SCOPE_ACTION_SWITCH,handleInlineSuggestedAction 没有匹配分支,
落到 startInlineConversation 重新发起对话,steward_remaining_tasks 完全丢失。

修复:当 payload 有 steward_current_task + session_type=application +
task_type=expense_application 时,直接调 startAiApplicationPreviewFromAction
(会透传 steward_remaining_tasks 到申请预览 message),不走 startInlineConversation。

这样保存草稿成功后,targetMessage 上有 stewardRemainingTasks,
buildApplicationPreviewNextTaskAction 能生成'继续处理费用报销'按钮。
2026-06-26 11:40:44 +08:00
caoxiaozhu
3e4b1e1597 fix(web): 保存草稿/提交成功后也推进到下一个 task
之前多 task 串行推进只在 steward action 执行链(executeInlineStewardAction)
生效,但保存草稿走的是 application preview 流(executeInlineApplicationPreviewAction),
成功后只显示'查看单据',不会提示继续下一个报销 task。

- startAiApplicationPreviewFromAction 透传 steward_remaining_tasks 到 preview message
- startAiApplicationPreview 把 stewardRemainingTasks 存到 message 上
- executeInlineApplicationPreviewAction 成功后:检查 targetMessage.stewardRemainingTasks,
  有剩余 task 则追加'继续处理费用报销'按钮(复用 steward_confirm_flow 分支)
- 新增 buildApplicationPreviewNextTaskAction 辅助函数

现在保存草稿/提交申请成功后,用户会看到:[查看单据] [继续处理费用报销]
2026-06-26 11:21:16 +08:00
caoxiaozhu
3a5664c4da feat(web): 多 task 串行推进 - task1 完成后自动展示 task2 确认按钮
场景:'出差并且报销招待费'→LLM 拆出 2 个 task→先做完出差申请→
完成后自动展示'继续处理费用报销'按钮→用户确认后推进到报销流程。

- 新增 buildNextTaskSuggestedAction:从 actionPayload.steward_remaining_tasks
  取第一个剩余 task,根据 task_type 生成推进按钮(steward_confirm_flow)
- executeInlineStewardAction 成功后:除'查看单据'外,追加'继续处理下一个 task'按钮
- 用户点击推进按钮复用 handleInlineSuggestedAction 的 steward_confirm_flow 分支
- 前端 28 passed 无回归
2026-06-26 10:51:43 +08:00
caoxiaozhu
d139a63e64 refactor(server): 意图识别改 LLM 驱动,规则只做闲聊拦截+resume 兜底
规则不再判断'这是哪个业务场景'——那交给 LLM function call。
规则只保留两个不可替代职责:闲聊拦截(省 LLM 成本)、resume 确定性兜底。

- gate_classify 简化:删掉规则匹配门(94 词 CHOICE 匹配)和 ambiguous 提前判断
- 新增 _is_lightweight_off_topic:只拦 greeting+meaningless,不依赖业务关键词
- HANDLER_ONLY 改为 LLM 输出驱动:LLM 返回 query_travel_standard 后转 handler
- 图拓扑简化:gate_classify 只输出 off_topic|resume|model_intent
- 验证:76 passed;复合场景'出差并且报销招待费'→LLM 返回 2 task
2026-06-26 10:19:04 +08:00
caoxiaozhu
8a2ae6eb75 fix(server): gate_classify 复用 _classify_irrelevant_input 修复 off_topic 误杀
回归问题:P1.3 重构时 gate_classify 的 off_topic 门用了 scene_registry
的 35 个 signal_keywords,丢掉了 legacy 的 73 个关键词(城市名/时间词/金额词/
交通词等),导致'下周去上海''昨天打车30块'等正常业务输入被误判 off_topic,
根本进不了 LLM。

修复:gate_classify 的 off_topic 门改用成熟的 _classify_irrelevant_input
(94 词 + registry 信号词 + greeting/meaningless 细分),scene_registry 的
signal_keywords 只用于规则匹配门(CHOICE 路由)。删除残缺的 _matches_any_signal。

验证:76 passed;实测'下周去上海'→llm_function_call、'昨天打车30块'→
llm_function_call、'你好'→off_topic(正确拦截)。
2026-06-25 16:06:52 +08:00
caoxiaozhu
992cf71fa1 refactor(server): Phase 1 图拓扑重构 - LangGraph 成为唯一编排者
P1.3-P1.7:把 endpoint 补丁搬进图节点,门控收敛到 gate_classify 单一决策点。

- StewardGraphState 扩展:recent_history/steward_state/gate_decision/gate_scene_id/conversation_id
- 新增 5 个图节点:load_context(读历史+state)/gate_classify(统一门控)/execute_scene_handler/resume_recent_task/pending_flow wrapper
- 图拓扑从 5 节点重构为 10 节点:load_context → gate_classify → {off_topic/handler_only/resume/ambiguous/model_intent} → attach_action_steps
- gate_classify 四步裁决:resume门 → off_topic门 → 规则匹配门 → LLM门
- resume 门控优先于 off_topic,避免'再提交'被误判闲聊
- schema 放宽 planning_source/next_action Literal → str,支持 scene_handler:*/context_resume/answer_only
- endpoint 按 planner 类型分发 build_plan(LangGraph 接 db,legacy 不接)
- 76 passed + 4 场景端到端验证(出差申请/再提交/查差旅标准/闲聊)
2026-06-25 15:44:20 +08:00
caoxiaozhu
54356ba81a refactor(server): scene 注册表骨架 + 统一门控管道设计文档
Phase 1 P1.1-P1.2:为后端门控收口提供声明式场景注册基础设施。

- 新建 scenes/ 目录:gate_rules(GateRule/SceneRoute 枚举)、scene_descriptor(SceneDescriptor dataclass)、scene_registry(SceneRegistry 单例)
- 3 个场景迁入 descriptor:expense_application / reimbursement / query_travel_standard
- __init__.py 的 bootstrap_scenes 在 import 时注册 + 运行时绑定 handler/builder/executor(解决循环 import)
- 查询场景 priority=50 优先于 MODEL_ONLY 场景,确保规则匹配先于 LLM
- 落地 UNIFIED_GATE_PIPELINE.md 架构文档:目标架构 / 验收标准(接入 O(1))/ 3 阶段迁移路径
- 76 passed,scene 注册表未破坏现有代码;与 intent_registry 暂时并存,P1.3-P1.8 会统一迁移
2026-06-25 15:09:16 +08:00
caoxiaozhu
e9d7c56d5b feat(server): 会话上下文保留(LLM 历史 + 确定性兜底双保险)
解决用户删除草稿后说'再提交'丢失上下文的问题:

- steward.py 新增 _inject_recent_conversation_history:build_plan 前读最近 10 条对话注入 context_json
- steward_intent_agent.py 的 _build_messages 把 recent_history 暴露给模型,system prompt 加确认类话术引导
- 新建 steward_context_resume.py:should_resume_recent_task 检测'再提交'类话术 + state 有可恢复 flow,attach_resumed_task 从 state 恢复 task
- 两个 plan 入口(/plans 和 /plans/stream)都已接入双保险
- 后端 67 passed,端到端验证'上海出差→再提交'成功恢复 task
2026-06-25 15:08:56 +08:00
caoxiaozhu
2ebc2756bf fix(server): 兼容模型 tasks 输出为 JSON 字符串与 flow_id 误填
- StewardModelPlanBuilder 解析 tasks 时兼容模型把数组序列化为字符串的情况,先反序列化;JSON 截断/语法不完整时用正则抢救 task_type/requested_action/ontology_fields 等关键字段
- task_type 未命中意图时尝试 flow_id→task_type 映射还原,避免模型把 flow_id(如 travel_application)误填为 task_type 导致正确意图被丢弃
2026-06-25 12:25:18 +08:00
caoxiaozhu
606a88c805 chore: stewardPlanModel 适配注册表动作结构并更新规则表与日志
- stewardPlanModel 适配新的意图注册表动作步骤结构
- 更新交通/通信/差旅等财务规则表,补 2026-06-25 work-log
2026-06-25 11:50:11 +08:00
caoxiaozhu
eaada4bc57 refactor(server): steward 意图改用声明式注册表编排
- 新增 steward_intent_registry,IntentDescriptor 统一描述意图的识别关键词、动作步骤构建、字段白名单与副作用集合,替代分散的 if/else
- 新增 steward_intent_bootstrap 注册 expense_application 等意图;新增 steward_query_executors 提供差旅标准查询的无副作用执行与城市/席别标签化输出
- action_contracts/action_executor/graph_planner/intent_agent/model_plan_builder/planner_extraction/fallback 适配注册表,识别与执行分发自动从注册表取数
- 新增 intent_registry/query_executors 测试,更新 intent_agent 测试
2026-06-25 11:50:02 +08:00
91 changed files with 6255 additions and 901 deletions

1
.gitignore vendored
View File

@@ -35,6 +35,7 @@ test-results/
.codex-remote-attachments/
tmp-*.png
tmp/
.zcode/
.nezha/
.omo/
.env

View File

@@ -0,0 +1,12 @@
# AI 工作台审核单二轮审核命令丢失候选上下文
日期2026-06-25
文档路径document/development/2026-06-25/dev-logs/bugs/ai-approval-followup-context.md
## 修复记录
- 16:24记录 bug 修复AI 工作台审核单二轮审核命令丢失候选上下文。bug-log:8a2ae6eb
- Git 提交检查fetch 失败fatal: unable to access 'https://www.caoxiaozhu.com:13002/YG-Soft/X-Financial.git/': LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to www.caoxiaozhu.com:13002upstream `origin/main`upstream 新提交:未发现;本地 ahead 提交8a2ae6eb (HEAD -> main) fix(server): gate_classify 复用 _classify_irrelevant_input 修复 off_topic 误杀992cf71f refactor(server): Phase 1 图拓扑重构 - LangGraph 成为唯一编排者54356ba8 refactor(server): scene 注册表骨架 + 统一门控管道设计文档e9d7c56d feat(server): 会话上下文保留LLM 历史 + 确定性兜底双保险)。
- 修改:`workbenchAiCommandIntentModel.js` 新增待审单据候选上下文解析与二轮审批命令提示;`useWorkbenchAiCommandIntents.js``query_candidates` 前优先复用上一轮待审候选;`workbench-ai-command-intent-model.test.mjs` 覆盖“我有哪些审核单”后继续说“审核通过”的候选承接。
- 操作:按 TDD 先补失败用例,再实现最小修复;执行 `tools/agent-change-log/update_change_log.py --kind bug` 创建当天 bug 记录,并手动补全真实修复细节。
- 验证:`node --test web/tests/workbench-ai-command-intent-model.test.mjs` 通过;`node --test web/tests/workbench-ai-command-intent-model.test.mjs web/tests/workbench-intent-frame-model.test.mjs web/tests/ai-document-query-model.test.mjs` 通过31 项前端相关测试全部通过。
- 影响:用户在 AI 工作台先查询待审/审核单后,再说“请帮我审核通过”或类似审批命令时,系统会接上刚才候选并要求进入详情确认,不会把二轮命令当成孤立查询或静默失智;仍保留高风险审批动作不直接执行的安全边界。

View File

@@ -0,0 +1,12 @@
# AI模式企业主题光球出现矩形闪动边框
日期2026-06-25
文档路径document/development/2026-06-25/dev-logs/bugs/ai-enterprise-orb-frame.md
## 修复记录
- 15:02记录 bug 修复AI模式企业主题光球出现矩形闪动边框。bug-log:2ebc2756
- Git 提交检查15:01 执行 `git fetch --all --prune` 成功upstream `origin/main`upstream 新提交:未发现;本地 ahead 提交:未发现。
- 修改:`personal-workbench-ai-mode.css`,把企业主题下 `.workbench-ai-orb` 从白底圆角矩形容器改回透明圆形承载,移除边框与阴影;`settings-theme-section.test.mjs` 增加断言锁定 `border: 0``border-radius: 50%``background: transparent``box-shadow: none`
- 操作:复现时确认企业主题覆盖块把光球容器设置为 `border-radius: 18px`、白底、阴影,导致 GIF 光球外层出现明显闪动方框;修复后用浏览器读取已加载 CSSOM确认企业主题规则已变为透明圆形版本。
- 验证:`node web/tests/settings-theme-section.test.mjs``node web/tests/settings-llm-section.test.mjs``node web/tests/settings-rendering-section.test.mjs``git diff --check``npm --prefix web run build` 均通过;本地工作台页面可加载,光球元素存在,页面无 error浏览器 CSSOM 中企业主题光球规则为 `border: 0px``border-radius: 50%``background: transparent``box-shadow: none`
- 影响:企业沉稳主题下 AI 模式欢迎区光球不再显示矩形闪动边框,动感/专业智能主题保持原有光球表现。

View File

@@ -0,0 +1,27 @@
# 申请详情退回/草稿状态修改申请卡壳
日期2026-06-25
文档路径document/development/2026-06-25/dev-logs/bugs/application-detail-edit-returned-draft.md
## 修复记录
- 16:40记录 bug 修复:申请详情退回/草稿状态修改申请卡壳。bug-log:8a2ae6eb
- Git 提交检查fetch 失败fatal: unable to access 'https://www.caoxiaozhu.com:13002/YG-Soft/X-Financial.git/': LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to www.caoxiaozhu.com:13002upstream `origin/main`upstream 新提交:未发现;本地 ahead 提交8a2ae6eb (HEAD -> main) fix(server): gate_classify 复用 _classify_irrelevant_input 修复 off_topic 误杀992cf71f refactor(server): Phase 1 图拓扑重构 - LangGraph 成为唯一编排者54356ba8 refactor(server): scene 注册表骨架 + 统一门控管道设计文档e9d7c56d feat(server): 会话上下文保留LLM 历史 + 确定性兜底双保险)。
- 修改:`travelRequestDetailSetup.js``TravelRequestDetailView.vue` 将“修改申请”入口从仅退回态放宽为申请单草稿/退回归一后的可编辑态,仍要求当前用户是申请人;打开助手时补齐原申请 `draftPayload``applicationEditMode` 和可编辑字段白名单。
- 修改:`useAppShell.js``AppShellRouteView.vue``TravelReimbursementCreateView.js``useTravelReimbursementCreateViewLifecycle.js` 贯通 `initialDraftPayload`,让申请详情带出的核对表首条消息保留原 `claim_id`,保存草稿/直接提交时走已有 `application_edit_claim_id` 更新链路,不再新建或卡在无上下文状态。
- 修改:`expenseApplicationPreview.js` 支持 `editableFields`,在修改申请场景只开放事由、时间、地点、出行方式;`aiApplicationPreviewActions.js` 将白名单写入 `application_editable_fields`,便于后续服务端字段级约束。
- 操作manual 触发 `tools/agent-change-log/update_change_log.py --kind bug` 生成日志后补充真实修复记录;未修改后端接口逻辑,因为 `user_agent_application.py` 已允许 `draft/returned/supplement` 申请按 `application_edit_claim_id` 更新。
- 验证:`node --test web/tests/travel-request-detail-risk-advice.test.mjs` 通过;`node --test --test-name-pattern "application edit prefill opens assistant without auto submit" web/tests/app-shell-financial-assistant-entry.test.mjs` 通过;`node --test --test-name-pattern "application edit preview only allows reason time location and transport changes" web/tests/expense-application-fast-preview.test.mjs` 通过;`node web/tests/ai-application-preview-actions.test.mjs` 通过;`git diff --check` 通过;`npm --prefix web run build` 通过。全量跑 `app-shell-financial-assistant-entry.test.mjs``expense-application-fast-preview.test.mjs` 时仍有既有结构断言漂移,失败点与本次修改无关。
- 影响:用户在申请详情里看到草稿和退回申请都能继续修改;修改面被限制在事由、时间、地点、出行方式,其他职级、负责人、费用标准和金额仍由原单或规则测算带入。
- 16:51补充修复取消底部“修改申请”按钮改为申请详情格子内联铅笔编辑。
- Git 提交检查fetch 仍失败fatal: unable to access 'https://www.caoxiaozhu.com:13002/YG-Soft/X-Financial.git/': LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to www.caoxiaozhu.com:13002upstream `origin/main`upstream 新提交:未发现;本地 ahead 提交仍为 8a2ae6eb、992cf71f、54356ba8、e9d7c56d。
- 修改:`TravelRequestDetailView.vue` 移除底部“修改申请”按钮,在申请详情事实格子的值旁显示小铅笔;点击后根据字段类型切换为日期框、下拉框或文本输入,并提供保存/取消按钮。
- 修改:`travelRequestDetailSetup.js` 新增 `applicationDetailEditor` 状态、`canEditApplicationDetailItem``openApplicationDetailEditor``saveApplicationDetailEdit` 等内联编辑流程;保存时复用 `runAiApplicationPreviewAction``save_draft` 路径和 `application_edit_claim_id`,保持更新原申请单。
- 修改:`travel-request-detail-view.css` 收窄申请详情标签选择器,避免内联编辑内部 `span` 被误当字段名,并补充铅笔、确认、取消、编辑控件样式。
- 验证:先运行新增目标断言失败,确认旧按钮仍存在;实现后 `node --test --test-name-pattern "draft or returned application detail edits allowed facts inline" web/tests/travel-request-detail-risk-advice.test.mjs` 通过;`node --test web/tests/travel-request-detail-risk-advice.test.mjs` 62 条通过;`node web/tests/ai-application-preview-actions.test.mjs` 通过;`git diff --check` 通过;`npm --prefix web run build` 通过。本地 `http://[::1]:5173/app/documents` 可达但跳转登录页,因无登录态未继续真实详情点击。
- 影响:草稿/退回申请不再需要先打开 AI 助手才能改,用户可以直接在详情表格中改事由、时间、地点、出行方式;其它字段仍不可编辑,由原单或规则测算带入。
- 17:02补充修复详情页因残留 `handleModifyApplication` 返回项导致 setup 阶段崩溃。
- Git 提交检查fetch 仍失败fatal: unable to access 'https://www.caoxiaozhu.com:13002/YG-Soft/X-Financial.git/': LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to www.caoxiaozhu.com:13002upstream `origin/main`upstream 新提交:未发现;本地 ahead 提交仍为 8a2ae6eb、992cf71f、54356ba8、e9d7c56d。
- 修改:`travelRequestDetailSetup.js` 删除 return 对象里已经不存在的 `handleModifyApplication`,避免详情页初始化时抛 `ReferenceError`
- 修改:`travel-request-detail-risk-advice.test.mjs` 为内联编辑回归用例增加 `handleModifyApplication` 不得残留的断言;先运行目标用例确认失败,再删除残留返回项后确认转绿。
- 验证:`node --test --test-name-pattern "draft or returned application detail edits allowed facts inline" web/tests/travel-request-detail-risk-advice.test.mjs` 先失败后通过;`node --test web/tests/travel-request-detail-risk-advice.test.mjs` 62 条通过;`node web/tests/ai-application-preview-actions.test.mjs` 通过;`git diff --check` 通过;`npm --prefix web run build` 通过。真实本地 `[::1]:5173` 以管理员打开 `AG2YUJ9FB` 详情页无控制台错误;切换申请人 `caoxiaozhu@xf.com` 后同页可见 `申请详情` 和单号,铅笔按钮 5 个,点击后出现 1 个编辑控件,取消后无保存副作用。
- 影响:申请/报销详情页 setup 不再因为旧按钮处理函数缺失而白屏,内联铅笔编辑入口保留。

View File

@@ -0,0 +1,31 @@
# AI 对话 UI 样式重构与 SaaS 化设计方案
为了让 X-Financial AI 助手的对话界面展现更专业的金融与 SaaS 企业化视觉,我们对前端全局的 Markdown 渲染样式进行了重构。
## 设计目标
- **中性色打底**:对话块背景、边框移除饱和偏色,统一采用 Slate/Neutral 冷灰色调(如 `#f8fafc``#cbd5e1`)。
- **降低色彩冗余**:将非必须的高亮蓝色改为中性灰色,各单据卡片仅在状态字样与极淡头部透明底中体现状态点缀,避免彩色杂乱堆积。
- **界面扁平微阴影**:移除带有斜向彩色发光的阴影与复杂的背景图案,采用 1px 实线描边配合微阴影,契合 Stripe, Jira 等现代 SaaS 产品规范。
## 详细参数定义
- **引用块 (`blockquote` / `.ai-html-callout`)**
- 边框:`3px solid #cbd5e1`
- 背景:`#f8fafc`
- 文字:`#334155`
- **信息网格 (`.ai-html-focus-grid`)**
- 边框:`3px solid #cbd5e1`
- 标题颜色:`#475569` (Slate-600)
- **单据卡片 (`.ai-document-card`)**
- 描边:`1px solid #e2e8f0`
- 投影:`0 1px 2px 0 rgba(15, 23, 42, 0.05)`
- 头部默认背景:`rgba(241, 245, 249, 0.5)`
- 状态点缀色:
- `is-success` (Teal-700): `#0f766e`
- `is-warning` (Amber-700): `#b45309`
- `is-danger` (Red-700): `#b91c1c`
- is-pending (Blue-600): `#2563eb`
## 消息排版格式规范
- **限制 Alert 引用块为单条**:在警示/阻塞状态下,只允许包含一条 `>` 引用块用作 Alert 提示。
- **列表扁平化**:发起前核对结果等普通多维状态信息,一律不使用 `>` 引用块,合并为标准的无序列表展示,保持界面视觉扁平、整洁。
- **常规表单数据平铺**:时间、单据编号等字段,均采用扁平加粗文本平铺,不再触发 focus-grid 等具有边框线的额外容器。

View File

@@ -0,0 +1,9 @@
# 任务计划 (SaaS 风格视觉优化)
- [x] 优化全局 `blockquote``.ai-html-callout` 的色彩饱和度,改用 Slate 灰蓝色系打底。
- [x] 优化 `.ai-html-focus-grid` 信息网格与竖线的亮蓝色调。
- [x] 移除单据卡片上的 `ai-document-card-bg.png` 渐变背景图。
- [x] 扁平化单据卡片的描边与中性阴影。
- [x] 优化卡片语义状态角标与操作链接的 SaaS 蓝及点缀。
- [x] 优化原生 Markdown 列表 `li::marker` 与表格外包边框的色彩层级。
- [x] 重构预审与冲突消息的 Markdown 输出格式,消除不必要的 `>` 引用块,攻克“三条大竖杠”的排版痛点。

View File

@@ -0,0 +1,207 @@
# 主题设置与企业沉稳 AI 模式 概念文档
更新时间2026-06-25
文档路径document/development/2026-06-25/feature/theme-settings-enterprise-ai-style/CONCEPT.md
## 功能一句话
将系统设置中的“界面皮肤”升级为“主题设置”,从单纯色板选择扩展为产品体验风格选择,并让 AI 模式在“企业沉稳”主题下呈现更符合企业级 SaaS 的低噪声、结构化、克制风格。
## 背景与问题
当前系统设置里的外观入口仍偏向“界面皮肤”语义,主要表达颜色和视觉皮肤选择。这个命名过窄,无法承载用户希望配置的完整体验风格。
现有 AI 模式默认更接近动感活泼风格,使用较多渐变、明亮色块和活跃视觉标识。它适合演示和助手化体验,但在企业级财务、审批、风控、报销场景中,容易显得色彩过重,不够沉稳。
用户希望在系统设置中明确提供主题类型,至少覆盖:
1. 动感活泼:保留当前这种更有活力、更有 AI 助手感的主题。
2. 企业沉稳:符合企业 SaaS 风格,尤其 AI 模式下的对话图标、样式和整体风格需要克制、稳定,减少颜色渲染。
3. 专业智能:作为第三类默认方案,介于前两者之间,保留少量智能化识别感,但整体更收敛。
## 目标与非目标
目标:
- 将“界面皮肤”入口重命名为“主题设置”,让用户理解这里配置的是整体体验风格。
- 将主题从一组颜色选项收敛成三类可理解的主题类型。
- 在“企业沉稳”主题下,让 AI 模式呈现企业后台应用的专业感。
- 保持现有设置保存链路稳定,优先复用当前 appearance 配置和主题变量机制。
- 为后续进一步扩展租户品牌色、暗色模式、组件密度预留结构边界。
非目标:
- 不重做整个系统设置信息架构。
- 不一次性重写所有业务页面的视觉风格。
- 不改变 AI 意图识别、报销流程、审批逻辑和后端业务规则。
- 不新增复杂的租户级主题发布、审批或版本管理能力。
- 不引入新的前端主题框架或额外依赖。
## 用户与场景
管理员在系统设置中进入“主题设置”,选择适合当前组织的产品体验风格。
演示、培训、轻量工作台场景可以使用“动感活泼”,保留当前更有活力的 AI 交互表达。
企业正式生产环境可以使用“企业沉稳”,让财务、审批、风控和 AI 对话看起来像成熟的企业应用,而不是营销页或玩具化助手。
希望保留智能化识别但又不希望过度活泼的组织,可以使用“专业智能”。
## 功能能力
主题设置页面需要提供三类主题:
- 动感活泼:当前风格延续,允许渐变、轻动效和更明显的 AI 识别色。
- 企业沉稳:低饱和色、白灰底、轻描边、少阴影、少渐变,强调信息层级和业务可信度。
- 专业智能:更克制的智能风格,允许小面积蓝灰、紫灰或品牌色点缀,但避免大面积彩色渲染。
页面文案调整:
- 左侧菜单从“界面皮肤”调整为“主题设置”。
- 页面标题从“界面皮肤与企业主色”调整为“主题风格与界面体验”。
- 保存反馈从“界面皮肤已保存”调整为“主题设置已保存”。
- 说明文案从“皮肤/配色”改为“主题/体验风格”。
AI 模式联动:
- 动感活泼:保留当前 AI 模式视觉语言。
- 企业沉稳:对 AI 对话区、消息气泡、工具调用状态、思考过程、图标、卡片、提示块做克制化覆写。
- 专业智能:保留轻量 AI 识别感,但降低渐变、发光、背景装饰和高饱和强调色。
## 方案设计
配置模型优先沿用当前系统设置外观配置,避免为了命名调整引入后端迁移风险。
首期可以继续复用 `appearanceForm.themeSkin` 作为持久化字段,将值从原先色板语义逐步映射为主题语义:
- `vivid`:动感活泼。
- `enterprise`:企业沉稳。
- `intelligent`:专业智能。
为了减少现有 CSS 变量和本地存储影响,前端可以在兼容期保留 `themeSkin` 字段,同时在 DOM 上补充更清晰的主题标识:
```text
document.documentElement.dataset.themeSkin = value
document.documentElement.dataset.themeMode = value
```
旧值兼容策略:
- 旧的 `sky``blue``emerald` 等明亮主题默认映射到“动感活泼”。
- 旧的 `navy``slate` 等偏稳重主题默认映射到“企业沉稳”。
- 无法识别的值回退到“企业沉稳”,保证生产环境默认更克制。
设置页结构:
- 保留现有表单保存机制。
- 将原色板卡片改为三张主题选项卡。
- 每个主题卡展示名称、适用场景、视觉关键词和小型预览。
- 当前选中主题需要有明确选中态,但避免大面积彩色边框。
企业沉稳 AI 模式样式:
- 通过 `[data-theme-mode="enterprise"]``[data-theme-skin="enterprise"]` 覆写 `personal-workbench-ai-mode.css` 中的 AI 模式变量。
- 背景从多层 radial-gradient 收敛为白灰底或极轻线性渐变。
- 对话气泡使用白底、浅灰描边和稳定文字层级。
- AI 图标使用低饱和单色或品牌主色的小面积点缀。
- 思考过程、工具调用、风险提示等区域使用结构化信息块,减少发光、彩色渐变和装饰性元素。
- 风险、成功、警告等语义色保留,但只在图标、状态条或小面积标签上表达。
专业智能主题样式:
- 保留少量 AI 识别色,例如主色强调、轻量渐变按钮或小面积状态标识。
- 背景和卡片仍以企业应用的可读性为主。
- 避免全屏强背景、过多彩色阴影和过密装饰。
## 算法与公式
本功能不涉及复杂算法。
需要定义稳定的主题值映射函数:
```text
normalizeThemeMode(rawTheme):
if rawTheme in ["vivid", "sky", "blue", "emerald", "purple"]:
return "vivid"
if rawTheme in ["enterprise", "navy", "slate", "gray"]:
return "enterprise"
if rawTheme in ["intelligent"]:
return "intelligent"
return "enterprise"
```
主题应用顺序:
```text
后端保存值 / 本地缓存值
-> normalizeThemeMode
-> 写入 appearanceForm.themeSkin
-> 写入 root dataset
-> 应用 CSS 变量
-> AI 模式按 data-theme-mode 覆写组件样式
```
## 测试方案
前端单元和静态测试:
- 断言系统设置外观入口显示为“主题设置”。
- 断言页面标题显示为“主题风格与界面体验”。
- 断言三类主题均可见:动感活泼、企业沉稳、专业智能。
- 断言企业沉稳主题保存后写入稳定主题值。
- 断言旧主题值能通过 normalize 逻辑回退到可识别主题。
- 断言 AI 模式存在企业沉稳主题 CSS 钩子。
构建验证:
- 运行前端构建,确保主题设置改动不破坏现有页面。
- 运行已有设置相关测试,确保系统设置保存链路不回归。
真实页面验收:
- 打开 `/app/settings?section=appearance`,确认左侧菜单、页面标题、三类主题和保存反馈符合预期。
- 切换到“企业沉稳”后打开 AI 工作台,确认对话区域、图标、消息、思考过程和提示块明显减少彩色渲染。
- 切回“动感活泼”,确认现有风格仍可正常展示。
- 切到“专业智能”,确认介于两者之间,不退化为纯色板换色。
## 指标与验收
功能验收:
- “界面皮肤”在设置入口和页面主标题中完成改名。
- 用户可以选择三类主题,而不是面对一堆颜色皮肤。
- 选择主题后刷新页面仍保持选中态。
- 企业沉稳主题下AI 模式整体视觉明显更接近企业 SaaS。
- 动感活泼主题不丢失现有活力风格。
- 专业智能主题具备独立视觉边界。
设计验收:
- 企业沉稳主题下不出现大面积高饱和渐变背景。
- AI 对话图标和卡片不依赖强发光、强阴影或多彩背景表达层级。
- 风险、审批、单据、工具调用等业务信息优先使用结构化排版。
- 主题卡片文字不溢出,不在移动端产生拥挤或重叠。
工程验收:
- 不新增前端依赖。
- 不引入后端表结构迁移。
- 旧主题值有明确兼容策略。
- 相关测试、构建和真实页面验收通过。
## 风险与开放问题
第三类主题默认命名为“专业智能”。如果后续用户指定更贴合业务的名称,可以只替换展示文案,不影响主题值和实现结构。
仅靠全局 CSS 变量可能无法完全消除 AI 模式的活泼感。企业沉稳主题需要对 AI 模式局部样式做有针对性的覆写。
旧色板值如果直接隐藏,可能让已有用户困惑。首期需要在兼容层处理旧值,并保证保存一次后落到新的三类主题值。
如果后续需要租户品牌色和主题组合,应该把“主题模式”和“品牌主色”拆成两个独立配置,避免再次把体验风格退化成颜色选择。
## 本轮文档记录
本轮已完成主题设置功能的前端实现:系统设置入口改名为“主题设置”,主题选项收敛为“动感活泼 / 企业沉稳 / 专业智能”三类,并通过主题归一化兼容旧色板值。
企业沉稳主题已联动 AI 工作台样式:根节点写入 `data-theme-mode="enterprise"`AI 模式背景、图标容器、输入框、消息、思考过程和建议动作改为低饱和、轻描边、少渲染的企业 SaaS 风格。

View File

@@ -0,0 +1,69 @@
# 主题设置与企业沉稳 AI 模式 开发 TODO
更新时间2026-06-25
文档路径document/development/2026-06-25/feature/theme-settings-enterprise-ai-style/TODO.md
## 使用规则
- 每个任务都需要关联 `CONCEPT.md` 中的章节,格式为 `[CONCEPT: 章节名]`
- 完成实现后再勾选对应任务,不用文档勾选代替代码验证。
- 涉及真实页面效果的任务,需要在 5173 页面完成验收后再标记完成。
## 1. 调研与边界
- [x] [CONCEPT: 背景与问题] 确认当前设置外观入口仍使用“界面皮肤”语义,后续需要改为“主题设置”。
- [x] [CONCEPT: 方案设计] 确认当前主题能力主要依赖 `appearanceForm.themeSkin`、主题选项和根节点 dataset。
- [x] [CONCEPT: 方案设计] 确认企业沉稳 AI 模式主要需要覆写 `personal-workbench-ai-mode.css` 中的背景、对话、图标和提示块样式。
- [x] [CONCEPT: 风险与开放问题] 梳理旧色板值到三类主题值的完整映射清单。
- [x] [CONCEPT: 用户与场景] 确认三类主题在设置页中的展示顺序和说明文案。
## 2. 契约与设计
- [x] [CONCEPT: 功能能力] 将主题枚举收敛为 `vivid``enterprise``intelligent`
- [x] [CONCEPT: 功能能力] 明确三类主题的中文名称、适用场景和视觉关键词。
- [x] [CONCEPT: 方案设计] 设计 `normalizeThemeMode` 兼容函数,保证旧值和未知值都有稳定回退。
- [x] [CONCEPT: 方案设计] 决定是否新增 `themeMode` 前端概念,并保持与 `themeSkin` 字段兼容。
- [x] [CONCEPT: 指标与验收] 定义企业沉稳 AI 模式的视觉验收标准。
## 3. 后端实现
- [x] [CONCEPT: 方案设计] 评估后端 settings schema 是否需要补充主题枚举校验。
- [x] [CONCEPT: 方案设计] 若继续复用 `themeSkin`,确保后端允许新主题值保存。
- [ ] [CONCEPT: 测试方案] 补充或更新设置持久化测试,覆盖三类主题值。
- [x] [CONCEPT: 风险与开放问题] 确认不需要数据库结构迁移,并在实现说明中记录。
## 4. 算法/规则实现
- [x] [CONCEPT: 算法与公式] 实现旧主题值到新主题值的 normalize 逻辑。
- [x] [CONCEPT: 算法与公式] 为未知值设置默认回退策略,优先回退到“企业沉稳”。
- [x] [CONCEPT: 算法与公式] 确保本地缓存、后端返回值和根节点 dataset 使用同一套归一化结果。
## 5. 前端实现
- [x] [CONCEPT: 功能能力] 将设置左侧菜单“界面皮肤”改为“主题设置”。
- [x] [CONCEPT: 功能能力] 将页面标题改为“主题风格与界面体验”。
- [x] [CONCEPT: 功能能力] 将保存反馈和说明文案从“皮肤”语义调整为“主题”语义。
- [x] [CONCEPT: 功能能力] 将原色板式选项调整为三类主题卡片。
- [x] [CONCEPT: 功能能力] 为“动感活泼”保留当前视觉风格。
- [x] [CONCEPT: 方案设计] 为“企业沉稳”新增 AI 模式样式覆写。
- [x] [CONCEPT: 方案设计] 为“专业智能”新增介于活泼和沉稳之间的样式边界。
- [x] [CONCEPT: 指标与验收] 检查主题卡片、按钮和说明文字在移动端不溢出、不重叠。
- [x] [CONCEPT: 方案设计] 保证刷新页面后主题选择和 AI 模式样式仍然一致。
## 6. 测试与验证
- [x] [CONCEPT: 测试方案] 更新设置页相关前端测试,断言“主题设置”和三类主题选项。
- [x] [CONCEPT: 测试方案] 补充 normalize 逻辑测试,覆盖旧值、未知值和三类新值。
- [x] [CONCEPT: 测试方案] 补充 AI 模式企业沉稳 CSS 钩子测试或静态断言。
- [x] [CONCEPT: 测试方案] 运行前端设置相关定向测试。
- [x] [CONCEPT: 测试方案] 运行 `npm --prefix web run build`
- [x] [CONCEPT: 测试方案] 运行 `git diff --check`
- [x] [CONCEPT: 测试方案] 在真实 5173 页面验收 `/app/settings?section=appearance`
- [ ] [CONCEPT: 测试方案] 在真实 5173 页面验收 AI 工作台三类主题切换效果。
## 7. 文档收尾
- [x] [CONCEPT: 本轮文档记录] 实现完成后更新本文勾选状态。
- [x] [CONCEPT: 指标与验收] 在最终交付说明中记录测试、构建和真实页面验收结果。
- [ ] [CONCEPT: 风险与开放问题] 若第三类主题命名发生变化,同步更新概念文档和测试描述。

View File

@@ -0,0 +1,37 @@
# 多 task 串行推进时 task2(业务招待费报销)无法启动
## 修复记录
- 12:14记录 bug 修复:多 task 串行推进时task1出差申请做完后点击"继续处理费用报销"task2业务招待费报销根本无法启动报销草稿交互不起来task2 语义(招待费/2000元/昨天)全部丢失。
- Git 提交检查:`git fetch --all --prune` 因远端 SSL 连接失败未拉到新内容;`HEAD..@{u}` 为空(无 upstream 新提交);`@{u}..HEAD` 本地领先 8 个提交,其中与本 bug 相关的前置提交为 `3a5664c4 feat(web): 多 task 串行推进``3e4b1e15 fix(web): 保存草稿/提交成功后也推进到下一个 task``43c3ff86 fix(web): steward plan 确认按钮直接拉起申请预览,不丢失 remaining tasks`
- 修改:
- `web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js``travel_reimbursement` 分支不再硬编码 `requiresApplicationBeforeReimbursement=true`,改为从 `steward_current_task.ontology_fields` 解析费用类型并调 `requiresApplicationBeforeReimbursement(expenseType)` 判断;用 `buildAiExpenseDraftPrefillValues` 把 task 语义(金额/时间/事由/地点)预填进报销草稿;透传 `steward_remaining_tasks``ai_application_start_inline` 分支也透传 `prefill_values``steward_remaining_tasks`,让"查不到申请单→发起申请单"后能回到 task2。`buildNextTaskSuggestedAction` payload 补 `steward_remaining_tasks: remainingTasks.slice(1)`,防 3+ task 断链。
- `web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js``startAiExpenseDraft` 扩展第 4 参 `options``prefillValues`/`stewardRemainingTasks``resolveAiExpenseApplicationLink` 接收 options查不到申请单时按费用类型动态生成"确认发起业务招待申请"按钮(不再写死"出差申请"),并透传 `prefill_values`/`steward_remaining_tasks`;新增 `attachStewardRemainingTasks`/`resolveStewardRemainingTasks`/`resolveRequiredApplicationLabel`/`buildExpenseDraftNextTaskAction` helper把 remaining tasks 上下文挂在 draft 上贯穿报销→关联申请单→pollLinkedDraftJob 全流程;`advanceAiExpenseDraft``linkAiExpenseApplication``replaceInlineAssistantMessage``resumePendingLinkedReimbursementDraftJobs` 均补 remaining tasks 透传。
- `web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js``buildApplicationPreviewNextTaskAction` payload 补 `steward_remaining_tasks: remainingTasks.slice(1)`
- `web/src/utils/aiExpenseDraftModel.js``createAiExpenseDraft` 新增第三参 `prefillValues`,按已填值推进 `stepKey` 到第一个未填字段;新增 `buildAiExpenseDraftPrefillValues` 把 task ontology 映射到草稿字段。
- `web/src/composables/workbenchAiMode/workbenchAiMessageModel.js``createInlineMessage`/`normalizeRuntimeMessage`/`serializeRuntimeMessage` 三处补 `stewardRemainingTasks` 字段读写,避免消息重建/刷新丢失推进上下文。
- `web/src/utils/aiWorkbenchConversationStore.js``normalizeMessage` 持久化时保留 `stewardRemainingTasks`
- 操作:未提交(工作区有大量预先存在的未提交改动,未自动提交)。容器 `local-x-financial-linux` 此前未运行,已 `docker start` 恢复。
- 验证在容器内node v22.22.3)跑 `node --test tests/ai-expense-draft-model.test.mjs tests/ai-workbench-conversation-store.test.mjs tests/workbench-ai-action-router.test.mjs`16 个测试全部通过(含新增的 `buildAiExpenseDraftPrefillValues``createAiExpenseDraft` 预填、`stewardRemainingTasks` 持久化、`travel_reimbursement` 分支预填+透传 4 个用例。其余前端测试失败为基线已存在git stash 对比确认,与本次改动无关,多为正则匹配源码文本的断言因工作区其他未提交改动而失败)。
- 影响:用户一次提问含"出差申请 + 招待费报销"等多 task 时task1 完成后 task2 现在能正常启动招待费类型、2000元金额、时间、事由会预填到报销草稿招待费需要前置招待申请单业务规则保留查不到时按钮文案按类型动态展示并承接语义发起申请单后能回到 task23+ task 不再断链;刷新会话后推进上下文不丢失。
- 12:23继续修复同一 bug模型计划已经返回“出差申请 + 业务招待费报销”两个 task 时AI 工作台入口只消费第一个申请 task第二个 task 没有自动开始。
- Git 提交检查:`git fetch --all --prune` 仍因 `LibreSSL SSL_connect: SSL_ERROR_SYSCALL` 失败;`HEAD..@{u}` 为空,未发现可见 upstream 新提交;`@{u}..HEAD` 本地领先 8 个提交:`43c3ff86 fix(web): steward plan 确认按钮直接拉起申请预览,不丢失 remaining tasks``3e4b1e15 fix(web): 保存草稿/提交成功后也推进到下一个 task``3a5664c4 feat(web): 多 task 串行推进 - task1 完成后自动展示 task2 确认按钮``d139a63e refactor(server): 意图识别改 LLM 驱动,规则只做闲聊拦截+resume 兜底``8a2ae6eb fix(server): gate_classify 复用 _classify_irrelevant_input 修复 off_topic 误杀``992cf71f refactor(server): Phase 1 图拓扑重构 - LangGraph 成为唯一编排者``54356ba8 refactor(server): scene 注册表骨架 + 统一门控管道设计文档``e9d7c56d feat(server): 会话上下文保留LLM 历史 + 确定性兜底双保险)`
- 修改:`workbenchAiIntentPlannerModel.js` 在归一化模型计划时保留 application task 后面的 `stewardRemainingTasks`,并只在存在剩余 task 时放进可执行申请请求;`usePersonalWorkbenchAiMode.js` 把 remaining tasks 传入申请预览,并在预览生成后通过现有 `steward_continue_next_task` 路由自动启动下一个 task低置信确认按钮 payload 也继续携带队列;`useWorkbenchAiApplicationPreviewFlow.js` 在普通申请预览完成后调用 `onPreviewReadyForNextTask``useWorkbenchAiActionRouter.js``ai_application_confirm_intent` 分支同样透传 remaining tasks 并注册自动续跑回调。
- 操作:新增回归测试覆盖“模型返回申请 task + 业务招待报销 task 时,前端可执行请求保留第二个 task”以及“低置信确认按钮不丢队列并提供自动续跑回调”保留既有未提交工作区改动没有回滚或提交其他文件。
- 验证:先看到新增测试红灯(`plan.stewardRemainingTasks``undefined`,确认按钮 options 也没有 remaining tasks修复后 `node --test web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-action-router.test.mjs` 通过 24/24`git diff --check` 通过;`docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-local-linux timeout 60s /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_planner.py -k 'future_travel_without_apply_word_as_application or uses_llm_for_multi_financial_demands'` 通过 2/2同命令在 `local-x-financial-linux` 收集阶段因该容器缺 `langgraph` 失败,已改用当前映射 5173 的 `x-financial-local-linux` 验证;`npm --prefix web run build` 通过;真实 `http://localhost:5173/api/v1/steward/plans` 采样确认该用户句子返回 `expense_application``reimbursement` 两个 task。
- 影响用户输入“2月20-23日去上海出差3天服务国网服务器部署并且报销昨天的业务招待费2000元”时前端不再在第一个申请预览处截断队列申请预览生成后会自动把第二个业务招待费报销 task 交给现有报销流程继续处理,同时刷新/低置信确认路径也不丢 task 队列。
- 22:30继续修复同一 bug用户点击“保存草稿”后task1 已经完成,但界面仍只展示“继续处理费用报销”按钮,没有自动开始 task2。
- Git 提交检查:`git fetch --all --prune` 仍因 `LibreSSL SSL_connect: SSL_ERROR_SYSCALL` 失败;`HEAD..@{u}` 为空,未发现可见 upstream 新提交;`@{u}..HEAD` 本地仍领先 8 个提交:`43c3ff86``3e4b1e15``3a5664c4``d139a63e``8a2ae6eb``992cf71f``54356ba8``e9d7c56d`
- 修改:`useWorkbenchAiApplicationPreviewFlow.js` 在保存草稿/提交申请成功后,如果当前申请消息还有 `stewardRemainingTasks`,就调用 `onApplicationActionCompleted` 自动续跑下一项;自动续跑时结果消息只保留查看详情动作,不再额外保留“继续处理”按钮,避免重复触发同一个 task。`usePersonalWorkbenchAiMode.js` 将该回调接到 `startModelPlannedNextTask``useWorkbenchAiActionRouter.js` 的低置信确认路径也同步传入保存/提交成功回调。
- 操作:先补红灯断言,要求工作台入口向申请预览流传 `onApplicationActionCompleted`,且申请预览流在保存/提交成功分支调用该回调;然后按同一条 `steward_continue_next_task` 路由复用原有报销启动逻辑。
- 验证:红灯阶段 `node --test web/tests/workbench-ai-intent-planner-model.test.mjs` 失败在缺少 `onApplicationActionCompleted`;修复后 `node --test web/tests/workbench-ai-intent-planner-model.test.mjs` 17/17 通过,`node --test web/tests/workbench-ai-action-router.test.mjs` 7/7 通过;`git diff --check` 通过;`npm --prefix web run build` 通过。
- 影响:复合任务里用户保存第一张出差申请草稿后,系统会立即进入第二个业务招待费报销任务,不再要求用户手动点击“继续处理费用报销”;低置信确认后再保存草稿也保持同样行为。
- 22:45继续修复同一 bug真实页面已经显示“申请草稿已保存”但保存动作结束后没有继续拉起第二个业务招待费报销 task。
- Git 提交检查:`git fetch --all --prune` 仍因 `LibreSSL SSL_connect: SSL_ERROR_SYSCALL` 失败;`HEAD..@{u}` 为空,未发现可见 upstream 新提交;`@{u}..HEAD` 本地领先 13 个提交:`6bdaeed6 chore: 忽略 .zcode 本地目录并更新规则表与开发日志``d5a8f847 refactor(web): 应用外壳/差旅详情/报销创建视图适配主题与多 task``c4b5fcc0 feat(web): AI 工作台多 task 串行推进与会话适配``5753899e feat(web): 主题皮肤系统与 LLM 设置面板重构``9c3fa80d feat(server): 设置持久化新增 LLM 模型表与主题字段`,以及前面记录过的 8 个本地 ahead 提交。
- 修改:`useWorkbenchAiApplicationPreviewFlow.js` 在保存草稿/提交申请成功分支新增 `actionCompletedHandler`,优先使用本次 `executeInlineApplicationPreviewAction` / `startAiApplicationPreview` options 传入的 `onApplicationActionCompleted`,没有时再回落到 composable 初始化回调;自动保存草稿时同步把 `options.onApplicationActionCompleted` 透传给保存动作。`workbench-ai-application-context-submit.test.mjs` 新增“自动保存申请草稿后继续剩余 steward task”的行为测试`workbench-ai-intent-planner-model.test.mjs` 更新结构断言,锁住 options 回调优先和自动保存透传契约。
- 操作:先用新增测试复现红灯:自动保存时确实只触发初始化回调,`options` 里的续跑回调没有被使用;再做最小修复,让保存动作按本次调用上下文续跑下一任务。
- 验证:红灯阶段 `node --test web/tests/workbench-ai-application-context-submit.test.mjs` 失败在 `fromOptions``undefined`;修复后该测试 2/2 通过;`node --test web/tests/workbench-ai-intent-planner-model.test.mjs` 17/17 通过;`node --test web/tests/workbench-ai-action-router.test.mjs` 7/7 通过;真实 `http://127.0.0.1:5173/api/v1/steward/plans` 采样确认该用户句子仍返回 `expense_application` + `reimbursement` 两个 task`npm --prefix web run build` 通过;`git diff --check` 通过。
- 影响:保存草稿结果消息到达后,前端会使用当前任务链路传下来的续跑回调立即处理剩余 task用户截图里的“申请草稿已保存”不再是终点后续业务招待费报销会自动进入现有报销流程。

View File

@@ -0,0 +1,17 @@
# 2026-06-26 综合工作日志
生成时间2026-06-26 17:45:36 CST
来源:`feature/` 功能点文档与 `dev-logs/bugs/` bug 记录
## 今日功能点
- 今日未发现功能点文档。
## 今日 Bugs
- 多 task 串行推进时 task2(业务招待费报销)无法启动影响用户输入“2月20-23日去上海出差3天服务国网服务器部署并且报销昨天的业务招待费2000元”时前端不再在第一个申请预览处截断队列申请预览生成后会自动把第二个业务招待费报销 task 交给现有报销流程继续处理,同时刷新/低置信确认路径也不丢 task 队列。(文件:`multi-task-reimbursement-not-starting.md`
## 综合分析
- 问题侧记录了 1 个 bug 修复。
- 后续复盘优先看本文件,再回到对应功能点或 bug 文件追溯证据。

View File

@@ -0,0 +1,17 @@
# 2026-06-27 综合工作日志
生成时间2026-06-27 17:43:24 CST
来源:`feature/` 功能点文档与 `dev-logs/bugs/` bug 记录
## 今日功能点
- 今日未发现功能点文档。
## 今日 Bugs
- 今日未发现 bug 修复记录。
## 综合分析
- 今日目录下暂无功能点或 bug 记录。
- 后续复盘优先看本文件,再回到对应功能点或 bug 文件追溯证据。

View File

@@ -0,0 +1,17 @@
# 2026-06-28 综合工作日志
生成时间2026-06-28 18:30:35 CST
来源:`feature/` 功能点文档与 `dev-logs/bugs/` bug 记录
## 今日功能点
- 今日未发现功能点文档。
## 今日 Bugs
- 今日未发现 bug 修复记录。
## 综合分析
- 今日目录下暂无功能点或 bug 记录。
- 后续复盘优先看本文件,再回到对应功能点或 bug 文件追溯证据。

View File

@@ -0,0 +1,16 @@
# 多 task 串行推进时 task2 无法启动onPreviewReadyForNextTask 时序缺陷)
日期2026-06-30
文档路径document/development/2026-06-30/dev-logs/bugs/multi-task-next-task-blocked-by-preview-ready-timing.md
## 修复记录
- 11:18记录 bug 修复:用户在 AI 工作台输入"出差申请 + 招待费报销"等多 task 时task1出差申请保存草稿/提交成功后task2招待费报销完全无法启动界面停在"申请草稿已保存"。
- Git 提交检查:`git fetch --all --prune` 成功upstream `origin/main``HEAD..@{u}` 未发现 upstream 新提交;`@{u}..HEAD` 未发现本地 ahead 提交。工作区改动仅为本次 3 个源文件 + 2 个测试文件(另有一个预先存在的未提交改动 `server/rules/finance-rules/公司通信费报销规则.xlsx`,与本次无关)。
- 根因:`onPreviewReadyForNextTask` 回调在 task1 申请核对表**刚生成、用户还没看、还没点保存草稿**时就立刻触发 `startModelPlannedNextTask`,提前把 task2 招待费报销拉起(`startAiExpenseDraft` 会 push 一条"选择费用报销"用户消息 + 报销 prompt。两条流程的消息和状态互相打架用户再在 task1 上点保存草稿时 `onApplicationActionCompleted` 又试图拉起 task2但 task2 状态已被前面 `onPreviewReadyForNextTask` 搞乱,最终表现为"完全无反应"。运行时复现脚本时序铁证:预览生成后立即出现 `!!! onPreviewReadyForNextTask 触发(task1预览刚生成,用户还没操作)`与串行推进的正确语义task1 完成后才推进 task2冲突。这是早期实现的残留——引入 `onApplicationActionCompleted`task1 完成后触发)后,`onPreviewReadyForNextTask` 职责重叠且时序错误。
- 修改(前端 web3 个源文件):
- `web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js`:删除 `startAiApplicationPreview` 预览生成后的 `else if (onPreviewReadyForNextTask ...)` 提前推进分支(原 L622-L628并加注释说明 task2 推进统一交给 `onApplicationActionCompleted` 在 task1 真正完成后触发。`executeInlineApplicationPreviewAction` 里 L466 的 `actionCompletedHandler` 回落逻辑保留不动(手动点保存草稿走 actionRouter 不传 options 回调,回落到模块级 `startModelPlannedNextTask`,这是正确的续跑路径)。
- `web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js``startModelPlannedApplicationPreview` 调用 `startAiApplicationPreview` 时删除 `onPreviewReadyForNextTask: startModelPlannedNextTask` 一行,只保留 `onApplicationActionCompleted: startModelPlannedNextTask`(原 L769
- `web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js``ai_application_confirm_intent`(低置信确认按钮)分支删除 `onPreviewReadyForNextTask` 回调,只保留 `onApplicationActionCompleted`(原 L99-L104消除低置信路径的同样时序缺陷。
- 操作:先写复现脚本 `/tmp/repro-timing.mjs`applicationFlow + 两个回调)锁定时序根因——修复前预览生成后立即触发推进回调,修复后无提前触发;再按计划小步改 3 个源文件 + 2 个测试文件;未提交(工作区有预先存在的无关 xlsx 改动,未自动提交)。
- 验证:宿主机 node v22.22.3 跑 `node --test web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-action-router.test.mjs web/tests/workbench-ai-application-context-submit.test.mjs` 通过 27/27含新增的时序回归用例 `workbench application preview does not continue next task until draft is saved or submitted`:断言预览生成时 `continuedTasks.length === 0`、保存草稿后才推进 task2 且走模块级续跑回调、自动续跑时不展示重复的"继续处理"按钮);`npm --prefix web run build` 通过3.97s);复现脚本 `/tmp/repro-timing.mjs` 修复后事件序列只剩用户消息、无提前推进;真实 `http://localhost:5173/api/v1/steward/plans``/api/v1/steward/plans/stream` 采样确认该句子仍返回 `expense_application` + `reimbursement` 两个 task后端拆分正确本次未动后端
- 影响:用户输入框提交"2月20-23日去上海出差辅助国网仿生产服务器部署并且报销昨天的上午招待费2000元"等多 task 时task1 出差申请核对表生成后干净停下等用户操作,用户点保存草稿/直接提交成功后自动进入 task2 招待费报销(预填金额/时间/事由),不再出现两条流程打架导致 task2 完全无反应的问题。不影响后端、单 task 场景、autoSaveDraft 路径(它走 `executeInlineApplicationPreviewAction` 完成后触发 `onApplicationActionCompleted`,链路不变);低置信确认按钮路径也同步修复。

View File

@@ -0,0 +1,243 @@
# 统一门控管道Unified Gate Pipeline
> 状态:**设计定稿,待实施**
> 创建2026-06-25
> 关联:`CONCEPT.md`、`LANGGRAPH_RUNTIME_MIGRATION.md`
## 1. 为什么要做这件事
### 1.1 现状的致命问题
小财管家的门控(决定用户输入走哪条路)目前散落在 **7 个位置**,互相不知道对方的结论,每加一个场景要找 n 个地方改:
| # | 位置 | 文件 | 做的门控 |
|---|---|---|---|
| 1 | 前端 7 层 if/else | `usePersonalWorkbenchAiMode.js:858-913` `startInlineConversation` | 命令→文本动作→草稿→模型规划→报销→闲聊,每层各自 return |
| 2 | 前端业务词预筛 | `workbenchAiIntentPlannerModel.js:shouldRequestWorkbenchAiIntentPlan` | 不含业务词的输入不发给后端 |
| 3 | 后端 endpoint 补丁群 | `steward.py:create_steward_plan` | `_hydrate_required_application_gate` / `_inject_recent_conversation_history` / `_apply_context_resume` 三个补丁串在 `build_plan` 前后 |
| 4 | 图条件边路由 | `steward_graph_planner.py:_route_after_prepare_context` | off_topic / model / fallback 三路 |
| 5 | off_topic 关键词 | `steward_planner_fallback.py:_classify_irrelevant_input` + `STEWARD_BUSINESS_SIGNAL_KEYWORDS` | 写死的信号词元组 |
| 6 | 候选流程歧义 | `steward_planner_extraction.py:_looks_like_ambiguous_travel_flow` | 独立的正则判定 |
| 7 | 图后意图处理 | `usePersonalWorkbenchAiMode.js:813-835` `executeModelPlannedWorkbenchIntent` | 前端再判一遍 task_type 决定渲染申请预览还是报销 |
**根因:没有单一的决策点。** LangGraph 图只承担了"意图识别"这一个职责,控制流泄漏到了 endpoint 层和前端 composable形成两个影子编排器。
### 1.2 不持久的判据
加一个场景(如"查报销进度")的成本是 **O(n)**——必须同步改前端门控、后端补丁、图条件边、off_topic 关键词、候选流程判定等多处,漏一处就静默出错。本次会话已经在不断验证这个痛点:每个新场景(查询、低置信度、上下文恢复)都是往不同位置打补丁。
## 2. 目标:接入成本 O(1)
加一个新场景,**全文改动只有一处**
```python
# server/src/app/services/scenes/scene_query_reimbursement_progress.py
register_scene(SceneDescriptor(
scene_id="query_reimbursement_progress",
label="报销进度查询",
signal_keywords=("报销进度", "报销状态", "审批进度", "审批到哪了"),
ontology_fields=("claim_no", "time_range"),
gate=GateRule.CHOICE, # 不走候选流程、不走 off_topic
can_resume=False, # 不参与上下文恢复
route=SceneRoute.HANDLER_ONLY, # 不走 LLM,直接执行 handler
handler=execute_progress_query, # 纯函数:检索 + 拼装
prompt_fragment="用户询问报销审批进度/状态时,识别为 query_reimbursement_progress。",
))
```
**不改图、不改 endpoint、不改前端门控、不改 extraction。** 判断规则、路由、执行、槽位、恢复能力在同一个 descriptor 里声明,不会割裂。
## 3. 目标架构
### 3.1 后端:图成为唯一编排者
```
POST /api/v1/steward/plans
endpoint: 纯 IO (收请求 → graph.invoke → 返响应,零编排)
LangGraph StateGraph (唯一编排者):
START
→ load_context 读最近10条历史 + steward_state + hydrate
→ gate_classify 统一门控:按 registry 规则裁决 scene + route
→ route 分支
├─ off_topic → off_topic_reply
├─ handler_only → execute_scene_handler (查询/命令类,不走 LLM)
├─ resume → resume_recent_task ("再提交"确定性恢复)
├─ ambiguous_flow → pending_flow_confirmation
└─ model_intent → detect_model_intent → {done | fallback}
→ attach_action_steps
→ persist_state 写 message + steward_state
→ END
```
**endpoint 层只剩 3 行**`planner = build(db); plan = planner.build_plan(payload); return plan`。所有 hydrate/inject/resume 全部搬进图节点。
### 3.2 前端:退化为纯渲染
```
用户输入
前端: 不再自己决策,统一发给后端
POST /steward/plans { message, conversation_id }
后端返回 StewardPlanResponse:
- plan.next_action 告诉前端该渲染什么
- plan.tasks[].task_type 告诉前端该用哪个渲染器
- plan.suggested_actions 告诉前端该显示哪些按钮
前端: 按 response 的指令渲染(申请预览/报销预览/查询结果/纯文本回复)
```
前端的 7 层 if/else **全部移除**,替换为:
```js
async function startInlineConversation(prompt) {
const plan = await fetchStewardPlan({ message: prompt, conversation_id: conversationId.value })
renderPlanResponse(plan) // 按 plan.next_action / task_type 分发到对应渲染器
}
```
### 3.3 SceneDescriptor场景的唯一声明
```python
@dataclass(frozen=True)
class SceneDescriptor:
scene_id: str # 唯一标识,等同 task_type
label: str # 中文标签
signal_keywords: tuple[str, ...] # 规则识别的关键词(聚合进 off_topic 信号池)
ontology_fields: tuple[str, ...] # 该场景允许的槽位
gate: GateRule # 门控规则(见 3.4)
route: SceneRoute # 路由策略(见 3.5)
handler: Callable | None # 执行函数(handler_only 路由用)
can_resume: bool = False # 是否参与"再提交"上下文恢复
action_steps_builder: Callable = ... # 动作步骤生成
prompt_fragment: str = "" # 注入 LLM system prompt 的识别指引
priority: int = 100 # gate_classify 的匹配优先级(小优先)
flow_id: str | None = None # 候选流程用;查询/命令类为 None
```
### 3.4 GateRule门控规则枚举
```python
class GateRule(Enum):
OFF_TOPIC = "off_topic" # 非业务输入,走 off_topic_reply
CHOICE = "choice" # 明确的业务选择,走 handler/model
AMBIGUOUS_FLOW = "ambiguous_flow" # 话术歧义,走候选流程确认
MODEL_ONLY = "model_only" # 只走 LLM function call,不参与规则匹配
```
### 3.5 SceneRoute路由策略枚举
```python
class SceneRoute(Enum):
HANDLER_ONLY = "handler_only" # 不走 LLM,直接执行 handler(查询/命令类)
MODEL_INTENT = "model_intent" # 走 LLM function call(申请/报销类)
OFF_TOPIC = "off_topic" # 走 off_topic 回复
RESUME = "resume" # 走确定性上下文恢复
AMBIGUOUS = "ambiguous" # 走候选流程确认
```
## 4. gate_classify 节点的裁决逻辑(唯一决策点)
```python
def gate_classify(state) -> dict:
"""统一门控:按优先级遍历 registry,输出 scene_id + route。"""
message = state["message"]
steward_state = state["steward_state"]
history = state["recent_history"]
# ① off_topic 门:聚合所有场景的 signal_keywords,无命中 → off_topic
if not _matches_any_signal(message):
return {"scene_id": "off_topic", "route": SceneRoute.OFF_TOPIC}
# ② resume 门:用户说"再提交"+ state 有可恢复 flow
resume_scene = _check_resume(message, steward_state)
if resume_scene:
return {"scene_id": resume_scene, "route": SceneRoute.RESUME}
# ③ 规则匹配门:按 priority 遍历,命中 signal_keywords 的场景
for scene in registry.scenes_sorted_by_priority():
if scene.gate == GateRule.CHOICE and _matches_keywords(message, scene.signal_keywords):
return {"scene_id": scene.scene_id, "route": scene.route}
# ④ LLM 门:规则未命中,走 model function call
return {"scene_id": None, "route": SceneRoute.MODEL_INTENT}
```
**所有门控收敛到这一个函数。** off_topic 信号词、resume 判断、规则匹配、LLM 兜底,全部在这里按固定顺序裁决。
## 5. 文件结构
```
server/src/app/services/
scenes/ # 场景声明(每个场景一个文件)
__init__.py # 注册所有场景
scene_registry.py # SceneRegistry 单例 + 查询方法
scene_descriptor.py # SceneDescriptor dataclass
scene_expense_application.py # 出差申请场景
scene_reimbursement.py # 报销场景
scene_query_travel_standard.py# 差旅标准查询场景
gate_rules.py # GateRule / SceneRoute 枚举
steward_graph_planner.py # 图:load_context/gate_classify/.../persist_state
steward_scene_handlers.py # 各场景的 handler 纯函数
```
## 6. 迁移路径(分阶段,每阶段可独立验证)
### Phase 1建场景注册表 + 收口后端门控(后端自闭环)
**目标**:后端 endpoint 零编排,图成为唯一编排者。
1. 新建 `scenes/` 目录,实现 `SceneDescriptor` / `SceneRegistry` / `GateRule` / `SceneRoute`
2. 把现有 3 个场景expense_application / reimbursement / query_travel_standard迁入 descriptor
3. 新增图节点:`load_context``gate_classify``resume_recent_task``persist_state`
4. 把 endpoint 的 4 个补丁函数搬进图节点
5. endpoint 退化为 3 行
**验证**:后端全量测试绿 + 端到端(上海出差/再提交/查差旅标准)通过
### Phase 2前端退化为纯渲染
**目标**:前端移除 7 层 if/else统一发给后端。
1. `startInlineConversation` 改为:`fetchStewardPlan → renderPlanResponse`
2.`plan.next_action` / `task_type` 分发到渲染器(申请预览/报销预览/查询结果/纯文本)
3. 移除 `shouldRequestWorkbenchAiIntentPlan``isReimbursementCreationIntent``isLowConfidenceTravelApplicationPlan` 等前端门控函数
4. 保留并复用现有渲染组件applicationPreview、stewardPlan 渲染逻辑不重写)
**验证**:前端测试绿 + 人工验证各场景渲染正确
### Phase 3清理冗余
1. 删除 `steward_planner_fallback.py``_classify_irrelevant_input` 独立门控
2. 删除 `_looks_like_ambiguous_travel_flow` 独立判定(收进 gate_classify
3. 统一 signal_keywords 来源registry 唯一)
4. 删除旧的 endpoint 补丁函数
## 7. 验证标准(持久性的可衡量判据)
接入一个新场景(如"查报销进度")时,**改动文件清单必须且仅限于**
| 文件 | 改动 |
|---|---|
| `scenes/scene_query_reimbursement_progress.py` | 新建1 个 SceneDescriptor + 1 个 handler 函数 |
| `scenes/__init__.py` | 加 1 行 import + register |
**如果接入时需要动 `steward_graph_planner.py` / `steward.py` / 前端 composable / extraction.py / fallback.py 中任何一个,说明架构没有收口成功。** 这是验收的硬标准。
## 8. 不改变的东西
- `RuntimeChatService`(模型供应商抽象):不动
- `StewardActionExecutor`(执行分发):已在 registry 驱动,不动
- `AgentConversationService`(消息持久化):不动,只是调用点从 endpoint 搬进图节点
- LangGraph 的 `StateGraph` / `interrupt` / checkpoint继续用只是节点职责更完整
- 现有渲染组件applicationPreview 表格、stewardPlan 消息):复用,不重写
## 9. 风险与对策
| 风险 | 对策 |
|---|---|
| 图重构引入回归 | Phase 1 每搬一个节点跑一次全量测试 |
| 前端去掉门控后某些场景渲染不出 | Phase 2 先保留渲染器映射,只改"谁决策"不改"怎么渲染" |
| gate_classify 性能(遍历 registry | 场景数 <20关键词正则匹配 O(1),无性能问题 |
| LLM 历史注入搬进图后 token 超限 | 保持 limit=10 不变 |

View File

@@ -28,15 +28,86 @@
- 影响:模型给出低置信度差旅申请意图时不再直接建预览,先反问确认;闲聊类输入不再误触发模型规划,响应更快、减轻后端压力。
- 局限:`agent-change-log` Skill 在当前环境不可调用,已按 AGENTS.md 规范手动增量更新本日志。
- 22:40我落地了注册表驱动的意图插槽架构,让新增意图从「改 6+ 处硬编码」降到「写一个描述符 + 执行函数 + 注册」,并端到端跑通了「查差旅标准」查询意图作为样板。
- Git 提交检查:`git fetch --all --prune` 后本地与 origin/main 同步(不 ahead 不 behind);工作区有本次新增/修改的后端文件。
- 背景:排查确认旧架构里 `task_type`/`assigned_agent`/`flow_id` 在 schema(Literal)、function call schema(enum)、model_plan_builder(白名单)、action_contracts(if/else)、action_executor(if/elif)五层硬编码,加一个意图要同步改 6+ 处,完全没有扩展点;且"查差旅标准"这类查询意图无任何位置(task_type enum 只有 expense_application/reimbursement)。
- 修改①(注册表核心):新建 `steward_intent_registry.py`——`IntentDescriptor` 声明 task_type/assigned_agent/signal_keywords/ontology_field_allowlist/action_steps_builder/executor/flow_id/prompt_fragment;新建 `steward_intent_bootstrap.py` 在 import 时注册 3 个意图(expense_application/reimbursement/query_travel_standard)。
- 修改②(schema 放宽):`schemas/steward.py``StewardTaskType`/`StewardAssignedAgent`/`StewardActionType`/`StewardFlowId` 从 Literal 改为 str,运行时校验下沉到 registry,让 schema 不再是扩展拦路虎。
- 修改③(执行分发):`steward_action_executor.py``execute()` 从 if/elif 链改为优先查 registry(`resolve_intent_by_action`)委托 executor;新增 `_dispatch_application_action`/`_dispatch_reimbursement_action` 分发入口;`SUPPORTED_ACTIONS`/`NOOP_ACTIONS` 改为与 registry 聚合(`all_side_effect_actions`/`all_noop_actions`)。
- 修改④(动作生成):`steward_action_contracts.py``build_task_action_steps` 改为查 registry 的 `action_steps_builder`;原 `_build_application_steps`/`_build_reimbursement_steps` 改公开供 registry 引用。
- 修改⑤(function schema 动态化):`steward_intent_agent.py` 的 task_type/flow_id enum 改为 `all_task_types()`/`all_flow_ids()` 动态生成;system prompt 改为从 registry 拼接意图列表 + 每个 intent 的 prompt_fragment。
- 修改⑥(白名单放开):`steward_model_plan_builder.py` 的 task_type 白名单改 `get_intent`;assigned_agent/flow_id/字段过滤全部改 registry 驱动;`_sanitize_model_ontology_fields`/`_sanitize_model_missing_fields` 改 per-task_type allowlist(`field_allowlist_for`);查询类意图(flow_id=None)跳过必填字段推断。
- 修改⑦(查询执行器):新建 `steward_query_executors.py`——`build_travel_standard_query_steps` 生成单步无副作用动作;`execute_travel_standard_query` 从槽位取 location/employee_grade/standard_category,复用 `DEFAULT_TRAVEL_POLICY_CONFIG` 按职级×城市分级查住宿/交通标准,拼装 Markdown 回复(补助标准因未纳入运行时配置用占位说明)。
- 修改⑧(门控适配):`steward_planner_extraction.py``_looks_like_ambiguous_travel_flow` 加查询信号词前置判断(命中查询意图直接返回 False,不走候选流程);`_build_task` 的 task_id/assigned_agent/label 改 registry 驱动;`steward_planner_fallback.py``_classify_irrelevant_input` 补充 registry signal_keywords 判断(避免查询类输入被判 off_topic);`steward_graph_planner.py` import bootstrap 触发注册。
- 修改⑨(前端):`stewardPlanModel.js``TASK_TYPE_LABELS`/`AGENT_LABELS`/`EXECUTABLE_STEWARD_ACTION_TYPES` 加 query_travel_standard/execute_travel_standard_query/policy_query_assistant。
- 验证:后端全量 steward 测试 **72 passed**(含新增 14 个:registry 7 + query executor 7);前端意图测试 **28 passed**;既有申请/报销/规划/动作执行/槽位决策链路全部无回归。
- 容器:容器名为 `x-financial-local-linux`(非 `local-x-financial-linux`),已运行 19 小时;后端测试在该容器内执行,venv 在 `/tmp/x-financial-server-venv`
- 影响:现在加一个新意图(如查报销进度、查预算执行)只需:① 写 `IntentDescriptor` 声明 task_type/槽位/信号词/executor;② 注册进 bootstrap;③ 写执行函数。function schema、动作生成、执行分发、字段过滤、门控全部自动适配,零硬编码改动。
- 局限:补助标准(allowance)尚未接入运行时配置,查询时返回占位说明;前端查询结果当前以 Markdown 消息展示,未做卡片化;registry 只在后端,前端 task_type 分发仍是 `resolveNextActionContext` 的 if/else(本次只加了 query 分支,未全面注册表化)。
## 遗留问题
- 09:18官方 `quick_validate.py` 仍因当前 Python 环境缺少 `PyYAML` 无法运行,已用 frontmatter、必需文件、占位符和 diff check 做人工兜底。建议后续统一为 skill 校验脚本补齐依赖或增加无 PyYAML 的轻量校验路径。
- 09:23当前环境没有找到 Skill Creator 的 `quick_validate.py` 脚本文件本体,因此本次继续采用人工兜底校验。建议后续恢复系统 Skill Creator 脚本路径,或把轻量校验脚本纳入仓库级工具。
- 21:30`expense-application-fast-preview.test.mjs` 仍有 12 个既有失败(文案「小财管家」「此意图系统不支持」与 markdown 表格整块渲染相关),与本次意图门控改动无关,建议单独排查。
- 21:30本次未纳入范围的三项已记录时间过滤维度扩展仅支持 N天前/昨天/今天)、排除词两处重复维护、`handleInlineDraftDeletionIntent` 命名与职责不符,建议后续分批处理。
- 22:40补助标准(allowance)未纳入 `DEFAULT_TRAVEL_POLICY_CONFIG`,查询差旅标准时住宿/交通有确定数值,补助只返回占位说明。补助数据在 `server/rules/finance-rules/出差补助标准.xlsx`,后续需把补助标准接入运行时配置并在 `resolve_travel_standard_snapshot` 补全。
- 22:40前端查询结果当前以普通 Markdown 消息展示,没有像申请预览那样的卡片化视图;查询意图的前端分发仍是 `resolveNextActionContext` 的 if/else,未全面注册表化(本次只加了 query 分支)。
- 22:40`server/rules/finance-rules/` 下有两个 Excel(交通工具等级标准、交通费用预估表)被标记为 modified,疑似容器运行时产物,非本次代码改动,未处理。
- 22:40`agent-change-log` Skill 在当前环境不可调用,已按 AGENTS.md 规范手动增量更新本日志。
- 23:30我落地了会话上下文保留机制LLM + 确定性双保险),解决了"用户删除草稿后说'再提交'丢失上下文"的问题。
- Git 提交检查:`git fetch --all --prune` 后本地与 origin/main 同步(不 ahead 不 behind)。
- 背景:排查确认对话消息和 steward_state 虽已持久化在 DB但 plan 接口的 `build_plan` 从不读历史 task且"再提交"被路由到 plan 接口(而非能恢复 task 的 runtime-decision 接口),导致系统无法把"再提交"和之前被拦的出差申请关联起来。
- 修改①LLM 历史关联·保险②):`steward.py` 新增 `_inject_recent_conversation_history`,在 build_plan 前用 `AgentConversationService.list_message_history(conversation_id, limit=10)` 读出最近 10 条对话,注入 `context_json.recent_history``steward_intent_agent.py``_build_messages` 把 recent_history 暴露为 context_payload 顶层结构化字段,并在 system prompt 加引导:"当用户说'再提交''继续''重新提交'等确认类话术时,必须结合 recent_history 里最近一次提到的出差/报销申请来理解"。
- 修改②(确定性兜底·保险①):新建 `steward_context_resume.py`——`should_resume_recent_task` 检测"再提交"类话术12 个关键词)+ `steward_state.flows` 有可恢复 flow`resume_task_from_flow` 从 flow.fields 恢复 StewardTask复用 runtime-decision 的恢复逻辑);`attach_resumed_task` 把恢复的 task 挂回 planplanning_source 标记为 `context_resume``steward.py` 新增 `_apply_context_resume`,在 build_plan 后、plan 无 task 时触发确定性兜底。两个入口(`/plans``/plans/stream`)都已接入。
- 验证:后端全量测试 **67 passed**(含新增 11 个context_resume 8 + intent_agent history 3端到端验证两轮对话——"上海出差火车"→"再提交"LLM 历史关联成功恢复 expense_application taskfields 完整);纯函数验证确定性兜底在模型返回空 task 时从 state 恢复planning_source=context_resume
- 影响:会话上下文保留到用户清理会话;行为处理只看最近 10 条,超长会话不爆 token"再提交"类话术现在能恢复之前被拦的申请 task。正常 plan 产生的 task 已通过 `merge_plan` 写进 `steward_state.flows`,重复检查不改 state所以 task 在 state 里一直存活到会话结束。
## 遗留问题(补充)
- 23:30历史条数固定为 10未做 token 感知裁剪;极端情况下单条消息很长(如粘贴大段文本)可能导致 token 超限,但实测正常对话不会触发。
- 00:10我完成了统一门控管道的架构设计文档作为后续重构的唯一事实来源。
- 文档路径:`document/development/AI意图规划器/UNIFIED_GATE_PIPELINE.md`
- 核心判断:当前门控散落在 7 处(前端 7 层 if/else + 后端 endpoint 4 个补丁 + 图条件边 + off_topic 关键词 + 候选流程判定),每加一个场景成本 O(n),漏一处静默出错。这是"不持久"的根因。
- 目标架构LangGraph 图成为唯一编排者load_context → gate_classify → route 分支 → attach_action_steps → persist_stateendpoint 退化为 3 行纯 IO前端退化为纯渲染fetchStewardPlan → renderPlanResponse
- 接入成本 O(1) 的硬验收标准:加场景只需新建 1 个 SceneDescriptor + 1 个 handler 函数 + 注册,不动图/endpoint/前端/extraction。
- 迁移分 3 阶段Phase 1 后端收口(建 scenes 注册表 + endpoint 补丁搬进图节点、Phase 2 前端退化纯渲染(移除 7 层 if/else、Phase 3 清理冗余。
- Git 提交检查:本地与 origin/main 同步。
- 00:50我完成了统一门控管道 Phase 1 的 scene 注册表骨架P1.1-P1.2),作为后端收口的基础设施。
- Git 提交检查:本地与 origin/main 同步。
- 修改:新建 `server/src/app/services/scenes/` 目录——`gate_rules.py`GateRule/SceneRoute 枚举)、`scene_descriptor.py`SceneDescriptor dataclass声明 scene_id/label/signal_keywords/ontology_fields/gate/route/handler/can_resume/flow_id/prompt_fragment/priority 等)、`scene_registry.py`SceneRegistry 单例 + 查询方法、3 个场景文件expense_application/reimbursement/query_travel_standard`__init__.py`bootstrap + 运行时绑定 handler/builder/executor
- 验证:冒烟测试 3 个场景注册成功、优先级排序正确query 在前,priority=50、35 个 signal_keywords 聚合、handler/builder/executor 运行时绑定成功、无循环 import后端全量 76 passedscene 注册表的加入未破坏任何现有代码。
- 影响为后续图拓扑重构P1.3-P1.8)提供了声明式场景注册基础设施。当前 scene_registry 与现有 intent_registry 并存,后续 P1.3-P1.7 会把 intent_registry 的消费者逐步迁移到 scene_registry。
- 下一步P1.3-P1.8 图拓扑重构(新增 load_context/gate_classify/resume/persist 节点、endpoint 退化、registry 消费者迁移)。
- 01:30我完成了统一门控管道 Phase 1 的图拓扑重构P1.3-P1.7LangGraph 成为唯一编排者。
- Git 提交检查:本地与 origin/main 同步。
- 修改①(图节点扩展):`steward_graph_planner.py``StewardGraphState` 新增 recent_history/steward_state/gate_decision/gate_scene_id/conversation_id 字段。新增 5 个图节点——`load_context`读最近10条历史+steward_state+注入recent_history到request`gate_classify`(统一门控裁决,resume门→off_topic门→规则匹配门→LLM门四步顺序`execute_scene_handler`HANDLER_ONLY 路由,构造 StewardActionExecuteRequest 调 handler`resume_recent_task`RESUME 路由,从 state 恢复 task`_build_pending_flow_fallback_graph_plan`ambiguous 路由 wrapper
- 修改②(图拓扑):从 5 节点prepare_context→{model/off_topic/fallback}→attach_action_steps重构为 10 节点load_context→gate_classify→{off_topic/handler_only/resume/ambiguous/model_intent}→各自分支→attach_action_steps
- 修改③endpoint 退化endpoint 的 inject/resume 补丁搬进图节点hydrate/persist 仍由 endpoint 调(阶段性保留,P3 收敛到图内)。按 planner 类型分发 `build_plan(payload, db=db)`LangGraphvs `build_plan(payload)`legacy
- 修改④schema 放宽):`StewardPlanningSource`/`StewardPlanNextAction` 从 Literal 改 str,支持 scene_handler:*/context_resume/answer_only 等新值。
- 修改⑤resume 门控优先级resume 门控提到 off_topic 门之前,避免"再提交"被误判 off_topic。
- 验证:后端全量 **76 passed**;端到端 4 场景全部通过——①出差申请(llm_function_call, 1 task)、②再提交(context_resume, 1 task, 5 fields)、③查差旅标准(scene_handler:query_travel_standard)、④闲聊(off_topic)。
- 影响LangGraph 图成为唯一编排者,门控收敛到 gate_classify 单一决策点。endpoint 仍保留 hydrate/persist 两个补丁,P3 会收敛。
- 02:30我把意图识别从"规则驱动"改为"LLM 驱动 + 极轻量规则过滤",规则不再判断"这是哪个业务场景"。
- Git 提交检查:本地与 origin/main 同步。
- 背景gate_classify 用 94 关键词 + scene_registry CHOICE 规则匹配判断业务相关性,每加场景要维护关键词,关键词冲突(出差既可能申请也可能查标准),还导致 off_topic 误杀("下周去上海"被拦)。但 LLM 已经完全具备多场景识别能力(schema enum + prompt_fragment 都从 registry 动态生成),规则匹配门是多余的。
- 修改①(简化 gate_classify删掉规则匹配门(③)和 ambiguous 提前判断(④)。gate_classify 现在只做两件事:①闲聊拦截(极轻量:greeting+meaningless,省 LLM 成本)②resume 确定性兜底。其他全部默认走 model_intent。新增 `_is_lightweight_off_topic` 只调 `_looks_like_greeting`+`_looks_like_meaningless`,不调带 94 词的 `_classify_irrelevant_input`
- 修改②HANDLER_ONLY 改为 LLM 输出驱动):查差旅标准不再由 gate_classify 规则命中,改为 LLM 返回 task_type=query_travel_standard 后,`_route_after_model_intent` 检查 scene.route=HANDLER_ONLY → 转 execute_scene_handler。`execute_scene_handler` 优先使用 LLM 生成的 task(含 ontology_fields)。
- 修改③图拓扑简化gate_classify 只输出 off_topic|resume|model_intent 三种。删掉 handler_only/ambiguous 边。
- 验证:后端 **76 passed**;端到端 7 场景全通过——①你好→off_topic、②123→off_topic、③下周去上海→llm_function_call(之前被误杀!)、④P5武汉住宿标准→scene_handler:query_travel_standard、⑤再提交→context_resume、⑥出差并且报销招待费→llm_function_call 返回 2 task(expense_application+reimbursement)。
- 影响:意图识别全部交给 LLM,规则只保留闲聊拦截和 resume 兜底两个不可替代职责。加新场景只需注册 SceneDescriptor(prompt_fragment+ontology_fields+handler),LLM 的 system prompt 和 schema 自动包含,**不加任何关键词规则,不动门控代码**。
## TODO
- [ ]`quick_validate.py` 准备稳定运行环境,避免后续新增 Skill 时继续依赖人工兜底。来源09:18 技能校验)
- [ ] 排查 `expense-application-fast-preview.test.mjs` 的 12 个既有失败(小财管家文案 / 表格整块渲染来源21:30 意图门控加固)
- [ ] 评估意图门控剩余三项:时间过滤维度扩展、排除词常量抽取、`handleInlineDraftDeletionIntent` 重命名。来源21:30 意图门控加固)
- [ ] 把补助标准(`出差补助标准.xlsx`)接入运行时配置,补全 `resolve_travel_standard_snapshot` 的 allowance 数值查询。来源22:40 注册表架构)
- [ ] 前端查询结果卡片化,并把前端 task_type 分发也改成注册表驱动。来源22:40 注册表架构)
- [ ] 在真实 LLM 连通环境下,用「我去武汉出差的住宿标准是多少」端到端验证查询意图识别→执行→回复。来源22:40 注册表架构)
- [ ] 评估是否需要把 LangGraph 迁移 Phase 5(trace UI、legacy 收敛)与注册表架构的查询意图 trace 打通。来源22:40 注册表架构)

View File

@@ -29,6 +29,10 @@ from app.services.agent_conversations import AgentConversationService
from app.services.expense_claim_draft_flow import APPROVED_APPLICATION_LINK_STATUSES
from app.services.expense_claims import ExpenseClaimService
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_context_resume import (
attach_resumed_task,
should_resume_recent_task,
)
from app.services.steward_flow_state import StewardFlowStateService
from app.services.steward_graph_action_runtime import StewardGraphActionRuntime
from app.services.steward_graph_planner import StewardGraphPlannerService
@@ -61,7 +65,10 @@ def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPl
try:
planner = _build_steward_planner(db)
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
plan = planner.build_plan(hydrated_payload)
if isinstance(planner, StewardGraphPlannerService):
plan = planner.build_plan(hydrated_payload, db=db)
else:
plan = planner.build_plan(hydrated_payload)
return _attach_conversation_state(db, hydrated_payload, plan)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -143,7 +150,10 @@ async def _iter_steward_plan_events(
try:
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
plan = planner.build_plan(hydrated_payload)
if isinstance(planner, StewardGraphPlannerService):
plan = planner.build_plan(hydrated_payload, db=db)
else:
plan = planner.build_plan(hydrated_payload)
plan = _attach_conversation_state(db, hydrated_payload, plan)
except ValueError as exc:
yield _encode_stream_event("error", {"message": str(exc)})
@@ -495,3 +505,66 @@ def _resolve_current_steward_state(
return stored_state
incoming_state = context_json.get("steward_state") or context_json.get("stewardState")
return incoming_state if isinstance(incoming_state, dict) else {}
def _inject_recent_conversation_history(
db: Session,
payload: StewardPlanRequest,
) -> StewardPlanRequest:
"""读取会话最近 10 条对话历史,注入 context_json.recent_history 供 LLM 关联上下文。
历史只给模型用,不返回前端。在 get_or_create_conversation 之前读取,
使用前端传入的 conversation_id避免把本轮消息算进历史。
"""
context_json = dict(payload.context_json or {})
conversation_id = _resolve_conversation_id(context_json)
if not conversation_id:
return payload
try:
recent_history = AgentConversationService(db).list_message_history(
conversation_id,
limit=10,
)
except Exception:
recent_history = []
if not recent_history:
return payload
return payload.model_copy(
update={
"context_json": {
**context_json,
"recent_history": recent_history,
}
}
)
def _apply_context_resume(
db: Session,
payload: StewardPlanRequest,
plan: StewardPlanResponse,
) -> StewardPlanResponse:
"""确定性兜底:若 plan 无 task 且用户说"再提交"类话术,从会话状态恢复最近 task。
不依赖 LLM 理解力100% 可靠地恢复上下文。LLM 注入历史(保险②)覆盖更模糊话术。
"""
if plan.tasks or plan.candidate_flows:
return plan
context_json = dict(payload.context_json or {})
conversation_id = _resolve_conversation_id(context_json)
if not conversation_id:
return plan
try:
conversation = AgentConversationService(db).get_conversation(conversation_id)
except Exception:
conversation = None
if conversation is None:
return plan
current_state = _resolve_current_steward_state(
conversation.state_json if isinstance(conversation.state_json, dict) else {},
context_json,
)
resume_flow_id = should_resume_recent_task(payload.message, current_state)
if not resume_flow_id:
return plan
return attach_resumed_task(plan, current_state, resume_flow_id)

View File

@@ -56,6 +56,23 @@ class SettingsSessionForm(BaseModel):
conversationRetentionDays: int = Field(default=3, ge=1, le=10)
class SettingsModelRow(BaseModel):
slot: str = Field(min_length=1, max_length=64)
provider: str = Field(min_length=1, max_length=64)
url: str = Field(min_length=1, max_length=512)
apiKey: str = Field(default="", max_length=1024)
apiKeyConfigured: bool = False
modelId: str = Field(min_length=1, max_length=255)
type: Literal["llm", "embedding", "rerank"] = "llm"
@field_validator("slot", "provider", "url", "apiKey", "modelId", mode="before")
@classmethod
def strip_model_row_string(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()
class SettingsLlmForm(BaseModel):
mainProvider: str = Field(min_length=1, max_length=64)
mainModel: str = Field(min_length=1, max_length=255)
@@ -80,6 +97,7 @@ class SettingsLlmForm(BaseModel):
rerankerEndpoint: str = Field(min_length=1, max_length=512)
rerankerApiKey: str = Field(default="", max_length=1024)
rerankerApiKeyConfigured: bool = False
models: list[SettingsModelRow] = Field(default_factory=list)
@field_validator(
"mainProvider",
@@ -201,7 +219,7 @@ class ModelConnectivityTestRequest(BaseModel):
model: str = Field(min_length=1, max_length=255)
api_key: str | None = Field(default=None, max_length=1024)
capability: Literal["chat", "embedding", "reranker"] = "chat"
slot: Literal["main", "backup", "embedding", "reranker"] | None = None
slot: str | None = Field(default=None, max_length=64)
@field_validator("provider", "endpoint", "model", "api_key", mode="before")
@classmethod
@@ -234,7 +252,7 @@ class SettingsCacheClearRead(BaseModel):
class RuntimeModelConfigRead(BaseModel):
slot: Literal["main", "backup", "embedding", "reranker"]
slot: str
provider: str
model: str
endpoint: str

View File

@@ -4,10 +4,10 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
StewardTaskType = Literal["expense_application", "reimbursement"]
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"]
StewardTaskType = str
StewardAssignedAgent = str
StewardPlanningSource = str # 放宽:支持 llm_function_call / rule_fallback / scene_handler:* / context_resume
StewardPlanNextAction = str # 放宽:支持 confirm_flow / confirm_task / delegate_task / none / answer_only
StewardRequestedAction = Literal["preview", "save_draft", "submit"]
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
StewardSlotNextAction = Literal["ask_user", "render_preview"]
@@ -22,20 +22,7 @@ StewardRuntimeNextAction = Literal[
"cancel_current_action",
"no_op",
]
StewardActionType = Literal[
"detect_intent",
"fill_application_fields",
"build_application_preview",
"fill_reimbursement_fields",
"build_reimbursement_preview",
"validate_required_fields",
"run_duplicate_precheck",
"save_application_draft",
"submit_application",
"link_existing_application",
"create_reimbursement_draft",
"associate_attachments",
]
StewardActionType = str
StewardActionStatus = Literal["completed", "planned", "pending_confirmation", "blocked"]
StewardActionExecutionStatus = Literal["succeeded", "blocked", "needs_confirmation", "failed"]
StewardTaskStatus = Literal[
@@ -47,7 +34,7 @@ StewardTaskStatus = Literal[
"blocked",
]
StewardConfirmationStatus = Literal["pending", "confirmed", "rejected"]
StewardFlowId = Literal["travel_application", "travel_reimbursement"]
StewardFlowId = str
StewardPendingFlowStatus = Literal["none", "pending", "confirmed", "rejected"]

View File

@@ -550,7 +550,9 @@ class RuntimeChatService:
"max_tokens": max_tokens,
"temperature": temperature,
}
if provider == "GLM":
# function calling 需要确定性结构化输出,thinking mode 与强制 tool_choice 冲突
# (如 Ali 通义在 thinking 模式下拒绝 tool_choice=object),这里统一禁用。
if provider in {"GLM", "Ali"}:
request_payload["thinking"] = {"type": "disabled"}
status_code, payload = _send_json_request(

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from typing import Any
from app.services.scenes.scene_descriptor import SceneDescriptor
from app.services.scenes.scene_registry import REGISTRY, register_scene # noqa: F401
def bootstrap_scenes() -> None:
"""注册全部业务场景,并运行时绑定 handler/builder/executor。
descriptor 声明时 handler/builder/executor 为 None(避免循环 import),
这里在运行时从各自的服务模块取回实际可调用对象并回填到 descriptor。
新增场景时:
1. 新建 scenes/scene_xxx.py,声明 SceneDescriptor(handler 留 None)
2. 在这里加一行 register 调用
3. 如有 handler,在 _bind_runtime_callbacks 里加绑定
"""
# 声明式注册(不依赖任何服务模块)
from app.services.scenes import (
scene_expense_application,
scene_query_travel_standard,
scene_reimbursement,
)
if REGISTRY.all_scene_ids():
return # 已注册,避免重复
scene_expense_application.register()
scene_reimbursement.register()
scene_query_travel_standard.register()
_bind_runtime_callbacks()
def _bind_runtime_callbacks() -> None:
"""运行时把 handler/builder/executor 绑定到 descriptor。
因为 SceneDescriptor 是 frozen dataclass,这里用替换的方式回填。
"""
from app.services.steward_action_contracts import StewardActionPlanBuilder
from app.services.steward_action_executor import StewardActionExecutor
from app.services.steward_query_executors import (
build_travel_standard_query_steps,
execute_travel_standard_query,
)
application_builder = StewardActionPlanBuilder()
# expense_application
_update_scene(
"expense_application",
action_steps_builder=application_builder.build_application_steps,
executor=StewardActionExecutor._dispatch_application_action,
)
# reimbursement
_update_scene(
"reimbursement",
action_steps_builder=application_builder.build_reimbursement_steps,
executor=StewardActionExecutor._dispatch_reimbursement_action,
)
# query_travel_standard
_update_scene(
"query_travel_standard",
action_steps_builder=build_travel_standard_query_steps,
handler=execute_travel_standard_query,
executor=execute_travel_standard_query,
)
def _update_scene(scene_id: str, **overrides: Any) -> None:
"""替换 REGISTRY 里的 descriptor 字段(frozen dataclass 需重建)。"""
scene = REGISTRY.get(scene_id)
if scene is None:
return
updated = SceneDescriptor(**{**scene.__dict__, **overrides})
REGISTRY.register(updated)
# import 即注册
bootstrap_scenes()

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from enum import Enum
class GateRule(str, Enum):
"""门控规则:决定场景如何参与 gate_classify 的裁决。"""
OFF_TOPIC = "off_topic"
"""非业务输入,走 off_topic_reply。"""
CHOICE = "choice"
"""明确的业务选择,命中 signal_keywords 即生效。"""
AMBIGUOUS_FLOW = "ambiguous_flow"
"""话术歧义,走候选流程确认。"""
MODEL_ONLY = "model_only"
"""只走 LLM function call,不参与规则匹配(如申请/报销的复杂识别)。"""
class SceneRoute(str, Enum):
"""路由策略:gate_classify 裁决后决定走图的哪条边。"""
HANDLER_ONLY = "handler_only"
"""不走 LLM,直接执行 handler(查询/命令类场景)。"""
MODEL_INTENT = "model_intent"
"""走 LLM function call(申请/报销类场景)。"""
OFF_TOPIC = "off_topic"
"""走 off_topic 回复。"""
RESUME = "resume"
"""走确定性上下文恢复。"""
AMBIGUOUS = "ambiguous"
"""走候选流程确认。"""

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
from app.services.scenes.gate_rules import GateRule, SceneRoute
@dataclass(frozen=True)
class SceneDescriptor:
"""单个业务场景的声明式描述符。
一个场景的"如何识别""走哪条路""做什么""要什么槽位""能否恢复上下文"
全部在这里声明,实现接入成本 O(1)。
"""
scene_id: str
"""唯一标识,等同 task_type(如 expense_application / query_travel_standard)。"""
label: str
"""中文标签,用于 system prompt、前端展示、日志。"""
signal_keywords: tuple[str, ...] = ()
"""规则识别的关键词;聚合进 off_topic 信号池,也用于 CHOICE 门控规则匹配。"""
ontology_fields: tuple[str, ...] = ()
"""该场景允许的 canonical 槽位;为空表示沿用全局 BUSINESS_CANONICAL_FIELDS。"""
gate: GateRule = GateRule.MODEL_ONLY
"""门控规则,决定场景如何参与 gate_classify 裁决。"""
route: SceneRoute = SceneRoute.MODEL_INTENT
"""路由策略,gate_classify 命中后决定走图的哪条边。"""
handler: Callable[..., Any] | None = None
"""执行函数;HANDLER_ONLY 路由必填,其他路由可选。"""
action_steps_builder: Callable[[Any], list[Any]] | None = None
"""动作步骤生成函数;把 StewardTask 转换为白名单 action steps。"""
can_resume: bool = False
"""是否参与"再提交"上下文恢复。"""
flow_id: str | None = None
"""候选流程确认使用的 flow_id;查询/命令类为 None。"""
prompt_fragment: str = ""
"""注入 steward_intent_agent system prompt 的识别指引片段。"""
priority: int = 100
"""gate_classify 的匹配优先级;数字小的优先。"""
side_effect_actions: tuple[str, ...] = ()
"""该场景产生副作用的 action_type 集合。"""
noop_actions: tuple[str, ...] = ()
"""该场景的无副作用 action_type 集合(填充/预览/校验等)。"""
assigned_agent: str = ""
"""该场景对应的执行 agent 标识。"""
executor: Callable[..., Any] | None = None
"""副作用/查询动作的执行器;供 action_executor 通过 registry 分发。"""

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from app.services.scenes.gate_rules import GateRule, SceneRoute
from app.services.scenes.scene_descriptor import SceneDescriptor
from app.services.scenes.scene_registry import register_scene
def register() -> None:
register_scene(
SceneDescriptor(
scene_id="expense_application",
label="费用申请",
assigned_agent="application_assistant",
signal_keywords=(
"申请", "出差", "差旅", "费用", "交通", "住宿", "采购", "会务", "会议",
"客户现场", "项目", "拜访", "调研", "驻场", "上线", "验收",
),
ontology_fields=(), # 沿用全局 BUSINESS_CANONICAL_FIELDS,运行时 fallback
gate=GateRule.MODEL_ONLY,
route=SceneRoute.MODEL_INTENT,
handler=None,
action_steps_builder=None, # 运行时从 StewardActionPlanBuilder 取
can_resume=True,
flow_id="travel_application",
side_effect_actions=("save_application_draft", "submit_application", "run_duplicate_precheck"),
noop_actions=(
"fill_application_fields",
"build_application_preview",
"validate_required_fields",
),
executor=None, # 运行时从 StewardActionExecutor 取
prompt_fragment=(
"用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时,"
"即使没有出现“申请”两个字,也必须优先识别为 expense_application。"
),
priority=100,
)
)

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from app.services.scenes.gate_rules import GateRule, SceneRoute
from app.services.scenes.scene_descriptor import SceneDescriptor
from app.services.scenes.scene_registry import register_scene
def register() -> None:
register_scene(
SceneDescriptor(
scene_id="query_travel_standard",
label="差旅标准查询",
assigned_agent="policy_query_assistant",
signal_keywords=(
"差旅标准", "住宿标准", "出差标准", "交通标准", "出差补助",
"差旅补贴", "住宿补助", "交通补助", "职级标准", "差标",
),
ontology_fields=(
"location",
"employee_grade",
"standard_category",
"expense_type",
),
gate=GateRule.CHOICE,
route=SceneRoute.HANDLER_ONLY,
handler=None, # 运行时从 steward_query_executors 取
action_steps_builder=None, # 运行时从 steward_query_executors 取
can_resume=False,
flow_id=None,
side_effect_actions=("execute_travel_standard_query",),
noop_actions=(),
executor=None, # 运行时从 steward_query_executors 取
prompt_fragment=(
"用户询问差旅住宿标准、交通标准、出差补助或差旅补贴标准时,"
"必须识别为 query_travel_standard,而不是 expense_application 或 reimbursement。"
"差旅标准查询不创建任何单据,只返回标准数值。"
),
priority=50, # 比 MODEL_ONLY 场景优先,确保查询类先被规则命中
)
)

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
from typing import Any, Callable
from app.services.scenes.gate_rules import GateRule, SceneRoute
from app.services.scenes.scene_descriptor import SceneDescriptor
class SceneRegistry:
"""场景注册表单例。
所有场景在 import 时注册,门控/路由/执行/字段过滤全部从这里查询。
gate_classify 节点是它的唯一消费者(单一决策点)。
"""
def __init__(self) -> None:
self._scenes: dict[str, SceneDescriptor] = {}
self._flow_to_scene: dict[str, str] = {}
# ---- 注册 ----
def register(self, descriptor: SceneDescriptor) -> SceneDescriptor:
self._scenes[descriptor.scene_id] = descriptor
if descriptor.flow_id:
self._flow_to_scene[descriptor.flow_id] = descriptor.scene_id
return descriptor
# ---- 查询 ----
def get(self, scene_id: str) -> SceneDescriptor | None:
return self._scenes.get(str(scene_id or "").strip())
def all_scenes(self) -> list[SceneDescriptor]:
return list(self._scenes.values())
def scenes_sorted_by_priority(self) -> list[SceneDescriptor]:
"""按 priority 升序排列(数字小优先)。"""
return sorted(self._scenes.values(), key=lambda s: s.priority)
def all_scene_ids(self) -> list[str]:
return [s.scene_id for s in self._scenes.values()]
def all_assigned_agents(self) -> list[str]:
return [s.assigned_agent for s in self._scenes.values() if s.assigned_agent]
def all_flow_ids(self) -> list[str]:
return [s.flow_id for s in self._scenes.values() if s.flow_id]
def all_signal_keywords(self) -> set[str]:
keywords: set[str] = set()
for scene in self._scenes.values():
keywords.update(scene.signal_keywords)
return keywords
def all_side_effect_actions(self) -> set[str]:
actions: set[str] = set()
for scene in self._scenes.values():
actions.update(scene.side_effect_actions)
return actions
def all_noop_actions(self) -> set[str]:
actions: set[str] = set()
for scene in self._scenes.values():
actions.update(scene.noop_actions)
return actions
def resolve_scene_by_action(self, action_type: str) -> SceneDescriptor | None:
normalized = str(action_type or "").strip()
for scene in self._scenes.values():
if normalized in scene.side_effect_actions or normalized in scene.noop_actions:
return scene
return None
def resolve_scene_by_flow(self, flow_id: str) -> SceneDescriptor | None:
scene_id = self._flow_to_scene.get(str(flow_id or "").strip())
return self.get(scene_id) if scene_id else None
def field_allowlist_for(
self,
scene_id: str,
*,
fallback: frozenset[str] | None = None,
) -> frozenset[str]:
scene = self.get(scene_id)
if scene and scene.ontology_fields:
return frozenset(scene.ontology_fields)
return fallback or frozenset()
def resumable_scenes(self) -> list[SceneDescriptor]:
"""返回所有声明了 can_resume=True 的场景。"""
return [s for s in self._scenes.values() if s.can_resume]
def prompt_fragments(self) -> str:
"""拼接所有场景的 prompt_fragment,供 system prompt 注入。"""
fragments = [s.prompt_fragment for s in self._scenes.values() if s.prompt_fragment]
return "".join(fragments)
def intent_summary(self) -> str:
"""拼接场景列表摘要,供 system prompt 引用。"""
fragments = [f"{s.scene_id}{s.label}" for s in self._scenes.values()]
return "".join(fragments) if fragments else "(暂无已注册场景)"
# 全局单例
REGISTRY = SceneRegistry()
def register_scene(descriptor: SceneDescriptor) -> SceneDescriptor:
"""注册场景到全局单例。"""
return REGISTRY.register(descriptor)

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from app.services.scenes.gate_rules import GateRule, SceneRoute
from app.services.scenes.scene_descriptor import SceneDescriptor
from app.services.scenes.scene_registry import register_scene
def register() -> None:
register_scene(
SceneDescriptor(
scene_id="reimbursement",
label="费用报销",
assigned_agent="reimbursement_assistant",
signal_keywords=(
"报销", "报账", "票据", "发票", "凭证", "行程单", "付款截图", "小票", "收据",
),
ontology_fields=(), # 沿用全局 BUSINESS_CANONICAL_FIELDS
gate=GateRule.MODEL_ONLY,
route=SceneRoute.MODEL_INTENT,
handler=None,
action_steps_builder=None,
can_resume=False,
flow_id="travel_reimbursement",
side_effect_actions=(
"create_reimbursement_draft",
"link_existing_application",
"associate_attachments",
),
noop_actions=(
"fill_reimbursement_fields",
"build_reimbursement_preview",
"validate_required_fields",
),
executor=None,
prompt_fragment=(
"用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,"
"才识别为 reimbursement。"
),
priority=100,
)
)

View File

@@ -94,6 +94,18 @@ MODEL_SLOT_CONFIGS = {
),
}
MODEL_TYPE_TO_CAPABILITY = {
"llm": "chat",
"embedding": "embedding",
"rerank": "reranker",
}
MODEL_CAPABILITY_TO_TYPE = {
"chat": "llm",
"embedding": "embedding",
"reranker": "rerank",
}
@dataclass(slots=True)
class AdminCredentialRecord:
@@ -110,6 +122,26 @@ class OnlyOfficeRuntimeConfig:
jwt_secret: str
def serialize_model_rows(model_rows: dict[str, SystemModelSetting]) -> list[dict[str, object]]:
ordered_rows = sorted(
model_rows.values(),
key=lambda row: (int(row.priority or 0), str(row.slot or "")),
)
return [
{
"slot": row.slot,
"provider": row.provider,
"url": row.endpoint,
"apiKey": "",
"apiKeyConfigured": bool(row.api_key_encrypted),
"modelId": row.model_name,
"type": MODEL_CAPABILITY_TO_TYPE.get(str(row.capability or "chat"), "llm"),
}
for row in ordered_rows
]
class SettingsService:
_schema_ready_lock = threading.Lock()
_schema_ready_keys: set[tuple[str, int]] = set()
@@ -282,6 +314,8 @@ class SettingsService:
payload.llmForm.rerankerEndpoint,
payload.llmForm.rerankerApiKey,
)
if payload.llmForm.models:
self._apply_model_rows(model_rows, payload.llmForm.models)
if payload.renderForm.enabled and not payload.renderForm.publicUrl:
raise ValueError("启用 ONLYOFFICE 时必须配置服务地址。")
@@ -367,31 +401,39 @@ class SettingsService:
)
def load_saved_model_api_key(self, slot: str | None) -> str:
if not slot or slot not in MODEL_SLOT_CONFIGS:
normalized_slot = str(slot or "").strip()
if not normalized_slot:
return ""
settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
encrypted_value = model_rows[slot].api_key_encrypted
model_row = model_rows.get(normalized_slot)
if model_row is None:
return ""
encrypted_value = model_row.api_key_encrypted
if not encrypted_value:
return ""
return self._decrypt_model_api_key(encrypted_value, slot=slot)
return self._decrypt_model_api_key(encrypted_value, slot=normalized_slot)
def get_runtime_model_config(self, slot: str) -> dict[str, str]:
if slot not in MODEL_SLOT_CONFIGS:
normalized_slot = str(slot or "").strip()
if not normalized_slot:
raise ValueError("未知模型槽位。")
settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
model_row = model_rows[slot]
model_row = model_rows.get(normalized_slot)
if model_row is None:
raise ValueError("未知模型槽位。")
return {
"slot": slot,
"slot": normalized_slot,
"provider": model_row.provider,
"model": model_row.model_name,
"endpoint": model_row.endpoint,
"apiKey": self.load_saved_model_api_key(slot),
"apiKey": self.load_saved_model_api_key(normalized_slot),
"capability": model_row.capability,
}
@@ -550,9 +592,61 @@ class SettingsService:
model_row.endpoint = endpoint
normalized_api_key = api_key.strip()
if normalized_api_key == "********":
return
if normalized_api_key:
model_row.api_key_encrypted = encrypt_secret(normalized_api_key)
def _apply_model_rows(self, model_rows: dict[str, SystemModelSetting], rows: list[object]) -> None:
seen_slots: set[str] = set()
active_custom_slots: set[str] = set()
for index, row in enumerate(rows, start=1):
slot = str(getattr(row, "slot", "") or "").strip()
if not slot:
raise ValueError("模型配置缺少槽位标识。")
if slot in seen_slots:
raise ValueError(f"模型槽位重复:{slot}")
seen_slots.add(slot)
if slot in MODEL_SLOT_CONFIGS:
continue
provider = str(getattr(row, "provider", "") or "").strip()
model_id = str(getattr(row, "modelId", "") or "").strip()
url = str(getattr(row, "url", "") or "").strip()
model_type = str(getattr(row, "type", "") or "llm").strip()
capability = MODEL_TYPE_TO_CAPABILITY.get(model_type)
if capability is None:
raise ValueError("模型类型必须是大语言模型、Embedding 或 Rerank。")
if not provider or not model_id or not url:
raise ValueError("模型配置必须填写供应商、model_id 和接口地址。")
model_row = model_rows.get(slot)
if model_row is None:
model_row = SystemModelSetting(slot=slot)
model_rows[slot] = model_row
self._apply_model_setting(
model_row,
provider,
model_id,
url,
str(getattr(row, "apiKey", "") or ""),
)
model_row.capability = capability
model_row.priority = index * 10
model_row.enabled = True
self.db.add(model_row)
if slot not in MODEL_SLOT_CONFIGS:
active_custom_slots.add(slot)
for slot, model_row in list(model_rows.items()):
if slot in MODEL_SLOT_CONFIGS or slot in active_custom_slots:
continue
self.db.delete(model_row)
model_rows.pop(slot, None)
def _build_hermes_model_route(self, model_row: SystemModelSetting) -> HermesModelRoute:
api_key = self._decrypt_model_api_key(model_row.api_key_encrypted, slot=model_row.slot)
@@ -804,6 +898,7 @@ class SettingsService:
"rerankerEndpoint": reranker_model.endpoint,
"rerankerApiKey": "",
"rerankerApiKeyConfigured": bool(reranker_model.api_key_encrypted),
"models": serialize_model_rows(model_rows),
},
renderForm={
"enabled": settings_row.onlyoffice_enabled,

View File

@@ -26,8 +26,21 @@ class StewardActionPlanBuilder:
return plan.model_copy(update={"tasks": tasks, "action_steps": plan_steps})
def build_task_action_steps(self, task: StewardTask) -> list[StewardActionStep]:
from app.services.steward_intent_registry import get_intent
intent = get_intent(task.task_type)
if intent is not None:
return intent.action_steps_builder(task)
if task.task_type == "expense_application":
return self._build_application_steps(task)
return self.build_application_steps(task)
if task.task_type == "reimbursement":
return self.build_reimbursement_steps(task)
return []
def build_application_steps(self, task: StewardTask) -> list[StewardActionStep]:
return self._build_application_steps(task)
def build_reimbursement_steps(self, task: StewardTask) -> list[StewardActionStep]:
return self._build_reimbursement_steps(task)
def _build_application_steps(self, task: StewardTask) -> list[StewardActionStep]:

View File

@@ -15,6 +15,11 @@ from app.schemas.steward import (
from app.schemas.user_agent import UserAgentRequest
from app.services.attachment_association_jobs import AttachmentAssociationJobRunner
from app.services.expense_claims import ExpenseClaimService
from app.services.steward_intent_registry import (
all_noop_actions,
all_side_effect_actions,
resolve_intent_by_action,
)
from app.services.user_agent import UserAgentService
from app.services.user_agent_application_dates import resolve_application_days_from_time_range
@@ -31,8 +36,8 @@ SUPPORTED_ACTIONS = {
"link_existing_application",
"associate_attachments",
}
APPLICATION_SIDE_EFFECT_ACTIONS = {"save_application_draft", "submit_application"}
REIMBURSEMENT_SIDE_EFFECT_ACTIONS = {"create_reimbursement_draft", "link_existing_application"}
APPLICATION_SIDE_EFFECT_ACTIONS = {"save_application_draft", "submit_application", "run_duplicate_precheck"}
REIMBURSEMENT_SIDE_EFFECT_ACTIONS = {"create_reimbursement_draft", "link_existing_application", "associate_attachments"}
NOOP_ACTIONS = {
"fill_application_fields",
"build_application_preview",
@@ -83,7 +88,8 @@ class StewardActionExecutor:
) -> StewardActionExecuteResponse:
action_type = self._normalize_action_type(request.action_type)
trace = [self._trace("received", action_type=action_type, plan_id=request.plan_id)]
if action_type not in SUPPORTED_ACTIONS:
supported = SUPPORTED_ACTIONS | all_side_effect_actions() | all_noop_actions()
if action_type not in supported:
return self._blocked(
action_type,
f"不支持的小财管家动作:{action_type or '空动作'}",
@@ -91,7 +97,8 @@ class StewardActionExecutor:
)
task = request.task
if task is None and action_type not in NOOP_ACTIONS:
noop_actions = NOOP_ACTIONS | all_noop_actions()
if task is None and action_type not in noop_actions:
return self._blocked(
action_type,
"动作缺少任务快照,无法安全执行。",
@@ -107,7 +114,7 @@ class StewardActionExecutor:
trace=[*trace, self._trace("blocked", reason="missing_fields")],
)
if action_type in NOOP_ACTIONS:
if action_type in noop_actions:
return StewardActionExecuteResponse(
action_type=action_type,
status="succeeded",
@@ -118,6 +125,13 @@ class StewardActionExecutor:
},
trace=[*trace, self._trace("completed", mode="noop")],
)
# 优先走注册表:查到 action 所属意图的 executor 即委托执行
intent = resolve_intent_by_action(action_type)
if intent is not None and intent.executor is not None:
return intent.executor(self, request, current_user, trace)
# 兼容回退:注册表未命中时按旧逻辑分发
if action_type == "run_duplicate_precheck":
return self._run_duplicate_precheck(request, current_user, trace)
if action_type in APPLICATION_SIDE_EFFECT_ACTIONS:
@@ -133,6 +147,30 @@ class StewardActionExecutor:
trace=[*trace, self._trace("blocked", reason="unwired_action")],
)
def _dispatch_application_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
"""registry 入口:分发申请类副作用动作。"""
action_type = self._normalize_action_type(request.action_type)
if action_type == "run_duplicate_precheck":
return self._run_duplicate_precheck(request, current_user, trace)
return self._execute_application_action(request, current_user, action_type, trace)
def _dispatch_reimbursement_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
"""registry 入口:分发报销类副作用动作。"""
action_type = self._normalize_action_type(request.action_type)
if action_type == "associate_attachments":
return self._execute_associate_attachments_action(request, current_user, trace)
return self._execute_reimbursement_action(request, current_user, action_type, trace)
def _run_duplicate_precheck(
self,
request: StewardActionExecuteRequest,

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import re
from typing import Any
from app.schemas.steward import (
StewardPlanResponse,
StewardTask,
StewardThinkingEvent,
)
# "再提交"类确认话术:用户在删除草稿/解决冲突后,用这些话术恢复之前的申请 task
RESUME_CONFIRMATION_KEYWORDS = (
"再提交",
"继续提交",
"重新提交",
"再申请",
"继续申请",
"重新申请",
"那就提交",
"那就申请",
"继续吧",
"再试一次",
"重新发起",
"重新创建",
)
# flow_id → task_type 映射,用于从 steward_state 恢复 task
_FLOW_TASK_TYPE = {
"travel_application": "expense_application",
"travel_reimbursement": "reimbursement",
}
_FLOW_ASSIGNED_AGENT = {
"travel_application": "application_assistant",
"travel_reimbursement": "reimbursement_assistant",
}
def should_resume_recent_task(
message: str,
steward_state: dict[str, Any] | None,
) -> str | None:
"""检测'再提交'类话术 + steward_state 里有可恢复的 flow返回 flow_id 或 None。
确定性兜底:不依赖 LLM当用户用确认类话术"再提交")且 state 里存在
一个仍有业务字段的 flow 时,直接恢复该 flow。
"""
if not _matches_resume_keywords(message):
return None
if not isinstance(steward_state, dict):
return None
active_flow = str(steward_state.get("active_flow") or "").strip()
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
# 优先恢复 active_flow其次遍历所有 flow 找最近一个有字段的
candidate_flow_ids: list[str] = []
if active_flow and active_flow in flows:
candidate_flow_ids.append(active_flow)
for flow_id in flows:
if flow_id not in candidate_flow_ids:
candidate_flow_ids.append(flow_id)
for flow_id in candidate_flow_ids:
flow = flows.get(flow_id)
if isinstance(flow, dict) and _flow_has_resumable_fields(flow):
return str(flow_id or "").strip() or None
return None
def resume_task_from_flow(
flow_id: str,
flow: dict[str, Any],
task_index: int = 1,
) -> StewardTask:
"""从 steward_state.flows[flow_id] 恢复成 StewardTask。
复用 runtime-decision 的恢复逻辑_hydrate_runtime_state 的 field 读取),
但产出完整 StewardTask 而非 runtime dict。
"""
task_type = _FLOW_TASK_TYPE.get(flow_id, "expense_application")
assigned_agent = _FLOW_ASSIGNED_AGENT.get(flow_id, "application_assistant")
fields = {
str(key or "").strip(): str(value or "").strip()
for key, value in (flow.get("fields") or {}).items()
if str(key or "").strip() and str(value or "").strip()
}
missing_fields = [
str(item or "").strip()
for item in (flow.get("missing_fields") or [])
if str(item or "").strip()
]
task_prefix = "app" if task_type == "expense_application" else "reim"
return StewardTask(
task_id=f"task_resume_{task_prefix}_{task_index:03d}",
task_type=task_type,
assigned_agent=assigned_agent,
title="恢复上次未完成的申请" if task_type == "expense_application" else "恢复上次未完成的报销",
summary="根据之前的对话上下文恢复该任务。",
status="needs_confirmation" if missing_fields else "ready_to_delegate",
confidence=0.85,
requested_action="submit",
ontology_fields=fields,
missing_fields=missing_fields,
confirmation_required=True,
)
def attach_resumed_task(
plan: StewardPlanResponse,
steward_state: dict[str, Any] | None,
flow_id: str,
) -> StewardPlanResponse:
"""把恢复的 task 挂回 plan并补充一条 thinking_event 说明上下文已恢复。"""
if not isinstance(steward_state, dict):
return plan
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
flow = flows.get(flow_id) if isinstance(flows, dict) else None
if not isinstance(flow, dict):
return plan
resumed_task = resume_task_from_flow(flow_id, flow, task_index=len(plan.tasks) + 1)
tasks = list(plan.tasks) + [resumed_task]
thinking_events = list(plan.thinking_events)
field_summary = "".join(
f"{key}:{value}" for key, value in resumed_task.ontology_fields.items() if value
)
thinking_events.append(
StewardThinkingEvent(
event_id="context_resume_recovered",
stage="llm_function_call",
title="已恢复上次未完成的申请",
content=(
f"识别到您要继续之前的{('出差申请' if resumed_task.task_type == 'expense_application' else '费用报销')}"
f"已从会话上下文恢复该任务"
+ (f"{field_summary})。" if field_summary else "")
),
status="completed",
)
)
return plan.model_copy(
update={
"tasks": tasks,
"thinking_events": thinking_events,
"planning_source": "context_resume",
"next_action": "confirm_task" if resumed_task.missing_fields else "delegate_task",
}
)
def _matches_resume_keywords(message: str) -> bool:
compact = re.sub(r"\s+", "", str(message or ""))
if not compact:
return False
return any(keyword in compact for keyword in RESUME_CONFIRMATION_KEYWORDS)
def _flow_has_resumable_fields(flow: dict[str, Any]) -> bool:
"""判断 flow 是否还有可恢复的业务字段(至少有 1 个非空字段)。"""
fields = flow.get("fields")
if not isinstance(fields, dict):
return False
return any(
str(value or "").strip()
for value in fields.values()
)

View File

@@ -1,13 +1,20 @@
from __future__ import annotations
import re
from datetime import date
from typing import Any, TypedDict
from langgraph.graph import END, START, StateGraph
from sqlalchemy.orm import Session
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse
from app.services import steward_intent_bootstrap # noqa: F401 导入即注册全部业务意图
from app.services.agent_conversations import AgentConversationService
from app.services.scenes import REGISTRY as SCENE_REGISTRY
from app.services.scenes.gate_rules import GateRule, SceneRoute
from app.services.steward_action_contracts import StewardActionPlanBuilder
from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER
from app.services.steward_context_resume import attach_resumed_task, should_resume_recent_task
from app.services.steward_intent_agent import StewardIntentAgent, StewardIntentAgentResult
from app.services.steward_model_plan_builder import StewardModelPlanBuilder
from app.services.steward_off_topic_agent import StewardOffTopicAgent
@@ -15,8 +22,28 @@ from app.services.steward_planner_extraction import StewardPlannerExtractionMixi
from app.services.steward_planner_fallback import StewardPlannerFallbackMixin
# ---- 模块级辅助函数:gate_classify 的判断逻辑 ----
def _compact_text(text: str) -> str:
return re.sub(r"\s+", "", str(text or ""))
def _scene_route_to_gate_decision(route: SceneRoute) -> str:
"""SceneRoute 映射到 gate_decision 字符串。"""
if route == SceneRoute.HANDLER_ONLY:
return "handler_only"
if route == SceneRoute.OFF_TOPIC:
return "off_topic"
if route == SceneRoute.RESUME:
return "resume"
if route == SceneRoute.AMBIGUOUS:
return "ambiguous"
return "model_intent"
class StewardGraphState(TypedDict, total=False):
request: StewardPlanRequest
db: Session
message: str
base_date: date
scenario: str | None
@@ -25,10 +52,25 @@ class StewardGraphState(TypedDict, total=False):
plan: StewardPlanResponse
model_call_traces: list[dict[str, Any]]
fallback_reason: str
# 新增:上下文状态
recent_history: list[dict[str, Any]]
steward_state: dict[str, Any]
# 新增:门控裁决
gate_decision: str # "off_topic" | "handler_only" | "resume" | "ambiguous" | "model_intent" | "fallback"
gate_scene_id: str | None
# 新增:回填的 conversation_id
conversation_id: str | None
class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtractionMixin):
"""用 LangGraph 编排小财管家的意图识别、流程判断和兜底路径。"""
"""用 LangGraph 编排小财管家的意图识别、流程判断和兜底路径。
Phase 1 P1.3-P1.7:LangGraph 是唯一编排者,endpoint 退化为 3 行。
图拓扑:
load_context → gate_classify → {off_topic/handler_only/resume/ambiguous/model_intent}
{build_off_topic_plan / execute_scene_handler / resume_recent_task / detect_model_intent}
→ attach_action_steps → persist_state
"""
def __init__(
self,
@@ -39,10 +81,388 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
self.off_topic_agent = off_topic_agent
self._graph = self._build_graph()
def build_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
# ---- 上下文加载 + 门控裁决(P1.3-P1.4 新增) ----
def _load_context(self, state: StewardGraphState) -> dict[str, Any]:
"""读最近 10 条对话历史 + steward_state,注入 state。
替代 endpoint 的 _hydrate_required_application_gate + _inject_recent_conversation_history。
"""
request = state.get("request")
if request is None:
return {}
db = state.get("db")
# 1. 读对话历史(只给模型用,不返回前端)
recent_history: list[dict[str, Any]] = []
context_json = dict(request.context_json or {})
conversation_id = self._extract_conversation_id(context_json)
if db is not None and conversation_id:
try:
recent_history = AgentConversationService(db).list_message_history(
conversation_id, limit=10
)
except Exception:
recent_history = []
# 2. 读 steward_state(DB 优先,前端传次之)
steward_state: dict[str, Any] = {}
if db is not None and conversation_id:
try:
conversation = AgentConversationService(db).get_conversation(conversation_id)
if conversation is not None and isinstance(conversation.state_json, dict):
stored = conversation.state_json.get("steward_state")
if isinstance(stored, dict):
steward_state = stored
except Exception:
steward_state = {}
if not steward_state:
incoming = context_json.get("steward_state") or context_json.get("stewardState")
if isinstance(incoming, dict):
steward_state = incoming
# 3. 注入 recent_history 到 context_json(供 LLM 使用)
if recent_history:
request = request.model_copy(
update={
"context_json": {
**context_json,
"recent_history": recent_history,
}
}
)
return {
"request": request,
"recent_history": recent_history,
"steward_state": steward_state,
"message": str(request.message or "").strip(),
"base_date": self._resolve_base_date_from_request(request),
}
def _gate_classify(self, state: StewardGraphState) -> dict[str, Any]:
"""统一门控裁决:单一决策点,按固定顺序决定场景和路由。
顺序:
① resume 门(用户说"再提交"+ state 有可恢复 flow → 上下文恢复)
② off_topic 门(复用 _classify_irrelevant_input:legacy 94 词 + registry 信号词 + greeting/meaningless 细分)
③ 规则匹配门(按 priority 遍历 scene_registry,命中 CHOICE 规则的)
④ LLM 门(规则未命中,走 model function call)
"""
from app.services.scenes import REGISTRY
from app.services.scenes.gate_rules import GateRule
request = state.get("request")
if request is None:
return {"gate_decision": "off_topic", "gate_scene_id": None}
message = str(state.get("message") or "").strip()
steward_state = state.get("steward_state") or {}
# ① 闲聊拦截(极轻量:greeting + meaningless,省 LLM 成本,不依赖业务关键词)
# 不用 _classify_irrelevant_input(那套带 94 词判断,会误杀"下周去上海"等正常业务输入)
if self._is_lightweight_off_topic(message, request):
return {"gate_decision": "off_topic", "gate_scene_id": None}
# ② resume 门(用户说"再提交" + state 有可恢复 flow → 确定性恢复)
resume_scene = should_resume_recent_task(message, steward_state)
if resume_scene:
return {"gate_decision": "resume", "gate_scene_id": resume_scene}
# ③ 其他全部走 LLM(不再有规则匹配门;LLM function call 是唯一的意图识别者)
return {"gate_decision": "model_intent", "gate_scene_id": None}
def _is_lightweight_off_topic(self, message: str, request: StewardPlanRequest) -> bool:
"""极轻量闲聊拦截:只拦 greeting 和 meaningless,不做业务相关性判断。
有附件时一定不是闲聊(附件意味着用户有业务诉求)。
业务相关性交给 LLM 判断,规则只挡掉绝对无关的输入省 LLM 成本。
"""
if request.attachments:
return False
compact = _compact_text(message)
if not compact:
return True
if self._looks_like_greeting(compact):
return True
if self._looks_like_meaningless(compact):
return True
return False
def _execute_scene_handler(self, state: StewardGraphState) -> dict[str, Any]:
"""HANDLER_ONLY 路由:不调 LLM,直接执行 scene 的 handler。
当前只有 query_travel_standard 走这条路径。handler 签名约定:
handler(executor_self, request, current_user, trace) -> StewardActionExecuteResponse
"""
from app.services.scenes import REGISTRY
from app.schemas.steward import (
StewardActionExecuteRequest,
StewardActionExecuteResponse,
StewardActionStep,
StewardPlanResponse,
StewardTask,
StewardThinkingEvent,
)
import time
# scene_id 优先从 gate_scene_id 取(兼容旧行为),否则从 plan.tasks[0].task_type 取(LLM 驱动)
scene_id = state.get("gate_scene_id")
if not scene_id:
plan = state.get("plan")
if isinstance(plan, StewardPlanResponse) and plan.tasks:
scene_id = str(plan.tasks[0].task_type or "").strip()
scene = REGISTRY.get(scene_id or "") if scene_id else None
if scene is None or scene.handler is None:
plan = self._build_rule_fallback_graph_plan(state)
return {"plan": plan}
request = state.get("request")
if request is None:
plan = self._build_rule_fallback_graph_plan(state)
return {"plan": plan}
# 构造 handler 期望的 StewardActionExecuteRequest
# 优先使用 LLM 已生成的 task(含 ontology_fields),否则构造最小 task
existing_plan = state.get("plan")
llm_task = None
if isinstance(existing_plan, StewardPlanResponse) and existing_plan.tasks:
llm_task = existing_plan.tasks[0]
if llm_task is not None:
task = llm_task
elif scene.action_steps_builder is not None:
task = StewardTask(
task_id=f"task_handler_{int(time.time() * 1000)}",
task_type=scene.scene_id,
assigned_agent=scene.assigned_agent or "policy_query_assistant",
title=scene.label,
summary=str(request.message or "").strip(),
status="delegated",
requested_action="preview",
ontology_fields={},
missing_fields=[],
confirmation_required=False,
)
else:
task = StewardTask(
task_id=f"task_handler_{int(time.time() * 1000)}",
task_type=scene.scene_id,
assigned_agent=scene.assigned_agent or "policy_query_assistant",
title=scene.label,
summary=str(request.message or "").strip(),
status="delegated",
requested_action="preview",
ontology_fields={},
missing_fields=[],
confirmation_required=False,
)
# 构造 action steps(优先用 scene 的 builder,否则最小 step)
action_steps: list[StewardActionStep] = []
if scene.action_steps_builder is not None:
try:
action_steps = list(scene.action_steps_builder(task) or [])
except Exception:
action_steps = []
if not action_steps:
action_steps = [StewardActionStep(
step_id=f"handler_{int(time.time() * 1000)}",
action_type=scene.side_effect_actions[0] if scene.side_effect_actions else scene.scene_id,
label=scene.label,
target_task_id=task.task_id,
status="planned",
requires_confirmation=False,
payload={"task_id": task.task_id, "ontology_fields": task.ontology_fields},
)]
step = action_steps[0]
action_request = StewardActionExecuteRequest(
action_type=step.action_type,
message=str(request.message or "").strip(),
task=task,
)
try:
response = scene.handler(self, action_request, current_user=None, trace=[])
except Exception as exc:
plan = self._build_rule_fallback_graph_plan(state)
plan.thinking_events = list(plan.thinking_events) + [
StewardThinkingEvent(
event_id=f"handler_error_{scene.scene_id}",
stage="llm_function_call",
title=f"{scene.label}执行失败",
content=f"handler 抛错: {type(exc).__name__}: {str(exc)[:80]}",
status="completed",
),
]
return {"plan": plan}
# handler 返回 StewardActionExecuteResponse,转换为 StewardPlanResponse
answer = ""
result_payload: dict[str, Any] = {}
if isinstance(response, StewardActionExecuteResponse):
answer = response.message or response.result_payload.get("answer_markdown", "")
result_payload = dict(response.result_payload or {})
# 把查询结果放进 summary(给前端展示)和 thinking_event(过程摘要)
plan = StewardPlanResponse(
plan_id=f"steward_handler_{int(time.time() * 1000)}",
planning_source=f"scene_handler:{scene.scene_id}",
summary=answer or scene.label,
next_action="answer_only",
tasks=[task] if scene.action_steps_builder else [],
thinking_events=[
StewardThinkingEvent(
event_id=f"handler_{scene.scene_id}_done",
stage="llm_function_call",
title=f"已执行{scene.label}",
content=answer or f"场景 {scene.scene_id} 已执行",
status="completed",
)
],
pending_flow_confirmation={"status": "none"},
conversation_id=state.get("conversation_id") or "",
steward_state=state.get("steward_state") or {},
action_steps=[],
)
return {"plan": plan}
def _resume_recent_task(self, state: StewardGraphState) -> dict[str, Any]:
"""RESUME 路由:从 steward_state 恢复之前被拦/中断的 task。
保险①:100% 可靠,覆盖"再提交""继续提交"等确认类话术。
"""
steward_state = state.get("steward_state") or {}
scene_id = state.get("gate_scene_id")
# 先建一个空 plan(无 task),让 attach_resumed_task 把恢复的 task 挂上
from app.schemas.steward import StewardPlanResponse
empty_plan = StewardPlanResponse(
plan_id="steward_resume_pending",
planning_source="rule_fallback",
summary="恢复上下文中的待办任务。",
next_action="confirm_task",
tasks=[],
thinking_events=[],
pending_flow_confirmation={"status": "none"},
)
if not scene_id:
return {"plan": empty_plan}
resumed = attach_resumed_task(empty_plan, steward_state, scene_id)
return {"plan": resumed}
def _persist_state(
self,
db: Session,
request: StewardPlanRequest,
plan: StewardPlanResponse,
final_state: StewardGraphState,
) -> StewardPlanResponse:
"""图执行后的副作用:写 message + steward_state 到 DB。
替代 endpoint 的 _attach_conversation_state。
"""
if db is None:
return plan
try:
context_json = dict(request.context_json or {})
context_json["session_type"] = str(context_json.get("session_type") or "steward").strip() or "steward"
conversation_id = self._extract_conversation_id(context_json)
conversation_service = AgentConversationService(db)
conversation = conversation_service.get_or_create_conversation(
conversation_id=conversation_id,
user_id=request.user_id,
source="user_message",
context_json=context_json,
)
current_state = self._resolve_steward_state_for_persist(conversation.state_json, final_state)
from app.services.steward_flow_state import StewardFlowStateService
steward_state = StewardFlowStateService().merge_plan(current_state, plan)
conversation = conversation_service.update_state(
conversation_id=conversation.conversation_id,
run_id=None,
scenario="steward",
intent="plan",
context_json={**context_json, "steward_state": steward_state},
) or conversation
conversation_service.append_message(
conversation_id=conversation.conversation_id,
role="user",
content=request.message,
message_json={"source": "steward_plan_request"},
)
conversation_service.append_message(
conversation_id=conversation.conversation_id,
role="assistant",
content=plan.summary,
message_json={
"source": "steward_plan_response",
"plan_id": plan.plan_id,
"steward_state": steward_state,
},
)
return plan.model_copy(
update={
"conversation_id": conversation.conversation_id,
"steward_state": steward_state,
}
)
except Exception:
return plan
# ---- 路由函数 ----
def _route_after_gate_classify(self, state: StewardGraphState) -> str:
"""gate_classify 后的路由:把 gate_decision 映射到下一个节点。"""
decision = str(state.get("gate_decision") or "model_intent")
return decision
@staticmethod
def _extract_conversation_id(context_json: dict[str, Any]) -> str | None:
return str(
context_json.get("conversation_id")
or context_json.get("conversationId")
or ""
).strip() or None
@staticmethod
def _resolve_base_date_from_request(request: StewardPlanRequest | None) -> date:
if request is None:
return date.today()
from app.services.steward_planner_extraction import StewardPlannerExtractionMixin
return StewardPlannerExtractionMixin._resolve_base_date(
request.client_now_iso,
dict(request.context_json or {}),
)
@staticmethod
def _resolve_steward_state_for_persist(
conversation_state: Any,
final_state: StewardGraphState,
) -> dict[str, Any]:
state_json = conversation_state if isinstance(conversation_state, dict) else {}
stored = state_json.get("steward_state")
if isinstance(stored, dict) and stored:
return stored
graph_state = final_state.get("steward_state")
if isinstance(graph_state, dict) and graph_state:
return graph_state
return {}
def build_plan(
self,
request: StewardPlanRequest,
db: Session | None = None,
) -> StewardPlanResponse:
"""编排一次 steward 计划请求,内部执行:load → classify → plan。
P1 中间状态:签名保持 build_plan(request) 不变以兼容现有测试/消费者。
显式传 db 时,load_context 节点会读历史/state;不传时图内 IO 静默跳过。
持久化由 endpoint 显式调用 _attach_conversation_state 完成(P3 收敛到图内)。
"""
final_state = self._graph.invoke(
{
"request": request,
"db": db,
"model_call_traces": [],
"fallback_reason": "",
}
@@ -54,13 +474,31 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
def _build_graph(self):
graph = StateGraph(StewardGraphState)
# 节点
graph.add_node("load_context", self._load_context)
graph.add_node("gate_classify", self._gate_classify)
graph.add_node("execute_scene_handler", self._execute_scene_handler)
graph.add_node("resume_recent_task", self._resume_recent_task)
graph.add_node("prepare_context", self._prepare_context)
graph.add_node("detect_model_intent", self._detect_model_intent)
graph.add_node("build_off_topic_plan", self._build_off_topic_graph_plan)
graph.add_node("build_rule_fallback_plan", self._build_rule_fallback_graph_plan)
graph.add_node("build_pending_flow_plan", self._build_pending_flow_fallback_graph_plan)
graph.add_node("attach_action_steps", self._attach_action_steps)
graph.add_edge(START, "prepare_context")
# 拓扑(P2:LLM 驱动,gate_classify 只输出 off_topic|resume|model_intent)
graph.add_edge(START, "load_context")
graph.add_edge("load_context", "gate_classify")
graph.add_conditional_edges(
"gate_classify",
self._route_after_gate_classify,
{
"off_topic": "build_off_topic_plan",
"resume": "resume_recent_task",
"model_intent": "prepare_context",
},
)
graph.add_edge("resume_recent_task", "attach_action_steps")
graph.add_conditional_edges(
"prepare_context",
self._route_after_prepare_context,
@@ -70,15 +508,19 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
"fallback": "build_rule_fallback_plan",
},
)
# detect_model_intent 成功后:HANDLER_ONLY 类(task_type 对应 scene.route=HANDLER_ONLY)
# 转 execute_scene_handler;其他转 attach_action_steps
graph.add_conditional_edges(
"detect_model_intent",
self._route_after_model_intent,
{
"done": "attach_action_steps",
"handler_only": "execute_scene_handler",
"off_topic": "build_off_topic_plan",
"fallback": "build_rule_fallback_plan",
},
)
graph.add_edge("execute_scene_handler", "attach_action_steps")
graph.add_edge("build_off_topic_plan", "attach_action_steps")
graph.add_edge("build_rule_fallback_plan", "attach_action_steps")
graph.add_edge("attach_action_steps", END)
@@ -174,7 +616,17 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
@staticmethod
def _route_after_model_intent(state: StewardGraphState) -> str:
if isinstance(state.get("plan"), StewardPlanResponse):
plan = state.get("plan")
if isinstance(plan, StewardPlanResponse):
# LLM 成功:检查第一个 task 是否对应 HANDLER_ONLY 场景(查询类,直接执行 handler)
if plan.tasks:
from app.services.scenes import REGISTRY
from app.services.scenes.gate_rules import SceneRoute
first_task_type = str(plan.tasks[0].task_type or "").strip()
scene = REGISTRY.get(first_task_type)
if scene is not None and scene.route == SceneRoute.HANDLER_ONLY:
return "handler_only"
return "done"
if state.get("scenario") is not None:
return "off_topic"
@@ -187,8 +639,22 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
return {
"plan": self._build_off_topic_plan(
state["request"],
scenario=str(state["scenario"] or ""),
model_call_traces=state.get("model_call_traces"),
scenario=str(state.get("scenario") or ""),
model_call_traces=state.get("model_call_traces") or [],
fallback_reason=str(state.get("fallback_reason") or ""),
)
}
def _build_pending_flow_fallback_graph_plan(
self,
state: StewardGraphState,
) -> dict[str, StewardPlanResponse]:
request = state["request"]
return {
"plan": self._build_pending_flow_fallback_plan(
request,
base_date=state["base_date"],
model_call_traces=state.get("model_call_traces") or [],
fallback_reason=str(state.get("fallback_reason") or ""),
)
}

View File

@@ -8,11 +8,30 @@ from typing import Any
from app.schemas.steward import StewardPlanRequest
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_intent_registry import (
all_flow_ids,
all_task_types,
all_intents,
)
STEWARD_INTENT_FUNCTION_NAME = "submit_steward_intent_plan"
def _build_supported_intent_summary() -> str:
"""从注册表拼接当前支持的意图列表,供 system prompt 引用。"""
fragments = [f"{desc.task_type}{desc.label}" for desc in all_intents()]
return "".join(fragments) if fragments else "(暂无已注册意图)"
def _build_intent_prompt_fragments() -> str:
"""从注册表拼接每个意图的识别指引片段。"""
fragments = [desc.prompt_fragment for desc in all_intents() if desc.prompt_fragment]
if not fragments:
return ""
return "".join(fragments)
@dataclass(frozen=True, slots=True)
class StewardIntentAgentResult:
payload: dict[str, Any]
@@ -84,8 +103,17 @@ class StewardIntentAgent:
"employee_grade",
"employee_no",
"client_timezone_offset_minutes",
"recent_history",
}
},
"recent_history": [
{
"role": str(item.get("role") or "").strip(),
"content": str(item.get("content") or "").strip(),
}
for item in (request.context_json.get("recent_history") or [])
if isinstance(item, dict) and str(item.get("content") or "").strip()
],
"attachments": [
{
"index": index + 1,
@@ -104,11 +132,9 @@ class StewardIntentAgent:
"content": (
"你是 X-Financial 的小财管家意图识别智能体。"
"你必须通过 function calling 输出结构化计划,不能只返回普通文本。"
"当前版本只支持 expense_application 和 reimbursement 两类任务;"
f"当前支持的 task_type 包括:{_build_supported_intent_summary()}"
"你只做识别、拆解、归集和确认点规划,不能执行入库、绑定附件或提交审批。"
"用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时,"
"即使没有出现“申请”两个字,也必须优先识别为 expense_application。"
"用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。"
f"{_build_intent_prompt_fragments()}"
"如果用户只描述出差时间、地点和事由,但没有明确申请、报销、提交、保存草稿等动作,"
"且无法从上下文判断流程方向,必须返回 pending_flow_confirmation.status=pending"
"candidate_flows 同时给出 travel_application 和 travel_reimbursementtasks 保持空数组。"
@@ -116,12 +142,17 @@ class StewardIntentAgent:
"如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。"
"每个 task 必须输出 requested_action用户只是要求整理/发起但未说保存或提交时为 preview"
"用户说保存草稿、先保存、存草稿时为 save_draft用户说直接提交、提交申请、确认提交时为 submit。"
"对于查询类任务(如查询差旅标准),requested_action 固定为 preview。"
"recent_history 是本会话最近 10 轮对话role 为 user 或 assistant"
"当用户说“再提交”“继续”“重新提交”“重新申请”等确认类话术时,"
"必须结合 recent_history 里最近一次提到的出差/报销申请来理解用户意图,"
"复用该申请的 ontology_fields 重新生成 task而不是把确认话术当作孤立的模糊输入。"
"如果 recent_history 为空或无法关联到具体申请,才按当前 message 字面理解。"
"相对日期必须以 base_date 为准转换为明确日期。"
"thinking_events 只能是面向用户的过程摘要,不能暴露内部推理链。"
"如果用户输入与出差、费用、报销、申请等财务事项完全无关"
"如果用户输入与出差、费用、报销、申请、差旅标准等财务事项完全无关"
"(例如纯数字、问候、闲聊、无意义字符、单字符重复),"
"必须让 tasks 返回空数组,并在 thinking_events 中明确说明“未识别到财务事项”"
"不要强行把无关输入识别为 expense_application 或 reimbursement 任务。"
"必须让 tasks 返回空数组,并在 thinking_events 中明确说明“未识别到财务事项”"
),
},
{
@@ -159,7 +190,7 @@ class StewardIntentAgent:
"properties": {
"task_type": {
"type": "string",
"enum": ["expense_application", "reimbursement"],
"enum": all_task_types(),
},
"title": {"type": "string"},
"summary": {"type": "string"},
@@ -211,7 +242,7 @@ class StewardIntentAgent:
"properties": {
"flow_id": {
"type": "string",
"enum": ["travel_application", "travel_reimbursement"],
"enum": all_flow_ids(),
},
"label": {"type": "string"},
"confidence": {

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from app.services.steward_action_contracts import StewardActionPlanBuilder
from app.services.steward_action_executor import StewardActionExecutor
from app.services.steward_constants import BUSINESS_CANONICAL_FIELDS
from app.services.steward_intent_registry import (
IntentDescriptor,
register_intent,
)
from app.services.steward_query_executors import (
build_travel_standard_query_steps,
execute_travel_standard_query,
)
def bootstrap_intents() -> None:
"""注册小财管家支持的全部业务意图。
新增意图时在这里追加一个 register_intent 调用即可,
无需改动识别 / 动作生成 / 执行分发链路。
"""
application_builder = StewardActionPlanBuilder()
register_intent(
IntentDescriptor(
task_type="expense_application",
assigned_agent="application_assistant",
label="费用申请",
action_steps_builder=application_builder.build_application_steps,
signal_keywords=(
"申请", "出差", "差旅", "费用", "交通", "住宿", "采购", "会务", "会议",
"客户现场", "项目", "拜访", "调研", "驻场", "上线", "验收",
),
ontology_field_allowlist=tuple(BUSINESS_CANONICAL_FIELDS),
noop_actions=(
"fill_application_fields",
"build_application_preview",
"validate_required_fields",
),
side_effect_actions=("save_application_draft", "submit_application", "run_duplicate_precheck"),
executor=StewardActionExecutor._dispatch_application_action,
flow_id="travel_application",
prompt_fragment=(
"用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时,"
"即使没有出现“申请”两个字,也必须优先识别为 expense_application。"
),
)
)
register_intent(
IntentDescriptor(
task_type="reimbursement",
assigned_agent="reimbursement_assistant",
label="费用报销",
action_steps_builder=application_builder.build_reimbursement_steps,
signal_keywords=("报销", "报账", "票据", "发票", "凭证", "行程单", "付款截图", "小票", "收据"),
ontology_field_allowlist=tuple(BUSINESS_CANONICAL_FIELDS),
noop_actions=(
"fill_reimbursement_fields",
"build_reimbursement_preview",
"validate_required_fields",
),
side_effect_actions=(
"create_reimbursement_draft",
"link_existing_application",
"associate_attachments",
),
executor=StewardActionExecutor._dispatch_reimbursement_action,
flow_id="travel_reimbursement",
prompt_fragment=(
"用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,"
"才识别为 reimbursement。"
),
)
)
register_intent(
IntentDescriptor(
task_type="query_travel_standard",
assigned_agent="policy_query_assistant",
label="差旅标准查询",
action_steps_builder=build_travel_standard_query_steps,
signal_keywords=(
"差旅标准", "住宿标准", "出差标准", "交通标准", "出差补助",
"差旅补贴", "住宿补助", "交通补助", "职级标准", "差标",
),
ontology_field_allowlist=(
"location",
"employee_grade",
"standard_category",
"expense_type",
),
noop_actions=(),
side_effect_actions=("execute_travel_standard_query",),
executor=execute_travel_standard_query,
flow_id=None,
prompt_fragment=(
"用户询问差旅住宿标准、交通标准、出差补助或差旅补贴标准时,"
"必须识别为 query_travel_standard,而不是 expense_application 或 reimbursement。"
"差旅标准查询不创建任何单据,只返回标准数值。"
),
)
)
def _ensure_bootstrapped() -> None:
if not all_task_types():
bootstrap_intents()
# 导入即注册,保证 registry 在首次 import 后即处于可用状态。
bootstrap_intents()

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Protocol
class ActionStepsBuilder(Protocol):
"""把单个 task 转换为确定性白名单动作步骤序列。"""
def __call__(self, task: Any) -> list[Any]: ...
class ActionExecutor(Protocol):
"""执行单个 action_type 对应的副作用或查询,返回 StewardActionExecuteResponse。"""
def __call__(self, executor: Any, request: Any, current_user: Any, trace: list[dict[str, Any]]) -> Any: ...
@dataclass(frozen=True)
class IntentDescriptor:
"""单个业务意图的声明式描述符。
注册一个 IntentDescriptor 即可让意图识别、动作生成、执行分发、字段过滤
全部自动适配,无需改动 if/else 链。
"""
task_type: str
assigned_agent: str
label: str
"""中文标签,用于 system prompt、前端展示和日志。"""
action_steps_builder: ActionStepsBuilder
"""把 StewardTask 转换为白名单动作步骤的可调用对象。"""
signal_keywords: tuple[str, ...] = ()
"""规则兜底 / off_topic 门控的关键词;命中即视为业务相关。"""
ontology_field_allowlist: tuple[str, ...] = ()
"""该意图允许的 canonical 槽位;为空表示沿用全局 BUSINESS_CANONICAL_FIELDS。"""
side_effect_actions: tuple[str, ...] = ()
"""该意图产生副作用的 action_type 集合;会被纳入执行器白名单。"""
noop_actions: tuple[str, ...] = ()
"""该意图的无副作用 action_type 集合(填充/预览/校验等)。"""
executor: ActionExecutor | None = None
"""副作用或查询动作的执行函数;None 表示全部走 NOOP。"""
flow_id: str | None = None
"""候选流程确认使用的 flow_id;查询类意图为 None。"""
prompt_fragment: str = ""
"""注入 steward_intent_agent system prompt 的识别指引片段。"""
_REGISTRY: dict[str, IntentDescriptor] = {}
_FLOW_TO_TASK: dict[str, str] = {}
def register_intent(descriptor: IntentDescriptor) -> IntentDescriptor:
"""注册一个业务意图;重复注册以最后一次为准。"""
_REGISTRY[descriptor.task_type] = descriptor
if descriptor.flow_id:
_FLOW_TO_TASK[descriptor.flow_id] = descriptor.task_type
return descriptor
def get_intent(task_type: str) -> IntentDescriptor | None:
return _REGISTRY.get(str(task_type or "").strip())
def all_intents() -> list[IntentDescriptor]:
return list(_REGISTRY.values())
def all_task_types() -> list[str]:
return [desc.task_type for desc in _REGISTRY.values()]
def all_assigned_agents() -> list[str]:
return [desc.assigned_agent for desc in _REGISTRY.values()]
def all_flow_ids() -> list[str]:
return [desc.flow_id for desc in _REGISTRY.values() if desc.flow_id]
def all_signal_keywords() -> set[str]:
keywords: set[str] = set()
for desc in _REGISTRY.values():
keywords.update(desc.signal_keywords)
return keywords
def all_side_effect_actions() -> set[str]:
actions: set[str] = set()
for desc in _REGISTRY.values():
actions.update(desc.side_effect_actions)
return actions
def all_noop_actions() -> set[str]:
actions: set[str] = set()
for desc in _REGISTRY.values():
actions.update(desc.noop_actions)
return actions
def resolve_task_type_for_flow(flow_id: str) -> str | None:
return _FLOW_TO_TASK.get(str(flow_id or "").strip())
def resolve_intent_by_action(action_type: str) -> IntentDescriptor | None:
"""根据 action_type 反查它所属的意图描述符。"""
normalized = str(action_type or "").strip()
for desc in _REGISTRY.values():
if normalized in desc.side_effect_actions or normalized in desc.noop_actions:
return desc
return None
def field_allowlist_for(task_type: str, *, fallback: frozenset[str] | None = None) -> frozenset[str]:
"""返回某意图允许的槽位集合;未声明则返回 fallback。"""
desc = get_intent(task_type)
if desc and desc.ontology_field_allowlist:
return frozenset(desc.ontology_field_allowlist)
return fallback or frozenset()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import re
import uuid
from datetime import date
@@ -18,6 +19,12 @@ from app.schemas.steward import (
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.steward_constants import BUSINESS_CANONICAL_FIELDS
from app.services.steward_intent_agent import StewardIntentAgentResult
from app.services.steward_intent_registry import (
all_flow_ids,
field_allowlist_for,
get_intent,
resolve_task_type_for_flow,
)
class StewardModelPlanBuilder:
@@ -104,6 +111,19 @@ class StewardModelPlanBuilder:
base_date: date,
) -> list[StewardTask]:
raw_tasks = payload.get("tasks")
# 兼容模型把 tasks 输出成 JSON 字符串(而非数组)的情况:
# 某些供应商在流式输出时会把整个数组序列化为字符串,需要先反序列化。
if isinstance(raw_tasks, str):
raw_tasks = raw_tasks.strip()
if not raw_tasks:
return []
try:
parsed_tasks = json.loads(raw_tasks)
except (TypeError, ValueError):
# JSON 可能被截断或语法不完整,尝试从残缺字符串里抢救 task_type 等关键字段,
# 避免模型偶发的格式抖动导致正确意图被整体丢弃。
parsed_tasks = self._salvage_tasks_from_fragment(raw_tasks)
raw_tasks = parsed_tasks if isinstance(parsed_tasks, list) else []
if not isinstance(raw_tasks, list):
return []
@@ -112,7 +132,15 @@ class StewardModelPlanBuilder:
if not isinstance(raw_task, dict):
continue
task_type = str(raw_task.get("task_type") or "").strip()
if task_type not in {"expense_application", "reimbursement"}:
intent_descriptor = get_intent(task_type)
if intent_descriptor is None:
# 兼容模型把 flow_id(如 travel_application)误填为 task_type 的情况:
# 通过 flow_id → task_type 映射还原,避免正确意图被丢弃。
mapped_task_type = resolve_task_type_for_flow(task_type)
if mapped_task_type is not None:
task_type = mapped_task_type
intent_descriptor = get_intent(task_type)
if intent_descriptor is None:
continue
task_index = len(tasks) + 1
@@ -120,6 +148,7 @@ class StewardModelPlanBuilder:
raw_task.get("ontology_fields"),
request=request,
base_date=base_date,
task_type=task_type,
)
supplement_segment = " ".join(
[
@@ -136,13 +165,9 @@ class StewardModelPlanBuilder:
for key, value in supplement_fields.items():
fields.setdefault(key, value)
assigned_agent = (
"application_assistant"
if task_type == "expense_application"
else "reimbursement_assistant"
)
task_id = f"task_{'app' if task_type == 'expense_application' else 'reim'}_{task_index:03d}"
title_prefix = "费用申请" if task_type == "expense_application" else "费用报销"
assigned_agent = intent_descriptor.assigned_agent
task_id = self._build_task_id(task_type, task_index)
title_prefix = intent_descriptor.label
title = self.planner._clean_text(raw_task.get("title")) or self.planner._build_task_title(
title_prefix,
fields,
@@ -184,6 +209,50 @@ class StewardModelPlanBuilder:
return tasks
@staticmethod
def _salvage_tasks_from_fragment(fragment: str) -> list[dict[str, Any]]:
"""从残缺的 JSON 字符串里抢救 task 结构,避免模型格式抖动导致意图被整体丢弃。
模型偶发把 tasks 输出成被截断或语法不完整的 JSON 字符串,直接 json.loads 会失败。
这里用正则提取 task_type、requested_action 和 ontology_fields 等关键字段,
构造最小可用的 task dict 列表。
"""
if not fragment:
return []
task_type_match = re.search(r'"task_type"\s*:\s*"([^"]+)"', fragment)
if not task_type_match:
return []
task_type = task_type_match.group(1).strip()
requested_action = "preview"
action_match = re.search(r'"requested_action"\s*:\s*"([^"]+)"', fragment)
if action_match:
requested_action = action_match.group(1).strip()
# 提取 ontology_fields 对象内的键值对
ontology_fields: dict[str, str] = {}
ontology_block = re.search(
r'"ontology_fields"\s*:\s*\{([^}]*)\}',
fragment,
)
if ontology_block:
for key_match in re.finditer(
r'"([^"]+)"\s*:\s*"([^"]*)"',
ontology_block.group(1),
):
ontology_fields[key_match.group(1)] = key_match.group(2)
title_match = re.search(r'"title"\s*:\s*"([^"]+)"', fragment)
summary_match = re.search(r'"summary"\s*:\s*"([^"]+)"', fragment)
return [{
"task_type": task_type,
"title": title_match.group(1) if title_match else "",
"summary": summary_match.group(1) if summary_match else "",
"confidence": 0.8,
"requested_action": requested_action,
"ontology_fields": ontology_fields,
"missing_fields": [],
}]
def _build_pending_flow_confirmation(
self,
payload: dict[str, Any],
@@ -226,13 +295,14 @@ class StewardModelPlanBuilder:
if not isinstance(raw_candidate, dict):
continue
flow_id = self.planner._clean_text(raw_candidate.get("flow_id"))
if flow_id not in {"travel_application", "travel_reimbursement"}:
if flow_id not in set(all_flow_ids()):
continue
task_type = "expense_application" if flow_id == "travel_application" else "reimbursement"
task_type = resolve_task_type_for_flow(flow_id) or "expense_application"
fields = self._sanitize_model_ontology_fields(
raw_candidate.get("ontology_fields"),
request=request,
base_date=base_date,
task_type=task_type,
)
if not fields:
fields = self.planner._extract_ontology_fields(
@@ -312,32 +382,45 @@ class StewardModelPlanBuilder:
)
return "我识别到这是一次财务事项,但还需要先确认具体流程方向。"
@staticmethod
def _build_task_id(task_type: str, index: int) -> str:
"""根据 task_type 生成稳定的任务 ID 前缀。"""
prefix_map = {
"expense_application": "app",
"reimbursement": "reim",
"query_travel_standard": "query",
}
prefix = prefix_map.get(task_type, re.sub(r"[^a-z0-9]+", "_", task_type.lower()).strip("_") or "task")
return f"task_{prefix}_{index:03d}"
def _sanitize_model_ontology_fields(
self,
raw_fields: Any,
*,
request: StewardPlanRequest,
base_date: date,
task_type: str = "",
) -> dict[str, str]:
allowlist = field_allowlist_for(task_type, fallback=BUSINESS_CANONICAL_FIELDS)
normalized_context = normalize_ontology_form_values(request.context_json.get("review_form_values"))
fields: dict[str, str] = {
key: value
for key, value in normalized_context.items()
if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip()
if key in allowlist and str(value or "").strip()
}
if not isinstance(raw_fields, dict):
return fields
normalized_model_fields = normalize_ontology_form_values(raw_fields)
for key, value in normalized_model_fields.items():
if key not in BUSINESS_CANONICAL_FIELDS:
if key not in allowlist:
continue
normalized_value = self._normalize_model_field_value(key, value, base_date)
if normalized_value:
fields[key] = normalized_value
if request.attachments and not fields.get("attachments"):
fields["attachments"] = "".join(item.name for item in request.attachments if item.name)
return {key: value for key, value in fields.items() if key in BUSINESS_CANONICAL_FIELDS and value}
return {key: value for key, value in fields.items() if key in allowlist and value}
def _build_attachment_groups_from_model_payload(
self,
@@ -435,12 +518,17 @@ class StewardModelPlanBuilder:
task_type: str,
fields: dict[str, str],
) -> list[str]:
allowlist = field_allowlist_for(task_type, fallback=BUSINESS_CANONICAL_FIELDS)
missing_fields: list[str] = []
if isinstance(raw_missing_fields, list):
for item in raw_missing_fields:
key = str(item or "").strip()
if key in BUSINESS_CANONICAL_FIELDS and key not in missing_fields and not fields.get(key):
if key in allowlist and key not in missing_fields and not fields.get(key):
missing_fields.append(key)
# 查询类意图没有强制的必填字段,跳过 planner 的必填推断
intent_descriptor = get_intent(task_type)
if intent_descriptor is not None and not intent_descriptor.flow_id:
return missing_fields
for key in self.planner._resolve_missing_fields(task_type, fields):
if key not in missing_fields:
missing_fields.append(key)

View File

@@ -25,6 +25,44 @@ from app.services.steward_planner_shared import (
)
def _matches_query_signal(compact: str) -> bool:
"""判断输入是否命中查询类意图信号词(差旅标准等)。
查询意图不应进入申请/报销候选流程确认,也不应被当作普通业务输入走规则兜底。
"""
from app.services.steward_intent_registry import all_signal_keywords
query_indicators = ("标准", "补助", "补贴", "差标", "政策", "制度", "多少")
if not any(indicator in compact for indicator in query_indicators):
return False
signal_keywords = all_signal_keywords()
return any(keyword in compact for keyword in signal_keywords)
def _resolve_assigned_agent_for_draft(task_type: str) -> str:
"""从注册表取 assigned_agent,查不到时回退到申请/报销默认值。"""
from app.services.steward_intent_registry import get_intent
intent = get_intent(task_type)
if intent is not None:
return intent.assigned_agent
if task_type == "expense_application":
return "application_assistant"
return "reimbursement_assistant"
def _resolve_task_label_for_draft(task_type: str) -> str:
"""从注册表取意图中文标签,查不到时回退到申请/报销默认值。"""
from app.services.steward_intent_registry import get_intent
intent = get_intent(task_type)
if intent is not None:
return intent.label
if task_type == "expense_application":
return "费用申请"
return "费用报销"
class StewardPlannerExtractionMixin:
def _has_multiple_financial_demands(self, message: str) -> bool:
task_drafts = self._extract_task_drafts(message)
@@ -89,6 +127,9 @@ class StewardPlannerExtractionMixin:
compact = re.sub(r"\s+", "", text)
if not compact or request.attachments:
return False
# 查询类意图(差旅标准等)不走申请/报销候选流程确认
if _matches_query_signal(compact):
return False
if re.search(r"申请|报销|草稿|提交|审批|保存|发起|创建", compact):
return False
if not re.search(r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", compact):
@@ -115,15 +156,13 @@ class StewardPlannerExtractionMixin:
base_date: date,
request: StewardPlanRequest,
) -> StewardTask:
from app.services.steward_model_plan_builder import StewardModelPlanBuilder
fields = self._extract_ontology_fields(draft.segment, draft.task_type, base_date, request)
missing_fields = self._resolve_missing_fields(draft.task_type, fields)
task_id = f"task_{'app' if draft.task_type == 'expense_application' else 'reim'}_{draft.index:03d}"
assigned_agent = (
"application_assistant"
if draft.task_type == "expense_application"
else "reimbursement_assistant"
)
title_prefix = "费用申请" if draft.task_type == "expense_application" else "费用报销"
task_id = StewardModelPlanBuilder._build_task_id(draft.task_type, draft.index)
assigned_agent = _resolve_assigned_agent_for_draft(draft.task_type)
title_prefix = _resolve_task_label_for_draft(draft.task_type)
title = self._build_task_title(title_prefix, fields, draft.index)
return StewardTask(
task_id=task_id,

View File

@@ -63,6 +63,11 @@ class StewardPlannerFallbackMixin:
return None
if any(keyword in compact for keyword in STEWARD_BUSINESS_SIGNAL_KEYWORDS):
return None
# 补充注册表里各意图声明的信号词(如查询类"差旅标准"等),避免被判 off_topic
from app.services.steward_intent_registry import all_signal_keywords
if any(keyword in compact for keyword in all_signal_keywords()):
return None
if StewardPlannerFallbackMixin._looks_like_greeting(compact):
return STEWARD_OFF_TOPIC_SCENARIO_GREETING

View File

@@ -0,0 +1,315 @@
from __future__ import annotations
from typing import Any
from app.api.deps import CurrentUserContext
from app.schemas.steward import (
StewardActionExecuteRequest,
StewardActionExecuteResponse,
StewardActionStep,
StewardTask,
)
from app.services.expense_rule_runtime_defaults import DEFAULT_TRAVEL_POLICY_CONFIG
from app.services.runtime_chat import RuntimeChatService
# 城市分级标签:差旅政策 city_tier 到面向用户的城市档位名称
CITY_TIER_LABELS = {
"tier_1": "一类城市(北上广深)",
"tier_2": "二类城市(省会及重点城市)",
"tier_3": "三类城市(其他地区)",
}
# 交通等级编码到中文标签
TRANSPORT_LEVEL_LABELS = {
1: "经济舱/二等座(普通席别)",
2: "高端经济舱/一等座(中级席别)",
3: "公务舱/商务座(高级席别)",
4: "头等舱(最高席别)",
}
def build_travel_standard_query_steps(task: StewardTask) -> list[StewardActionStep]:
"""生成差旅标准查询任务的动作步骤。
查询不产生副作用,无需校验必填、保存或提交,只生成单步执行动作。
"""
fields = _resolve_task_fields(task)
return [
StewardActionStep(
step_id=f"{task.task_id}:01",
action_type="execute_travel_standard_query",
label="查询差旅标准",
target_task_id=task.task_id,
status="planned",
requires_confirmation=False,
payload={
"task_id": task.task_id,
"ontology_fields": fields,
},
)
]
def execute_travel_standard_query(
executor: Any,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
"""执行差旅标准查询:检索业务数据 → 交给 LLM 整理成自然语言。
数据源为 DEFAULT_TRAVEL_POLICY_CONFIG(住宿标准 by 职级×城市分级、
交通等级 by 职级)。补助标准当前未纳入运行时配置,用制度说明兜底。
"""
action_type = "execute_travel_standard_query"
fields = _resolve_task_fields(request.task)
message = _resolve_message(request)
location = str(fields.get("location") or "").strip()
employee_grade = _resolve_employee_grade(fields, current_user)
standard_category = str(fields.get("standard_category") or "").strip().lower()
standards = resolve_travel_standard_snapshot(
location=location,
employee_grade=employee_grade,
standard_category=standard_category,
)
if not standards["matched_any"]:
answer = _build_no_match_answer(location, employee_grade, standard_category)
return StewardActionExecuteResponse(
action_type=action_type,
status="succeeded",
message=answer,
result_payload={
"answer_markdown": answer,
"standards": standards,
"matched": False,
},
trace=[*trace, _trace("completed", mode="query_no_match")],
)
answer = _compose_travel_standard_answer(
message=message,
standards=standards,
location=location,
employee_grade=employee_grade,
)
return StewardActionExecuteResponse(
action_type=action_type,
status="succeeded",
message=answer,
result_payload={
"answer_markdown": answer,
"standards": standards,
"matched": True,
},
trace=[*trace, _trace("completed", mode="query_travel_standard")],
)
def resolve_travel_standard_snapshot(
*,
location: str,
employee_grade: str,
standard_category: str = "",
) -> dict[str, Any]:
"""按地点、职级和关注标准类别,从差旅政策配置检索确定性标准数值。
standard_category 为空表示返回全部类别;非空时只返回指定类别。
支持的类别:lodging(住宿)、transport(交通)、allowance(补助)。
"""
config = DEFAULT_TRAVEL_POLICY_CONFIG
city_tiers = config.get("city_tiers", {})
hotel_limits = config.get("hotel_limits", {})
transport_limits = config.get("transport_limits", {})
band_labels = config.get("band_labels", {})
normalized_city = str(location or "").strip()
city_tier = city_tiers.get(normalized_city, "tier_3") if normalized_city else "tier_3"
normalized_grade = _normalize_grade(employee_grade)
snapshot: dict[str, Any] = {
"location": normalized_city or "",
"city_tier": city_tier,
"city_tier_label": CITY_TIER_LABELS.get(city_tier, city_tier),
"employee_grade": normalized_grade,
"employee_grade_label": band_labels.get(normalized_grade, normalized_grade or "未指定"),
"standard_category": standard_category or "",
"matched_any": False,
"lodging": None,
"transport": None,
"allowance": None,
}
want_all = not standard_category
if want_all or standard_category == "lodging":
lodging_cap = _resolve_lodging_cap(hotel_limits, normalized_grade, city_tier)
if lodging_cap is not None:
snapshot["lodging"] = {
"daily_cap": str(lodging_cap),
"unit": "元/晚",
}
snapshot["matched_any"] = True
if want_all or standard_category == "transport":
transport_band = _resolve_transport_band(transport_limits, normalized_grade)
if transport_band is not None:
snapshot["transport"] = {
"flight_level": transport_band.get("flight"),
"train_level": transport_band.get("train"),
"flight_label": TRANSPORT_LEVEL_LABELS.get(int(transport_band.get("flight", 0)), ""),
"train_label": TRANSPORT_LEVEL_LABELS.get(int(transport_band.get("train", 0)), ""),
}
snapshot["matched_any"] = True
if want_all or standard_category == "allowance":
# 补助标准当前未纳入运行时配置,用占位说明,等补助数据源接入后补全。
# 占位说明不计入 matched_any,避免无有效数据时仍标记为已匹配。
snapshot["allowance"] = {
"note": "出差补助标准按地区(直辖市/港澳台/境外等)分档,具体数值请参考《公司差旅费报销规则》或咨询财务。",
}
return snapshot
def _resolve_lodging_cap(
hotel_limits: dict[str, Any],
grade: str,
city_tier: str,
) -> str | None:
grade_entry = hotel_limits.get(grade)
if not isinstance(grade_entry, dict):
return None
cap = grade_entry.get(city_tier)
return str(cap).strip() if cap is not None else None
def _resolve_transport_band(
transport_limits: dict[str, Any],
grade: str,
) -> dict[str, Any] | None:
band = transport_limits.get(grade)
if not isinstance(band, dict):
return None
return {"flight": band.get("flight"), "train": band.get("train")}
def _normalize_grade(value: str) -> str:
normalized = str(value or "").strip().upper()
if normalized in {"", "未指定", "未知"}:
return ""
if normalized in DEFAULT_TRAVEL_POLICY_CONFIG.get("band_labels", {}):
return normalized
# 容忍 P05 / p5 等写法
compact = normalized.lstrip("Pp")
if compact.isdigit():
candidate = f"P{int(compact)}"
if candidate in DEFAULT_TRAVEL_POLICY_CONFIG.get("band_labels", {}):
return candidate
return normalized
def _resolve_employee_grade(
fields: dict[str, str],
current_user: CurrentUserContext,
) -> str:
grade = str(fields.get("employee_grade") or "").strip()
if grade:
return grade
return str(getattr(current_user, "grade", "") or "").strip()
def _resolve_task_fields(task: StewardTask | None) -> dict[str, str]:
if task is None or not isinstance(task.ontology_fields, dict):
return {}
return {
str(key or "").strip(): str(value or "").strip()
for key, value in task.ontology_fields.items()
if str(key or "").strip() and str(value or "").strip()
}
def _resolve_message(request: StewardActionExecuteRequest) -> str:
message = str(request.message or "").strip()
if message:
return message
if request.task is not None:
return str(request.task.summary or request.task.title or "").strip()
return "差旅标准查询"
def _build_no_match_answer(location: str, employee_grade: str, standard_category: str) -> str:
parts = ["### 未能匹配到具体差旅标准"]
details = []
if location:
details.append(f"目的地:**{location}**")
if employee_grade:
details.append(f"职级:**{employee_grade}**")
if standard_category:
details.append(f"关注类别:**{standard_category}**")
if details:
parts.append("")
parts.append("当前识别到:" + "".join(details) + "")
parts.append("")
parts.append(
"请补充更明确的信息,例如\"P5 去武汉出差的住宿标准是多少\","
"或直接说\"查武汉的住宿标准\""
)
return "\n".join(parts)
def _compose_travel_standard_answer(
*,
message: str,
standards: dict[str, Any],
location: str,
employee_grade: str,
) -> str:
"""把结构化标准整理成面向用户的 Markdown 回复。
优先用确定性数据拼装;如需更自然的表述,可在此处接入 LLM,
当前阶段确定性拼装已足够清晰,避免额外模型调用开销。
"""
lines = ["### 差旅标准查询结果"]
context_parts = []
if location:
context_parts.append(f"目的地 **{location}**({standards.get('city_tier_label', '')})")
if employee_grade:
context_parts.append(f"职级 **{standards.get('employee_grade_label', employee_grade)}**")
if context_parts:
lines.append("")
lines.append("查询条件:" + "".join(context_parts) + "")
lodging = standards.get("lodging")
transport = standards.get("transport")
allowance = standards.get("allowance")
if lodging:
lines.append("")
lines.append(f"- **住宿标准**:{lodging['daily_cap']} {lodging['unit']}")
if transport:
lines.append(
f"- **交通工具等级**:飞机 {transport.get('flight_label', '')}"
f"火车 {transport.get('train_label', '')}"
)
if allowance:
lines.append(f"- **出差补助**:{allowance.get('note', '')}")
lines.append("")
lines.append(
"> 标准依据公司差旅政策运行时配置。如需了解超标说明、多城市行程等例外口径,"
"请进一步描述您的场景。"
)
return "\n".join(lines)
def _trace(stage: str, **extra: Any) -> dict[str, Any]:
from datetime import UTC, datetime
return {
"stage": stage,
"at": datetime.now(UTC).isoformat(),
**extra,
}

View File

@@ -148,6 +148,46 @@ def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None:
assert runtime_model["capability"] == "chat"
def test_settings_service_persists_additional_model_rows(monkeypatch) -> None:
temp_dir = build_temp_secret_dir()
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
payload = service.get_settings_snapshot().model_dump()
payload["llmForm"]["models"].append(
{
"slot": "llm_expense_audit",
"provider": "MiniMax",
"url": "https://api.minimaxi.com/v1",
"apiKey": "extra-secret",
"apiKeyConfigured": False,
"modelId": "MiniMax-Text-01",
"type": "llm",
}
)
saved_snapshot = service.save_settings_snapshot(SettingsWrite(**payload))
saved_model = next(
model for model in saved_snapshot.llmForm.models if model.slot == "llm_expense_audit"
)
assert saved_model.provider == "MiniMax"
assert saved_model.url == "https://api.minimaxi.com/v1"
assert saved_model.modelId == "MiniMax-Text-01"
assert saved_model.type == "llm"
assert saved_model.apiKey == ""
assert saved_model.apiKeyConfigured is True
model_row = db.get(SystemModelSetting, "llm_expense_audit")
assert model_row is not None
assert model_row.capability == "chat"
assert model_row.priority == 50
assert service.load_saved_model_api_key("llm_expense_audit") == "extra-secret"
def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> None:
temp_dir = build_temp_secret_dir()
admin_file = temp_dir / "admin.json"

View File

@@ -0,0 +1,130 @@
from __future__ import annotations
from datetime import date
from app.schemas.steward import StewardPlanResponse, StewardTask, StewardThinkingEvent
from app.services.steward_context_resume import (
RESUME_CONFIRMATION_KEYWORDS,
attach_resumed_task,
resume_task_from_flow,
should_resume_recent_task,
)
def _state_with_travel_application(fields: dict | None = None) -> dict:
return {
"active_flow": "travel_application",
"flows": {
"travel_application": {
"flow_id": "travel_application",
"status": "ready_for_confirmation",
"fields": fields or {"location": "上海", "time_range": "2026-02-20 至 2026-02-23"},
"missing_fields": [],
}
},
}
def test_should_resume_returns_flow_id_for_confirmation_keyword_with_state():
state = _state_with_travel_application()
for keyword in ("再提交", "继续提交", "重新提交", "再申请", "重新申请", "那就提交", "继续吧", "再试一次"):
assert should_resume_recent_task(keyword, state) == "travel_application", f"keyword={keyword}"
def test_should_resume_returns_none_when_state_empty():
assert should_resume_recent_task("再提交", {}) is None
assert should_resume_recent_task("再提交", None) is None
def test_should_resume_returns_none_for_non_confirmation_message():
state = _state_with_travel_application()
assert should_resume_recent_task("今天天气不错", state) is None
assert should_resume_recent_task("你好", state) is None
assert should_resume_recent_task("查一下差旅标准", state) is None
assert should_resume_recent_task("", state) is None
def test_should_resume_returns_none_when_flow_has_no_fields():
state = {
"active_flow": "travel_application",
"flows": {"travel_application": {"fields": {}, "missing_fields": []}},
}
assert should_resume_recent_task("再提交", state) is None
def test_should_resume_finds_flow_when_active_flow_empty():
# active_flow 已清空,但 flows 里仍有可恢复的 flow
state = {
"active_flow": "",
"flows": {
"travel_application": {
"fields": {"location": "武汉"},
}
},
}
assert should_resume_recent_task("再提交", state) == "travel_application"
def test_resume_task_from_flow_restores_travel_application():
flow = {
"flow_id": "travel_application",
"fields": {"location": "上海", "time_range": "2026-02-20 至 2026-02-23"},
"missing_fields": [],
}
task = resume_task_from_flow("travel_application", flow, task_index=1)
assert task.task_type == "expense_application"
assert task.assigned_agent == "application_assistant"
assert task.ontology_fields["location"] == "上海"
assert task.requested_action == "submit"
assert task.status == "ready_to_delegate" # 无 missing_fields
def test_resume_task_from_flow_marks_needs_confirmation_when_missing_fields():
flow = {
"fields": {"location": "武汉"},
"missing_fields": ["time_range", "reason"],
}
task = resume_task_from_flow("travel_application", flow)
assert task.missing_fields == ["time_range", "reason"]
assert task.status == "needs_confirmation"
def test_attach_resumed_task_adds_task_and_thinking_event():
plan = StewardPlanResponse(
plan_id="plan_test",
planning_source="rule_fallback",
summary="占位",
tasks=[],
thinking_events=[],
pending_flow_confirmation={"status": "none"},
)
state = _state_with_travel_application({"location": "上海", "time_range": "2026-02-20 至 2026-02-23"})
updated = attach_resumed_task(plan, state, "travel_application")
assert len(updated.tasks) == 1
assert updated.tasks[0].task_type == "expense_application"
assert updated.tasks[0].ontology_fields["location"] == "上海"
assert updated.planning_source == "context_resume"
# thinking_event 应说明上下文已恢复
assert any("恢复" in event.title or "恢复" in event.content for event in updated.thinking_events)
def test_attach_resumed_task_returns_unchanged_when_flow_missing():
plan = StewardPlanResponse(
plan_id="plan_test",
planning_source="rule_fallback",
summary="占位",
tasks=[],
thinking_events=[],
pending_flow_confirmation={"status": "none"},
)
updated = attach_resumed_task(plan, {"flows": {}}, "travel_application")
assert updated is plan # 原样返回
def test_resume_keywords_cover_common_variants():
# 确认关键词覆盖场景里常见的表述
assert "再提交" in RESUME_CONFIRMATION_KEYWORDS
assert "继续提交" in RESUME_CONFIRMATION_KEYWORDS
assert "重新申请" in RESUME_CONFIRMATION_KEYWORDS
# "提交" 单独不在列表里(避免把"首次提交"误判为恢复)
assert "提交" not in RESUME_CONFIRMATION_KEYWORDS

View File

@@ -68,3 +68,88 @@ def test_steward_intent_agent_uses_ten_second_timeout_and_three_attempts() -> No
assert runtime_chat.kwargs["timeout_seconds"] == 10
assert runtime_chat.kwargs["max_attempts"] == 3
assert runtime_chat.kwargs["use_failure_cooldown"] is False
def test_steward_intent_tool_schema_includes_query_task_type_from_registry() -> None:
"""function call schema 的 task_type enum 应从注册表动态生成,包含查询意图。"""
from app.services import steward_intent_bootstrap # noqa: F401 触发意图注册
schema = StewardIntentAgent._build_intent_tool_schema(
["expense_type", "time_range", "location", "reason", "transport_mode"]
)
task_schema = schema["function"]["parameters"]["properties"]["tasks"]["items"]
task_type_enum = task_schema["properties"]["task_type"]["enum"]
assert "expense_application" in task_type_enum
assert "reimbursement" in task_type_enum
assert "query_travel_standard" in task_type_enum
def test_steward_intent_system_prompt_mentions_query_intent_guidance() -> None:
"""system prompt 应包含查询意图的识别指引,避免被误识别为申请。"""
from app.services import steward_intent_bootstrap # noqa: F401 触发意图注册
messages = StewardIntentAgent._build_messages(
StewardPlanRequest(message="武汉出差标准是多少"),
base_date=__import__("datetime").date(2026, 6, 24),
canonical_fields=["location", "employee_grade"],
)
system_prompt = messages[0]["content"]
assert "query_travel_standard" in system_prompt
assert "差旅" in system_prompt
assert "住宿标准" in system_prompt
def test_steward_intent_system_prompt_includes_conversation_history_guidance() -> None:
"""system prompt 应包含'结合对话历史理解确认类话术'的引导。"""
from app.services import steward_intent_bootstrap # noqa: F401
messages = StewardIntentAgent._build_messages(
StewardPlanRequest(message="再提交"),
base_date=__import__("datetime").date(2026, 6, 24),
canonical_fields=["location", "time_range"],
)
system_prompt = messages[0]["content"]
assert "recent_history" in system_prompt
assert "再提交" in system_prompt
assert "确认类话术" in system_prompt
def test_steward_intent_context_payload_includes_recent_history() -> None:
"""context_payload 应携带 recent_history 结构化字段role + content"""
import json
request = StewardPlanRequest(
message="再提交",
context_json={
"recent_history": [
{"role": "user", "content": "2026-02-20 至 2026-02-23去上海出差火车"},
{"role": "assistant", "content": "好的,为您整理出差申请预览。"},
{"role": "user", "content": "直接提交"},
{"role": "assistant", "content": "检测到重复申请,已暂停提交。"},
],
},
)
messages = StewardIntentAgent._build_messages(
request,
base_date=__import__("datetime").date(2026, 6, 24),
canonical_fields=["location", "time_range"],
)
user_payload = json.loads(messages[1]["content"])
assert "recent_history" in user_payload
assert len(user_payload["recent_history"]) == 4
assert user_payload["recent_history"][0]["role"] == "user"
assert "上海" in user_payload["recent_history"][0]["content"]
def test_steward_intent_context_payload_omits_empty_recent_history() -> None:
"""无 recent_history 时不应注入空列表。"""
import json
messages = StewardIntentAgent._build_messages(
StewardPlanRequest(message="你好"),
base_date=__import__("datetime").date(2026, 6, 24),
canonical_fields=["location"],
)
user_payload = json.loads(messages[1]["content"])
assert user_payload.get("recent_history", []) == []

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
from app.services import steward_intent_bootstrap # noqa: F401 触发意图注册
from app.services.steward_intent_registry import (
all_flow_ids,
all_intents,
all_signal_keywords,
all_task_types,
field_allowlist_for,
get_intent,
resolve_intent_by_action,
resolve_task_type_for_flow,
)
from app.services.steward_constants import BUSINESS_CANONICAL_FIELDS
def test_registry_registers_application_reimbursement_and_query_intents():
task_types = all_task_types()
assert "expense_application" in task_types
assert "reimbursement" in task_types
assert "query_travel_standard" in task_types
application = get_intent("expense_application")
assert application is not None
assert application.assigned_agent == "application_assistant"
assert application.flow_id == "travel_application"
reimbursement = get_intent("reimbursement")
assert reimbursement is not None
assert reimbursement.assigned_agent == "reimbursement_assistant"
assert reimbursement.flow_id == "travel_reimbursement"
query = get_intent("query_travel_standard")
assert query is not None
assert query.assigned_agent == "policy_query_assistant"
assert query.flow_id is None # 查询意图不进入候选流程确认
def test_registry_aggregates_flow_ids_and_signal_keywords():
flow_ids = set(all_flow_ids())
assert flow_ids == {"travel_application", "travel_reimbursement"}
keywords = all_signal_keywords()
assert "出差" in keywords # 来自 expense_application
assert "报销" in keywords # 来自 reimbursement
assert "差旅标准" in keywords # 来自 query_travel_standard
def test_registry_resolves_intent_by_action_type():
assert resolve_intent_by_action("save_application_draft").task_type == "expense_application"
assert resolve_intent_by_action("submit_application").task_type == "expense_application"
assert resolve_intent_by_action("create_reimbursement_draft").task_type == "reimbursement"
assert resolve_intent_by_action("associate_attachments").task_type == "reimbursement"
assert resolve_intent_by_action("execute_travel_standard_query").task_type == "query_travel_standard"
assert resolve_intent_by_action("unknown_action") is None
def test_registry_resolves_task_type_for_flow():
assert resolve_task_type_for_flow("travel_application") == "expense_application"
assert resolve_task_type_for_flow("travel_reimbursement") == "reimbursement"
assert resolve_task_type_for_flow("unknown_flow") is None
def test_field_allowlist_uses_per_intent_overrides():
# 申请/报销沿用全局 BUSINESS_CANONICAL_FIELDS
application_fields = field_allowlist_for("expense_application")
assert "location" in application_fields
assert "amount" in application_fields
assert application_fields == frozenset(BUSINESS_CANONICAL_FIELDS)
# 查询意图使用专属槽位集合
query_fields = field_allowlist_for("query_travel_standard")
assert "location" in query_fields
assert "employee_grade" in query_fields
assert "standard_category" in query_fields
assert "amount" not in query_fields # 查询不需要金额
# 未注册意图回退到 fallback
fallback_fields = field_allowlist_for("unknown", fallback=frozenset({"foo"}))
assert fallback_fields == frozenset({"foo"})
def test_query_intent_prompt_fragment_includes_identification_guidance():
query = get_intent("query_travel_standard")
assert query is not None
assert "差旅" in query.prompt_fragment
assert "住宿标准" in query.prompt_fragment

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
from types import SimpleNamespace
from app.schemas.steward import StewardActionExecuteRequest, StewardTask
from app.services.steward_query_executors import (
build_travel_standard_query_steps,
execute_travel_standard_query,
resolve_travel_standard_snapshot,
)
def _build_current_user(grade: str = "P5") -> SimpleNamespace:
return SimpleNamespace(
username="test_user",
name="测试员工",
grade=grade,
department_name="技术部",
position="工程师",
role_codes=["employee"],
is_admin=False,
employee_no="E00001",
manager_name="李总",
)
def _build_request(
*,
location: str = "武汉",
employee_grade: str = "",
standard_category: str = "",
message: str = "我去武汉出差的住宿标准是多少",
) -> StewardActionExecuteRequest:
ontology_fields: dict[str, str] = {}
if location:
ontology_fields["location"] = location
if employee_grade:
ontology_fields["employee_grade"] = employee_grade
if standard_category:
ontology_fields["standard_category"] = standard_category
task = StewardTask(
task_id="task_query_001",
task_type="query_travel_standard",
assigned_agent="policy_query_assistant",
title="差旅标准查询",
summary=message,
ontology_fields=ontology_fields,
)
return StewardActionExecuteRequest(
action_type="execute_travel_standard_query",
message=message,
task=task,
)
def test_resolve_travel_standard_snapshot_returns_lodging_and_transport_for_known_city_and_grade():
snapshot = resolve_travel_standard_snapshot(
location="武汉",
employee_grade="P5",
)
assert snapshot["location"] == "武汉"
assert snapshot["city_tier"] == "tier_2" # 武汉是二类城市
assert snapshot["employee_grade"] == "P5"
assert snapshot["lodging"] is not None
assert snapshot["lodging"]["daily_cap"] == "480.00" # P5 + tier_2
assert snapshot["transport"] is not None
assert snapshot["transport"]["flight_level"] == 1
assert snapshot["matched_any"] is True
def test_resolve_travel_standard_snapshot_filters_by_standard_category():
lodging_only = resolve_travel_standard_snapshot(
location="北京",
employee_grade="P7",
standard_category="lodging",
)
assert lodging_only["lodging"] is not None
assert lodging_only["lodging"]["daily_cap"] == "900.00" # P7 + tier_1
assert lodging_only["transport"] is None
transport_only = resolve_travel_standard_snapshot(
location="北京",
employee_grade="P7",
standard_category="transport",
)
assert transport_only["transport"] is not None
assert transport_only["transport"]["flight_level"] == 3 # P7 飞机等级
assert transport_only["lodging"] is None
def test_resolve_travel_standard_snapshot_normalizes_grade_variants():
# "p5" 小写、未标准化写法应被归一为 "P5"
snapshot = resolve_travel_standard_snapshot(
location="武汉",
employee_grade="p5",
)
assert snapshot["employee_grade"] == "P5"
assert snapshot["lodging"]["daily_cap"] == "480.00"
def test_resolve_travel_standard_snapshot_handles_unknown_grade():
snapshot = resolve_travel_standard_snapshot(
location="武汉",
employee_grade="",
)
# 无职级时无法匹配住宿标准(需要职级档位);补助为占位说明,不计入 matched_any
assert snapshot["lodging"] is None
assert snapshot["matched_any"] is False
assert snapshot["allowance"] is not None # 仍返回占位说明
def test_execute_travel_standard_query_returns_succeeded_with_answer_markdown():
request = _build_request(location="武汉", employee_grade="P5")
response = execute_travel_standard_query(
executor=None,
request=request,
current_user=_build_current_user("P5"),
trace=[],
)
assert response.status == "succeeded"
assert response.action_type == "execute_travel_standard_query"
assert "差旅标准查询结果" in response.message
assert "480.00" in response.message # 住宿标准
assert response.result_payload["matched"] is True
assert response.result_payload["standards"]["lodging"]["daily_cap"] == "480.00"
def test_execute_travel_standard_query_falls_back_to_current_user_grade_when_field_missing():
request = _build_request(location="武汉", employee_grade="")
response = execute_travel_standard_query(
executor=None,
request=request,
current_user=_build_current_user("P5"),
trace=[],
)
assert response.status == "succeeded"
# 应回退到 current_user.grade = P5
assert response.result_payload["standards"]["employee_grade"] == "P5"
def test_execute_travel_standard_query_returns_no_match_when_grade_and_city_unknown():
request = _build_request(
location="未知城市",
employee_grade="",
message="查差旅标准",
)
# ontology_fields 也没有 grade,current_user 也没有
response = execute_travel_standard_query(
executor=None,
request=request,
current_user=_build_current_user(""),
trace=[],
)
assert response.status == "succeeded"
assert response.result_payload["matched"] is False
assert "未能匹配" in response.message
def test_build_travel_standard_query_steps_generates_single_executable_step():
task = StewardTask(
task_id="task_query_001",
task_type="query_travel_standard",
assigned_agent="policy_query_assistant",
title="差旅标准查询",
summary="查武汉住宿标准",
ontology_fields={"location": "武汉", "employee_grade": "P5"},
)
steps = build_travel_standard_query_steps(task)
assert len(steps) == 1
assert steps[0].action_type == "execute_travel_standard_query"
assert steps[0].requires_confirmation is False
assert steps[0].payload["ontology_fields"]["location"] == "武汉"

View File

@@ -1274,71 +1274,71 @@
}
.workbench-ai-answer-markdown :deep(li::marker) {
color: #2563eb;
font-weight: 850;
color: #64748b;
font-weight: 600;
}
.workbench-ai-answer-markdown :deep(strong) {
color: #0f172a;
font-weight: 850;
font-weight: 600;
}
.workbench-ai-answer-markdown :deep(hr) {
margin: 26px 0;
border: 0;
border-top: 1px solid rgba(226, 232, 240, 0.9);
border-top: 1px solid #e2e8f0;
}
.workbench-ai-answer-markdown :deep(blockquote) {
margin: 18px 0 0;
padding: 14px 16px;
border-left: 3px solid rgba(37, 99, 235, 0.5);
border-radius: 12px;
background: rgba(239, 246, 255, 0.62);
color: #475569;
border-left: 3px solid #cbd5e1;
border-radius: 8px;
background: #f8fafc;
color: #334155;
}
.workbench-ai-answer-markdown :deep(.ai-html-callout) {
margin: 0;
padding: 14px 16px;
border-left: 3px solid rgba(37, 99, 235, 0.5);
border-radius: 12px;
background: rgba(239, 246, 255, 0.62);
color: #475569;
border-left: 3px solid #cbd5e1;
border-radius: 8px;
background: #f8fafc;
color: #334155;
}
.workbench-ai-answer-markdown :deep(.ai-html-focus-grid) {
display: grid;
gap: 0;
margin: 2px 0 18px;
padding-left: 22px;
border-left: 3px solid rgba(96, 165, 250, 0.66);
padding-left: 20px;
border-left: 3px solid #cbd5e1;
}
.workbench-ai-answer-markdown :deep(.ai-html-focus-card) {
padding: 11px 0 16px;
padding: 8px 0 12px;
border: 0;
border-radius: 0;
background: transparent;
}
.workbench-ai-answer-markdown :deep(.ai-html-focus-card + .ai-html-focus-card) {
border-top: 1px solid rgba(226, 232, 240, 0.92);
border-top: 1px solid #e2e8f0;
}
.workbench-ai-answer-markdown :deep(.ai-html-focus-label) {
display: block;
margin-bottom: 4px;
color: #1d4ed8;
font-size: 15px;
font-weight: 900;
color: #475569;
font-size: 14px;
font-weight: 600;
}
.workbench-ai-answer-markdown :deep(.ai-html-focus-card p) {
color: #475569;
font-size: 16px;
font-weight: 650;
line-height: 1.72;
color: #1e293b;
font-size: 15px;
font-weight: 500;
line-height: 1.6;
}
.workbench-ai-answer-markdown :deep(.ai-html-steps),
@@ -1366,9 +1366,9 @@
padding-top: 1px;
border-radius: 0;
background: transparent;
color: #1d4ed8;
font-size: 17px;
font-weight: 900;
color: #64748b;
font-size: 15px;
font-weight: 600;
line-height: 1.45;
}
@@ -1482,34 +1482,27 @@
}
.workbench-ai-answer-markdown :deep(.ai-document-card) {
--ai-document-card-head-bg: rgba(37, 99, 235, 0.075);
--ai-document-card-head-bg: rgba(241, 245, 249, 0.5);
position: relative;
display: grid;
gap: 0;
overflow: hidden;
padding: 0;
border: 0;
border-radius: 14px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background-color: #ffffff;
background-image:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.9)),
url("../../ai-document-card-bg.png");
background-position: center;
background-size: cover;
box-shadow:
inset 0 0 0 1px rgba(203, 213, 225, 0.5),
0 1px 2px rgba(15, 23, 42, 0.035),
0 14px 34px rgba(15, 23, 42, 0.05);
0 1px 2px 0 rgba(15, 23, 42, 0.05);
color: #334155;
animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
transition: box-shadow 180ms ease, transform 180ms ease;
transition: box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease;
}
.workbench-ai-answer-markdown :deep(.ai-document-card:hover) {
border-color: #cbd5e1;
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.46),
0 1px 2px rgba(15, 23, 42, 0.04),
0 18px 38px rgba(15, 23, 42, 0.07);
0 4px 6px -1px rgba(15, 23, 42, 0.08),
0 2px 4px -2px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
@@ -1532,8 +1525,9 @@
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 13px 18px 13px 20px;
padding: 12px 18px;
background: var(--ai-document-card-head-bg);
border-bottom: 1px solid #f1f5f9;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__status) {
@@ -1543,31 +1537,31 @@
padding: 0;
border-radius: 0;
background: transparent;
color: #1d4ed8;
font-size: 15px;
font-weight: 860;
color: #475569;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__head) {
background: rgba(22, 163, 74, 0.08);
background: rgba(240, 253, 250, 0.6);
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__head) {
background: rgba(217, 119, 6, 0.09);
background: rgba(254, 243, 199, 0.6);
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__head) {
background: rgba(220, 38, 38, 0.08);
background: rgba(254, 226, 226, 0.6);
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-pending .ai-document-card__status) {
color: #1d4ed8;
color: #2563eb;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__status) {
color: #15803d;
color: #0f766e;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__status) {
@@ -1588,9 +1582,9 @@
.workbench-ai-answer-markdown :deep(.ai-document-card__reason) {
display: -webkit-box;
min-width: 0;
color: #1e40af;
color: #1e293b;
font-size: 15px;
font-weight: 760;
font-weight: 600;
line-height: 1.45;
overflow: hidden;
-webkit-line-clamp: 1;
@@ -1598,19 +1592,19 @@
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__reason) {
color: #166534;
color: #1e293b;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__reason) {
color: #92400e;
color: #1e293b;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__reason) {
color: #991b1b;
color: #1e293b;
}
.workbench-ai-answer-markdown :deep(.ai-document-card--application) {
--ai-document-card-head-bg: rgba(37, 99, 235, 0.075);
--ai-document-card-head-bg: rgba(239, 246, 255, 0.5);
}
.workbench-ai-answer-markdown :deep(.ai-document-card--application .ai-document-card__head) {
@@ -1618,11 +1612,11 @@
}
.workbench-ai-answer-markdown :deep(.ai-document-card--application .ai-document-card__reason) {
color: #1e40af;
color: #1e293b;
}
.workbench-ai-answer-markdown :deep(.ai-document-card--reimbursement) {
--ai-document-card-head-bg: rgba(13, 148, 136, 0.075);
--ai-document-card-head-bg: rgba(240, 253, 250, 0.5);
}
.workbench-ai-answer-markdown :deep(.ai-document-card--reimbursement .ai-document-card__head) {
@@ -1630,15 +1624,11 @@
}
.workbench-ai-answer-markdown :deep(.ai-document-card--reimbursement .ai-document-card__reason) {
color: #0f766e;
color: #1e293b;
}
.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task) {
--ai-document-card-head-bg: rgba(245, 158, 11, 0.1);
box-shadow:
inset 0 0 0 1px rgba(245, 158, 11, 0.18),
0 1px 2px rgba(120, 53, 15, 0.04),
0 14px 34px rgba(120, 53, 15, 0.06);
--ai-document-card-head-bg: rgba(254, 243, 199, 0.5);
}
.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__head) {
@@ -1646,15 +1636,17 @@
}
.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__reason) {
color: #92400e;
color: #1e293b;
}
.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__status) {
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
background: rgba(245, 158, 11, 0.18);
min-height: 22px;
padding: 0 8px;
border-radius: 4px;
background: rgba(217, 119, 6, 0.1);
color: #b45309;
font-size: 13px;
font-weight: 600;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__summary),
@@ -1666,7 +1658,7 @@
.workbench-ai-answer-markdown :deep(.ai-document-card__summary) {
padding-bottom: 14px;
border-bottom: 1px solid rgba(203, 213, 225, 0.76);
border-bottom: 1px solid #f1f5f9;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__details) {
@@ -1690,26 +1682,26 @@
}
.workbench-ai-answer-markdown :deep(.ai-document-card__label) {
color: #8a94a6;
color: #64748b;
font-size: 13px;
font-weight: 640;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__value) {
min-width: 0;
color: #334155;
color: #1e293b;
font-size: 14px;
font-weight: 720;
font-weight: 500;
line-height: 1.45;
overflow-wrap: anywhere;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__amount) {
color: #0f172a;
font-size: 18px;
font-weight: 900;
font-size: 17px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
@@ -1717,33 +1709,30 @@
.workbench-ai-answer-markdown :deep(.ai-document-card__number) {
color: #64748b;
font-size: 13px;
font-weight: 740;
font-weight: 500;
letter-spacing: 0;
}
.workbench-ai-answer-markdown :deep(.ai-attachment-association-card) {
background-image:
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(248, 250, 252, 0.94)),
url("../../ai-document-card-bg.png");
background-image: none;
background-color: #ffffff;
}
.workbench-ai-answer-markdown :deep(.ai-attachment-association-card .ai-document-card__head) {
background: linear-gradient(90deg, rgba(219, 234, 254, 0.92), rgba(240, 253, 250, 0.82));
background: rgba(241, 245, 249, 0.5);
}
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card) {
box-shadow:
inset 0 0 0 1px rgba(147, 197, 253, 0.42),
0 1px 2px rgba(15, 23, 42, 0.03),
0 12px 28px rgba(37, 99, 235, 0.045);
border-color: #cbd5e1;
box-shadow: 0 1px 2px 0 rgba(15, 23, 42, 0.05);
}
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__head) {
background: linear-gradient(90deg, rgba(219, 234, 254, 0.92), rgba(239, 246, 255, 0.74));
background: rgba(239, 246, 255, 0.5);
}
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__reason) {
color: #1d4ed8;
color: #1e293b;
}
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__status) {
@@ -1781,16 +1770,16 @@
padding: 0;
border-radius: 0;
background: transparent;
color: #1d4ed8;
color: #2563eb;
font-size: 14px;
font-weight: 820;
font-weight: 600;
box-shadow: none;
white-space: nowrap;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__action:hover) {
background: transparent;
color: #1e40af;
color: #1d4ed8;
text-decoration: underline;
}
@@ -1798,10 +1787,10 @@
.workbench-ai-answer-markdown :deep(.ai-html-table-wrap) {
overflow-x: auto;
margin-top: 18px;
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.workbench-ai-answer-markdown :deep(table) {
@@ -1813,7 +1802,7 @@
.workbench-ai-answer-markdown :deep(th),
.workbench-ai-answer-markdown :deep(td) {
padding: 11px 14px;
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
border-bottom: 1px solid #f1f5f9;
text-align: left;
}
@@ -1922,6 +1911,149 @@
}
}
[data-theme-mode="enterprise"] .workbench-ai-mode {
--ai-ink: #111827;
--ai-text: #334155;
--ai-muted: #64748b;
--ai-line: #d8dee8;
--ai-blue: #475569;
--ai-blue-deep: #334155;
--ai-purple: #64748b;
--ai-cyan: #64748b;
background:
linear-gradient(180deg, #f8fafc 0%, #ffffff 56%, #f8fafc 100%),
var(--bg);
}
[data-theme-mode="enterprise"] .workbench-ai-mode::after {
background:
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
linear-gradient(180deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px);
background-size: 64px 64px;
opacity: 0.36;
filter: none;
}
[data-theme-mode="enterprise"] .workbench-ai-mode.has-conversation {
background: #f8fafc;
}
[data-theme-mode="enterprise"] .workbench-ai-mode.has-conversation::after {
opacity: 0.22;
}
[data-theme-mode="enterprise"] .workbench-ai-orb {
width: clamp(96px, 7vw, 112px);
height: clamp(96px, 7vw, 112px);
border: 0;
border-radius: 50%;
background: transparent;
color: #475569;
box-shadow: none;
}
[data-theme-mode="enterprise"] .workbench-ai-composer,
[data-theme-mode="enterprise"] .workbench-ai-composer--inline {
border-color: #d8dee8;
border-radius: 8px;
background: #ffffff;
box-shadow:
0 10px 26px rgba(15, 23, 42, 0.06),
0 1px 2px rgba(15, 23, 42, 0.04);
}
[data-theme-mode="enterprise"] .workbench-ai-icon-btn:hover,
[data-theme-mode="enterprise"] .workbench-ai-icon-btn.active {
color: #334155;
background: #f1f5f9;
}
[data-theme-mode="enterprise"] .workbench-ai-send-btn {
background: #334155;
box-shadow: none;
}
[data-theme-mode="enterprise"] .workbench-ai-send-btn:hover:not(:disabled) {
background: #1f2937;
box-shadow: none;
}
[data-theme-mode="enterprise"] .workbench-ai-message {
animation-duration: 220ms;
}
[data-theme-mode="enterprise"] .workbench-ai-user-bubble {
border-radius: 8px 8px 3px;
background: #334155;
box-shadow: none;
}
[data-theme-mode="enterprise"] .workbench-ai-answer-card {
border-color: #d8dee8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
[data-theme-mode="enterprise"] .workbench-ai-thinking-panel {
border-color: #d8dee8;
border-radius: 8px;
background: #f8fafc;
}
[data-theme-mode="enterprise"] .workbench-ai-thinking-toggle {
border-radius: 8px;
color: #334155;
}
[data-theme-mode="enterprise"] .workbench-ai-thinking-toggle:hover {
background: #ffffff;
}
[data-theme-mode="enterprise"] .workbench-ai-thinking-toggle strong,
[data-theme-mode="enterprise"] .workbench-ai-thinking-item strong {
color: #334155;
}
[data-theme-mode="enterprise"] .workbench-ai-thinking-dot {
background: #64748b;
box-shadow: 0 0 0 4px rgba(100, 116, 139, 0.12);
}
[data-theme-mode="enterprise"] .workbench-ai-suggested-actions button {
border-color: #d8dee8;
border-radius: 6px;
background: #ffffff;
color: #334155;
}
[data-theme-mode="enterprise"] .workbench-ai-suggested-actions button:hover:not(:disabled) {
background: #f8fafc;
}
[data-theme-mode="intelligent"] .workbench-ai-mode {
--ai-blue: #5f6f9f;
--ai-blue-deep: #465275;
--ai-purple: #6d6a9f;
--ai-cyan: #477c9e;
background:
radial-gradient(circle at 16% 0%, rgba(95, 111, 159, 0.1), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.94)),
var(--bg);
}
[data-theme-mode="intelligent"] .workbench-ai-mode.has-conversation {
background:
radial-gradient(circle at 12% 0%, rgba(95, 111, 159, 0.1), transparent 34%),
linear-gradient(180deg, #ffffff, #f8fafc);
}
[data-theme-mode="intelligent"] .workbench-ai-composer,
[data-theme-mode="intelligent"] .workbench-ai-composer--inline,
[data-theme-mode="intelligent"] .workbench-ai-answer-card {
border-radius: 12px;
}
@media (prefers-reduced-motion: reduce) {
.workbench-ai-answer-markdown :deep(.ai-document-card) {
animation: none;

View File

@@ -252,6 +252,331 @@
opacity: 0.6;
}
.model-config-surface {
min-width: 0;
}
.model-table-card > .model-table-wrap {
margin-top: 0;
padding: 0;
overflow: auto;
}
.add-model-button,
.secondary-button,
.icon-action-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #334155;
font-size: 12.5px;
font-weight: 700;
transition: all 0.2s ease;
cursor: pointer;
}
.add-model-button {
min-height: 36px;
gap: 6px;
padding: 0 14px;
border-color: var(--theme-primary);
background: var(--theme-primary);
color: #ffffff;
box-shadow: 0 4px 12px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
}
.add-model-button:hover,
.secondary-button:hover,
.icon-action-button:hover:not(:disabled) {
transform: translateY(-1px);
}
.secondary-button {
min-height: 36px;
padding: 0 16px;
}
.icon-action-button {
min-width: 52px;
width: auto;
height: 32px;
padding: 0 10px;
white-space: nowrap;
}
.icon-action-button:hover:not(:disabled) {
border-color: var(--theme-primary);
color: var(--theme-primary-active);
background: var(--theme-primary-soft);
}
.icon-action-button.danger:hover:not(:disabled) {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.icon-action-button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.model-config-table {
width: 100%;
min-width: 960px;
border-collapse: collapse;
table-layout: fixed;
background: #ffffff;
}
.model-config-table th,
.model-config-table td {
padding: 13px 16px;
border-bottom: 1px solid #e2e8f0;
color: #334155;
font-size: 12.5px;
line-height: 1.45;
text-align: left;
vertical-align: middle;
}
.model-config-table th {
background: #f8fafc;
color: #475569;
font-weight: 800;
}
.model-config-table tbody tr:hover {
background: #f8fafc;
}
.model-config-table th:nth-child(1),
.model-config-table td:nth-child(1) {
width: 132px;
}
.model-config-table th:nth-child(2),
.model-config-table td:nth-child(2) {
width: 120px;
}
.model-config-table th:nth-child(3),
.model-config-table td:nth-child(3) {
width: 180px;
}
.model-config-table th:nth-child(5),
.model-config-table td:nth-child(5) {
width: 100px;
}
.model-config-table th:nth-child(6),
.model-config-table td:nth-child(6) {
width: 180px;
}
.model-action-col,
.model-config-table td:last-child {
width: 184px;
}
.model-icon-text {
color: var(--theme-primary-active);
font-size: 13px;
font-weight: 900;
line-height: 1;
}
.model-type-pill,
.secret-state,
.test-feedback-inline {
display: inline-flex;
align-items: center;
max-width: 100%;
gap: 6px;
min-height: 26px;
padding: 0 9px;
border-radius: 4px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.model-type-pill {
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.secret-state {
background: #f1f5f9;
color: #64748b;
}
.secret-state.configured {
background: #ecfdf5;
color: #047857;
}
.test-feedback-inline {
overflow: hidden;
text-overflow: ellipsis;
}
.test-feedback-inline.is-idle {
background: #f1f5f9;
color: #64748b;
}
.test-feedback-inline.is-testing {
background: #eff6ff;
color: #2563eb;
}
.test-feedback-inline.is-success {
background: #ecfdf5;
color: #047857;
}
.test-feedback-inline.is-error {
background: #fef2f2;
color: #dc2626;
}
.model-provider-name,
.model-id-text,
.model-url-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-provider-name {
color: #0f172a;
font-weight: 700;
}
.model-id-text {
padding: 2px 0;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
background: transparent;
}
.model-url-text {
color: #64748b;
}
.model-row-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.model-dialog-overlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.36);
}
.model-dialog {
width: min(720px, 100%);
max-height: min(720px, calc(100vh - 48px));
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #ffffff;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
}
.model-dialog-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 22px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.model-dialog-head h4 {
margin: 0;
color: #0f172a;
font-size: 17px;
font-weight: 800;
}
.model-dialog-head p {
margin: 4px 0 0;
color: #64748b;
font-size: 12.5px;
}
.model-dialog-form {
min-height: 0;
overflow: auto;
padding: 22px;
}
.model-type-segment {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.model-type-segment button {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #475569;
font-size: 12.5px;
font-weight: 800;
cursor: pointer;
transition: all 0.2s ease;
}
.model-type-segment button.active {
border-color: var(--theme-primary);
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.model-type-segment button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.model-dialog-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 16px 22px;
border-top: 1px solid #e2e8f0;
background: #ffffff;
}
.save-button.compact {
min-height: 36px;
padding: 0 16px;
}
.profile-grid {
grid-template-columns: 96px repeat(2, minmax(0, 1fr));
}
@@ -387,18 +712,18 @@
color: var(--theme-primary-active);
}
.skin-option-grid {
.theme-option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
}
.skin-option {
min-height: 104px;
.theme-option {
min-height: 148px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 16px;
padding: 16px;
border: 1px solid #d8dee8;
border-radius: 4px;
@@ -411,18 +736,19 @@
box-shadow 160ms var(--ease);
}
.skin-option:hover,
.skin-option.active {
.theme-option:hover,
.theme-option.active {
border-color: var(--primary);
background: var(--theme-primary-light-9);
background: #ffffff;
box-shadow: 0 0 0 3px var(--theme-focus-ring);
}
.skin-swatch {
width: 64px;
height: 38px;
.theme-style-preview {
grid-column: 1 / -1;
width: 100%;
height: 42px;
display: grid;
grid-template-columns: 1.3fr 1fr 1fr 1fr;
grid-template-columns: 1.4fr 1fr 1fr 0.8fr;
grid-template-rows: 1fr;
border: 1px solid #d8dee8;
border-radius: 4px;
@@ -430,28 +756,48 @@
background: #ffffff;
}
.skin-swatch i + i {
.theme-style-preview i + i {
border-left: 1px solid rgba(255, 255, 255, 0.72);
}
.skin-copy {
.theme-copy {
display: grid;
gap: 4px;
gap: 8px;
}
.skin-copy strong {
.theme-copy strong {
color: #111827;
font-size: 14px;
font-size: 15px;
font-weight: 700;
}
.skin-copy small {
.theme-copy small {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.skin-current {
.theme-keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.theme-keywords em {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 11px;
font-style: normal;
font-weight: 700;
}
.theme-current {
min-height: 24px;
display: inline-flex;
align-items: center;
@@ -464,7 +810,7 @@
font-weight: 700;
}
.skin-preview-panel {
.theme-preview-panel {
display: flex;
align-items: center;
justify-content: space-between;
@@ -472,33 +818,56 @@
padding: 16px;
border: 1px solid #d8dee8;
border-radius: 4px;
background: linear-gradient(180deg, #ffffff 0%, var(--theme-primary-light-9) 100%);
background: #ffffff;
}
.skin-preview-panel div {
.theme-preview-panel div {
display: grid;
gap: 4px;
}
.skin-preview-panel strong {
.theme-preview-panel strong {
color: #111827;
font-size: 14px;
}
.skin-preview-panel span {
.theme-preview-panel span {
color: #64748b;
font-size: 12px;
}
.skin-preview-action {
min-height: 34px;
padding: 0 14px;
border: 1px solid var(--primary);
.theme-preview-surface {
width: min(220px, 36%);
min-width: 160px;
display: grid;
grid-template-columns: 1fr 44px;
gap: 8px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.theme-preview-surface span,
.theme-preview-surface i,
.theme-preview-surface b {
display: block;
min-height: 10px;
border-radius: 3px;
}
.theme-preview-surface span {
grid-column: 1 / -1;
background: var(--theme-primary-soft);
}
.theme-preview-surface i {
background: #ffffff;
border: 1px solid #e2e8f0;
}
.theme-preview-surface b {
background: var(--theme-gradient-primary);
color: #fff;
font-size: 13px;
font-weight: 700;
}
.secret-bound-state {

View File

@@ -616,8 +616,8 @@
border-left: 0;
}
.application-detail-fact span,
.application-detail-fact strong {
.application-detail-fact > span,
.application-detail-fact > strong {
display: flex;
align-items: center;
min-width: 0;
@@ -625,7 +625,7 @@
line-height: 1.5;
}
.application-detail-fact span {
.application-detail-fact > span {
background: #f8fafc;
color: #64748b;
font-size: 12px;
@@ -637,10 +637,11 @@
color: #0f172a;
font-size: 13px;
font-weight: 750;
gap: 8px;
overflow-wrap: anywhere;
}
.application-detail-fact.highlight span {
.application-detail-fact.highlight > span {
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
@@ -654,6 +655,77 @@
font-weight: 850;
}
.application-detail-fact-value {
min-width: 0;
flex: 1 1 auto;
overflow-wrap: anywhere;
}
.application-detail-edit-btn,
.application-detail-edit-confirm,
.application-detail-edit-cancel {
flex: 0 0 auto;
width: 24px;
height: 24px;
display: inline-grid;
place-items: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.application-detail-edit-btn {
opacity: 0;
transition:
opacity 0.16s ease,
background 0.16s ease,
color 0.16s ease;
}
.application-detail-fact.editable:hover .application-detail-edit-btn,
.application-detail-edit-btn:focus-visible {
opacity: 1;
}
.application-detail-edit-btn:hover:not(:disabled),
.application-detail-edit-btn:focus-visible,
.application-detail-edit-confirm:hover:not(:disabled),
.application-detail-edit-cancel:hover:not(:disabled) {
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active);
}
.application-detail-edit-confirm {
background: rgba(22, 163, 74, 0.1);
color: #15803d;
}
.application-detail-edit-cancel {
background: #f1f5f9;
}
.application-detail-edit-btn:disabled,
.application-detail-edit-confirm:disabled,
.application-detail-edit-cancel:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.application-detail-fact.editing strong {
align-items: center;
}
.application-detail-editor-control {
flex: 1 1 auto;
min-width: 0;
}
.application-detail-editor-select {
width: 100%;
}
.related-application-facts {
margin-top: 0;
}

View File

@@ -22,3 +22,9 @@ const {
</script>
<style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style>
<style scoped>
/* Force styling cache invalidation and hot update */
.force-reload-css-cache-v1 {
display: block;
}
</style>

View File

@@ -56,7 +56,8 @@ export function useAppShell() {
sessionType: '',
budgetContext: null,
initialPromptAutoSubmit: true,
initialApplicationPreview: null
initialApplicationPreview: null,
initialDraftPayload: null
})
const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0)
@@ -337,7 +338,8 @@ export function useAppShell() {
sessionType: '',
budgetContext: null,
initialPromptAutoSubmit: true,
initialApplicationPreview: null
initialApplicationPreview: null,
initialDraftPayload: null
}
smartEntrySessionId.value += 1
}
@@ -504,7 +506,8 @@ export function useAppShell() {
? payload.budgetContext
: null,
initialPromptAutoSubmit: false,
initialApplicationPreview: null
initialApplicationPreview: null,
initialDraftPayload: null
}
smartEntrySessionId.value += 1
return
@@ -531,6 +534,9 @@ export function useAppShell() {
initialPromptAutoSubmit: payload.initialPromptAutoSubmit !== false,
initialApplicationPreview: payload.applicationPreview && typeof payload.applicationPreview === 'object'
? payload.applicationPreview
: null,
initialDraftPayload: payload.draftPayload && typeof payload.draftPayload === 'object'
? payload.draftPayload
: null
}
smartEntrySessionId.value += 1

View File

@@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSystemState } from './useSystemState.js'
import { useThemeSkin } from './useThemeSkin.js'
import { normalizeThemeMode, useThemeSkin } from './useThemeSkin.js'
import { clearSystemCaches, fetchSettings, saveSettings } from '../services/settings.js'
import { useToast } from './useToast.js'
import {
@@ -22,6 +22,7 @@ import {
maskConfiguredModelSecrets,
maskConfiguredRenderSecret,
mergeState,
normalizeLlmModelRows,
normalizeValue,
persistSettings,
readStoredSettings
@@ -61,6 +62,8 @@ export function useSettings() {
const cacheClearMessage = ref('')
const cacheClearFailed = ref(false)
pageState.value.appearanceForm.themeSkin = setThemeSkin(pageState.value.appearanceForm.themeSkin)
const sections = SECTION_DEFINITIONS
const logLevels = LOG_LEVELS
const providerOptions = PROVIDER_OPTIONS
@@ -108,6 +111,13 @@ export function useSettings() {
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
const modelApiKeysBySlot = new Map(
normalizeLlmModelRows(currentState.llmForm.models).map((row) => [row.slot, row.apiKey])
)
nextState.llmForm.models = normalizeLlmModelRows(nextState.llmForm.models).map((row) => ({
...row,
apiKey: modelApiKeysBySlot.get(row.slot) ?? row.apiKey
}))
}
if (preserveAdminPasswords) {
@@ -123,13 +133,16 @@ export function useSettings() {
nextState.mailForm.password = currentState.mailForm.password
}
const normalizedThemeMode = normalizeThemeMode(nextState.appearanceForm?.themeSkin)
nextState.appearanceForm = {
...nextState.appearanceForm,
themeSkin: normalizedThemeMode
}
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value)
if (nextState.appearanceForm?.themeSkin) {
setThemeSkin(nextState.appearanceForm.themeSkin)
}
setThemeSkin(normalizedThemeMode)
}
async function loadSettingsSnapshot() {
@@ -358,12 +371,12 @@ export function useSettings() {
}
function selectThemeSkin(skinId) {
setThemeSkin(skinId)
pageState.value.appearanceForm.themeSkin = skinId
pageState.value.appearanceForm.themeSkin = setThemeSkin(skinId)
}
async function saveAppearanceSection() {
await persistRemoteSettings('界面皮肤已保存并应用到企业配置。', {
pageState.value.appearanceForm.themeSkin = normalizeThemeMode(pageState.value.appearanceForm.themeSkin)
await persistRemoteSettings('主题设置已保存并应用到企业配置。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
@@ -373,16 +386,16 @@ export function useSettings() {
async function saveLlmSection() {
const llmForm = pageState.value.llmForm
const modelConfigs = [
['主模型', llmForm.mainProvider, llmForm.mainModel, llmForm.mainEndpoint],
['备份模型', llmForm.backupProvider, llmForm.backupModel, llmForm.backupEndpoint],
['Embedding 模型', llmForm.embeddingProvider, llmForm.embeddingModel, llmForm.embeddingEndpoint],
['Reranker 模型', llmForm.rerankerProvider, llmForm.rerankerModel, llmForm.rerankerEndpoint]
]
const modelRows = normalizeLlmModelRows(llmForm.models)
for (const [label, provider, model, endpoint] of modelConfigs) {
if (!isModelConfigReady(provider, model, endpoint)) {
toast(`请完整填写${label}的供应商、模型名称和接口地址。`)
if (modelRows.length === 0) {
toast('请至少添加一个模型配置。')
return
}
for (const row of modelRows) {
if (!isModelConfigReady(row.provider, row.modelId, row.url)) {
toast('请完整填写每个模型的供应商、model_id 和接口地址。')
return
}
}

View File

@@ -1,7 +1,7 @@
import { computed, ref } from 'vue'
const THEME_SKIN_STORAGE_KEY = 'x-financial-theme-skin'
const DEFAULT_THEME_SKIN_ID = 'sky'
const DEFAULT_THEME_SKIN_ID = 'enterprise'
const DEFAULT_SEMANTIC_COLORS = {
success: '#2f855a',
@@ -28,112 +28,45 @@ const DEFAULT_SEMANTIC_COLORS = {
export const THEME_SKIN_OPTIONS = [
{
id: 'sky',
label: '浅蓝企业',
desc: '默认皮肤,降低蓝色饱和度,适合财务 SaaS 和审批后台。',
primary: '#3a7ca5',
primaryHover: '#2f6d95',
primaryActive: '#255b7d',
primarySoft: '#eaf4fa',
primarySoftStrong: '#d4e8f3',
secondary: '#4f6f9f',
chartBlue: '#4f6f9f',
chartPurple: '#6e7fa6',
chartAmber: '#b58b4c'
id: 'vivid',
label: '动感活泼',
desc: '保留当前 AI 助手的明快节奏,适合演示、培训和轻量工作台。',
keywords: ['明快', '渐变', '助手感'],
primary: '#2f7cff',
primaryHover: '#2563eb',
primaryActive: '#1d4ed8',
primarySoft: '#eef6ff',
primarySoftStrong: '#dbeafe',
secondary: '#7c5cff',
chartBlue: '#2f7cff',
chartPurple: '#7c5cff',
chartAmber: '#f59e0b'
},
{
id: 'blue',
label: '湖蓝灰',
desc: '偏灰的湖蓝色,弱化科技感,更适合高密度运营页面。',
primary: '#477c9e',
primaryHover: '#3a6a89',
primaryActive: '#305873',
primarySoft: '#edf5f8',
primarySoftStrong: '#d8e8ef',
secondary: '#5d7288',
chartBlue: '#477c9e',
chartPurple: '#77799c',
chartAmber: '#b28a54'
},
{
id: 'navy',
label: '稳健蓝',
desc: '偏金融和管理驾驶舱的稳重蓝,适合长时间办公查看。',
primary: '#4b6f95',
primaryHover: '#405f80',
primaryActive: '#354e69',
primarySoft: '#eef3f8',
primarySoftStrong: '#dbe6f0',
secondary: '#6b7280',
chartBlue: '#4b6f95',
chartPurple: '#69769d',
chartAmber: '#aa8a55'
},
{
id: 'teal',
label: '雾青',
desc: '保留绿色倾向但降低鲜艳度,比旧绿色更克制。',
primary: '#3f827c',
primaryHover: '#36706b',
primaryActive: '#2d5c58',
primarySoft: '#eef8f6',
primarySoftStrong: '#d8ebe8',
secondary: '#4f6f9f',
chartBlue: '#4f7f9f',
chartPurple: '#708099',
chartAmber: '#b18a53'
},
{
id: 'legacy-green',
label: '经典绿',
desc: '保留旧版系统绿色,适合继续沿用原有品牌记忆。',
primary: '#10b981',
primaryHover: '#059669',
primaryActive: '#047857',
primarySoft: '#ecfdf5',
primarySoftStrong: '#d1fae5',
secondary: '#2563eb',
chartBlue: '#2563eb',
chartPurple: '#6d6a9f',
chartAmber: '#b88a44'
},
{
id: 'sage',
label: '鼠尾草绿',
desc: '低饱和灰绿色,比经典绿更安静,适合企业内控场景。',
primary: '#5f8d72',
primaryHover: '#517b62',
primaryActive: '#436653',
primarySoft: '#f0f7f2',
primarySoftStrong: '#dcebe0',
secondary: '#4f6f9f',
chartBlue: '#4f748f',
chartPurple: '#7a7898',
chartAmber: '#a98753'
},
{
id: 'slate',
label: '石板灰蓝',
desc: '弱主色方案,适合审计、规则和报表密集页面。',
primary: '#64748b',
primaryHover: '#526174',
primaryActive: '#3f4a5a',
id: 'enterprise',
label: '企业沉稳',
desc: '低饱和、轻描边、少渲染,适合正式生产环境和企业级财务 SaaS。',
keywords: ['克制', '结构化', '低噪声'],
primary: '#475569',
primaryHover: '#3f4a5a',
primaryActive: '#334155',
primarySoft: '#f1f5f9',
primarySoftStrong: '#e2e8f0',
secondary: '#3a7ca5',
secondary: '#64748b',
chartBlue: '#5d7590',
chartPurple: '#77748f',
chartAmber: '#a88955'
chartPurple: '#6b7280',
chartAmber: '#9a7a45'
},
{
id: 'soft-violet',
label: '灰紫蓝',
desc: '保留一点智能系统气质,但用灰度压低 AI 感和饱和度。',
primary: '#6d6a9f',
primaryHover: '#5f5b8c',
primaryActive: '#504c78',
primarySoft: '#f2f1f8',
primarySoftStrong: '#e2e0ef',
id: 'intelligent',
label: '专业智能',
desc: '保留少量智能识别感,同时控制饱和度,适合稳定办公和 AI 辅助并重的团队。',
keywords: ['智能', '专业', '轻点缀'],
primary: '#5f6f9f',
primaryHover: '#53618b',
primaryActive: '#465275',
primarySoft: '#f3f4fb',
primarySoftStrong: '#e2e5f4',
secondary: '#477c9e',
chartBlue: '#4f7495',
chartPurple: '#6d6a9f',
@@ -142,9 +75,36 @@ export const THEME_SKIN_OPTIONS = [
]
const activeThemeSkinId = ref(DEFAULT_THEME_SKIN_ID)
const THEME_MODE_IDS = new Set(THEME_SKIN_OPTIONS.map((skin) => skin.id))
const LEGACY_THEME_MODE_MAP = {
sky: 'vivid',
blue: 'vivid',
emerald: 'vivid',
teal: 'vivid',
'legacy-green': 'vivid',
navy: 'enterprise',
slate: 'enterprise',
sage: 'enterprise',
gray: 'enterprise',
grey: 'enterprise',
purple: 'intelligent',
violet: 'intelligent',
'soft-violet': 'intelligent'
}
export function normalizeThemeMode(value) {
const normalized = String(value ?? '').trim()
if (THEME_MODE_IDS.has(normalized)) {
return normalized
}
return LEGACY_THEME_MODE_MAP[normalized] || DEFAULT_THEME_SKIN_ID
}
function findThemeSkin(id) {
return THEME_SKIN_OPTIONS.find((skin) => skin.id === id) || THEME_SKIN_OPTIONS[0]
const themeMode = normalizeThemeMode(id)
return THEME_SKIN_OPTIONS.find((skin) => skin.id === themeMode) || THEME_SKIN_OPTIONS[0]
}
function hexToRgb(hex) {
@@ -185,6 +145,7 @@ function applyThemeSkin(skin) {
const infoRgb = hexToRgb(DEFAULT_SEMANTIC_COLORS.info)
root.dataset.themeSkin = skin.id
root.dataset.themeMode = skin.id
setVariables(root, {
'--primary': skin.primary,
@@ -270,6 +231,8 @@ export function setThemeSkin(id) {
if (typeof window !== 'undefined') {
window.localStorage.setItem(THEME_SKIN_STORAGE_KEY, skin.id)
}
return skin.id
}
export function useThemeSkin() {

View File

@@ -258,7 +258,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
sending,
toast
toast,
onApplicationActionCompleted: startModelPlannedNextTask
})
const expenseFlow = useWorkbenchAiExpenseFlow({
@@ -710,6 +711,46 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return pendingMessage
}
function buildModelPlannedNextTaskAction(remainingTasks = []) {
const tasks = Array.isArray(remainingTasks) ? remainingTasks : []
const nextTask = tasks[0]
if (!nextTask || typeof nextTask !== 'object') {
return null
}
const taskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const assignedAgent = String(nextTask.assigned_agent || nextTask.assignedAgent || '').trim()
const isApplication = taskType === 'expense_application' || assignedAgent === 'application_assistant'
const isReimbursement = taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
if (!isApplication && !isReimbursement) {
return null
}
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销'
return {
label: `继续处理${taskLabel}`,
action_type: 'steward_continue_next_task',
payload: {
steward_confirm_flow: true,
flow_id: flowId,
steward_current_task: nextTask,
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields,
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
steward_remaining_tasks: tasks.slice(1)
}
}
}
function startModelPlannedNextTask(remainingTasks = []) {
const nextTaskAction = buildModelPlannedNextTaskAction(remainingTasks)
if (!nextTaskAction) {
return
}
actionRouter.handleInlineSuggestedAction(nextTaskAction)
}
function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) {
void applicationFlow.startAiApplicationPreview(
travelApplicationRequest.expenseType,
@@ -723,7 +764,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit,
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
onApplicationActionCompleted: startModelPlannedNextTask
}
)
}
@@ -741,7 +784,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit,
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks
}
}
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, {

View File

@@ -4,6 +4,8 @@ import {
} from '../../services/aiApplicationPreviewActions.js'
import { executeStewardAction } from '../../services/steward.js'
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
import { buildAiExpenseDraftPrefillValues } from '../../utils/aiExpenseDraftModel.js'
import { requiresApplicationBeforeReimbursement } from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
@@ -82,6 +84,9 @@ export function useWorkbenchAiActionRouter({
}
if (actionType === 'ai_application_confirm_intent') {
aiExpenseDraft.value = null
const stewardRemainingTasks = Array.isArray(actionPayload.stewardRemainingTasks)
? actionPayload.stewardRemainingTasks
: (Array.isArray(actionPayload.steward_remaining_tasks) ? actionPayload.steward_remaining_tasks : [])
void applicationFlow.startAiApplicationPreview('travel', '差旅费', String(actionPayload.sourceText || '').trim(), {
userMessage: String(actionPayload.sourceText || '').trim() || '确认发起出差申请',
pushUserMessage: true,
@@ -89,7 +94,14 @@ export function useWorkbenchAiActionRouter({
autoSubmit: Boolean(actionPayload.autoSubmit),
autoSaveDraft: Boolean(actionPayload.autoSaveDraft),
requestedSubmit: Boolean(actionPayload.requestedSubmit),
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation)
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation),
stewardRemainingTasks,
onApplicationActionCompleted: (remainingTasks = []) => {
const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks })
if (nextTaskAction) {
handleInlineSuggestedAction(nextTaskAction)
}
}
})
return
}
@@ -104,9 +116,21 @@ export function useWorkbenchAiActionRouter({
return
}
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, true)
const currentTask = actionPayload.steward_current_task || {}
const ontologyFields = currentTask.ontology_fields || currentTask.ontologyFields || actionPayload.ontology_fields || {}
const expenseType = String(actionPayload.expense_type || ontologyFields.expense_type || 'travel').trim() || 'travel'
const expenseTypeLabel = String(actionPayload.expense_type_label || ontologyFields.expense_type_label || '差旅费').trim() || '差旅费'
// 从 task ontology 解析报销语义(金额/时间/事由/地点),预填到报销草稿,
// 让 task2(如业务招待费 2000 元)的信息直接落到草稿,而不是丢失。
const prefillValues = buildAiExpenseDraftPrefillValues(ontologyFields)
const needsApplicationLink = requiresApplicationBeforeReimbursement(expenseType)
const stewardRemainingTasks = Array.isArray(actionPayload.steward_remaining_tasks)
? actionPayload.steward_remaining_tasks
: []
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, needsApplicationLink, {
prefillValues,
stewardRemainingTasks
})
return
}
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
@@ -114,6 +138,17 @@ export function useWorkbenchAiActionRouter({
void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
return
}
// steward plan 的"确认创建申请单"按钮:payload 有 steward_current_task + session_type=application,
// 直接拉起申请预览(带 remaining tasks),不走 startInlineConversation(会丢失 steward 上下文)
if (
actionPayload.steward_current_task
&& String(actionPayload.session_type || '').trim() === 'application'
&& String(actionPayload.steward_current_task.task_type || '').trim() === 'expense_application'
) {
aiExpenseDraft.value = null
void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
return
}
if (actionType === 'select_expense_type') {
const expenseType = String(action?.payload?.expense_type || '').trim()
const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
@@ -165,7 +200,18 @@ export function useWorkbenchAiActionRouter({
if (actionType === 'ai_application_start_inline') {
aiExpenseDraft.value = null
void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label)
// 多 task 推进:从 resolveAiExpenseApplicationLink "查不到申请单"分支过来的按钮,
// payload 里带 prefill_values 和 steward_remaining_tasks,这里透传给申请预览,
// 保证发起的申请单带着报销语义,且申请单做完后能继续 task2 报销流程。
void expenseFlow.startAiApplicationPreviewFromAction({
...(action?.payload || {}),
expense_type: actionPayload.expense_type,
expense_type_label: actionPayload.expense_type_label,
carry_text: actionPayload.carry_text || actionPayload.original_message || action?.label,
steward_remaining_tasks: Array.isArray(actionPayload.steward_remaining_tasks)
? actionPayload.steward_remaining_tasks
: []
}, action?.label)
return
}
@@ -223,8 +269,10 @@ export function useWorkbenchAiActionRouter({
const result = await executeStewardAction(
buildStewardActionExecutePayload(action, actionType, contextJson)
)
const resultActions = buildStewardActionResultActions(result)
const nextTaskAction = buildNextTaskSuggestedAction(actionPayload)
finalizeStewardActionMessage(pendingMessage, buildStewardActionResultText(result), {
suggestedActions: buildStewardActionResultActions(result)
suggestedActions: nextTaskAction ? [...resultActions, nextTaskAction] : resultActions
})
return true
} catch (error) {
@@ -369,6 +417,43 @@ export function useWorkbenchAiActionRouter({
}]
}
function buildNextTaskSuggestedAction(actionPayload = {}) {
// 多 task 串行推进:task1 完成后,从剩余 task 列表取下一个,生成推进按钮。
// 用户点击推进按钮后,handleInlineSuggestedAction 的 steward_confirm_flow 分支
// 会自动拉起下一个 task 的申请预览/报销流程,实现"先做完 A 再做 B"。
const remainingTasks = Array.isArray(actionPayload.steward_remaining_tasks)
? actionPayload.steward_remaining_tasks
: []
const nextTask = remainingTasks[0]
if (!nextTask || !nextTask.task_type) {
return null
}
const taskType = String(nextTask.task_type || '').trim()
const isApplication = taskType === 'expense_application'
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销'
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
// 透传去掉当前 nextTask 之后的剩余 task 列表,保证 task2 完成后 task3 也能继续推进,
// 避免 3+ task 场景在 task2 处断链。
const furtherRemainingTasks = remainingTasks.slice(1)
return {
label: `继续处理${taskLabel}`,
description: `接下来处理${taskLabel}: ${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`,
icon: isApplication ? 'mdi mdi-file-plus-outline' : 'mdi mdi-receipt-text-plus-outline',
action_type: 'steward_continue_next_task',
payload: {
steward_confirm_flow: true,
flow_id: flowId,
steward_current_task: nextTask,
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields,
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
steward_remaining_tasks: furtherRemainingTasks
}
}
}
return {
handleInlineSuggestedAction
}

View File

@@ -85,7 +85,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
sending,
toast
toast,
onApplicationActionCompleted = null
}) {
function isApplicationPreviewEstimatePending(message = {}) {
return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
@@ -330,6 +331,42 @@ export function useWorkbenchAiApplicationPreviewFlow({
}
}
function buildApplicationPreviewNextTaskAction(targetMessage) {
// 多 task 串行推进:申请草稿保存/提交成功后,检查是否有剩余 task(如报销),
// 有则生成"继续处理下一个任务"按钮,让用户一键推进。
const remainingTasks = Array.isArray(targetMessage?.stewardRemainingTasks)
? targetMessage.stewardRemainingTasks
: []
const nextTask = remainingTasks[0]
if (!nextTask || !nextTask.task_type) {
return null
}
const taskType = String(nextTask.task_type || '').trim()
const isApplication = taskType === 'expense_application'
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销'
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
// 透传去掉当前 nextTask 之后的剩余 task 列表,保证 task2 完成后 task3 也能继续推进,
// 避免 3+ task 场景在 task2 处断链。
const furtherRemainingTasks = remainingTasks.slice(1)
return {
label: `继续处理${taskLabel}`,
description: `接下来处理${taskLabel}${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`,
icon: isApplication ? 'mdi mdi-file-plus-outline' : 'mdi mdi-receipt-text-plus-outline',
action_type: 'steward_continue_next_task',
payload: {
steward_confirm_flow: true,
flow_id: flowId,
steward_current_task: nextTask,
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields,
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
steward_remaining_tasks: furtherRemainingTasks
}
}
}
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestInlineApplicationPreviewMessage()
if (!targetMessage?.applicationPreview) {
@@ -424,6 +461,17 @@ export function useWorkbenchAiApplicationPreviewFlow({
targetMessage.draftPayload = draftPayload
}
targetMessage.suggestedActions = []
const detailActions = buildInlineApplicationDetailAction(draftPayload)
const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage)
const actionCompletedHandler = typeof options.onApplicationActionCompleted === 'function'
? options.onApplicationActionCompleted
: onApplicationActionCompleted
const shouldAutoContinueNextTask = Boolean(
nextTaskAction &&
typeof actionCompletedHandler === 'function' &&
Array.isArray(targetMessage.stewardRemainingTasks) &&
targetMessage.stewardRemainingTasks.length
)
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
@@ -432,11 +480,16 @@ export function useWorkbenchAiApplicationPreviewFlow({
streamStatus: 'completed',
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
},
suggestedActions: buildInlineApplicationDetailAction(draftPayload)
suggestedActions: shouldAutoContinueNextTask
? detailActions
: (nextTaskAction ? [...detailActions, nextTaskAction] : detailActions)
})
)
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
if (shouldAutoContinueNextTask) {
actionCompletedHandler(targetMessage.stewardRemainingTasks, targetMessage)
}
return true
} catch (error) {
replaceInlineMessage(
@@ -552,9 +605,10 @@ export function useWorkbenchAiApplicationPreviewFlow({
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
requestedSubmit: Boolean(options.requestedSubmit),
submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation),
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
thinkingEvents: completeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
},
text: content
})
@@ -562,9 +616,13 @@ export function useWorkbenchAiApplicationPreviewFlow({
if (options.autoSaveDraft) {
await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, previewMessage, {
skipUserMessage: true,
userText: options.userMessage || '保存草稿'
userText: options.userMessage || '保存草稿',
onApplicationActionCompleted: options.onApplicationActionCompleted
})
}
// 多 task 串行推进:预览生成后不提前拉起下一个 task(避免和用户在 task1 核对表上的
// 保存草稿/提交操作互相打架,导致 task2 状态错乱)。task2 的推进统一交给
// onApplicationActionCompleted,在 task1 真正完成(保存草稿/提交成功)后再触发。
} catch (error) {
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
id: pendingMessage.id,

View File

@@ -1,6 +1,8 @@
import {
buildWorkbenchDocumentCommandFollowupGuidance,
buildWorkbenchDraftDeletionGuidance,
isWorkbenchDraftDeletionIntent,
resolveLatestWorkbenchDocumentCommandContext,
resolveLatestWorkbenchDraftPayload
} from './workbenchAiCommandIntentModel.js'
import { resolveWorkbenchIntentActionRoute } from './workbenchIntentActionPolicy.js'
@@ -58,6 +60,9 @@ export function useWorkbenchAiCommandIntents({
if (!handlesWorkbenchCommand) {
return false
}
const documentCommandContext = route.nextStep === 'query_candidates'
? resolveLatestWorkbenchDocumentCommandContext(conversationMessages.value, frame)
: null
prepareInlineCommandConversation(cleanPrompt, entry)
const draftPayload = frame?.targetMode === 'current_context' || legacyDraftDelete
? resolveLatestWorkbenchDraftPayload(conversationMessages.value)
@@ -72,6 +77,16 @@ export function useWorkbenchAiCommandIntents({
return true
}
if (route.nextStep === 'query_candidates' && documentCommandContext) {
const guidance = buildWorkbenchDocumentCommandFollowupGuidance(documentCommandContext, frame)
conversationMessages.value.push(createInlineMessage('assistant', guidance.content, {
suggestedActions: guidance.suggestedActions
}))
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
return true
}
const queryPrompt = route.queryPrompt || frame?.normalizedQuery || '我的草稿单据'
const pendingText = frame?.safetyLevel === 'confirm_required'
? '正在先筛选候选单据,不会直接执行删除或审核动作...'

View File

@@ -9,6 +9,7 @@ import {
} from '../../services/linkedReimbursementDraftJobs.js'
import {
applyAiExpenseAnswer,
buildAiExpenseDraftPrefillValues,
buildAiExpenseStepPrompt,
buildAiExpenseSummary,
createAiExpenseDraft,
@@ -113,6 +114,7 @@ export function useWorkbenchAiExpenseFlow({
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
draftPayload: options.draftPayload || null,
linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null,
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
text: options.text || content
})
replaceInlineMessage(messageId, nextMessage)
@@ -143,7 +145,12 @@ export function useWorkbenchAiExpenseFlow({
return startAiApplicationPreview(
expenseType,
expenseTypeLabel,
payload.carry_text || resolveLatestInlineUserPrompt()
payload.carry_text || resolveLatestInlineUserPrompt(),
{
stewardRemainingTasks: Array.isArray(payload.steward_remaining_tasks)
? payload.steward_remaining_tasks
: []
}
)
}
@@ -318,7 +325,40 @@ export function useWorkbenchAiExpenseFlow({
scrollInlineConversationToBottom()
}
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
// 多 task 推进时,把当前报销流程后续要处理的剩余 task 挂在 draft 上,
// 这样关联申请单、发起申请单、生成报销草稿等子流程都能把 remaining tasks 透传下去,
// 保证 task2 完成后能继续 task3。draft 被清空时上下文也随之消失。
function attachStewardRemainingTasks(draft, stewardRemainingTasks) {
if (!draft) {
return draft
}
const tasks = Array.isArray(stewardRemainingTasks) ? stewardRemainingTasks : []
draft.stewardRemainingTasks = tasks
return draft
}
function resolveStewardRemainingTasks(draft) {
const draftTasks = Array.isArray(draft?.stewardRemainingTasks) ? draft.stewardRemainingTasks : []
return draftTasks.length ? draftTasks : null
}
// 把 expenseType 解析成"发起 XX 申请"按钮里的 XX,避免对招待费也显示"出差申请"。
function resolveRequiredApplicationLabel(expenseType, fallbackLabel = '') {
const normalized = String(expenseType || '').trim().toLowerCase()
if (normalized === 'meal' || normalized === 'entertainment') {
return '业务招待'
}
if (normalized === 'travel') {
return '出差'
}
const label = String(fallbackLabel || '').trim()
if (label) {
return label.replace(/费$/, '')
}
return '费用'
}
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options = {}) {
if (!conversationStarted.value) {
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
}
@@ -328,12 +368,25 @@ export function useWorkbenchAiExpenseFlow({
clearAiModeFiles()
pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
const prefillValues = options.prefillValues && typeof options.prefillValues === 'object'
? options.prefillValues
: null
const stewardRemainingTasks = Array.isArray(options.stewardRemainingTasks)
? options.stewardRemainingTasks
: []
if (requiresApplicationBeforeReimbursement) {
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel)
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel, {
prefillValues,
stewardRemainingTasks
})
return
}
const draft = createAiExpenseDraft(expenseType, expenseTypeLabel)
const draft = attachStewardRemainingTasks(
createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues),
stewardRemainingTasks
)
aiExpenseDraft.value = draft
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
persistCurrentConversation()
@@ -346,7 +399,11 @@ export function useWorkbenchAiExpenseFlow({
assistantDraft.value = ''
clearAiModeFiles()
const next = applyAiExpenseAnswer(aiExpenseDraft.value, answer, fileNames)
const currentDraft = aiExpenseDraft.value
const next = applyAiExpenseAnswer(currentDraft, answer, fileNames)
// applyAiExpenseAnswer 不会保留 draft 上的运行时上下文,这里手动透传 remaining tasks,
// 保证报销草稿收集完所有字段后,仍能拿到后续 task 列表用于推进 task3。
attachStewardRemainingTasks(next, resolveStewardRemainingTasks(currentDraft))
aiExpenseDraft.value = next
if (isAiExpenseDraftComplete(next)) {
@@ -359,7 +416,14 @@ export function useWorkbenchAiExpenseFlow({
scrollInlineConversationToBottom()
}
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel, options = {}) {
const prefillValues = options.prefillValues && typeof options.prefillValues === 'object'
? options.prefillValues
: null
const stewardRemainingTasks = Array.isArray(options.stewardRemainingTasks)
? options.stewardRemainingTasks
: []
let claims = null
try {
claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
@@ -372,18 +436,30 @@ export function useWorkbenchAiExpenseFlow({
}
const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {})
aiExpenseDraft.value = createAiExpenseDraft(expenseType, expenseTypeLabel)
// 即使后续可能被清空,也先把报销语义 + remaining tasks 上下文挂到 draft 上,
// 这样查不到申请单时仍能透传给"发起申请单"按钮,保证 task2 不丢失语义。
const draft = attachStewardRemainingTasks(
createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues),
stewardRemainingTasks
)
aiExpenseDraft.value = draft
if (!candidates.length) {
// 查不到可关联申请单:不要让 task2 语义丢失。生成"发起申请单"按钮时,
// 按费用类型动态生成 label,带上 ontology 上下文 + remaining tasks,
// 让用户发起申请单后能回到 task2 报销流程(见 ai_application_start_inline 分支)。
const applicationLabel = resolveRequiredApplicationLabel(expenseType, expenseTypeLabel)
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
suggestedActions: [{
label: '确认发起出差申请',
label: `确认发起${applicationLabel}申请`,
description: '生成完整申请表,并预填已识别的时间、地点和事由',
icon: 'mdi mdi-file-plus-outline',
action_type: 'ai_application_start_inline',
payload: {
expense_type: expenseType,
expense_type_label: expenseTypeLabel
expense_type_label: expenseTypeLabel,
prefill_values: prefillValues || {},
steward_remaining_tasks: stewardRemainingTasks
}
}]
}))
@@ -454,7 +530,8 @@ export function useWorkbenchAiExpenseFlow({
jobId,
pendingMessageId,
claimNo = '',
initialJob = null
initialJob = null,
stewardRemainingTasks = []
}) {
const normalizedJobId = String(jobId || '').trim()
if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) {
@@ -474,13 +551,17 @@ export function useWorkbenchAiExpenseFlow({
const content = draftClaimNo
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
// 多 task 推进:报销草稿生成成功后,若还有剩余 task,补一个"继续处理"按钮。
const nextTaskAction = buildExpenseDraftNextTaskAction(stewardRemainingTasks)
replaceInlineAssistantMessage(pendingMessageId, content, {
draftPayload,
linkedReimbursementDraftJob: {
...currentJob,
applicationClaimNo: claimNo
},
suggestedActions: buildLinkedDraftAction(draftPayload)
suggestedActions: nextTaskAction
? [...buildLinkedDraftAction(draftPayload), nextTaskAction]
: buildLinkedDraftAction(draftPayload)
})
aiExpenseDraft.value = null
persistCurrentConversation()
@@ -519,7 +600,9 @@ export function useWorkbenchAiExpenseFlow({
jobId: job.jobId,
pendingMessageId: message.id,
claimNo: job.applicationClaimNo,
initialJob: job
initialJob: job,
// 刷新恢复时从消息上读回 remaining tasks,保证报销完成后仍能补出"继续处理"按钮。
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : []
}).catch((error) => {
replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), {
linkedReimbursementDraftJob: {
@@ -551,6 +634,38 @@ export function useWorkbenchAiExpenseFlow({
}]
}
// 报销草稿生成成功后,若有剩余 task,生成"继续处理下一个任务"按钮。
// 与 useWorkbenchAiApplicationPreviewFlow.buildApplicationPreviewNextTaskAction 同构,
// 但数据源是 draft 上透传过来的 stewardRemainingTasks,保证报销完成后 task3 也能推进。
function buildExpenseDraftNextTaskAction(remainingTasks = []) {
const tasks = Array.isArray(remainingTasks) ? remainingTasks : []
const nextTask = tasks[0]
if (!nextTask || !nextTask.task_type) {
return null
}
const taskType = String(nextTask.task_type || '').trim()
const isApplication = taskType === 'expense_application'
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销'
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
return {
label: `继续处理${taskLabel}`,
description: `接下来处理${taskLabel}${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`,
icon: isApplication ? 'mdi mdi-file-plus-outline' : 'mdi mdi-receipt-text-plus-outline',
action_type: 'steward_continue_next_task',
payload: {
steward_confirm_flow: true,
flow_id: flowId,
steward_current_task: nextTask,
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields,
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
steward_remaining_tasks: tasks.slice(1)
}
}
}
async function linkAiExpenseApplication(application = {}) {
const draft = aiExpenseDraft.value || (() => {
const resolved = resolveRequiredApplicationReimbursementType(application)
@@ -572,9 +687,14 @@ export function useWorkbenchAiExpenseFlow({
stepKey: 'attachments'
}
aiExpenseDraft.value = linked
// 关联申请单时,保留 draft 上的 remaining tasks 上下文,透传给后续轮询,
// 这样报销草稿生成成功后能补出"继续处理 task3"按钮。
const stewardRemainingTasks = resolveStewardRemainingTasks(linked) || []
const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, {
pending: true,
suggestedActions: []
suggestedActions: [],
// 把 remaining tasks 挂到 pending 消息上,刷新后 resume 轮询能读回并透传给 poll 成功分支。
stewardRemainingTasks
})
conversationMessages.value.push(pendingMessage)
const pendingMessageId = pendingMessage.id
@@ -602,7 +722,8 @@ export function useWorkbenchAiExpenseFlow({
jobId: normalizedJob.jobId,
pendingMessageId,
claimNo,
initialJob: normalizedJob
initialJob: normalizedJob,
stewardRemainingTasks
})
} catch (error) {
replaceInlineAssistantMessage(

View File

@@ -1,3 +1,7 @@
import {
parseAiDocumentDetailHref
} from '../../utils/aiDocumentDetailReference.js'
const DRAFT_DELETION_ACTION_PATTERN = /删除|删掉|删了|移除|作废|撤销/
const DRAFT_DELETION_TARGET_PATTERN = (
/草稿|这个单据|这张单据|当前单据|当前申请|当前报销|刚才保存的草稿|刚才的草稿|上面的单据|最近的单据|申请单|报销单/
@@ -22,11 +26,26 @@ const SUBMITTED_OR_FINAL_STATUS = new Set([
'已驳回',
'已退回'
])
const DOCUMENT_DETAIL_LINK_RE = /<a\b[^>]*href="([^"]*#ai-open-document-detail:[^"]+)"[^>]*>(.*?)<\/a>/g
const DOCUMENT_COMMAND_ACTION_LABELS = {
delete: '删除',
approve: '审核通过',
reject: '驳回/退回'
}
const DOCUMENT_COMMAND_DETAIL_LABELS = {
delete: '进入详情确认删除',
approve: '进入详情确认审核',
reject: '进入详情确认驳回'
}
function normalizeCompactText(value = '') {
return String(value || '').replace(/\s+/g, '').trim()
}
function normalizeText(value = '') {
return String(value || '').replace(/\s+/g, ' ').trim()
}
function normalizeDraftDocumentType(payload = {}, claimNo = '') {
const rawType = String(payload.document_type || payload.documentType || payload.draft_type || payload.draftType || '').trim()
if (/application|expense_application|申请/.test(rawType)) {
@@ -77,6 +96,60 @@ function extractDraftPayloadFromSuggestedActions(message = {}) {
return null
}
function stripHtml(value = '') {
return normalizeText(String(value || '').replace(/<[^>]*>/g, ''))
}
function normalizeDocumentCommandCandidate(detailReference = null, rawLabel = '') {
if (!detailReference || typeof detailReference !== 'object') {
return null
}
const claimId = String(detailReference.claimId || detailReference.claim_id || '').trim()
const claimNo = String(detailReference.claimNo || detailReference.claim_no || detailReference.reference || '').trim()
if (!claimId && !claimNo) {
return null
}
return {
claimId,
claimNo,
documentType: normalizeDraftDocumentType(detailReference, claimNo),
actionLabel: stripHtml(rawLabel) || '查看详情'
}
}
function extractDocumentCommandCandidatesFromContent(content = '') {
const text = String(content || '')
const candidates = []
const seen = new Set()
for (const match of text.matchAll(DOCUMENT_DETAIL_LINK_RE)) {
const candidate = normalizeDocumentCommandCandidate(
parseAiDocumentDetailHref(match[1]),
match[2]
)
if (!candidate) {
continue
}
const key = `${candidate.claimId || ''}:${candidate.claimNo || ''}`
if (seen.has(key)) {
continue
}
seen.add(key)
candidates.push(candidate)
}
return candidates
}
function canReuseDocumentCommandContext(content = '', commandFrame = {}) {
const action = String(commandFrame?.action || '').trim()
if (!['approve', 'reject'].includes(action)) {
return false
}
if (String(commandFrame?.safetyLevel || '').trim() !== 'confirm_required') {
return false
}
return /ai-document-card--approval-task|待我审核|待审|待审批|待审核|确认审核|进入详情确认审核/.test(String(content || ''))
}
export function isWorkbenchDraftDeletionIntent(prompt = '') {
const compact = normalizeCompactText(prompt)
if (!compact || !DRAFT_DELETION_ACTION_PATTERN.test(compact)) {
@@ -104,6 +177,29 @@ export function resolveLatestWorkbenchDraftPayload(messages = []) {
return null
}
export function resolveLatestWorkbenchDocumentCommandContext(messages = [], commandFrame = {}) {
const safeMessages = Array.isArray(messages) ? messages : []
for (const message of [...safeMessages].reverse()) {
if (String(message?.role || '').trim() !== 'assistant') {
continue
}
const content = String(message?.content || message?.text || '')
if (!canReuseDocumentCommandContext(content, commandFrame)) {
continue
}
const candidates = extractDocumentCommandCandidatesFromContent(content)
if (!candidates.length) {
continue
}
return {
sourceMessageId: String(message?.id || '').trim(),
action: String(commandFrame?.action || '').trim(),
candidates
}
}
return null
}
export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) {
const claimNo = String(draftPayload.claimNo || draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claimId || draftPayload.claim_id || '').trim()
@@ -128,3 +224,42 @@ export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) {
}]
}
}
export function buildWorkbenchDocumentCommandFollowupGuidance(context = {}, commandFrame = {}) {
const action = String(commandFrame?.action || context?.action || '').trim()
const actionLabel = DOCUMENT_COMMAND_ACTION_LABELS[action] || '处理'
const detailLabel = DOCUMENT_COMMAND_DETAIL_LABELS[action] || '进入详情确认'
const candidates = Array.isArray(context?.candidates) ? context.candidates : []
const visibleCandidates = candidates.slice(0, 8)
const candidateLines = visibleCandidates.map((candidate, index) => {
const reference = candidate.claimNo || candidate.claimId || `候选 ${index + 1}`
return `${index + 1}. ${reference}`
})
const overflowText = candidates.length > visibleCandidates.length
? `\n\n还有 ${candidates.length - visibleCandidates.length} 张候选未展示,请先补充更具体条件。`
: ''
return {
content: [
'### 已接上刚才查询到的待审单据',
`您想继续执行 **${actionLabel}**。这属于高风险审批动作,我不会直接替您通过或驳回。`,
'请先从刚才的候选单据中选择一张,进入详情页核对风险、金额和审批节点后再确认。',
candidateLines.length ? candidateLines.join('\n') : '',
overflowText
].filter(Boolean).join('\n\n'),
suggestedActions: visibleCandidates.map((candidate) => {
const reference = candidate.claimNo || candidate.claimId || '单据'
return {
label: `${detailLabel} ${reference}`,
description: '打开详情页核对后,再完成审批确认。',
icon: 'mdi mdi-open-in-new',
action_type: 'open_application_detail',
payload: {
claim_id: candidate.claimId,
claim_no: candidate.claimNo,
document_type: candidate.documentType,
command_action: action
}
}
})
}
}

View File

@@ -143,13 +143,37 @@ function normalizeServerApplicationSteps(rawSteps = []) {
return [...new Set(mappedSteps)]
}
function resolveModelTasks(rawPlan = {}) {
return Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : []
}
function isModelTravelApplicationTask(task = {}) {
if (!task || typeof task !== 'object') {
return false
}
const taskType = String(task?.task_type || task?.taskType || '').trim()
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
return taskType === 'expense_application' || assignedAgent === 'application_assistant'
}
function findModelTravelApplicationTask(rawPlan = {}) {
const tasks = Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : []
return tasks.find((task) => {
return resolveModelTasks(rawPlan).find(isModelTravelApplicationTask) || null
}
function resolveModelRemainingTasks(rawPlan = {}, selectedTask = null) {
const tasks = resolveModelTasks(rawPlan)
const selectedIndex = tasks.findIndex((task) => task === selectedTask)
if (selectedIndex < 0) {
return []
}
return tasks.slice(selectedIndex + 1).filter((task) => {
if (!task || typeof task !== 'object') {
return false
}
const taskType = String(task?.task_type || task?.taskType || '').trim()
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
return taskType === 'expense_application' || assignedAgent === 'application_assistant'
}) || null
return Boolean(taskType || assignedAgent)
})
}
function resolveCandidateFlows(rawPlan = {}) {
@@ -211,7 +235,7 @@ export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) {
task.requested_action ||
task.requestedAction ||
rawPlan.requested_action ||
rawPlan.requestedAction ||
rawPlan.requestedAction ||
''
).trim() || normalizePromptAction(prompt)
const serverSteps = normalizeServerApplicationSteps(task.action_steps || task.actionSteps)
@@ -226,7 +250,8 @@ export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) {
missingFields: Array.isArray(task.missing_fields || task.missingFields)
? task.missing_fields || task.missingFields
: [],
steps: serverSteps.length ? serverSteps : buildApplicationSteps(requestedAction)
steps: serverSteps.length ? serverSteps : buildApplicationSteps(requestedAction),
stewardRemainingTasks: resolveModelRemainingTasks(rawPlan, task)
}
}
@@ -275,7 +300,7 @@ export function resolveExecutableTravelApplicationPlan(plan = null) {
return null
}
const requestedSubmit = plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION)
return {
const request = {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: String(plan.sourceText || '').trim(),
@@ -285,6 +310,11 @@ export function resolveExecutableTravelApplicationPlan(plan = null) {
requestedSubmit,
submitRequiresConfirmation: requestedSubmit
}
const stewardRemainingTasks = Array.isArray(plan.stewardRemainingTasks) ? plan.stewardRemainingTasks : []
if (stewardRemainingTasks.length) {
request.stewardRemainingTasks = stewardRemainingTasks
}
return request
}
export function isLowConfidenceTravelApplicationPlan(plan = null) {

View File

@@ -156,6 +156,9 @@ export function createWorkbenchAiMessageRuntime() {
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null),
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null),
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
// 多 task 推进上下文:申请预览/报销草稿消息上挂载剩余 task 列表,
// 刷新或消息重建后仍能继续推进,避免 task 链断裂。
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
text: options.text || normalizedContent,
createdAt: options.createdAt || Date.now()
}
@@ -175,6 +178,7 @@ export function createWorkbenchAiMessageRuntime() {
attachmentAssociationJob: message.attachmentAssociationJob || null,
linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null,
attachmentOcrDetails: message.attachmentOcrDetails || null,
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : [],
text: message.text || message.content || ''
})
}
@@ -194,7 +198,8 @@ export function createWorkbenchAiMessageRuntime() {
draftPayload: message.draftPayload || null,
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null),
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null),
attachmentOcrDetails: message.attachmentOcrDetails || null
attachmentOcrDetails: message.attachmentOcrDetails || null,
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : []
}
}

View File

@@ -72,6 +72,9 @@ export function buildAiApplicationPreviewActionPayload({
: []
const draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId)
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
const applicationEditableFields = Array.isArray(normalizedPreview.editableFields)
? normalizedPreview.editableFields.map((field) => normalizeText(field)).filter(Boolean)
: []
return {
source: 'user_message',
@@ -107,6 +110,9 @@ export function buildAiApplicationPreviewActionPayload({
application_stage: 'expense_application',
user_input_text: message,
application_preview: normalizedPreview,
...(applicationEditableFields.length
? { application_editable_fields: applicationEditableFields }
: {}),
...(isSubmit
? {}
: {

View File

@@ -313,7 +313,9 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
}
lines.push(
'',
'> **请先核对**:请先检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先处理或关联已有申请单,避免重复申请。',
'**后续行动建议**',
'- 请检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
'- 若日期无误,请先处理或关联已有申请单,避免重复申请。',
'',
'我会先暂停本次申请表生成,不会开放保存草稿或提交入口。'
)
@@ -323,18 +325,17 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
const normalized = normalizeApplicationPreview(preview)
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
const missingText = missingFields.length ? missingFields.join('、') : '暂无'
const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '**预算管理者审核提示**' : '**预算与审批影响**'
const overlapPrefix = precheck?.overlap?.status === 'warning' ? '**时间重叠提醒**' : '**单据重叠核查**'
const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '预算管理者审核提示' : '预算与审批影响'
const overlapPrefix = precheck?.overlap?.status === 'warning' ? '时间重叠提醒' : '单据重叠核查'
const lines = [
'### 出差申请表草稿已生成',
'',
'**我已完成发起前的单据与预算预审**,并为您生成一张完整的出差申请表。',
'',
`> ${overlapPrefix}${precheck?.overlap?.summary || '已完成已有单据核查。'}`,
'',
`> ${budgetPrefix}${precheck?.budget?.summary || '已完成预算影响评估。'}`,
'',
`> **仍需补充**${missingText}`,
'**发起前预审结果**',
`- **${overlapPrefix}**${precheck?.overlap?.summary || '已完成已有单据核查。'}`,
`- **${budgetPrefix}**${precheck?.budget?.summary || '已完成预算影响评估。'}`,
`- **仍需补充**${missingText}`,
'',
'请直接点击表格中的字段补充或修改;费用测算会根据地点、天数和出行方式自动更新。'
]
@@ -363,14 +364,16 @@ export function buildAiApplicationSubmitConflictMessage(preview = {}, precheck =
'',
`> **相同日期提醒**${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`,
'',
`> **本次申请时间**${currentRangeText}`,
`**本次申请时间**${currentRangeText}`,
]
if (matchTable) {
lines.push('', matchTable)
}
lines.push(
'',
'> **请先核对**:请先核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先查看或处理已有申请单,避免重复申请。',
'**后续行动建议**',
'- 请核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
'- 若日期无误,请先查看或处理已有申请单,避免重复申请。',
'',
'我会先暂停本次提交,不会生成新的审批流。'
)

View File

@@ -26,13 +26,59 @@ export function getAiExpenseSteps() {
return DEFAULT_FIELD_STEPS
}
export function createAiExpenseDraft(expenseType, expenseTypeLabel) {
// 将 task 的 ontology_fields 映射到报销草稿字段。
// 只映射草稿能识别的字段(amount/time_range/reason/location),未知字段忽略。
export function buildAiExpenseDraftPrefillValues(ontologyFields = {}) {
const source = ontologyFields || {}
const values = {}
const amount = normalizeAnswer(
source.amount || source.application_amount || source.applicationAmount || source.estimated_amount
)
if (amount) {
values.amount = amount
}
const timeRange = normalizeAnswer(
source.time_range || source.business_time || source.application_business_time || source.time
)
if (timeRange) {
values.time_range = timeRange
}
const reason = normalizeAnswer(source.reason || source.application_reason || source.title || source.summary)
if (reason) {
values.reason = reason
}
const location = normalizeAnswer(source.location || source.application_location)
if (location) {
values.location = location
}
return values
}
// 根据已填值推进 stepKey 到第一个未填字段,全部填满则到 summary。
function resolveInitialStepKey(values = {}) {
for (const step of DEFAULT_FIELD_STEPS) {
if (!normalizeAnswer(values[step.key])) {
return step.key
}
}
return SUMMARY_STEP_KEY
}
export function createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues = {}) {
const safePrefill = prefillValues && typeof prefillValues === 'object' ? prefillValues : {}
const values = {}
for (const step of DEFAULT_FIELD_STEPS) {
const value = normalizeAnswer(safePrefill[step.key])
if (value) {
values[step.key] = value
}
}
return {
expenseType: normalizeAnswer(expenseType),
expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
applicationClaim: null,
values: {},
stepKey: DEFAULT_FIELD_STEPS[0].key
values,
stepKey: resolveInitialStepKey(values)
}
}

View File

@@ -263,7 +263,9 @@ function normalizeMessage(message = {}) {
streamStatus: safeString(message.stewardPlan.streamStatus) || 'completed'
}
: null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : []
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
// 保留多 task 推进上下文,刷新后申请预览/报销草稿消息仍能拿到剩余 task 列表。
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : []
}
}

View File

@@ -292,8 +292,20 @@ export function normalizeApplicationPreview(preview = {}) {
...resolveApplicationValidationIssues(fields),
...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview)
]
const editableFields = Array.isArray(preview?.editableFields)
? preview.editableFields
: Array.isArray(preview?.editable_fields)
? preview.editable_fields
: null
return {
...preview,
...(editableFields
? {
editableFields: editableFields
.map((field) => String(field || '').trim())
.filter(Boolean)
}
: {}),
fields,
missingFields,
validationIssues,
@@ -301,6 +313,37 @@ export function normalizeApplicationPreview(preview = {}) {
}
}
function resolveApplicationPreviewEditableFields(preview = {}) {
const source = Array.isArray(preview?.editableFields)
? preview.editableFields
: Array.isArray(preview?.editable_fields)
? preview.editable_fields
: null
if (!Array.isArray(source)) {
return null
}
const fields = new Set(
source
.map((field) => String(field || '').trim())
.filter(Boolean)
)
if (fields.has('time')) {
fields.add('time_return')
}
return fields
}
function isApplicationPreviewFieldEditable(preview = {}, item = {}, rowKey = '') {
if (item.editable === false) {
return false
}
const editableFields = resolveApplicationPreviewEditableFields(preview)
if (!editableFields) {
return true
}
return editableFields.has(rowKey)
}
export function applyApplicationBusinessTimeContext(preview = {}, businessTimeContext = null) {
if (!businessTimeContext || typeof businessTimeContext !== 'object') {
return normalizeApplicationPreview(preview)
@@ -394,7 +437,7 @@ export function buildApplicationPreviewRows(preview = {}) {
...item,
label: '出发时间',
value: tripDates.startDate || '待补充',
editable: item.editable !== false,
editable: isApplicationPreviewFieldEditable(normalized, item, 'time'),
highlight: Boolean(item.highlight),
missing
},
@@ -402,7 +445,7 @@ export function buildApplicationPreviewRows(preview = {}) {
key: 'time_return',
label: '返回时间',
value: tripDates.endDate || '待补充',
editable: item.editable !== false,
editable: isApplicationPreviewFieldEditable(normalized, item, 'time_return'),
highlight: Boolean(item.highlight),
missing
}
@@ -415,7 +458,7 @@ export function buildApplicationPreviewRows(preview = {}) {
...item,
label: resolveApplicationFieldLabel(item, fields),
value,
editable: item.editable !== false,
editable: isApplicationPreviewFieldEditable(normalized, item, item.key),
highlight: Boolean(item.highlight),
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
}]
@@ -484,6 +527,10 @@ export function buildApplicationTemplatePreview(currentUser = {}) {
export function buildLocalApplicationPreviewMessage(preview) {
const normalized = normalizeApplicationPreview(preview)
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
const editMode = Boolean(normalized.applicationEditMode || normalized.application_edit_mode)
if (editMode) {
return '我已载入原申请信息。请只修改事由、时间、地点和出行方式;职级、负责人、标准和费用会按规则带入或重新测算。'
}
return [
modelReviewStatus === 'completed'
? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。'

View File

@@ -21,11 +21,11 @@ export const SECTION_DEFINITIONS = [
},
{
id: 'appearance',
label: '界面皮肤',
title: '界面皮肤与主色',
desc: '整体主色与控件观感',
longDesc: '设置当前浏览器的界面主色。默认使用浅蓝企业主题,后续可扩展为企业级统一下发。',
actionLabel: '保存皮肤设置'
label: '主题设置',
title: '主题风格与界面体验',
desc: '动感、沉稳与智能风格',
longDesc: '选择当前系统的整体体验风格。主题会联动全局主色、控件状态和 AI 模式的对话呈现。',
actionLabel: '保存主题设置'
},
{
id: 'admin',
@@ -173,11 +173,58 @@ export const MODEL_TEST_CONFIGS = {
}
export const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS)
export const MODEL_TYPE_OPTIONS = [
{ label: '大语言模型', value: 'llm', capability: 'chat' },
{ label: 'Embedding', value: 'embedding', capability: 'embedding' },
{ label: 'Rerank', value: 'rerank', capability: 'reranker' }
]
export const MODEL_TYPE_LABELS = Object.fromEntries(MODEL_TYPE_OPTIONS.map((option) => [option.value, option.label]))
export const FIXED_MODEL_ROW_DEFINITIONS = [
{
slot: 'main',
type: 'llm',
providerKey: 'mainProvider',
modelKey: 'mainModel',
endpointKey: 'mainEndpoint',
apiKeyKey: 'mainApiKey',
configuredKey: 'mainApiKeyConfigured'
},
{
slot: 'backup',
type: 'llm',
providerKey: 'backupProvider',
modelKey: 'backupModel',
endpointKey: 'backupEndpoint',
apiKeyKey: 'backupApiKey',
configuredKey: 'backupApiKeyConfigured'
},
{
slot: 'embedding',
type: 'embedding',
providerKey: 'embeddingProvider',
modelKey: 'embeddingModel',
endpointKey: 'embeddingEndpoint',
apiKeyKey: 'embeddingApiKey',
configuredKey: 'embeddingApiKeyConfigured'
},
{
slot: 'reranker',
type: 'rerank',
providerKey: 'rerankerProvider',
modelKey: 'rerankerModel',
endpointKey: 'rerankerEndpoint',
apiKeyKey: 'rerankerApiKey',
configuredKey: 'rerankerApiKeyConfigured'
}
]
export const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
value: index + 1,
label: `${index + 1}`
}))
const FIXED_MODEL_ROW_SLOTS = new Set(FIXED_MODEL_ROW_DEFINITIONS.map((definition) => definition.slot))
const MODEL_TYPE_VALUES = new Set(MODEL_TYPE_OPTIONS.map((option) => option.value))
export function normalizeValue(value) {
return String(value ?? '').trim()
}
@@ -204,6 +251,68 @@ export function getRerankerEndpoint(provider) {
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
}
function normalizeModelType(type, fallback = 'llm') {
const normalized = normalizeValue(type)
return MODEL_TYPE_VALUES.has(normalized) ? normalized : fallback
}
export function normalizeLlmModelRows(rows) {
if (!Array.isArray(rows)) {
return []
}
return rows
.map((row) => ({
slot: normalizeValue(row?.slot),
provider: normalizeProviderValue(row?.provider, CUSTOM_OPENAI_PROVIDER),
url: normalizeValue(row?.url ?? row?.endpoint),
apiKey: normalizeValue(row?.apiKey),
apiKeyConfigured: Boolean(row?.apiKeyConfigured),
modelId: normalizeValue(row?.modelId ?? row?.model),
type: normalizeModelType(row?.type)
}))
.filter((row) => row.slot)
}
function buildFixedModelRow(llmForm, definition) {
return {
slot: definition.slot,
provider: normalizeProviderValue(llmForm?.[definition.providerKey], 'Codex'),
url: normalizeValue(llmForm?.[definition.endpointKey]),
apiKey: normalizeValue(llmForm?.[definition.apiKeyKey]),
apiKeyConfigured: Boolean(llmForm?.[definition.configuredKey]),
modelId: normalizeValue(llmForm?.[definition.modelKey]),
type: definition.type
}
}
export function buildLlmModelRows(llmForm) {
const fixedRows = FIXED_MODEL_ROW_DEFINITIONS.map((definition) => buildFixedModelRow(llmForm, definition))
const customRows = normalizeLlmModelRows(llmForm?.models).filter((row) => !FIXED_MODEL_ROW_SLOTS.has(row.slot))
return normalizeLlmModelRows([...fixedRows, ...customRows])
}
export function syncLegacyModelFieldsFromRows(llmForm) {
const rows = normalizeLlmModelRows(llmForm?.models)
const nextForm = { ...llmForm, models: rows }
for (const definition of FIXED_MODEL_ROW_DEFINITIONS) {
const row = rows.find((item) => item.slot === definition.slot)
if (!row) {
continue
}
nextForm[definition.providerKey] = row.provider
nextForm[definition.modelKey] = row.modelId
nextForm[definition.endpointKey] = row.url
nextForm[definition.apiKeyKey] = row.apiKey
nextForm[definition.configuredKey] = row.apiKeyConfigured
}
return nextForm
}
export function buildDefaultState(companyProfile, currentUser) {
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
const companyCode = normalizeValue(companyProfile?.code) || 'XF-001'
@@ -223,7 +332,7 @@ export function buildDefaultState(companyProfile, currentUser) {
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
},
appearanceForm: {
themeSkin: 'sky'
themeSkin: 'enterprise'
},
adminForm: {
adminAccount,
@@ -260,7 +369,29 @@ export function buildDefaultState(companyProfile, currentUser) {
rerankerModel: 'gte-rerank-v2',
rerankerEndpoint: getRerankerEndpoint('Ali'),
rerankerApiKey: '',
rerankerApiKeyConfigured: false
rerankerApiKeyConfigured: false,
models: buildLlmModelRows({
mainProvider: 'Codex',
mainModel: 'codex-mini-latest',
mainEndpoint: getProviderEndpoint('Codex'),
mainApiKey: '',
mainApiKeyConfigured: false,
backupProvider: 'GLM',
backupModel: 'glm-5.1',
backupEndpoint: getProviderEndpoint('GLM'),
backupApiKey: '',
backupApiKeyConfigured: false,
embeddingProvider: 'GLM',
embeddingModel: 'Embedding-3',
embeddingEndpoint: getProviderEndpoint('GLM'),
embeddingApiKey: '',
embeddingApiKeyConfigured: false,
rerankerProvider: 'Ali',
rerankerModel: 'gte-rerank-v2',
rerankerEndpoint: getRerankerEndpoint('Ali'),
rerankerApiKey: '',
rerankerApiKeyConfigured: false
})
},
renderForm: {
enabled: false,
@@ -326,6 +457,7 @@ export function mergeState(baseState, overrideState) {
mergedLlmForm.rerankerProvider,
baseState.llmForm.rerankerProvider
)
mergedLlmForm.models = buildLlmModelRows(mergedLlmForm)
return {
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
@@ -355,11 +487,15 @@ export function sanitizeForStorage(state) {
sessionForm: { ...state.sessionForm },
hermesForm: mergeHermesEmployeeForm(state.hermesForm),
llmForm: {
...state.llmForm,
...syncLegacyModelFieldsFromRows(state.llmForm),
mainApiKey: '',
backupApiKey: '',
embeddingApiKey: '',
rerankerApiKey: ''
rerankerApiKey: '',
models: normalizeLlmModelRows(state.llmForm.models).map((row) => ({
...row,
apiKey: ''
}))
},
renderForm: {
...state.renderForm,
@@ -390,11 +526,21 @@ export function maskConfiguredModelSecrets(state) {
}
}
state.llmForm.models = normalizeLlmModelRows(state.llmForm.models).map((row) => {
if (row.apiKeyConfigured && !normalizeValue(row.apiKey)) {
return { ...row, apiKey: MODEL_SECRET_MASK }
}
return row
})
return state
}
export function buildLlmPayload(llmForm) {
const payload = { ...llmForm }
const payload = syncLegacyModelFieldsFromRows({
...llmForm,
models: normalizeLlmModelRows(llmForm.models)
})
for (const config of MODEL_API_KEY_CONFIGS) {
if (isModelSecretMask(payload[config.apiKeyKey])) {
@@ -402,6 +548,11 @@ export function buildLlmPayload(llmForm) {
}
}
payload.models = normalizeLlmModelRows(payload.models).map((row) => ({
...row,
apiKey: isModelSecretMask(row.apiKey) ? '' : row.apiKey
}))
return payload
}
@@ -457,20 +608,13 @@ export function computeSectionStatus(state) {
Number(state.sessionForm.conversationRetentionDays) <= 10
),
hermes: isHermesEmployeeSettingsReady(state.hermesForm),
llm: Boolean(
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
isModelConfigReady(
state.llmForm.embeddingProvider,
state.llmForm.embeddingModel,
state.llmForm.embeddingEndpoint
) &&
isModelConfigReady(
state.llmForm.rerankerProvider,
state.llmForm.rerankerModel,
state.llmForm.rerankerEndpoint
)
),
llm: (() => {
const rows = normalizeLlmModelRows(state.llmForm.models)
return Boolean(
rows.length > 0 &&
rows.every((row) => isModelConfigReady(row.provider, row.modelId, row.url))
)
})(),
rendering: Boolean(
!state.renderForm.enabled ||
(normalizeValue(state.renderForm.publicUrl) &&

View File

@@ -227,6 +227,7 @@
:initial-budget-context="smartEntryContext.budgetContext"
:initial-prompt-auto-submit="smartEntryContext.initialPromptAutoSubmit"
:initial-application-preview="smartEntryContext.initialApplicationPreview"
:initial-draft-payload="smartEntryContext.initialDraftPayload"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"

View File

@@ -1,316 +1,178 @@
<template>
<div class="model-grid">
<!-- 主模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="model-config-surface">
<section class="settings-card model-table-card">
<div class="card-head model-table-toolbar">
<div class="card-title-with-icon">
<div class="model-icon-box purple">
<i class="mdi mdi-brain"></i>
<span class="model-icon-text">AI</span>
</div>
<div>
<h4>模型配置</h4>
<p>用于 AI 助手和主业务排队调度的默认模型接入</p>
<h4>模型配置</h4>
<p>集中维护大语言模型Embedding Rerank 模型接入</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('main')"
@click="testModelConnection('main')"
>
<i :class="isModelTesting('main') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('main') ? '测试中...' : '测试模型' }}</span>
<button class="add-model-button" type="button" @click="openAddModelDialog">
<i class="mdi mdi-plus"></i>
<span>添加模型</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="llmForm.mainProvider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPreset('main')"
/>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.mainModel" type="text" placeholder="请输入主模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.mainEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.mainApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('main')"
:placeholder="llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.mainApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`">
<i
:class="
getModelTestState('main').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('main').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('main').message }}</span>
<div class="model-table-wrap">
<table class="model-config-table">
<thead>
<tr>
<th>模型类型</th>
<th>供应商</th>
<th>model_id</th>
<th>接口地址</th>
<th>API Key</th>
<th>连通性</th>
<th class="model-action-col">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="model in modelRows" :key="model.slot">
<td>
<span class="model-type-pill">
<span>{{ getModelTypeLabel(model.type) }}</span>
</span>
</td>
<td>
<strong class="model-provider-name">{{ model.provider }}</strong>
</td>
<td>
<code class="model-id-text">{{ model.modelId }}</code>
</td>
<td>
<span class="model-url-text" :title="model.url">{{ model.url }}</span>
</td>
<td>
<span class="secret-state" :class="{ configured: model.apiKeyConfigured }">
<i :class="model.apiKeyConfigured ? 'mdi mdi-database-lock' : 'mdi mdi-key-outline'"></i>
<span>{{ model.apiKeyConfigured ? '已配置' : '未配置' }}</span>
</span>
</td>
<td>
<span
v-if="getModelTestState(model.slot).message"
class="test-feedback-inline"
:class="`is-${getModelTestState(model.slot).status}`"
>
<i
:class="
getModelTestState(model.slot).status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState(model.slot).status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState(model.slot).message }}</span>
</span>
<span v-else class="test-feedback-inline is-idle">待测试</span>
</td>
<td>
<div class="model-row-actions">
<button
class="icon-action-button"
type="button"
:disabled="isModelTesting(model.slot)"
title="测试模型"
@click="testModelConnection(model)"
>
<span>{{ isModelTesting(model.slot) ? '测试中' : '测试' }}</span>
</button>
<button class="icon-action-button" type="button" title="编辑模型" @click="openEditModelDialog(model)">
<span>编辑</span>
</button>
<button
class="icon-action-button danger"
type="button"
title="删除模型"
:disabled="isFixedModelSlot(model.slot)"
@click="removeModel(model)"
>
<span>删除</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 备份模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box orange">
<i class="mdi mdi-lifebuoy"></i>
</div>
<div v-if="modelDialogOpen" class="model-dialog-overlay" @click.self="closeModelDialog">
<section class="model-dialog" role="dialog" aria-modal="true" aria-labelledby="model-dialog-title">
<header class="model-dialog-head">
<div>
<h4>备份模型配置</h4>
<p>主模型不可用或限频时用于兜底切换的备用模型接入</p>
<h4 id="model-dialog-title">{{ modelDraft.slot ? '编辑模型' : '添加模型' }}</h4>
<p>配置供应商URL密钥model_id 和模型类型</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('backup')"
@click="testModelConnection('backup')"
>
<i :class="isModelTesting('backup') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('backup') ? '测试中...' : '测试模型' }}</span>
<button class="icon-action-button" type="button" title="关闭" @click="closeModelDialog">
<span>关闭</span>
</button>
</div>
</div>
</header>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="llmForm.backupProvider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPreset('backup')"
/>
</label>
<div class="form-grid model-dialog-form">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="modelDraft.provider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPresetToDraft"
/>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.backupModel" type="text" placeholder="请输入备份模型名称" />
</label>
<label class="field">
<span><em>*</em> 接口地址</span>
<input v-model="modelDraft.url" type="text" placeholder="https://api.example.com/v1" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.backupEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field">
<span>API Key</span>
<input
v-model="modelDraft.apiKey"
type="password"
autocomplete="off"
:placeholder="modelDraft.apiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后加密存储'"
@focus="clearDraftSecretMask"
/>
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.backupApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('backup')"
:placeholder="llmForm.backupApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.backupApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<label class="field">
<span><em>*</em> model_id</span>
<input v-model="modelDraft.modelId" type="text" placeholder="例如 gpt-5.4-mini" />
</label>
<div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`">
<i
:class="
getModelTestState('backup').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('backup').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('backup').message }}</span>
</div>
</section>
<!-- Embedding 模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box cyan">
<i class="mdi mdi-vector-combine"></i>
</div>
<div>
<h4>Embedding 模型配置</h4>
<p>用于向量检索知识库召回和语义匹配的嵌入模型设置</p>
<div class="field field-full">
<span><em>*</em> 模型类型</span>
<div class="model-type-segment" :class="{ disabled: isEditingFixedModel }">
<button
v-for="option in MODEL_TYPE_OPTIONS"
:key="option.value"
type="button"
:class="{ active: modelDraft.type === option.value }"
:disabled="isEditingFixedModel"
@click="selectDraftModelType(option.value)"
>
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('embedding')"
@click="testModelConnection('embedding')"
>
<i :class="isModelTesting('embedding') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('embedding') ? '测试中...' : '测试模型' }}</span>
<footer class="model-dialog-actions">
<button class="secondary-button" type="button" @click="closeModelDialog">取消</button>
<button class="save-button compact" type="button" @click="saveModelDialog">
<span>保存模型</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="llmForm.embeddingProvider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPreset('embedding')"
/>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.embeddingModel" type="text" placeholder="请输入 Embedding 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.embeddingEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.embeddingApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('embedding')"
:placeholder="llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.embeddingApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('embedding').message"
class="test-feedback"
:class="`is-${getModelTestState('embedding').status}`"
>
<i
:class="
getModelTestState('embedding').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('embedding').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('embedding').message }}</span>
</div>
</section>
<!-- Reranker 模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box teal">
<i class="mdi mdi-filter-variant"></i>
</div>
<div>
<h4>Reranker 模型配置</h4>
<p>用于检索结果重排和语义精排的 Reranker 模型设置</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('reranker')"
@click="testModelConnection('reranker')"
>
<i :class="isModelTesting('reranker') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('reranker') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="llmForm.rerankerProvider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPreset('reranker')"
/>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.rerankerModel" type="text" placeholder="请输入 Reranker 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.rerankerEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.rerankerApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('reranker')"
:placeholder="llmForm.rerankerApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.rerankerApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('reranker').message"
class="test-feedback"
:class="`is-${getModelTestState('reranker').status}`"
>
<i
:class="
getModelTestState('reranker').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('reranker').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('reranker').message }}</span>
</div>
</section>
</footer>
</section>
</div>
</div>
</template>

View File

@@ -109,44 +109,51 @@
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-palette-outline"></i>
<i class="mdi mdi-tune-variant"></i>
</div>
<div>
<h4>界面皮肤与企业主色</h4>
<p>只调整整体主色焦点态按钮和 Element Plus 控件颜色不改变业务布局</p>
<h4>主题风格与界面体验</h4>
<p>选择系统整体体验风格并联动 AI 模式的对话图标卡片和提示样式</p>
</div>
</div>
</div>
<div class="skin-option-grid">
<div class="theme-option-grid">
<button
v-for="skin in themeSkinOptions"
:key="skin.id"
class="skin-option"
:class="{ active: activeThemeSkinId === skin.id }"
v-for="theme in themeSkinOptions"
:key="theme.id"
class="theme-option"
:class="{ active: activeThemeSkinId === theme.id }"
type="button"
@click="selectThemeSkin(skin.id)"
@click="selectThemeSkin(theme.id)"
>
<span class="skin-swatch" aria-hidden="true">
<i :style="{ background: skin.primary }"></i>
<i :style="{ background: skin.primarySoftStrong }"></i>
<i :style="{ background: skin.secondary }"></i>
<i :style="{ background: skin.chartAmber }"></i>
<span class="theme-style-preview" aria-hidden="true">
<i :style="{ background: theme.primary }"></i>
<i :style="{ background: theme.primarySoftStrong }"></i>
<i :style="{ background: theme.secondary }"></i>
<i :style="{ background: theme.chartAmber }"></i>
</span>
<span class="skin-copy">
<strong>{{ skin.label }}</strong>
<small>{{ skin.desc }}</small>
<span class="theme-copy">
<strong>{{ theme.label }}</strong>
<small>{{ theme.desc }}</small>
<span class="theme-keywords">
<em v-for="keyword in theme.keywords" :key="keyword">{{ keyword }}</em>
</span>
</span>
<span v-if="activeThemeSkinId === skin.id" class="skin-current">当前</span>
<span v-if="activeThemeSkinId === theme.id" class="theme-current">当前</span>
</button>
</div>
<div class="skin-preview-panel">
<div class="theme-preview-panel">
<div>
<strong>{{ activeThemeSkin.label }}</strong>
<span>当前主会同步到全局按钮焦点环下拉浮层和表单控件</span>
<span>当前主会同步到全局按钮焦点环表单控件 AI 对话界面</span>
</div>
<div class="theme-preview-surface" aria-hidden="true">
<span></span>
<i></i>
<b></b>
</div>
<button class="skin-preview-action" type="button">主按钮</button>
</div>
</section>
</template>

View File

@@ -43,10 +43,84 @@
v-for="item in applicationDetailFactItems"
:key="item.key"
class="application-detail-fact"
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
:class="{
highlight: item.highlight,
emphasis: item.emphasis,
editable: canEditApplicationDetailItem(item),
editing: isApplicationDetailEditing(item)
}"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<strong>
<template v-if="isApplicationDetailEditing(item)">
<ElDatePicker
v-if="resolveApplicationDetailEditorControl(item) === 'date'"
v-model="applicationDetailEditor.draftValue"
class="application-detail-editor-control"
type="date"
value-format="YYYY-MM-DD"
format="YYYY/MM/DD"
popper-class="detail-editor-date-popper"
:clearable="false"
:disabled="applicationDetailEditor.saving"
@click.stop
/>
<EnterpriseSelect
v-else-if="resolveApplicationDetailEditorControl(item) === 'select'"
v-model="applicationDetailEditor.draftValue"
class="application-detail-editor-control application-detail-editor-select"
:options="APPLICATION_TRANSPORT_MODE_OPTIONS"
clearable
:teleported="false"
:disabled="applicationDetailEditor.saving"
@click.stop
/>
<ElInput
v-else
v-model="applicationDetailEditor.draftValue"
class="application-detail-editor-control"
clearable
:disabled="applicationDetailEditor.saving"
@click.stop
@keydown.enter.stop.prevent="saveApplicationDetailEdit(item)"
@keydown.esc.stop.prevent="cancelApplicationDetailEditor"
/>
<button
class="application-detail-edit-confirm"
type="button"
title="保存"
aria-label="保存"
:disabled="applicationDetailEditor.saving"
@click.stop="saveApplicationDetailEdit(item)"
>
<i :class="applicationDetailEditor.saving ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-check'"></i>
</button>
<button
class="application-detail-edit-cancel"
type="button"
title="取消"
aria-label="取消"
:disabled="applicationDetailEditor.saving"
@click.stop="cancelApplicationDetailEditor"
>
<i class="mdi mdi-close"></i>
</button>
</template>
<template v-else>
<span class="application-detail-fact-value">{{ item.value }}</span>
<button
v-if="canEditApplicationDetailItem(item)"
class="application-detail-edit-btn"
type="button"
title="编辑"
aria-label="编辑"
:disabled="actionBusy"
@click.stop="openApplicationDetailEditor(item)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</strong>
</div>
</div>
<TravelRequestBudgetAnalysis
@@ -458,16 +532,6 @@
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : deleteActionLabel }}
</button>
<button
v-if="canModifyReturnedApplication"
class="secondary-action"
type="button"
:disabled="actionBusy"
@click="handleModifyApplication"
>
<i class="mdi mdi-pencil-outline"></i>
修改申请
</button>
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
<i :class="submitActionIcon"></i>
{{ submitActionLabel }}

View File

@@ -1,103 +1,55 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { testModelConnectivity } from '../../services/settings.js'
import { useToast } from '../../composables/useToast.js'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import {
CUSTOM_OPENAI_PROVIDER,
MODEL_SECRET_MASK,
MODEL_TYPE_LABELS,
MODEL_TYPE_OPTIONS,
getProviderEndpoint,
getRerankerEndpoint,
isModelSecretMask,
normalizeLlmModelRows,
normalizeProviderValue,
normalizeValue
} from '../../utils/settingsModelHelper.js'
const MODEL_SECRET_MASK = '********'
const FIXED_MODEL_SLOTS = new Set(['main', 'backup', 'embedding', 'reranker'])
const MODEL_TYPE_CAPABILITY = Object.fromEntries(
MODEL_TYPE_OPTIONS.map((option) => [option.value, option.capability])
)
const MODEL_TEST_CONFIGS = {
main: {
label: '主模型',
providerKey: 'mainProvider',
modelKey: 'mainModel',
endpointKey: 'mainEndpoint',
apiKeyKey: 'mainApiKey',
capability: 'chat'
},
backup: {
label: '备份模型',
providerKey: 'backupProvider',
modelKey: 'backupModel',
endpointKey: 'backupEndpoint',
apiKeyKey: 'backupApiKey',
capability: 'chat'
},
embedding: {
label: 'Embedding 模型',
providerKey: 'embeddingProvider',
modelKey: 'embeddingModel',
endpointKey: 'embeddingEndpoint',
apiKeyKey: 'embeddingApiKey',
capability: 'embedding'
},
reranker: {
label: 'Reranker 模型',
providerKey: 'rerankerProvider',
modelKey: 'rerankerModel',
endpointKey: 'rerankerEndpoint',
apiKeyKey: 'rerankerApiKey',
capability: 'reranker'
function buildEmptyModelDraft() {
return {
slot: '',
provider: CUSTOM_OPENAI_PROVIDER,
url: '',
apiKey: '',
apiKeyConfigured: false,
modelId: '',
type: 'llm'
}
}
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
const PROVIDER_ENDPOINTS = {
MiniMax: 'https://api.minimaxi.com/v1',
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
Kimi: 'https://api.moonshot.ai/v1',
Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
Codex: 'https://api.openai.com/v1',
Claude: 'https://api.anthropic.com/v1/',
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
[CUSTOM_OPENAI_PROVIDER]: ''
function generateModelSlot(type) {
const prefix = type === 'embedding' ? 'embedding' : type === 'rerank' ? 'rerank' : 'llm'
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
return `${prefix}_${suffix}`
}
const RERANKER_PROVIDER_ENDPOINTS = {
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
[CUSTOM_OPENAI_PROVIDER]: ''
}
const LEGACY_PROVIDER_MAP = {
'OpenAI Compatible': 'Codex',
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
Ollama: CUSTOM_OPENAI_PROVIDER,
'自定义网关': CUSTOM_OPENAI_PROVIDER
}
function normalizeValue(value) {
return String(value ?? '').trim()
}
function normalizeProviderValue(value, fallback = 'Codex') {
const normalized = normalizeValue(value)
const providerOptions = Object.keys(PROVIDER_ENDPOINTS)
if (providerOptions.includes(normalized)) {
return normalized
}
if (LEGACY_PROVIDER_MAP[normalized]) {
return LEGACY_PROVIDER_MAP[normalized]
}
return fallback
}
function getProviderEndpoint(provider) {
return PROVIDER_ENDPOINTS[provider] ?? ''
}
function getRerankerEndpoint(provider) {
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
}
function isModelConfigReady(provider, model, endpoint) {
return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint))
}
function isModelSecretMask(value) {
return value === MODEL_SECRET_MASK
function normalizeDraftModel(draft) {
return normalizeLlmModelRows([
{
slot: draft.slot || generateModelSlot(draft.type),
provider: draft.provider,
url: draft.url,
apiKey: draft.apiKey,
apiKeyConfigured: draft.apiKeyConfigured,
modelId: draft.modelId,
type: draft.type
}
])[0]
}
export default {
@@ -117,81 +69,170 @@ export default {
},
setup(props) {
const { toast } = useToast()
const modelTestState = ref({
main: { status: 'idle', message: '' },
backup: { status: 'idle', message: '' },
embedding: { status: 'idle', message: '' },
reranker: { status: 'idle', message: '' }
})
const modelTestState = ref({})
const modelDialogOpen = ref(false)
const editingSlot = ref('')
const modelDraft = ref(buildEmptyModelDraft())
function applyProviderPreset(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
const provider = normalizeProviderValue(props.llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER)
const modelRows = computed(() => normalizeLlmModelRows(props.llmForm.models))
const isEditingFixedModel = computed(() => isFixedModelSlot(editingSlot.value))
props.llmForm[config.providerKey] = provider
props.llmForm[config.endpointKey] =
testKey === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
function replaceModelRows(rows) {
props.llmForm.models = normalizeLlmModelRows(rows)
}
function getModelTestState(testKey) {
return modelTestState.value[testKey] || { status: 'idle', message: '' }
function getModelTypeLabel(type) {
return MODEL_TYPE_LABELS[type] || MODEL_TYPE_LABELS.llm
}
function isModelTesting(testKey) {
return getModelTestState(testKey).status === 'testing'
function isFixedModelSlot(slot) {
return FIXED_MODEL_SLOTS.has(String(slot || ''))
}
function clearModelSecretMask(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
if (isModelSecretMask(props.llmForm[config.apiKeyKey])) {
props.llmForm[config.apiKeyKey] = ''
function getModelTestState(slot) {
return modelTestState.value[slot] || { status: 'idle', message: '' }
}
function isModelTesting(slot) {
return getModelTestState(slot).status === 'testing'
}
function openAddModelDialog() {
editingSlot.value = ''
modelDraft.value = buildEmptyModelDraft()
modelDialogOpen.value = true
}
function openEditModelDialog(model) {
editingSlot.value = model.slot
modelDraft.value = { ...model }
modelDialogOpen.value = true
}
function closeModelDialog() {
modelDialogOpen.value = false
editingSlot.value = ''
modelDraft.value = buildEmptyModelDraft()
}
function applyProviderPresetToDraft() {
const provider = normalizeProviderValue(modelDraft.value.provider, CUSTOM_OPENAI_PROVIDER)
modelDraft.value.provider = provider
modelDraft.value.url =
modelDraft.value.type === 'rerank' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
}
function selectDraftModelType(type) {
if (isEditingFixedModel.value) {
return
}
modelDraft.value.type = type
applyProviderPresetToDraft()
}
function clearDraftSecretMask() {
if (isModelSecretMask(modelDraft.value.apiKey)) {
modelDraft.value.apiKey = ''
}
}
async function testModelConnection(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
const provider = props.llmForm[config.providerKey]
const model = props.llmForm[config.modelKey]
const endpoint = props.llmForm[config.endpointKey]
const apiKey = props.llmForm[config.apiKeyKey]
function validateDraftModel() {
const provider = normalizeValue(modelDraft.value.provider)
const url = normalizeValue(modelDraft.value.url)
const modelId = normalizeValue(modelDraft.value.modelId)
if (!isModelConfigReady(provider, model, endpoint)) {
const message = `请先完整填写${config.label}的供应商、模型名称和接口地址。`
modelTestState.value[testKey] = { status: 'error', message }
if (!provider || !url || !modelId) {
toast('请完整填写供应商、接口地址和 model_id。')
return false
}
return true
}
function saveModelDialog() {
if (!validateDraftModel()) {
return
}
const nextModel = normalizeDraftModel(modelDraft.value)
const rows = [...modelRows.value]
const currentIndex = rows.findIndex((model) => model.slot === editingSlot.value)
if (currentIndex >= 0) {
rows.splice(currentIndex, 1, nextModel)
} else {
rows.push(nextModel)
}
replaceModelRows(rows)
closeModelDialog()
}
function removeModel(model) {
if (isFixedModelSlot(model.slot)) {
toast('内置运行时槽位不能删除。')
return
}
if (typeof window !== 'undefined' && !window.confirm('确定删除这个模型配置吗?')) {
return
}
replaceModelRows(modelRows.value.filter((row) => row.slot !== model.slot))
}
async function testModelConnection(model) {
if (!normalizeValue(model.provider) || !normalizeValue(model.modelId) || !normalizeValue(model.url)) {
const message = '请先完整填写模型的供应商、model_id 和接口地址。'
modelTestState.value[model.slot] = { status: 'error', message }
toast(message)
return
}
modelTestState.value[testKey] = { status: 'testing', message: '正在测试模型连通性...' }
modelTestState.value[model.slot] = { status: 'testing', message: '正在测试模型连通性...' }
const payload = {
provider,
model,
endpoint,
api_key: isModelSecretMask(apiKey) ? '' : apiKey,
capability: config.capability,
slot: testKey
provider: model.provider,
model: model.modelId,
endpoint: model.url,
api_key: model.apiKey === MODEL_SECRET_MASK ? '' : model.apiKey,
capability: MODEL_TYPE_CAPABILITY[model.type] || 'chat',
slot: model.slot
}
try {
const result = await testModelConnectivity(payload)
modelTestState.value[testKey] = {
modelTestState.value[model.slot] = {
status: result.ok ? 'success' : 'error',
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
}
toast(modelTestState.value[testKey].message)
toast(modelTestState.value[model.slot].message)
} catch (error) {
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
modelTestState.value[testKey] = { status: 'error', message }
modelTestState.value[model.slot] = { status: 'error', message }
toast(message)
}
}
return {
applyProviderPreset,
MODEL_TYPE_OPTIONS,
applyProviderPresetToDraft,
clearDraftSecretMask,
closeModelDialog,
getModelTestState,
getModelTypeLabel,
isEditingFixedModel,
isFixedModelSlot,
isModelTesting,
clearModelSecretMask,
modelDialogOpen,
modelDraft,
modelRows,
openAddModelDialog,
openEditModelDialog,
removeModel,
saveModelDialog,
selectDraftModelType,
testModelConnection
}
}

View File

@@ -94,6 +94,10 @@ export default {
type: Object,
default: null
},
initialDraftPayload: {
type: Object,
default: null
},
initialFiles: {
type: Array,
default: () => []

View File

@@ -16,7 +16,8 @@ import {
const TASK_TYPE_LABELS = {
expense_application: '费用申请',
reimbursement: '费用报销'
reimbursement: '费用报销',
query_travel_standard: '差旅标准查询'
}
const AGENT_LABELS = {
@@ -25,14 +26,17 @@ const AGENT_LABELS = {
expense_application: '申请助手',
reimbursement_assistant: '报销助手',
reimbursement: '报销助手',
expense: '报销助手'
expense: '报销助手',
policy_query_assistant: '政策查询助手',
query_travel_standard: '政策查询助手'
}
const EXECUTABLE_STEWARD_ACTION_TYPES = new Set([
'save_application_draft',
'submit_application',
'create_reimbursement_draft',
'associate_attachments'
'associate_attachments',
'execute_travel_standard_query'
])
export function buildStewardPlanRequest({

View File

@@ -2,6 +2,11 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
runAiApplicationPreviewAction
} from '../../services/aiApplicationPreviewActions.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
import {
canApproveBudgetExpenseApplications,
canApproveLeaderExpenseClaims,
@@ -23,6 +28,13 @@ import {
import { buildRiskViewerContext } from '../../utils/riskVisibility.js'
import { resolveProgressStepsForViewer } from '../../utils/requestProgressViewer.js'
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
EXPENSE_TYPE_OPTIONS,
buildFallbackExpenseItems,
@@ -43,6 +55,11 @@ export function useTravelRequestDetailSetup(props, { emit }) {
const { currentUser } = useSystemState()
const expenseItems = ref([])
const expenseAttachmentMeta = reactive({})
const applicationDetailEditor = reactive({
fieldKey: '',
draftValue: '',
saving: false
})
const riskFlagPreviewSnapshot = ref(null)
let actionBusy = { value: false }
const getActionBusy = () => Boolean(actionBusy?.value)
@@ -92,11 +109,10 @@ export function useTravelRequestDetailSetup(props, { emit }) {
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
const canModifyReturnedApplication = computed(() => (
const canModifyApplication = computed(() => (
isApplicationDocument.value
&& isEditableRequest.value
&& isCurrentApplicant.value
&& String(request.value.status || '').trim().toLowerCase() === 'returned'
))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
@@ -261,6 +277,7 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|| approvalFlow.returnBusy.value
|| approvalFlow.approveBusy.value
|| paymentFlow.payBusy.value
|| applicationDetailEditor.saving
|| smartEntryRecognitionBusy.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
@@ -350,6 +367,15 @@ export function useTravelRequestDetailSetup(props, { emit }) {
)
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
const applicationEditEditableFields = ['reason', 'time', 'location', 'transportMode']
const applicationDetailEditableFactKeys = new Set([
'reason',
'location',
'transport_mode',
'trip_start_time',
'trip_return_time',
'time'
])
watch(
request,
@@ -366,6 +392,7 @@ export function useTravelRequestDetailSetup(props, { emit }) {
attachmentPreview.closeAttachmentPreview()
}
expenseEditor.resetExpenseWorkState()
cancelApplicationDetailEditor()
void attachmentPreview.syncExpenseAttachmentMeta()
},
{ immediate: true }
@@ -403,6 +430,8 @@ export function useTravelRequestDetailSetup(props, { emit }) {
return {
sourceText: '修改申请',
modelReviewStatus: 'template',
applicationEditMode: true,
editableFields: applicationEditEditableFields,
fields: {
applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请',
applicant: request.value.profileName || request.value.person || request.value.applicant || '',
@@ -424,27 +453,163 @@ export function useTravelRequestDetailSetup(props, { emit }) {
}
}
function handleModifyApplication() {
if (!canModifyReturnedApplication.value) {
function buildApplicationEditDraftPayload() {
const claimId = String(request.value?.claimId || '').trim()
const claimNo = String(request.value?.claimNo || request.value?.documentNo || request.value?.id || '').trim()
return {
draft_type: 'expense_application',
document_type: 'expense_application',
claim_id: claimId,
claim_no: claimNo,
status: String(request.value?.status || request.value?.approvalKey || '').trim(),
approval_stage: String(request.value?.node || request.value?.approvalStage || '待提交').trim(),
title: String(request.value?.typeLabel || '费用申请').trim(),
application_edit_mode: true
}
}
function normalizeApplicationDetailEditorValue(value = '') {
const text = String(value || '').trim()
return text === '待补充' ? '' : text
}
function resolveApplicationDetailFactValue(key = '') {
const targetKey = String(key || '').trim()
return String(applicationDetailFactItems.value.find((item) => item?.key === targetKey)?.value || '').trim()
}
function buildApplicationDetailDateRange(startDate = '', endDate = '') {
const start = String(startDate || '').trim()
const end = String(endDate || '').trim()
if (!start && !end) return ''
if (!start) return end
if (!end || end === start) return start
return `${start}${end}`
}
function resolveApplicationDetailDays(startDate = '', endDate = '') {
const start = String(startDate || '').trim()
const end = String(endDate || '').trim()
if (!start || !end) return ''
const startTime = new Date(`${start}T00:00:00`).getTime()
const endTime = new Date(`${end}T00:00:00`).getTime()
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) {
return ''
}
return `${Math.round((endTime - startTime) / 86400000) + 1}`
}
function canEditApplicationDetailItem(item = {}) {
return (
canModifyApplication.value
&& applicationDetailEditableFactKeys.has(String(item?.key || '').trim())
)
}
function isApplicationDetailEditing(item = {}) {
return String(applicationDetailEditor.fieldKey || '') === String(item?.key || '')
}
function resolveApplicationDetailEditorControl(item = {}) {
const key = String(item?.key || '').trim()
if (['trip_start_time', 'trip_return_time', 'time'].includes(key)) {
return 'date'
}
if (key === 'transport_mode') {
return 'select'
}
return 'text'
}
function openApplicationDetailEditor(item = {}) {
if (!canEditApplicationDetailItem(item) || applicationDetailEditor.saving) {
return
}
applicationDetailEditor.fieldKey = String(item.key || '').trim()
applicationDetailEditor.draftValue = normalizeApplicationDetailEditorValue(item.value)
}
function cancelApplicationDetailEditor() {
applicationDetailEditor.fieldKey = ''
applicationDetailEditor.draftValue = ''
}
function buildEditedApplicationPreview(item = {}) {
const key = String(item?.key || '').trim()
const nextValue = normalizeApplicationDetailEditorValue(applicationDetailEditor.draftValue)
const preview = buildApplicationEditPreview()
const fields = { ...(preview.fields || {}) }
if (key === 'reason') {
fields.reason = nextValue
} else if (key === 'location') {
fields.location = nextValue
} else if (key === 'transport_mode') {
fields.transportMode = nextValue
} else if (key === 'time') {
fields.time = nextValue
} else if (key === 'trip_start_time' || key === 'trip_return_time') {
const startDate = key === 'trip_start_time'
? nextValue
: resolveApplicationDetailFactValue('trip_start_time')
const endDate = key === 'trip_return_time'
? nextValue
: resolveApplicationDetailFactValue('trip_return_time')
fields.time = buildApplicationDetailDateRange(startDate, endDate)
fields.days = resolveApplicationDetailDays(startDate, endDate) || fields.days
}
return normalizeApplicationPreview({
...preview,
fields
})
}
async function refreshEditedApplicationPreviewEstimate(preview = {}) {
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, currentUser.value || {})
if (!estimateRequest.canCalculate) {
return preview
}
try {
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, currentUser.value || {})
} catch (error) {
return applyApplicationPolicyEstimateError(preview, error, currentUser.value || {})
}
}
async function saveApplicationDetailEdit(item = {}) {
if (!isApplicationDetailEditing(item) || applicationDetailEditor.saving) {
return
}
if (!String(request.value?.claimId || '').trim()) {
toast('当前申请缺少单据标识,暂不能修改。')
return
}
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', {
source: 'application',
sessionType: 'application',
prompt: '',
applicationPreview: buildApplicationEditPreview(),
request: {
...request.value,
applicationEditMode: true
},
restoreLatestConversation: false,
initialPromptAutoSubmit: false,
scope: claimId
? { type: 'claim', claimId }
: null
})
applicationDetailEditor.saving = true
try {
const preview = await refreshEditedApplicationPreviewEstimate(buildEditedApplicationPreview(item))
const payload = await runAiApplicationPreviewAction({
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
applicationPreview: preview,
currentUser: currentUser.value || {},
draftPayload: buildApplicationEditDraftPayload()
})
const draftPayload = payload?.result?.draft_payload || payload?.draft_payload || {}
emit('request-updated', {
claimId: String(draftPayload.claim_id || request.value.claimId || '').trim(),
claimNo: String(draftPayload.claim_no || request.value.claimNo || request.value.documentNo || '').trim(),
status: String(draftPayload.status || request.value.status || '').trim(),
approvalStage: String(draftPayload.approval_stage || request.value.node || '').trim()
})
cancelApplicationDetailEditor()
toast('申请信息已更新。')
} catch (error) {
toast(error?.message || '申请信息更新失败,请稍后重试。')
} finally {
applicationDetailEditor.saving = false
}
}
onBeforeUnmount(() => {
@@ -461,22 +626,25 @@ export function useTravelRequestDetailSetup(props, { emit }) {
...expenseEditor,
...paymentFlow,
...riskSubmit,
APPLICATION_TRANSPORT_MODE_OPTIONS,
applicationDetailEditor,
applicationDetailFactItems,
relatedApplicationFactItems,
canEditApplicationDetailItem,
canDeleteRequest,
canManageCurrentClaim,
canModifyReturnedApplication,
canModifyApplication,
canOpenAiEntry,
canApproveRequest,
canReturnRequest,
currentProgressRingMotion,
expenseItems,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleModifyApplication,
hasLeaderApprovalEvents,
hasSingleLeaderApprovalEvent,
heroFactItems,
isApplicationDocument,
isApplicationDetailEditing,
isDraftRequest,
isEditableRequest,
isTravelRequest,
@@ -485,8 +653,12 @@ export function useTravelRequestDetailSetup(props, { emit }) {
profile,
progressSteps,
request,
cancelApplicationDetailEditor,
openApplicationDetailEditor,
resolveExpenseReasonHelper,
resolveExpenseReasonPlaceholder,
resolveApplicationDetailEditorControl,
saveApplicationDetailEdit,
showApplicationLeaderOpinion,
showBudgetAnalysis,
showStageRiskAdvice,

View File

@@ -221,9 +221,13 @@ export function useTravelReimbursementCreateViewLifecycle({
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
const draftPayload = props.initialDraftPayload && typeof props.initialDraftPayload === 'object'
? props.initialDraftPayload
: null
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
meta: ['修改申请'],
applicationPreview
applicationPreview,
draftPayload
}))
persistSessionState()
}

View File

@@ -95,9 +95,62 @@ async function testSaveDraftActionUsesFastPreviewEndpoint() {
assert.equal(body.context_json.application_stage, 'expense_application')
}
async function testEditDraftActionCarriesClaimAndEditableFields() {
let capturedOptions = null
global.fetch = async (_url, options) => {
capturedOptions = options
return {
ok: true,
async json() {
return {
status: 'succeeded',
result: {
draft_payload: {
claim_id: 'claim-edit-application',
claim_no: 'AP-20260620-EDIT',
status: 'draft',
approval_stage: '待提交'
}
}
}
}
}
}
await runAiApplicationPreviewAction({
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
applicationPreview: {
applicationEditMode: true,
editableFields: ['reason', 'time', 'location', 'transportMode'],
fields: {
applicationType: '差旅费用申请',
time: '2026-07-01 至 2026-07-03',
location: '北京',
reason: '项目实施',
days: '3天',
transportMode: '火车',
amount: '1000元'
}
},
currentUser: { username: 'zhangsan@example.com', name: '张三' },
draftPayload: {
claim_id: 'claim-edit-application',
claim_no: 'AP-20260620-EDIT',
status: 'returned'
}
})
const body = JSON.parse(capturedOptions.body)
assert.equal(body.context_json.application_edit_claim_id, 'claim-edit-application')
assert.equal(body.context_json.application_edit_mode, true)
assert.deepEqual(body.context_json.application_editable_fields, ['reason', 'time', 'location', 'transportMode'])
}
async function run() {
await testSubmitActionUsesFastPreviewEndpoint()
await testSaveDraftActionUsesFastPreviewEndpoint()
await testEditDraftActionCarriesClaimAndEditableFields()
console.log('ai-application-preview-actions tests passed')
}

View File

@@ -3,6 +3,7 @@ import test from 'node:test'
import {
applyAiExpenseAnswer,
buildAiExpenseDraftPrefillValues,
buildAiExpenseStepPrompt,
buildAiExpenseSummary,
createAiExpenseDraft,
@@ -71,3 +72,41 @@ test('summary lists every filled field and the linked application', () => {
assert.match(summary, /AP-202606-001/)
assert.match(summary, /85元/)
})
test('buildAiExpenseDraftPrefillValues maps task ontology fields onto draft fields', () => {
const values = buildAiExpenseDraftPrefillValues({
expense_type: 'meal',
amount: '2000元',
time_range: '昨天',
reason: '客户招待',
location: '上海',
unrelated_field: 'ignore me'
})
assert.equal(values.amount, '2000元')
assert.equal(values.time_range, '昨天')
assert.equal(values.reason, '客户招待')
assert.equal(values.location, '上海')
assert.equal(values.unrelated_field, undefined)
})
test('createAiExpenseDraft with prefillValues skips already filled steps', () => {
const draft = createAiExpenseDraft('meal', '业务招待费', {
amount: '2000元',
reason: '客户招待'
})
// reason 已填,跳到下一个未填字段 time_range
assert.equal(draft.values.amount, '2000元')
assert.equal(draft.values.reason, '客户招待')
assert.equal(draft.stepKey, 'time_range')
})
test('createAiExpenseDraft with all prefillValues lands on summary', () => {
const draft = createAiExpenseDraft('meal', '业务招待费', {
reason: '客户招待',
time_range: '昨天',
location: '上海',
amount: '2000元',
attachments: '稍后上传'
})
assert.ok(isAiExpenseDraftComplete(draft))
})

View File

@@ -67,3 +67,37 @@ test('AI workbench conversation store persists scoped history for sidebar sessio
assert.equal(nextHistory.length, 1)
assert.equal(nextHistory[0].id, 'conv-first')
})
test('AI workbench conversation store preserves stewardRemainingTasks on messages', () => {
installLocalStorageMock()
const user = { username: 'caoxiaozhu' }
const remainingTasks = [
{ task_id: 't2', task_type: 'reimbursement', ontology_fields: { expense_type: 'meal' } }
]
saveAiWorkbenchConversation(user, {
id: 'conv-multi-task',
title: '出差+招待费',
updatedAt: Date.now(),
messages: [
{ id: 'u1', role: 'user', content: '出差+报销招待费' },
{
id: 'a1',
role: 'assistant',
content: '申请草稿已保存',
stewardRemainingTasks: remainingTasks
}
]
})
const history = loadAiWorkbenchConversationHistory(user)
assert.equal(history.length, 1)
// 历史摘要不要求保留 stewardRemainingTasks,但加载完整会话时消息上应保留。
// 这里通过 saveAiWorkbenchConversation 的往返确认 normalizeMessage 不会丢弃该字段。
const stored = JSON.parse(globalThis.window.localStorage.getItem(
'x-financial:workbench-ai-conversations:caoxiaozhu'
))
const conversation = stored.find((item) => item.id === 'conv-multi-task')
const persistedMessage = conversation.messages.find((m) => m.id === 'a1')
assert.deepEqual(persistedMessage.stewardRemainingTasks, remainingTasks)
})

View File

@@ -182,10 +182,13 @@ test('application entry keeps its own assistant source without creating a separa
test('application edit prefill opens assistant without auto submit', () => {
assert.match(appShellRouteView, /:initial-prompt-auto-submit="smartEntryContext\.initialPromptAutoSubmit"/)
assert.match(appShellRouteView, /:initial-application-preview="smartEntryContext\.initialApplicationPreview"/)
assert.match(appShellRouteView, /:initial-draft-payload="smartEntryContext\.initialDraftPayload"/)
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*true/)
assert.match(appShellComposable, /initialApplicationPreview:\s*null/)
assert.match(appShellComposable, /initialDraftPayload:\s*null/)
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*payload\.initialPromptAutoSubmit !== false/)
assert.match(appShellComposable, /initialApplicationPreview:\s*payload\.applicationPreview && typeof payload\.applicationPreview === 'object'/)
assert.match(appShellComposable, /initialDraftPayload:\s*payload\.draftPayload && typeof payload\.draftPayload === 'object'/)
assert.match(
assistantScript,
/initialPromptAutoSubmit:\s*\{[\s\S]*type:\s*Boolean[\s\S]*default:\s*true/
@@ -194,9 +197,13 @@ test('application edit prefill opens assistant without auto submit', () => {
assistantScript,
/initialApplicationPreview:\s*\{[\s\S]*type:\s*Object[\s\S]*default:\s*null/
)
assert.match(
assistantScript,
/initialDraftPayload:\s*\{[\s\S]*type:\s*Object[\s\S]*default:\s*null/
)
assert.match(
assistantSurface,
/props\.initialApplicationPreview[\s\S]*normalizeApplicationPreview\(props\.initialApplicationPreview\)[\s\S]*createMessage\('assistant', buildLocalApplicationPreviewMessage\(applicationPreview\)/
/props\.initialApplicationPreview[\s\S]*normalizeApplicationPreview\(props\.initialApplicationPreview\)[\s\S]*const draftPayload = props\.initialDraftPayload[\s\S]*createMessage\('assistant', buildLocalApplicationPreviewMessage\(applicationPreview\)[\s\S]*draftPayload/
)
assert.match(assistantSessionStateScript, /&& !props\.initialApplicationPreview/)
assert.match(

View File

@@ -362,6 +362,40 @@ test('travel application submit can continue with conversational planning recomm
assert.match(recommendation, /AP-202606030001-ABCDE123/)
})
test('application edit preview only allows reason time location and transport changes', () => {
const preview = normalizeApplicationPreview({
sourceText: '修改申请',
applicationEditMode: true,
editableFields: ['reason', 'time', 'location', 'transportMode'],
fields: {
applicationType: '差旅费用申请',
applicant: '李文静',
grade: 'P5',
department: '财务部',
position: '财务分析师',
managerName: '王强',
time: '2026-05-25 至 2026-05-28',
location: '上海',
reason: '客户现场项目支持',
days: '4天',
transportMode: '火车',
lodgingDailyCap: '450元/天',
subsidyDailyCap: '100元/天',
transportPolicy: '按规则测算',
policyEstimate: '交通 300元 + 住宿 1800元 + 补贴 400元 = 2500元',
amount: '2500元'
}
})
const rows = buildApplicationPreviewRows(preview)
const editableKeys = rows.filter((row) => row.editable).map((row) => row.key)
assert.deepEqual(editableKeys, ['time', 'time_return', 'location', 'reason', 'transportMode'])
assert.equal(rows.find((row) => row.key === 'applicationType')?.editable, false)
assert.equal(rows.find((row) => row.key === 'days')?.editable, false)
assert.equal(rows.find((row) => row.key === 'amount')?.editable, false)
assert.match(buildLocalApplicationPreviewMessage(preview), /只修改事由、时间、地点和出行方式/)
})
test('application preview renders ordered editable rows and submit text uses edited values', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆伊犁出差服务美团业务部署火车预计费用1800元', {
name: '李文静',

View File

@@ -7,17 +7,27 @@ const llmSettingsPanel = readFileSync(new URL('../src/views/LlmSettingsPanel.vue
function testLlmSectionReplacesVlmWithReranker() {
assert.doesNotMatch(settingsView, /VLM 模型/)
assert.match(llmSettingsPanel, /Reranker 模型配置/)
assert.match(llmSettingsPanel, /Rerank/)
assert.match(settingsModel, /rerankerProvider/)
}
function testRerankerCardRendersAfterEmbeddingCard() {
assert.match(llmSettingsPanel, /Embedding 模型配置[\s\S]*Reranker 模型配置/)
function testLlmSectionUsesTableAndAddModelDialog() {
assert.match(llmSettingsPanel, /model-table-toolbar[\s\S]*添加模型/)
assert.match(llmSettingsPanel, /<table class="model-config-table">/)
assert.match(llmSettingsPanel, /model-dialog-overlay/)
assert.match(llmSettingsPanel, /供应商[\s\S]*接口地址[\s\S]*API Key[\s\S]*model_id[\s\S]*模型类型/)
assert.match(llmSettingsPanel, /大语言模型[\s\S]*Embedding[\s\S]*Rerank/)
}
function testSettingsModelKeepsExtensibleModelRows() {
assert.match(settingsModel, /models:\s*buildLlmModelRows/)
assert.match(settingsModel, /buildLlmModelRows/)
}
function run() {
testLlmSectionReplacesVlmWithReranker()
testRerankerCardRendersAfterEmbeddingCard()
testLlmSectionUsesTableAndAddModelDialog()
testSettingsModelKeepsExtensibleModelRows()
console.log('settings llm section tests passed')
}

View File

@@ -0,0 +1,73 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import * as themeSkinModel from '../src/composables/useThemeSkin.js'
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
const settingsStyles = readFileSync(new URL('../src/assets/styles/views/settings-view.css', import.meta.url), 'utf8')
const aiModeStyles = readFileSync(
new URL('../src/assets/styles/components/personal-workbench-ai-mode.css', import.meta.url),
'utf8'
)
function testAppearanceSectionIsThemeSettings() {
assert.match(settingsModel, /id:\s*'appearance'[\s\S]*label:\s*'主题设置'/)
assert.match(settingsModel, /id:\s*'appearance'[\s\S]*title:\s*'主题风格与界面体验'/)
assert.match(settingsModel, /id:\s*'appearance'[\s\S]*actionLabel:\s*'保存主题设置'/)
assert.match(settingsView, /<h4>主题风格与界面体验<\/h4>/)
assert.doesNotMatch(settingsModel, /label:\s*'界面皮肤'/)
assert.doesNotMatch(settingsView, /界面皮肤与企业主色/)
}
function testThemeOptionsCollapseToThreeExperienceModes() {
assert.deepEqual(
themeSkinModel.THEME_SKIN_OPTIONS.map((theme) => theme.id),
['vivid', 'enterprise', 'intelligent']
)
assert.deepEqual(
themeSkinModel.THEME_SKIN_OPTIONS.map((theme) => theme.label),
['动感活泼', '企业沉稳', '专业智能']
)
assert.equal(typeof themeSkinModel.normalizeThemeMode, 'function')
assert.equal(themeSkinModel.normalizeThemeMode('sky'), 'vivid')
assert.equal(themeSkinModel.normalizeThemeMode('blue'), 'vivid')
assert.equal(themeSkinModel.normalizeThemeMode('legacy-green'), 'vivid')
assert.equal(themeSkinModel.normalizeThemeMode('navy'), 'enterprise')
assert.equal(themeSkinModel.normalizeThemeMode('slate'), 'enterprise')
assert.equal(themeSkinModel.normalizeThemeMode('soft-violet'), 'intelligent')
assert.equal(themeSkinModel.normalizeThemeMode(''), 'enterprise')
assert.equal(themeSkinModel.normalizeThemeMode('unknown-theme'), 'enterprise')
}
function testSettingsThemeCardsAvoidLegacySkinLanguage() {
assert.match(settingsView, /theme-option-grid/)
assert.match(settingsView, /theme-preview-panel/)
assert.match(settingsStyles, /\.theme-option-grid/)
assert.match(settingsStyles, /\.theme-style-preview/)
assert.doesNotMatch(settingsView, /skin-option-grid/)
assert.doesNotMatch(settingsStyles, /\.skin-option-grid/)
}
function testEnterpriseThemeHasAiModeOverrides() {
assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-mode\s*\{/)
assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-mode\.has-conversation\s*\{/)
assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-orb\s*\{/)
assert.match(
aiModeStyles,
/\[data-theme-mode="enterprise"\]\s+\.workbench-ai-orb\s*\{[\s\S]*border:\s*0;[\s\S]*border-radius:\s*50%;[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*none;/
)
assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-composer[\s\S]*\{/)
assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-message\s*\{/)
assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-thinking-panel\s*\{/)
}
function run() {
testAppearanceSectionIsThemeSettings()
testThemeOptionsCollapseToThreeExperienceModes()
testSettingsThemeCardsAvoidLegacySkinLanguage()
testEnterpriseThemeHasAiModeOverrides()
console.log('settings theme section tests passed')
}
run()

View File

@@ -1593,27 +1593,35 @@ test('application detail uses application labels instead of reimbursement labels
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
})
test('returned application detail can open assistant with editable prefill', () => {
assert.match(
detailViewTemplate,
/v-if="canModifyReturnedApplication"[\s\S]*@click="handleModifyApplication"[\s\S]*修改申请/
)
test('draft or returned application detail edits allowed facts inline', () => {
assert.doesNotMatch(detailViewTemplate, /修改申请/)
assert.match(detailViewTemplate, /canEditApplicationDetailItem\(item\)/)
assert.match(detailViewTemplate, /application-detail-edit-btn/)
assert.match(detailViewTemplate, /openApplicationDetailEditor\(item\)/)
assert.match(detailViewTemplate, /saveApplicationDetailEdit\(item\)/)
assert.doesNotMatch(detailViewScript, /handleModifyApplication/)
assert.match(
detailViewScript,
/const canModifyReturnedApplication = computed\(\(\) => \([\s\S]*isApplicationDocument\.value[\s\S]*isCurrentApplicant\.value[\s\S]*returned/
/const canModifyApplication = computed\(\(\) => \([\s\S]*isApplicationDocument\.value[\s\S]*isEditableRequest\.value[\s\S]*isCurrentApplicant\.value[\s\S]*\)\)/
)
assert.match(detailViewScript, /function buildApplicationEditPreview\(\)/)
assert.match(detailViewScript, /applicationDetailFactItems\.value[\s\S]*sourceText:\s*'修改申请'/)
assert.match(detailViewScript, /fields:\s*\{[\s\S]*applicationType:[\s\S]*reason:[\s\S]*transportMode:/)
assert.match(detailViewScript, /function handleModifyApplication\(\)/)
assert.match(detailViewScript, /source:\s*'application'/)
assert.match(detailViewScript, /sessionType:\s*'application'/)
assert.match(detailViewScript, /prompt:\s*''/)
assert.match(detailViewScript, /applicationPreview:\s*buildApplicationEditPreview\(\)/)
assert.match(detailViewScript, /applicationEditMode:\s*true/)
assert.match(detailViewScript, /initialPromptAutoSubmit:\s*false/)
assert.match(detailViewScript, /canModifyReturnedApplication,/)
assert.match(detailViewScript, /handleModifyApplication,/)
assert.match(detailViewScript, /editableFields:\s*applicationEditEditableFields/)
assert.match(detailViewScript, /fields:\s*\{[\s\S]*applicationType:[\s\S]*reason:[\s\S]*transportMode:/)
assert.match(detailViewScript, /function buildApplicationEditDraftPayload\(\)/)
assert.match(detailViewScript, /draft_type:\s*'expense_application'/)
assert.match(detailViewScript, /claim_id:\s*claimId/)
assert.match(detailViewScript, /application_edit_mode:\s*true/)
assert.match(detailViewScript, /function canEditApplicationDetailItem\(item = \{\}\)/)
assert.match(detailViewScript, /function openApplicationDetailEditor\(item = \{\}\)/)
assert.match(detailViewScript, /async function saveApplicationDetailEdit\(item = \{\}\)/)
assert.match(detailViewScript, /runAiApplicationPreviewAction\(\{[\s\S]*AI_APPLICATION_ACTION_SAVE_DRAFT/)
assert.match(detailViewScript, /emit\('request-updated'/)
assert.match(detailViewScript, /canModifyApplication,/)
assert.match(detailViewScript, /canEditApplicationDetailItem,/)
assert.match(detailViewScript, /openApplicationDetailEditor,/)
assert.match(detailViewScript, /saveApplicationDetailEdit,/)
})
test('application detail does not show optional travel receipt reminders', () => {

View File

@@ -93,6 +93,66 @@ test('workbench steward application confirmation opens inline application previe
assert.equal(preview.fields.transportMode, '')
})
test('workbench low-confidence application confirmation forwards remaining tasks', () => {
let previewCall = null
const remainingTasks = [{
task_id: 'task-reimbursement-2',
task_type: 'reimbursement',
assigned_agent: 'reimbursement_assistant',
ontology_fields: {
expense_type: 'entertainment',
expense_type_label: '业务招待费',
amount: '2000元',
time_range: '2026-06-25',
reason: '业务招待'
}
}]
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {},
startAiApplicationPreview: (...args) => {
previewCall = args
}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction({
label: '确认发起出差申请',
action_type: 'ai_application_confirm_intent',
payload: {
sourceText: '2月20-23日去上海出差3天服务国网服务器部署并且报销昨天的业务招待费2000元',
ontologyFields: { location: '上海', reason: '服务国网服务器部署' },
stewardRemainingTasks: remainingTasks
}
})
assert.ok(previewCall, 'startAiApplicationPreview 应被调用')
assert.deepEqual(previewCall[3].stewardRemainingTasks, remainingTasks)
// 低置信确认按钮只在 task1 完成后推进 task2,不再在预览生成时提前推进。
assert.equal(previewCall[3].onPreviewReadyForNextTask, undefined)
assert.equal(typeof previewCall[3].onApplicationActionCompleted, 'function')
})
test('workbench reimbursement skip link action opens new reimbursement flow', () => {
let sceneSelectionPayload = null
let fallbackConversationStarted = false
@@ -389,3 +449,70 @@ test('workbench steward executable submit action runs precheck before submit and
globalThis.fetch = originalFetch
}
})
test('workbench steward continue-next-task reimbursement prefills ontology and forwards remaining tasks', () => {
let expenseDraftCall = null
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
promptAiReimbursementDraftContinuation: () => {},
promptStandaloneReimbursementDraftCreation: () => {},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: (expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options) => {
expenseDraftCall = { expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options }
},
startAiReimbursementAssociationGate: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction({
label: '继续处理费用报销',
action_type: 'steward_continue_next_task',
payload: {
steward_confirm_flow: true,
flow_id: 'travel_reimbursement',
steward_current_task: {
task_id: 'task-meal-1',
task_type: 'reimbursement',
title: '业务招待费报销',
summary: '报销昨天业务招待费2000元',
ontology_fields: {
expense_type: 'meal',
expense_type_label: '业务招待费',
amount: '2000元',
time_range: '昨天',
reason: '客户招待'
}
},
steward_remaining_tasks: []
}
})
// task2(招待费报销)启动时:费用类型正确、语义预填到草稿、remaining tasks 透传
assert.ok(expenseDraftCall, 'startAiExpenseDraft 应被调用')
assert.equal(expenseDraftCall.expenseType, 'meal')
assert.equal(expenseDraftCall.expenseTypeLabel, '业务招待费')
assert.equal(expenseDraftCall.requiresApplicationBeforeReimbursement, true)
assert.equal(expenseDraftCall.options.prefillValues.amount, '2000元')
assert.equal(expenseDraftCall.options.prefillValues.reason, '客户招待')
assert.equal(expenseDraftCall.options.prefillValues.time_range, '昨天')
assert.deepEqual(expenseDraftCall.options.stewardRemainingTasks, [])
})

View File

@@ -19,7 +19,7 @@ function createInlineMessage(role, content, options = {}) {
}
}
function buildApplicationPreviewFlowHarness(messages) {
function buildApplicationPreviewFlowHarness(messages, options = {}) {
const conversationMessages = createRef(messages)
const applicationSubmitConfirmOpen = createRef(false)
const applicationSubmitConfirmContext = createRef(null)
@@ -69,7 +69,8 @@ function buildApplicationPreviewFlowHarness(messages) {
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23去上海出差交通火车保存草稿',
scrollInlineConversationToBottom: () => {},
sending: createRef(false),
toast: () => {}
toast: () => {},
onApplicationActionCompleted: options.onApplicationActionCompleted
})
return {
@@ -80,6 +81,129 @@ function buildApplicationPreviewFlowHarness(messages) {
}
}
test('workbench auto-saved application draft continues remaining steward task', async () => {
const originalFetch = globalThis.fetch
const requests = []
globalThis.fetch = async (url, options = {}) => {
const normalizedUrl = String(url)
if (normalizedUrl.includes('/reimbursements/application-preview-action')) {
const body = JSON.parse(String(options.body || '{}'))
requests.push({ url: normalizedUrl, body })
return {
ok: true,
async json() {
return {
status: 'succeeded',
result: {
draft_payload: {
claim_id: 'claim-auto-saved-draft',
claim_no: 'AEW2DDAFL',
status: 'draft'
}
}
}
}
}
}
throw new Error(`unexpected request: ${normalizedUrl}`)
}
try {
const continuedTasks = []
const remainingTasks = [{
task_id: 'task-reimbursement-2',
task_type: 'reimbursement',
assigned_agent: 'reimbursement_assistant',
summary: '报销昨天的业务招待费 2000 元',
ontology_fields: {
expense_type: 'entertainment',
amount: '2000元',
time_range: '2026-06-25',
reason: '业务招待费报销'
}
}]
const harness = buildApplicationPreviewFlowHarness([], {
onApplicationActionCompleted: (tasks, sourceMessage) => {
continuedTasks.push({ tasks, sourceMessage })
}
})
await harness.flow.startAiApplicationPreview('travel', '差旅费', '2月20-23日去上海出差3天服务国网服务器部署并且报销昨天的业务招待费2000元', {
autoSaveDraft: true,
stewardRemainingTasks: remainingTasks,
onApplicationActionCompleted: (tasks, sourceMessage) => {
continuedTasks.push({ tasks, sourceMessage, fromOptions: true })
}
})
assert.equal(requests.length, 1)
assert.equal(continuedTasks.length, 1)
assert.deepEqual(continuedTasks[0].tasks, remainingTasks)
assert.equal(continuedTasks[0].sourceMessage.stewardRemainingTasks, remainingTasks)
assert.equal(continuedTasks[0].fromOptions, true)
assert.doesNotMatch(harness.conversationMessages.value.at(-1).content, /继续处理费用报销/)
} finally {
globalThis.fetch = originalFetch
}
})
test('workbench application preview does not continue next task until draft is saved or submitted', async () => {
// 时序回归:task1 申请核对表刚生成、用户还没点保存草稿/提交时,
// 不能提前拉起 task2(会导致两条流程消息和状态互相打架,最终 task2 无反应)。
// task2 的推进必须等 task1 真正完成(onApplicationActionCompleted)后再触发。
const originalFetch = globalThis.fetch
globalThis.fetch = async (url) => {
if (String(url).includes('/reimbursements/application-preview-action')) {
return {
ok: true,
async json() {
return { status: 'succeeded', result: { draft_payload: { claim_id: 'c1', claim_no: 'AEW2', status: 'draft' } } }
}
}
}
throw new Error(`unexpected request: ${url}`)
}
try {
const continuedTasks = []
const remainingTasks = [{
task_id: 'task-reimbursement-2',
task_type: 'reimbursement',
assigned_agent: 'reimbursement_assistant',
summary: '报销昨天的业务招待费 2000 元',
ontology_fields: { expense_type: 'entertainment', amount: '2000元', time_range: '2026-06-29', reason: '业务招待费报销' }
}]
const harness = buildApplicationPreviewFlowHarness([], {
onApplicationActionCompleted: (tasks) => { continuedTasks.push({ tasks, phase: 'module' }) }
})
// 第一步:生成申请核对表(不传 autoSaveDraft,模拟用户需要手动操作 task1)
await harness.flow.startAiApplicationPreview('travel', '差旅费', '2月20-23去上海出差并且报销昨天招待费2000元', {
stewardRemainingTasks: remainingTasks,
onApplicationActionCompleted: (tasks) => { continuedTasks.push({ tasks, phase: 'options' }) }
})
// 预览生成后,task2 不应被提前拉起
assert.equal(continuedTasks.length, 0, '预览生成时不应触发 task2 推进回调')
const previewMessage = harness.conversationMessages.value.find((m) => m.applicationPreview)
assert.equal(previewMessage?.stewardRemainingTasks?.length, 1, 'task2 应仍挂在核对表消息上等待用户完成 task1')
// 第二步:用户手动点击"保存草稿"(走 actionRouter,不传 options.onApplicationActionCompleted),
// 此时回落到模块级 onApplicationActionCompleted 触发 task2,这正是真实运行时的续跑路径。
await harness.flow.executeInlineApplicationPreviewAction('save_draft', previewMessage, {
userText: '保存草稿',
draftPayload: null
})
assert.equal(continuedTasks.length, 1, '保存草稿完成后应推进 task2')
assert.deepEqual(continuedTasks[0].tasks, remainingTasks)
assert.equal(continuedTasks[0].phase, 'module', '手动保存草稿走模块级续跑回调')
assert.doesNotMatch(harness.conversationMessages.value.at(-1).content, /继续处理费用报销/, '自动续跑时不展示重复的继续处理按钮')
} finally {
globalThis.fetch = originalFetch
}
})
test('workbench saved application draft can be submitted by contextual text without re-planning', async () => {
const originalFetch = globalThis.fetch
const requests = []

View File

@@ -4,8 +4,10 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildWorkbenchDocumentCommandFollowupGuidance,
buildWorkbenchDraftDeletionGuidance,
isWorkbenchDraftDeletionIntent,
resolveLatestWorkbenchDocumentCommandContext,
resolveLatestWorkbenchDraftPayload
} from '../src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js'
@@ -87,8 +89,45 @@ test('workbench draft deletion guidance opens detail instead of deleting directl
assert.equal(guidance.suggestedActions[0].payload.claim_no, 'ALATEST1')
})
test('workbench command intent reuses previous approval candidates for follow-up approval command', () => {
const context = resolveLatestWorkbenchDocumentCommandContext([
{
role: 'assistant',
content: [
'### 已查询到相关单据',
'',
'<article class="ai-document-card ai-document-card--application ai-document-card--approval-task is-pending">',
'<a class="ai-document-card__action" href="#ai-open-document-detail:claim_id%3Dapproval-1%26claim_no%3DAP-APPROVAL-001">查看详情</a>',
'</article>',
'<article class="ai-document-card ai-document-card--reimbursement ai-document-card--approval-task is-pending">',
'<a class="ai-document-card__action" href="#ai-open-document-detail:claim_id%3Dapproval-2%26claim_no%3DRE-APPROVAL-002">查看详情</a>',
'</article>'
].join('\n')
}
], { action: 'approve', safetyLevel: 'confirm_required' })
assert.equal(context?.candidates.length, 2)
assert.deepEqual(context.candidates[0], {
claimId: 'approval-1',
claimNo: 'AP-APPROVAL-001',
documentType: 'application',
actionLabel: '查看详情'
})
const guidance = buildWorkbenchDocumentCommandFollowupGuidance(context, { action: 'approve' })
assert.match(guidance.content, /已接上刚才查询到的待审单据/)
assert.match(guidance.content, /AP-APPROVAL-001/)
assert.match(guidance.content, /RE-APPROVAL-002/)
assert.equal(guidance.suggestedActions.length, 2)
assert.equal(guidance.suggestedActions[0].action_type, 'open_application_detail')
assert.equal(guidance.suggestedActions[0].payload.claim_id, 'approval-1')
assert.equal(guidance.suggestedActions[0].payload.command_action, 'approve')
})
test('workbench draft deletion intent is wired before draft slot continuation', () => {
assert.match(commandIntentsScript, /isWorkbenchDraftDeletionIntent/)
assert.match(commandIntentsScript, /resolveLatestWorkbenchDocumentCommandContext/)
assert.match(commandIntentsScript, /buildWorkbenchDocumentCommandFollowupGuidance/)
assert.match(commandIntentsScript, /function handleInlineDraftDeletionIntent\(cleanPrompt, entry = \{\}\)/)
assert.match(commandIntentsScript, /resolveLatestWorkbenchDraftPayload\(conversationMessages\.value\)/)
assert.match(commandIntentsScript, /buildWorkbenchDraftDeletionGuidance\(draftPayload\)/)

View File

@@ -79,6 +79,48 @@ test('workbench AI intent planner normalizes model travel application submit pla
})
})
test('workbench AI intent planner keeps reimbursement task after first application task', () => {
const reimbursementTask = {
task_id: 'task-reimbursement-2',
task_type: 'reimbursement',
assigned_agent: 'reimbursement_assistant',
title: '业务招待费报销',
summary: '报销昨天的业务招待费 2000 元',
requested_action: 'preview',
confidence: 0.9,
ontology_fields: {
expense_type: 'entertainment',
expense_type_label: '业务招待费',
time_range: '2026-06-25',
amount: '2000元',
reason: '业务招待'
},
missing_fields: []
}
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'llm_function_call',
tasks: [{
task_id: 'task-application-1',
task_type: 'expense_application',
assigned_agent: 'application_assistant',
requested_action: 'preview',
confidence: 0.93,
ontology_fields: {
expense_type: 'travel',
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '服务国网服务器部署'
},
missing_fields: ['transport_mode']
}, reimbursementTask]
}, {
prompt: '2月20-23日去上海出差3天服务国网服务器部署并且报销昨天的业务招待费2000元'
})
assert.deepEqual(plan.stewardRemainingTasks, [reimbursementTask])
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan).stewardRemainingTasks, [reimbursementTask])
})
test('workbench AI intent planner prefers server action steps when present', () => {
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'llm_function_call',
@@ -304,7 +346,16 @@ test('workbench AI mode asks steward model plan before fallback execution', () =
assert.match(personalWorkbenchAiModeScript, /requestedSubmit:\s*travelApplicationRequest\.requestedSubmit/)
assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/)
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
assert.match(personalWorkbenchAiModeScript, /stewardRemainingTasks:\s*travelApplicationRequest\.stewardRemainingTasks/)
assert.match(personalWorkbenchAiModeScript, /onApplicationActionCompleted:\s*startModelPlannedNextTask/)
// 多 task 串行推进:预览生成时不再提前拉起下一个 task(会与用户在 task1 上的操作互相打架),
// 改为只在 task1 完成(保存草稿/提交)后通过 onApplicationActionCompleted 推进 task2。
assert.doesNotMatch(personalWorkbenchAiModeScript, /onPreviewReadyForNextTask/)
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
assert.doesNotMatch(applicationPreviewFlowScript, /onPreviewReadyForNextTask/)
assert.match(applicationPreviewFlowScript, /const actionCompletedHandler = typeof options\.onApplicationActionCompleted === 'function'/)
assert.match(applicationPreviewFlowScript, /actionCompletedHandler\(targetMessage\.stewardRemainingTasks/)
assert.match(applicationPreviewFlowScript, /onApplicationActionCompleted:\s*options\.onApplicationActionCompleted/)
assert.doesNotMatch(applicationPreviewFlowScript, /options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit/)
assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/)
assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)