Files
X-Financial/web/tests/workbench-ai-action-router.test.mjs
caoxiaozhu bc560145a4 feat(web): AI 工作台意图规划与规划思考模型
- 新增 workbenchAiIntentPlannerModel,基于 LLM function_call 解析建单/草稿/提交意图,区分 model 与 rule_fallback 来源
- 新增 workbenchAiPlanningThinkingModel 合并规划思考事件流,按 eventId 去重合并
- application gate/preview 模型接入意图规划,usePersonalWorkbenchAiMode/useWorkbenchAiStewardFlow/useWorkbenchAiActionRouter 链路适配,支持上下文提交
- steward 服务与 stewardPlanModel 适配新动作结构,receipt-folder-view 微调样式
- 新增 intent-planner-model/application-context-submit/steward-actions-service 测试,更新 gate-model/action-router/plan-message-copy/fast-preview 测试
2026-06-24 21:58:46 +08:00

392 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.js'
import { useWorkbenchAiActionRouter } from '../src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js'
import {
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION
} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
test('workbench steward application confirmation opens inline application preview directly', () => {
const [action] = buildStewardSuggestedActions({
plan_id: 'steward-plan-ready-application',
plan_status: 'ready',
tasks: [
{
task_id: 'task-application-beijing',
task_type: 'expense_application',
title: '费用申请 2026-06-23 北京',
summary: '明天前往北京出差3天支撑客户现场实施。',
assigned_agent: 'application_assistant',
ontology_fields: {
expense_type: 'travel',
time_range: '2026-06-23 至 2026-06-25',
location: '北京',
days: '3天',
reason: '支撑客户现场实施'
},
missing_fields: ['transport_mode']
}
],
confirmation_groups: [
{
confirmation_id: 'confirm-application-beijing',
action_type: 'confirm_create_application',
target_task_id: 'task-application-beijing'
}
]
})
let previewPayload = null
let fallbackConversationStarted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: (payload) => {
previewPayload = payload
},
startAiExpenseDraft: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {
fallbackConversationStarted = true
},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction(action)
assert.equal(fallbackConversationStarted, false)
assert.equal(previewPayload?.flow_id, 'travel_application')
assert.equal(previewPayload?.expense_type, 'travel')
assert.equal(previewPayload?.expense_type_label, '差旅费')
assert.match(previewPayload?.carry_text || '', /支撑客户现场实施/)
const preview = buildInlineApplicationPreview(previewPayload.expense_type_label, previewPayload.carry_text, {
name: '测试用户',
departmentName: '交付部',
position: '实施顾问',
managerName: '张经理',
grade: 'P5'
})
assert.equal(preview.fields.time, '2026-06-23 至 2026-06-25')
assert.equal(preview.fields.location, '北京')
assert.equal(preview.fields.reason, '支撑客户现场实施')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.transportMode, '')
})
test('workbench reimbursement skip link action opens new reimbursement flow', () => {
let sceneSelectionPayload = null
let fallbackConversationStarted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
pushInlineExpenseSceneSelectionPrompt: (sourceText, label) => {
sceneSelectionPayload = { sourceText, label }
},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {
fallbackConversationStarted = true
},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction({
label: '不关联,单独新建报销单',
action_type: 'skip_required_application_link',
payload: {
original_message: '我要报销'
}
})
assert.equal(fallbackConversationStarted, false)
assert.deepEqual(sceneSelectionPayload, {
sourceText: '我要报销',
label: '不关联,单独新建报销单'
})
})
test('workbench draft continuation action asks for attachments or description', () => {
let continuationPayload = null
let fallbackConversationStarted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
promptAiReimbursementDraftContinuation: (payload) => {
continuationPayload = payload
},
promptStandaloneReimbursementDraftCreation: () => {},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {},
startAiReimbursementAssociationGate: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {
fallbackConversationStarted = true
},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction({
label: '继续关联草稿 RE-202606-010',
action_type: CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
payload: {
claim_id: 'draft-travel-1',
claim_no: 'RE-202606-010',
original_message: '我要报销'
}
})
assert.equal(fallbackConversationStarted, false)
assert.deepEqual(continuationPayload, {
claim_id: 'draft-travel-1',
claim_no: 'RE-202606-010',
original_message: '我要报销'
})
})
test('workbench standalone draft action asks before creating a new reimbursement draft', () => {
let standalonePrompt = null
let fallbackConversationStarted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
promptAiReimbursementDraftContinuation: () => {},
promptStandaloneReimbursementDraftCreation: (sourceText, label) => {
standalonePrompt = { sourceText, label }
},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {},
startAiReimbursementAssociationGate: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {
fallbackConversationStarted = true
},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction({
label: '独立新建报销单',
action_type: CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
payload: {
original_message: '我要报销'
}
})
assert.equal(fallbackConversationStarted, false)
assert.deepEqual(standalonePrompt, {
sourceText: '我要报销',
label: '独立新建报销单'
})
})
test('workbench steward executable submit action runs precheck before submit and writes result message', async () => {
const requests = []
const originalFetch = globalThis.fetch
globalThis.fetch = async (_url, options = {}) => {
const body = JSON.parse(String(options.body || '{}'))
requests.push(body)
if (body.action_type === 'run_duplicate_precheck') {
return {
ok: true,
async json() {
return {
action_type: 'run_duplicate_precheck',
status: 'succeeded',
message: '未发现重复或冲突申请,可以继续提交。',
result_payload: {
status: 'ok',
blocking: false
}
}
}
}
}
return {
ok: true,
async json() {
return {
action_type: 'submit_application',
status: 'succeeded',
message: '申请已提交审批。',
result_payload: {
draft_payload: {
claim_id: 'claim-app-1',
claim_no: 'A1BCDEF2'
}
}
}
}
}
}
try {
const messages = []
let messageSeq = 0
const createInlineMessage = (role, content, options = {}) => ({
id: options.id || `msg-${++messageSeq}`,
role,
content,
pending: Boolean(options.pending),
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : []
})
const replaceInlineMessage = (id, nextMessage) => {
const index = messages.findIndex((item) => item.id === id)
if (index >= 0) {
messages.splice(index, 1, nextMessage)
}
}
let persisted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
conversationMessages: { value: messages },
createInlineMessage,
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
persistCurrentConversation: () => {
persisted = true
},
replaceInlineMessage,
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23去上海出差交通火车直接提交',
scrollInlineConversationToBottom: () => {},
selectedFiles: { value: [] },
startInlineConversation: () => {},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
const sourceMessage = {
suggestedActionsLocked: false
}
await router.handleInlineSuggestedAction({
label: '确认提交申请',
action_type: 'switch_session',
payload: {
steward_execute_action: true,
steward_plan_id: 'plan-submit-1',
steward_action_type: 'submit_application',
steward_action_requires_confirmation: true,
steward_action_step: {
step_id: 'task-app-1:05',
action_type: 'submit_application',
requires_confirmation: true
},
steward_current_task: {
task_id: 'task-app-1',
task_type: 'expense_application',
assigned_agent: 'application_assistant',
title: '上海出差申请',
summary: '2026-02-20 至 2026-02-23 去上海出差,交通火车。',
requested_action: 'submit',
ontology_fields: {
expense_type: 'travel',
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: 'train'
},
missing_fields: [],
action_steps: [
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck' },
{ step_id: 'task-app-1:05', action_type: 'submit_application', requires_confirmation: true }
]
},
carry_text: '2026-02-20 至 2026-02-23去上海出差交通火车直接提交'
}
}, sourceMessage)
assert.equal(requests.length, 2)
assert.equal(requests[0].action_type, 'run_duplicate_precheck')
assert.equal(requests[1].action_type, 'submit_application')
assert.equal(requests[1].confirmed, true)
assert.equal(requests[1].context_json.precheck_result.status, 'ok')
assert.equal(sourceMessage.suggestedActionsLocked, true)
assert.equal(persisted, true)
assert.match(messages.at(-1).content, /申请已提交审批/)
assert.equal(messages.at(-1).suggestedActions[0].action_type, 'open_application_detail')
} finally {
globalThis.fetch = originalFetch
}
})