Compare commits
14 Commits
2ebc2756bf
...
08f023243e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f023243e | ||
|
|
6bdaeed6d4 | ||
|
|
d5a8f84703 | ||
|
|
c4b5fcc067 | ||
|
|
5753899eb3 | ||
|
|
9c3fa80d22 | ||
|
|
43c3ff860c | ||
|
|
3e4b1e1597 | ||
|
|
3a5664c4da | ||
|
|
d139a63e64 | ||
|
|
8a2ae6eb75 | ||
|
|
992cf71fa1 | ||
|
|
54356ba81a | ||
|
|
e9d7c56d5b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,6 +35,7 @@ test-results/
|
||||
.codex-remote-attachments/
|
||||
tmp-*.png
|
||||
tmp/
|
||||
.zcode/
|
||||
.nezha/
|
||||
.omo/
|
||||
.env
|
||||
|
||||
@@ -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:13002;upstream `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 工作台先查询待审/审核单后,再说“请帮我审核通过”或类似审批命令时,系统会接上刚才候选并要求进入详情确认,不会把二轮命令当成孤立查询或静默失智;仍保留高风险审批动作不直接执行的安全边界。
|
||||
@@ -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 模式欢迎区光球不再显示矩形闪动边框,动感/专业智能主题保持原有光球表现。
|
||||
@@ -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:13002;upstream `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:13002;upstream `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:13002;upstream `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 不再因为旧按钮处理函数缺失而白屏,内联铅笔编辑入口保留。
|
||||
@@ -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 等具有边框线的额外容器。
|
||||
@@ -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 输出格式,消除不必要的 `>` 引用块,攻克“三条大竖杠”的排版痛点。
|
||||
@@ -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 风格。
|
||||
@@ -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: 风险与开放问题] 若第三类主题命名发生变化,同步更新概念文档和测试描述。
|
||||
@@ -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元金额、时间、事由会预填到报销草稿;招待费需要前置招待申请单(业务规则保留),查不到时按钮文案按类型动态展示并承接语义,发起申请单后能回到 task2;3+ 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;用户截图里的“申请草稿已保存”不再是终点,后续业务招待费报销会自动进入现有报销流程。
|
||||
17
document/development/2026-06-26/work-logs.med
Normal file
17
document/development/2026-06-26/work-logs.med
Normal 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 文件追溯证据。
|
||||
17
document/development/2026-06-27/work-logs.med
Normal file
17
document/development/2026-06-27/work-logs.med
Normal file
@@ -0,0 +1,17 @@
|
||||
# 2026-06-27 综合工作日志
|
||||
|
||||
生成时间:2026-06-27 17:43:24 CST
|
||||
来源:`feature/` 功能点文档与 `dev-logs/bugs/` bug 记录
|
||||
|
||||
## 今日功能点
|
||||
|
||||
- 今日未发现功能点文档。
|
||||
|
||||
## 今日 Bugs
|
||||
|
||||
- 今日未发现 bug 修复记录。
|
||||
|
||||
## 综合分析
|
||||
|
||||
- 今日目录下暂无功能点或 bug 记录。
|
||||
- 后续复盘优先看本文件,再回到对应功能点或 bug 文件追溯证据。
|
||||
17
document/development/2026-06-28/work-logs.med
Normal file
17
document/development/2026-06-28/work-logs.med
Normal file
@@ -0,0 +1,17 @@
|
||||
# 2026-06-28 综合工作日志
|
||||
|
||||
生成时间:2026-06-28 18:30:35 CST
|
||||
来源:`feature/` 功能点文档与 `dev-logs/bugs/` bug 记录
|
||||
|
||||
## 今日功能点
|
||||
|
||||
- 今日未发现功能点文档。
|
||||
|
||||
## 今日 Bugs
|
||||
|
||||
- 今日未发现 bug 修复记录。
|
||||
|
||||
## 综合分析
|
||||
|
||||
- 今日目录下暂无功能点或 bug 记录。
|
||||
- 后续复盘优先看本文件,再回到对应功能点或 bug 文件追溯证据。
|
||||
243
document/development/AI意图规划器/UNIFIED_GATE_PIPELINE.md
Normal file
243
document/development/AI意图规划器/UNIFIED_GATE_PIPELINE.md
Normal 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 不变 |
|
||||
@@ -56,6 +56,52 @@
|
||||
- 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 挂回 plan,planning_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 task(fields 完整);纯函数验证确定性兜底在模型返回空 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_state),endpoint 退化为 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 passed,scene 注册表的加入未破坏任何现有代码。
|
||||
- 影响:为后续图拓扑重构(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.7),LangGraph 成为唯一编排者。
|
||||
- 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)`(LangGraph)vs `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 技能校验)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,8 +6,8 @@ from pydantic import BaseModel, Field
|
||||
|
||||
StewardTaskType = str
|
||||
StewardAssignedAgent = str
|
||||
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"]
|
||||
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"]
|
||||
|
||||
84
server/src/app/services/scenes/__init__.py
Normal file
84
server/src/app/services/scenes/__init__.py
Normal 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()
|
||||
38
server/src/app/services/scenes/gate_rules.py
Normal file
38
server/src/app/services/scenes/gate_rules.py
Normal 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"
|
||||
"""走候选流程确认。"""
|
||||
63
server/src/app/services/scenes/scene_descriptor.py
Normal file
63
server/src/app/services/scenes/scene_descriptor.py
Normal 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 分发。"""
|
||||
38
server/src/app/services/scenes/scene_expense_application.py
Normal file
38
server/src/app/services/scenes/scene_expense_application.py
Normal 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,
|
||||
)
|
||||
)
|
||||
@@ -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 场景优先,确保查询类先被规则命中
|
||||
)
|
||||
)
|
||||
110
server/src/app/services/scenes/scene_registry.py
Normal file
110
server/src/app/services/scenes/scene_registry.py
Normal 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)
|
||||
41
server/src/app/services/scenes/scene_reimbursement.py
Normal file
41
server/src/app/services/scenes/scene_reimbursement.py
Normal 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,
|
||||
)
|
||||
)
|
||||
@@ -93,6 +93,18 @@ MODEL_SLOT_CONFIGS = {
|
||||
priority=40,
|
||||
),
|
||||
}
|
||||
|
||||
MODEL_TYPE_TO_CAPABILITY = {
|
||||
"llm": "chat",
|
||||
"embedding": "embedding",
|
||||
"rerank": "reranker",
|
||||
}
|
||||
|
||||
MODEL_CAPABILITY_TO_TYPE = {
|
||||
"chat": "llm",
|
||||
"embedding": "embedding",
|
||||
"reranker": "rerank",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -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,
|
||||
|
||||
170
server/src/app/services/steward_context_resume.py
Normal file
170
server/src/app/services/steward_context_resume.py
Normal 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()
|
||||
)
|
||||
@@ -1,14 +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
|
||||
@@ -16,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
|
||||
@@ -26,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,
|
||||
@@ -40,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": "",
|
||||
}
|
||||
@@ -55,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,
|
||||
@@ -71,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)
|
||||
@@ -175,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"
|
||||
@@ -188,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 ""),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,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,
|
||||
@@ -134,6 +143,11 @@ class StewardIntentAgent:
|
||||
"每个 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 只能是面向用户的过程摘要,不能暴露内部推理链。"
|
||||
"如果用户输入与出差、费用、报销、申请、差旅标准等财务事项完全无关"
|
||||
|
||||
@@ -146,8 +146,48 @@ def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None:
|
||||
assert runtime_model["endpoint"] == "https://api.minimaxi.com/v1"
|
||||
assert runtime_model["apiKey"] == "shared-main-key"
|
||||
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"
|
||||
|
||||
130
server/tests/test_steward_context_resume.py
Normal file
130
server/tests/test_steward_context_resume.py
Normal 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
|
||||
@@ -98,3 +98,58 @@ def test_steward_intent_system_prompt_mentions_query_intent_guidance() -> None:
|
||||
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", []) == []
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,10 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
autoSubmit: travelApplicationRequest.autoSubmit,
|
||||
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
|
||||
requestedSubmit: travelApplicationRequest.requestedSubmit,
|
||||
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation
|
||||
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
|
||||
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
|
||||
onPreviewReadyForNextTask: startModelPlannedNextTask,
|
||||
onApplicationActionCompleted: startModelPlannedNextTask
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -741,7 +785,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, {
|
||||
|
||||
@@ -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,20 @@ export function useWorkbenchAiActionRouter({
|
||||
autoSubmit: Boolean(actionPayload.autoSubmit),
|
||||
autoSaveDraft: Boolean(actionPayload.autoSaveDraft),
|
||||
requestedSubmit: Boolean(actionPayload.requestedSubmit),
|
||||
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation)
|
||||
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation),
|
||||
stewardRemainingTasks,
|
||||
onPreviewReadyForNextTask: (remainingTasks = []) => {
|
||||
const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks })
|
||||
if (nextTaskAction) {
|
||||
handleInlineSuggestedAction(nextTaskAction)
|
||||
}
|
||||
},
|
||||
onApplicationActionCompleted: (remainingTasks = []) => {
|
||||
const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks })
|
||||
if (nextTaskAction) {
|
||||
handleInlineSuggestedAction(nextTaskAction)
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -104,9 +122,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 +144,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 +206,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 +275,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 +423,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
|
||||
}
|
||||
|
||||
@@ -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,8 +616,15 @@ 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
|
||||
})
|
||||
} else if (
|
||||
typeof options.onPreviewReadyForNextTask === 'function' &&
|
||||
Array.isArray(previewMessage.stewardRemainingTasks) &&
|
||||
previewMessage.stewardRemainingTasks.length
|
||||
) {
|
||||
options.onPreviewReadyForNextTask(previewMessage.stewardRemainingTasks, previewMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
||||
|
||||
@@ -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'
|
||||
? '正在先筛选候选单据,不会直接执行删除或审核动作...'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 : []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -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(
|
||||
'',
|
||||
'> **请先核对**:请先核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先查看或处理已有申请单,避免重复申请。',
|
||||
'**后续行动建议**:',
|
||||
'- 请核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
|
||||
'- 若日期无误,请先查看或处理已有申请单,避免重复申请。',
|
||||
'',
|
||||
'我会先暂停本次提交,不会生成新的审批流。'
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 : []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。'
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ export default {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
initialDraftPayload: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
initialFiles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: '李文静',
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
73
web/tests/settings-theme-section.test.mjs
Normal file
73
web/tests/settings-theme-section.test.mjs
Normal 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()
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -93,6 +93,65 @@ 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)
|
||||
assert.equal(typeof previewCall[3].onPreviewReadyForNextTask, 'function')
|
||||
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 +448,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, [])
|
||||
})
|
||||
|
||||
@@ -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,72 @@ 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 saved application draft can be submitted by contextual text without re-planning', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests = []
|
||||
|
||||
@@ -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\)/)
|
||||
|
||||
@@ -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,14 @@ 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, /onPreviewReadyForNextTask:\s*startModelPlannedNextTask/)
|
||||
assert.match(personalWorkbenchAiModeScript, /onApplicationActionCompleted:\s*startModelPlannedNextTask/)
|
||||
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
|
||||
assert.match(applicationPreviewFlowScript, /options\.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/)
|
||||
|
||||
Reference in New Issue
Block a user