3 Commits

Author SHA1 Message Date
caoxiaozhu
a12c4bea64 chore: 更新公司通信费规则表、work-log 与 superpowers specs 文档 2026-06-24 22:59:10 +08:00
caoxiaozhu
e5b03c6601 feat(web): 文档查询意图补充风险过滤与 X 天前范围
- aiDocumentQueryIntent 新增风险等级过滤(无/高/中/低/有风险)与 N 天前日期范围解析
- aiDocumentQueryModel/aiConversationHtmlRenderer 渲染适配风险过滤标签
- useWorkbenchAiDocumentQueryFlow/aiWorkbenchConversationStore 会话流转适配命令意图
- 更新 ai-document-query-model/ai-conversation-html-renderer/assistant-session-draft-delete 测试
2026-06-24 22:59:05 +08:00
caoxiaozhu
3eb78d343a feat(web): AI 工作台命令意图解析与动作策略
- 新增 workbenchIntentFrameModel,解析删除/审批/查询/咨询政策等意图帧,含风险等级、目标模式(当前上下文/筛选候选)与日期归一化
- 新增 workbenchIntentActionPolicy,按意图帧路由下一步(直通/拦截批量/上下文确认/查询候选)
- 新增 workbenchAiCommandIntentModel 与 useWorkbenchAiCommandIntents,识别草稿删除意图并解析最近草稿载荷生成引导
- usePersonalWorkbenchAiMode 接入命令意图处理,personal-workbench-ai-mode.css 适配
- 新增 command-intent-model/intent-frame-model 测试
2026-06-24 22:58:59 +08:00
19 changed files with 1527 additions and 41 deletions

View File

@@ -0,0 +1,215 @@
# AI 工作台统一意图识别框架设计
## 背景
AI 工作台当前的输入识别分散在多个前端 flow 中:申请预览、报销草稿、单据查询、草稿删除提示各自判断输入。这样的结构能快速修复单点问题,但会让“删除 3 天前的草稿”“审核合规没有风险的申请”这类组合型请求继续变成关键词补丁。
本设计把自然语言输入先统一解析成结构化 `IntentFrame`,再根据目标是否明确、安全等级和业务边界决定下一步动作。
## 目标
- 让工作台所有自然语言输入先进入统一意图框架,不再在各业务 flow 中散落判断。
- 支持动作、对象、筛选条件、上下文指代、安全等级的组合识别。
- 对删除、审核、驳回等高风险动作固定走“筛选候选 + 详情确认”,禁止自然语言直接执行。
- 保留当前会话内的快速指代能力,例如“删除刚才那个草稿”能定位最近创建的草稿。
- 对带筛选条件的请求,例如“删除 3 天前的草稿”“审核无风险申请”,先展示思考过程和候选列表。
## 非目标
- 第一版不引入 LangGraph也不把前端本地识别迁到后端状态机。
- 第一版不做自然语言直接批量删除、批量审核或批量驳回。
- 第一版不改后端审批、删除接口的权限模型。
- 第一版不重写现有申请预览和报销草稿流程,只把入口识别前置统一。
## 总体架构
统一意图识别分三层:
1. `IntentFrame Parser`:把用户输入解析为结构化意图。
2. `Target Resolver`:结合当前会话、最近动作和筛选条件,判断目标是否唯一。
3. `Action Policy`:根据动作风险决定直接查询、展示候选、要求澄清或阻断。
输入链路应调整为:
```text
用户输入
-> IntentFrame Parser
-> Action Policy
-> Target Resolver
-> 业务 flow
- 查询候选
- 打开详情确认
- 进入申请/报销流程
- 要求补充条件
```
## IntentFrame 数据结构
```js
{
action: 'query' | 'delete' | 'approve' | 'reject' | 'create' | 'update' | 'ask_policy',
objectType: 'draft' | 'application' | 'reimbursement' | 'approval_task' | 'receipt' | 'document',
filters: {
timeRange: null,
status: null,
risk: null,
documentType: null,
amount: null,
keyword: null
},
targetMode: 'current_context' | 'filtered_candidates' | 'ambiguous',
safetyLevel: 'read_only' | 'confirm_required' | 'blocked',
confidence: 0,
normalizedQuery: ''
}
```
### 字段含义
- `action` 表示用户想做什么,例如查、删、审核、驳回、创建或咨询规则。
- `objectType` 表示动作对象,例如草稿、申请单、报销单、待审任务或票据。
- `filters` 表示筛选条件,必须可以复用到单据查询 flow。
- `targetMode` 表示目标定位方式:
- `current_context`:明确指向当前会话最近对象。
- `filtered_candidates`:需要查询候选列表。
- `ambiguous`:条件不足,需要澄清。
- `safetyLevel` 表示动作安全级别:
- `read_only`:可直接查询或解释。
- `confirm_required`:只展示候选或详情入口,不直接执行。
- `blocked`:存在批量破坏性风险或越权风险,必须阻断。
- `normalizedQuery` 是给现有查询 flow 使用的可读查询句,例如“我的 3 天前草稿单据”。
## 识别策略
### 动作识别
- 查询:查、看、列出、有哪些、找一下。
- 删除:删除、删掉、移除、作废、撤销。
- 审核:审核、审批、处理待办、去审批。
- 驳回:驳回、退回、拒绝。
- 创建:新建、发起、申请、我要报销。
- 更新:补充、修改、改成、填入。
- 规则咨询:怎么走、能不能、规则、制度、政策、标准。
### 对象识别
- 草稿:草稿、未提交、刚才保存的单据。
- 申请单:申请、申请单、出差申请、费用申请。
- 报销单:报销、报销单、费用报销。
- 待审任务:待办、待我审核、待审批、审核单。
- 票据:发票、票据、附件、图片。
### 筛选条件识别
- 时间今天、昨天、3 天前、近 7 天、上周、本月、具体日期、日期范围。
- 状态:草稿、审批中、已通过、已驳回、待补充。
- 风险:无风险、低风险、中风险、高风险、合规、异常、超标。
- 金额:超过 1000、500 以下、100 到 300。
- 关键词:地点、事由、人员、部门、单号等自由文本。
## 目标解析规则
### 当前上下文直达
当用户使用“刚才那个”“当前”“这个”“上面那个”这类指代,并且当前会话中能找到最近的可操作对象时,`targetMode``current_context`
例子:
- “删除刚才那个草稿”
- “打开这个申请单”
- “继续刚才的报销草稿”
即便目标唯一,删除、审核、驳回仍然只打开详情页或确认入口。
### 筛选候选
当用户输入包含时间、风险、金额、状态、类型等筛选条件时,`targetMode` 必须为 `filtered_candidates`
例子:
- “删除 3 天前的草稿”
- “审核合规没有风险的申请”
- “找一下上海相关的低风险待审申请”
这类请求必须进入单据查询 flow展示候选结果不能直接套最近草稿。
### 条件不足澄清
当动作高风险但目标既不唯一,也没有足够筛选条件时,`targetMode``ambiguous`
例子:
- “把草稿删了”
- “帮我审核一下”
- “退回这个单”
系统应提示用户选择候选或补充条件。
## Action Policy
| 动作 | 安全等级 | 第一版行为 |
| --- | --- | --- |
| 查询 | `read_only` | 直接查询并展示结果 |
| 规则咨询 | `read_only` | 走政策/规则解释,不进入单据查询 |
| 删除 | `confirm_required` | 展示候选或打开详情页确认,不直接删除 |
| 审核通过 | `confirm_required` | 展示待审候选或打开审核详情,不直接通过 |
| 驳回/退回 | `confirm_required` | 展示待审候选或打开审核详情,不直接驳回 |
| 批量删除/批量审核 | `blocked` | 阻断并要求用户选择具体单据 |
## 用户可见思考过程
筛选型命令必须复用现有查询 thinking events并补充动作意图说明
1. 解析自然语言动作和筛选条件。
2. 判断操作风险和目标定位方式。
3. 查询业务单据接口。
4. 按条件组合筛选候选。
5. 展示候选卡片和下一步入口。
示例:
```text
解析识别到“删除”是高风险动作对象是“草稿”时间条件是“3 天前”。
策略:不会直接删除,将先查询我的草稿候选。
结果:命中 2 张草稿,请打开详情页确认删除目标。
```
## 文件边界
- 新增 `workbenchIntentFrameModel.js`:负责解析用户输入到 `IntentFrame`
- 新增 `workbenchIntentActionPolicy.js`:负责动作安全策略和下一步路由判断。
- 调整 `workbenchAiCommandIntentModel.js`:保留会话内最近草稿解析,但不再单独拥有顶层意图判断。
- 调整 `useWorkbenchAiCommandIntents.js`:基于 `IntentFrame` 分发到当前上下文直达或候选查询。
- 调整 `aiDocumentQueryIntent.js``aiDocumentQueryModel.js`:补充风险筛选、相对日期和筛选摘要。
- 补充前端测试,覆盖组合型输入和安全边界。
## 迁移步骤
1. 先为 `IntentFrame` 解析补测试:
- “删除刚才那个草稿”解析为 `delete + draft + current_context + confirm_required`
- “删除 3 天前的草稿”解析为 `delete + draft + filtered_candidates + confirm_required`
- “审核合规没有风险的申请”解析为 `approve + application + risk:none + filtered_candidates + confirm_required`
- “审批规则怎么走”解析为 `ask_policy`,不进入单据查询。
2. 实现 `workbenchIntentFrameModel.js`,不接入 UI。
3. 实现风险和相对日期筛选能力,让查询 flow 能承接筛选型命令。
4. 接入 `useWorkbenchAiCommandIntents.js`
- 当前上下文直达:生成详情确认入口。
- 筛选候选:调用 `handleAiDocumentQueryIntent(normalizedQuery, pendingMessage)`
- 条件不足:提示补充条件或查询可选候选。
5. 移除或降级旧的散落正则,让顶层输入先走统一框架。
6. 跑定向测试、相邻测试、前端构建和 5173 工作台烟测。
## 验收标准
- 输入“删除刚才那个草稿”时,系统定位当前会话最近草稿,并只打开详情确认入口。
- 输入“删除 3 天前的草稿”时,系统展示筛选思考过程和草稿候选,不使用最近草稿快捷路径。
- 输入“审核合规没有风险的申请”时,系统查询待我审核申请,并筛选无风险候选。
- 输入“审批规则怎么走”时,不进入单据查询。
- 删除、审核、驳回均不通过自然语言直接执行最终动作。
- 新增/调整测试全部通过,前端构建通过,`git diff --check` 无空白错误。
## 后续演进
- 当后端 steward planner 能稳定输出同等 `IntentFrame` 时,可以把 Parser 层迁到后端,前端只保留策略兜底。
- 如果需要跨会话目标记忆,可把最近创建/保存草稿写入会话快照,并附带过期时间和用户确认边界。
- 如果未来引入 LangGraph应把它用于多步状态编排而不是替代 `IntentFrame` schema 本身。

View File

@@ -396,8 +396,62 @@
- 验证:`node --test web/tests/workbench-ai-intent-planner-model.test.mjs``node --test web/tests/workbench-ai-application-gate-model.test.mjs``node --test web/tests/workbench-ai-application-context-submit.test.mjs``node --test web/tests/steward-plan-model-pending-flow.test.mjs` 均通过;`git diff --check -- web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js web/tests/workbench-ai-intent-planner-model.test.mjs` 无输出。
- 影响用户输入“2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车”时如果门禁已查明没有可关联申请单前端会把“应先申请单据”直接落到申请预览链路不再表现成只会反复识别意图。
- 22:04我修复了 AI 工作台短句意图漏识别,覆盖“删除草稿”和“我要审核/待办审批”这两类高频输入。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;本轮修改集中在 AI 工作台命令意图、待审单据查询识别和对应前端测试。
- 根因:工作台输入链路目前优先进入模型规划,但前端本地可执行意图只覆盖申请预览、报销和单据查询的较完整问法;“我要审核”没有命中 `resolveAiDocumentQueryIntent()` 的待审查询触发词,“删除草稿”则没有上下文命令入口,容易退回普通 steward 规划或被当作报销填槽文本。
- 修改:新增 `workbenchAiCommandIntentModel.js``useWorkbenchAiCommandIntents.js`,把“删除草稿 / 删除这个申请单 / 把刚才保存的草稿删掉”等短句识别为上下文命令;命中最近草稿时只给“查看草稿详情”动作,要求用户在详情页二次确认删除,不直接执行破坏性删除。
- 修改:`usePersonalWorkbenchAiMode.js` 接入命令意图 composable并让删除草稿意图优先于报销草稿填槽处理未找到最近草稿时自动转为“我的草稿单据”查询帮助用户先选中目标。
- 修改:`aiDocumentQueryIntent.js` 扩展“我要审核 / 我要审批 / 待办审批 / 去审核 / 处理审批”等短句到待我审核单据查询,同时排除“审批规则怎么走”这类政策咨询,避免误打到单据列表。
- 测试:先补红灯测试复现缺口;实现后 `node --test web/tests/workbench-ai-command-intent-model.test.mjs web/tests/ai-document-query-model.test.mjs` 通过 19/19相邻 `workbench-ai-action-router.test.mjs``workbench-ai-intent-planner-model.test.mjs` 通过;`npm --prefix web run build` 通过;`git diff --check -- ...` 无输出。
- 真页烟测:当前 `x-financial-local-linux` 正在监听 5173`curl -I http://127.0.0.1:5173/app/workbench` 返回 200说明本地 AI 工作台入口可访问。
- 影响:用户在 AI 工作台直接输入“删除草稿”时,会被引导到最近草稿详情并保留删除确认边界;输入“我要审核”或“待办审批”时,会直接查询待我审核单据,不再表现为没识别到意图。
- 22:31我把 AI 工作台意图识别从场景补丁收敛成统一 Intent Frame 方案,并按方案完成第一版前端接线。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;本轮在 22:04 短句修复基础上继续新增 Markdown 方案、统一意图框架、动作策略、筛选能力和前端测试。
- 文档:新增 `2026-06-24-workbench-intent-frame-design.md`,明确 `IntentFrame Parser -> Target Resolver -> Action Policy -> 业务 flow` 的总体方案;用户确认只需要 `.md` 后,我清理了临时 DOCX 产物,只保留 Markdown 文档。
- 修改:新增 `workbenchIntentFrameModel.js`,把输入统一解析为 `action / objectType / filters / targetMode / safetyLevel / normalizedQuery`;覆盖“删除刚才那个草稿”“删除 3 天前的草稿”“审核合规没有风险的申请”和“审批规则怎么走”。
- 修改:新增 `workbenchIntentActionPolicy.js`,把删除、审核、驳回固定为 `confirm_required`;当前上下文目标只打开详情确认,筛选型目标只查询候选,不通过自然语言直接执行副作用。
- 修改:`useWorkbenchAiCommandIntents.js` 先走统一 Intent Frame再分发到最近草稿详情确认或候选查询并排除“删除附件/票据”这类非单据删除,避免抢走附件流程。
- 修改:`aiDocumentQueryIntent.js``aiDocumentQueryModel.js` 增加“3 天前”相对日期、无风险/高/中/低风险筛选、`我的草稿单据` 查询来源识别和风险摘要展示;“审批规则怎么走”仍保持规则咨询,不进入单据查询。
- 测试:先补红灯测试复现缺口;实现后 `node --test web/tests/workbench-intent-frame-model.test.mjs` 通过 4/4`node --test web/tests/ai-document-query-model.test.mjs` 通过 16/16相邻 `workbench-ai-command-intent-model.test.mjs``workbench-ai-action-router.test.mjs``workbench-ai-intent-planner-model.test.mjs` 均通过。
- 验证:`npm --prefix web run build` 通过,保留既有 Rollup pure 注释与 chunk size warning`git diff --check -- ...` 无输出;`curl -I http://127.0.0.1:5173/app/workbench` 返回 200。
- 影响:后续“删除 3 天前的草稿”会先转成“我的 3天前 草稿单据”候选查询;“审核合规没有风险的申请”会先转成“待我审核 无风险 申请单”候选查询;“刚才那个草稿”仍可在当前会话内定位最近草稿,但只给详情确认入口。
- 22:36我修复了“删除申请单草稿”虽然识别到删除动作但前端没有显示删除思考过程的问题。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;本轮继续修改统一 Intent Frame 查询句、单据查询 thinking 和对应回归测试。
- 根因:`resolveWorkbenchIntentFrame()` 能解析出 `action=delete`,但 `normalizedQuery` 被压成“我的草稿单据”,丢了“申请单”筛选;同时 `handleAiDocumentQueryIntent()` 只接收查询句,不接收原始 `commandFrame`,导致 thinking 只展示“解析筛选条件”,没有展示“删除是高风险动作,不会直接执行,会先筛选候选”。
- 修改:`workbenchIntentFrameModel.js` 保留“草稿 + 申请单”的组合筛选,`删除申请单草稿` 现在生成 `我的 草稿 申请单`,避免查询阶段退化成全部草稿。
- 修改:`aiDocumentQueryModel.js` 新增 `buildAiDocumentQueryThinkingEvents()`,当传入 `commandFrame``safetyLevel=confirm_required` 时,首个 thinking 事件固定为“识别高风险操作意图”,文案明确删除/审核不会直接执行,会先筛选候选。
- 修改:`useWorkbenchAiDocumentQueryFlow.js` 支持第三个参数 `{ commandFrame }``useWorkbenchAiCommandIntents.js` 在候选查询时传入统一意图帧,让查询 thinking 保留原始动作语义。
- 测试:新增回归覆盖 `删除申请单草稿`,断言查询句保留 `申请单``草稿`,且 thinking 首条包含“删除 / 不会直接执行 / 先筛选候选”;相关 `workbench-intent-frame-model.test.mjs` 6/6 通过。
- 验证:`node --test web/tests/workbench-intent-frame-model.test.mjs web/tests/workbench-ai-command-intent-model.test.mjs web/tests/ai-document-query-model.test.mjs` 通过 27/27相邻 `workbench-ai-action-router.test.mjs``workbench-ai-intent-planner-model.test.mjs` 通过 17/17`npm --prefix web run build` 通过;`git diff --check -- ...` 无输出5173 工作台路由返回 200。
- 影响:用户输入“删除申请单草稿”时,不会只看到普通查询筛选;系统会先展示删除动作的高风险策略思考,再查询“我的草稿申请单”候选,并保留详情页二次确认边界。
- 22:42我补强了高风险命令的最终候选结果提示让用户在卡片结果里也能看到“不会直接删除/审核”和快捷入口说明。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;本轮继续修改 `aiDocumentQueryModel.js`、查询 flow 和统一意图回归测试。
- 根因22:36 只把删除动作写进 thinking 事件,但最终候选结果仍是普通单据卡片和“查看详情”按钮;用户如果只看结果卡片,会看不到“系统不会直接删除,请先进入详情确认”的明确操作边界。
- 修改:`buildAiDocumentQueryMessage()` 支持 `commandFrame`,当命令为删除/审核/驳回等 `confirm_required` 动作时,在候选卡片上方插入高风险操作提示:系统不会直接删除/审核相关单据,需点击下方候选单据里的快捷按钮并进入详情核对后再操作。
- 修改:候选单据卡片的操作按钮会随命令动作改文案:删除为“进入详情确认删除”,审核为“进入详情确认审核”,驳回为“进入详情确认驳回”;普通查询仍保持“查看详情”。
- 修改:`useWorkbenchAiDocumentQueryFlow.js` 在生成最终查询消息时传入 `commandFrame`,保证真实工作台路径能展示同一套提示和按钮文案。
- 测试:新增回归断言 `删除申请单草稿` 的结果消息包含“系统不会直接删除相关单据 / 请点击下方候选单据里的快捷按钮 / 进入单据详情核对后再操作”,并出现 `进入详情确认删除` 按钮文案。
- 验证:`node --test web/tests/workbench-intent-frame-model.test.mjs web/tests/workbench-ai-command-intent-model.test.mjs web/tests/ai-document-query-model.test.mjs` 通过 28/28相邻 `workbench-ai-action-router.test.mjs``workbench-ai-intent-planner-model.test.mjs` 通过 17/17`npm --prefix web run build` 通过;`git diff --check -- ...` 无输出5173 工作台路由返回 200。
- 影响:用户输入“删除申请单草稿”后,既能在 thinking 里看到删除动作识别,也能在最终结果卡片里看到删除确认边界和实体快捷入口,不会误以为系统已经直接删除或只能普通查看。
- 22:49我根据截图修复了“删除申请单草稿”结果页只剩“已查询到相关单据”、高风险提示和候选卡片没有显示的问题。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍是本轮 AI 工作台意图识别和结果渲染相关未提交改动。
- 根因:高风险提示块里使用了 `<p>` 标签,但 `conversationTrustedHtml` 的 trusted HTML 白名单不允许 `<p>`;安全渲染会把包含该标签的整段 trusted HTML 丢弃,所以页面只保留 Markdown 标题,看起来像没有继续删除思考。
- 修改:`aiDocumentQueryModel.js` 将高风险提示正文改为白名单已允许的 `<div>`,不扩大 trusted HTML 标签面;`personal-workbench-ai-mode.css` 增加提示块样式,让“系统不会直接删除相关单据”和后续详情确认说明稳定显示在候选卡片上方。
- 测试:先新增渲染级红灯测试复现截图,确认渲染后只剩标题;修复后该测试断言 `ai-document-command-guidance``系统不会直接删除相关单据``进入详情确认删除``ai-document-card-list` 都保留在最终 HTML 中。
- 验证:`node --test web/tests/ai-document-query-model.test.mjs` 通过 17/17`node --test web/tests/ai-document-query-model.test.mjs web/tests/workbench-intent-frame-model.test.mjs web/tests/workbench-ai-command-intent-model.test.mjs web/tests/workbench-ai-action-router.test.mjs web/tests/workbench-ai-intent-planner-model.test.mjs` 通过 46/46`npm --prefix web run build` 通过;`git diff --check -- ...` 无输出;`curl -I http://127.0.0.1:5173/app/workbench` 返回 200。
- 影响:用户输入“删除申请单草稿”后,结果标题下方会恢复高风险说明、候选单据卡片和“进入详情确认删除”快捷按钮;系统仍不会直接删除单据,而是要求进入详情核对后再操作。
## 遗留问题
- 22:49本轮用渲染级回归测试确认 trusted HTML 已恢复,但还没有做真实浏览器点击级回放和截图复核。建议后续在 5173 输入“删除申请单草稿”,确认真实主题下提示块、候选卡片和按钮没有拥挤或错位。
- 22:42本轮仍未做真实浏览器点击级回放确认高风险提示在 AI 工作台卡片里的视觉效果。建议后续在 5173 输入“删除申请单草稿”,截图确认提示块、候选卡片和“进入详情确认删除”按钮在当前主题下没有拥挤或错位。
- 22:36本轮仍未做浏览器点击级回放确认“删除申请单草稿”的实际 thinking 卡片视觉状态。建议后续在真实 5173 工作台输入该句,确认首条 thinking 为删除高风险策略、后续才是查询和筛选候选。
- 22:31本轮已完成统一意图框架的模型测试、查询筛选测试、相邻前端测试、构建和 5173 路由烟测,但没有做浏览器点击级回放确认“删除 3 天前的草稿 / 审核合规没有风险的申请”的最终候选卡片。建议后续在真实工作台输入这两句,截图确认 thinking、候选列表和详情入口。
- 22:04本轮完成了前端行为测试、构建和 5173 路由烟测,但没有做浏览器点击级回放确认输入“删除草稿 / 我要审核”的最终卡片状态。建议后续在真实浏览器里回放这两个短句,截图确认动作卡片和待审单据列表符合预期。
- 09:41当前 Skill 是新建在项目级 `.codex/skills` 目录里,本轮可以通过文件检查验证结构,但是否被未来会话自动加载还依赖 Codex 对项目 Skill 的刷新机制。建议后续新开会话或下一次任务时确认 Skill 列表是否出现 `agent-change-log`
- 09:43`.codex/` 曾被 `.gitignore` 整体忽略,新建的 `agent-change-log` 默认不会进入普通提交范围09:44 已改成只放行该 Skill。建议后续如果还新增其他项目 Skill也按同样方式逐个显式放行别一次性开放整个 `.codex`
- 10:01当前工作区已有大量未提交改动且本地 `main` ahead 1。建议后续如果真的发现 upstream 新提交,先用 fetch 和 `HEAD..@{u}` 写日志;只有在工作区干净、可快进时再执行 `git pull --ff-only`,避免把其他智能体提交和本地半成品混到一起。
@@ -488,6 +542,15 @@
- [x] ~~让 AI 模式模型规划等待期间持续追加 thinking 事件,而不是静态显示“思考中”后一次性出现步骤。~~(完成于 17:26证据真页干净会话 thinking 数量从 2 条逐步增长到 6 条,相关前端测试 33/33 通过)
- [x] ~~修复 AI 工作台“保存草稿”后的上下文提交短句,让“提交这个单据”复用最近申请草稿而不是重新规划。~~(完成于 17:40证据`workbench-ai-application-context-submit.test.mjs``workbench-ai-application-gate-model.test.mjs` 通过)
- [x] ~~修复“完整出差信息但未说申请/报销”时只停在候选流程确认的问题,让唯一的“先发起出差申请”候选流直接进入申请预览计划。~~(完成于 17:48证据`workbench-ai-intent-planner-model.test.mjs` 新增回归通过,相邻 4 组前端测试通过)
- [x] ~~修复 AI 工作台“删除草稿”和“我要审核/待办审批”短句意图漏识别。~~(完成于 22:04证据`node --test web/tests/workbench-ai-command-intent-model.test.mjs web/tests/ai-document-query-model.test.mjs` 通过 19/19前端 build 通过)
- [ ] 在真实 5173 AI 工作台分别输入“删除草稿”和“我要审核”确认删除草稿只打开详情确认入口、审核短句直接返回待我审核单据卡片。来源22:04 短句意图修复)
- [x] ~~落地 AI 工作台统一意图识别 Markdown 设计文档,并实现第一版 Intent Frame + Action Policy。~~(完成于 22:31证据`2026-06-24-workbench-intent-frame-design.md` 已新增,`workbench-intent-frame-model.test.mjs` 4/4 与 `ai-document-query-model.test.mjs` 16/16 通过)
- [ ] 在真实 5173 AI 工作台分别输入“删除 3 天前的草稿”和“审核合规没有风险的申请”,确认系统先筛选候选而不是直接执行删除/审核。来源22:31 统一 Intent Frame 接线)
- [x] ~~修复“删除申请单草稿”不显示删除动作思考的问题。~~(完成于 22:36证据`workbench-intent-frame-model.test.mjs` 新增回归通过,相关前端测试 44/44 通过,前端 build 通过)
- [ ] 在真实 5173 AI 工作台输入“删除申请单草稿”,确认首条 thinking 明确显示“删除”高风险策略且结果只展示申请单草稿候选。来源22:36 删除动作 thinking 修复)
- [x] ~~为高风险删除/审核候选结果补充用户可见确认边界说明和实体快捷按钮文案。~~(完成于 22:42证据`workbench-intent-frame-model.test.mjs` 新增结果提示回归通过,相关前端测试 45/45 通过,前端 build 通过)
- [x] ~~修复“删除申请单草稿”高风险提示和候选卡片被 trusted HTML 安全渲染整块丢弃的问题。~~(完成于 22:49证据新增渲染级红灯测试后修复相关前端测试 46/46 通过,前端 build 通过)
- [ ] 在真实 5173 AI 工作台输入“删除申请单草稿”确认结果卡片上方显示高风险说明候选单据按钮显示“进入详情确认删除”。来源22:42 高风险结果提示补强)
- [ ] 单独修复或重写 `workbench-ai-mode-switch.test.mjs` 的旧静态结构断言,使它适配 `WorkbenchAiFileStrip` 和 OCR composable 拆分后的真实代码。来源17:26 额外测试发现)
- [ ] 单独修复或重写 `workbench-ai-mode-expense-scene-action.test.mjs``expense-application-fast-preview.test.mjs` 中继续扫描旧 Vue 单文件的静态断言,改为覆盖 composable/model 的行为测试。来源17:40 上下文提交验证)
- [ ] 在真实 5173 AI 工作台回放“2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车”确认唯一申请候选流直接生成申请核对表。来源17:48 无动作话术直进申请预览修复)

View File

@@ -1450,6 +1450,31 @@
font-weight: 900;
}
.workbench-ai-answer-markdown :deep(.ai-document-command-guidance) {
display: grid;
gap: 6px;
margin: 0 0 14px;
padding: 13px 15px;
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: 12px;
background: rgba(255, 251, 235, 0.82);
color: #78350f;
}
.workbench-ai-answer-markdown :deep(.ai-document-command-guidance__title) {
color: #92400e;
font-size: 14px;
font-weight: 860;
line-height: 1.35;
}
.workbench-ai-answer-markdown :deep(.ai-document-command-guidance__body) {
color: #78350f;
font-size: 13px;
font-weight: 680;
line-height: 1.6;
}
.workbench-ai-answer-markdown :deep(.ai-document-card-list) {
display: grid;
gap: 16px;

View File

@@ -38,6 +38,7 @@ import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
import {
isReimbursementCreationIntent
} from './workbenchAiApplicationGateModel.js'
import { useWorkbenchAiCommandIntents } from './useWorkbenchAiCommandIntents.js'
import {
buildRuleFallbackWorkbenchAiIntentPlan,
normalizeWorkbenchAiIntentPlan,
@@ -164,6 +165,24 @@ export function usePersonalWorkbenchAiMode(props, emit) {
scrollInlineConversationToBottom
})
const commandIntents = useWorkbenchAiCommandIntents({
activateInlineConversation,
activeConversationTitle,
assistantDraft,
clearAiModeFiles: filesFlow.clearAiModeFiles,
closeWorkbenchDatePicker,
conversationId,
conversationMessages,
createInlineMessage,
documentQueryFlow,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
removeWorkbenchDateTag,
scrollInlineConversationToBottom,
searchConversationId: AI_SEARCH_CONVERSATION_ID,
sending
})
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime,
conversationId,
@@ -728,8 +747,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return
}
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
if (commandIntents.handleInlineDraftDeletionIntent(cleanPrompt, entry)) {
return
}
@@ -737,6 +755,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return
}
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
return
}
if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) {
void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
return

View File

@@ -0,0 +1,98 @@
import {
buildWorkbenchDraftDeletionGuidance,
isWorkbenchDraftDeletionIntent,
resolveLatestWorkbenchDraftPayload
} from './workbenchAiCommandIntentModel.js'
import { resolveWorkbenchIntentActionRoute } from './workbenchIntentActionPolicy.js'
import { resolveWorkbenchIntentFrame } from './workbenchIntentFrameModel.js'
export function useWorkbenchAiCommandIntents({
activateInlineConversation,
activeConversationTitle,
assistantDraft,
clearAiModeFiles,
closeWorkbenchDatePicker,
conversationId,
conversationMessages,
createInlineMessage,
documentQueryFlow,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
removeWorkbenchDateTag,
scrollInlineConversationToBottom,
searchConversationId,
sending
}) {
function prepareInlineCommandConversation(cleanPrompt, entry = {}) {
if (conversationId.value === searchConversationId) {
conversationId.value = ''
conversationMessages.value = []
activeConversationTitle.value = ''
}
activateInlineConversation({
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
})
inlineConversationAutoScrollPinned.value = true
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
clearAiModeFiles()
scrollInlineConversationToBottom()
}
function handleInlineDraftDeletionIntent(cleanPrompt, entry = {}) {
const frame = resolveWorkbenchIntentFrame(cleanPrompt)
const route = resolveWorkbenchIntentActionRoute(frame)
const legacyDraftDelete = isWorkbenchDraftDeletionIntent(cleanPrompt)
const commandObjectSupported = !frame || frame.action !== 'delete' || (
['draft', 'application', 'reimbursement', 'document'].includes(frame.objectType)
)
const handlesWorkbenchCommand = (
commandObjectSupported && (
route.nextStep === 'open_context_confirm' ||
route.nextStep === 'query_candidates'
) ||
legacyDraftDelete
)
if (!handlesWorkbenchCommand) {
return false
}
prepareInlineCommandConversation(cleanPrompt, entry)
const draftPayload = frame?.targetMode === 'current_context' || legacyDraftDelete
? resolveLatestWorkbenchDraftPayload(conversationMessages.value)
: null
if (route.nextStep === 'open_context_confirm' && draftPayload) {
const guidance = buildWorkbenchDraftDeletionGuidance(draftPayload)
conversationMessages.value.push(createInlineMessage('assistant', guidance.content, {
suggestedActions: guidance.suggestedActions
}))
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
return true
}
const queryPrompt = route.queryPrompt || frame?.normalizedQuery || '我的草稿单据'
const pendingText = frame?.safetyLevel === 'confirm_required'
? '正在先筛选候选单据,不会直接执行删除或审核动作...'
: '正在查询匹配条件的单据...'
const pendingMessage = createInlineMessage('assistant', pendingText, {
pending: true
})
conversationMessages.value.push(pendingMessage)
persistCurrentConversation()
sending.value = true
void documentQueryFlow.handleAiDocumentQueryIntent(queryPrompt, pendingMessage, {
commandFrame: frame
})
.finally(() => {
sending.value = false
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
})
return true
}
return {
handleInlineDraftDeletionIntent
}
}

View File

@@ -2,6 +2,7 @@ import { nextTick } from 'vue'
import {
buildAiDocumentQueryConditionSummary,
buildAiDocumentQueryThinkingEvents,
buildAiDocumentQueryMessage,
filterAiDocumentQueryRecords,
mergeAiDocumentQueryPayloads,
@@ -100,33 +101,20 @@ export function useWorkbenchAiDocumentQueryFlow({
await nextTick()
}
async function handleAiDocumentQueryIntent(prompt, pendingMessage) {
async function handleAiDocumentQueryIntent(prompt, pendingMessage, options = {}) {
const intent = resolveAiDocumentQueryIntent(prompt)
if (!intent) {
return false
}
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
let thinkingEvents = [
{
eventId: 'document-query-parse',
title: '解析自然语言筛选条件',
content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}`,
status: 'running'
},
{
eventId: 'document-query-fetch',
title: '查询业务单据接口',
content: resolveAiDocumentQueryFetchPendingText(intent),
status: 'pending'
},
{
eventId: 'document-query-filter',
title: '组合筛选单据',
content: '等待接口返回后,再按已识别条件做二次筛选。',
status: 'pending'
}
]
let thinkingEvents = buildAiDocumentQueryThinkingEvents(intent, {
commandFrame: options.commandFrame
}).map((event) => (
event.eventId === 'document-query-fetch'
? { ...event, content: resolveAiDocumentQueryFetchPendingText(intent) }
: event
))
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
await waitForAiDocumentQueryStep()
@@ -163,7 +151,9 @@ export function useWorkbenchAiDocumentQueryFlow({
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
await waitForAiDocumentQueryStep()
const finalMessageText = buildAiDocumentQueryMessage(intent, payload)
const finalMessageText = buildAiDocumentQueryMessage(intent, payload, {
commandFrame: options.commandFrame
})
thinkingEvents = completeAiDocumentQueryEvent(
thinkingEvents,
'document-query-filter',

View File

@@ -0,0 +1,130 @@
const DRAFT_DELETION_ACTION_PATTERN = /删除|删掉|删了|移除|作废|撤销/
const DRAFT_DELETION_TARGET_PATTERN = (
/草稿|这个单据|这张单据|当前单据|当前申请|当前报销|刚才保存的草稿|刚才的草稿|上面的单据|最近的单据|申请单|报销单/
)
const NON_DRAFT_DELETE_TARGET_PATTERN = /附件|票据|发票|图片|文件|明细|费用行/
const DELETABLE_DRAFT_STATUS = new Set(['', 'draft', 'pending', '待提交', '草稿'])
const SUBMITTED_OR_FINAL_STATUS = new Set([
'submitted',
'approved',
'completed',
'paid',
'archived',
'deleted',
'rejected',
'returned',
'审批中',
'已审批',
'已完成',
'已付款',
'已归档',
'已删除',
'已驳回',
'已退回'
])
function normalizeCompactText(value = '') {
return String(value || '').replace(/\s+/g, '').trim()
}
function normalizeDraftDocumentType(payload = {}, claimNo = '') {
const rawType = String(payload.document_type || payload.documentType || payload.draft_type || payload.draftType || '').trim()
if (/application|expense_application|申请/.test(rawType)) {
return 'application'
}
if (/reimbursement|expense|报销/.test(rawType)) {
return 'reimbursement'
}
return /^A/i.test(String(claimNo || '').trim()) ? 'application' : 'reimbursement'
}
function normalizeDraftPayload(payload = null, sourceText = '') {
if (!payload || typeof payload !== 'object') {
return null
}
const claimId = String(payload.claim_id || payload.claimId || payload.id || '').trim()
const claimNo = String(payload.claim_no || payload.claimNo || payload.document_no || payload.documentNo || '').trim()
if (!claimId && !claimNo) {
return null
}
const status = String(payload.status || payload.status_label || payload.statusLabel || '').trim()
if (SUBMITTED_OR_FINAL_STATUS.has(status.toLowerCase()) || SUBMITTED_OR_FINAL_STATUS.has(status)) {
return null
}
if (!DELETABLE_DRAFT_STATUS.has(status.toLowerCase()) && !DELETABLE_DRAFT_STATUS.has(status) && !/草稿/.test(sourceText)) {
return null
}
return {
claimId,
claimNo,
status: status || 'draft',
documentType: normalizeDraftDocumentType(payload, claimNo)
}
}
function extractDraftPayloadFromSuggestedActions(message = {}) {
const actions = Array.isArray(message?.suggestedActions) ? message.suggestedActions : []
for (const action of [...actions].reverse()) {
const actionType = String(action?.action_type || action?.actionType || '').trim()
if (actionType !== 'open_application_detail') {
continue
}
const payload = normalizeDraftPayload(action.payload, String(message.content || message.text || ''))
if (payload) {
return payload
}
}
return null
}
export function isWorkbenchDraftDeletionIntent(prompt = '') {
const compact = normalizeCompactText(prompt)
if (!compact || !DRAFT_DELETION_ACTION_PATTERN.test(compact)) {
return false
}
if (NON_DRAFT_DELETE_TARGET_PATTERN.test(compact) && !/草稿|单据|申请单|报销单/.test(compact)) {
return false
}
return DRAFT_DELETION_TARGET_PATTERN.test(compact)
}
export function resolveLatestWorkbenchDraftPayload(messages = []) {
const safeMessages = Array.isArray(messages) ? messages : []
for (const message of [...safeMessages].reverse()) {
const sourceText = String(message?.content || message?.text || '')
const actionPayload = extractDraftPayloadFromSuggestedActions(message)
if (actionPayload) {
return actionPayload
}
const draftPayload = normalizeDraftPayload(message?.draftPayload, sourceText)
if (draftPayload) {
return draftPayload
}
}
return null
}
export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) {
const claimNo = String(draftPayload.claimNo || draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claimId || draftPayload.claim_id || '').trim()
const documentType = String(draftPayload.documentType || draftPayload.document_type || 'reimbursement').trim()
const reference = claimNo || claimId || '最近这张草稿'
return {
content: [
'### 已识别到您想删除草稿',
`我找到了最近这张草稿:**${reference}**。`,
'删除草稿会影响单据和附件关联,我不会直接替您删除。请先打开详情页,在详情页点击 **删除草稿** 并完成二次确认。'
].join('\n\n'),
suggestedActions: [{
label: claimNo ? `查看草稿 ${claimNo}` : '查看草稿详情',
description: '打开详情页后可点击删除草稿并二次确认。',
icon: 'mdi mdi-open-in-new',
action_type: 'open_application_detail',
payload: {
claim_id: claimId,
claim_no: claimNo,
document_type: documentType
}
}]
}
}

View File

@@ -0,0 +1,23 @@
const QUERY_CANDIDATE_ACTIONS = new Set(['delete', 'approve', 'reject', 'query'])
export function resolveWorkbenchIntentActionRoute(frame = null) {
if (!frame) {
return { nextStep: 'pass_through' }
}
if (frame.safetyLevel === 'blocked') {
return { nextStep: 'blocked', reason: '高风险批量动作需要先选择具体单据。' }
}
if (frame.action === 'ask_policy') {
return { nextStep: 'pass_through' }
}
if (frame.targetMode === 'current_context' && frame.safetyLevel === 'confirm_required') {
return { nextStep: 'open_context_confirm' }
}
if (frame.targetMode === 'filtered_candidates' && QUERY_CANDIDATE_ACTIONS.has(frame.action)) {
return {
nextStep: 'query_candidates',
queryPrompt: frame.normalizedQuery || ''
}
}
return { nextStep: 'pass_through' }
}

View File

@@ -0,0 +1,211 @@
import { formatDate, parseDate } from '../../utils/aiDocumentQueryText.js'
const CONFIRM_REQUIRED_ACTIONS = new Set(['delete', 'approve', 'reject'])
function compactText(value = '') {
return String(value || '').replace(/\s+/g, '').trim()
}
function resolveToday(options = {}) {
return parseDate(options.today) || new Date()
}
function shiftDay(today, offset) {
const date = new Date(today.getTime())
date.setUTCDate(date.getUTCDate() + offset)
return formatDate(date)
}
function resolveAction(text = '') {
if (/(标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗)/.test(text)) {
return 'ask_policy'
}
if (/删除|删掉|删了|移除|作废|撤销/.test(text)) {
return 'delete'
}
if (/驳回|退回|拒绝/.test(text)) {
return 'reject'
}
if (/审核|审批|通过|处理待办|去审批|去审核/.test(text)) {
return 'approve'
}
if (/新建|发起|创建|我要报销|申请/.test(text)) {
return 'create'
}
if (/补充|修改|改成|填入/.test(text)) {
return 'update'
}
if (/查|看|列出|有哪些|找一下/.test(text)) {
return 'query'
}
return null
}
function resolveObjectType(text = '') {
if (/草稿|未提交/.test(text)) {
return 'draft'
}
if (/待办|待审|待审核|待审批|审核单|审批单/.test(text)) {
return 'approval_task'
}
if (/申请单|申请/.test(text)) {
return 'application'
}
if (/报销单|报销/.test(text)) {
return 'reimbursement'
}
if (/票据|发票|附件|图片|文件/.test(text)) {
return 'receipt'
}
if (/单据|单子/.test(text)) {
return 'document'
}
return 'document'
}
function resolveTimeFilter(text = '', options = {}) {
const today = resolveToday(options)
const daysAgo = text.match(/(?<days>\d{1,3})天前/)
if (daysAgo?.groups?.days) {
const days = Math.max(0, Number(daysAgo.groups.days))
const value = shiftDay(today, -days)
return { start: value, end: value, label: `${days}天前` }
}
if (/昨天/.test(text)) {
const value = shiftDay(today, -1)
return { start: value, end: value, label: '昨天' }
}
if (/今天|今日/.test(text)) {
const value = shiftDay(today, 0)
return { start: value, end: value, label: '今天' }
}
return null
}
function resolveRiskFilter(text = '') {
if (/无风险|没有风险|暂无风险|无异常|合规/.test(text)) {
return { level: 'none', label: '无风险' }
}
if (/高风险/.test(text)) {
return { level: 'high', label: '高风险' }
}
if (/中风险/.test(text)) {
return { level: 'medium', label: '中风险' }
}
if (/低风险/.test(text)) {
return { level: 'low', label: '低风险' }
}
if (/风险|异常|超标/.test(text)) {
return { level: 'has', label: '有风险' }
}
return null
}
function resolveStatusFilter(text = '', objectType = '') {
if (objectType === 'draft' || /草稿|未提交/.test(text)) {
return { keys: ['draft'], label: '草稿' }
}
if (/待审|待审核|待审批|审批中|审核中/.test(text)) {
return { keys: ['submitted', 'pending'], label: '审批中' }
}
return null
}
function resolveDocumentType(text = '', objectType = '') {
if (objectType === 'application' || /申请单|申请/.test(text)) {
return 'application'
}
if (objectType === 'reimbursement' || /报销单|报销/.test(text)) {
return 'reimbursement'
}
return null
}
function hasContextReference(text = '') {
return /刚才|当前|这个|这张|上面|最近/.test(text)
}
function hasExplicitFilter(filters = {}) {
return Boolean(filters.timeRange || filters.risk || filters.amount || filters.keyword)
}
function resolveTargetMode(action = '', text = '', filters = {}) {
if (!CONFIRM_REQUIRED_ACTIONS.has(action)) {
return 'filtered_candidates'
}
if (hasContextReference(text) && !hasExplicitFilter(filters)) {
return 'current_context'
}
return 'filtered_candidates'
}
function resolveSafetyLevel(action = '') {
if (CONFIRM_REQUIRED_ACTIONS.has(action)) {
return 'confirm_required'
}
return 'read_only'
}
function buildNormalizedQuery({ action, objectType, filters }) {
if ((objectType === 'draft' || action === 'delete') && !filters.timeRange?.label && !filters.risk?.label && !filters.documentType) {
return '我的草稿单据'
}
const parts = []
if (action === 'approve' || objectType === 'approval_task') {
parts.push('待我审核')
} else if (objectType === 'draft' || action === 'delete') {
parts.push('我的')
}
if (filters.timeRange?.label) {
parts.push(filters.timeRange.label)
}
if (filters.risk?.label) {
parts.push(filters.risk.label)
}
if (filters.status?.label && (filters.documentType || objectType !== 'draft')) {
parts.push(filters.status.label)
}
if (filters.documentType === 'application') {
parts.push('申请单')
} else if (filters.documentType === 'reimbursement') {
parts.push('报销单')
} else if (objectType === 'draft') {
parts.push('草稿单据')
} else {
parts.push('单据')
}
return parts.join(' ').trim()
}
export function resolveWorkbenchIntentFrame(prompt = '', options = {}) {
const text = compactText(prompt)
if (!text) {
return null
}
const action = resolveAction(text)
if (!action) {
return null
}
const objectType = resolveObjectType(text)
const filters = {
timeRange: resolveTimeFilter(text, options),
status: resolveStatusFilter(text, objectType),
risk: resolveRiskFilter(text),
documentType: resolveDocumentType(text, objectType),
amount: null,
keyword: null
}
const safetyLevel = resolveSafetyLevel(action)
const targetMode = action === 'ask_policy'
? 'ambiguous'
: resolveTargetMode(action, text, filters)
return {
action,
objectType,
filters,
targetMode,
safetyLevel,
confidence: 0.86,
normalizedQuery: action === 'ask_policy' ? String(prompt || '').trim() : buildNormalizedQuery({ action, objectType, filters })
}
}

View File

@@ -9,6 +9,7 @@ import {
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
const DELETED_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-deleted-document-detail:'
function escapeHtml(value = '') {
return String(value)
@@ -39,6 +40,10 @@ function isDocumentDetailHref(href = '') {
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
}
function isDeletedDocumentDetailHref(href = '') {
return String(href || '').trim().startsWith(DELETED_DOCUMENT_DETAIL_HREF_PREFIX)
}
function sanitizeImageSrc(src = '') {
const value = String(src || '').trim()
if (/^(https?:\/\/|blob:|\/)/i.test(value)) {
@@ -63,6 +68,17 @@ function renderLinkHtml(label = '', href = '') {
'</span>'
].join('')
}
if (isDeletedDocumentDetailHref(href)) {
return [
'<span',
' class="ai-html-action-link ai-html-action-link-document is-disabled"',
' data-ai-action="deleted-document-detail"',
' aria-disabled="true"',
'>',
label,
'</span>'
].join('')
}
if (isApplicationDetailHref(href)) {
return [
`<a href="${sanitizedHref}"`,

View File

@@ -23,6 +23,14 @@ const EXPENSE_TYPE_FILTERS = [
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
]
const RISK_FILTERS = [
{ label: '无风险', level: 'none', pattern: /无风险|没有风险|暂无风险|无异常|合规/ },
{ label: '高风险', level: 'high', pattern: /高风险/ },
{ label: '中风险', level: 'medium', pattern: /中风险/ },
{ label: '低风险', level: 'low', pattern: /低风险/ },
{ label: '有风险', level: 'has', pattern: /风险|异常|超标/ }
]
function resolveToday(options = {}) {
return parseDate(options.today) || new Date()
}
@@ -82,6 +90,15 @@ function resolveTimeRange(prompt, options = {}) {
return { start: value, end: value, label: '昨天' }
}
const daysAgo = text.match(/(?<days>\d{1,3})天前/)
if (daysAgo?.groups?.days) {
const days = Math.max(0, Number(daysAgo.groups.days))
const date = new Date(today.getTime())
date.setUTCDate(date.getUTCDate() - days)
const value = formatDate(date)
return { start: value, end: value, label: `${days}天前` }
}
if (/本月|这个月|当月/.test(text)) {
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
}
@@ -129,6 +146,11 @@ function resolveExpenseTypeFilter(prompt) {
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
}
function resolveRiskFilter(prompt) {
const text = compactText(prompt)
return RISK_FILTERS.find((item) => item.pattern.test(text)) || null
}
function normalizeAmountText(value = '') {
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
if (!matched) {
@@ -191,13 +213,13 @@ function resolveKeywordFilter(prompt) {
function resolveSource(prompt) {
const text = compactText(prompt)
if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) {
if (/审核单|审批单|待办|待审|待审核|待审批|我审批|我审核|我要审批|我要审核|去审批|去审核|处理审批|处理审核/.test(text)) {
return {
source: 'approval',
sourceLabel: '待我审核的单据'
}
}
if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
if (/我名下|我发起|我提交|我创建|我的/.test(text)) {
return {
source: 'mine',
sourceLabel: '我的单据'
@@ -211,7 +233,13 @@ function resolveSource(prompt) {
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
const text = compactText(prompt)
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) {
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核|我要审核|我要审批|去审核|去审批|处理审核|处理审批|草稿)/.test(text)) {
return null
}
if (
/(标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗)/.test(text) &&
!/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核)/.test(text)
) {
return null
}
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
@@ -223,6 +251,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) {
const expenseTypeFilter = resolveExpenseTypeFilter(text)
const keywordFilter = resolveKeywordFilter(prompt)
const amountFilter = resolveAmountFilter(text)
const riskFilter = resolveRiskFilter(text)
return {
...source,
documentType,
@@ -235,6 +264,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) {
statusFilter,
expenseTypeFilter,
keywordFilter,
amountFilter
amountFilter,
riskFilter
}
}

View File

@@ -2,6 +2,7 @@ import { extractExpenseClaimItems } from '../services/reimbursements.js'
import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js'
import { isApplicationDocumentNo } from './documentClassification.js'
import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js'
import { filterActionableRiskFlags, isRiskSummaryWithRisk, normalizeRiskFlagTone } from './riskFlags.js'
export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js'
@@ -36,6 +37,26 @@ const TYPE_LABELS = {
other: '其他费用'
}
const COMMAND_ACTION_LABELS = {
delete: '删除',
approve: '审核通过',
reject: '驳回/退回'
}
const COMMAND_ACTION_VERBS = {
delete: '删除',
approve: '审核',
reject: '驳回或退回'
}
const COMMAND_OBJECT_LABELS = {
draft: '草稿',
application: '申请单',
reimbursement: '报销单',
approval_task: '待审任务',
document: '单据'
}
const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
@@ -155,6 +176,54 @@ function resolveRecordQuerySource(claim = {}, intent = {}) {
)
}
function resolveRiskFlags(claim = {}) {
if (Array.isArray(claim.risk_flags_json)) {
return claim.risk_flags_json
}
if (Array.isArray(claim.riskFlags)) {
return claim.riskFlags
}
if (Array.isArray(claim.review_flags)) {
return claim.review_flags
}
if (Array.isArray(claim.reviewFlags)) {
return claim.reviewFlags
}
return []
}
function resolveRiskSummary(claim = {}) {
return normalizeText(
claim.risk_summary ||
claim.riskSummary ||
claim.risk ||
claim.risk_label ||
claim.riskLabel
)
}
function resolveRecordRiskMeta(claim = {}) {
const flags = filterActionableRiskFlags(resolveRiskFlags(claim))
const summary = resolveRiskSummary(claim)
if (!flags.length && !isRiskSummaryWithRisk(summary)) {
return { hasRisk: false, riskTone: 'none', riskLabel: '无风险' }
}
const tones = flags.map((flag) => normalizeRiskFlagTone(flag))
const riskTone = tones.includes('high')
? 'high'
: tones.includes('medium')
? 'medium'
: tones.includes('low')
? 'low'
: 'medium'
const riskLabel = riskTone === 'high'
? '高风险'
: riskTone === 'medium'
? '中风险'
: '低风险'
return { hasRisk: true, riskTone, riskLabel }
}
function isApprovalTaskClaim(claim = {}, intent = {}) {
return resolveRecordQuerySource(claim, intent) === 'approval'
}
@@ -381,6 +450,7 @@ function normalizeRecord(claim = {}, intent = {}) {
const rawStatusLabel = resolveStatusLabel(claim)
const querySource = resolveRecordQuerySource(claim, intent)
const isApprovalTask = isApprovalTaskClaim(claim, intent)
const riskMeta = resolveRecordRiskMeta(claim)
const statusLabel = isApprovalTask && isPendingApprovalStatus(statusKey, rawStatusLabel)
? '待审批'
: rawStatusLabel
@@ -402,6 +472,9 @@ function normalizeRecord(claim = {}, intent = {}) {
statusKey,
statusLabel,
statusTone: resolveStatusTone(statusLabel),
hasRisk: riskMeta.hasRisk,
riskTone: riskMeta.riskTone,
riskLabel: riskMeta.riskLabel,
querySource,
isApprovalTask,
reason,
@@ -417,7 +490,8 @@ function normalizeRecord(claim = {}, intent = {}) {
departmentLabel,
locationLabel,
typeLabel,
statusLabel
statusLabel,
riskMeta.riskLabel
].join(' '))
}
}
@@ -459,6 +533,19 @@ function matchesAmountFilter(record = {}, amountFilter = null) {
return true
}
function matchesRiskFilter(record = {}, riskFilter = null) {
if (!riskFilter?.level) {
return true
}
if (riskFilter.level === 'none') {
return !record.hasRisk
}
if (riskFilter.level === 'has') {
return record.hasRisk
}
return record.hasRisk && record.riskTone === riskFilter.level
}
export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
const rows = extractExpenseClaimItems(claimsPayload)
.map((claim) => normalizeRecord(claim, intent))
@@ -472,6 +559,7 @@ export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
.filter((record) => matchesExpenseTypeFilter(record, intent?.expenseTypeFilter))
.filter((record) => matchesKeywordFilter(record, intent?.keywordFilter))
.filter((record) => matchesAmountFilter(record, intent?.amountFilter))
.filter((record) => matchesRiskFilter(record, intent?.riskFilter))
.sort((left, right) => toTimestamp(right.dateKey) - toTimestamp(left.dateKey))
return rows
@@ -492,7 +580,40 @@ function buildDocumentCardFieldHtml(label = '', value = '', options = {}) {
].join('')
}
function buildDocumentCardHtml(record = {}) {
function resolveCommandDetailActionLabel(commandFrame = null) {
if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') {
return '查看详情'
}
if (commandFrame.action === 'delete') {
return '进入详情确认删除'
}
if (commandFrame.action === 'approve') {
return '进入详情确认审核'
}
if (commandFrame.action === 'reject') {
return '进入详情确认驳回'
}
return '进入详情确认'
}
function buildCommandConfirmationGuidanceHtml(commandFrame = null) {
if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') {
return ''
}
const actionVerb = COMMAND_ACTION_VERBS[commandFrame.action] || '操作'
const ctaLabel = resolveCommandDetailActionLabel(commandFrame)
return [
'<section class="ai-document-command-guidance" aria-label="高风险操作提示">',
'<strong class="ai-document-command-guidance__title">需要先确认目标单据</strong>',
'<div class="ai-document-command-guidance__body">',
`系统不会直接${escapeHtml(actionVerb)}相关单据。`,
`如果您希望继续${escapeHtml(actionVerb)}单据,请点击下方候选单据里的快捷按钮“${escapeHtml(ctaLabel)}”,进入单据详情核对后再操作。`,
'</div>',
'</section>'
].join('')
}
function buildDocumentCardHtml(record = {}, options = {}) {
const href = buildAiDocumentDetailHref(record)
const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement'
const approvalTaskClass = record.isApprovalTask ? ' ai-document-card--approval-task' : ''
@@ -503,12 +624,14 @@ function buildDocumentCardHtml(record = {}) {
const ownerText = [record.ownerLabel, record.departmentLabel]
.filter((item) => item && item !== '未显示')
.join(' · ') || '未显示'
const detailActionLabel = resolveCommandDetailActionLabel(options.commandFrame)
const summaryHtml = [
buildDocumentCardFieldHtml('日期', record.time || '待补充'),
buildDocumentCardFieldHtml(amountLabel, record.amountLabel, { valueClass: 'ai-document-card__amount' })
].join('')
const detailsHtml = [
buildDocumentCardFieldHtml('地点', record.locationLabel || '待补充'),
buildDocumentCardFieldHtml('风险', record.riskLabel || '无风险'),
buildDocumentCardFieldHtml('单据编号', record.documentNo || '未编号单据', { valueClass: 'ai-document-card__number' }),
buildDocumentCardFieldHtml('事由', record.reason || '待补充'),
buildDocumentCardFieldHtml('申请人', ownerText),
@@ -516,7 +639,7 @@ function buildDocumentCardHtml(record = {}) {
'<div class="ai-document-card__field ai-document-card__field--action">',
'<span class="ai-document-card__label">操作</span>',
href
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">查看详情</a>`
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">${escapeHtml(detailActionLabel)}</a>`
: '<span class="ai-document-card__value">暂无详情</span>',
'</div>'
].join(''),
@@ -556,11 +679,13 @@ function buildDocumentQuerySummaryHtml(scopeText = '', totalCount = 0, visibleCo
function buildDocumentCardsHtml(records = [], options = {}) {
const querySummaryHtml = options.querySummaryHtml || ''
const commandGuidanceHtml = buildCommandConfirmationGuidanceHtml(options.commandFrame)
return [
'<!-- ai-trusted-html:start -->',
commandGuidanceHtml,
querySummaryHtml,
'<section class="ai-document-card-list" aria-label="单据查询结果">',
...records.map((record) => buildDocumentCardHtml(record)),
...records.map((record) => buildDocumentCardHtml(record, options)),
'</section>',
'<!-- ai-trusted-html:end -->'
].join('\n')
@@ -574,7 +699,8 @@ function buildQueryScopeText(intent = {}) {
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : ''
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '',
intent.riskFilter?.label ? `风险:${intent.riskFilter.label}` : ''
].filter(Boolean).join(' / ')
}
@@ -586,12 +712,52 @@ export function buildAiDocumentQueryConditionSummary(intent = {}) {
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : ''
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '',
intent.riskFilter?.label ? `风险:${intent.riskFilter.label}` : ''
].filter(Boolean)
return conditions.join('')
}
export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
function buildAiDocumentCommandPolicyEvent(commandFrame = null) {
if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') {
return null
}
const actionLabel = COMMAND_ACTION_LABELS[commandFrame.action] || '高风险操作'
const objectLabel = COMMAND_OBJECT_LABELS[commandFrame.objectType] || '单据'
return {
eventId: 'document-command-policy',
title: '识别高风险操作意图',
content: `识别到用户想执行“${actionLabel}”动作,对象是“${objectLabel}”。这类动作不会直接执行,我会先筛选候选单据,再让用户进入详情页确认。`,
status: 'running'
}
}
export function buildAiDocumentQueryThinkingEvents(intent = {}, options = {}) {
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
return [
buildAiDocumentCommandPolicyEvent(options.commandFrame),
{
eventId: 'document-query-parse',
title: '解析自然语言筛选条件',
content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、风险、关键词和金额条件。当前识别:${conditionSummary}`,
status: 'running'
},
{
eventId: 'document-query-fetch',
title: '查询业务单据接口',
content: '等待调用业务单据接口。',
status: 'pending'
},
{
eventId: 'document-query-filter',
title: '组合筛选单据',
content: '等待接口返回后,再按已识别条件做二次筛选。',
status: 'pending'
}
].filter(Boolean)
}
export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = [], options = {}) {
const records = filterAiDocumentQueryRecords(claimsPayload, intent)
const visibleRecords = records.slice(0, DOCUMENT_QUERY_LIMIT)
const scopeText = buildQueryScopeText(intent)
@@ -610,7 +776,8 @@ export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
'### 已查询到相关单据',
'',
buildDocumentCardsHtml(visibleRecords, {
querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length)
querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length),
commandFrame: options.commandFrame
})
]

View File

@@ -3,7 +3,10 @@ const MAX_CONVERSATION_HISTORY = 30
const MAX_STORED_MESSAGES = 80
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
const DELETED_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-deleted-document-detail:'
const APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g
const DOCUMENT_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-document-detail:[^)]+)\)/g
function safeString(value) {
return String(value || '').trim()
@@ -25,12 +28,12 @@ function collectDeletedDraftIdentifiers(payload = {}) {
].map((item) => normalizeIdentifier(item)).filter(Boolean))
}
function decodeApplicationDetailHref(href = '') {
function decodeDetailHref(href = '', prefix = '') {
const value = safeString(href)
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
if (!value.startsWith(prefix)) {
return []
}
const encodedReference = value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)
const encodedReference = value.slice(prefix.length)
if (!encodedReference) {
return []
}
@@ -54,10 +57,22 @@ function decodeApplicationDetailHref(href = '') {
return [...identifiers]
}
function decodeApplicationDetailHref(href = '') {
return decodeDetailHref(href, APPLICATION_DETAIL_HREF_PREFIX)
}
function decodeDocumentDetailHref(href = '') {
return decodeDetailHref(href, DOCUMENT_DETAIL_HREF_PREFIX)
}
function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) {
return decodeApplicationDetailHref(href).some((item) => identifiers.has(item))
}
function documentDetailHrefMatchesDeletedDocument(href = '', identifiers = new Set()) {
return decodeDocumentDetailHref(href).some((item) => identifiers.has(item))
}
function buildDeletedApplicationDetailHref(href = '') {
const value = safeString(href)
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
@@ -66,6 +81,14 @@ function buildDeletedApplicationDetailHref(href = '') {
return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)}`
}
function buildDeletedDocumentDetailHref(href = '') {
const value = safeString(href)
if (!value.startsWith(DOCUMENT_DETAIL_HREF_PREFIX)) {
return ''
}
return `${DELETED_DOCUMENT_DETAIL_HREF_PREFIX}${value.slice(DOCUMENT_DETAIL_HREF_PREFIX.length)}`
}
function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()) {
let changed = false
const nextContent = String(content || '').replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
@@ -82,6 +105,33 @@ function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()
return { content: nextContent, changed }
}
function markDocumentDetailLinksDeleted(content = '', identifiers = new Set()) {
let changed = false
const afterDocumentLinks = String(content || '').replace(DOCUMENT_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
if (!documentDetailHrefMatchesDeletedDocument(href, identifiers)) {
return match
}
const deletedHref = buildDeletedDocumentDetailHref(href)
if (!deletedHref) {
return '单据已删除'
}
changed = true
return `[单据已删除](${deletedHref})`
})
const nextContent = afterDocumentLinks.replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
if (!applicationDetailHrefMatchesDeletedDraft(href, identifiers)) {
return match
}
const deletedHref = buildDeletedApplicationDetailHref(href)
if (!deletedHref) {
return '单据已删除'
}
changed = true
return `[单据已删除](${deletedHref})`
})
return { content: nextContent, changed }
}
function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
return [
@@ -98,7 +148,8 @@ function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
let changed = false
const nextActions = (Array.isArray(actions) ? actions : []).map((action) => {
if (String(action?.action_type || '').trim() !== 'open_application_detail') {
const actionType = String(action?.action_type || '').trim()
if (!['open_application_detail', 'open_document_detail'].includes(actionType)) {
return action
}
if (!actionMatchesDeletedDraft(action, identifiers)) {
@@ -111,7 +162,9 @@ function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
description: '草稿单据已经删除,请重新再次申请。',
icon: 'mdi mdi-trash-can-outline',
disabled: true,
action_type: 'deleted_application_detail'
action_type: actionType === 'open_document_detail'
? 'deleted_document_detail'
: 'deleted_application_detail'
}
})
return { actions: nextActions, changed }
@@ -132,6 +185,21 @@ function buildDraftDeletedMessage(payload = {}) {
}
}
function buildDocumentDeletedMessage(payload = {}) {
const claimNo = safeString(payload.claimNo || payload.claim_no || payload.documentNo || payload.document_no)
return {
id: `document-deleted-${safeString(payload.claimId || payload.claim_id || payload.id || claimNo) || Date.now()}`,
role: 'assistant',
content: [
`单据${claimNo ? ` ${claimNo}` : ''} 已经删除或不可访问。`,
'该历史入口已失效,请返回单据列表重新查询。'
].join('\n\n'),
feedback: '',
stewardPlan: null,
suggestedActions: []
}
}
function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
return messages.some((message) => {
const content = safeString(message?.content)
@@ -139,6 +207,13 @@ function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
})
}
function conversationHasDocumentDeletionNotice(messages = [], identifiers = new Set()) {
return messages.some((message) => {
const content = safeString(message?.content)
return content.includes('已经删除或不可访问') && [...identifiers].some((item) => content.includes(item))
})
}
function resolveUserStorageKey(user = {}) {
const identity = safeString(user.username || user.email || user.name || 'anonymous')
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
@@ -329,3 +404,46 @@ export function markAiWorkbenchConversationDraftDeleted(user = {}, payload = {})
writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user)
}
export function markAiWorkbenchConversationDocumentDeleted(user = {}, payload = {}) {
const identifiers = collectDeletedDraftIdentifiers(payload)
if (!identifiers.size) {
return loadAiWorkbenchConversationHistory(user)
}
const nextList = readStoredList(user).map((conversation) => {
const normalized = normalizeConversation(conversation)
let conversationChanged = false
const messages = normalized.messages.map((message) => {
const contentResult = markDocumentDetailLinksDeleted(message.content, identifiers)
const actionsResult = markSuggestedActionsDeleted(message.suggestedActions, identifiers)
if (!contentResult.changed && !actionsResult.changed) {
return message
}
conversationChanged = true
return {
...message,
content: contentResult.content,
suggestedActions: actionsResult.actions
}
})
if (!conversationChanged) {
return normalized
}
if (!conversationHasDocumentDeletionNotice(messages, identifiers)) {
messages.push(buildDocumentDeletedMessage(payload))
}
return {
...normalized,
desc: '单据已经删除或不可访问。',
messages,
updatedAt: Date.now()
}
})
writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user)
}

View File

@@ -115,6 +115,15 @@ test('AI conversation renderer renders document detail action links as buttons',
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-document-detail/)
})
test('AI conversation renderer renders deleted document detail actions as disabled buttons', () => {
const rendered = renderAiConversationHtml('[单据已删除](#ai-deleted-document-detail:claim-deleted-1)')
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document is-disabled"/)
assert.match(rendered, /aria-disabled="true"/)
assert.match(rendered, /data-ai-action="deleted-document-detail"/)
assert.doesNotMatch(rendered, /href="#ai-deleted-document-detail/)
})
test('AI conversation renderer renders images as html and rejects unsafe image sources', () => {
const rendered = renderAiConversationHtml([
'### 图片材料',

View File

@@ -9,6 +9,8 @@ import {
resolveAiDocumentQueryIntent
} from '../src/utils/aiDocumentQueryModel.js'
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
import { resolveWorkbenchIntentActionRoute } from '../src/composables/workbenchAiMode/workbenchIntentActionPolicy.js'
import { resolveWorkbenchIntentFrame } from '../src/composables/workbenchAiMode/workbenchIntentFrameModel.js'
const today = '2026-06-20'
@@ -68,6 +70,21 @@ test('AI document query intent detects approval document questions', () => {
assert.equal(intent?.sourceLabel, '待我审核的单据')
})
test('AI document query intent detects short approval workbench commands', () => {
const reviewIntent = resolveAiDocumentQueryIntent('我要审核', { today })
const todoIntent = resolveAiDocumentQueryIntent('待办审批', { today })
assert.equal(reviewIntent?.source, 'approval')
assert.equal(reviewIntent?.documentType, 'all')
assert.equal(reviewIntent?.sourceLabel, '待我审核的单据')
assert.equal(todoIntent?.source, 'approval')
assert.equal(todoIntent?.sourceLabel, '待我审核的单据')
})
test('AI document query intent keeps approval policy questions out of document query', () => {
assert.equal(resolveAiDocumentQueryIntent('审批规则怎么走', { today }), null)
})
test('AI document query keeps explicit own-document scope separate from accessible documents', () => {
const intent = resolveAiDocumentQueryIntent('我名下有哪些单据?', { today })
@@ -136,6 +153,66 @@ test('AI document query combines natural-language filters', () => {
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额不少于1000元/)
})
test('AI document query filters draft candidates by relative day', () => {
const intent = resolveAiDocumentQueryIntent('我的 3天前 草稿单据', { today: '2026-06-24' })
const records = filterAiDocumentQueryRecords([
{
id: 'draft-3-days-ago',
claim_no: 'AP-20260621001',
document_type_code: 'application',
status: 'draft',
reason: '三天前保存的申请草稿',
created_at: '2026-06-21T09:00:00Z'
},
{
id: 'draft-yesterday',
claim_no: 'AP-20260623001',
document_type_code: 'application',
status: 'draft',
reason: '昨天保存的申请草稿',
created_at: '2026-06-23T09:00:00Z'
}
], intent)
assert.equal(intent?.source, 'mine')
assert.equal(intent?.statusFilter?.label, '草稿')
assert.equal(intent?.timeRange?.start, '2026-06-21')
assert.equal(intent?.timeRange?.end, '2026-06-21')
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260621001'])
})
test('AI document query filters no-risk approval application candidates', () => {
const intent = resolveAiDocumentQueryIntent('待我审核 无风险 申请单', { today })
const payload = [{
id: 'approval-no-risk',
claim_no: 'AP-NORISK-001',
document_type_code: 'application',
expense_type: 'travel_application',
status: 'submitted',
reason: '合规差旅申请',
created_at: '2026-06-20T09:00:00Z',
risk_flags_json: [],
risk_summary: '无'
}, {
id: 'approval-high-risk',
claim_no: 'AP-RISK-001',
document_type_code: 'application',
expense_type: 'travel_application',
status: 'submitted',
reason: '住宿超标申请',
created_at: '2026-06-20T10:00:00Z',
risk_flags_json: [{ severity: 'high', summary: '住宿超标' }],
risk_summary: '住宿超标'
}]
const records = filterAiDocumentQueryRecords({ items: payload, querySource: 'approval' }, intent)
assert.equal(intent?.source, 'approval')
assert.equal(intent?.documentType, 'application')
assert.equal(intent?.riskFilter?.level, 'none')
assert.match(buildAiDocumentQueryConditionSummary(intent), /风险:无风险/)
assert.deepEqual(records.map((record) => record.documentNo), ['AP-NORISK-001'])
})
test('AI document query excludes undated rows when a time condition is present', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const records = filterAiDocumentQueryRecords([
@@ -246,6 +323,28 @@ test('AI document query html cards render as trusted card markup', () => {
assert.doesNotMatch(rendered, /<blockquote>/)
})
test('AI document query keeps high-risk command guidance in trusted rendered markup', () => {
const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today: '2026-06-24' })
const route = resolveWorkbenchIntentActionRoute(frame)
const intent = resolveAiDocumentQueryIntent(route.queryPrompt, { today: '2026-06-24' })
const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, [{
id: 'draft-application-1',
claim_no: 'AP-DRAFT-001',
document_type_code: 'application',
expense_type: 'travel_application',
status: 'draft',
reason: '测试申请草稿',
created_at: '2026-06-24T09:00:00Z'
}], { commandFrame: frame }))
assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/)
assert.match(rendered, /<section class="ai-document-command-guidance" aria-label="高风险操作提示">/)
assert.match(rendered, /系统不会直接删除相关单据/)
assert.match(rendered, /进入单据详情核对后再操作/)
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
assert.match(rendered, /进入详情确认删除/)
})
test('AI document query trusted html rejects unsafe card markup', () => {
const rendered = renderAiConversationHtml([
'### 查询结果',

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import {
markAiWorkbenchConversationDraftDeleted,
markAiWorkbenchConversationDocumentDeleted,
loadAiWorkbenchConversationHistory,
saveAiWorkbenchConversation
} from '../src/utils/aiWorkbenchConversationStore.js'
@@ -120,6 +121,54 @@ test('deleting an application draft marks AI workbench detail links as unavailab
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
})
test('deleted document detail links are disabled for reopened AI conversations', () => {
installWindowStub()
const user = { username: 'zhangsan@example.com' }
saveAiWorkbenchConversation(user, {
id: 'conversation-document-delete-candidate',
title: '删除申请单草稿',
messages: [
{
id: 'assistant-document-candidates',
role: 'assistant',
content: [
'### 已查询到相关单据',
'',
'[进入详情确认删除](#ai-open-document-detail:claim_id%3Dclaim-draft-2%26claim_no%3DAP-DRAFT-002)'
].join('\n')
}
]
})
const nextHistory = markAiWorkbenchConversationDocumentDeleted(user, {
claimId: 'claim-draft-2',
claimNo: 'AP-DRAFT-002'
})
const conversation = nextHistory.find((item) => item.id === 'conversation-document-delete-candidate')
assert.ok(conversation)
assert.match(conversation.messages[0].content, /#ai-deleted-document-detail:/)
assert.doesNotMatch(conversation.messages[0].content, /#ai-open-document-detail:/)
assert.match(conversation.messages[0].content, /\[单据已删除\]/)
assert.match(conversation.messages.at(-1).content, /单据 AP-DRAFT-002 已经删除或不可访问/)
assert.match(conversation.messages.at(-1).content, /该历史入口已失效,请返回单据列表重新查询/)
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
})
test('AI workbench validates document detail links before opening stale history entries', () => {
const aiMode = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
assert.match(aiMode, /fetchExpenseClaimDetail/)
assert.match(aiMode, /markAiWorkbenchConversationDocumentDeleted/)
assert.match(aiMode, /async function handleAiAnswerMarkdownClick/)
assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable/)
assert.match(aiMode, /已将这条历史入口标记为不可查看/)
})
test('saving a draft keeps the financial assistant open for continued work', () => {
const appShellScript = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),

View File

@@ -0,0 +1,104 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildWorkbenchDraftDeletionGuidance,
isWorkbenchDraftDeletionIntent,
resolveLatestWorkbenchDraftPayload
} from '../src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js'
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
const commandIntentsScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiCommandIntents.js', import.meta.url)),
'utf8'
)
test('workbench command intent detects draft deletion phrases without broad delete matching', () => {
assert.equal(isWorkbenchDraftDeletionIntent('删除草稿'), true)
assert.equal(isWorkbenchDraftDeletionIntent('把刚才保存的草稿删掉'), true)
assert.equal(isWorkbenchDraftDeletionIntent('删除这个申请单'), true)
assert.equal(isWorkbenchDraftDeletionIntent('删除附件'), false)
assert.equal(isWorkbenchDraftDeletionIntent('草稿还要补什么'), false)
})
test('workbench command intent resolves latest draft payload from conversation context', () => {
const payload = resolveLatestWorkbenchDraftPayload([
{
role: 'assistant',
draftPayload: {
claim_id: 'old-draft',
claim_no: 'AOLD001',
status: 'draft'
}
},
{
role: 'assistant',
suggestedActions: [{
action_type: 'open_application_detail',
payload: {
claim_id: 'latest-draft',
claim_no: 'ALATEST1',
status: 'draft'
}
}]
}
])
assert.deepEqual(payload, {
claimId: 'latest-draft',
claimNo: 'ALATEST1',
status: 'draft',
documentType: 'application'
})
})
test('workbench command intent skips submitted documents when deleting draft', () => {
const payload = resolveLatestWorkbenchDraftPayload([
{
role: 'assistant',
draftPayload: {
claim_id: 'submitted-application',
claim_no: 'ASUBMIT1',
status: 'submitted'
}
}
])
assert.equal(payload, null)
})
test('workbench draft deletion guidance opens detail instead of deleting directly', () => {
const guidance = buildWorkbenchDraftDeletionGuidance({
claimId: 'latest-draft',
claimNo: 'ALATEST1',
documentType: 'application'
})
assert.match(guidance.content, /已识别到您想删除草稿/)
assert.match(guidance.content, /不会直接替您删除/)
assert.equal(guidance.suggestedActions.length, 1)
assert.equal(guidance.suggestedActions[0].action_type, 'open_application_detail')
assert.equal(guidance.suggestedActions[0].payload.claim_id, 'latest-draft')
assert.equal(guidance.suggestedActions[0].payload.claim_no, 'ALATEST1')
})
test('workbench draft deletion intent is wired before draft slot continuation', () => {
assert.match(commandIntentsScript, /isWorkbenchDraftDeletionIntent/)
assert.match(commandIntentsScript, /function handleInlineDraftDeletionIntent\(cleanPrompt, entry = \{\}\)/)
assert.match(commandIntentsScript, /resolveLatestWorkbenchDraftPayload\(conversationMessages\.value\)/)
assert.match(commandIntentsScript, /buildWorkbenchDraftDeletionGuidance\(draftPayload\)/)
assert.match(personalWorkbenchAiModeScript, /useWorkbenchAiCommandIntents/)
const startIndex = personalWorkbenchAiModeScript.indexOf('function startInlineConversation')
const startBlock = personalWorkbenchAiModeScript.slice(startIndex)
const deleteIntentIndex = startBlock.indexOf('if (commandIntents.handleInlineDraftDeletionIntent(cleanPrompt, entry))')
const draftContinuationIndex = startBlock.indexOf('if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value))')
assert.ok(deleteIntentIndex >= 0)
assert.ok(draftContinuationIndex > deleteIntentIndex)
})

View File

@@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
resolveWorkbenchIntentFrame
} from '../src/composables/workbenchAiMode/workbenchIntentFrameModel.js'
import {
resolveWorkbenchIntentActionRoute
} from '../src/composables/workbenchAiMode/workbenchIntentActionPolicy.js'
import {
buildAiDocumentQueryMessage,
buildAiDocumentQueryThinkingEvents,
resolveAiDocumentQueryIntent
} from '../src/utils/aiDocumentQueryModel.js'
const today = '2026-06-24'
test('workbench intent frame resolves contextual draft deletion as confirm-only current target', () => {
const frame = resolveWorkbenchIntentFrame('请删除刚才那个草稿', { today })
assert.equal(frame?.action, 'delete')
assert.equal(frame?.objectType, 'draft')
assert.equal(frame?.targetMode, 'current_context')
assert.equal(frame?.safetyLevel, 'confirm_required')
assert.equal(frame?.filters.status?.label, '草稿')
assert.equal(frame?.normalizedQuery, '我的草稿单据')
})
test('workbench intent frame sends filtered draft deletion to candidate search', () => {
const frame = resolveWorkbenchIntentFrame('删除3天前的草稿', { today })
const route = resolveWorkbenchIntentActionRoute(frame)
assert.equal(frame?.action, 'delete')
assert.equal(frame?.objectType, 'draft')
assert.equal(frame?.targetMode, 'filtered_candidates')
assert.equal(frame?.safetyLevel, 'confirm_required')
assert.equal(frame?.filters.timeRange?.start, '2026-06-21')
assert.equal(frame?.filters.timeRange?.end, '2026-06-21')
assert.equal(frame?.normalizedQuery, '我的 3天前 草稿单据')
assert.equal(route.nextStep, 'query_candidates')
assert.equal(route.queryPrompt, '我的 3天前 草稿单据')
})
test('workbench intent frame preserves application draft deletion filters', () => {
const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today })
const route = resolveWorkbenchIntentActionRoute(frame)
const queryIntent = resolveAiDocumentQueryIntent(route.queryPrompt, { today })
assert.equal(frame?.action, 'delete')
assert.equal(frame?.objectType, 'draft')
assert.equal(frame?.filters.documentType, 'application')
assert.equal(frame?.filters.status?.label, '草稿')
assert.equal(frame?.targetMode, 'filtered_candidates')
assert.equal(frame?.safetyLevel, 'confirm_required')
assert.equal(route.queryPrompt, '我的 草稿 申请单')
assert.equal(queryIntent?.source, 'mine')
assert.equal(queryIntent?.documentType, 'application')
assert.equal(queryIntent?.statusFilter?.label, '草稿')
})
test('workbench document query thinking exposes destructive action policy before filtering', () => {
const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today })
const intent = resolveAiDocumentQueryIntent(resolveWorkbenchIntentActionRoute(frame).queryPrompt, { today })
const events = buildAiDocumentQueryThinkingEvents(intent, { commandFrame: frame })
assert.equal(events[0].eventId, 'document-command-policy')
assert.match(events[0].title, /识别高风险操作意图/)
assert.match(events[0].content, /删除/)
assert.match(events[0].content, /不会直接执行/)
assert.match(events[0].content, /先筛选候选/)
})
test('workbench high-risk command result explains confirmation boundary and labels detail shortcut', () => {
const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today })
const intent = resolveAiDocumentQueryIntent(resolveWorkbenchIntentActionRoute(frame).queryPrompt, { today })
const message = buildAiDocumentQueryMessage(intent, [{
id: 'draft-application-1',
claim_no: 'AP-DRAFT-001',
document_type_code: 'application',
expense_type: 'travel_application',
status: 'draft',
reason: '测试申请草稿',
created_at: '2026-06-24T09:00:00Z',
risk_flags_json: [],
risk_summary: '无'
}], { commandFrame: frame })
assert.match(message, /系统不会直接删除相关单据/)
assert.match(message, /请点击下方候选单据里的快捷按钮/)
assert.match(message, /进入单据详情核对后再操作/)
assert.match(message, />进入详情确认删除</)
})
test('workbench intent frame resolves compliant no-risk approval request as filtered approval candidates', () => {
const frame = resolveWorkbenchIntentFrame('审核合规没有风险的申请', { today })
const route = resolveWorkbenchIntentActionRoute(frame)
assert.equal(frame?.action, 'approve')
assert.equal(frame?.objectType, 'application')
assert.equal(frame?.targetMode, 'filtered_candidates')
assert.equal(frame?.safetyLevel, 'confirm_required')
assert.equal(frame?.filters.risk?.level, 'none')
assert.equal(frame?.filters.documentType, 'application')
assert.equal(frame?.normalizedQuery, '待我审核 无风险 申请单')
assert.equal(route.nextStep, 'query_candidates')
assert.equal(route.queryPrompt, '待我审核 无风险 申请单')
})
test('workbench intent frame keeps approval policy questions out of document actions', () => {
const frame = resolveWorkbenchIntentFrame('审批规则怎么走', { today })
const route = resolveWorkbenchIntentActionRoute(frame)
assert.equal(frame?.action, 'ask_policy')
assert.equal(frame?.safetyLevel, 'read_only')
assert.equal(route.nextStep, 'pass_through')
})