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 测试
This commit is contained in:
caoxiaozhu
2026-06-24 21:58:46 +08:00
parent 5311c99d69
commit bc560145a4
18 changed files with 1914 additions and 38 deletions

View File

@@ -51,6 +51,9 @@ import {
import {
shouldUseBudgetCompileReport
} from '../src/views/scripts/budgetAssistantReportModel.js'
import {
buildInlineApplicationPreview
} from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
import {
ASSISTANT_SCOPE_ACTION_SWITCH,
@@ -140,6 +143,14 @@ const flowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
const applicationPreviewFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
'utf8'
)
function createFlowHarness() {
return useTravelReimbursementFlow({
@@ -241,6 +252,32 @@ test('application intent uses local preview instead of immediate orchestrator ca
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('AI workbench routes compact travel direct-submit planner into application preview auto submit', () => {
assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan/)
assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan/)
assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan/)
assert.match(
personalWorkbenchAiModeScript,
/async function executeModelPlannedWorkbenchIntent\(cleanPrompt, entry = \{\}, files = \[\]\)/
)
assert.match(
personalWorkbenchAiModeScript,
/modelPlan = await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files\)/
)
assert.match(
personalWorkbenchAiModeScript,
/const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/
)
assert.match(
personalWorkbenchAiModeScript,
/applicationFlow\.startAiApplicationPreview\([\s\S]*travelApplicationRequest\.expenseType[\s\S]*travelApplicationRequest\.expenseTypeLabel[\s\S]*travelApplicationRequest\.sourceText[\s\S]*ontologyFields:\s*travelApplicationRequest\.ontologyFields[\s\S]*autoSubmit:\s*travelApplicationRequest\.autoSubmit/
)
assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/)
assert.match(applicationPreviewFlowScript, /if \(options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit\)/)
assert.match(applicationPreviewFlowScript, /confirmed:\s*true/)
assert.match(applicationPreviewFlowScript, /skipUserMessage:\s*true/)
})
test('unsupported business guidance opens in assistant conversation form', () => {
const conversation = buildUnsupportedBusinessScopeConversation('你好')
@@ -366,6 +403,20 @@ test('application preview renders ordered editable rows and submit text uses edi
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用1900元/)
})
test('application preview keeps compact direct-submit command out of business reason', () => {
const preview = buildInlineApplicationPreview(
'差旅费',
'去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
{ grade: 'P5' }
)
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.readyToSubmit, false)
assert.deepEqual(preview.missingFields, ['出发时间', '天数'])
})
test('application estimate builds deterministic mock transport amount and total', () => {
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
const datedTrainEstimate = buildMockApplicationTransportEstimate({

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import { executeStewardAction } from '../src/services/steward.js'
async function testExecuteStewardActionUsesActionEndpoint() {
let capturedUrl = ''
let capturedOptions = null
global.fetch = async (url, options) => {
capturedUrl = String(url)
capturedOptions = options
return {
ok: true,
async json() {
return {
action_type: 'save_application_draft',
status: 'succeeded',
message: '申请草稿已保存。',
result_payload: {
draft_payload: {
claim_id: 'claim-action-draft',
claim_no: 'A12345678',
status: 'draft'
}
}
}
}
}
}
const payload = await executeStewardAction({
action_type: 'save_application_draft',
message: '保存草稿',
task: {
task_id: 'task-app-1',
task_type: 'expense_application'
}
})
assert.equal(capturedUrl, '/api/v1/steward/actions/execute')
assert.equal(capturedOptions.method, 'POST')
assert.equal(JSON.parse(capturedOptions.body).action_type, 'save_application_draft')
assert.equal(payload.status, 'succeeded')
}
async function run() {
await testExecuteStewardActionUsesActionEndpoint()
console.log('steward actions service tests passed')
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -71,3 +71,55 @@ test('steward plan summary guides bare reimbursement intent into scene selection
assert.match(action.description, /先进入报销助手选择具体费用类型/)
assert.equal(action.payload.carry_text, '我要报销')
})
test('steward suggested action carries server executable application action step', () => {
const plan = {
plan_id: 'plan-application-submit',
tasks: [
{
task_id: 'task-app-1',
task_type: 'expense_application',
title: '上海出差申请',
assigned_agent: 'application_assistant',
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:01', action_type: 'fill_application_fields', status: 'planned' },
{ step_id: 'task-app-1:02', action_type: 'build_application_preview', status: 'planned' },
{ step_id: 'task-app-1:03', action_type: 'validate_required_fields', status: 'planned' },
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck', status: 'planned' },
{
step_id: 'task-app-1:05',
action_type: 'submit_application',
status: 'pending_confirmation',
requires_confirmation: true
}
]
}
],
confirmation_groups: [
{
confirmation_id: 'confirm-task-app-1',
action_type: 'confirm_create_application',
target_task_id: 'task-app-1'
}
],
next_action: 'confirm_task'
}
const [action] = buildStewardSuggestedActions(plan)
assert.equal(action.payload.steward_execute_action, true)
assert.equal(action.payload.steward_action_type, 'submit_application')
assert.equal(action.payload.steward_action_step.step_id, 'task-app-1:05')
assert.equal(action.payload.steward_action_requires_confirmation, true)
assert.equal(action.payload.steward_current_task.requested_action, 'submit')
assert.equal(action.payload.steward_current_task.action_steps.at(-1).action_type, 'submit_application')
})

View File

@@ -245,3 +245,147 @@ test('workbench standalone draft action asks before creating a new reimbursement
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
}
})

View File

@@ -0,0 +1,166 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useWorkbenchAiApplicationPreviewFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js'
function createRef(value) {
return { value }
}
function createInlineMessage(role, content, options = {}) {
return {
id: options.id || `msg-${Math.random().toString(16).slice(2)}`,
role,
content,
text: content,
paragraphs: String(content || '').split(/\n+/).filter(Boolean),
pending: Boolean(options.pending),
...options
}
}
function buildApplicationPreviewFlowHarness(messages) {
const conversationMessages = createRef(messages)
const applicationSubmitConfirmOpen = createRef(false)
const applicationSubmitConfirmContext = createRef(null)
const persisted = createRef(0)
const flow = useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation: () => {},
applicationPreviewEditor: createRef({}),
applicationSubmitConfirmContext,
applicationSubmitConfirmOpen,
assistantDraft: createRef(''),
cancelApplicationPreviewEditor: () => {},
clearAiModeFiles: () => {},
closeWorkbenchDatePicker: () => {},
commitApplicationPreviewEditor: async () => true,
conversationId: createRef('conversation-context-submit'),
conversationMessages,
conversationStarted: createRef(true),
createInlineMessage,
currentUser: createRef({ username: 'zhangsan@example.com', name: '张三' }),
handleApplicationPreviewEditorKeydown: () => {},
inlineConversationAutoScrollPinned: createRef(true),
isApplicationPreviewEditing: createRef(false),
openApplicationPreviewEditor: () => {},
persistCurrentConversation: () => { persisted.value += 1 },
pushInlineApplicationActionUserMessage: (text) => {
conversationMessages.value.push(createInlineMessage('user', text))
},
pushInlineUserMessage: (text) => {
conversationMessages.value.push(createInlineMessage('user', text))
},
refreshApplicationPreviewEstimate: async (preview) => preview,
removeWorkbenchDateTag: () => {},
replaceInlineMessage: (id, nextMessage) => {
const index = conversationMessages.value.findIndex((item) => item.id === id)
if (index >= 0) {
conversationMessages.value.splice(index, 1, nextMessage)
} else {
conversationMessages.value.push(nextMessage)
}
},
resolveApplicationPreviewEditorDateMax: () => '',
resolveApplicationPreviewEditorDateMin: () => '',
resolveApplicationPreviewEditorControl: () => null,
resolveApplicationPreviewEditorOptions: () => [],
resolveInlineThinkingEvents: (message) => message?.stewardPlan?.thinkingEvents || [],
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23去上海出差交通火车保存草稿',
scrollInlineConversationToBottom: () => {},
sending: createRef(false),
toast: () => {}
})
return {
applicationSubmitConfirmOpen,
conversationMessages,
flow,
persisted
}
}
test('workbench saved application draft can be submitted by contextual text without re-planning', async () => {
const originalFetch = globalThis.fetch
const requests = []
globalThis.fetch = async (url, options = {}) => {
const normalizedUrl = String(url)
if (normalizedUrl.includes('/reimbursements/claims')) {
return {
ok: true,
async json() {
return { items: [] }
}
}
}
if (normalizedUrl.includes('/reimbursements/application-preview-action')) {
const body = JSON.parse(String(options.body || '{}'))
requests.push({ url: normalizedUrl, body })
return {
ok: true,
async json() {
return {
status: 'succeeded',
result: {
draft_payload: {
claim_id: 'claim-saved-draft',
claim_no: 'A20260220',
status: 'submitted',
approval_stage: '直属领导审批'
}
}
}
}
}
}
throw new Error(`unexpected request: ${normalizedUrl}`)
}
try {
const previewMessage = createInlineMessage('assistant', '申请核对表', {
id: 'application-preview-1',
applicationPreview: {
readyToSubmit: true,
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
amount: '1200元'
},
missingFields: [],
validationIssues: []
},
draftPayload: {
claim_id: 'claim-saved-draft',
claim_no: 'A20260220',
status: 'draft'
}
})
const harness = buildApplicationPreviewFlowHarness([
createInlineMessage('user', '2026-02-20 至 2026-02-23去上海出差交通火车保存草稿'),
previewMessage,
createInlineMessage('assistant', '### 申请草稿已保存', {
draftPayload: previewMessage.draftPayload
})
])
const handled = harness.flow.handleInlineApplicationPreviewTextAction(
'提交这个单据',
createRef(false)
)
assert.equal(handled, true)
await new Promise((resolve) => setTimeout(resolve, 0))
assert.equal(harness.applicationSubmitConfirmOpen.value, false)
assert.equal(requests.length, 1)
assert.equal(requests[0].body.context_json.application_edit_claim_id, 'claim-saved-draft')
assert.equal(requests[0].body.context_json.application_edit_mode, true)
assert.match(harness.conversationMessages.value.at(-1).content, /申请单据已生成/)
assert.ok(harness.persisted.value > 0)
} finally {
globalThis.fetch = originalFetch
}
})

View File

@@ -4,6 +4,7 @@ import test from 'node:test'
import {
isOrphanInlineApplicationPreviewMessage,
isReimbursementCreationIntent,
resolveInlineTravelApplicationRequest,
resolveInlineApplicationPreviewTextAction,
resolveLatestApplicationPreviewMessage,
resolveLatestOrphanApplicationPreviewMessage
@@ -28,9 +29,24 @@ test('workbench application gate resolves save and submit text actions consisten
assert.equal(resolveInlineApplicationPreviewTextAction(' 先保存 '), AI_APPLICATION_ACTION_SAVE_DRAFT)
assert.equal(resolveInlineApplicationPreviewTextAction('确认提交'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('直接提交'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('提交这个单据'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('提交这个申请单'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('继续修改'), '')
})
test('workbench application gate detects compact travel application direct submit intent', () => {
const request = resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
assert.deepEqual(request, {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
autoSubmit: true
})
assert.equal(resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车')?.autoSubmit, false)
assert.equal(resolveInlineTravelApplicationRequest('帮我查询上海差旅标准'), null)
})
test('workbench application gate resolves latest live or orphan preview message', () => {
const messages = [
{ id: 'user-1', role: 'user', content: '2月去上海出差' },

View File

@@ -0,0 +1,331 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
WORKBENCH_AI_INTENT_SOURCE_MODEL,
WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK,
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT,
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
WORKBENCH_AI_STEP_SUBMIT_APPLICATION,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
buildRuleFallbackWorkbenchAiIntentPlan,
normalizeWorkbenchAiIntentPlan,
resolveExecutableTravelApplicationPlan,
shouldRequestWorkbenchAiIntentPlan
} from '../src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js'
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
const stewardFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js', import.meta.url)),
'utf8'
)
const applicationPreviewFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
'utf8'
)
const planningThinkingModelScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/workbenchAiPlanningThinkingModel.js', import.meta.url)),
'utf8'
)
test('workbench AI intent planner normalizes model travel application submit plan into executable steps', () => {
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'llm_function_call',
tasks: [{
task_type: 'expense_application',
assigned_agent: 'application_assistant',
requested_action: 'submit',
confidence: 0.91,
ontology_fields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
}
}]
}, {
prompt: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交'
})
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_MODEL)
assert.equal(plan.intent, 'create_travel_application')
assert.equal(plan.requestedAction, 'submit')
assert.deepEqual(plan.steps, [
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
])
assert.deepEqual(plan.ontologyFields, {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
})
assert.deepEqual(plan.slots, {
timeRange: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transportMode: '火车'
})
})
test('workbench AI intent planner prefers server action steps when present', () => {
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'llm_function_call',
tasks: [{
task_type: 'expense_application',
assigned_agent: 'application_assistant',
requested_action: 'submit',
confidence: 0.91,
ontology_fields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
},
action_steps: [
{ action_type: 'fill_application_fields' },
{ action_type: 'build_application_preview' },
{ action_type: 'validate_required_fields' },
{ action_type: 'save_application_draft' }
]
}]
}, {
prompt: '2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交'
})
assert.deepEqual(plan.steps, [
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
])
})
test('workbench AI intent planner falls back to rule plan for compact travel direct submit', () => {
const plan = buildRuleFallbackWorkbenchAiIntentPlan('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
assert.equal(plan.intent, 'create_travel_application')
assert.equal(plan.requestedAction, 'submit')
assert.deepEqual(plan.steps, [
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
])
})
test('workbench AI intent planner detects compact travel save-draft variant before rules are enough', () => {
const prompt = '2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。'
const plan = buildRuleFallbackWorkbenchAiIntentPlan(prompt)
assert.equal(shouldRequestWorkbenchAiIntentPlan(prompt), true)
assert.equal(shouldRequestWorkbenchAiIntentPlan('帮我查询上海差旅标准'), true)
assert.equal(shouldRequestWorkbenchAiIntentPlan('1'), false)
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
assert.equal(plan.intent, 'create_travel_application')
assert.equal(plan.requestedAction, 'save_draft')
assert.deepEqual(plan.steps, [
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
])
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: prompt,
ontologyFields: {},
autoSubmit: false,
autoSaveDraft: true
})
})
test('workbench AI intent planner turns model fields and action into executable application preview payload', () => {
const prompt = '2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。'
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'llm_function_call',
tasks: [{
task_type: 'expense_application',
assigned_agent: 'application_assistant',
requested_action: 'save_draft',
confidence: 0.95,
ontology_fields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '国网仿生产服务器部署',
transport_mode: '火车'
},
missing_fields: []
}]
}, { prompt })
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: prompt,
ontologyFields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '国网仿生产服务器部署',
transport_mode: '火车'
},
autoSubmit: false,
autoSaveDraft: true
})
})
test('workbench AI intent planner turns single application candidate flow into executable preview payload', () => {
const prompt = '2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车'
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'rule_fallback',
plan_status: 'needs_flow_confirmation',
pending_flow_confirmation: {
status: 'pending',
candidate_flows: [{
flow_id: 'travel_application',
label: '先发起出差申请',
confidence: 0.86,
ontology_fields: {
expense_type: 'travel',
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
},
missing_fields: []
}]
}
}, { prompt })
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
assert.equal(plan.intent, 'create_travel_application')
assert.equal(plan.requestedAction, 'preview')
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: prompt,
ontologyFields: {
expense_type: 'travel',
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
},
autoSubmit: false,
autoSaveDraft: false
})
})
test('workbench AI application preview prefers model ontology fields over local text guesses', () => {
const preview = buildInlineApplicationPreview(
'差旅费',
'2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。',
{ name: '李文静', grade: 'P5', location: '武汉' },
{
ontologyFields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '国网仿生产服务器部署',
transport_mode: '火车'
}
}
)
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '国网仿生产服务器部署')
assert.equal(preview.fields.transportMode, '火车')
})
test('workbench AI intent planner rejects policy question and resolves executable application request', () => {
assert.equal(buildRuleFallbackWorkbenchAiIntentPlan('帮我查询上海差旅标准'), null)
const request = resolveExecutableTravelApplicationPlan(
buildRuleFallbackWorkbenchAiIntentPlan('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
)
assert.deepEqual(request, {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
ontologyFields: {},
autoSubmit: true,
autoSaveDraft: false
})
})
test('workbench AI mode asks steward model plan before fallback execution', () => {
assert.match(stewardFlowScript, /async function resolveInlineExecutionPlan\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
assert.match(stewardFlowScript, /fetchStewardPlan\(planRequest/)
assert.match(stewardFlowScript, /timeoutMs:\s*35000/)
assert.match(personalWorkbenchAiModeScript, /async function executeModelPlannedWorkbenchIntent\(cleanPrompt, entry = \{\}, files = \[\]\)/)
assert.match(personalWorkbenchAiModeScript, /await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{/)
assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan\(modelPlan,\s*\{\s*prompt:\s*cleanPrompt/)
assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/)
assert.match(personalWorkbenchAiModeScript, /shouldRequestWorkbenchAiIntentPlan\(cleanPrompt\)/)
assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan\(intentPlan\)/)
assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/)
assert.match(personalWorkbenchAiModeScript, /autoSaveDraft:\s*travelApplicationRequest\.autoSaveDraft/)
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/)
assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)
assert.match(applicationPreviewFlowScript, /buildInlineApplicationPreview\([\s\S]*ontologyFields:\s*options\.ontologyFields/)
assert.doesNotMatch(personalWorkbenchAiModeScript, /const travelApplicationRequest = resolveInlineTravelApplicationRequest\(cleanPrompt\)/)
})
test('workbench AI mode shows a visible planning response before waiting for steward model plan', () => {
assert.match(personalWorkbenchAiModeScript, /function startModelPlanningConversation\(cleanPrompt, entry = \{\}\)/)
assert.match(personalWorkbenchAiModeScript, /conversationMessages\.value\.push\(createInlineMessage\('user', cleanPrompt\)\)/)
assert.match(personalWorkbenchAiModeScript, /正在识别意图,准备拆解申请、报销和附件任务/)
assert.match(
personalWorkbenchAiModeScript,
/const plannerPendingMessage = startModelPlanningConversation\(cleanPrompt, entry\)[\s\S]*await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{/
)
assert.match(
personalWorkbenchAiModeScript,
/pendingMessageId:\s*plannerPendingMessage\?\.id/
)
assert.match(applicationPreviewFlowScript, /options\.pendingMessageId/)
})
test('workbench AI mode streams planning thinking into the pending message', () => {
assert.match(planningThinkingModelScript, /buildModelPlanningProgressSchedule/)
assert.match(planningThinkingModelScript, /判断办理意图/)
assert.match(planningThinkingModelScript, /抽取关键信息/)
assert.match(planningThinkingModelScript, /规划执行步骤/)
assert.match(planningThinkingModelScript, /准备兜底策略/)
assert.match(personalWorkbenchAiModeScript, /function startModelPlanningProgressUpdates\(messageId\)/)
assert.match(personalWorkbenchAiModeScript, /globalThis\.setTimeout\(\(\) => \{\s*updateModelPlanningThinkingEvent\(messageId, event\)/)
assert.match(personalWorkbenchAiModeScript, /const stopPlanningProgressUpdates = startModelPlanningProgressUpdates\(plannerPendingMessage\.id\)/)
assert.match(personalWorkbenchAiModeScript, /stopPlanningProgressUpdates\(\)/)
assert.match(
personalWorkbenchAiModeScript,
/await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{\s*pendingMessageId:\s*plannerPendingMessage\.id\s*\}\)/
)
assert.match(stewardFlowScript, /async function resolveInlineExecutionPlan\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
assert.match(stewardFlowScript, /fetchInlineStewardPlan\(planningMessageId, planRequest,\s*\{[\s\S]*includeAnswerDelta:\s*false/)
assert.match(applicationPreviewFlowScript, /mergeWorkbenchAiThinkingEvents\(previousThinkingEvents,\s*\[/)
})
test('workbench AI mode reuses planning pending message for regular steward replies', () => {
assert.match(
personalWorkbenchAiModeScript,
/stewardFlow\.requestInlineAssistantReply\(cleanPrompt, entry, files,\s*\{\s*pendingMessageId:\s*plannerPendingMessage\.id\s*\}\)/
)
assert.doesNotMatch(
personalWorkbenchAiModeScript,
/replaceInlineMessage\(plannerPendingMessage\.id,\s*createInlineMessage\('assistant', '已完成意图识别,继续为您整理回复。'/
)
assert.match(stewardFlowScript, /async function requestInlineAssistantReply\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
assert.match(stewardFlowScript, /const reusablePendingMessageId = String\(options\.pendingMessageId \|\| ''\)\.trim\(\)/)
assert.match(stewardFlowScript, /reusablePendingMessageId \? replaceInlineMessage\(reusablePendingMessageId, pendingMessage\) : conversationMessages\.value\.push\(pendingMessage\)/)
})