From bc560145a470c62f1d743fd98444b30e129ab93e Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Wed, 24 Jun 2026 21:58:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20AI=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E6=84=8F=E5=9B=BE=E8=A7=84=E5=88=92=E4=B8=8E=E8=A7=84=E5=88=92?= =?UTF-8?q?=E6=80=9D=E8=80=83=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 测试 --- .../styles/views/receipt-folder-view.css | 123 ++++++- .../usePersonalWorkbenchAiMode.js | 150 +++++++- .../useWorkbenchAiActionRouter.js | 194 ++++++++++ .../useWorkbenchAiApplicationPreviewFlow.js | 67 +++- .../useWorkbenchAiStewardFlow.js | 81 ++++- .../workbenchAiApplicationGateModel.js | 34 +- .../workbenchAiApplicationPreviewModel.js | 68 +++- .../workbenchAiIntentPlannerModel.js | 276 +++++++++++++++ .../workbenchAiPlanningThinkingModel.js | 80 +++++ web/src/services/steward.js | 8 + web/src/views/scripts/stewardPlanModel.js | 57 ++- .../expense-application-fast-preview.test.mjs | 51 +++ web/tests/steward-actions-service.test.mjs | 54 +++ web/tests/steward-plan-message-copy.test.mjs | 52 +++ web/tests/workbench-ai-action-router.test.mjs | 144 ++++++++ ...nch-ai-application-context-submit.test.mjs | 166 +++++++++ ...rkbench-ai-application-gate-model.test.mjs | 16 + ...workbench-ai-intent-planner-model.test.mjs | 331 ++++++++++++++++++ 18 files changed, 1914 insertions(+), 38 deletions(-) create mode 100644 web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js create mode 100644 web/src/composables/workbenchAiMode/workbenchAiPlanningThinkingModel.js create mode 100644 web/tests/steward-actions-service.test.mjs create mode 100644 web/tests/workbench-ai-application-context-submit.test.mjs create mode 100644 web/tests/workbench-ai-intent-planner-model.test.mjs diff --git a/web/src/assets/styles/views/receipt-folder-view.css b/web/src/assets/styles/views/receipt-folder-view.css index 51ad5f1..02b75b6 100644 --- a/web/src/assets/styles/views/receipt-folder-view.css +++ b/web/src/assets/styles/views/receipt-folder-view.css @@ -549,59 +549,156 @@ } .associate-hint { - margin: 0; + margin: 0 0 2px 0; color: #64748b; font-size: 13px; + line-height: 1.4; } .receipt-checkbox-list, .draft-choice-list { - max-height: 360px; + max-height: 340px; display: grid; gap: 8px; overflow-y: auto; + padding: 1px; } .receipt-checkbox-list :deep(.el-checkbox), .draft-choice { - min-height: 50px; + display: flex !important; + min-height: 52px; align-items: center; margin-right: 0; - padding: 9px 10px; - border: 1px solid #dbe4ee; + padding: 10px 14px; + border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; + transition: all 150ms ease; + box-sizing: border-box; +} + +.receipt-checkbox-list :deep(.el-checkbox:hover), +.draft-choice:hover { + border-color: #cbd5e1; + background: #f8fafc; +} + +.receipt-checkbox-list :deep(.el-checkbox.is-checked) { + border-color: var(--theme-primary); + background: rgba(58, 124, 165, 0.04); } .receipt-checkbox-list :deep(.el-checkbox__label) { + flex: 1; display: grid; - gap: 3px; + gap: 2px; color: #0f172a; - font-weight: 750; + font-size: 13px; + font-weight: 600; + line-height: 1.35; } .receipt-checkbox-list small, .draft-choice small { color: #64748b; - font-size: 12px; - font-weight: 650; + font-size: 11.5px; + font-weight: 400; + line-height: 1.35; } .draft-choice { display: grid; grid-template-columns: auto minmax(0, 1fr); - gap: 9px; + gap: 10px; cursor: pointer; } .draft-choice.active { - border-color: rgba(58, 124, 165, .42); - background: rgba(58, 124, 165, .07); + border-color: var(--theme-primary); + background: rgba(58, 124, 165, 0.04); } .draft-choice span { display: grid; - gap: 3px; + gap: 2px; +} + +.draft-choice strong { + color: #0f172a; + font-size: 13px; + font-weight: 600; + line-height: 1.35; +} + +/* Global styles for the association dialog */ +:global(.receipt-associate-dialog.el-dialog) { + border-radius: 6px; + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.12); +} + +:global(.receipt-associate-dialog .el-dialog__header) { + padding: 20px 24px 12px 24px; + margin-right: 0; +} + +:global(.receipt-associate-dialog .el-dialog__headerbtn) { + top: 20px; + right: 24px; +} + +:global(.receipt-associate-dialog .el-dialog__title) { + font-size: 15px; + font-weight: 600; + color: #0f172a; +} + +:global(.receipt-associate-dialog .el-dialog__body) { + padding: 0 24px 20px 24px; +} + +:global(.receipt-associate-dialog .el-dialog__footer) { + padding: 12px 24px 16px 24px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +:global(.receipt-associate-dialog .el-dialog__footer .apply-btn), +:global(.receipt-associate-dialog .el-dialog__footer .ghost-btn) { + min-height: 36px; + height: 36px; + padding: 0 16px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + transition: all 150ms ease; +} + +:global(.receipt-associate-dialog .el-dialog__footer .ghost-btn) { + border-color: #cbd5e1; + color: #334155; + background: #fff; +} + +:global(.receipt-associate-dialog .el-dialog__footer .ghost-btn:hover) { + background: #f8fafc; + border-color: #94a3b8; + color: #0f172a; +} + +:global(.receipt-associate-dialog .el-dialog__footer .apply-btn) { + background: var(--theme-primary); + border-color: var(--theme-primary); + color: #fff; +} + +:global(.receipt-associate-dialog .el-dialog__footer .apply-btn:hover:not(:disabled)) { + background: var(--theme-primary-active); + border-color: var(--theme-primary-active); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } @media (max-width: 1180px) { diff --git a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js index 00b8670..10cb2d3 100644 --- a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js +++ b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js @@ -35,7 +35,20 @@ import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js' import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js' import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js' import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js' -import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js' +import { + isReimbursementCreationIntent +} from './workbenchAiApplicationGateModel.js' +import { + buildRuleFallbackWorkbenchAiIntentPlan, + normalizeWorkbenchAiIntentPlan, + resolveExecutableTravelApplicationPlan, + shouldRequestWorkbenchAiIntentPlan +} from './workbenchAiIntentPlannerModel.js' +import { + buildInitialModelPlanningThinkingEvents, + buildModelPlanningProgressSchedule, + mergeWorkbenchAiThinkingEvents +} from './workbenchAiPlanningThinkingModel.js' const AI_SEARCH_CONVERSATION_ID = 'ai-search' const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6 @@ -251,11 +264,16 @@ export function usePersonalWorkbenchAiMode(props, emit) { applicationFlow, assistantDraft, attachmentFlow, + conversationMessages, + createInlineMessage, emit, expenseFlow, focusAiModeInput, hasInlineAttachmentOcrDetails, + persistCurrentConversation, + replaceInlineMessage, resolveLatestInlineUserPrompt, + scrollInlineConversationToBottom, selectedFiles, startInlineConversation, toast, @@ -560,6 +578,131 @@ export function usePersonalWorkbenchAiMode(props, emit) { return false } + function isModelPlannedReimbursementTask(modelPlan = {}) { + const tasks = Array.isArray(modelPlan?.tasks) ? modelPlan.tasks : [] + return tasks.some((task) => { + const taskType = String(task?.task_type || task?.taskType || '').trim() + const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim() + return taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant' + }) + } + + function updateModelPlanningThinkingEvent(messageId, event) { + const message = conversationMessages.value.find((item) => item.id === messageId) + if (!message) { + return + } + const currentPlan = message.stewardPlan || {} + message.stewardPlan = { + ...currentPlan, + streamStatus: 'streaming', + thinkingEvents: mergeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(message), [event]) + } + persistCurrentConversation() + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + } + + function startModelPlanningProgressUpdates(messageId) { + const timerIds = buildModelPlanningProgressSchedule().map(({ delayMs, event }) => ( + globalThis.setTimeout(() => { + updateModelPlanningThinkingEvent(messageId, event) + }, delayMs) + )) + return () => { + timerIds.forEach((timerId) => globalThis.clearTimeout(timerId)) + } + } + + function startModelPlanningConversation(cleanPrompt, entry = {}) { + if (conversationId.value === AI_SEARCH_CONVERSATION_ID) { + 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() + filesFlow.clearAiModeFiles() + const pendingMessage = createInlineMessage('assistant', '正在识别意图,准备拆解申请、报销和附件任务。', { + pending: true, + stewardPlan: { + streamStatus: 'streaming', + thinkingEvents: buildInitialModelPlanningThinkingEvents() + } + }) + conversationMessages.value.push(pendingMessage) + scrollInlineConversationToBottom() + persistCurrentConversation() + return pendingMessage + } + + function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) { + void applicationFlow.startAiApplicationPreview( + travelApplicationRequest.expenseType, + travelApplicationRequest.expenseTypeLabel, + travelApplicationRequest.sourceText, + { + userMessage: travelApplicationRequest.sourceText, + pushUserMessage: !plannerPendingMessage, + pendingMessageId: plannerPendingMessage?.id, + ontologyFields: travelApplicationRequest.ontologyFields, + autoSubmit: travelApplicationRequest.autoSubmit, + autoSaveDraft: travelApplicationRequest.autoSaveDraft + } + ) + } + + async function executeModelPlannedWorkbenchIntent(cleanPrompt, entry = {}, files = []) { + let intentPlan = null + let modelPlan = null + const plannerPendingMessage = startModelPlanningConversation(cleanPrompt, entry) + const stopPlanningProgressUpdates = startModelPlanningProgressUpdates(plannerPendingMessage.id) + sending.value = true + try { + modelPlan = await stewardFlow.resolveInlineExecutionPlan(cleanPrompt, entry, files, { + pendingMessageId: plannerPendingMessage.id + }) + intentPlan = normalizeWorkbenchAiIntentPlan(modelPlan, { prompt: cleanPrompt }) + } catch (error) { + console.warn('AI mode intent planner failed, using local fallback:', error) + const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan(cleanPrompt) + const ruleRequest = resolveExecutableTravelApplicationPlan(rulePlan) + if (ruleRequest) { + sending.value = false + startModelPlannedApplicationPreview(ruleRequest, plannerPendingMessage) + return + } + } finally { + stopPlanningProgressUpdates() + sending.value = false + } + + const travelApplicationRequest = resolveExecutableTravelApplicationPlan(intentPlan) + if (travelApplicationRequest) { + startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage) + return + } + + if (isModelPlannedReimbursementTask(modelPlan) || isReimbursementCreationIntent(cleanPrompt)) { + replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', '已识别为报销任务,正在进入报销流程。', { + id: plannerPendingMessage.id, + stewardPlan: { + streamStatus: 'completed', + thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' })) + } + })) + void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || cleanPrompt) + return + } + + void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files, { pendingMessageId: plannerPendingMessage.id }) + } + function handleAiAnswerMarkdownClick(event) { const target = event?.target const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]') @@ -594,6 +737,11 @@ export function usePersonalWorkbenchAiMode(props, emit) { return } + if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) { + void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files) + return + } + if (isReimbursementCreationIntent(cleanPrompt)) { void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || '我要报销') return diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js index 2bf362d..d50998b 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js @@ -2,6 +2,7 @@ import { AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT } from '../../services/aiApplicationPreviewActions.js' +import { executeStewardAction } from '../../services/steward.js' import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js' import { mergeComposerPrefill, @@ -25,11 +26,16 @@ export function useWorkbenchAiActionRouter({ applicationFlow, assistantDraft, attachmentFlow, + conversationMessages, + createInlineMessage, emit, expenseFlow, focusAiModeInput, hasInlineAttachmentOcrDetails, + persistCurrentConversation, + replaceInlineMessage, resolveLatestInlineUserPrompt, + scrollInlineConversationToBottom, selectedFiles, startInlineConversation, toast, @@ -45,6 +51,9 @@ export function useWorkbenchAiActionRouter({ const actionType = String(action?.action_type || '').trim() const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + if (actionPayload.steward_execute_action) { + return executeInlineStewardAction(action, sourceMessage) + } if (actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION) { if (!hasInlineAttachmentOcrDetails(sourceMessage)) { toast('当前消息没有可查看的附件识别明细。') @@ -162,6 +171,191 @@ export function useWorkbenchAiActionRouter({ }, action?.payload?.carry_files ? Array.from(selectedFiles.value) : []) } + async function executeInlineStewardAction(action = {}, sourceMessage = null) { + const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + const actionType = String(actionPayload.steward_action_type || '').trim() + if (!actionType || !actionPayload.steward_current_task) { + toast('当前动作缺少可执行任务快照,请重新发起规划。') + return false + } + if (sourceMessage?.suggestedActionsLocked) { + return true + } + if (sourceMessage) { + sourceMessage.suggestedActionsLocked = true + } + + const pendingMessage = appendStewardActionMessage( + `正在执行:${String(action.label || '小财管家动作').trim() || '小财管家动作'}...`, + { pending: true } + ) + + try { + let precheckResult = null + if (actionType === 'submit_application') { + precheckResult = await executeStewardAction( + buildStewardActionExecutePayload(action, 'run_duplicate_precheck') + ) + if (!isSuccessfulStewardPrecheck(precheckResult)) { + finalizeStewardActionMessage(pendingMessage, buildStewardActionResultText(precheckResult), { + suggestedActions: [] + }) + return true + } + } + + const contextJson = precheckResult + ? { precheck_result: precheckResult.result_payload || precheckResult.resultPayload || {} } + : {} + const result = await executeStewardAction( + buildStewardActionExecutePayload(action, actionType, contextJson) + ) + finalizeStewardActionMessage(pendingMessage, buildStewardActionResultText(result), { + suggestedActions: buildStewardActionResultActions(result) + }) + return true + } catch (error) { + if (sourceMessage) { + sourceMessage.suggestedActionsLocked = false + } + finalizeStewardActionMessage( + pendingMessage, + `动作执行失败:${String(error?.message || '请稍后重试。').trim()}` + ) + return false + } + } + + function buildStewardActionExecutePayload(action = {}, actionType = '', contextJson = {}) { + const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + const currentTask = actionPayload.steward_current_task || null + return { + action_type: actionType, + message: resolveStewardActionMessage(action), + plan_id: String(actionPayload.steward_plan_id || '').trim(), + conversation_id: String(actionPayload.conversation_id || actionPayload.conversationId || '').trim() || null, + task: currentTask, + action_step: resolveStewardActionStep(actionPayload, actionType), + confirmed: true, + context_json: contextJson, + client_trace_id: buildStewardActionClientTraceId(actionPayload, actionType) + } + } + + function resolveStewardActionMessage(action = {}) { + const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + return String( + actionPayload.carry_text || + actionPayload.original_message || + resolveLatestInlineUserPrompt?.() || + action.label || + '' + ).trim() + } + + function resolveStewardActionStep(actionPayload = {}, actionType = '') { + if (String(actionPayload.steward_action_step?.action_type || '').trim() === actionType) { + return actionPayload.steward_action_step + } + const steps = Array.isArray(actionPayload.steward_current_task?.action_steps) + ? actionPayload.steward_current_task.action_steps + : [] + return steps.find((step) => String(step?.action_type || '').trim() === actionType) || null + } + + function buildStewardActionClientTraceId(actionPayload = {}, actionType = '') { + const planId = String(actionPayload.steward_plan_id || 'steward').trim() || 'steward' + const taskId = String(actionPayload.steward_action_task_id || actionPayload.steward_current_task?.task_id || 'task').trim() || 'task' + return `${planId}:${taskId}:${actionType}:${Date.now()}` + } + + function isSuccessfulStewardPrecheck(result = {}) { + const status = String(result?.status || '').trim() + const payload = result?.result_payload || result?.resultPayload || {} + return status === 'succeeded' && payload?.blocking !== true + } + + function appendStewardActionMessage(content, options = {}) { + if (!conversationMessages?.value || typeof createInlineMessage !== 'function') { + toast(String(content || '').trim()) + return null + } + const message = createInlineMessage('assistant', content, options) + conversationMessages.value.push(message) + persistStewardActionConversation() + return message + } + + function finalizeStewardActionMessage(pendingMessage, content, options = {}) { + if (!conversationMessages?.value || typeof createInlineMessage !== 'function') { + toast(String(content || '').trim()) + return + } + const finalMessage = createInlineMessage('assistant', content, { + ...options, + id: pendingMessage?.id + }) + if (pendingMessage?.id && typeof replaceInlineMessage === 'function') { + replaceInlineMessage(pendingMessage.id, finalMessage) + } else if (pendingMessage?.id) { + const index = conversationMessages.value.findIndex((item) => item.id === pendingMessage.id) + if (index >= 0) { + conversationMessages.value.splice(index, 1, finalMessage) + } else { + conversationMessages.value.push(finalMessage) + } + } else { + conversationMessages.value.push(finalMessage) + } + persistStewardActionConversation() + } + + function persistStewardActionConversation() { + persistCurrentConversation?.() + scrollInlineConversationToBottom?.({ force: true }) + } + + function buildStewardActionResultText(result = {}) { + const status = String(result?.status || '').trim() + const message = String(result?.message || '').trim() + if (status === 'succeeded') { + return message || '动作已完成。' + } + if (status === 'needs_confirmation') { + return message || '这一步还需要您确认后才能继续。' + } + if (status === 'blocked') { + return ['这一步暂时不能继续。', message].filter(Boolean).join('\n\n') + } + if (status === 'failed') { + return `动作执行失败:${message || '请稍后重试。'}` + } + return message || '动作已返回结果。' + } + + function buildStewardActionResultActions(result = {}) { + if (String(result?.status || '').trim() !== 'succeeded') { + return [] + } + const resultPayload = result?.result_payload || result?.resultPayload || {} + const draftPayload = resultPayload?.draft_payload || resultPayload?.draftPayload || resultPayload + const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim() + const claimId = String(draftPayload?.claim_id || draftPayload?.claimId || '').trim() + if (!claimNo && !claimId) { + return [] + } + return [{ + label: claimNo ? `查看单据 ${claimNo}` : '查看单据', + description: '打开刚生成的单据详情。', + icon: 'mdi mdi-open-in-new', + action_type: 'open_application_detail', + payload: { + claim_id: claimId, + claim_no: claimNo + } + }] + } + return { handleInlineSuggestedAction } diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js index 58426d1..f89de40 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js @@ -27,6 +27,10 @@ import { extractInlineApplicationDraftPayload } from './workbenchAiApplicationPreviewModel.js' import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js' +import { + completeWorkbenchAiThinkingEvents, + mergeWorkbenchAiThinkingEvents +} from './workbenchAiPlanningThinkingModel.js' import { isOrphanInlineApplicationPreviewMessage, resolveInlineApplicationPreviewTextAction, @@ -202,6 +206,26 @@ export function useWorkbenchAiApplicationPreviewFlow({ ].join('\n\n') } + function hasSavedInlineApplicationDraft(message = {}, options = {}) { + const draftPayload = message?.draftPayload || options.draftPayload || null + if (!draftPayload || typeof draftPayload !== 'object') { + return false + } + const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim() + const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim() + const status = String(draftPayload.status || '').trim().toLowerCase() + return Boolean((claimId || claimNo) && status !== 'submitted') + } + + function isContextualInlineApplicationSubmitText(text = '') { + const normalized = String(text || '').replace(/\s+/g, '').trim() + return Boolean( + normalized && + /提交/.test(normalized) && + /(这个|这张|当前|上面|上述|刚才|申请单|单据|草稿|领导|审核|审批)/.test(normalized) + ) + } + function resolveLatestInlineApplicationPreviewMessage() { return resolveLatestApplicationPreviewMessage(conversationMessages.value) } @@ -334,7 +358,12 @@ export function useWorkbenchAiApplicationPreviewFlow({ return true } - if (isSubmit && !options.confirmed) { + const shouldSubmitSavedDraftDirectly = isSubmit && + !options.confirmed && + hasSavedInlineApplicationDraft(targetMessage, options) && + isContextualInlineApplicationSubmitText(userText) + + if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly) { requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText }) return true } @@ -474,11 +503,16 @@ export function useWorkbenchAiApplicationPreviewFlow({ pushInlineUserMessage(options.userMessage || '确认发起出差申请') } + const existingPendingMessage = options.pendingMessageId + ? conversationMessages.value.find((item) => item.id === options.pendingMessageId) + : null + const previousThinkingEvents = completeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(existingPendingMessage)) const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', { + id: options.pendingMessageId, pending: true, stewardPlan: { streamStatus: 'streaming', - thinkingEvents: [ + thinkingEvents: mergeWorkbenchAiThinkingEvents(previousThinkingEvents, [ { eventId: 'application-preview-build', title: '整理申请表字段', @@ -491,19 +525,25 @@ export function useWorkbenchAiApplicationPreviewFlow({ content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。', status: 'pending' } - ] + ]) } }) - conversationMessages.value.push(pendingMessage) + if (options.pendingMessageId) { + replaceInlineMessage(options.pendingMessageId, pendingMessage) + } else { + conversationMessages.value.push(pendingMessage) + } persistCurrentConversation() scrollInlineConversationToBottom() try { const preview = await refreshApplicationPreviewEstimate( - buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {}) + buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {}, { + ontologyFields: options.ontologyFields + }) ) const content = buildLocalApplicationPreviewMessage(preview) - replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, { + const previewMessage = createInlineMessage('assistant', content, { id: pendingMessage.id, applicationPreview: preview, suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview), @@ -512,7 +552,20 @@ export function useWorkbenchAiApplicationPreviewFlow({ thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage)) }, text: content - })) + }) + replaceInlineMessage(pendingMessage.id, previewMessage) + if (options.autoSubmit && normalizeApplicationPreview(preview).readyToSubmit) { + await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, previewMessage, { + confirmed: true, + skipUserMessage: true, + userText: options.userMessage || '直接提交' + }) + } else if (options.autoSaveDraft) { + await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, previewMessage, { + skipUserMessage: true, + userText: options.userMessage || '保存草稿' + }) + } } catch (error) { replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', { id: pendingMessage.id, diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js index 9a750ec..f8e0560 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js @@ -21,6 +21,10 @@ import { import { buildInlineAttachmentOcrDetails } from './workbenchAiMessageModel.js' +import { + completeWorkbenchAiThinkingEvents, + mergeWorkbenchAiThinkingEvents +} from './workbenchAiPlanningThinkingModel.js' function shouldCheckAiRequiredApplicationGate(prompt) { const compact = String(prompt || '').replace(/\s+/g, '') @@ -187,13 +191,16 @@ export function useWorkbenchAiStewardFlow({ return planRequest } - function handleInlineStewardStreamEvent(messageId, event) { + function handleInlineStewardStreamEvent(messageId, event, options = {}) { const message = conversationMessages.value.find((item) => item.id === messageId) if (!message) { return } if (event?.event === 'answer_delta') { + if (options.includeAnswerDelta === false) { + return + } const data = event?.data && typeof event.data === 'object' ? event.data : {} const shouldAutoScroll = inlineConversationAutoScrollPinned.value appendInlineMessageContent(message, data.delta || data.content || data.text || '') @@ -226,16 +233,23 @@ export function useWorkbenchAiStewardFlow({ scrollInlineConversationToBottom({ force: shouldAutoScroll }) } - async function fetchInlineStewardPlan(messageId, payload) { + async function fetchInlineStewardPlan(messageId, payload, options = {}) { + const { + includeAnswerDelta = true, + idleTimeoutMs = 90000, + timeoutMs = 0, + timeoutMessage = '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。' + } = options try { return await fetchStewardPlanStream( payload, { - onEvent: (event) => handleInlineStewardStreamEvent(messageId, event) + onEvent: (event) => handleInlineStewardStreamEvent(messageId, event, { includeAnswerDelta }) }, { - idleTimeoutMs: 90000, - timeoutMessage: '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。' + idleTimeoutMs, + timeoutMs, + timeoutMessage } ) } catch (error) { @@ -249,23 +263,67 @@ export function useWorkbenchAiStewardFlow({ } } - async function requestInlineAssistantReply(prompt, entry = {}, files = []) { + async function resolveInlineExecutionPlan(prompt, entry = {}, files = [], options = {}) { + const receiptContext = await collectAiModeReceiptContext(files) + const planRequest = buildStewardPlanRequest({ + rawText: prompt, + files, + currentUser: currentUser.value || {}, + conversationId: conversationId.value, + stewardState: stewardState.value + }) + planRequest.context_json = { + ...planRequest.context_json, + entry_source: 'workbench_ai_inline_execution_plan', + source: entry.source || 'workbench', + attachment_names: receiptContext.attachmentNames, + attachment_count: receiptContext.attachmentCount, + ocr_summary: receiptContext.ocrSummary, + ocr_documents: receiptContext.ocrDocuments, + ocr_source_file_names: receiptContext.ocrSourceFileNames, + requested_execution_mode: 'plan_then_execute', + ...(receiptContext.ocrError ? { ocr_error: receiptContext.ocrError } : {}) + } + await attachAiRequiredApplicationGate(planRequest, prompt) + const planningMessageId = String(options.pendingMessageId || '').trim() + if (planningMessageId) { + return fetchInlineStewardPlan(planningMessageId, planRequest, { + includeAnswerDelta: false, + idleTimeoutMs: 45000, + timeoutMs: 35000, + timeoutMessage: '小财管家意图规划超时,已切换到本地保守计划。' + }) + } + return fetchStewardPlan(planRequest, { + timeoutMs: 35000, + timeoutMessage: '小财管家意图规划超时,已切换到本地保守计划。' + }) + } + + async function requestInlineAssistantReply(prompt, entry = {}, files = [], options = {}) { let shouldAutoScrollOnFinish = true + const reusablePendingMessageId = String(options.pendingMessageId || '').trim() + const reusablePendingMessage = reusablePendingMessageId + ? conversationMessages.value.find((item) => item.id === reusablePendingMessageId) + : null + const previousThinkingEvents = resolveInlineThinkingEvents(reusablePendingMessage) + const completedPreviousThinkingEvents = completeWorkbenchAiThinkingEvents(previousThinkingEvents) const pendingMessage = createInlineMessage('assistant', '', { + id: reusablePendingMessageId || undefined, pending: true, stewardPlan: { streamStatus: 'streaming', - thinkingEvents: [ + thinkingEvents: mergeWorkbenchAiThinkingEvents(completedPreviousThinkingEvents, [ { eventId: 'init', title: '小财管家正在接入业务流程', content: '正在识别您的意图、上下文和附件信息。', status: 'running' } - ] + ]) } }) - conversationMessages.value.push(pendingMessage) + reusablePendingMessageId ? replaceInlineMessage(reusablePendingMessageId, pendingMessage) : conversationMessages.value.push(pendingMessage) scrollInlineConversationToBottom() try { @@ -303,8 +361,8 @@ export function useWorkbenchAiStewardFlow({ }) const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage) const nextThinkingEvents = normalizedPlan.thinkingEvents.length - ? normalizedPlan.thinkingEvents - : previousThinkingEvents.map((item) => ({ ...item, status: 'completed' })) + ? mergeWorkbenchAiThinkingEvents(previousThinkingEvents, normalizedPlan.thinkingEvents) + : completeWorkbenchAiThinkingEvents(previousThinkingEvents) const previousConversationId = conversationId.value const nextConversationId = String(normalizedPlan.conversationId || '').trim() if (nextConversationId) { @@ -374,6 +432,7 @@ export function useWorkbenchAiStewardFlow({ buildRequiredApplicationMissingText, buildRequiredApplicationSelectionText, filterRequiredApplicationCandidates, + resolveInlineExecutionPlan, requestInlineAssistantReply } } diff --git a/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js b/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js index 8da6298..0d24565 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js @@ -17,15 +17,45 @@ export function isReimbursementCreationIntent(prompt = '') { ) } +export function resolveInlineTravelApplicationRequest(prompt = '') { + const sourceText = String(prompt || '').trim() + const compact = sourceText.replace(/\s+/g, '') + if (!compact) { + return null + } + if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) { + return null + } + + const hasTravelIntent = /出差|差旅|差旅费|交通/.test(compact) + const hasTravelDetail = ( + /去[\u4e00-\u9fa5A-Za-z0-9]{2,24}/.test(compact) || + /[\u4e00-\u9fa5A-Za-z0-9]{2,24}出差/.test(compact) || + /地点[::]?[\u4e00-\u9fa5A-Za-z0-9]{2,24}/.test(compact) || + /交通(?:方式)?[::]?(火车|高铁|动车|飞机|航班|轮船|轮渡|汽车|网约车|自驾)/.test(compact) || + /(火车|高铁|动车|飞机|航班|轮船|轮渡|汽车|网约车|自驾)/.test(compact) + ) + if (!hasTravelIntent || !hasTravelDetail) { + return null + } + + return { + expenseType: 'travel', + expenseTypeLabel: '差旅费', + sourceText, + autoSubmit: /直接提交|提交申请|确认提交|提交审批/.test(compact) + } +} + export function resolveInlineApplicationPreviewTextAction(text = '') { const normalized = String(text || '').replace(/\s+/g, '').trim() if (!normalized) { return '' } - if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) { + if (/^(保存草稿|保存|存草稿|先保存|保存这个单据|保存这个申请|保存这个申请单|保存这张单据|保存这张申请单|保存当前单据|保存当前申请|保存上面的单据|保存刚才的草稿)$/.test(normalized)) { return AI_APPLICATION_ACTION_SAVE_DRAFT } - if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) { + if (/^(提交|提交申请|确认提交|提交审批|直接提交|提交这个单据|提交这个申请|提交这个申请单|提交这张单据|提交这张申请单|提交当前单据|提交当前申请|提交上面的单据|提交刚才保存的草稿|提交刚才的草稿|把这个单据提交|把这个申请单提交|提交到领导审批|提交给领导审核)$/.test(normalized)) { return AI_APPLICATION_ACTION_SUBMIT } return '' diff --git a/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js b/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js index 1ea5166..2bd96f6 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js @@ -1,6 +1,7 @@ import { buildApplicationTemplatePreview, buildLocalApplicationPreview, + buildModelRefinedApplicationPreview, normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js' import { @@ -187,11 +188,74 @@ export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = return `${label}申请` } -export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}) { +function buildOntologyTimeRangeFromCanonical(value = '') { + const raw = String(value || '').trim() + if (!raw) { + return {} + } + const normalized = raw.replace(/\s+/g, ' ') + const [startDate, endDate] = normalized + .split(/\s*(?:至|到|~|--|—)\s*/) + .map((item) => item.trim()) + .filter(Boolean) + if (/^20\d{2}-\d{1,2}-\d{1,2}$/.test(startDate || '')) { + return { + raw, + start_date: startDate, + end_date: /^20\d{2}-\d{1,2}-\d{1,2}$/.test(endDate || '') ? endDate : startDate + } + } + return { raw } +} + +function buildEntityFromCanonicalField(type, value) { + const normalizedValue = String(value || '').trim() + if (!type || !normalizedValue) { + return null + } + return { + type, + value: normalizedValue, + normalized_value: normalizedValue + } +} + +function buildApplicationOntologyFromCanonicalFields(fields = {}) { + const source = fields && typeof fields === 'object' ? fields : {} + const entityKeys = [ + 'expense_type', + 'location', + 'reason', + 'amount', + 'transport_mode', + 'customer_name', + 'merchant_name', + 'department_name', + 'employee_name', + 'employee_no' + ] + return { + parse_strategy: 'llm_primary', + time_range: buildOntologyTimeRangeFromCanonical(source.time_range), + entities: entityKeys + .map((key) => buildEntityFromCanonicalField(key, source[key])) + .filter(Boolean) + } +} + +export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}, options = {}) { const rawText = String(sourceText || '').trim() - const preview = rawText + const basePreview = rawText ? buildLocalApplicationPreview(rawText, currentUser) : buildApplicationTemplatePreview(currentUser) + const preview = options.ontologyFields && typeof options.ontologyFields === 'object' + ? buildModelRefinedApplicationPreview( + basePreview, + buildApplicationOntologyFromCanonicalFields(options.ontologyFields), + rawText, + currentUser + ) + : basePreview const normalized = normalizeApplicationPreview(preview) return normalizeApplicationPreview({ ...normalized, diff --git a/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js b/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js new file mode 100644 index 0000000..a6fafc3 --- /dev/null +++ b/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js @@ -0,0 +1,276 @@ +import { resolveInlineTravelApplicationRequest } from './workbenchAiApplicationGateModel.js' + +export const WORKBENCH_AI_INTENT_SOURCE_MODEL = 'llm_function_call' +export const WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK = 'rule_fallback' + +export const WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW = 'build_application_preview' +export const WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS = 'validate_required_fields' +export const WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT = 'save_application_draft' +export const WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK = 'run_duplicate_precheck' +export const WORKBENCH_AI_STEP_SUBMIT_APPLICATION = 'submit_application' + +const TRAVEL_APPLICATION_INTENT = 'create_travel_application' + +function normalizePromptAction(prompt = '') { + const compact = String(prompt || '').replace(/\s+/g, '') + if (/直接提交|提交申请|确认提交|提交审批/.test(compact)) { + return 'submit' + } + if (/保存草稿|保存|存草稿|先保存/.test(compact)) { + return 'save_draft' + } + return 'preview' +} + +function normalizePlannerSource(value = '') { + return String(value || '').trim() === WORKBENCH_AI_INTENT_SOURCE_MODEL + ? WORKBENCH_AI_INTENT_SOURCE_MODEL + : WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK +} + +function normalizeSlotKey(key = '') { + const normalized = String(key || '').trim() + if (['time_range', 'business_time', 'occurred_date', 'application_time'].includes(normalized)) { + return 'timeRange' + } + if (['transport_mode', 'transportType', 'transport_type', 'trafficMode'].includes(normalized)) { + return 'transportMode' + } + if (['business_reason', 'businessPurpose', 'purpose'].includes(normalized)) { + return 'reason' + } + return normalized +} + +function normalizeSlots(rawSlots = {}) { + if (!rawSlots || typeof rawSlots !== 'object') { + return {} + } + return Object.entries(rawSlots).reduce((slots, [key, value]) => { + const normalizedKey = normalizeSlotKey(key) + const normalizedValue = String(value || '').trim() + if (normalizedKey && normalizedValue) { + slots[normalizedKey] = normalizedValue + } + return slots + }, {}) +} + +const ONTOLOGY_FIELD_ALIASES = { + business_time: 'time_range', + occurred_date: 'time_range', + application_time: 'time_range', + transportType: 'transport_mode', + transport_type: 'transport_mode', + trafficMode: 'transport_mode', + business_reason: 'reason', + businessPurpose: 'reason', + purpose: 'reason' +} + +const SUPPORTED_ONTOLOGY_FIELDS = new Set([ + 'expense_type', + 'time_range', + 'location', + 'reason', + 'amount', + 'transport_mode', + 'attachments', + 'customer_name', + 'merchant_name', + 'department_name', + 'employee_name', + 'employee_no' +]) + +function normalizeOntologyFields(rawFields = {}) { + if (!rawFields || typeof rawFields !== 'object') { + return {} + } + return Object.entries(rawFields).reduce((fields, [key, value]) => { + const normalizedKey = ONTOLOGY_FIELD_ALIASES[String(key || '').trim()] || String(key || '').trim() + const normalizedValue = String(value || '').trim() + if (SUPPORTED_ONTOLOGY_FIELDS.has(normalizedKey) && normalizedValue) { + fields[normalizedKey] = normalizedValue + } + return fields + }, {}) +} + +function buildApplicationSteps(requestedAction = 'preview') { + const steps = [ + WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW, + WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS + ] + if (requestedAction === 'submit') { + steps.push( + WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK, + WORKBENCH_AI_STEP_SUBMIT_APPLICATION + ) + } else if (requestedAction === 'save_draft') { + steps.push(WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT) + } + return steps +} + +function normalizeServerApplicationSteps(rawSteps = []) { + if (!Array.isArray(rawSteps)) { + return [] + } + const mappedSteps = rawSteps + .map((step) => String(step?.action_type || step?.actionType || '').trim()) + .map((actionType) => { + if (actionType === WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW) { + return WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW + } + if (actionType === WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS) { + return WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS + } + if (actionType === WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT) { + return WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT + } + if (actionType === WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK) { + return WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK + } + if (actionType === WORKBENCH_AI_STEP_SUBMIT_APPLICATION) { + return WORKBENCH_AI_STEP_SUBMIT_APPLICATION + } + return '' + }) + .filter(Boolean) + return [...new Set(mappedSteps)] +} + +function findModelTravelApplicationTask(rawPlan = {}) { + const tasks = Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : [] + return tasks.find((task) => { + const taskType = String(task?.task_type || task?.taskType || '').trim() + const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim() + return taskType === 'expense_application' || assignedAgent === 'application_assistant' + }) || null +} + +function resolveCandidateFlows(rawPlan = {}) { + const pendingFlow = rawPlan?.pending_flow_confirmation || rawPlan?.pendingFlowConfirmation || {} + const pendingCandidates = pendingFlow?.candidate_flows || pendingFlow?.candidateFlows + const rootCandidates = rawPlan?.candidate_flows || rawPlan?.candidateFlows + if (Array.isArray(pendingCandidates)) { + return pendingCandidates + } + return Array.isArray(rootCandidates) ? rootCandidates : [] +} + +function findSingleApplicationCandidateFlow(rawPlan = {}) { + const pendingFlow = rawPlan?.pending_flow_confirmation || rawPlan?.pendingFlowConfirmation || {} + if (String(pendingFlow?.status || '').trim() !== 'pending') { + return null + } + const candidateFlows = resolveCandidateFlows(rawPlan) + if (candidateFlows.length !== 1) { + return null + } + const [candidate] = candidateFlows + const flowId = String(candidate?.flow_id || candidate?.flowId || '').trim() + const label = String(candidate?.label || '').trim() + if (flowId === 'travel_application' && /先发起出差申请/.test(label)) { + return candidate + } + return null +} + +export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) { + const prompt = String(options.prompt || rawPlan?.sourceText || rawPlan?.source_text || '').trim() + const task = findModelTravelApplicationTask(rawPlan) + if (!task) { + const candidateFlow = findSingleApplicationCandidateFlow(rawPlan) + if (!candidateFlow) { + return null + } + const ontologyFields = normalizeOntologyFields(candidateFlow.ontology_fields || candidateFlow.ontologyFields) + const requestedAction = normalizePromptAction(prompt) + return { + source: normalizePlannerSource(rawPlan.planning_source || rawPlan.planningSource), + intent: TRAVEL_APPLICATION_INTENT, + requestedAction, + confidence: Number(candidateFlow.confidence || rawPlan.confidence || 0), + sourceText: prompt, + ontologyFields, + slots: normalizeSlots(ontologyFields), + missingFields: Array.isArray(candidateFlow.missing_fields || candidateFlow.missingFields) + ? candidateFlow.missing_fields || candidateFlow.missingFields + : [], + steps: buildApplicationSteps(requestedAction) + } + } + + const rawOntologyFields = task.ontology_fields || task.ontologyFields || rawPlan.slots + const ontologyFields = normalizeOntologyFields(rawOntologyFields) + const requestedAction = String( + task.requested_action || + task.requestedAction || + rawPlan.requested_action || + rawPlan.requestedAction || + '' + ).trim() || normalizePromptAction(prompt) + const serverSteps = normalizeServerApplicationSteps(task.action_steps || task.actionSteps) + return { + source: normalizePlannerSource(rawPlan.planning_source || rawPlan.planningSource), + intent: TRAVEL_APPLICATION_INTENT, + requestedAction, + confidence: Number(task.confidence || rawPlan.confidence || 0), + sourceText: prompt, + ontologyFields, + slots: normalizeSlots(ontologyFields), + missingFields: Array.isArray(task.missing_fields || task.missingFields) + ? task.missing_fields || task.missingFields + : [], + steps: serverSteps.length ? serverSteps : buildApplicationSteps(requestedAction) + } +} + +export function buildRuleFallbackWorkbenchAiIntentPlan(prompt = '') { + const request = resolveInlineTravelApplicationRequest(prompt) + if (!request) { + return null + } + const requestedAction = request.autoSubmit ? 'submit' : normalizePromptAction(prompt) + return { + source: WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK, + intent: TRAVEL_APPLICATION_INTENT, + requestedAction, + confidence: 0.72, + sourceText: request.sourceText, + ontologyFields: {}, + slots: {}, + missingFields: [], + steps: buildApplicationSteps(requestedAction) + } +} + +export function shouldRequestWorkbenchAiIntentPlan(prompt = '') { + const compact = String(prompt || '').replace(/\s+/g, '') + if (!compact) { + return false + } + if (compact.length < 2 || /^[\d\s.,,。::;;!?!?-]+$/.test(compact)) { + return false + } + return true +} + +export function resolveExecutableTravelApplicationPlan(plan = null) { + if (!plan || plan.intent !== TRAVEL_APPLICATION_INTENT) { + return null + } + if (!Array.isArray(plan.steps) || !plan.steps.includes(WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW)) { + return null + } + return { + expenseType: 'travel', + expenseTypeLabel: '差旅费', + sourceText: String(plan.sourceText || '').trim(), + ontologyFields: normalizeOntologyFields(plan.ontologyFields || {}), + autoSubmit: plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION), + autoSaveDraft: plan.steps.includes(WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT) + } +} diff --git a/web/src/composables/workbenchAiMode/workbenchAiPlanningThinkingModel.js b/web/src/composables/workbenchAiMode/workbenchAiPlanningThinkingModel.js new file mode 100644 index 0000000..cd1e651 --- /dev/null +++ b/web/src/composables/workbenchAiMode/workbenchAiPlanningThinkingModel.js @@ -0,0 +1,80 @@ +export function mergeWorkbenchAiThinkingEvents(...groups) { + const merged = [] + const indexById = new Map() + groups.flat().filter(Boolean).forEach((event) => { + const eventId = String(event.eventId || event.event_id || '').trim() + if (!eventId || !indexById.has(eventId)) { + if (eventId) { + indexById.set(eventId, merged.length) + } + merged.push(event) + return + } + const index = indexById.get(eventId) + merged.splice(index, 1, { + ...merged[index], + ...event, + eventId + }) + }) + return merged +} + +export function completeWorkbenchAiThinkingEvents(events = []) { + return events.map((event) => ({ + ...event, + status: event.status === 'failed' ? 'failed' : 'completed' + })) +} + +export function buildInitialModelPlanningThinkingEvents() { + return [ + { + eventId: 'model-planning-intent', + title: '判断办理意图', + content: '正在判断这句话是要办申请、做报销、处理附件,还是普通咨询。', + status: 'running' + } + ] +} + +export function buildModelPlanningProgressSchedule() { + return [ + { + delayMs: 900, + event: { + eventId: 'model-planning-slots', + title: '抽取关键信息', + content: '正在整理日期、地点、事由、交通方式、附件和“保存/提交”等动作线索。', + status: 'running' + } + }, + { + delayMs: 2200, + event: { + eventId: 'model-planning-steps', + title: '规划执行步骤', + content: '正在生成可执行动作序列,例如填充申请表、校验必填项、保存草稿或发起提交。', + status: 'running' + } + }, + { + delayMs: 5200, + event: { + eventId: 'model-planning-tools', + title: '匹配业务工具', + content: '正在把模型计划映射到白名单工具,避免未确认的提交或附件关联直接产生副作用。', + status: 'running' + } + }, + { + delayMs: 9000, + event: { + eventId: 'model-planning-fallback', + title: '准备兜底策略', + content: '如果模型等待过久或调用失败,会按保守规则继续识别,保证申请、报销和草稿流程还能推进。', + status: 'running' + } + } + ] +} diff --git a/web/src/services/steward.js b/web/src/services/steward.js index 86dd086..af9d8d6 100644 --- a/web/src/services/steward.js +++ b/web/src/services/steward.js @@ -24,6 +24,14 @@ export function fetchStewardRuntimeDecision(payload, options = {}) { }) } +export function executeStewardAction(payload, options = {}) { + return apiRequest('/steward/actions/execute', { + method: 'POST', + body: JSON.stringify(payload), + ...options + }) +} + export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) { const { timeoutMs = 0, diff --git a/web/src/views/scripts/stewardPlanModel.js b/web/src/views/scripts/stewardPlanModel.js index b1f8e66..c92f128 100644 --- a/web/src/views/scripts/stewardPlanModel.js +++ b/web/src/views/scripts/stewardPlanModel.js @@ -28,6 +28,13 @@ const AGENT_LABELS = { expense: '报销助手' } +const EXECUTABLE_STEWARD_ACTION_TYPES = new Set([ + 'save_application_draft', + 'submit_application', + 'create_reimbursement_draft', + 'associate_attachments' +]) + export function buildStewardPlanRequest({ rawText = '', files = [], @@ -102,10 +109,12 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) { summary: String(item.summary || ''), status: String(item.status || ''), confidence: Number(item.confidence || 0), + requestedAction: String(item.requested_action || item.requestedAction || ''), ontologyFields: item.ontology_fields || item.ontologyFields || {}, missingFields, missingFieldItems: buildStewardFieldItems(missingFields, taskType), - confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true + confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true, + actionSteps: normalizeStewardActionSteps(item.action_steps || item.actionSteps) } }) : [], @@ -137,6 +146,26 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) { } } +function normalizeStewardActionSteps(rawSteps = []) { + if (!Array.isArray(rawSteps)) { + return [] + } + return rawSteps + .map((step) => ({ + step_id: String(step?.step_id || step?.stepId || ''), + action_type: String(step?.action_type || step?.actionType || ''), + label: String(step?.label || ''), + target_task_id: String(step?.target_task_id || step?.targetTaskId || ''), + status: String(step?.status || 'planned'), + requires_confirmation: Boolean(step?.requires_confirmation ?? step?.requiresConfirmation), + depends_on: Array.isArray(step?.depends_on || step?.dependsOn) + ? step.depends_on || step.dependsOn + : [], + payload: step?.payload && typeof step.payload === 'object' ? step.payload : {} + })) + .filter((step) => step.step_id && step.action_type) +} + export function buildStewardPlanMessageText(plan) { const normalized = normalizeStewardPlan(plan) if (isOffTopicPlan(normalized)) { @@ -353,6 +382,7 @@ export function buildStewardSuggestedActions(plan) { const targetSessionType = actionType === 'confirm_create_application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE + const executableStep = resolveExecutableStewardActionStep(task) return [ { label: buildNextActionLabel(actionType, task), @@ -372,6 +402,7 @@ export function buildStewardSuggestedActions(plan) { steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''), steward_plan_id: normalized.planId, steward_next_task_id: task?.taskId || '', + ...buildStewardExecuteActionPayload(executableStep, task), steward_current_task: buildStewardTaskPayload(task), steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length, steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId) @@ -380,6 +411,26 @@ export function buildStewardSuggestedActions(plan) { ] } +function resolveExecutableStewardActionStep(task = null) { + const steps = Array.isArray(task?.actionSteps || task?.action_steps) + ? task.actionSteps || task.action_steps + : [] + return [...steps].reverse().find((step) => EXECUTABLE_STEWARD_ACTION_TYPES.has(String(step.action_type || step.actionType || ''))) || null +} + +function buildStewardExecuteActionPayload(step, task) { + if (!step) { + return {} + } + return { + steward_execute_action: true, + steward_action_type: String(step.action_type || step.actionType || ''), + steward_action_step: step, + steward_action_requires_confirmation: Boolean(step.requires_confirmation ?? step.requiresConfirmation), + steward_action_task_id: task?.taskId || task?.task_id || '' + } +} + function normalizePendingFlowConfirmation(rawPlan = {}) { const rawPending = rawPlan.pending_flow_confirmation || rawPlan.pendingFlowConfirmation || {} const rawCandidates = Array.isArray(rawPlan.candidate_flows || rawPlan.candidateFlows) @@ -779,7 +830,9 @@ function buildStewardTaskPayload(task) { title: task.title || '', summary: task.summary || '', assigned_agent: task.assignedAgent || task.assigned_agent || '', + requested_action: task.requestedAction || task.requested_action || '', ontology_fields: task.ontologyFields || task.ontology_fields || {}, - missing_fields: task.missingFields || task.missing_fields || [] + missing_fields: task.missingFields || task.missing_fields || [], + action_steps: task.actionSteps || task.action_steps || [] } } diff --git a/web/tests/expense-application-fast-preview.test.mjs b/web/tests/expense-application-fast-preview.test.mjs index 16948c2..ec108a5 100644 --- a/web/tests/expense-application-fast-preview.test.mjs +++ b/web/tests/expense-application-fast-preview.test.mjs @@ -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({ diff --git a/web/tests/steward-actions-service.test.mjs b/web/tests/steward-actions-service.test.mjs new file mode 100644 index 0000000..2dc7d4e --- /dev/null +++ b/web/tests/steward-actions-service.test.mjs @@ -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) +}) diff --git a/web/tests/steward-plan-message-copy.test.mjs b/web/tests/steward-plan-message-copy.test.mjs index e1ef61d..accff2f 100644 --- a/web/tests/steward-plan-message-copy.test.mjs +++ b/web/tests/steward-plan-message-copy.test.mjs @@ -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') +}) diff --git a/web/tests/workbench-ai-action-router.test.mjs b/web/tests/workbench-ai-action-router.test.mjs index 7bb9813..8df9444 100644 --- a/web/tests/workbench-ai-action-router.test.mjs +++ b/web/tests/workbench-ai-action-router.test.mjs @@ -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 + } +}) diff --git a/web/tests/workbench-ai-application-context-submit.test.mjs b/web/tests/workbench-ai-application-context-submit.test.mjs new file mode 100644 index 0000000..8cb726a --- /dev/null +++ b/web/tests/workbench-ai-application-context-submit.test.mjs @@ -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 + } +}) diff --git a/web/tests/workbench-ai-application-gate-model.test.mjs b/web/tests/workbench-ai-application-gate-model.test.mjs index 2d5a884..ddaab02 100644 --- a/web/tests/workbench-ai-application-gate-model.test.mjs +++ b/web/tests/workbench-ai-application-gate-model.test.mjs @@ -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月去上海出差' }, diff --git a/web/tests/workbench-ai-intent-planner-model.test.mjs b/web/tests/workbench-ai-intent-planner-model.test.mjs new file mode 100644 index 0000000..7cf08cc --- /dev/null +++ b/web/tests/workbench-ai-intent-planner-model.test.mjs @@ -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\)/) +})