feat(web): AI 工作台多 task 串行推进与会话适配
- useWorkbenchAiApplicationPreviewFlow/useWorkbenchAiActionRouter/useWorkbenchAiCommandIntents 支持 task1 完成后自动推进 task2,确认按钮直接拉起申请预览,草稿/提交成功后继续推进下一 task - workbenchAiIntentPlannerModel/workbenchAiMessageModel/workbenchAiCommandIntentModel 适配多 task 意图规划与消息结构 - aiApplicationPreviewActions/aiApplicationPrecheckModel/aiExpenseDraftModel/aiWorkbenchConversationStore 草稿与会话存储适配 - PersonalWorkbenchAiMode 与样式适配,更新 preview-actions/expense-draft/conversation-store/fast-preview/action-router/command-intent/intent-planner 测试
This commit is contained in:
@@ -95,9 +95,62 @@ async function testSaveDraftActionUsesFastPreviewEndpoint() {
|
||||
assert.equal(body.context_json.application_stage, 'expense_application')
|
||||
}
|
||||
|
||||
async function testEditDraftActionCarriesClaimAndEditableFields() {
|
||||
let capturedOptions = null
|
||||
|
||||
global.fetch = async (_url, options) => {
|
||||
capturedOptions = options
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
status: 'succeeded',
|
||||
result: {
|
||||
draft_payload: {
|
||||
claim_id: 'claim-edit-application',
|
||||
claim_no: 'AP-20260620-EDIT',
|
||||
status: 'draft',
|
||||
approval_stage: '待提交'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await runAiApplicationPreviewAction({
|
||||
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
applicationPreview: {
|
||||
applicationEditMode: true,
|
||||
editableFields: ['reason', 'time', 'location', 'transportMode'],
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-07-01 至 2026-07-03',
|
||||
location: '北京',
|
||||
reason: '项目实施',
|
||||
days: '3天',
|
||||
transportMode: '火车',
|
||||
amount: '1000元'
|
||||
}
|
||||
},
|
||||
currentUser: { username: 'zhangsan@example.com', name: '张三' },
|
||||
draftPayload: {
|
||||
claim_id: 'claim-edit-application',
|
||||
claim_no: 'AP-20260620-EDIT',
|
||||
status: 'returned'
|
||||
}
|
||||
})
|
||||
|
||||
const body = JSON.parse(capturedOptions.body)
|
||||
assert.equal(body.context_json.application_edit_claim_id, 'claim-edit-application')
|
||||
assert.equal(body.context_json.application_edit_mode, true)
|
||||
assert.deepEqual(body.context_json.application_editable_fields, ['reason', 'time', 'location', 'transportMode'])
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await testSubmitActionUsesFastPreviewEndpoint()
|
||||
await testSaveDraftActionUsesFastPreviewEndpoint()
|
||||
await testEditDraftActionCarriesClaimAndEditableFields()
|
||||
console.log('ai-application-preview-actions tests passed')
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test'
|
||||
|
||||
import {
|
||||
applyAiExpenseAnswer,
|
||||
buildAiExpenseDraftPrefillValues,
|
||||
buildAiExpenseStepPrompt,
|
||||
buildAiExpenseSummary,
|
||||
createAiExpenseDraft,
|
||||
@@ -71,3 +72,41 @@ test('summary lists every filled field and the linked application', () => {
|
||||
assert.match(summary, /AP-202606-001/)
|
||||
assert.match(summary, /85元/)
|
||||
})
|
||||
|
||||
test('buildAiExpenseDraftPrefillValues maps task ontology fields onto draft fields', () => {
|
||||
const values = buildAiExpenseDraftPrefillValues({
|
||||
expense_type: 'meal',
|
||||
amount: '2000元',
|
||||
time_range: '昨天',
|
||||
reason: '客户招待',
|
||||
location: '上海',
|
||||
unrelated_field: 'ignore me'
|
||||
})
|
||||
assert.equal(values.amount, '2000元')
|
||||
assert.equal(values.time_range, '昨天')
|
||||
assert.equal(values.reason, '客户招待')
|
||||
assert.equal(values.location, '上海')
|
||||
assert.equal(values.unrelated_field, undefined)
|
||||
})
|
||||
|
||||
test('createAiExpenseDraft with prefillValues skips already filled steps', () => {
|
||||
const draft = createAiExpenseDraft('meal', '业务招待费', {
|
||||
amount: '2000元',
|
||||
reason: '客户招待'
|
||||
})
|
||||
// reason 已填,跳到下一个未填字段 time_range
|
||||
assert.equal(draft.values.amount, '2000元')
|
||||
assert.equal(draft.values.reason, '客户招待')
|
||||
assert.equal(draft.stepKey, 'time_range')
|
||||
})
|
||||
|
||||
test('createAiExpenseDraft with all prefillValues lands on summary', () => {
|
||||
const draft = createAiExpenseDraft('meal', '业务招待费', {
|
||||
reason: '客户招待',
|
||||
time_range: '昨天',
|
||||
location: '上海',
|
||||
amount: '2000元',
|
||||
attachments: '稍后上传'
|
||||
})
|
||||
assert.ok(isAiExpenseDraftComplete(draft))
|
||||
})
|
||||
|
||||
@@ -67,3 +67,37 @@ test('AI workbench conversation store persists scoped history for sidebar sessio
|
||||
assert.equal(nextHistory.length, 1)
|
||||
assert.equal(nextHistory[0].id, 'conv-first')
|
||||
})
|
||||
|
||||
test('AI workbench conversation store preserves stewardRemainingTasks on messages', () => {
|
||||
installLocalStorageMock()
|
||||
const user = { username: 'caoxiaozhu' }
|
||||
const remainingTasks = [
|
||||
{ task_id: 't2', task_type: 'reimbursement', ontology_fields: { expense_type: 'meal' } }
|
||||
]
|
||||
|
||||
saveAiWorkbenchConversation(user, {
|
||||
id: 'conv-multi-task',
|
||||
title: '出差+招待费',
|
||||
updatedAt: Date.now(),
|
||||
messages: [
|
||||
{ id: 'u1', role: 'user', content: '出差+报销招待费' },
|
||||
{
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '申请草稿已保存',
|
||||
stewardRemainingTasks: remainingTasks
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const history = loadAiWorkbenchConversationHistory(user)
|
||||
assert.equal(history.length, 1)
|
||||
// 历史摘要不要求保留 stewardRemainingTasks,但加载完整会话时消息上应保留。
|
||||
// 这里通过 saveAiWorkbenchConversation 的往返确认 normalizeMessage 不会丢弃该字段。
|
||||
const stored = JSON.parse(globalThis.window.localStorage.getItem(
|
||||
'x-financial:workbench-ai-conversations:caoxiaozhu'
|
||||
))
|
||||
const conversation = stored.find((item) => item.id === 'conv-multi-task')
|
||||
const persistedMessage = conversation.messages.find((m) => m.id === 'a1')
|
||||
assert.deepEqual(persistedMessage.stewardRemainingTasks, remainingTasks)
|
||||
})
|
||||
|
||||
@@ -362,6 +362,40 @@ test('travel application submit can continue with conversational planning recomm
|
||||
assert.match(recommendation, /AP-202606030001-ABCDE123/)
|
||||
})
|
||||
|
||||
test('application edit preview only allows reason time location and transport changes', () => {
|
||||
const preview = normalizeApplicationPreview({
|
||||
sourceText: '修改申请',
|
||||
applicationEditMode: true,
|
||||
editableFields: ['reason', 'time', 'location', 'transportMode'],
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
applicant: '李文静',
|
||||
grade: 'P5',
|
||||
department: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
time: '2026-05-25 至 2026-05-28',
|
||||
location: '上海',
|
||||
reason: '客户现场项目支持',
|
||||
days: '4天',
|
||||
transportMode: '火车',
|
||||
lodgingDailyCap: '450元/天',
|
||||
subsidyDailyCap: '100元/天',
|
||||
transportPolicy: '按规则测算',
|
||||
policyEstimate: '交通 300元 + 住宿 1800元 + 补贴 400元 = 2500元',
|
||||
amount: '2500元'
|
||||
}
|
||||
})
|
||||
|
||||
const rows = buildApplicationPreviewRows(preview)
|
||||
const editableKeys = rows.filter((row) => row.editable).map((row) => row.key)
|
||||
assert.deepEqual(editableKeys, ['time', 'time_return', 'location', 'reason', 'transportMode'])
|
||||
assert.equal(rows.find((row) => row.key === 'applicationType')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'days')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.editable, false)
|
||||
assert.match(buildLocalApplicationPreviewMessage(preview), /只修改事由、时间、地点和出行方式/)
|
||||
})
|
||||
|
||||
test('application preview renders ordered editable rows and submit text uses edited values', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
|
||||
@@ -93,6 +93,65 @@ test('workbench steward application confirmation opens inline application previe
|
||||
assert.equal(preview.fields.transportMode, '')
|
||||
})
|
||||
|
||||
test('workbench low-confidence application confirmation forwards remaining tasks', () => {
|
||||
let previewCall = null
|
||||
const remainingTasks = [{
|
||||
task_id: 'task-reimbursement-2',
|
||||
task_type: 'reimbursement',
|
||||
assigned_agent: 'reimbursement_assistant',
|
||||
ontology_fields: {
|
||||
expense_type: 'entertainment',
|
||||
expense_type_label: '业务招待费',
|
||||
amount: '2000元',
|
||||
time_range: '2026-06-25',
|
||||
reason: '业务招待'
|
||||
}
|
||||
}]
|
||||
const router = useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft: { value: null },
|
||||
applicationFlow: {
|
||||
isInlineSuggestedActionDisabled: () => false,
|
||||
executeInlineApplicationPreviewAction: () => {},
|
||||
startAiApplicationPreview: (...args) => {
|
||||
previewCall = args
|
||||
}
|
||||
},
|
||||
assistantDraft: { value: '' },
|
||||
attachmentFlow: {
|
||||
confirmAiAttachmentAssociation: () => {}
|
||||
},
|
||||
emit: () => {},
|
||||
expenseFlow: {
|
||||
linkAiExpenseApplication: () => {},
|
||||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||
startAiApplicationPreviewFromAction: () => {},
|
||||
startAiExpenseDraft: () => {}
|
||||
},
|
||||
focusAiModeInput: () => {},
|
||||
hasInlineAttachmentOcrDetails: () => false,
|
||||
resolveLatestInlineUserPrompt: () => '',
|
||||
selectedFiles: { value: [] },
|
||||
startInlineConversation: () => {},
|
||||
toast: () => {},
|
||||
toggleInlineAttachmentOcrDetails: () => {}
|
||||
})
|
||||
|
||||
router.handleInlineSuggestedAction({
|
||||
label: '确认发起出差申请',
|
||||
action_type: 'ai_application_confirm_intent',
|
||||
payload: {
|
||||
sourceText: '2月20-23日去上海出差3天,服务国网服务器部署,并且报销昨天的业务招待费2000元',
|
||||
ontologyFields: { location: '上海', reason: '服务国网服务器部署' },
|
||||
stewardRemainingTasks: remainingTasks
|
||||
}
|
||||
})
|
||||
|
||||
assert.ok(previewCall, 'startAiApplicationPreview 应被调用')
|
||||
assert.deepEqual(previewCall[3].stewardRemainingTasks, remainingTasks)
|
||||
assert.equal(typeof previewCall[3].onPreviewReadyForNextTask, 'function')
|
||||
assert.equal(typeof previewCall[3].onApplicationActionCompleted, 'function')
|
||||
})
|
||||
|
||||
test('workbench reimbursement skip link action opens new reimbursement flow', () => {
|
||||
let sceneSelectionPayload = null
|
||||
let fallbackConversationStarted = false
|
||||
@@ -389,3 +448,70 @@ test('workbench steward executable submit action runs precheck before submit and
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('workbench steward continue-next-task reimbursement prefills ontology and forwards remaining tasks', () => {
|
||||
let expenseDraftCall = null
|
||||
const router = useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft: { value: null },
|
||||
applicationFlow: {
|
||||
isInlineSuggestedActionDisabled: () => false,
|
||||
executeInlineApplicationPreviewAction: () => {}
|
||||
},
|
||||
assistantDraft: { value: '' },
|
||||
attachmentFlow: {
|
||||
confirmAiAttachmentAssociation: () => {}
|
||||
},
|
||||
emit: () => {},
|
||||
expenseFlow: {
|
||||
linkAiExpenseApplication: () => {},
|
||||
promptAiReimbursementDraftContinuation: () => {},
|
||||
promptStandaloneReimbursementDraftCreation: () => {},
|
||||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||
startAiApplicationPreviewFromAction: () => {},
|
||||
startAiExpenseDraft: (expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options) => {
|
||||
expenseDraftCall = { expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options }
|
||||
},
|
||||
startAiReimbursementAssociationGate: () => {}
|
||||
},
|
||||
focusAiModeInput: () => {},
|
||||
hasInlineAttachmentOcrDetails: () => false,
|
||||
resolveLatestInlineUserPrompt: () => '',
|
||||
selectedFiles: { value: [] },
|
||||
startInlineConversation: () => {},
|
||||
toast: () => {},
|
||||
toggleInlineAttachmentOcrDetails: () => {}
|
||||
})
|
||||
|
||||
router.handleInlineSuggestedAction({
|
||||
label: '继续处理费用报销',
|
||||
action_type: 'steward_continue_next_task',
|
||||
payload: {
|
||||
steward_confirm_flow: true,
|
||||
flow_id: 'travel_reimbursement',
|
||||
steward_current_task: {
|
||||
task_id: 'task-meal-1',
|
||||
task_type: 'reimbursement',
|
||||
title: '业务招待费报销',
|
||||
summary: '报销昨天业务招待费2000元',
|
||||
ontology_fields: {
|
||||
expense_type: 'meal',
|
||||
expense_type_label: '业务招待费',
|
||||
amount: '2000元',
|
||||
time_range: '昨天',
|
||||
reason: '客户招待'
|
||||
}
|
||||
},
|
||||
steward_remaining_tasks: []
|
||||
}
|
||||
})
|
||||
|
||||
// task2(招待费报销)启动时:费用类型正确、语义预填到草稿、remaining tasks 透传
|
||||
assert.ok(expenseDraftCall, 'startAiExpenseDraft 应被调用')
|
||||
assert.equal(expenseDraftCall.expenseType, 'meal')
|
||||
assert.equal(expenseDraftCall.expenseTypeLabel, '业务招待费')
|
||||
assert.equal(expenseDraftCall.requiresApplicationBeforeReimbursement, true)
|
||||
assert.equal(expenseDraftCall.options.prefillValues.amount, '2000元')
|
||||
assert.equal(expenseDraftCall.options.prefillValues.reason, '客户招待')
|
||||
assert.equal(expenseDraftCall.options.prefillValues.time_range, '昨天')
|
||||
assert.deepEqual(expenseDraftCall.options.stewardRemainingTasks, [])
|
||||
})
|
||||
|
||||
@@ -4,8 +4,10 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
buildWorkbenchDocumentCommandFollowupGuidance,
|
||||
buildWorkbenchDraftDeletionGuidance,
|
||||
isWorkbenchDraftDeletionIntent,
|
||||
resolveLatestWorkbenchDocumentCommandContext,
|
||||
resolveLatestWorkbenchDraftPayload
|
||||
} from '../src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js'
|
||||
|
||||
@@ -87,8 +89,45 @@ test('workbench draft deletion guidance opens detail instead of deleting directl
|
||||
assert.equal(guidance.suggestedActions[0].payload.claim_no, 'ALATEST1')
|
||||
})
|
||||
|
||||
test('workbench command intent reuses previous approval candidates for follow-up approval command', () => {
|
||||
const context = resolveLatestWorkbenchDocumentCommandContext([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
'### 已查询到相关单据',
|
||||
'',
|
||||
'<article class="ai-document-card ai-document-card--application ai-document-card--approval-task is-pending">',
|
||||
'<a class="ai-document-card__action" href="#ai-open-document-detail:claim_id%3Dapproval-1%26claim_no%3DAP-APPROVAL-001">查看详情</a>',
|
||||
'</article>',
|
||||
'<article class="ai-document-card ai-document-card--reimbursement ai-document-card--approval-task is-pending">',
|
||||
'<a class="ai-document-card__action" href="#ai-open-document-detail:claim_id%3Dapproval-2%26claim_no%3DRE-APPROVAL-002">查看详情</a>',
|
||||
'</article>'
|
||||
].join('\n')
|
||||
}
|
||||
], { action: 'approve', safetyLevel: 'confirm_required' })
|
||||
|
||||
assert.equal(context?.candidates.length, 2)
|
||||
assert.deepEqual(context.candidates[0], {
|
||||
claimId: 'approval-1',
|
||||
claimNo: 'AP-APPROVAL-001',
|
||||
documentType: 'application',
|
||||
actionLabel: '查看详情'
|
||||
})
|
||||
|
||||
const guidance = buildWorkbenchDocumentCommandFollowupGuidance(context, { action: 'approve' })
|
||||
assert.match(guidance.content, /已接上刚才查询到的待审单据/)
|
||||
assert.match(guidance.content, /AP-APPROVAL-001/)
|
||||
assert.match(guidance.content, /RE-APPROVAL-002/)
|
||||
assert.equal(guidance.suggestedActions.length, 2)
|
||||
assert.equal(guidance.suggestedActions[0].action_type, 'open_application_detail')
|
||||
assert.equal(guidance.suggestedActions[0].payload.claim_id, 'approval-1')
|
||||
assert.equal(guidance.suggestedActions[0].payload.command_action, 'approve')
|
||||
})
|
||||
|
||||
test('workbench draft deletion intent is wired before draft slot continuation', () => {
|
||||
assert.match(commandIntentsScript, /isWorkbenchDraftDeletionIntent/)
|
||||
assert.match(commandIntentsScript, /resolveLatestWorkbenchDocumentCommandContext/)
|
||||
assert.match(commandIntentsScript, /buildWorkbenchDocumentCommandFollowupGuidance/)
|
||||
assert.match(commandIntentsScript, /function handleInlineDraftDeletionIntent\(cleanPrompt, entry = \{\}\)/)
|
||||
assert.match(commandIntentsScript, /resolveLatestWorkbenchDraftPayload\(conversationMessages\.value\)/)
|
||||
assert.match(commandIntentsScript, /buildWorkbenchDraftDeletionGuidance\(draftPayload\)/)
|
||||
|
||||
@@ -79,6 +79,48 @@ test('workbench AI intent planner normalizes model travel application submit pla
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench AI intent planner keeps reimbursement task after first application task', () => {
|
||||
const reimbursementTask = {
|
||||
task_id: 'task-reimbursement-2',
|
||||
task_type: 'reimbursement',
|
||||
assigned_agent: 'reimbursement_assistant',
|
||||
title: '业务招待费报销',
|
||||
summary: '报销昨天的业务招待费 2000 元',
|
||||
requested_action: 'preview',
|
||||
confidence: 0.9,
|
||||
ontology_fields: {
|
||||
expense_type: 'entertainment',
|
||||
expense_type_label: '业务招待费',
|
||||
time_range: '2026-06-25',
|
||||
amount: '2000元',
|
||||
reason: '业务招待'
|
||||
},
|
||||
missing_fields: []
|
||||
}
|
||||
const plan = normalizeWorkbenchAiIntentPlan({
|
||||
planning_source: 'llm_function_call',
|
||||
tasks: [{
|
||||
task_id: 'task-application-1',
|
||||
task_type: 'expense_application',
|
||||
assigned_agent: 'application_assistant',
|
||||
requested_action: 'preview',
|
||||
confidence: 0.93,
|
||||
ontology_fields: {
|
||||
expense_type: 'travel',
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '服务国网服务器部署'
|
||||
},
|
||||
missing_fields: ['transport_mode']
|
||||
}, reimbursementTask]
|
||||
}, {
|
||||
prompt: '2月20-23日去上海出差3天,服务国网服务器部署,并且报销昨天的业务招待费2000元'
|
||||
})
|
||||
|
||||
assert.deepEqual(plan.stewardRemainingTasks, [reimbursementTask])
|
||||
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan).stewardRemainingTasks, [reimbursementTask])
|
||||
})
|
||||
|
||||
test('workbench AI intent planner prefers server action steps when present', () => {
|
||||
const plan = normalizeWorkbenchAiIntentPlan({
|
||||
planning_source: 'llm_function_call',
|
||||
@@ -304,7 +346,12 @@ test('workbench AI mode asks steward model plan before fallback execution', () =
|
||||
assert.match(personalWorkbenchAiModeScript, /requestedSubmit:\s*travelApplicationRequest\.requestedSubmit/)
|
||||
assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/)
|
||||
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
|
||||
assert.match(personalWorkbenchAiModeScript, /stewardRemainingTasks:\s*travelApplicationRequest\.stewardRemainingTasks/)
|
||||
assert.match(personalWorkbenchAiModeScript, /onPreviewReadyForNextTask:\s*startModelPlannedNextTask/)
|
||||
assert.match(personalWorkbenchAiModeScript, /onApplicationActionCompleted:\s*startModelPlannedNextTask/)
|
||||
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
|
||||
assert.match(applicationPreviewFlowScript, /options\.onPreviewReadyForNextTask/)
|
||||
assert.match(applicationPreviewFlowScript, /onApplicationActionCompleted\(\s*targetMessage\.stewardRemainingTasks/)
|
||||
assert.doesNotMatch(applicationPreviewFlowScript, /options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit/)
|
||||
assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/)
|
||||
assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)
|
||||
|
||||
Reference in New Issue
Block a user