Compare commits
17 Commits
2ebc2756bf
...
fix/multi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ced9d93bc | ||
|
|
876cf342ac | ||
|
|
43779f8f2c | ||
|
|
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/
|
.codex-remote-attachments/
|
||||||
tmp-*.png
|
tmp-*.png
|
||||||
tmp/
|
tmp/
|
||||||
|
.zcode/
|
||||||
.nezha/
|
.nezha/
|
||||||
.omo/
|
.omo/
|
||||||
.env
|
.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 文件追溯证据。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 多 task 串行推进时 task2 无法启动(onPreviewReadyForNextTask 时序缺陷)
|
||||||
|
|
||||||
|
日期:2026-06-30
|
||||||
|
文档路径:document/development/2026-06-30/dev-logs/bugs/multi-task-next-task-blocked-by-preview-ready-timing.md
|
||||||
|
|
||||||
|
## 修复记录
|
||||||
|
- 11:18:记录 bug 修复:用户在 AI 工作台输入"出差申请 + 招待费报销"等多 task 时,task1(出差申请)保存草稿/提交成功后,task2(招待费报销)完全无法启动,界面停在"申请草稿已保存"。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main`;`HEAD..@{u}` 未发现 upstream 新提交;`@{u}..HEAD` 未发现本地 ahead 提交。工作区改动仅为本次 3 个源文件 + 2 个测试文件(另有一个预先存在的未提交改动 `server/rules/finance-rules/公司通信费报销规则.xlsx`,与本次无关)。
|
||||||
|
- 根因:`onPreviewReadyForNextTask` 回调在 task1 申请核对表**刚生成、用户还没看、还没点保存草稿**时就立刻触发 `startModelPlannedNextTask`,提前把 task2 招待费报销拉起(`startAiExpenseDraft` 会 push 一条"选择费用报销"用户消息 + 报销 prompt)。两条流程的消息和状态互相打架,用户再在 task1 上点保存草稿时 `onApplicationActionCompleted` 又试图拉起 task2,但 task2 状态已被前面 `onPreviewReadyForNextTask` 搞乱,最终表现为"完全无反应"。运行时复现脚本时序铁证:预览生成后立即出现 `!!! onPreviewReadyForNextTask 触发(task1预览刚生成,用户还没操作)`,与串行推进的正确语义(task1 完成后才推进 task2)冲突。这是早期实现的残留——引入 `onApplicationActionCompleted`(task1 完成后触发)后,`onPreviewReadyForNextTask` 职责重叠且时序错误。
|
||||||
|
- 修改(前端 web,3 个源文件):
|
||||||
|
- `web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js`:删除 `startAiApplicationPreview` 预览生成后的 `else if (onPreviewReadyForNextTask ...)` 提前推进分支(原 L622-L628),并加注释说明 task2 推进统一交给 `onApplicationActionCompleted` 在 task1 真正完成后触发。`executeInlineApplicationPreviewAction` 里 L466 的 `actionCompletedHandler` 回落逻辑保留不动(手动点保存草稿走 actionRouter 不传 options 回调,回落到模块级 `startModelPlannedNextTask`,这是正确的续跑路径)。
|
||||||
|
- `web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js`:`startModelPlannedApplicationPreview` 调用 `startAiApplicationPreview` 时删除 `onPreviewReadyForNextTask: startModelPlannedNextTask` 一行,只保留 `onApplicationActionCompleted: startModelPlannedNextTask`(原 L769)。
|
||||||
|
- `web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js`:`ai_application_confirm_intent`(低置信确认按钮)分支删除 `onPreviewReadyForNextTask` 回调,只保留 `onApplicationActionCompleted`(原 L99-L104),消除低置信路径的同样时序缺陷。
|
||||||
|
- 操作:先写复现脚本 `/tmp/repro-timing.mjs`(applicationFlow + 两个回调)锁定时序根因——修复前预览生成后立即触发推进回调,修复后无提前触发;再按计划小步改 3 个源文件 + 2 个测试文件;未提交(工作区有预先存在的无关 xlsx 改动,未自动提交)。
|
||||||
|
- 验证:宿主机 node v22.22.3 跑 `node --test web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-action-router.test.mjs web/tests/workbench-ai-application-context-submit.test.mjs` 通过 27/27(含新增的时序回归用例 `workbench application preview does not continue next task until draft is saved or submitted`:断言预览生成时 `continuedTasks.length === 0`、保存草稿后才推进 task2 且走模块级续跑回调、自动续跑时不展示重复的"继续处理"按钮);`npm --prefix web run build` 通过(3.97s);复现脚本 `/tmp/repro-timing.mjs` 修复后事件序列只剩用户消息、无提前推进;真实 `http://localhost:5173/api/v1/steward/plans` 与 `/api/v1/steward/plans/stream` 采样确认该句子仍返回 `expense_application` + `reimbursement` 两个 task(后端拆分正确,本次未动后端)。
|
||||||
|
- 影响:用户输入框提交"2月20-23日,去上海出差辅助国网仿生产服务器部署,并且报销昨天的上午招待费2000元"等多 task 时,task1 出差申请核对表生成后干净停下等用户操作,用户点保存草稿/直接提交成功后自动进入 task2 招待费报销(预填金额/时间/事由),不再出现两条流程打架导致 task2 完全无反应的问题。不影响后端、单 task 场景、autoSaveDraft 路径(它走 `executeInlineApplicationPreviewAction` 完成后触发 `onApplicationActionCompleted`,链路不变);低置信确认按钮路径也同步修复。
|
||||||
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:`server/rules/finance-rules/` 下有两个 Excel(交通工具等级标准、交通费用预估表)被标记为 modified,疑似容器运行时产物,非本次代码改动,未处理。
|
||||||
- 22:40:`agent-change-log` Skill 在当前环境不可调用,已按 AGENTS.md 规范手动增量更新本日志。
|
- 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
|
## TODO
|
||||||
|
|
||||||
- [ ] 为 `quick_validate.py` 准备稳定运行环境,避免后续新增 Skill 时继续依赖人工兜底。(来源:09:18 技能校验)
|
- [ ] 为 `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_claim_draft_flow import APPROVED_APPLICATION_LINK_STATUSES
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.runtime_chat import RuntimeChatService
|
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_flow_state import StewardFlowStateService
|
||||||
from app.services.steward_graph_action_runtime import StewardGraphActionRuntime
|
from app.services.steward_graph_action_runtime import StewardGraphActionRuntime
|
||||||
from app.services.steward_graph_planner import StewardGraphPlannerService
|
from app.services.steward_graph_planner import StewardGraphPlannerService
|
||||||
@@ -61,6 +65,9 @@ def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPl
|
|||||||
try:
|
try:
|
||||||
planner = _build_steward_planner(db)
|
planner = _build_steward_planner(db)
|
||||||
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
|
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
|
||||||
|
if isinstance(planner, StewardGraphPlannerService):
|
||||||
|
plan = planner.build_plan(hydrated_payload, db=db)
|
||||||
|
else:
|
||||||
plan = planner.build_plan(hydrated_payload)
|
plan = planner.build_plan(hydrated_payload)
|
||||||
return _attach_conversation_state(db, hydrated_payload, plan)
|
return _attach_conversation_state(db, hydrated_payload, plan)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -143,6 +150,9 @@ async def _iter_steward_plan_events(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
|
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
|
||||||
|
if isinstance(planner, StewardGraphPlannerService):
|
||||||
|
plan = planner.build_plan(hydrated_payload, db=db)
|
||||||
|
else:
|
||||||
plan = planner.build_plan(hydrated_payload)
|
plan = planner.build_plan(hydrated_payload)
|
||||||
plan = _attach_conversation_state(db, hydrated_payload, plan)
|
plan = _attach_conversation_state(db, hydrated_payload, plan)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -495,3 +505,66 @@ def _resolve_current_steward_state(
|
|||||||
return stored_state
|
return stored_state
|
||||||
incoming_state = context_json.get("steward_state") or context_json.get("stewardState")
|
incoming_state = context_json.get("steward_state") or context_json.get("stewardState")
|
||||||
return incoming_state if isinstance(incoming_state, dict) else {}
|
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)
|
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):
|
class SettingsLlmForm(BaseModel):
|
||||||
mainProvider: str = Field(min_length=1, max_length=64)
|
mainProvider: str = Field(min_length=1, max_length=64)
|
||||||
mainModel: str = Field(min_length=1, max_length=255)
|
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)
|
rerankerEndpoint: str = Field(min_length=1, max_length=512)
|
||||||
rerankerApiKey: str = Field(default="", max_length=1024)
|
rerankerApiKey: str = Field(default="", max_length=1024)
|
||||||
rerankerApiKeyConfigured: bool = False
|
rerankerApiKeyConfigured: bool = False
|
||||||
|
models: list[SettingsModelRow] = Field(default_factory=list)
|
||||||
|
|
||||||
@field_validator(
|
@field_validator(
|
||||||
"mainProvider",
|
"mainProvider",
|
||||||
@@ -201,7 +219,7 @@ class ModelConnectivityTestRequest(BaseModel):
|
|||||||
model: str = Field(min_length=1, max_length=255)
|
model: str = Field(min_length=1, max_length=255)
|
||||||
api_key: str | None = Field(default=None, max_length=1024)
|
api_key: str | None = Field(default=None, max_length=1024)
|
||||||
capability: Literal["chat", "embedding", "reranker"] = "chat"
|
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")
|
@field_validator("provider", "endpoint", "model", "api_key", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -234,7 +252,7 @@ class SettingsCacheClearRead(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RuntimeModelConfigRead(BaseModel):
|
class RuntimeModelConfigRead(BaseModel):
|
||||||
slot: Literal["main", "backup", "embedding", "reranker"]
|
slot: str
|
||||||
provider: str
|
provider: str
|
||||||
model: str
|
model: str
|
||||||
endpoint: str
|
endpoint: str
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
StewardTaskType = str
|
StewardTaskType = str
|
||||||
StewardAssignedAgent = str
|
StewardAssignedAgent = str
|
||||||
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
|
StewardPlanningSource = str # 放宽:支持 llm_function_call / rule_fallback / scene_handler:* / context_resume
|
||||||
StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"]
|
StewardPlanNextAction = str # 放宽:支持 confirm_flow / confirm_task / delegate_task / none / answer_only
|
||||||
StewardRequestedAction = Literal["preview", "save_draft", "submit"]
|
StewardRequestedAction = Literal["preview", "save_draft", "submit"]
|
||||||
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
|
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
|
||||||
StewardSlotNextAction = Literal["ask_user", "render_preview"]
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -94,6 +94,18 @@ MODEL_SLOT_CONFIGS = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MODEL_TYPE_TO_CAPABILITY = {
|
||||||
|
"llm": "chat",
|
||||||
|
"embedding": "embedding",
|
||||||
|
"rerank": "reranker",
|
||||||
|
}
|
||||||
|
|
||||||
|
MODEL_CAPABILITY_TO_TYPE = {
|
||||||
|
"chat": "llm",
|
||||||
|
"embedding": "embedding",
|
||||||
|
"reranker": "rerank",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AdminCredentialRecord:
|
class AdminCredentialRecord:
|
||||||
@@ -110,6 +122,26 @@ class OnlyOfficeRuntimeConfig:
|
|||||||
jwt_secret: str
|
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:
|
class SettingsService:
|
||||||
_schema_ready_lock = threading.Lock()
|
_schema_ready_lock = threading.Lock()
|
||||||
_schema_ready_keys: set[tuple[str, int]] = set()
|
_schema_ready_keys: set[tuple[str, int]] = set()
|
||||||
@@ -282,6 +314,8 @@ class SettingsService:
|
|||||||
payload.llmForm.rerankerEndpoint,
|
payload.llmForm.rerankerEndpoint,
|
||||||
payload.llmForm.rerankerApiKey,
|
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:
|
if payload.renderForm.enabled and not payload.renderForm.publicUrl:
|
||||||
raise ValueError("启用 ONLYOFFICE 时必须配置服务地址。")
|
raise ValueError("启用 ONLYOFFICE 时必须配置服务地址。")
|
||||||
@@ -367,31 +401,39 @@ class SettingsService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def load_saved_model_api_key(self, slot: str | None) -> str:
|
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 ""
|
return ""
|
||||||
|
|
||||||
settings_row, secrets_row = self.ensure_settings_ready()
|
settings_row, secrets_row = self.ensure_settings_ready()
|
||||||
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
|
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:
|
if not encrypted_value:
|
||||||
return ""
|
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]:
|
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("未知模型槽位。")
|
raise ValueError("未知模型槽位。")
|
||||||
|
|
||||||
settings_row, secrets_row = self.ensure_settings_ready()
|
settings_row, secrets_row = self.ensure_settings_ready()
|
||||||
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
|
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 {
|
return {
|
||||||
"slot": slot,
|
"slot": normalized_slot,
|
||||||
"provider": model_row.provider,
|
"provider": model_row.provider,
|
||||||
"model": model_row.model_name,
|
"model": model_row.model_name,
|
||||||
"endpoint": model_row.endpoint,
|
"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,
|
"capability": model_row.capability,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,9 +592,61 @@ class SettingsService:
|
|||||||
model_row.endpoint = endpoint
|
model_row.endpoint = endpoint
|
||||||
|
|
||||||
normalized_api_key = api_key.strip()
|
normalized_api_key = api_key.strip()
|
||||||
|
if normalized_api_key == "********":
|
||||||
|
return
|
||||||
if normalized_api_key:
|
if normalized_api_key:
|
||||||
model_row.api_key_encrypted = encrypt_secret(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:
|
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)
|
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,
|
"rerankerEndpoint": reranker_model.endpoint,
|
||||||
"rerankerApiKey": "",
|
"rerankerApiKey": "",
|
||||||
"rerankerApiKeyConfigured": bool(reranker_model.api_key_encrypted),
|
"rerankerApiKeyConfigured": bool(reranker_model.api_key_encrypted),
|
||||||
|
"models": serialize_model_rows(model_rows),
|
||||||
},
|
},
|
||||||
renderForm={
|
renderForm={
|
||||||
"enabled": settings_row.onlyoffice_enabled,
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any, TypedDict
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
from langgraph.graph import END, START, StateGraph
|
from langgraph.graph import END, START, StateGraph
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse
|
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse
|
||||||
from app.services import steward_intent_bootstrap # noqa: F401 导入即注册全部业务意图
|
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_action_contracts import StewardActionPlanBuilder
|
||||||
from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER
|
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_intent_agent import StewardIntentAgent, StewardIntentAgentResult
|
||||||
from app.services.steward_model_plan_builder import StewardModelPlanBuilder
|
from app.services.steward_model_plan_builder import StewardModelPlanBuilder
|
||||||
from app.services.steward_off_topic_agent import StewardOffTopicAgent
|
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
|
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):
|
class StewardGraphState(TypedDict, total=False):
|
||||||
request: StewardPlanRequest
|
request: StewardPlanRequest
|
||||||
|
db: Session
|
||||||
message: str
|
message: str
|
||||||
base_date: date
|
base_date: date
|
||||||
scenario: str | None
|
scenario: str | None
|
||||||
@@ -26,10 +52,25 @@ class StewardGraphState(TypedDict, total=False):
|
|||||||
plan: StewardPlanResponse
|
plan: StewardPlanResponse
|
||||||
model_call_traces: list[dict[str, Any]]
|
model_call_traces: list[dict[str, Any]]
|
||||||
fallback_reason: str
|
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):
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -40,10 +81,388 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
|
|||||||
self.off_topic_agent = off_topic_agent
|
self.off_topic_agent = off_topic_agent
|
||||||
self._graph = self._build_graph()
|
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(
|
final_state = self._graph.invoke(
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
|
"db": db,
|
||||||
"model_call_traces": [],
|
"model_call_traces": [],
|
||||||
"fallback_reason": "",
|
"fallback_reason": "",
|
||||||
}
|
}
|
||||||
@@ -55,13 +474,31 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
|
|||||||
|
|
||||||
def _build_graph(self):
|
def _build_graph(self):
|
||||||
graph = StateGraph(StewardGraphState)
|
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("prepare_context", self._prepare_context)
|
||||||
graph.add_node("detect_model_intent", self._detect_model_intent)
|
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_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_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_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(
|
graph.add_conditional_edges(
|
||||||
"prepare_context",
|
"prepare_context",
|
||||||
self._route_after_prepare_context,
|
self._route_after_prepare_context,
|
||||||
@@ -71,15 +508,19 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
|
|||||||
"fallback": "build_rule_fallback_plan",
|
"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(
|
graph.add_conditional_edges(
|
||||||
"detect_model_intent",
|
"detect_model_intent",
|
||||||
self._route_after_model_intent,
|
self._route_after_model_intent,
|
||||||
{
|
{
|
||||||
"done": "attach_action_steps",
|
"done": "attach_action_steps",
|
||||||
|
"handler_only": "execute_scene_handler",
|
||||||
"off_topic": "build_off_topic_plan",
|
"off_topic": "build_off_topic_plan",
|
||||||
"fallback": "build_rule_fallback_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_off_topic_plan", "attach_action_steps")
|
||||||
graph.add_edge("build_rule_fallback_plan", "attach_action_steps")
|
graph.add_edge("build_rule_fallback_plan", "attach_action_steps")
|
||||||
graph.add_edge("attach_action_steps", END)
|
graph.add_edge("attach_action_steps", END)
|
||||||
@@ -175,7 +616,17 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _route_after_model_intent(state: StewardGraphState) -> str:
|
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"
|
return "done"
|
||||||
if state.get("scenario") is not None:
|
if state.get("scenario") is not None:
|
||||||
return "off_topic"
|
return "off_topic"
|
||||||
@@ -188,8 +639,22 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
|
|||||||
return {
|
return {
|
||||||
"plan": self._build_off_topic_plan(
|
"plan": self._build_off_topic_plan(
|
||||||
state["request"],
|
state["request"],
|
||||||
scenario=str(state["scenario"] or ""),
|
scenario=str(state.get("scenario") or ""),
|
||||||
model_call_traces=state.get("model_call_traces"),
|
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 ""),
|
fallback_reason=str(state.get("fallback_reason") or ""),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,8 +103,17 @@ class StewardIntentAgent:
|
|||||||
"employee_grade",
|
"employee_grade",
|
||||||
"employee_no",
|
"employee_no",
|
||||||
"client_timezone_offset_minutes",
|
"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": [
|
"attachments": [
|
||||||
{
|
{
|
||||||
"index": index + 1,
|
"index": index + 1,
|
||||||
@@ -134,6 +143,11 @@ class StewardIntentAgent:
|
|||||||
"每个 task 必须输出 requested_action:用户只是要求整理/发起但未说保存或提交时为 preview;"
|
"每个 task 必须输出 requested_action:用户只是要求整理/发起但未说保存或提交时为 preview;"
|
||||||
"用户说保存草稿、先保存、存草稿时为 save_draft;用户说直接提交、提交申请、确认提交时为 submit。"
|
"用户说保存草稿、先保存、存草稿时为 save_draft;用户说直接提交、提交申请、确认提交时为 submit。"
|
||||||
"对于查询类任务(如查询差旅标准),requested_action 固定为 preview。"
|
"对于查询类任务(如查询差旅标准),requested_action 固定为 preview。"
|
||||||
|
"recent_history 是本会话最近 10 轮对话(role 为 user 或 assistant)。"
|
||||||
|
"当用户说“再提交”“继续”“重新提交”“重新申请”等确认类话术时,"
|
||||||
|
"必须结合 recent_history 里最近一次提到的出差/报销申请来理解用户意图,"
|
||||||
|
"复用该申请的 ontology_fields 重新生成 task,而不是把确认话术当作孤立的模糊输入。"
|
||||||
|
"如果 recent_history 为空或无法关联到具体申请,才按当前 message 字面理解。"
|
||||||
"相对日期必须以 base_date 为准转换为明确日期。"
|
"相对日期必须以 base_date 为准转换为明确日期。"
|
||||||
"thinking_events 只能是面向用户的过程摘要,不能暴露内部推理链。"
|
"thinking_events 只能是面向用户的过程摘要,不能暴露内部推理链。"
|
||||||
"如果用户输入与出差、费用、报销、申请、差旅标准等财务事项完全无关"
|
"如果用户输入与出差、费用、报销、申请、差旅标准等财务事项完全无关"
|
||||||
|
|||||||
@@ -148,6 +148,46 @@ def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None:
|
|||||||
assert runtime_model["capability"] == "chat"
|
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:
|
def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> None:
|
||||||
temp_dir = build_temp_secret_dir()
|
temp_dir = build_temp_secret_dir()
|
||||||
admin_file = temp_dir / "admin.json"
|
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 "query_travel_standard" in system_prompt
|
||||||
assert "差旅" in system_prompt
|
assert "差旅" 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) {
|
.workbench-ai-answer-markdown :deep(li::marker) {
|
||||||
color: #2563eb;
|
color: #64748b;
|
||||||
font-weight: 850;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(strong) {
|
.workbench-ai-answer-markdown :deep(strong) {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-weight: 850;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(hr) {
|
.workbench-ai-answer-markdown :deep(hr) {
|
||||||
margin: 26px 0;
|
margin: 26px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-top: 1px solid rgba(226, 232, 240, 0.9);
|
border-top: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(blockquote) {
|
.workbench-ai-answer-markdown :deep(blockquote) {
|
||||||
margin: 18px 0 0;
|
margin: 18px 0 0;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-left: 3px solid rgba(37, 99, 235, 0.5);
|
border-left: 3px solid #cbd5e1;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
background: rgba(239, 246, 255, 0.62);
|
background: #f8fafc;
|
||||||
color: #475569;
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-html-callout) {
|
.workbench-ai-answer-markdown :deep(.ai-html-callout) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-left: 3px solid rgba(37, 99, 235, 0.5);
|
border-left: 3px solid #cbd5e1;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
background: rgba(239, 246, 255, 0.62);
|
background: #f8fafc;
|
||||||
color: #475569;
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-html-focus-grid) {
|
.workbench-ai-answer-markdown :deep(.ai-html-focus-grid) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin: 2px 0 18px;
|
margin: 2px 0 18px;
|
||||||
padding-left: 22px;
|
padding-left: 20px;
|
||||||
border-left: 3px solid rgba(96, 165, 250, 0.66);
|
border-left: 3px solid #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-html-focus-card) {
|
.workbench-ai-answer-markdown :deep(.ai-html-focus-card) {
|
||||||
padding: 11px 0 16px;
|
padding: 8px 0 12px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-html-focus-card + .ai-html-focus-card) {
|
.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) {
|
.workbench-ai-answer-markdown :deep(.ai-html-focus-label) {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: #1d4ed8;
|
color: #475569;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 900;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-html-focus-card p) {
|
.workbench-ai-answer-markdown :deep(.ai-html-focus-card p) {
|
||||||
color: #475569;
|
color: #1e293b;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 650;
|
font-weight: 500;
|
||||||
line-height: 1.72;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-html-steps),
|
.workbench-ai-answer-markdown :deep(.ai-html-steps),
|
||||||
@@ -1366,9 +1366,9 @@
|
|||||||
padding-top: 1px;
|
padding-top: 1px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #1d4ed8;
|
color: #64748b;
|
||||||
font-size: 17px;
|
font-size: 15px;
|
||||||
font-weight: 900;
|
font-weight: 600;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1482,34 +1482,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card) {
|
.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;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
background-color: #ffffff;
|
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:
|
box-shadow:
|
||||||
inset 0 0 0 1px rgba(203, 213, 225, 0.5),
|
0 1px 2px 0 rgba(15, 23, 42, 0.05);
|
||||||
0 1px 2px rgba(15, 23, 42, 0.035),
|
|
||||||
0 14px 34px rgba(15, 23, 42, 0.05);
|
|
||||||
color: #334155;
|
color: #334155;
|
||||||
animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
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) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card:hover) {
|
||||||
|
border-color: #cbd5e1;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 0 0 1px rgba(148, 163, 184, 0.46),
|
0 4px 6px -1px rgba(15, 23, 42, 0.08),
|
||||||
0 1px 2px rgba(15, 23, 42, 0.04),
|
0 2px 4px -2px rgba(15, 23, 42, 0.08);
|
||||||
0 18px 38px rgba(15, 23, 42, 0.07);
|
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1532,8 +1525,9 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 13px 18px 13px 20px;
|
padding: 12px 18px;
|
||||||
background: var(--ai-document-card-head-bg);
|
background: var(--ai-document-card-head-bg);
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card__status) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card__status) {
|
||||||
@@ -1543,31 +1537,31 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #1d4ed8;
|
color: #475569;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 860;
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__head) {
|
.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) {
|
.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) {
|
.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) {
|
.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) {
|
.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) {
|
.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) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card__reason) {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: #1e40af;
|
color: #1e293b;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 760;
|
font-weight: 600;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 1;
|
||||||
@@ -1598,19 +1592,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__reason) {
|
.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) {
|
.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) {
|
.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) {
|
.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) {
|
.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) {
|
.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) {
|
.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) {
|
.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) {
|
.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) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task) {
|
||||||
--ai-document-card-head-bg: rgba(245, 158, 11, 0.1);
|
--ai-document-card-head-bg: rgba(254, 243, 199, 0.5);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__head) {
|
.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) {
|
.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) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__status) {
|
||||||
min-height: 26px;
|
min-height: 22px;
|
||||||
padding: 0 10px;
|
padding: 0 8px;
|
||||||
border-radius: 999px;
|
border-radius: 4px;
|
||||||
background: rgba(245, 158, 11, 0.18);
|
background: rgba(217, 119, 6, 0.1);
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card__summary),
|
.workbench-ai-answer-markdown :deep(.ai-document-card__summary),
|
||||||
@@ -1666,7 +1658,7 @@
|
|||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card__summary) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card__summary) {
|
||||||
padding-bottom: 14px;
|
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) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card__details) {
|
||||||
@@ -1690,26 +1682,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card__label) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card__label) {
|
||||||
color: #8a94a6;
|
color: #64748b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 640;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card__value) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card__value) {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: #334155;
|
color: #1e293b;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 720;
|
font-weight: 500;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card__amount) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card__amount) {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -1717,33 +1709,30 @@
|
|||||||
.workbench-ai-answer-markdown :deep(.ai-document-card__number) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card__number) {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 740;
|
font-weight: 500;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-attachment-association-card) {
|
.workbench-ai-answer-markdown :deep(.ai-attachment-association-card) {
|
||||||
background-image:
|
background-image: none;
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(248, 250, 252, 0.94)),
|
background-color: #ffffff;
|
||||||
url("../../ai-document-card-bg.png");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-attachment-association-card .ai-document-card__head) {
|
.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) {
|
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card) {
|
||||||
box-shadow:
|
border-color: #cbd5e1;
|
||||||
inset 0 0 0 1px rgba(147, 197, 253, 0.42),
|
box-shadow: 0 1px 2px 0 rgba(15, 23, 42, 0.05);
|
||||||
0 1px 2px rgba(15, 23, 42, 0.03),
|
|
||||||
0 12px 28px rgba(37, 99, 235, 0.045);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__head) {
|
.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) {
|
.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) {
|
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__status) {
|
||||||
@@ -1781,16 +1770,16 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #1d4ed8;
|
color: #2563eb;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 820;
|
font-weight: 600;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card__action:hover) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card__action:hover) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #1e40af;
|
color: #1d4ed8;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1798,10 +1787,10 @@
|
|||||||
.workbench-ai-answer-markdown :deep(.ai-html-table-wrap) {
|
.workbench-ai-answer-markdown :deep(.ai-html-table-wrap) {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 14px;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
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) {
|
.workbench-ai-answer-markdown :deep(table) {
|
||||||
@@ -1813,7 +1802,7 @@
|
|||||||
.workbench-ai-answer-markdown :deep(th),
|
.workbench-ai-answer-markdown :deep(th),
|
||||||
.workbench-ai-answer-markdown :deep(td) {
|
.workbench-ai-answer-markdown :deep(td) {
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
|
border-bottom: 1px solid #f1f5f9;
|
||||||
text-align: left;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card) {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
@@ -252,6 +252,331 @@
|
|||||||
opacity: 0.6;
|
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 {
|
.profile-grid {
|
||||||
grid-template-columns: 96px repeat(2, minmax(0, 1fr));
|
grid-template-columns: 96px repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -387,18 +712,18 @@
|
|||||||
color: var(--theme-primary-active);
|
color: var(--theme-primary-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-option-grid {
|
.theme-option-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-option {
|
.theme-option {
|
||||||
min-height: 104px;
|
min-height: 148px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 1px solid #d8dee8;
|
border: 1px solid #d8dee8;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -411,18 +736,19 @@
|
|||||||
box-shadow 160ms var(--ease);
|
box-shadow 160ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-option:hover,
|
.theme-option:hover,
|
||||||
.skin-option.active {
|
.theme-option.active {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: var(--theme-primary-light-9);
|
background: #ffffff;
|
||||||
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-swatch {
|
.theme-style-preview {
|
||||||
width: 64px;
|
grid-column: 1 / -1;
|
||||||
height: 38px;
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.3fr 1fr 1fr 1fr;
|
grid-template-columns: 1.4fr 1fr 1fr 0.8fr;
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
border: 1px solid #d8dee8;
|
border: 1px solid #d8dee8;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -430,28 +756,48 @@
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-swatch i + i {
|
.theme-style-preview i + i {
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.72);
|
border-left: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-copy {
|
.theme-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-copy strong {
|
.theme-copy strong {
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-copy small {
|
.theme-copy small {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
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;
|
min-height: 24px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -464,7 +810,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-preview-panel {
|
.theme-preview-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -472,33 +818,56 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 1px solid #d8dee8;
|
border: 1px solid #d8dee8;
|
||||||
border-radius: 4px;
|
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;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-preview-panel strong {
|
.theme-preview-panel strong {
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-preview-panel span {
|
.theme-preview-panel span {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-preview-action {
|
.theme-preview-surface {
|
||||||
min-height: 34px;
|
width: min(220px, 36%);
|
||||||
padding: 0 14px;
|
min-width: 160px;
|
||||||
border: 1px solid var(--primary);
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 44px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 4px;
|
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);
|
background: var(--theme-gradient-primary);
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.secret-bound-state {
|
.secret-bound-state {
|
||||||
|
|||||||
@@ -616,8 +616,8 @@
|
|||||||
border-left: 0;
|
border-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.application-detail-fact span,
|
.application-detail-fact > span,
|
||||||
.application-detail-fact strong {
|
.application-detail-fact > strong {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -625,7 +625,7 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.application-detail-fact span {
|
.application-detail-fact > span {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -637,10 +637,11 @@
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
|
gap: 8px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.application-detail-fact.highlight span {
|
.application-detail-fact.highlight > span {
|
||||||
background: var(--theme-primary-soft);
|
background: var(--theme-primary-soft);
|
||||||
color: var(--theme-primary-active);
|
color: var(--theme-primary-active);
|
||||||
}
|
}
|
||||||
@@ -654,6 +655,77 @@
|
|||||||
font-weight: 850;
|
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 {
|
.related-application-facts {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,3 +22,9 @@ const {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style>
|
<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: '',
|
sessionType: '',
|
||||||
budgetContext: null,
|
budgetContext: null,
|
||||||
initialPromptAutoSubmit: true,
|
initialPromptAutoSubmit: true,
|
||||||
initialApplicationPreview: null
|
initialApplicationPreview: null,
|
||||||
|
initialDraftPayload: null
|
||||||
})
|
})
|
||||||
const smartEntrySessionId = ref(0)
|
const smartEntrySessionId = ref(0)
|
||||||
const smartEntryRevealToken = ref(0)
|
const smartEntryRevealToken = ref(0)
|
||||||
@@ -337,7 +338,8 @@ export function useAppShell() {
|
|||||||
sessionType: '',
|
sessionType: '',
|
||||||
budgetContext: null,
|
budgetContext: null,
|
||||||
initialPromptAutoSubmit: true,
|
initialPromptAutoSubmit: true,
|
||||||
initialApplicationPreview: null
|
initialApplicationPreview: null,
|
||||||
|
initialDraftPayload: null
|
||||||
}
|
}
|
||||||
smartEntrySessionId.value += 1
|
smartEntrySessionId.value += 1
|
||||||
}
|
}
|
||||||
@@ -504,7 +506,8 @@ export function useAppShell() {
|
|||||||
? payload.budgetContext
|
? payload.budgetContext
|
||||||
: null,
|
: null,
|
||||||
initialPromptAutoSubmit: false,
|
initialPromptAutoSubmit: false,
|
||||||
initialApplicationPreview: null
|
initialApplicationPreview: null,
|
||||||
|
initialDraftPayload: null
|
||||||
}
|
}
|
||||||
smartEntrySessionId.value += 1
|
smartEntrySessionId.value += 1
|
||||||
return
|
return
|
||||||
@@ -531,6 +534,9 @@ export function useAppShell() {
|
|||||||
initialPromptAutoSubmit: payload.initialPromptAutoSubmit !== false,
|
initialPromptAutoSubmit: payload.initialPromptAutoSubmit !== false,
|
||||||
initialApplicationPreview: payload.applicationPreview && typeof payload.applicationPreview === 'object'
|
initialApplicationPreview: payload.applicationPreview && typeof payload.applicationPreview === 'object'
|
||||||
? payload.applicationPreview
|
? payload.applicationPreview
|
||||||
|
: null,
|
||||||
|
initialDraftPayload: payload.draftPayload && typeof payload.draftPayload === 'object'
|
||||||
|
? payload.draftPayload
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
smartEntrySessionId.value += 1
|
smartEntrySessionId.value += 1
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useSystemState } from './useSystemState.js'
|
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 { clearSystemCaches, fetchSettings, saveSettings } from '../services/settings.js'
|
||||||
import { useToast } from './useToast.js'
|
import { useToast } from './useToast.js'
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
maskConfiguredModelSecrets,
|
maskConfiguredModelSecrets,
|
||||||
maskConfiguredRenderSecret,
|
maskConfiguredRenderSecret,
|
||||||
mergeState,
|
mergeState,
|
||||||
|
normalizeLlmModelRows,
|
||||||
normalizeValue,
|
normalizeValue,
|
||||||
persistSettings,
|
persistSettings,
|
||||||
readStoredSettings
|
readStoredSettings
|
||||||
@@ -61,6 +62,8 @@ export function useSettings() {
|
|||||||
const cacheClearMessage = ref('')
|
const cacheClearMessage = ref('')
|
||||||
const cacheClearFailed = ref(false)
|
const cacheClearFailed = ref(false)
|
||||||
|
|
||||||
|
pageState.value.appearanceForm.themeSkin = setThemeSkin(pageState.value.appearanceForm.themeSkin)
|
||||||
|
|
||||||
const sections = SECTION_DEFINITIONS
|
const sections = SECTION_DEFINITIONS
|
||||||
const logLevels = LOG_LEVELS
|
const logLevels = LOG_LEVELS
|
||||||
const providerOptions = PROVIDER_OPTIONS
|
const providerOptions = PROVIDER_OPTIONS
|
||||||
@@ -108,6 +111,13 @@ export function useSettings() {
|
|||||||
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
|
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
|
||||||
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
|
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
|
||||||
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
|
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) {
|
if (preserveAdminPasswords) {
|
||||||
@@ -123,13 +133,16 @@ export function useSettings() {
|
|||||||
nextState.mailForm.password = currentState.mailForm.password
|
nextState.mailForm.password = currentState.mailForm.password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedThemeMode = normalizeThemeMode(nextState.appearanceForm?.themeSkin)
|
||||||
|
nextState.appearanceForm = {
|
||||||
|
...nextState.appearanceForm,
|
||||||
|
themeSkin: normalizedThemeMode
|
||||||
|
}
|
||||||
|
|
||||||
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
|
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
|
||||||
persistSettings(pageState.value)
|
persistSettings(pageState.value)
|
||||||
updateBrandPreviewFromState(pageState.value)
|
updateBrandPreviewFromState(pageState.value)
|
||||||
|
setThemeSkin(normalizedThemeMode)
|
||||||
if (nextState.appearanceForm?.themeSkin) {
|
|
||||||
setThemeSkin(nextState.appearanceForm.themeSkin)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettingsSnapshot() {
|
async function loadSettingsSnapshot() {
|
||||||
@@ -358,12 +371,12 @@ export function useSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectThemeSkin(skinId) {
|
function selectThemeSkin(skinId) {
|
||||||
setThemeSkin(skinId)
|
pageState.value.appearanceForm.themeSkin = setThemeSkin(skinId)
|
||||||
pageState.value.appearanceForm.themeSkin = skinId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAppearanceSection() {
|
async function saveAppearanceSection() {
|
||||||
await persistRemoteSettings('界面皮肤已保存并应用到企业配置。', {
|
pageState.value.appearanceForm.themeSkin = normalizeThemeMode(pageState.value.appearanceForm.themeSkin)
|
||||||
|
await persistRemoteSettings('主题设置已保存并应用到企业配置。', {
|
||||||
preserveModelApiKeys: true,
|
preserveModelApiKeys: true,
|
||||||
preserveAdminPasswords: true,
|
preserveAdminPasswords: true,
|
||||||
preserveRenderSecret: true,
|
preserveRenderSecret: true,
|
||||||
@@ -373,16 +386,16 @@ export function useSettings() {
|
|||||||
|
|
||||||
async function saveLlmSection() {
|
async function saveLlmSection() {
|
||||||
const llmForm = pageState.value.llmForm
|
const llmForm = pageState.value.llmForm
|
||||||
const modelConfigs = [
|
const modelRows = normalizeLlmModelRows(llmForm.models)
|
||||||
['主模型', 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]
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const [label, provider, model, endpoint] of modelConfigs) {
|
if (modelRows.length === 0) {
|
||||||
if (!isModelConfigReady(provider, model, endpoint)) {
|
toast('请至少添加一个模型配置。')
|
||||||
toast(`请完整填写${label}的供应商、模型名称和接口地址。`)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of modelRows) {
|
||||||
|
if (!isModelConfigReady(row.provider, row.modelId, row.url)) {
|
||||||
|
toast('请完整填写每个模型的供应商、model_id 和接口地址。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const THEME_SKIN_STORAGE_KEY = 'x-financial-theme-skin'
|
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 = {
|
const DEFAULT_SEMANTIC_COLORS = {
|
||||||
success: '#2f855a',
|
success: '#2f855a',
|
||||||
@@ -28,112 +28,45 @@ const DEFAULT_SEMANTIC_COLORS = {
|
|||||||
|
|
||||||
export const THEME_SKIN_OPTIONS = [
|
export const THEME_SKIN_OPTIONS = [
|
||||||
{
|
{
|
||||||
id: 'sky',
|
id: 'vivid',
|
||||||
label: '浅蓝企业',
|
label: '动感活泼',
|
||||||
desc: '默认皮肤,降低蓝色饱和度,适合财务 SaaS 和审批后台。',
|
desc: '保留当前 AI 助手的明快节奏,适合演示、培训和轻量工作台。',
|
||||||
primary: '#3a7ca5',
|
keywords: ['明快', '渐变', '助手感'],
|
||||||
primaryHover: '#2f6d95',
|
primary: '#2f7cff',
|
||||||
primaryActive: '#255b7d',
|
primaryHover: '#2563eb',
|
||||||
primarySoft: '#eaf4fa',
|
primaryActive: '#1d4ed8',
|
||||||
primarySoftStrong: '#d4e8f3',
|
primarySoft: '#eef6ff',
|
||||||
secondary: '#4f6f9f',
|
primarySoftStrong: '#dbeafe',
|
||||||
chartBlue: '#4f6f9f',
|
secondary: '#7c5cff',
|
||||||
chartPurple: '#6e7fa6',
|
chartBlue: '#2f7cff',
|
||||||
chartAmber: '#b58b4c'
|
chartPurple: '#7c5cff',
|
||||||
|
chartAmber: '#f59e0b'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'blue',
|
id: 'enterprise',
|
||||||
label: '湖蓝灰',
|
label: '企业沉稳',
|
||||||
desc: '偏灰的湖蓝色,弱化科技感,更适合高密度运营页面。',
|
desc: '低饱和、轻描边、少渲染,适合正式生产环境和企业级财务 SaaS。',
|
||||||
primary: '#477c9e',
|
keywords: ['克制', '结构化', '低噪声'],
|
||||||
primaryHover: '#3a6a89',
|
primary: '#475569',
|
||||||
primaryActive: '#305873',
|
primaryHover: '#3f4a5a',
|
||||||
primarySoft: '#edf5f8',
|
primaryActive: '#334155',
|
||||||
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',
|
|
||||||
primarySoft: '#f1f5f9',
|
primarySoft: '#f1f5f9',
|
||||||
primarySoftStrong: '#e2e8f0',
|
primarySoftStrong: '#e2e8f0',
|
||||||
secondary: '#3a7ca5',
|
secondary: '#64748b',
|
||||||
chartBlue: '#5d7590',
|
chartBlue: '#5d7590',
|
||||||
chartPurple: '#77748f',
|
chartPurple: '#6b7280',
|
||||||
chartAmber: '#a88955'
|
chartAmber: '#9a7a45'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'soft-violet',
|
id: 'intelligent',
|
||||||
label: '灰紫蓝',
|
label: '专业智能',
|
||||||
desc: '保留一点智能系统气质,但用灰度压低 AI 感和饱和度。',
|
desc: '保留少量智能识别感,同时控制饱和度,适合稳定办公和 AI 辅助并重的团队。',
|
||||||
primary: '#6d6a9f',
|
keywords: ['智能', '专业', '轻点缀'],
|
||||||
primaryHover: '#5f5b8c',
|
primary: '#5f6f9f',
|
||||||
primaryActive: '#504c78',
|
primaryHover: '#53618b',
|
||||||
primarySoft: '#f2f1f8',
|
primaryActive: '#465275',
|
||||||
primarySoftStrong: '#e2e0ef',
|
primarySoft: '#f3f4fb',
|
||||||
|
primarySoftStrong: '#e2e5f4',
|
||||||
secondary: '#477c9e',
|
secondary: '#477c9e',
|
||||||
chartBlue: '#4f7495',
|
chartBlue: '#4f7495',
|
||||||
chartPurple: '#6d6a9f',
|
chartPurple: '#6d6a9f',
|
||||||
@@ -142,9 +75,36 @@ export const THEME_SKIN_OPTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const activeThemeSkinId = ref(DEFAULT_THEME_SKIN_ID)
|
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) {
|
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) {
|
function hexToRgb(hex) {
|
||||||
@@ -185,6 +145,7 @@ function applyThemeSkin(skin) {
|
|||||||
const infoRgb = hexToRgb(DEFAULT_SEMANTIC_COLORS.info)
|
const infoRgb = hexToRgb(DEFAULT_SEMANTIC_COLORS.info)
|
||||||
|
|
||||||
root.dataset.themeSkin = skin.id
|
root.dataset.themeSkin = skin.id
|
||||||
|
root.dataset.themeMode = skin.id
|
||||||
|
|
||||||
setVariables(root, {
|
setVariables(root, {
|
||||||
'--primary': skin.primary,
|
'--primary': skin.primary,
|
||||||
@@ -270,6 +231,8 @@ export function setThemeSkin(id) {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem(THEME_SKIN_STORAGE_KEY, skin.id)
|
window.localStorage.setItem(THEME_SKIN_STORAGE_KEY, skin.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return skin.id
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useThemeSkin() {
|
export function useThemeSkin() {
|
||||||
|
|||||||
@@ -258,7 +258,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
resolveLatestInlineUserPrompt,
|
resolveLatestInlineUserPrompt,
|
||||||
scrollInlineConversationToBottom,
|
scrollInlineConversationToBottom,
|
||||||
sending,
|
sending,
|
||||||
toast
|
toast,
|
||||||
|
onApplicationActionCompleted: startModelPlannedNextTask
|
||||||
})
|
})
|
||||||
|
|
||||||
const expenseFlow = useWorkbenchAiExpenseFlow({
|
const expenseFlow = useWorkbenchAiExpenseFlow({
|
||||||
@@ -710,6 +711,46 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
return pendingMessage
|
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) {
|
function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) {
|
||||||
void applicationFlow.startAiApplicationPreview(
|
void applicationFlow.startAiApplicationPreview(
|
||||||
travelApplicationRequest.expenseType,
|
travelApplicationRequest.expenseType,
|
||||||
@@ -723,7 +764,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
autoSubmit: travelApplicationRequest.autoSubmit,
|
autoSubmit: travelApplicationRequest.autoSubmit,
|
||||||
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
|
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
|
||||||
requestedSubmit: travelApplicationRequest.requestedSubmit,
|
requestedSubmit: travelApplicationRequest.requestedSubmit,
|
||||||
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation
|
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
|
||||||
|
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
|
||||||
|
onApplicationActionCompleted: startModelPlannedNextTask
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -741,7 +784,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
autoSubmit: travelApplicationRequest.autoSubmit,
|
autoSubmit: travelApplicationRequest.autoSubmit,
|
||||||
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
|
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
|
||||||
requestedSubmit: travelApplicationRequest.requestedSubmit,
|
requestedSubmit: travelApplicationRequest.requestedSubmit,
|
||||||
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation
|
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
|
||||||
|
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, {
|
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
} from '../../services/aiApplicationPreviewActions.js'
|
} from '../../services/aiApplicationPreviewActions.js'
|
||||||
import { executeStewardAction } from '../../services/steward.js'
|
import { executeStewardAction } from '../../services/steward.js'
|
||||||
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
|
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
|
||||||
|
import { buildAiExpenseDraftPrefillValues } from '../../utils/aiExpenseDraftModel.js'
|
||||||
|
import { requiresApplicationBeforeReimbursement } from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||||
import {
|
import {
|
||||||
mergeComposerPrefill,
|
mergeComposerPrefill,
|
||||||
resolveSuggestedActionPrefill
|
resolveSuggestedActionPrefill
|
||||||
@@ -82,6 +84,9 @@ export function useWorkbenchAiActionRouter({
|
|||||||
}
|
}
|
||||||
if (actionType === 'ai_application_confirm_intent') {
|
if (actionType === 'ai_application_confirm_intent') {
|
||||||
aiExpenseDraft.value = null
|
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(), {
|
void applicationFlow.startAiApplicationPreview('travel', '差旅费', String(actionPayload.sourceText || '').trim(), {
|
||||||
userMessage: String(actionPayload.sourceText || '').trim() || '确认发起出差申请',
|
userMessage: String(actionPayload.sourceText || '').trim() || '确认发起出差申请',
|
||||||
pushUserMessage: true,
|
pushUserMessage: true,
|
||||||
@@ -89,7 +94,14 @@ export function useWorkbenchAiActionRouter({
|
|||||||
autoSubmit: Boolean(actionPayload.autoSubmit),
|
autoSubmit: Boolean(actionPayload.autoSubmit),
|
||||||
autoSaveDraft: Boolean(actionPayload.autoSaveDraft),
|
autoSaveDraft: Boolean(actionPayload.autoSaveDraft),
|
||||||
requestedSubmit: Boolean(actionPayload.requestedSubmit),
|
requestedSubmit: Boolean(actionPayload.requestedSubmit),
|
||||||
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation)
|
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation),
|
||||||
|
stewardRemainingTasks,
|
||||||
|
onApplicationActionCompleted: (remainingTasks = []) => {
|
||||||
|
const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks })
|
||||||
|
if (nextTaskAction) {
|
||||||
|
handleInlineSuggestedAction(nextTaskAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -104,9 +116,21 @@ export function useWorkbenchAiActionRouter({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
|
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
|
||||||
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
|
const currentTask = actionPayload.steward_current_task || {}
|
||||||
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
|
const ontologyFields = currentTask.ontology_fields || currentTask.ontologyFields || actionPayload.ontology_fields || {}
|
||||||
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, true)
|
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
|
return
|
||||||
}
|
}
|
||||||
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
|
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
|
||||||
@@ -114,6 +138,17 @@ export function useWorkbenchAiActionRouter({
|
|||||||
void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
|
void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
|
||||||
return
|
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') {
|
if (actionType === 'select_expense_type') {
|
||||||
const expenseType = String(action?.payload?.expense_type || '').trim()
|
const expenseType = String(action?.payload?.expense_type || '').trim()
|
||||||
const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
|
const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
|
||||||
@@ -165,7 +200,18 @@ export function useWorkbenchAiActionRouter({
|
|||||||
|
|
||||||
if (actionType === 'ai_application_start_inline') {
|
if (actionType === 'ai_application_start_inline') {
|
||||||
aiExpenseDraft.value = null
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,8 +269,10 @@ export function useWorkbenchAiActionRouter({
|
|||||||
const result = await executeStewardAction(
|
const result = await executeStewardAction(
|
||||||
buildStewardActionExecutePayload(action, actionType, contextJson)
|
buildStewardActionExecutePayload(action, actionType, contextJson)
|
||||||
)
|
)
|
||||||
|
const resultActions = buildStewardActionResultActions(result)
|
||||||
|
const nextTaskAction = buildNextTaskSuggestedAction(actionPayload)
|
||||||
finalizeStewardActionMessage(pendingMessage, buildStewardActionResultText(result), {
|
finalizeStewardActionMessage(pendingMessage, buildStewardActionResultText(result), {
|
||||||
suggestedActions: buildStewardActionResultActions(result)
|
suggestedActions: nextTaskAction ? [...resultActions, nextTaskAction] : resultActions
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -369,6 +417,43 @@ export function useWorkbenchAiActionRouter({
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildNextTaskSuggestedAction(actionPayload = {}) {
|
||||||
|
// 多 task 串行推进:task1 完成后,从剩余 task 列表取下一个,生成推进按钮。
|
||||||
|
// 用户点击推进按钮后,handleInlineSuggestedAction 的 steward_confirm_flow 分支
|
||||||
|
// 会自动拉起下一个 task 的申请预览/报销流程,实现"先做完 A 再做 B"。
|
||||||
|
const remainingTasks = Array.isArray(actionPayload.steward_remaining_tasks)
|
||||||
|
? actionPayload.steward_remaining_tasks
|
||||||
|
: []
|
||||||
|
const nextTask = remainingTasks[0]
|
||||||
|
if (!nextTask || !nextTask.task_type) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const taskType = String(nextTask.task_type || '').trim()
|
||||||
|
const isApplication = taskType === 'expense_application'
|
||||||
|
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
|
||||||
|
const taskLabel = isApplication ? '出差申请' : '费用报销'
|
||||||
|
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
|
||||||
|
// 透传去掉当前 nextTask 之后的剩余 task 列表,保证 task2 完成后 task3 也能继续推进,
|
||||||
|
// 避免 3+ task 场景在 task2 处断链。
|
||||||
|
const furtherRemainingTasks = remainingTasks.slice(1)
|
||||||
|
return {
|
||||||
|
label: `继续处理${taskLabel}`,
|
||||||
|
description: `接下来处理${taskLabel}: ${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`,
|
||||||
|
icon: isApplication ? 'mdi mdi-file-plus-outline' : 'mdi mdi-receipt-text-plus-outline',
|
||||||
|
action_type: 'steward_continue_next_task',
|
||||||
|
payload: {
|
||||||
|
steward_confirm_flow: true,
|
||||||
|
flow_id: flowId,
|
||||||
|
steward_current_task: nextTask,
|
||||||
|
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
|
||||||
|
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
|
||||||
|
ontology_fields: ontologyFields,
|
||||||
|
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
|
||||||
|
steward_remaining_tasks: furtherRemainingTasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleInlineSuggestedAction
|
handleInlineSuggestedAction
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
resolveLatestInlineUserPrompt,
|
resolveLatestInlineUserPrompt,
|
||||||
scrollInlineConversationToBottom,
|
scrollInlineConversationToBottom,
|
||||||
sending,
|
sending,
|
||||||
toast
|
toast,
|
||||||
|
onApplicationActionCompleted = null
|
||||||
}) {
|
}) {
|
||||||
function isApplicationPreviewEstimatePending(message = {}) {
|
function isApplicationPreviewEstimatePending(message = {}) {
|
||||||
return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
|
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 = {}) {
|
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
|
||||||
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestInlineApplicationPreviewMessage()
|
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestInlineApplicationPreviewMessage()
|
||||||
if (!targetMessage?.applicationPreview) {
|
if (!targetMessage?.applicationPreview) {
|
||||||
@@ -424,6 +461,17 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
targetMessage.draftPayload = draftPayload
|
targetMessage.draftPayload = draftPayload
|
||||||
}
|
}
|
||||||
targetMessage.suggestedActions = []
|
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(
|
replaceInlineMessage(
|
||||||
pendingMessage.id,
|
pendingMessage.id,
|
||||||
createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
|
createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
|
||||||
@@ -432,11 +480,16 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
streamStatus: 'completed',
|
streamStatus: 'completed',
|
||||||
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
||||||
},
|
},
|
||||||
suggestedActions: buildInlineApplicationDetailAction(draftPayload)
|
suggestedActions: shouldAutoContinueNextTask
|
||||||
|
? detailActions
|
||||||
|
: (nextTaskAction ? [...detailActions, nextTaskAction] : detailActions)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
persistCurrentConversation()
|
persistCurrentConversation()
|
||||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||||
|
if (shouldAutoContinueNextTask) {
|
||||||
|
actionCompletedHandler(targetMessage.stewardRemainingTasks, targetMessage)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
replaceInlineMessage(
|
replaceInlineMessage(
|
||||||
@@ -552,9 +605,10 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
|
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
|
||||||
requestedSubmit: Boolean(options.requestedSubmit),
|
requestedSubmit: Boolean(options.requestedSubmit),
|
||||||
submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation),
|
submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation),
|
||||||
|
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
|
||||||
stewardPlan: {
|
stewardPlan: {
|
||||||
streamStatus: 'completed',
|
streamStatus: 'completed',
|
||||||
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
thinkingEvents: completeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
||||||
},
|
},
|
||||||
text: content
|
text: content
|
||||||
})
|
})
|
||||||
@@ -562,9 +616,13 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
if (options.autoSaveDraft) {
|
if (options.autoSaveDraft) {
|
||||||
await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, previewMessage, {
|
await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, previewMessage, {
|
||||||
skipUserMessage: true,
|
skipUserMessage: true,
|
||||||
userText: options.userMessage || '保存草稿'
|
userText: options.userMessage || '保存草稿',
|
||||||
|
onApplicationActionCompleted: options.onApplicationActionCompleted
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 多 task 串行推进:预览生成后不提前拉起下一个 task(避免和用户在 task1 核对表上的
|
||||||
|
// 保存草稿/提交操作互相打架,导致 task2 状态错乱)。task2 的推进统一交给
|
||||||
|
// onApplicationActionCompleted,在 task1 真正完成(保存草稿/提交成功)后再触发。
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
||||||
id: pendingMessage.id,
|
id: pendingMessage.id,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
buildWorkbenchDocumentCommandFollowupGuidance,
|
||||||
buildWorkbenchDraftDeletionGuidance,
|
buildWorkbenchDraftDeletionGuidance,
|
||||||
isWorkbenchDraftDeletionIntent,
|
isWorkbenchDraftDeletionIntent,
|
||||||
|
resolveLatestWorkbenchDocumentCommandContext,
|
||||||
resolveLatestWorkbenchDraftPayload
|
resolveLatestWorkbenchDraftPayload
|
||||||
} from './workbenchAiCommandIntentModel.js'
|
} from './workbenchAiCommandIntentModel.js'
|
||||||
import { resolveWorkbenchIntentActionRoute } from './workbenchIntentActionPolicy.js'
|
import { resolveWorkbenchIntentActionRoute } from './workbenchIntentActionPolicy.js'
|
||||||
@@ -58,6 +60,9 @@ export function useWorkbenchAiCommandIntents({
|
|||||||
if (!handlesWorkbenchCommand) {
|
if (!handlesWorkbenchCommand) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
const documentCommandContext = route.nextStep === 'query_candidates'
|
||||||
|
? resolveLatestWorkbenchDocumentCommandContext(conversationMessages.value, frame)
|
||||||
|
: null
|
||||||
prepareInlineCommandConversation(cleanPrompt, entry)
|
prepareInlineCommandConversation(cleanPrompt, entry)
|
||||||
const draftPayload = frame?.targetMode === 'current_context' || legacyDraftDelete
|
const draftPayload = frame?.targetMode === 'current_context' || legacyDraftDelete
|
||||||
? resolveLatestWorkbenchDraftPayload(conversationMessages.value)
|
? resolveLatestWorkbenchDraftPayload(conversationMessages.value)
|
||||||
@@ -72,6 +77,16 @@ export function useWorkbenchAiCommandIntents({
|
|||||||
return true
|
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 queryPrompt = route.queryPrompt || frame?.normalizedQuery || '我的草稿单据'
|
||||||
const pendingText = frame?.safetyLevel === 'confirm_required'
|
const pendingText = frame?.safetyLevel === 'confirm_required'
|
||||||
? '正在先筛选候选单据,不会直接执行删除或审核动作...'
|
? '正在先筛选候选单据,不会直接执行删除或审核动作...'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../services/linkedReimbursementDraftJobs.js'
|
} from '../../services/linkedReimbursementDraftJobs.js'
|
||||||
import {
|
import {
|
||||||
applyAiExpenseAnswer,
|
applyAiExpenseAnswer,
|
||||||
|
buildAiExpenseDraftPrefillValues,
|
||||||
buildAiExpenseStepPrompt,
|
buildAiExpenseStepPrompt,
|
||||||
buildAiExpenseSummary,
|
buildAiExpenseSummary,
|
||||||
createAiExpenseDraft,
|
createAiExpenseDraft,
|
||||||
@@ -113,6 +114,7 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
|
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
|
||||||
draftPayload: options.draftPayload || null,
|
draftPayload: options.draftPayload || null,
|
||||||
linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null,
|
linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null,
|
||||||
|
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
|
||||||
text: options.text || content
|
text: options.text || content
|
||||||
})
|
})
|
||||||
replaceInlineMessage(messageId, nextMessage)
|
replaceInlineMessage(messageId, nextMessage)
|
||||||
@@ -143,7 +145,12 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
return startAiApplicationPreview(
|
return startAiApplicationPreview(
|
||||||
expenseType,
|
expenseType,
|
||||||
expenseTypeLabel,
|
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()
|
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) {
|
if (!conversationStarted.value) {
|
||||||
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
|
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
|
||||||
}
|
}
|
||||||
@@ -328,12 +368,25 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
clearAiModeFiles()
|
clearAiModeFiles()
|
||||||
pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
|
pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
|
||||||
|
|
||||||
|
const prefillValues = options.prefillValues && typeof options.prefillValues === 'object'
|
||||||
|
? options.prefillValues
|
||||||
|
: null
|
||||||
|
const stewardRemainingTasks = Array.isArray(options.stewardRemainingTasks)
|
||||||
|
? options.stewardRemainingTasks
|
||||||
|
: []
|
||||||
|
|
||||||
if (requiresApplicationBeforeReimbursement) {
|
if (requiresApplicationBeforeReimbursement) {
|
||||||
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel)
|
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel, {
|
||||||
|
prefillValues,
|
||||||
|
stewardRemainingTasks
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const draft = createAiExpenseDraft(expenseType, expenseTypeLabel)
|
const draft = attachStewardRemainingTasks(
|
||||||
|
createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues),
|
||||||
|
stewardRemainingTasks
|
||||||
|
)
|
||||||
aiExpenseDraft.value = draft
|
aiExpenseDraft.value = draft
|
||||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
|
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
|
||||||
persistCurrentConversation()
|
persistCurrentConversation()
|
||||||
@@ -346,7 +399,11 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
assistantDraft.value = ''
|
assistantDraft.value = ''
|
||||||
clearAiModeFiles()
|
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
|
aiExpenseDraft.value = next
|
||||||
|
|
||||||
if (isAiExpenseDraftComplete(next)) {
|
if (isAiExpenseDraftComplete(next)) {
|
||||||
@@ -359,7 +416,14 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
scrollInlineConversationToBottom()
|
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
|
let claims = null
|
||||||
try {
|
try {
|
||||||
claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||||
@@ -372,18 +436,30 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {})
|
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) {
|
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), {
|
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
|
||||||
suggestedActions: [{
|
suggestedActions: [{
|
||||||
label: '确认发起出差申请',
|
label: `确认发起${applicationLabel}申请`,
|
||||||
description: '生成完整申请表,并预填已识别的时间、地点和事由',
|
description: '生成完整申请表,并预填已识别的时间、地点和事由',
|
||||||
icon: 'mdi mdi-file-plus-outline',
|
icon: 'mdi mdi-file-plus-outline',
|
||||||
action_type: 'ai_application_start_inline',
|
action_type: 'ai_application_start_inline',
|
||||||
payload: {
|
payload: {
|
||||||
expense_type: expenseType,
|
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,
|
jobId,
|
||||||
pendingMessageId,
|
pendingMessageId,
|
||||||
claimNo = '',
|
claimNo = '',
|
||||||
initialJob = null
|
initialJob = null,
|
||||||
|
stewardRemainingTasks = []
|
||||||
}) {
|
}) {
|
||||||
const normalizedJobId = String(jobId || '').trim()
|
const normalizedJobId = String(jobId || '').trim()
|
||||||
if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) {
|
if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) {
|
||||||
@@ -474,13 +551,17 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
const content = draftClaimNo
|
const content = draftClaimNo
|
||||||
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||||
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||||
|
// 多 task 推进:报销草稿生成成功后,若还有剩余 task,补一个"继续处理"按钮。
|
||||||
|
const nextTaskAction = buildExpenseDraftNextTaskAction(stewardRemainingTasks)
|
||||||
replaceInlineAssistantMessage(pendingMessageId, content, {
|
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||||
draftPayload,
|
draftPayload,
|
||||||
linkedReimbursementDraftJob: {
|
linkedReimbursementDraftJob: {
|
||||||
...currentJob,
|
...currentJob,
|
||||||
applicationClaimNo: claimNo
|
applicationClaimNo: claimNo
|
||||||
},
|
},
|
||||||
suggestedActions: buildLinkedDraftAction(draftPayload)
|
suggestedActions: nextTaskAction
|
||||||
|
? [...buildLinkedDraftAction(draftPayload), nextTaskAction]
|
||||||
|
: buildLinkedDraftAction(draftPayload)
|
||||||
})
|
})
|
||||||
aiExpenseDraft.value = null
|
aiExpenseDraft.value = null
|
||||||
persistCurrentConversation()
|
persistCurrentConversation()
|
||||||
@@ -519,7 +600,9 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
jobId: job.jobId,
|
jobId: job.jobId,
|
||||||
pendingMessageId: message.id,
|
pendingMessageId: message.id,
|
||||||
claimNo: job.applicationClaimNo,
|
claimNo: job.applicationClaimNo,
|
||||||
initialJob: job
|
initialJob: job,
|
||||||
|
// 刷新恢复时从消息上读回 remaining tasks,保证报销完成后仍能补出"继续处理"按钮。
|
||||||
|
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : []
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), {
|
replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), {
|
||||||
linkedReimbursementDraftJob: {
|
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 = {}) {
|
async function linkAiExpenseApplication(application = {}) {
|
||||||
const draft = aiExpenseDraft.value || (() => {
|
const draft = aiExpenseDraft.value || (() => {
|
||||||
const resolved = resolveRequiredApplicationReimbursementType(application)
|
const resolved = resolveRequiredApplicationReimbursementType(application)
|
||||||
@@ -572,9 +687,14 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
stepKey: 'attachments'
|
stepKey: 'attachments'
|
||||||
}
|
}
|
||||||
aiExpenseDraft.value = linked
|
aiExpenseDraft.value = linked
|
||||||
|
// 关联申请单时,保留 draft 上的 remaining tasks 上下文,透传给后续轮询,
|
||||||
|
// 这样报销草稿生成成功后能补出"继续处理 task3"按钮。
|
||||||
|
const stewardRemainingTasks = resolveStewardRemainingTasks(linked) || []
|
||||||
const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, {
|
const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, {
|
||||||
pending: true,
|
pending: true,
|
||||||
suggestedActions: []
|
suggestedActions: [],
|
||||||
|
// 把 remaining tasks 挂到 pending 消息上,刷新后 resume 轮询能读回并透传给 poll 成功分支。
|
||||||
|
stewardRemainingTasks
|
||||||
})
|
})
|
||||||
conversationMessages.value.push(pendingMessage)
|
conversationMessages.value.push(pendingMessage)
|
||||||
const pendingMessageId = pendingMessage.id
|
const pendingMessageId = pendingMessage.id
|
||||||
@@ -602,7 +722,8 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
jobId: normalizedJob.jobId,
|
jobId: normalizedJob.jobId,
|
||||||
pendingMessageId,
|
pendingMessageId,
|
||||||
claimNo,
|
claimNo,
|
||||||
initialJob: normalizedJob
|
initialJob: normalizedJob,
|
||||||
|
stewardRemainingTasks
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
replaceInlineAssistantMessage(
|
replaceInlineAssistantMessage(
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
parseAiDocumentDetailHref
|
||||||
|
} from '../../utils/aiDocumentDetailReference.js'
|
||||||
|
|
||||||
const DRAFT_DELETION_ACTION_PATTERN = /删除|删掉|删了|移除|作废|撤销/
|
const DRAFT_DELETION_ACTION_PATTERN = /删除|删掉|删了|移除|作废|撤销/
|
||||||
const DRAFT_DELETION_TARGET_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 = '') {
|
function normalizeCompactText(value = '') {
|
||||||
return String(value || '').replace(/\s+/g, '').trim()
|
return String(value || '').replace(/\s+/g, '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeText(value = '') {
|
||||||
|
return String(value || '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDraftDocumentType(payload = {}, claimNo = '') {
|
function normalizeDraftDocumentType(payload = {}, claimNo = '') {
|
||||||
const rawType = String(payload.document_type || payload.documentType || payload.draft_type || payload.draftType || '').trim()
|
const rawType = String(payload.document_type || payload.documentType || payload.draft_type || payload.draftType || '').trim()
|
||||||
if (/application|expense_application|申请/.test(rawType)) {
|
if (/application|expense_application|申请/.test(rawType)) {
|
||||||
@@ -77,6 +96,60 @@ function extractDraftPayloadFromSuggestedActions(message = {}) {
|
|||||||
return null
|
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 = '') {
|
export function isWorkbenchDraftDeletionIntent(prompt = '') {
|
||||||
const compact = normalizeCompactText(prompt)
|
const compact = normalizeCompactText(prompt)
|
||||||
if (!compact || !DRAFT_DELETION_ACTION_PATTERN.test(compact)) {
|
if (!compact || !DRAFT_DELETION_ACTION_PATTERN.test(compact)) {
|
||||||
@@ -104,6 +177,29 @@ export function resolveLatestWorkbenchDraftPayload(messages = []) {
|
|||||||
return null
|
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 = {}) {
|
export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) {
|
||||||
const claimNo = String(draftPayload.claimNo || draftPayload.claim_no || '').trim()
|
const claimNo = String(draftPayload.claimNo || draftPayload.claim_no || '').trim()
|
||||||
const claimId = String(draftPayload.claimId || draftPayload.claim_id || '').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)]
|
return [...new Set(mappedSteps)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function findModelTravelApplicationTask(rawPlan = {}) {
|
function resolveModelTasks(rawPlan = {}) {
|
||||||
const tasks = Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : []
|
return Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : []
|
||||||
return tasks.find((task) => {
|
}
|
||||||
|
|
||||||
|
function isModelTravelApplicationTask(task = {}) {
|
||||||
|
if (!task || typeof task !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const taskType = String(task?.task_type || task?.taskType || '').trim()
|
const taskType = String(task?.task_type || task?.taskType || '').trim()
|
||||||
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
|
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
|
||||||
return taskType === 'expense_application' || assignedAgent === 'application_assistant'
|
return taskType === 'expense_application' || assignedAgent === 'application_assistant'
|
||||||
}) || null
|
}
|
||||||
|
|
||||||
|
function findModelTravelApplicationTask(rawPlan = {}) {
|
||||||
|
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 Boolean(taskType || assignedAgent)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCandidateFlows(rawPlan = {}) {
|
function resolveCandidateFlows(rawPlan = {}) {
|
||||||
@@ -226,7 +250,8 @@ export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) {
|
|||||||
missingFields: Array.isArray(task.missing_fields || task.missingFields)
|
missingFields: Array.isArray(task.missing_fields || task.missingFields)
|
||||||
? 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
|
return null
|
||||||
}
|
}
|
||||||
const requestedSubmit = plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION)
|
const requestedSubmit = plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION)
|
||||||
return {
|
const request = {
|
||||||
expenseType: 'travel',
|
expenseType: 'travel',
|
||||||
expenseTypeLabel: '差旅费',
|
expenseTypeLabel: '差旅费',
|
||||||
sourceText: String(plan.sourceText || '').trim(),
|
sourceText: String(plan.sourceText || '').trim(),
|
||||||
@@ -285,6 +310,11 @@ export function resolveExecutableTravelApplicationPlan(plan = null) {
|
|||||||
requestedSubmit,
|
requestedSubmit,
|
||||||
submitRequiresConfirmation: requestedSubmit
|
submitRequiresConfirmation: requestedSubmit
|
||||||
}
|
}
|
||||||
|
const stewardRemainingTasks = Array.isArray(plan.stewardRemainingTasks) ? plan.stewardRemainingTasks : []
|
||||||
|
if (stewardRemainingTasks.length) {
|
||||||
|
request.stewardRemainingTasks = stewardRemainingTasks
|
||||||
|
}
|
||||||
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLowConfidenceTravelApplicationPlan(plan = null) {
|
export function isLowConfidenceTravelApplicationPlan(plan = null) {
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ export function createWorkbenchAiMessageRuntime() {
|
|||||||
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null),
|
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null),
|
||||||
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null),
|
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null),
|
||||||
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
|
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
|
||||||
|
// 多 task 推进上下文:申请预览/报销草稿消息上挂载剩余 task 列表,
|
||||||
|
// 刷新或消息重建后仍能继续推进,避免 task 链断裂。
|
||||||
|
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
|
||||||
text: options.text || normalizedContent,
|
text: options.text || normalizedContent,
|
||||||
createdAt: options.createdAt || Date.now()
|
createdAt: options.createdAt || Date.now()
|
||||||
}
|
}
|
||||||
@@ -175,6 +178,7 @@ export function createWorkbenchAiMessageRuntime() {
|
|||||||
attachmentAssociationJob: message.attachmentAssociationJob || null,
|
attachmentAssociationJob: message.attachmentAssociationJob || null,
|
||||||
linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null,
|
linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null,
|
||||||
attachmentOcrDetails: message.attachmentOcrDetails || null,
|
attachmentOcrDetails: message.attachmentOcrDetails || null,
|
||||||
|
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : [],
|
||||||
text: message.text || message.content || ''
|
text: message.text || message.content || ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -194,7 +198,8 @@ export function createWorkbenchAiMessageRuntime() {
|
|||||||
draftPayload: message.draftPayload || null,
|
draftPayload: message.draftPayload || null,
|
||||||
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null),
|
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null),
|
||||||
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || 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 draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId)
|
||||||
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
|
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
|
||||||
|
const applicationEditableFields = Array.isArray(normalizedPreview.editableFields)
|
||||||
|
? normalizedPreview.editableFields.map((field) => normalizeText(field)).filter(Boolean)
|
||||||
|
: []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: 'user_message',
|
source: 'user_message',
|
||||||
@@ -107,6 +110,9 @@ export function buildAiApplicationPreviewActionPayload({
|
|||||||
application_stage: 'expense_application',
|
application_stage: 'expense_application',
|
||||||
user_input_text: message,
|
user_input_text: message,
|
||||||
application_preview: normalizedPreview,
|
application_preview: normalizedPreview,
|
||||||
|
...(applicationEditableFields.length
|
||||||
|
? { application_editable_fields: applicationEditableFields }
|
||||||
|
: {}),
|
||||||
...(isSubmit
|
...(isSubmit
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -313,7 +313,9 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
|
|||||||
}
|
}
|
||||||
lines.push(
|
lines.push(
|
||||||
'',
|
'',
|
||||||
'> **请先核对**:请先检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先处理或关联已有申请单,避免重复申请。',
|
'**后续行动建议**:',
|
||||||
|
'- 请检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
|
||||||
|
'- 若日期无误,请先处理或关联已有申请单,避免重复申请。',
|
||||||
'',
|
'',
|
||||||
'我会先暂停本次申请表生成,不会开放保存草稿或提交入口。'
|
'我会先暂停本次申请表生成,不会开放保存草稿或提交入口。'
|
||||||
)
|
)
|
||||||
@@ -323,18 +325,17 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
|
|||||||
const normalized = normalizeApplicationPreview(preview)
|
const normalized = normalizeApplicationPreview(preview)
|
||||||
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||||||
const missingText = missingFields.length ? missingFields.join('、') : '暂无'
|
const missingText = missingFields.length ? missingFields.join('、') : '暂无'
|
||||||
const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '**预算管理者审核提示**' : '**预算与审批影响**'
|
const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '预算管理者审核提示' : '预算与审批影响'
|
||||||
const overlapPrefix = precheck?.overlap?.status === 'warning' ? '**时间重叠提醒**' : '**单据重叠核查**'
|
const overlapPrefix = precheck?.overlap?.status === 'warning' ? '时间重叠提醒' : '单据重叠核查'
|
||||||
const lines = [
|
const lines = [
|
||||||
'### 出差申请表草稿已生成',
|
'### 出差申请表草稿已生成',
|
||||||
'',
|
'',
|
||||||
'**我已完成发起前的单据与预算预审**,并为您生成一张完整的出差申请表。',
|
'**我已完成发起前的单据与预算预审**,并为您生成一张完整的出差申请表。',
|
||||||
'',
|
'',
|
||||||
`> ${overlapPrefix}:${precheck?.overlap?.summary || '已完成已有单据核查。'}`,
|
'**发起前预审结果**:',
|
||||||
'',
|
`- **${overlapPrefix}**:${precheck?.overlap?.summary || '已完成已有单据核查。'}`,
|
||||||
`> ${budgetPrefix}:${precheck?.budget?.summary || '已完成预算影响评估。'}`,
|
`- **${budgetPrefix}**:${precheck?.budget?.summary || '已完成预算影响评估。'}`,
|
||||||
'',
|
`- **仍需补充**:${missingText}`,
|
||||||
`> **仍需补充**:${missingText}`,
|
|
||||||
'',
|
'',
|
||||||
'请直接点击表格中的字段补充或修改;费用测算会根据地点、天数和出行方式自动更新。'
|
'请直接点击表格中的字段补充或修改;费用测算会根据地点、天数和出行方式自动更新。'
|
||||||
]
|
]
|
||||||
@@ -363,14 +364,16 @@ export function buildAiApplicationSubmitConflictMessage(preview = {}, precheck =
|
|||||||
'',
|
'',
|
||||||
`> **相同日期提醒**:${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`,
|
`> **相同日期提醒**:${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`,
|
||||||
'',
|
'',
|
||||||
`> **本次申请时间**:${currentRangeText}`,
|
`**本次申请时间**:${currentRangeText}`,
|
||||||
]
|
]
|
||||||
if (matchTable) {
|
if (matchTable) {
|
||||||
lines.push('', matchTable)
|
lines.push('', matchTable)
|
||||||
}
|
}
|
||||||
lines.push(
|
lines.push(
|
||||||
'',
|
'',
|
||||||
'> **请先核对**:请先核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先查看或处理已有申请单,避免重复申请。',
|
'**后续行动建议**:',
|
||||||
|
'- 请核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
|
||||||
|
'- 若日期无误,请先查看或处理已有申请单,避免重复申请。',
|
||||||
'',
|
'',
|
||||||
'我会先暂停本次提交,不会生成新的审批流。'
|
'我会先暂停本次提交,不会生成新的审批流。'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,13 +26,59 @@ export function getAiExpenseSteps() {
|
|||||||
return DEFAULT_FIELD_STEPS
|
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 {
|
return {
|
||||||
expenseType: normalizeAnswer(expenseType),
|
expenseType: normalizeAnswer(expenseType),
|
||||||
expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
|
expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
|
||||||
applicationClaim: null,
|
applicationClaim: null,
|
||||||
values: {},
|
values,
|
||||||
stepKey: DEFAULT_FIELD_STEPS[0].key
|
stepKey: resolveInitialStepKey(values)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,9 @@ function normalizeMessage(message = {}) {
|
|||||||
streamStatus: safeString(message.stewardPlan.streamStatus) || 'completed'
|
streamStatus: safeString(message.stewardPlan.streamStatus) || 'completed'
|
||||||
}
|
}
|
||||||
: null,
|
: 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),
|
...resolveApplicationValidationIssues(fields),
|
||||||
...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview)
|
...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview)
|
||||||
]
|
]
|
||||||
|
const editableFields = Array.isArray(preview?.editableFields)
|
||||||
|
? preview.editableFields
|
||||||
|
: Array.isArray(preview?.editable_fields)
|
||||||
|
? preview.editable_fields
|
||||||
|
: null
|
||||||
return {
|
return {
|
||||||
...preview,
|
...preview,
|
||||||
|
...(editableFields
|
||||||
|
? {
|
||||||
|
editableFields: editableFields
|
||||||
|
.map((field) => String(field || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
fields,
|
fields,
|
||||||
missingFields,
|
missingFields,
|
||||||
validationIssues,
|
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) {
|
export function applyApplicationBusinessTimeContext(preview = {}, businessTimeContext = null) {
|
||||||
if (!businessTimeContext || typeof businessTimeContext !== 'object') {
|
if (!businessTimeContext || typeof businessTimeContext !== 'object') {
|
||||||
return normalizeApplicationPreview(preview)
|
return normalizeApplicationPreview(preview)
|
||||||
@@ -394,7 +437,7 @@ export function buildApplicationPreviewRows(preview = {}) {
|
|||||||
...item,
|
...item,
|
||||||
label: '出发时间',
|
label: '出发时间',
|
||||||
value: tripDates.startDate || '待补充',
|
value: tripDates.startDate || '待补充',
|
||||||
editable: item.editable !== false,
|
editable: isApplicationPreviewFieldEditable(normalized, item, 'time'),
|
||||||
highlight: Boolean(item.highlight),
|
highlight: Boolean(item.highlight),
|
||||||
missing
|
missing
|
||||||
},
|
},
|
||||||
@@ -402,7 +445,7 @@ export function buildApplicationPreviewRows(preview = {}) {
|
|||||||
key: 'time_return',
|
key: 'time_return',
|
||||||
label: '返回时间',
|
label: '返回时间',
|
||||||
value: tripDates.endDate || '待补充',
|
value: tripDates.endDate || '待补充',
|
||||||
editable: item.editable !== false,
|
editable: isApplicationPreviewFieldEditable(normalized, item, 'time_return'),
|
||||||
highlight: Boolean(item.highlight),
|
highlight: Boolean(item.highlight),
|
||||||
missing
|
missing
|
||||||
}
|
}
|
||||||
@@ -415,7 +458,7 @@ export function buildApplicationPreviewRows(preview = {}) {
|
|||||||
...item,
|
...item,
|
||||||
label: resolveApplicationFieldLabel(item, fields),
|
label: resolveApplicationFieldLabel(item, fields),
|
||||||
value,
|
value,
|
||||||
editable: item.editable !== false,
|
editable: isApplicationPreviewFieldEditable(normalized, item, item.key),
|
||||||
highlight: Boolean(item.highlight),
|
highlight: Boolean(item.highlight),
|
||||||
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
||||||
}]
|
}]
|
||||||
@@ -484,6 +527,10 @@ export function buildApplicationTemplatePreview(currentUser = {}) {
|
|||||||
export function buildLocalApplicationPreviewMessage(preview) {
|
export function buildLocalApplicationPreviewMessage(preview) {
|
||||||
const normalized = normalizeApplicationPreview(preview)
|
const normalized = normalizeApplicationPreview(preview)
|
||||||
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
|
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
|
||||||
|
const editMode = Boolean(normalized.applicationEditMode || normalized.application_edit_mode)
|
||||||
|
if (editMode) {
|
||||||
|
return '我已载入原申请信息。请只修改事由、时间、地点和出行方式;职级、负责人、标准和费用会按规则带入或重新测算。'
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
modelReviewStatus === 'completed'
|
modelReviewStatus === 'completed'
|
||||||
? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。'
|
? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。'
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ export const SECTION_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'appearance',
|
id: 'appearance',
|
||||||
label: '界面皮肤',
|
label: '主题设置',
|
||||||
title: '界面皮肤与主色',
|
title: '主题风格与界面体验',
|
||||||
desc: '整体主色与控件观感',
|
desc: '动感、沉稳与智能风格',
|
||||||
longDesc: '设置当前浏览器的界面主色。默认使用浅蓝企业主题,后续可扩展为企业级统一下发。',
|
longDesc: '选择当前系统的整体体验风格。主题会联动全局主色、控件状态和 AI 模式的对话呈现。',
|
||||||
actionLabel: '保存皮肤设置'
|
actionLabel: '保存主题设置'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'admin',
|
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_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) => ({
|
export const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
|
||||||
value: index + 1,
|
value: index + 1,
|
||||||
label: `${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) {
|
export function normalizeValue(value) {
|
||||||
return String(value ?? '').trim()
|
return String(value ?? '').trim()
|
||||||
}
|
}
|
||||||
@@ -204,6 +251,68 @@ export function getRerankerEndpoint(provider) {
|
|||||||
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(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) {
|
export function buildDefaultState(companyProfile, currentUser) {
|
||||||
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
|
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
|
||||||
const companyCode = normalizeValue(companyProfile?.code) || 'XF-001'
|
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.`
|
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
|
||||||
},
|
},
|
||||||
appearanceForm: {
|
appearanceForm: {
|
||||||
themeSkin: 'sky'
|
themeSkin: 'enterprise'
|
||||||
},
|
},
|
||||||
adminForm: {
|
adminForm: {
|
||||||
adminAccount,
|
adminAccount,
|
||||||
@@ -241,6 +350,27 @@ export function buildDefaultState(companyProfile, currentUser) {
|
|||||||
conversationRetentionDays: 3
|
conversationRetentionDays: 3
|
||||||
},
|
},
|
||||||
llmForm: {
|
llmForm: {
|
||||||
|
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,
|
||||||
|
models: buildLlmModelRows({
|
||||||
mainProvider: 'Codex',
|
mainProvider: 'Codex',
|
||||||
mainModel: 'codex-mini-latest',
|
mainModel: 'codex-mini-latest',
|
||||||
mainEndpoint: getProviderEndpoint('Codex'),
|
mainEndpoint: getProviderEndpoint('Codex'),
|
||||||
@@ -261,6 +391,7 @@ export function buildDefaultState(companyProfile, currentUser) {
|
|||||||
rerankerEndpoint: getRerankerEndpoint('Ali'),
|
rerankerEndpoint: getRerankerEndpoint('Ali'),
|
||||||
rerankerApiKey: '',
|
rerankerApiKey: '',
|
||||||
rerankerApiKeyConfigured: false
|
rerankerApiKeyConfigured: false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
renderForm: {
|
renderForm: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -326,6 +457,7 @@ export function mergeState(baseState, overrideState) {
|
|||||||
mergedLlmForm.rerankerProvider,
|
mergedLlmForm.rerankerProvider,
|
||||||
baseState.llmForm.rerankerProvider
|
baseState.llmForm.rerankerProvider
|
||||||
)
|
)
|
||||||
|
mergedLlmForm.models = buildLlmModelRows(mergedLlmForm)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
|
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
|
||||||
@@ -355,11 +487,15 @@ export function sanitizeForStorage(state) {
|
|||||||
sessionForm: { ...state.sessionForm },
|
sessionForm: { ...state.sessionForm },
|
||||||
hermesForm: mergeHermesEmployeeForm(state.hermesForm),
|
hermesForm: mergeHermesEmployeeForm(state.hermesForm),
|
||||||
llmForm: {
|
llmForm: {
|
||||||
...state.llmForm,
|
...syncLegacyModelFieldsFromRows(state.llmForm),
|
||||||
mainApiKey: '',
|
mainApiKey: '',
|
||||||
backupApiKey: '',
|
backupApiKey: '',
|
||||||
embeddingApiKey: '',
|
embeddingApiKey: '',
|
||||||
rerankerApiKey: ''
|
rerankerApiKey: '',
|
||||||
|
models: normalizeLlmModelRows(state.llmForm.models).map((row) => ({
|
||||||
|
...row,
|
||||||
|
apiKey: ''
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
renderForm: {
|
renderForm: {
|
||||||
...state.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
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLlmPayload(llmForm) {
|
export function buildLlmPayload(llmForm) {
|
||||||
const payload = { ...llmForm }
|
const payload = syncLegacyModelFieldsFromRows({
|
||||||
|
...llmForm,
|
||||||
|
models: normalizeLlmModelRows(llmForm.models)
|
||||||
|
})
|
||||||
|
|
||||||
for (const config of MODEL_API_KEY_CONFIGS) {
|
for (const config of MODEL_API_KEY_CONFIGS) {
|
||||||
if (isModelSecretMask(payload[config.apiKeyKey])) {
|
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
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,20 +608,13 @@ export function computeSectionStatus(state) {
|
|||||||
Number(state.sessionForm.conversationRetentionDays) <= 10
|
Number(state.sessionForm.conversationRetentionDays) <= 10
|
||||||
),
|
),
|
||||||
hermes: isHermesEmployeeSettingsReady(state.hermesForm),
|
hermes: isHermesEmployeeSettingsReady(state.hermesForm),
|
||||||
llm: Boolean(
|
llm: (() => {
|
||||||
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
|
const rows = normalizeLlmModelRows(state.llmForm.models)
|
||||||
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
|
return Boolean(
|
||||||
isModelConfigReady(
|
rows.length > 0 &&
|
||||||
state.llmForm.embeddingProvider,
|
rows.every((row) => isModelConfigReady(row.provider, row.modelId, row.url))
|
||||||
state.llmForm.embeddingModel,
|
|
||||||
state.llmForm.embeddingEndpoint
|
|
||||||
) &&
|
|
||||||
isModelConfigReady(
|
|
||||||
state.llmForm.rerankerProvider,
|
|
||||||
state.llmForm.rerankerModel,
|
|
||||||
state.llmForm.rerankerEndpoint
|
|
||||||
)
|
)
|
||||||
),
|
})(),
|
||||||
rendering: Boolean(
|
rendering: Boolean(
|
||||||
!state.renderForm.enabled ||
|
!state.renderForm.enabled ||
|
||||||
(normalizeValue(state.renderForm.publicUrl) &&
|
(normalizeValue(state.renderForm.publicUrl) &&
|
||||||
|
|||||||
@@ -227,6 +227,7 @@
|
|||||||
:initial-budget-context="smartEntryContext.budgetContext"
|
:initial-budget-context="smartEntryContext.budgetContext"
|
||||||
:initial-prompt-auto-submit="smartEntryContext.initialPromptAutoSubmit"
|
:initial-prompt-auto-submit="smartEntryContext.initialPromptAutoSubmit"
|
||||||
:initial-application-preview="smartEntryContext.initialApplicationPreview"
|
:initial-application-preview="smartEntryContext.initialApplicationPreview"
|
||||||
|
:initial-draft-payload="smartEntryContext.initialDraftPayload"
|
||||||
:entry-source="smartEntryContext.source"
|
:entry-source="smartEntryContext.source"
|
||||||
:request-context="smartEntryContext.request"
|
:request-context="smartEntryContext.request"
|
||||||
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
||||||
|
|||||||
@@ -1,316 +1,178 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="model-grid">
|
<div class="model-config-surface">
|
||||||
<!-- 主模型配置 -->
|
<section class="settings-card model-table-card">
|
||||||
<section class="settings-card">
|
<div class="card-head model-table-toolbar">
|
||||||
<div class="card-head">
|
|
||||||
<div class="card-title-with-icon">
|
<div class="card-title-with-icon">
|
||||||
<div class="model-icon-box purple">
|
<div class="model-icon-box purple">
|
||||||
<i class="mdi mdi-brain"></i>
|
<span class="model-icon-text">AI</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4>主模型配置</h4>
|
<h4>模型配置</h4>
|
||||||
<p>用于 AI 助手和主业务排队调度的默认模型接入。</p>
|
<p>集中维护大语言模型、Embedding 和 Rerank 模型接入。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-head-actions">
|
<div class="card-head-actions">
|
||||||
<button
|
<button class="add-model-button" type="button" @click="openAddModelDialog">
|
||||||
class="test-button"
|
<i class="mdi mdi-plus"></i>
|
||||||
type="button"
|
<span>添加模型</span>
|
||||||
:disabled="isModelTesting('main')"
|
|
||||||
@click="testModelConnection('main')"
|
|
||||||
>
|
|
||||||
<i :class="isModelTesting('main') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
|
||||||
<span>{{ isModelTesting('main') ? '测试中...' : '测试模型' }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="model-table-wrap">
|
||||||
<label class="field">
|
<table class="model-config-table">
|
||||||
<span><em>*</em> 供应商</span>
|
<thead>
|
||||||
<EnterpriseSelect
|
<tr>
|
||||||
v-model="llmForm.mainProvider"
|
<th>模型类型</th>
|
||||||
:options="providerOptions"
|
<th>供应商</th>
|
||||||
placeholder="选择供应商"
|
<th>model_id</th>
|
||||||
@change="applyProviderPreset('main')"
|
<th>接口地址</th>
|
||||||
/>
|
<th>API Key</th>
|
||||||
</label>
|
<th>连通性</th>
|
||||||
|
<th class="model-action-col">操作</th>
|
||||||
<label class="field">
|
</tr>
|
||||||
<span><em>*</em> 模型名称</span>
|
</thead>
|
||||||
<input v-model="llmForm.mainModel" type="text" placeholder="请输入主模型名称" />
|
<tbody>
|
||||||
</label>
|
<tr v-for="model in modelRows" :key="model.slot">
|
||||||
|
<td>
|
||||||
<label class="field field-full">
|
<span class="model-type-pill">
|
||||||
<span><em>*</em> 接口地址</span>
|
<span>{{ getModelTypeLabel(model.type) }}</span>
|
||||||
<input v-model="llmForm.mainEndpoint" type="text" placeholder="请输入模型接口地址" />
|
</span>
|
||||||
</label>
|
</td>
|
||||||
|
<td>
|
||||||
<label class="field field-full">
|
<strong class="model-provider-name">{{ model.provider }}</strong>
|
||||||
<span>API Key</span>
|
</td>
|
||||||
<input
|
<td>
|
||||||
v-model="llmForm.mainApiKey"
|
<code class="model-id-text">{{ model.modelId }}</code>
|
||||||
type="password"
|
</td>
|
||||||
autocomplete="off"
|
<td>
|
||||||
@focus="clearModelSecretMask('main')"
|
<span class="model-url-text" :title="model.url">{{ model.url }}</span>
|
||||||
:placeholder="llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
</td>
|
||||||
/>
|
<td>
|
||||||
<small v-if="llmForm.mainApiKeyConfigured" class="secret-bound-state">
|
<span class="secret-state" :class="{ configured: model.apiKeyConfigured }">
|
||||||
<i class="mdi mdi-database-lock"></i>
|
<i :class="model.apiKeyConfigured ? 'mdi mdi-database-lock' : 'mdi mdi-key-outline'"></i>
|
||||||
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
<span>{{ model.apiKeyConfigured ? '已配置' : '未配置' }}</span>
|
||||||
</small>
|
</span>
|
||||||
</label>
|
</td>
|
||||||
</div>
|
<td>
|
||||||
|
<span
|
||||||
<div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`">
|
v-if="getModelTestState(model.slot).message"
|
||||||
|
class="test-feedback-inline"
|
||||||
|
:class="`is-${getModelTestState(model.slot).status}`"
|
||||||
|
>
|
||||||
<i
|
<i
|
||||||
:class="
|
:class="
|
||||||
getModelTestState('main').status === 'success'
|
getModelTestState(model.slot).status === 'success'
|
||||||
? 'mdi mdi-check-circle'
|
? 'mdi mdi-check-circle'
|
||||||
: getModelTestState('main').status === 'testing'
|
: getModelTestState(model.slot).status === 'testing'
|
||||||
? 'mdi mdi-loading mdi-spin'
|
? 'mdi mdi-loading mdi-spin'
|
||||||
: 'mdi mdi-alert-circle'
|
: 'mdi mdi-alert-circle'
|
||||||
"
|
"
|
||||||
></i>
|
></i>
|
||||||
<span>{{ getModelTestState('main').message }}</span>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 备份模型配置 -->
|
<div v-if="modelDialogOpen" class="model-dialog-overlay" @click.self="closeModelDialog">
|
||||||
<section class="settings-card">
|
<section class="model-dialog" role="dialog" aria-modal="true" aria-labelledby="model-dialog-title">
|
||||||
<div class="card-head">
|
<header class="model-dialog-head">
|
||||||
<div class="card-title-with-icon">
|
|
||||||
<div class="model-icon-box orange">
|
|
||||||
<i class="mdi mdi-lifebuoy"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h4>备份模型配置</h4>
|
<h4 id="model-dialog-title">{{ modelDraft.slot ? '编辑模型' : '添加模型' }}</h4>
|
||||||
<p>主模型不可用或限频时用于兜底切换的备用模型接入。</p>
|
<p>配置供应商、URL、密钥、model_id 和模型类型。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button class="icon-action-button" type="button" title="关闭" @click="closeModelDialog">
|
||||||
<div class="card-head-actions">
|
<span>关闭</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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="modelDraft.url" type="text" placeholder="https://api.example.com/v1" />
|
||||||
|
</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">
|
||||||
|
<span><em>*</em> model_id</span>
|
||||||
|
<input v-model="modelDraft.modelId" type="text" placeholder="例如 gpt-5.4-mini" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field field-full">
|
||||||
|
<span><em>*</em> 模型类型</span>
|
||||||
|
<div class="model-type-segment" :class="{ disabled: isEditingFixedModel }">
|
||||||
<button
|
<button
|
||||||
class="test-button"
|
v-for="option in MODEL_TYPE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="isModelTesting('backup')"
|
:class="{ active: modelDraft.type === option.value }"
|
||||||
@click="testModelConnection('backup')"
|
:disabled="isEditingFixedModel"
|
||||||
|
@click="selectDraftModelType(option.value)"
|
||||||
>
|
>
|
||||||
<i :class="isModelTesting('backup') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
<span>{{ option.label }}</span>
|
||||||
<span>{{ isModelTesting('backup') ? '测试中...' : '测试模型' }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 供应商</span>
|
|
||||||
<EnterpriseSelect
|
|
||||||
v-model="llmForm.backupProvider"
|
|
||||||
:options="providerOptions"
|
|
||||||
placeholder="选择供应商"
|
|
||||||
@change="applyProviderPreset('backup')"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 模型名称</span>
|
|
||||||
<input v-model="llmForm.backupModel" type="text" placeholder="请输入备份模型名称" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span><em>*</em> 接口地址</span>
|
|
||||||
<input v-model="llmForm.backupEndpoint" type="text" placeholder="请输入模型接口地址" />
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`">
|
<footer class="model-dialog-actions">
|
||||||
<i
|
<button class="secondary-button" type="button" @click="closeModelDialog">取消</button>
|
||||||
:class="
|
<button class="save-button compact" type="button" @click="saveModelDialog">
|
||||||
getModelTestState('backup').status === 'success'
|
<span>保存模型</span>
|
||||||
? '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>
|
|
||||||
</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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</footer>
|
||||||
</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>
|
</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>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -109,44 +109,51 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="card-title-with-icon">
|
<div class="card-title-with-icon">
|
||||||
<div class="model-icon-box slate">
|
<div class="model-icon-box slate">
|
||||||
<i class="mdi mdi-palette-outline"></i>
|
<i class="mdi mdi-tune-variant"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4>界面皮肤与企业主色</h4>
|
<h4>主题风格与界面体验</h4>
|
||||||
<p>只调整整体主色、焦点态、按钮和 Element Plus 控件颜色,不改变业务布局。</p>
|
<p>选择系统整体体验风格,并联动 AI 模式的对话、图标、卡片和提示样式。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="skin-option-grid">
|
<div class="theme-option-grid">
|
||||||
<button
|
<button
|
||||||
v-for="skin in themeSkinOptions"
|
v-for="theme in themeSkinOptions"
|
||||||
:key="skin.id"
|
:key="theme.id"
|
||||||
class="skin-option"
|
class="theme-option"
|
||||||
:class="{ active: activeThemeSkinId === skin.id }"
|
:class="{ active: activeThemeSkinId === theme.id }"
|
||||||
type="button"
|
type="button"
|
||||||
@click="selectThemeSkin(skin.id)"
|
@click="selectThemeSkin(theme.id)"
|
||||||
>
|
>
|
||||||
<span class="skin-swatch" aria-hidden="true">
|
<span class="theme-style-preview" aria-hidden="true">
|
||||||
<i :style="{ background: skin.primary }"></i>
|
<i :style="{ background: theme.primary }"></i>
|
||||||
<i :style="{ background: skin.primarySoftStrong }"></i>
|
<i :style="{ background: theme.primarySoftStrong }"></i>
|
||||||
<i :style="{ background: skin.secondary }"></i>
|
<i :style="{ background: theme.secondary }"></i>
|
||||||
<i :style="{ background: skin.chartAmber }"></i>
|
<i :style="{ background: theme.chartAmber }"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="skin-copy">
|
<span class="theme-copy">
|
||||||
<strong>{{ skin.label }}</strong>
|
<strong>{{ theme.label }}</strong>
|
||||||
<small>{{ skin.desc }}</small>
|
<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>
|
||||||
|
<span v-if="activeThemeSkinId === theme.id" class="theme-current">当前</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="skin-preview-panel">
|
<div class="theme-preview-panel">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ activeThemeSkin.label }}</strong>
|
<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>
|
</div>
|
||||||
<button class="skin-preview-action" type="button">主按钮</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -43,10 +43,84 @@
|
|||||||
v-for="item in applicationDetailFactItems"
|
v-for="item in applicationDetailFactItems"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
class="application-detail-fact"
|
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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<TravelRequestBudgetAnalysis
|
<TravelRequestBudgetAnalysis
|
||||||
@@ -458,16 +532,6 @@
|
|||||||
<i class="mdi mdi-trash-can-outline"></i>
|
<i class="mdi mdi-trash-can-outline"></i>
|
||||||
{{ deleteBusy ? '删除中' : deleteActionLabel }}
|
{{ deleteBusy ? '删除中' : deleteActionLabel }}
|
||||||
</button>
|
</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">
|
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
|
||||||
<i :class="submitActionIcon"></i>
|
<i :class="submitActionIcon"></i>
|
||||||
{{ submitActionLabel }}
|
{{ submitActionLabel }}
|
||||||
|
|||||||
@@ -1,103 +1,55 @@
|
|||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { testModelConnectivity } from '../../services/settings.js'
|
import { testModelConnectivity } from '../../services/settings.js'
|
||||||
import { useToast } from '../../composables/useToast.js'
|
import { useToast } from '../../composables/useToast.js'
|
||||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
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 = {
|
function buildEmptyModelDraft() {
|
||||||
main: {
|
return {
|
||||||
label: '主模型',
|
slot: '',
|
||||||
providerKey: 'mainProvider',
|
provider: CUSTOM_OPENAI_PROVIDER,
|
||||||
modelKey: 'mainModel',
|
url: '',
|
||||||
endpointKey: 'mainEndpoint',
|
apiKey: '',
|
||||||
apiKeyKey: 'mainApiKey',
|
apiKeyConfigured: false,
|
||||||
capability: 'chat'
|
modelId: '',
|
||||||
},
|
type: 'llm'
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
|
function generateModelSlot(type) {
|
||||||
|
const prefix = type === 'embedding' ? 'embedding' : type === 'rerank' ? 'rerank' : 'llm'
|
||||||
const PROVIDER_ENDPOINTS = {
|
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
||||||
MiniMax: 'https://api.minimaxi.com/v1',
|
return `${prefix}_${suffix}`
|
||||||
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]: ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RERANKER_PROVIDER_ENDPOINTS = {
|
function normalizeDraftModel(draft) {
|
||||||
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
|
return normalizeLlmModelRows([
|
||||||
[CUSTOM_OPENAI_PROVIDER]: ''
|
{
|
||||||
}
|
slot: draft.slot || generateModelSlot(draft.type),
|
||||||
|
provider: draft.provider,
|
||||||
const LEGACY_PROVIDER_MAP = {
|
url: draft.url,
|
||||||
'OpenAI Compatible': 'Codex',
|
apiKey: draft.apiKey,
|
||||||
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
|
apiKeyConfigured: draft.apiKeyConfigured,
|
||||||
Ollama: CUSTOM_OPENAI_PROVIDER,
|
modelId: draft.modelId,
|
||||||
'自定义网关': CUSTOM_OPENAI_PROVIDER
|
type: draft.type
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
])[0]
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -117,81 +69,170 @@ export default {
|
|||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const modelTestState = ref({
|
const modelTestState = ref({})
|
||||||
main: { status: 'idle', message: '' },
|
const modelDialogOpen = ref(false)
|
||||||
backup: { status: 'idle', message: '' },
|
const editingSlot = ref('')
|
||||||
embedding: { status: 'idle', message: '' },
|
const modelDraft = ref(buildEmptyModelDraft())
|
||||||
reranker: { status: 'idle', message: '' }
|
|
||||||
})
|
|
||||||
|
|
||||||
function applyProviderPreset(testKey) {
|
const modelRows = computed(() => normalizeLlmModelRows(props.llmForm.models))
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
const isEditingFixedModel = computed(() => isFixedModelSlot(editingSlot.value))
|
||||||
const provider = normalizeProviderValue(props.llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER)
|
|
||||||
|
|
||||||
props.llmForm[config.providerKey] = provider
|
function replaceModelRows(rows) {
|
||||||
props.llmForm[config.endpointKey] =
|
props.llmForm.models = normalizeLlmModelRows(rows)
|
||||||
testKey === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelTestState(testKey) {
|
function getModelTypeLabel(type) {
|
||||||
return modelTestState.value[testKey] || { status: 'idle', message: '' }
|
return MODEL_TYPE_LABELS[type] || MODEL_TYPE_LABELS.llm
|
||||||
}
|
}
|
||||||
|
|
||||||
function isModelTesting(testKey) {
|
function isFixedModelSlot(slot) {
|
||||||
return getModelTestState(testKey).status === 'testing'
|
return FIXED_MODEL_SLOTS.has(String(slot || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearModelSecretMask(testKey) {
|
function getModelTestState(slot) {
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
return modelTestState.value[slot] || { status: 'idle', message: '' }
|
||||||
if (isModelSecretMask(props.llmForm[config.apiKeyKey])) {
|
}
|
||||||
props.llmForm[config.apiKeyKey] = ''
|
|
||||||
|
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) {
|
function validateDraftModel() {
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
const provider = normalizeValue(modelDraft.value.provider)
|
||||||
const provider = props.llmForm[config.providerKey]
|
const url = normalizeValue(modelDraft.value.url)
|
||||||
const model = props.llmForm[config.modelKey]
|
const modelId = normalizeValue(modelDraft.value.modelId)
|
||||||
const endpoint = props.llmForm[config.endpointKey]
|
|
||||||
const apiKey = props.llmForm[config.apiKeyKey]
|
|
||||||
|
|
||||||
if (!isModelConfigReady(provider, model, endpoint)) {
|
if (!provider || !url || !modelId) {
|
||||||
const message = `请先完整填写${config.label}的供应商、模型名称和接口地址。`
|
toast('请完整填写供应商、接口地址和 model_id。')
|
||||||
modelTestState.value[testKey] = { status: 'error', message }
|
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)
|
toast(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modelTestState.value[testKey] = { status: 'testing', message: '正在测试模型连通性...' }
|
modelTestState.value[model.slot] = { status: 'testing', message: '正在测试模型连通性...' }
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
provider,
|
provider: model.provider,
|
||||||
model,
|
model: model.modelId,
|
||||||
endpoint,
|
endpoint: model.url,
|
||||||
api_key: isModelSecretMask(apiKey) ? '' : apiKey,
|
api_key: model.apiKey === MODEL_SECRET_MASK ? '' : model.apiKey,
|
||||||
capability: config.capability,
|
capability: MODEL_TYPE_CAPABILITY[model.type] || 'chat',
|
||||||
slot: testKey
|
slot: model.slot
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await testModelConnectivity(payload)
|
const result = await testModelConnectivity(payload)
|
||||||
modelTestState.value[testKey] = {
|
modelTestState.value[model.slot] = {
|
||||||
status: result.ok ? 'success' : 'error',
|
status: result.ok ? 'success' : 'error',
|
||||||
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
|
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
|
||||||
}
|
}
|
||||||
toast(modelTestState.value[testKey].message)
|
toast(modelTestState.value[model.slot].message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
|
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
|
||||||
modelTestState.value[testKey] = { status: 'error', message }
|
modelTestState.value[model.slot] = { status: 'error', message }
|
||||||
toast(message)
|
toast(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
applyProviderPreset,
|
MODEL_TYPE_OPTIONS,
|
||||||
|
applyProviderPresetToDraft,
|
||||||
|
clearDraftSecretMask,
|
||||||
|
closeModelDialog,
|
||||||
getModelTestState,
|
getModelTestState,
|
||||||
|
getModelTypeLabel,
|
||||||
|
isEditingFixedModel,
|
||||||
|
isFixedModelSlot,
|
||||||
isModelTesting,
|
isModelTesting,
|
||||||
clearModelSecretMask,
|
modelDialogOpen,
|
||||||
|
modelDraft,
|
||||||
|
modelRows,
|
||||||
|
openAddModelDialog,
|
||||||
|
openEditModelDialog,
|
||||||
|
removeModel,
|
||||||
|
saveModelDialog,
|
||||||
|
selectDraftModelType,
|
||||||
testModelConnection
|
testModelConnection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
|
initialDraftPayload: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
initialFiles: {
|
initialFiles: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
|||||||
|
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
import { useToast } from '../../composables/useToast.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 {
|
import {
|
||||||
canApproveBudgetExpenseApplications,
|
canApproveBudgetExpenseApplications,
|
||||||
canApproveLeaderExpenseClaims,
|
canApproveLeaderExpenseClaims,
|
||||||
@@ -23,6 +28,13 @@ import {
|
|||||||
import { buildRiskViewerContext } from '../../utils/riskVisibility.js'
|
import { buildRiskViewerContext } from '../../utils/riskVisibility.js'
|
||||||
import { resolveProgressStepsForViewer } from '../../utils/requestProgressViewer.js'
|
import { resolveProgressStepsForViewer } from '../../utils/requestProgressViewer.js'
|
||||||
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||||
|
import {
|
||||||
|
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||||
|
applyApplicationPolicyEstimateError,
|
||||||
|
applyApplicationPolicyEstimateResult,
|
||||||
|
buildApplicationPolicyEstimateRequest,
|
||||||
|
normalizeApplicationPreview
|
||||||
|
} from '../../utils/expenseApplicationPreview.js'
|
||||||
import {
|
import {
|
||||||
EXPENSE_TYPE_OPTIONS,
|
EXPENSE_TYPE_OPTIONS,
|
||||||
buildFallbackExpenseItems,
|
buildFallbackExpenseItems,
|
||||||
@@ -43,6 +55,11 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|
|||||||
const { currentUser } = useSystemState()
|
const { currentUser } = useSystemState()
|
||||||
const expenseItems = ref([])
|
const expenseItems = ref([])
|
||||||
const expenseAttachmentMeta = reactive({})
|
const expenseAttachmentMeta = reactive({})
|
||||||
|
const applicationDetailEditor = reactive({
|
||||||
|
fieldKey: '',
|
||||||
|
draftValue: '',
|
||||||
|
saving: false
|
||||||
|
})
|
||||||
const riskFlagPreviewSnapshot = ref(null)
|
const riskFlagPreviewSnapshot = ref(null)
|
||||||
let actionBusy = { value: false }
|
let actionBusy = { value: false }
|
||||||
const getActionBusy = () => Boolean(actionBusy?.value)
|
const getActionBusy = () => Boolean(actionBusy?.value)
|
||||||
@@ -92,11 +109,10 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|
|||||||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||||||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
||||||
const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
|
const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
|
||||||
const canModifyReturnedApplication = computed(() => (
|
const canModifyApplication = computed(() => (
|
||||||
isApplicationDocument.value
|
isApplicationDocument.value
|
||||||
&& isEditableRequest.value
|
&& isEditableRequest.value
|
||||||
&& isCurrentApplicant.value
|
&& isCurrentApplicant.value
|
||||||
&& String(request.value.status || '').trim().toLowerCase() === 'returned'
|
|
||||||
))
|
))
|
||||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||||
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
||||||
@@ -261,6 +277,7 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|
|||||||
|| approvalFlow.returnBusy.value
|
|| approvalFlow.returnBusy.value
|
||||||
|| approvalFlow.approveBusy.value
|
|| approvalFlow.approveBusy.value
|
||||||
|| paymentFlow.payBusy.value
|
|| paymentFlow.payBusy.value
|
||||||
|
|| applicationDetailEditor.saving
|
||||||
|| smartEntryRecognitionBusy.value
|
|| smartEntryRecognitionBusy.value
|
||||||
|| Boolean(uploadingExpenseId.value)
|
|| Boolean(uploadingExpenseId.value)
|
||||||
|| Boolean(deletingAttachmentId.value)
|
|| Boolean(deletingAttachmentId.value)
|
||||||
@@ -350,6 +367,15 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|
|||||||
)
|
)
|
||||||
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
||||||
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(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(
|
watch(
|
||||||
request,
|
request,
|
||||||
@@ -366,6 +392,7 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|
|||||||
attachmentPreview.closeAttachmentPreview()
|
attachmentPreview.closeAttachmentPreview()
|
||||||
}
|
}
|
||||||
expenseEditor.resetExpenseWorkState()
|
expenseEditor.resetExpenseWorkState()
|
||||||
|
cancelApplicationDetailEditor()
|
||||||
void attachmentPreview.syncExpenseAttachmentMeta()
|
void attachmentPreview.syncExpenseAttachmentMeta()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -403,6 +430,8 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|
|||||||
return {
|
return {
|
||||||
sourceText: '修改申请',
|
sourceText: '修改申请',
|
||||||
modelReviewStatus: 'template',
|
modelReviewStatus: 'template',
|
||||||
|
applicationEditMode: true,
|
||||||
|
editableFields: applicationEditEditableFields,
|
||||||
fields: {
|
fields: {
|
||||||
applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请',
|
applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请',
|
||||||
applicant: request.value.profileName || request.value.person || request.value.applicant || '',
|
applicant: request.value.profileName || request.value.person || request.value.applicant || '',
|
||||||
@@ -424,27 +453,163 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleModifyApplication() {
|
function buildApplicationEditDraftPayload() {
|
||||||
if (!canModifyReturnedApplication.value) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const claimId = String(request.value?.claimId || '').trim()
|
applicationDetailEditor.saving = true
|
||||||
emit('openAssistant', {
|
try {
|
||||||
source: 'application',
|
const preview = await refreshEditedApplicationPreviewEstimate(buildEditedApplicationPreview(item))
|
||||||
sessionType: 'application',
|
const payload = await runAiApplicationPreviewAction({
|
||||||
prompt: '',
|
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||||
applicationPreview: buildApplicationEditPreview(),
|
applicationPreview: preview,
|
||||||
request: {
|
currentUser: currentUser.value || {},
|
||||||
...request.value,
|
draftPayload: buildApplicationEditDraftPayload()
|
||||||
applicationEditMode: true
|
|
||||||
},
|
|
||||||
restoreLatestConversation: false,
|
|
||||||
initialPromptAutoSubmit: false,
|
|
||||||
scope: claimId
|
|
||||||
? { type: 'claim', claimId }
|
|
||||||
: null
|
|
||||||
})
|
})
|
||||||
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -461,22 +626,25 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|
|||||||
...expenseEditor,
|
...expenseEditor,
|
||||||
...paymentFlow,
|
...paymentFlow,
|
||||||
...riskSubmit,
|
...riskSubmit,
|
||||||
|
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||||
|
applicationDetailEditor,
|
||||||
applicationDetailFactItems,
|
applicationDetailFactItems,
|
||||||
relatedApplicationFactItems,
|
relatedApplicationFactItems,
|
||||||
|
canEditApplicationDetailItem,
|
||||||
canDeleteRequest,
|
canDeleteRequest,
|
||||||
canManageCurrentClaim,
|
canManageCurrentClaim,
|
||||||
canModifyReturnedApplication,
|
canModifyApplication,
|
||||||
canOpenAiEntry,
|
canOpenAiEntry,
|
||||||
canApproveRequest,
|
canApproveRequest,
|
||||||
canReturnRequest,
|
canReturnRequest,
|
||||||
currentProgressRingMotion,
|
currentProgressRingMotion,
|
||||||
expenseItems,
|
expenseItems,
|
||||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||||
handleModifyApplication,
|
|
||||||
hasLeaderApprovalEvents,
|
hasLeaderApprovalEvents,
|
||||||
hasSingleLeaderApprovalEvent,
|
hasSingleLeaderApprovalEvent,
|
||||||
heroFactItems,
|
heroFactItems,
|
||||||
isApplicationDocument,
|
isApplicationDocument,
|
||||||
|
isApplicationDetailEditing,
|
||||||
isDraftRequest,
|
isDraftRequest,
|
||||||
isEditableRequest,
|
isEditableRequest,
|
||||||
isTravelRequest,
|
isTravelRequest,
|
||||||
@@ -485,8 +653,12 @@ export function useTravelRequestDetailSetup(props, { emit }) {
|
|||||||
profile,
|
profile,
|
||||||
progressSteps,
|
progressSteps,
|
||||||
request,
|
request,
|
||||||
|
cancelApplicationDetailEditor,
|
||||||
|
openApplicationDetailEditor,
|
||||||
resolveExpenseReasonHelper,
|
resolveExpenseReasonHelper,
|
||||||
resolveExpenseReasonPlaceholder,
|
resolveExpenseReasonPlaceholder,
|
||||||
|
resolveApplicationDetailEditorControl,
|
||||||
|
saveApplicationDetailEdit,
|
||||||
showApplicationLeaderOpinion,
|
showApplicationLeaderOpinion,
|
||||||
showBudgetAnalysis,
|
showBudgetAnalysis,
|
||||||
showStageRiskAdvice,
|
showStageRiskAdvice,
|
||||||
|
|||||||
@@ -221,9 +221,13 @@ export function useTravelReimbursementCreateViewLifecycle({
|
|||||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
|
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
|
||||||
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
|
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
|
||||||
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
|
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
|
||||||
|
const draftPayload = props.initialDraftPayload && typeof props.initialDraftPayload === 'object'
|
||||||
|
? props.initialDraftPayload
|
||||||
|
: null
|
||||||
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
|
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
|
||||||
meta: ['修改申请'],
|
meta: ['修改申请'],
|
||||||
applicationPreview
|
applicationPreview,
|
||||||
|
draftPayload
|
||||||
}))
|
}))
|
||||||
persistSessionState()
|
persistSessionState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,9 +95,62 @@ async function testSaveDraftActionUsesFastPreviewEndpoint() {
|
|||||||
assert.equal(body.context_json.application_stage, 'expense_application')
|
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() {
|
async function run() {
|
||||||
await testSubmitActionUsesFastPreviewEndpoint()
|
await testSubmitActionUsesFastPreviewEndpoint()
|
||||||
await testSaveDraftActionUsesFastPreviewEndpoint()
|
await testSaveDraftActionUsesFastPreviewEndpoint()
|
||||||
|
await testEditDraftActionCarriesClaimAndEditableFields()
|
||||||
console.log('ai-application-preview-actions tests passed')
|
console.log('ai-application-preview-actions tests passed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applyAiExpenseAnswer,
|
applyAiExpenseAnswer,
|
||||||
|
buildAiExpenseDraftPrefillValues,
|
||||||
buildAiExpenseStepPrompt,
|
buildAiExpenseStepPrompt,
|
||||||
buildAiExpenseSummary,
|
buildAiExpenseSummary,
|
||||||
createAiExpenseDraft,
|
createAiExpenseDraft,
|
||||||
@@ -71,3 +72,41 @@ test('summary lists every filled field and the linked application', () => {
|
|||||||
assert.match(summary, /AP-202606-001/)
|
assert.match(summary, /AP-202606-001/)
|
||||||
assert.match(summary, /85元/)
|
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.length, 1)
|
||||||
assert.equal(nextHistory[0].id, 'conv-first')
|
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', () => {
|
test('application edit prefill opens assistant without auto submit', () => {
|
||||||
assert.match(appShellRouteView, /:initial-prompt-auto-submit="smartEntryContext\.initialPromptAutoSubmit"/)
|
assert.match(appShellRouteView, /:initial-prompt-auto-submit="smartEntryContext\.initialPromptAutoSubmit"/)
|
||||||
assert.match(appShellRouteView, /:initial-application-preview="smartEntryContext\.initialApplicationPreview"/)
|
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, /initialPromptAutoSubmit:\s*true/)
|
||||||
assert.match(appShellComposable, /initialApplicationPreview:\s*null/)
|
assert.match(appShellComposable, /initialApplicationPreview:\s*null/)
|
||||||
|
assert.match(appShellComposable, /initialDraftPayload:\s*null/)
|
||||||
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*payload\.initialPromptAutoSubmit !== false/)
|
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*payload\.initialPromptAutoSubmit !== false/)
|
||||||
assert.match(appShellComposable, /initialApplicationPreview:\s*payload\.applicationPreview && typeof payload\.applicationPreview === 'object'/)
|
assert.match(appShellComposable, /initialApplicationPreview:\s*payload\.applicationPreview && typeof payload\.applicationPreview === 'object'/)
|
||||||
|
assert.match(appShellComposable, /initialDraftPayload:\s*payload\.draftPayload && typeof payload\.draftPayload === 'object'/)
|
||||||
assert.match(
|
assert.match(
|
||||||
assistantScript,
|
assistantScript,
|
||||||
/initialPromptAutoSubmit:\s*\{[\s\S]*type:\s*Boolean[\s\S]*default:\s*true/
|
/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,
|
assistantScript,
|
||||||
/initialApplicationPreview:\s*\{[\s\S]*type:\s*Object[\s\S]*default:\s*null/
|
/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(
|
assert.match(
|
||||||
assistantSurface,
|
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(assistantSessionStateScript, /&& !props\.initialApplicationPreview/)
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -362,6 +362,40 @@ test('travel application submit can continue with conversational planning recomm
|
|||||||
assert.match(recommendation, /AP-202606030001-ABCDE123/)
|
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', () => {
|
test('application preview renders ordered editable rows and submit text uses edited values', () => {
|
||||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', {
|
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', {
|
||||||
name: '李文静',
|
name: '李文静',
|
||||||
|
|||||||
@@ -7,17 +7,27 @@ const llmSettingsPanel = readFileSync(new URL('../src/views/LlmSettingsPanel.vue
|
|||||||
|
|
||||||
function testLlmSectionReplacesVlmWithReranker() {
|
function testLlmSectionReplacesVlmWithReranker() {
|
||||||
assert.doesNotMatch(settingsView, /VLM 模型/)
|
assert.doesNotMatch(settingsView, /VLM 模型/)
|
||||||
assert.match(llmSettingsPanel, /Reranker 模型配置/)
|
assert.match(llmSettingsPanel, /Rerank/)
|
||||||
assert.match(settingsModel, /rerankerProvider/)
|
assert.match(settingsModel, /rerankerProvider/)
|
||||||
}
|
}
|
||||||
|
|
||||||
function testRerankerCardRendersAfterEmbeddingCard() {
|
function testLlmSectionUsesTableAndAddModelDialog() {
|
||||||
assert.match(llmSettingsPanel, /Embedding 模型配置[\s\S]*Reranker 模型配置/)
|
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() {
|
function run() {
|
||||||
testLlmSectionReplacesVlmWithReranker()
|
testLlmSectionReplacesVlmWithReranker()
|
||||||
testRerankerCardRendersAfterEmbeddingCard()
|
testLlmSectionUsesTableAndAddModelDialog()
|
||||||
|
testSettingsModelKeepsExtensibleModelRows()
|
||||||
console.log('settings llm section tests passed')
|
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, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
|
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returned application detail can open assistant with editable prefill', () => {
|
test('draft or returned application detail edits allowed facts inline', () => {
|
||||||
assert.match(
|
assert.doesNotMatch(detailViewTemplate, /修改申请/)
|
||||||
detailViewTemplate,
|
assert.match(detailViewTemplate, /canEditApplicationDetailItem\(item\)/)
|
||||||
/v-if="canModifyReturnedApplication"[\s\S]*@click="handleModifyApplication"[\s\S]*修改申请/
|
assert.match(detailViewTemplate, /application-detail-edit-btn/)
|
||||||
)
|
assert.match(detailViewTemplate, /openApplicationDetailEditor\(item\)/)
|
||||||
|
assert.match(detailViewTemplate, /saveApplicationDetailEdit\(item\)/)
|
||||||
|
assert.doesNotMatch(detailViewScript, /handleModifyApplication/)
|
||||||
assert.match(
|
assert.match(
|
||||||
detailViewScript,
|
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, /function buildApplicationEditPreview\(\)/)
|
||||||
assert.match(detailViewScript, /applicationDetailFactItems\.value[\s\S]*sourceText:\s*'修改申请'/)
|
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, /applicationEditMode:\s*true/)
|
||||||
assert.match(detailViewScript, /initialPromptAutoSubmit:\s*false/)
|
assert.match(detailViewScript, /editableFields:\s*applicationEditEditableFields/)
|
||||||
assert.match(detailViewScript, /canModifyReturnedApplication,/)
|
assert.match(detailViewScript, /fields:\s*\{[\s\S]*applicationType:[\s\S]*reason:[\s\S]*transportMode:/)
|
||||||
assert.match(detailViewScript, /handleModifyApplication,/)
|
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', () => {
|
test('application detail does not show optional travel receipt reminders', () => {
|
||||||
|
|||||||
@@ -93,6 +93,66 @@ test('workbench steward application confirmation opens inline application previe
|
|||||||
assert.equal(preview.fields.transportMode, '')
|
assert.equal(preview.fields.transportMode, '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('workbench low-confidence application confirmation forwards remaining tasks', () => {
|
||||||
|
let previewCall = null
|
||||||
|
const remainingTasks = [{
|
||||||
|
task_id: 'task-reimbursement-2',
|
||||||
|
task_type: 'reimbursement',
|
||||||
|
assigned_agent: 'reimbursement_assistant',
|
||||||
|
ontology_fields: {
|
||||||
|
expense_type: 'entertainment',
|
||||||
|
expense_type_label: '业务招待费',
|
||||||
|
amount: '2000元',
|
||||||
|
time_range: '2026-06-25',
|
||||||
|
reason: '业务招待'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
const router = useWorkbenchAiActionRouter({
|
||||||
|
aiExpenseDraft: { value: null },
|
||||||
|
applicationFlow: {
|
||||||
|
isInlineSuggestedActionDisabled: () => false,
|
||||||
|
executeInlineApplicationPreviewAction: () => {},
|
||||||
|
startAiApplicationPreview: (...args) => {
|
||||||
|
previewCall = args
|
||||||
|
}
|
||||||
|
},
|
||||||
|
assistantDraft: { value: '' },
|
||||||
|
attachmentFlow: {
|
||||||
|
confirmAiAttachmentAssociation: () => {}
|
||||||
|
},
|
||||||
|
emit: () => {},
|
||||||
|
expenseFlow: {
|
||||||
|
linkAiExpenseApplication: () => {},
|
||||||
|
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||||
|
startAiApplicationPreviewFromAction: () => {},
|
||||||
|
startAiExpenseDraft: () => {}
|
||||||
|
},
|
||||||
|
focusAiModeInput: () => {},
|
||||||
|
hasInlineAttachmentOcrDetails: () => false,
|
||||||
|
resolveLatestInlineUserPrompt: () => '',
|
||||||
|
selectedFiles: { value: [] },
|
||||||
|
startInlineConversation: () => {},
|
||||||
|
toast: () => {},
|
||||||
|
toggleInlineAttachmentOcrDetails: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.handleInlineSuggestedAction({
|
||||||
|
label: '确认发起出差申请',
|
||||||
|
action_type: 'ai_application_confirm_intent',
|
||||||
|
payload: {
|
||||||
|
sourceText: '2月20-23日去上海出差3天,服务国网服务器部署,并且报销昨天的业务招待费2000元',
|
||||||
|
ontologyFields: { location: '上海', reason: '服务国网服务器部署' },
|
||||||
|
stewardRemainingTasks: remainingTasks
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ok(previewCall, 'startAiApplicationPreview 应被调用')
|
||||||
|
assert.deepEqual(previewCall[3].stewardRemainingTasks, remainingTasks)
|
||||||
|
// 低置信确认按钮只在 task1 完成后推进 task2,不再在预览生成时提前推进。
|
||||||
|
assert.equal(previewCall[3].onPreviewReadyForNextTask, undefined)
|
||||||
|
assert.equal(typeof previewCall[3].onApplicationActionCompleted, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
test('workbench reimbursement skip link action opens new reimbursement flow', () => {
|
test('workbench reimbursement skip link action opens new reimbursement flow', () => {
|
||||||
let sceneSelectionPayload = null
|
let sceneSelectionPayload = null
|
||||||
let fallbackConversationStarted = false
|
let fallbackConversationStarted = false
|
||||||
@@ -389,3 +449,70 @@ test('workbench steward executable submit action runs precheck before submit and
|
|||||||
globalThis.fetch = originalFetch
|
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 conversationMessages = createRef(messages)
|
||||||
const applicationSubmitConfirmOpen = createRef(false)
|
const applicationSubmitConfirmOpen = createRef(false)
|
||||||
const applicationSubmitConfirmContext = createRef(null)
|
const applicationSubmitConfirmContext = createRef(null)
|
||||||
@@ -69,7 +69,8 @@ function buildApplicationPreviewFlowHarness(messages) {
|
|||||||
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23,去上海出差,交通火车,保存草稿',
|
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23,去上海出差,交通火车,保存草稿',
|
||||||
scrollInlineConversationToBottom: () => {},
|
scrollInlineConversationToBottom: () => {},
|
||||||
sending: createRef(false),
|
sending: createRef(false),
|
||||||
toast: () => {}
|
toast: () => {},
|
||||||
|
onApplicationActionCompleted: options.onApplicationActionCompleted
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -80,6 +81,129 @@ function buildApplicationPreviewFlowHarness(messages) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('workbench auto-saved application draft continues remaining steward task', async () => {
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
const requests = []
|
||||||
|
globalThis.fetch = async (url, options = {}) => {
|
||||||
|
const normalizedUrl = String(url)
|
||||||
|
if (normalizedUrl.includes('/reimbursements/application-preview-action')) {
|
||||||
|
const body = JSON.parse(String(options.body || '{}'))
|
||||||
|
requests.push({ url: normalizedUrl, body })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
status: 'succeeded',
|
||||||
|
result: {
|
||||||
|
draft_payload: {
|
||||||
|
claim_id: 'claim-auto-saved-draft',
|
||||||
|
claim_no: 'AEW2DDAFL',
|
||||||
|
status: 'draft'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected request: ${normalizedUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const continuedTasks = []
|
||||||
|
const remainingTasks = [{
|
||||||
|
task_id: 'task-reimbursement-2',
|
||||||
|
task_type: 'reimbursement',
|
||||||
|
assigned_agent: 'reimbursement_assistant',
|
||||||
|
summary: '报销昨天的业务招待费 2000 元',
|
||||||
|
ontology_fields: {
|
||||||
|
expense_type: 'entertainment',
|
||||||
|
amount: '2000元',
|
||||||
|
time_range: '2026-06-25',
|
||||||
|
reason: '业务招待费报销'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
const harness = buildApplicationPreviewFlowHarness([], {
|
||||||
|
onApplicationActionCompleted: (tasks, sourceMessage) => {
|
||||||
|
continuedTasks.push({ tasks, sourceMessage })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await harness.flow.startAiApplicationPreview('travel', '差旅费', '2月20-23日去上海出差3天,服务国网服务器部署,并且报销昨天的业务招待费2000元', {
|
||||||
|
autoSaveDraft: true,
|
||||||
|
stewardRemainingTasks: remainingTasks,
|
||||||
|
onApplicationActionCompleted: (tasks, sourceMessage) => {
|
||||||
|
continuedTasks.push({ tasks, sourceMessage, fromOptions: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(requests.length, 1)
|
||||||
|
assert.equal(continuedTasks.length, 1)
|
||||||
|
assert.deepEqual(continuedTasks[0].tasks, remainingTasks)
|
||||||
|
assert.equal(continuedTasks[0].sourceMessage.stewardRemainingTasks, remainingTasks)
|
||||||
|
assert.equal(continuedTasks[0].fromOptions, true)
|
||||||
|
assert.doesNotMatch(harness.conversationMessages.value.at(-1).content, /继续处理费用报销/)
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench application preview does not continue next task until draft is saved or submitted', async () => {
|
||||||
|
// 时序回归:task1 申请核对表刚生成、用户还没点保存草稿/提交时,
|
||||||
|
// 不能提前拉起 task2(会导致两条流程消息和状态互相打架,最终 task2 无反应)。
|
||||||
|
// task2 的推进必须等 task1 真正完成(onApplicationActionCompleted)后再触发。
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
if (String(url).includes('/reimbursements/application-preview-action')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return { status: 'succeeded', result: { draft_payload: { claim_id: 'c1', claim_no: 'AEW2', status: 'draft' } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected request: ${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const continuedTasks = []
|
||||||
|
const remainingTasks = [{
|
||||||
|
task_id: 'task-reimbursement-2',
|
||||||
|
task_type: 'reimbursement',
|
||||||
|
assigned_agent: 'reimbursement_assistant',
|
||||||
|
summary: '报销昨天的业务招待费 2000 元',
|
||||||
|
ontology_fields: { expense_type: 'entertainment', amount: '2000元', time_range: '2026-06-29', reason: '业务招待费报销' }
|
||||||
|
}]
|
||||||
|
const harness = buildApplicationPreviewFlowHarness([], {
|
||||||
|
onApplicationActionCompleted: (tasks) => { continuedTasks.push({ tasks, phase: 'module' }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 第一步:生成申请核对表(不传 autoSaveDraft,模拟用户需要手动操作 task1)
|
||||||
|
await harness.flow.startAiApplicationPreview('travel', '差旅费', '2月20-23去上海出差,并且报销昨天招待费2000元', {
|
||||||
|
stewardRemainingTasks: remainingTasks,
|
||||||
|
onApplicationActionCompleted: (tasks) => { continuedTasks.push({ tasks, phase: 'options' }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 预览生成后,task2 不应被提前拉起
|
||||||
|
assert.equal(continuedTasks.length, 0, '预览生成时不应触发 task2 推进回调')
|
||||||
|
const previewMessage = harness.conversationMessages.value.find((m) => m.applicationPreview)
|
||||||
|
assert.equal(previewMessage?.stewardRemainingTasks?.length, 1, 'task2 应仍挂在核对表消息上等待用户完成 task1')
|
||||||
|
|
||||||
|
// 第二步:用户手动点击"保存草稿"(走 actionRouter,不传 options.onApplicationActionCompleted),
|
||||||
|
// 此时回落到模块级 onApplicationActionCompleted 触发 task2,这正是真实运行时的续跑路径。
|
||||||
|
await harness.flow.executeInlineApplicationPreviewAction('save_draft', previewMessage, {
|
||||||
|
userText: '保存草稿',
|
||||||
|
draftPayload: null
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(continuedTasks.length, 1, '保存草稿完成后应推进 task2')
|
||||||
|
assert.deepEqual(continuedTasks[0].tasks, remainingTasks)
|
||||||
|
assert.equal(continuedTasks[0].phase, 'module', '手动保存草稿走模块级续跑回调')
|
||||||
|
assert.doesNotMatch(harness.conversationMessages.value.at(-1).content, /继续处理费用报销/, '自动续跑时不展示重复的继续处理按钮')
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('workbench saved application draft can be submitted by contextual text without re-planning', async () => {
|
test('workbench saved application draft can be submitted by contextual text without re-planning', async () => {
|
||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
const requests = []
|
const requests = []
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import test from 'node:test'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildWorkbenchDocumentCommandFollowupGuidance,
|
||||||
buildWorkbenchDraftDeletionGuidance,
|
buildWorkbenchDraftDeletionGuidance,
|
||||||
isWorkbenchDraftDeletionIntent,
|
isWorkbenchDraftDeletionIntent,
|
||||||
|
resolveLatestWorkbenchDocumentCommandContext,
|
||||||
resolveLatestWorkbenchDraftPayload
|
resolveLatestWorkbenchDraftPayload
|
||||||
} from '../src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js'
|
} 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')
|
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', () => {
|
test('workbench draft deletion intent is wired before draft slot continuation', () => {
|
||||||
assert.match(commandIntentsScript, /isWorkbenchDraftDeletionIntent/)
|
assert.match(commandIntentsScript, /isWorkbenchDraftDeletionIntent/)
|
||||||
|
assert.match(commandIntentsScript, /resolveLatestWorkbenchDocumentCommandContext/)
|
||||||
|
assert.match(commandIntentsScript, /buildWorkbenchDocumentCommandFollowupGuidance/)
|
||||||
assert.match(commandIntentsScript, /function handleInlineDraftDeletionIntent\(cleanPrompt, entry = \{\}\)/)
|
assert.match(commandIntentsScript, /function handleInlineDraftDeletionIntent\(cleanPrompt, entry = \{\}\)/)
|
||||||
assert.match(commandIntentsScript, /resolveLatestWorkbenchDraftPayload\(conversationMessages\.value\)/)
|
assert.match(commandIntentsScript, /resolveLatestWorkbenchDraftPayload\(conversationMessages\.value\)/)
|
||||||
assert.match(commandIntentsScript, /buildWorkbenchDraftDeletionGuidance\(draftPayload\)/)
|
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', () => {
|
test('workbench AI intent planner prefers server action steps when present', () => {
|
||||||
const plan = normalizeWorkbenchAiIntentPlan({
|
const plan = normalizeWorkbenchAiIntentPlan({
|
||||||
planning_source: 'llm_function_call',
|
planning_source: 'llm_function_call',
|
||||||
@@ -304,7 +346,16 @@ test('workbench AI mode asks steward model plan before fallback execution', () =
|
|||||||
assert.match(personalWorkbenchAiModeScript, /requestedSubmit:\s*travelApplicationRequest\.requestedSubmit/)
|
assert.match(personalWorkbenchAiModeScript, /requestedSubmit:\s*travelApplicationRequest\.requestedSubmit/)
|
||||||
assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/)
|
assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/)
|
||||||
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
|
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /stewardRemainingTasks:\s*travelApplicationRequest\.stewardRemainingTasks/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /onApplicationActionCompleted:\s*startModelPlannedNextTask/)
|
||||||
|
// 多 task 串行推进:预览生成时不再提前拉起下一个 task(会与用户在 task1 上的操作互相打架),
|
||||||
|
// 改为只在 task1 完成(保存草稿/提交)后通过 onApplicationActionCompleted 推进 task2。
|
||||||
|
assert.doesNotMatch(personalWorkbenchAiModeScript, /onPreviewReadyForNextTask/)
|
||||||
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
|
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
|
||||||
|
assert.doesNotMatch(applicationPreviewFlowScript, /onPreviewReadyForNextTask/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /const actionCompletedHandler = typeof options\.onApplicationActionCompleted === 'function'/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /actionCompletedHandler\(targetMessage\.stewardRemainingTasks/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /onApplicationActionCompleted:\s*options\.onApplicationActionCompleted/)
|
||||||
assert.doesNotMatch(applicationPreviewFlowScript, /options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit/)
|
assert.doesNotMatch(applicationPreviewFlowScript, /options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit/)
|
||||||
assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/)
|
assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/)
|
||||||
assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)
|
assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)
|
||||||
|
|||||||
Reference in New Issue
Block a user