diff --git a/web/src/assets/styles/components/personal-workbench-ai-mode.css b/web/src/assets/styles/components/personal-workbench-ai-mode.css index 2a32c1a..36bfc03 100644 --- a/web/src/assets/styles/components/personal-workbench-ai-mode.css +++ b/web/src/assets/styles/components/personal-workbench-ai-mode.css @@ -1274,71 +1274,71 @@ } .workbench-ai-answer-markdown :deep(li::marker) { - color: #2563eb; - font-weight: 850; + color: #64748b; + font-weight: 600; } .workbench-ai-answer-markdown :deep(strong) { color: #0f172a; - font-weight: 850; + font-weight: 600; } .workbench-ai-answer-markdown :deep(hr) { margin: 26px 0; border: 0; - border-top: 1px solid rgba(226, 232, 240, 0.9); + border-top: 1px solid #e2e8f0; } .workbench-ai-answer-markdown :deep(blockquote) { margin: 18px 0 0; padding: 14px 16px; - border-left: 3px solid rgba(37, 99, 235, 0.5); - border-radius: 12px; - background: rgba(239, 246, 255, 0.62); - color: #475569; + border-left: 3px solid #cbd5e1; + border-radius: 8px; + background: #f8fafc; + color: #334155; } .workbench-ai-answer-markdown :deep(.ai-html-callout) { margin: 0; padding: 14px 16px; - border-left: 3px solid rgba(37, 99, 235, 0.5); - border-radius: 12px; - background: rgba(239, 246, 255, 0.62); - color: #475569; + border-left: 3px solid #cbd5e1; + border-radius: 8px; + background: #f8fafc; + color: #334155; } .workbench-ai-answer-markdown :deep(.ai-html-focus-grid) { display: grid; gap: 0; margin: 2px 0 18px; - padding-left: 22px; - border-left: 3px solid rgba(96, 165, 250, 0.66); + padding-left: 20px; + border-left: 3px solid #cbd5e1; } .workbench-ai-answer-markdown :deep(.ai-html-focus-card) { - padding: 11px 0 16px; + padding: 8px 0 12px; border: 0; border-radius: 0; background: transparent; } .workbench-ai-answer-markdown :deep(.ai-html-focus-card + .ai-html-focus-card) { - border-top: 1px solid rgba(226, 232, 240, 0.92); + border-top: 1px solid #e2e8f0; } .workbench-ai-answer-markdown :deep(.ai-html-focus-label) { display: block; margin-bottom: 4px; - color: #1d4ed8; - font-size: 15px; - font-weight: 900; + color: #475569; + font-size: 14px; + font-weight: 600; } .workbench-ai-answer-markdown :deep(.ai-html-focus-card p) { - color: #475569; - font-size: 16px; - font-weight: 650; - line-height: 1.72; + color: #1e293b; + font-size: 15px; + font-weight: 500; + line-height: 1.6; } .workbench-ai-answer-markdown :deep(.ai-html-steps), @@ -1366,9 +1366,9 @@ padding-top: 1px; border-radius: 0; background: transparent; - color: #1d4ed8; - font-size: 17px; - font-weight: 900; + color: #64748b; + font-size: 15px; + font-weight: 600; line-height: 1.45; } @@ -1482,34 +1482,27 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card) { - --ai-document-card-head-bg: rgba(37, 99, 235, 0.075); + --ai-document-card-head-bg: rgba(241, 245, 249, 0.5); position: relative; display: grid; gap: 0; overflow: hidden; padding: 0; - border: 0; - border-radius: 14px; + border: 1px solid #e2e8f0; + border-radius: 12px; background-color: #ffffff; - background-image: - linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.9)), - url("../../ai-document-card-bg.png"); - background-position: center; - background-size: cover; box-shadow: - inset 0 0 0 1px rgba(203, 213, 225, 0.5), - 0 1px 2px rgba(15, 23, 42, 0.035), - 0 14px 34px rgba(15, 23, 42, 0.05); + 0 1px 2px 0 rgba(15, 23, 42, 0.05); color: #334155; animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both; - transition: box-shadow 180ms ease, transform 180ms ease; + transition: box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease; } .workbench-ai-answer-markdown :deep(.ai-document-card:hover) { + border-color: #cbd5e1; box-shadow: - inset 0 0 0 1px rgba(148, 163, 184, 0.46), - 0 1px 2px rgba(15, 23, 42, 0.04), - 0 18px 38px rgba(15, 23, 42, 0.07); + 0 4px 6px -1px rgba(15, 23, 42, 0.08), + 0 2px 4px -2px rgba(15, 23, 42, 0.08); transform: translateY(-1px); } @@ -1532,8 +1525,9 @@ justify-content: space-between; gap: 16px; min-width: 0; - padding: 13px 18px 13px 20px; + padding: 12px 18px; background: var(--ai-document-card-head-bg); + border-bottom: 1px solid #f1f5f9; } .workbench-ai-answer-markdown :deep(.ai-document-card__status) { @@ -1543,31 +1537,31 @@ padding: 0; border-radius: 0; background: transparent; - color: #1d4ed8; - font-size: 15px; - font-weight: 860; + color: #475569; + font-size: 14px; + font-weight: 600; line-height: 1.3; white-space: nowrap; } .workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__head) { - background: rgba(22, 163, 74, 0.08); + background: rgba(240, 253, 250, 0.6); } .workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__head) { - background: rgba(217, 119, 6, 0.09); + background: rgba(254, 243, 199, 0.6); } .workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__head) { - background: rgba(220, 38, 38, 0.08); + background: rgba(254, 226, 226, 0.6); } .workbench-ai-answer-markdown :deep(.ai-document-card.is-pending .ai-document-card__status) { - color: #1d4ed8; + color: #2563eb; } .workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__status) { - color: #15803d; + color: #0f766e; } .workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__status) { @@ -1588,9 +1582,9 @@ .workbench-ai-answer-markdown :deep(.ai-document-card__reason) { display: -webkit-box; min-width: 0; - color: #1e40af; + color: #1e293b; font-size: 15px; - font-weight: 760; + font-weight: 600; line-height: 1.45; overflow: hidden; -webkit-line-clamp: 1; @@ -1598,19 +1592,19 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__reason) { - color: #166534; + color: #1e293b; } .workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__reason) { - color: #92400e; + color: #1e293b; } .workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__reason) { - color: #991b1b; + color: #1e293b; } .workbench-ai-answer-markdown :deep(.ai-document-card--application) { - --ai-document-card-head-bg: rgba(37, 99, 235, 0.075); + --ai-document-card-head-bg: rgba(239, 246, 255, 0.5); } .workbench-ai-answer-markdown :deep(.ai-document-card--application .ai-document-card__head) { @@ -1618,11 +1612,11 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card--application .ai-document-card__reason) { - color: #1e40af; + color: #1e293b; } .workbench-ai-answer-markdown :deep(.ai-document-card--reimbursement) { - --ai-document-card-head-bg: rgba(13, 148, 136, 0.075); + --ai-document-card-head-bg: rgba(240, 253, 250, 0.5); } .workbench-ai-answer-markdown :deep(.ai-document-card--reimbursement .ai-document-card__head) { @@ -1630,15 +1624,11 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card--reimbursement .ai-document-card__reason) { - color: #0f766e; + color: #1e293b; } .workbench-ai-answer-markdown :deep(.ai-document-card--approval-task) { - --ai-document-card-head-bg: rgba(245, 158, 11, 0.1); - box-shadow: - inset 0 0 0 1px rgba(245, 158, 11, 0.18), - 0 1px 2px rgba(120, 53, 15, 0.04), - 0 14px 34px rgba(120, 53, 15, 0.06); + --ai-document-card-head-bg: rgba(254, 243, 199, 0.5); } .workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__head) { @@ -1646,15 +1636,17 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__reason) { - color: #92400e; + color: #1e293b; } .workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__status) { - min-height: 26px; - padding: 0 10px; - border-radius: 999px; - background: rgba(245, 158, 11, 0.18); + min-height: 22px; + padding: 0 8px; + border-radius: 4px; + background: rgba(217, 119, 6, 0.1); color: #b45309; + font-size: 13px; + font-weight: 600; } .workbench-ai-answer-markdown :deep(.ai-document-card__summary), @@ -1666,7 +1658,7 @@ .workbench-ai-answer-markdown :deep(.ai-document-card__summary) { padding-bottom: 14px; - border-bottom: 1px solid rgba(203, 213, 225, 0.76); + border-bottom: 1px solid #f1f5f9; } .workbench-ai-answer-markdown :deep(.ai-document-card__details) { @@ -1690,26 +1682,26 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card__label) { - color: #8a94a6; + color: #64748b; font-size: 13px; - font-weight: 640; + font-weight: 500; line-height: 1.4; white-space: nowrap; } .workbench-ai-answer-markdown :deep(.ai-document-card__value) { min-width: 0; - color: #334155; + color: #1e293b; font-size: 14px; - font-weight: 720; + font-weight: 500; line-height: 1.45; overflow-wrap: anywhere; } .workbench-ai-answer-markdown :deep(.ai-document-card__amount) { color: #0f172a; - font-size: 18px; - font-weight: 900; + font-size: 17px; + font-weight: 700; line-height: 1.2; white-space: nowrap; } @@ -1717,33 +1709,30 @@ .workbench-ai-answer-markdown :deep(.ai-document-card__number) { color: #64748b; font-size: 13px; - font-weight: 740; + font-weight: 500; letter-spacing: 0; } .workbench-ai-answer-markdown :deep(.ai-attachment-association-card) { - background-image: - linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(248, 250, 252, 0.94)), - url("../../ai-document-card-bg.png"); + background-image: none; + background-color: #ffffff; } .workbench-ai-answer-markdown :deep(.ai-attachment-association-card .ai-document-card__head) { - background: linear-gradient(90deg, rgba(219, 234, 254, 0.92), rgba(240, 253, 250, 0.82)); + background: rgba(241, 245, 249, 0.5); } .workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card) { - box-shadow: - inset 0 0 0 1px rgba(147, 197, 253, 0.42), - 0 1px 2px rgba(15, 23, 42, 0.03), - 0 12px 28px rgba(37, 99, 235, 0.045); + border-color: #cbd5e1; + box-shadow: 0 1px 2px 0 rgba(15, 23, 42, 0.05); } .workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__head) { - background: linear-gradient(90deg, rgba(219, 234, 254, 0.92), rgba(239, 246, 255, 0.74)); + background: rgba(239, 246, 255, 0.5); } .workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__reason) { - color: #1d4ed8; + color: #1e293b; } .workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__status) { @@ -1781,16 +1770,16 @@ padding: 0; border-radius: 0; background: transparent; - color: #1d4ed8; + color: #2563eb; font-size: 14px; - font-weight: 820; + font-weight: 600; box-shadow: none; white-space: nowrap; } .workbench-ai-answer-markdown :deep(.ai-document-card__action:hover) { background: transparent; - color: #1e40af; + color: #1d4ed8; text-decoration: underline; } @@ -1798,10 +1787,10 @@ .workbench-ai-answer-markdown :deep(.ai-html-table-wrap) { overflow-x: auto; margin-top: 18px; - border: 1px solid rgba(226, 232, 240, 0.9); - border-radius: 14px; + border: 1px solid #e2e8f0; + border-radius: 8px; background: #ffffff; - box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .workbench-ai-answer-markdown :deep(table) { @@ -1813,7 +1802,7 @@ .workbench-ai-answer-markdown :deep(th), .workbench-ai-answer-markdown :deep(td) { padding: 11px 14px; - border-bottom: 1px solid rgba(226, 232, 240, 0.9); + border-bottom: 1px solid #f1f5f9; text-align: left; } @@ -1922,6 +1911,149 @@ } } +[data-theme-mode="enterprise"] .workbench-ai-mode { + --ai-ink: #111827; + --ai-text: #334155; + --ai-muted: #64748b; + --ai-line: #d8dee8; + --ai-blue: #475569; + --ai-blue-deep: #334155; + --ai-purple: #64748b; + --ai-cyan: #64748b; + background: + linear-gradient(180deg, #f8fafc 0%, #ffffff 56%, #f8fafc 100%), + var(--bg); +} + +[data-theme-mode="enterprise"] .workbench-ai-mode::after { + background: + linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px), + linear-gradient(180deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px); + background-size: 64px 64px; + opacity: 0.36; + filter: none; +} + +[data-theme-mode="enterprise"] .workbench-ai-mode.has-conversation { + background: #f8fafc; +} + +[data-theme-mode="enterprise"] .workbench-ai-mode.has-conversation::after { + opacity: 0.22; +} + +[data-theme-mode="enterprise"] .workbench-ai-orb { + width: clamp(96px, 7vw, 112px); + height: clamp(96px, 7vw, 112px); + border: 0; + border-radius: 50%; + background: transparent; + color: #475569; + box-shadow: none; +} + +[data-theme-mode="enterprise"] .workbench-ai-composer, +[data-theme-mode="enterprise"] .workbench-ai-composer--inline { + border-color: #d8dee8; + border-radius: 8px; + background: #ffffff; + box-shadow: + 0 10px 26px rgba(15, 23, 42, 0.06), + 0 1px 2px rgba(15, 23, 42, 0.04); +} + +[data-theme-mode="enterprise"] .workbench-ai-icon-btn:hover, +[data-theme-mode="enterprise"] .workbench-ai-icon-btn.active { + color: #334155; + background: #f1f5f9; +} + +[data-theme-mode="enterprise"] .workbench-ai-send-btn { + background: #334155; + box-shadow: none; +} + +[data-theme-mode="enterprise"] .workbench-ai-send-btn:hover:not(:disabled) { + background: #1f2937; + box-shadow: none; +} + +[data-theme-mode="enterprise"] .workbench-ai-message { + animation-duration: 220ms; +} + +[data-theme-mode="enterprise"] .workbench-ai-user-bubble { + border-radius: 8px 8px 3px; + background: #334155; + box-shadow: none; +} + +[data-theme-mode="enterprise"] .workbench-ai-answer-card { + border-color: #d8dee8; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +[data-theme-mode="enterprise"] .workbench-ai-thinking-panel { + border-color: #d8dee8; + border-radius: 8px; + background: #f8fafc; +} + +[data-theme-mode="enterprise"] .workbench-ai-thinking-toggle { + border-radius: 8px; + color: #334155; +} + +[data-theme-mode="enterprise"] .workbench-ai-thinking-toggle:hover { + background: #ffffff; +} + +[data-theme-mode="enterprise"] .workbench-ai-thinking-toggle strong, +[data-theme-mode="enterprise"] .workbench-ai-thinking-item strong { + color: #334155; +} + +[data-theme-mode="enterprise"] .workbench-ai-thinking-dot { + background: #64748b; + box-shadow: 0 0 0 4px rgba(100, 116, 139, 0.12); +} + +[data-theme-mode="enterprise"] .workbench-ai-suggested-actions button { + border-color: #d8dee8; + border-radius: 6px; + background: #ffffff; + color: #334155; +} + +[data-theme-mode="enterprise"] .workbench-ai-suggested-actions button:hover:not(:disabled) { + background: #f8fafc; +} + +[data-theme-mode="intelligent"] .workbench-ai-mode { + --ai-blue: #5f6f9f; + --ai-blue-deep: #465275; + --ai-purple: #6d6a9f; + --ai-cyan: #477c9e; + background: + radial-gradient(circle at 16% 0%, rgba(95, 111, 159, 0.1), transparent 36%), + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.94)), + var(--bg); +} + +[data-theme-mode="intelligent"] .workbench-ai-mode.has-conversation { + background: + radial-gradient(circle at 12% 0%, rgba(95, 111, 159, 0.1), transparent 34%), + linear-gradient(180deg, #ffffff, #f8fafc); +} + +[data-theme-mode="intelligent"] .workbench-ai-composer, +[data-theme-mode="intelligent"] .workbench-ai-composer--inline, +[data-theme-mode="intelligent"] .workbench-ai-answer-card { + border-radius: 12px; +} + @media (prefers-reduced-motion: reduce) { .workbench-ai-answer-markdown :deep(.ai-document-card) { animation: none; diff --git a/web/src/components/business/PersonalWorkbenchAiMode.vue b/web/src/components/business/PersonalWorkbenchAiMode.vue index 034d8ed..9d07177 100644 --- a/web/src/components/business/PersonalWorkbenchAiMode.vue +++ b/web/src/components/business/PersonalWorkbenchAiMode.vue @@ -22,3 +22,9 @@ const { + diff --git a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js index 494a041..ba026d0 100644 --- a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js +++ b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js @@ -258,7 +258,8 @@ export function usePersonalWorkbenchAiMode(props, emit) { resolveLatestInlineUserPrompt, scrollInlineConversationToBottom, sending, - toast + toast, + onApplicationActionCompleted: startModelPlannedNextTask }) const expenseFlow = useWorkbenchAiExpenseFlow({ @@ -710,6 +711,46 @@ export function usePersonalWorkbenchAiMode(props, emit) { return pendingMessage } + function buildModelPlannedNextTaskAction(remainingTasks = []) { + const tasks = Array.isArray(remainingTasks) ? remainingTasks : [] + const nextTask = tasks[0] + if (!nextTask || typeof nextTask !== 'object') { + return null + } + const taskType = String(nextTask.task_type || nextTask.taskType || '').trim() + const assignedAgent = String(nextTask.assigned_agent || nextTask.assignedAgent || '').trim() + const isApplication = taskType === 'expense_application' || assignedAgent === 'application_assistant' + const isReimbursement = taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant' + if (!isApplication && !isReimbursement) { + return null + } + const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {} + const flowId = isApplication ? 'travel_application' : 'travel_reimbursement' + const taskLabel = isApplication ? '出差申请' : '费用报销' + return { + label: `继续处理${taskLabel}`, + action_type: 'steward_continue_next_task', + payload: { + steward_confirm_flow: true, + flow_id: flowId, + steward_current_task: nextTask, + expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel', + expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费', + ontology_fields: ontologyFields, + original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(), + steward_remaining_tasks: tasks.slice(1) + } + } + } + + function startModelPlannedNextTask(remainingTasks = []) { + const nextTaskAction = buildModelPlannedNextTaskAction(remainingTasks) + if (!nextTaskAction) { + return + } + actionRouter.handleInlineSuggestedAction(nextTaskAction) + } + function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) { void applicationFlow.startAiApplicationPreview( travelApplicationRequest.expenseType, @@ -723,7 +764,10 @@ export function usePersonalWorkbenchAiMode(props, emit) { autoSubmit: travelApplicationRequest.autoSubmit, autoSaveDraft: travelApplicationRequest.autoSaveDraft, requestedSubmit: travelApplicationRequest.requestedSubmit, - submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation + submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation, + stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks, + onPreviewReadyForNextTask: startModelPlannedNextTask, + onApplicationActionCompleted: startModelPlannedNextTask } ) } @@ -741,7 +785,8 @@ export function usePersonalWorkbenchAiMode(props, emit) { autoSubmit: travelApplicationRequest.autoSubmit, autoSaveDraft: travelApplicationRequest.autoSaveDraft, requestedSubmit: travelApplicationRequest.requestedSubmit, - submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation + submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation, + stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks } } replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, { diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js index c91b57f..76f06ce 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js @@ -4,6 +4,8 @@ import { } from '../../services/aiApplicationPreviewActions.js' import { executeStewardAction } from '../../services/steward.js' import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js' +import { buildAiExpenseDraftPrefillValues } from '../../utils/aiExpenseDraftModel.js' +import { requiresApplicationBeforeReimbursement } from '../../views/scripts/travelReimbursementApplicationLinkModel.js' import { mergeComposerPrefill, resolveSuggestedActionPrefill @@ -82,6 +84,9 @@ export function useWorkbenchAiActionRouter({ } if (actionType === 'ai_application_confirm_intent') { aiExpenseDraft.value = null + const stewardRemainingTasks = Array.isArray(actionPayload.stewardRemainingTasks) + ? actionPayload.stewardRemainingTasks + : (Array.isArray(actionPayload.steward_remaining_tasks) ? actionPayload.steward_remaining_tasks : []) void applicationFlow.startAiApplicationPreview('travel', '差旅费', String(actionPayload.sourceText || '').trim(), { userMessage: String(actionPayload.sourceText || '').trim() || '确认发起出差申请', pushUserMessage: true, @@ -89,7 +94,20 @@ export function useWorkbenchAiActionRouter({ autoSubmit: Boolean(actionPayload.autoSubmit), autoSaveDraft: Boolean(actionPayload.autoSaveDraft), requestedSubmit: Boolean(actionPayload.requestedSubmit), - submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation) + submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation), + stewardRemainingTasks, + onPreviewReadyForNextTask: (remainingTasks = []) => { + const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks }) + if (nextTaskAction) { + handleInlineSuggestedAction(nextTaskAction) + } + }, + onApplicationActionCompleted: (remainingTasks = []) => { + const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks }) + if (nextTaskAction) { + handleInlineSuggestedAction(nextTaskAction) + } + } }) return } @@ -104,9 +122,21 @@ export function useWorkbenchAiActionRouter({ return } if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') { - const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel' - const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费' - expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, true) + const currentTask = actionPayload.steward_current_task || {} + const ontologyFields = currentTask.ontology_fields || currentTask.ontologyFields || actionPayload.ontology_fields || {} + const expenseType = String(actionPayload.expense_type || ontologyFields.expense_type || 'travel').trim() || 'travel' + const expenseTypeLabel = String(actionPayload.expense_type_label || ontologyFields.expense_type_label || '差旅费').trim() || '差旅费' + // 从 task ontology 解析报销语义(金额/时间/事由/地点),预填到报销草稿, + // 让 task2(如业务招待费 2000 元)的信息直接落到草稿,而不是丢失。 + const prefillValues = buildAiExpenseDraftPrefillValues(ontologyFields) + const needsApplicationLink = requiresApplicationBeforeReimbursement(expenseType) + const stewardRemainingTasks = Array.isArray(actionPayload.steward_remaining_tasks) + ? actionPayload.steward_remaining_tasks + : [] + expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, needsApplicationLink, { + prefillValues, + stewardRemainingTasks + }) return } if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') { @@ -176,7 +206,18 @@ export function useWorkbenchAiActionRouter({ if (actionType === 'ai_application_start_inline') { aiExpenseDraft.value = null - void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label) + // 多 task 推进:从 resolveAiExpenseApplicationLink "查不到申请单"分支过来的按钮, + // payload 里带 prefill_values 和 steward_remaining_tasks,这里透传给申请预览, + // 保证发起的申请单带着报销语义,且申请单做完后能继续 task2 报销流程。 + void expenseFlow.startAiApplicationPreviewFromAction({ + ...(action?.payload || {}), + expense_type: actionPayload.expense_type, + expense_type_label: actionPayload.expense_type_label, + carry_text: actionPayload.carry_text || actionPayload.original_message || action?.label, + steward_remaining_tasks: Array.isArray(actionPayload.steward_remaining_tasks) + ? actionPayload.steward_remaining_tasks + : [] + }, action?.label) return } @@ -398,6 +439,9 @@ export function useWorkbenchAiActionRouter({ const flowId = isApplication ? 'travel_application' : 'travel_reimbursement' const taskLabel = isApplication ? '出差申请' : '费用报销' const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {} + // 透传去掉当前 nextTask 之后的剩余 task 列表,保证 task2 完成后 task3 也能继续推进, + // 避免 3+ task 场景在 task2 处断链。 + const furtherRemainingTasks = remainingTasks.slice(1) return { label: `继续处理${taskLabel}`, description: `接下来处理${taskLabel}: ${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`, @@ -410,7 +454,8 @@ export function useWorkbenchAiActionRouter({ expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel', expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费', ontology_fields: ontologyFields, - original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim() + original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(), + steward_remaining_tasks: furtherRemainingTasks } } } diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js index ee3de19..cee1efa 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js @@ -85,7 +85,8 @@ export function useWorkbenchAiApplicationPreviewFlow({ resolveLatestInlineUserPrompt, scrollInlineConversationToBottom, sending, - toast + toast, + onApplicationActionCompleted = null }) { function isApplicationPreviewEstimatePending(message = {}) { return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview)) @@ -345,6 +346,9 @@ export function useWorkbenchAiApplicationPreviewFlow({ const flowId = isApplication ? 'travel_application' : 'travel_reimbursement' const taskLabel = isApplication ? '出差申请' : '费用报销' const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {} + // 透传去掉当前 nextTask 之后的剩余 task 列表,保证 task2 完成后 task3 也能继续推进, + // 避免 3+ task 场景在 task2 处断链。 + const furtherRemainingTasks = remainingTasks.slice(1) return { label: `继续处理${taskLabel}`, description: `接下来处理${taskLabel}:${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`, @@ -357,7 +361,8 @@ export function useWorkbenchAiApplicationPreviewFlow({ expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel', expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费', ontology_fields: ontologyFields, - original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim() + original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(), + steward_remaining_tasks: furtherRemainingTasks } } } @@ -458,6 +463,12 @@ export function useWorkbenchAiApplicationPreviewFlow({ targetMessage.suggestedActions = [] const detailActions = buildInlineApplicationDetailAction(draftPayload) const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage) + const shouldAutoContinueNextTask = Boolean( + nextTaskAction && + typeof onApplicationActionCompleted === 'function' && + Array.isArray(targetMessage.stewardRemainingTasks) && + targetMessage.stewardRemainingTasks.length + ) replaceInlineMessage( pendingMessage.id, createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), { @@ -466,11 +477,16 @@ export function useWorkbenchAiApplicationPreviewFlow({ streamStatus: 'completed', thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage)) }, - suggestedActions: nextTaskAction ? [...detailActions, nextTaskAction] : detailActions + suggestedActions: shouldAutoContinueNextTask + ? detailActions + : (nextTaskAction ? [...detailActions, nextTaskAction] : detailActions) }) ) persistCurrentConversation() scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + if (shouldAutoContinueNextTask) { + onApplicationActionCompleted(targetMessage.stewardRemainingTasks, targetMessage) + } return true } catch (error) { replaceInlineMessage( @@ -599,6 +615,12 @@ export function useWorkbenchAiApplicationPreviewFlow({ skipUserMessage: true, userText: options.userMessage || '保存草稿' }) + } else if ( + typeof options.onPreviewReadyForNextTask === 'function' && + Array.isArray(previewMessage.stewardRemainingTasks) && + previewMessage.stewardRemainingTasks.length + ) { + options.onPreviewReadyForNextTask(previewMessage.stewardRemainingTasks, previewMessage) } } catch (error) { replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', { diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiCommandIntents.js b/web/src/composables/workbenchAiMode/useWorkbenchAiCommandIntents.js index 05be506..5117794 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiCommandIntents.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiCommandIntents.js @@ -1,6 +1,8 @@ import { + buildWorkbenchDocumentCommandFollowupGuidance, buildWorkbenchDraftDeletionGuidance, isWorkbenchDraftDeletionIntent, + resolveLatestWorkbenchDocumentCommandContext, resolveLatestWorkbenchDraftPayload } from './workbenchAiCommandIntentModel.js' import { resolveWorkbenchIntentActionRoute } from './workbenchIntentActionPolicy.js' @@ -58,6 +60,9 @@ export function useWorkbenchAiCommandIntents({ if (!handlesWorkbenchCommand) { return false } + const documentCommandContext = route.nextStep === 'query_candidates' + ? resolveLatestWorkbenchDocumentCommandContext(conversationMessages.value, frame) + : null prepareInlineCommandConversation(cleanPrompt, entry) const draftPayload = frame?.targetMode === 'current_context' || legacyDraftDelete ? resolveLatestWorkbenchDraftPayload(conversationMessages.value) @@ -72,6 +77,16 @@ export function useWorkbenchAiCommandIntents({ return true } + if (route.nextStep === 'query_candidates' && documentCommandContext) { + const guidance = buildWorkbenchDocumentCommandFollowupGuidance(documentCommandContext, frame) + conversationMessages.value.push(createInlineMessage('assistant', guidance.content, { + suggestedActions: guidance.suggestedActions + })) + persistCurrentConversation() + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + return true + } + const queryPrompt = route.queryPrompt || frame?.normalizedQuery || '我的草稿单据' const pendingText = frame?.safetyLevel === 'confirm_required' ? '正在先筛选候选单据,不会直接执行删除或审核动作...' diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js index e7efc8b..320c8d1 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js @@ -9,6 +9,7 @@ import { } from '../../services/linkedReimbursementDraftJobs.js' import { applyAiExpenseAnswer, + buildAiExpenseDraftPrefillValues, buildAiExpenseStepPrompt, buildAiExpenseSummary, createAiExpenseDraft, @@ -113,6 +114,7 @@ export function useWorkbenchAiExpenseFlow({ suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [], draftPayload: options.draftPayload || null, linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null, + stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [], text: options.text || content }) replaceInlineMessage(messageId, nextMessage) @@ -323,7 +325,40 @@ export function useWorkbenchAiExpenseFlow({ scrollInlineConversationToBottom() } - function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) { + // 多 task 推进时,把当前报销流程后续要处理的剩余 task 挂在 draft 上, + // 这样关联申请单、发起申请单、生成报销草稿等子流程都能把 remaining tasks 透传下去, + // 保证 task2 完成后能继续 task3。draft 被清空时上下文也随之消失。 + function attachStewardRemainingTasks(draft, stewardRemainingTasks) { + if (!draft) { + return draft + } + const tasks = Array.isArray(stewardRemainingTasks) ? stewardRemainingTasks : [] + draft.stewardRemainingTasks = tasks + return draft + } + + function resolveStewardRemainingTasks(draft) { + const draftTasks = Array.isArray(draft?.stewardRemainingTasks) ? draft.stewardRemainingTasks : [] + return draftTasks.length ? draftTasks : null + } + + // 把 expenseType 解析成"发起 XX 申请"按钮里的 XX,避免对招待费也显示"出差申请"。 + function resolveRequiredApplicationLabel(expenseType, fallbackLabel = '') { + const normalized = String(expenseType || '').trim().toLowerCase() + if (normalized === 'meal' || normalized === 'entertainment') { + return '业务招待' + } + if (normalized === 'travel') { + return '出差' + } + const label = String(fallbackLabel || '').trim() + if (label) { + return label.replace(/费$/, '') + } + return '费用' + } + + function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options = {}) { if (!conversationStarted.value) { activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' }) } @@ -333,12 +368,25 @@ export function useWorkbenchAiExpenseFlow({ clearAiModeFiles() pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`) + const prefillValues = options.prefillValues && typeof options.prefillValues === 'object' + ? options.prefillValues + : null + const stewardRemainingTasks = Array.isArray(options.stewardRemainingTasks) + ? options.stewardRemainingTasks + : [] + if (requiresApplicationBeforeReimbursement) { - void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) + void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel, { + prefillValues, + stewardRemainingTasks + }) return } - const draft = createAiExpenseDraft(expenseType, expenseTypeLabel) + const draft = attachStewardRemainingTasks( + createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues), + stewardRemainingTasks + ) aiExpenseDraft.value = draft conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft))) persistCurrentConversation() @@ -351,7 +399,11 @@ export function useWorkbenchAiExpenseFlow({ assistantDraft.value = '' clearAiModeFiles() - const next = applyAiExpenseAnswer(aiExpenseDraft.value, answer, fileNames) + const currentDraft = aiExpenseDraft.value + const next = applyAiExpenseAnswer(currentDraft, answer, fileNames) + // applyAiExpenseAnswer 不会保留 draft 上的运行时上下文,这里手动透传 remaining tasks, + // 保证报销草稿收集完所有字段后,仍能拿到后续 task 列表用于推进 task3。 + attachStewardRemainingTasks(next, resolveStewardRemainingTasks(currentDraft)) aiExpenseDraft.value = next if (isAiExpenseDraftComplete(next)) { @@ -364,7 +416,14 @@ export function useWorkbenchAiExpenseFlow({ scrollInlineConversationToBottom() } - async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) { + async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel, options = {}) { + const prefillValues = options.prefillValues && typeof options.prefillValues === 'object' + ? options.prefillValues + : null + const stewardRemainingTasks = Array.isArray(options.stewardRemainingTasks) + ? options.stewardRemainingTasks + : [] + let claims = null try { claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS) @@ -377,18 +436,30 @@ export function useWorkbenchAiExpenseFlow({ } const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {}) - aiExpenseDraft.value = createAiExpenseDraft(expenseType, expenseTypeLabel) + // 即使后续可能被清空,也先把报销语义 + remaining tasks 上下文挂到 draft 上, + // 这样查不到申请单时仍能透传给"发起申请单"按钮,保证 task2 不丢失语义。 + const draft = attachStewardRemainingTasks( + createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues), + stewardRemainingTasks + ) + aiExpenseDraft.value = draft if (!candidates.length) { + // 查不到可关联申请单:不要让 task2 语义丢失。生成"发起申请单"按钮时, + // 按费用类型动态生成 label,带上 ontology 上下文 + remaining tasks, + // 让用户发起申请单后能回到 task2 报销流程(见 ai_application_start_inline 分支)。 + const applicationLabel = resolveRequiredApplicationLabel(expenseType, expenseTypeLabel) conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), { suggestedActions: [{ - label: '确认发起出差申请', + label: `确认发起${applicationLabel}申请`, description: '生成完整申请表,并预填已识别的时间、地点和事由', icon: 'mdi mdi-file-plus-outline', action_type: 'ai_application_start_inline', payload: { expense_type: expenseType, - expense_type_label: expenseTypeLabel + expense_type_label: expenseTypeLabel, + prefill_values: prefillValues || {}, + steward_remaining_tasks: stewardRemainingTasks } }] })) @@ -459,7 +530,8 @@ export function useWorkbenchAiExpenseFlow({ jobId, pendingMessageId, claimNo = '', - initialJob = null + initialJob = null, + stewardRemainingTasks = [] }) { const normalizedJobId = String(jobId || '').trim() if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) { @@ -479,13 +551,17 @@ export function useWorkbenchAiExpenseFlow({ const content = draftClaimNo ? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。` : `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。` + // 多 task 推进:报销草稿生成成功后,若还有剩余 task,补一个"继续处理"按钮。 + const nextTaskAction = buildExpenseDraftNextTaskAction(stewardRemainingTasks) replaceInlineAssistantMessage(pendingMessageId, content, { draftPayload, linkedReimbursementDraftJob: { ...currentJob, applicationClaimNo: claimNo }, - suggestedActions: buildLinkedDraftAction(draftPayload) + suggestedActions: nextTaskAction + ? [...buildLinkedDraftAction(draftPayload), nextTaskAction] + : buildLinkedDraftAction(draftPayload) }) aiExpenseDraft.value = null persistCurrentConversation() @@ -524,7 +600,9 @@ export function useWorkbenchAiExpenseFlow({ jobId: job.jobId, pendingMessageId: message.id, claimNo: job.applicationClaimNo, - initialJob: job + initialJob: job, + // 刷新恢复时从消息上读回 remaining tasks,保证报销完成后仍能补出"继续处理"按钮。 + stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : [] }).catch((error) => { replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), { linkedReimbursementDraftJob: { @@ -556,6 +634,38 @@ export function useWorkbenchAiExpenseFlow({ }] } + // 报销草稿生成成功后,若有剩余 task,生成"继续处理下一个任务"按钮。 + // 与 useWorkbenchAiApplicationPreviewFlow.buildApplicationPreviewNextTaskAction 同构, + // 但数据源是 draft 上透传过来的 stewardRemainingTasks,保证报销完成后 task3 也能推进。 + function buildExpenseDraftNextTaskAction(remainingTasks = []) { + const tasks = Array.isArray(remainingTasks) ? remainingTasks : [] + const nextTask = tasks[0] + if (!nextTask || !nextTask.task_type) { + return null + } + const taskType = String(nextTask.task_type || '').trim() + const isApplication = taskType === 'expense_application' + const flowId = isApplication ? 'travel_application' : 'travel_reimbursement' + const taskLabel = isApplication ? '出差申请' : '费用报销' + const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {} + return { + label: `继续处理${taskLabel}`, + description: `接下来处理${taskLabel}:${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`, + icon: isApplication ? 'mdi mdi-file-plus-outline' : 'mdi mdi-receipt-text-plus-outline', + action_type: 'steward_continue_next_task', + payload: { + steward_confirm_flow: true, + flow_id: flowId, + steward_current_task: nextTask, + expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel', + expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费', + ontology_fields: ontologyFields, + original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(), + steward_remaining_tasks: tasks.slice(1) + } + } + } + async function linkAiExpenseApplication(application = {}) { const draft = aiExpenseDraft.value || (() => { const resolved = resolveRequiredApplicationReimbursementType(application) @@ -577,9 +687,14 @@ export function useWorkbenchAiExpenseFlow({ stepKey: 'attachments' } aiExpenseDraft.value = linked + // 关联申请单时,保留 draft 上的 remaining tasks 上下文,透传给后续轮询, + // 这样报销草稿生成成功后能补出"继续处理 task3"按钮。 + const stewardRemainingTasks = resolveStewardRemainingTasks(linked) || [] const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, { pending: true, - suggestedActions: [] + suggestedActions: [], + // 把 remaining tasks 挂到 pending 消息上,刷新后 resume 轮询能读回并透传给 poll 成功分支。 + stewardRemainingTasks }) conversationMessages.value.push(pendingMessage) const pendingMessageId = pendingMessage.id @@ -607,7 +722,8 @@ export function useWorkbenchAiExpenseFlow({ jobId: normalizedJob.jobId, pendingMessageId, claimNo, - initialJob: normalizedJob + initialJob: normalizedJob, + stewardRemainingTasks }) } catch (error) { replaceInlineAssistantMessage( diff --git a/web/src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js b/web/src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js index edeb2d8..59a8ec0 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js @@ -1,3 +1,7 @@ +import { + parseAiDocumentDetailHref +} from '../../utils/aiDocumentDetailReference.js' + const DRAFT_DELETION_ACTION_PATTERN = /删除|删掉|删了|移除|作废|撤销/ const DRAFT_DELETION_TARGET_PATTERN = ( /草稿|这个单据|这张单据|当前单据|当前申请|当前报销|刚才保存的草稿|刚才的草稿|上面的单据|最近的单据|申请单|报销单/ @@ -22,11 +26,26 @@ const SUBMITTED_OR_FINAL_STATUS = new Set([ '已驳回', '已退回' ]) +const DOCUMENT_DETAIL_LINK_RE = /]*href="([^"]*#ai-open-document-detail:[^"]+)"[^>]*>(.*?)<\/a>/g +const DOCUMENT_COMMAND_ACTION_LABELS = { + delete: '删除', + approve: '审核通过', + reject: '驳回/退回' +} +const DOCUMENT_COMMAND_DETAIL_LABELS = { + delete: '进入详情确认删除', + approve: '进入详情确认审核', + reject: '进入详情确认驳回' +} function normalizeCompactText(value = '') { return String(value || '').replace(/\s+/g, '').trim() } +function normalizeText(value = '') { + return String(value || '').replace(/\s+/g, ' ').trim() +} + function normalizeDraftDocumentType(payload = {}, claimNo = '') { const rawType = String(payload.document_type || payload.documentType || payload.draft_type || payload.draftType || '').trim() if (/application|expense_application|申请/.test(rawType)) { @@ -77,6 +96,60 @@ function extractDraftPayloadFromSuggestedActions(message = {}) { return null } +function stripHtml(value = '') { + return normalizeText(String(value || '').replace(/<[^>]*>/g, '')) +} + +function normalizeDocumentCommandCandidate(detailReference = null, rawLabel = '') { + if (!detailReference || typeof detailReference !== 'object') { + return null + } + const claimId = String(detailReference.claimId || detailReference.claim_id || '').trim() + const claimNo = String(detailReference.claimNo || detailReference.claim_no || detailReference.reference || '').trim() + if (!claimId && !claimNo) { + return null + } + return { + claimId, + claimNo, + documentType: normalizeDraftDocumentType(detailReference, claimNo), + actionLabel: stripHtml(rawLabel) || '查看详情' + } +} + +function extractDocumentCommandCandidatesFromContent(content = '') { + const text = String(content || '') + const candidates = [] + const seen = new Set() + for (const match of text.matchAll(DOCUMENT_DETAIL_LINK_RE)) { + const candidate = normalizeDocumentCommandCandidate( + parseAiDocumentDetailHref(match[1]), + match[2] + ) + if (!candidate) { + continue + } + const key = `${candidate.claimId || ''}:${candidate.claimNo || ''}` + if (seen.has(key)) { + continue + } + seen.add(key) + candidates.push(candidate) + } + return candidates +} + +function canReuseDocumentCommandContext(content = '', commandFrame = {}) { + const action = String(commandFrame?.action || '').trim() + if (!['approve', 'reject'].includes(action)) { + return false + } + if (String(commandFrame?.safetyLevel || '').trim() !== 'confirm_required') { + return false + } + return /ai-document-card--approval-task|待我审核|待审|待审批|待审核|确认审核|进入详情确认审核/.test(String(content || '')) +} + export function isWorkbenchDraftDeletionIntent(prompt = '') { const compact = normalizeCompactText(prompt) if (!compact || !DRAFT_DELETION_ACTION_PATTERN.test(compact)) { @@ -104,6 +177,29 @@ export function resolveLatestWorkbenchDraftPayload(messages = []) { return null } +export function resolveLatestWorkbenchDocumentCommandContext(messages = [], commandFrame = {}) { + const safeMessages = Array.isArray(messages) ? messages : [] + for (const message of [...safeMessages].reverse()) { + if (String(message?.role || '').trim() !== 'assistant') { + continue + } + const content = String(message?.content || message?.text || '') + if (!canReuseDocumentCommandContext(content, commandFrame)) { + continue + } + const candidates = extractDocumentCommandCandidatesFromContent(content) + if (!candidates.length) { + continue + } + return { + sourceMessageId: String(message?.id || '').trim(), + action: String(commandFrame?.action || '').trim(), + candidates + } + } + return null +} + export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) { const claimNo = String(draftPayload.claimNo || draftPayload.claim_no || '').trim() const claimId = String(draftPayload.claimId || draftPayload.claim_id || '').trim() @@ -128,3 +224,42 @@ export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) { }] } } + +export function buildWorkbenchDocumentCommandFollowupGuidance(context = {}, commandFrame = {}) { + const action = String(commandFrame?.action || context?.action || '').trim() + const actionLabel = DOCUMENT_COMMAND_ACTION_LABELS[action] || '处理' + const detailLabel = DOCUMENT_COMMAND_DETAIL_LABELS[action] || '进入详情确认' + const candidates = Array.isArray(context?.candidates) ? context.candidates : [] + const visibleCandidates = candidates.slice(0, 8) + const candidateLines = visibleCandidates.map((candidate, index) => { + const reference = candidate.claimNo || candidate.claimId || `候选 ${index + 1}` + return `${index + 1}. ${reference}` + }) + const overflowText = candidates.length > visibleCandidates.length + ? `\n\n还有 ${candidates.length - visibleCandidates.length} 张候选未展示,请先补充更具体条件。` + : '' + return { + content: [ + '### 已接上刚才查询到的待审单据', + `您想继续执行 **${actionLabel}**。这属于高风险审批动作,我不会直接替您通过或驳回。`, + '请先从刚才的候选单据中选择一张,进入详情页核对风险、金额和审批节点后再确认。', + candidateLines.length ? candidateLines.join('\n') : '', + overflowText + ].filter(Boolean).join('\n\n'), + suggestedActions: visibleCandidates.map((candidate) => { + const reference = candidate.claimNo || candidate.claimId || '单据' + return { + label: `${detailLabel} ${reference}`, + description: '打开详情页核对后,再完成审批确认。', + icon: 'mdi mdi-open-in-new', + action_type: 'open_application_detail', + payload: { + claim_id: candidate.claimId, + claim_no: candidate.claimNo, + document_type: candidate.documentType, + command_action: action + } + } + }) + } +} diff --git a/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js b/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js index 4b921ad..6b3191b 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js @@ -143,13 +143,37 @@ function normalizeServerApplicationSteps(rawSteps = []) { return [...new Set(mappedSteps)] } +function resolveModelTasks(rawPlan = {}) { + return Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : [] +} + +function isModelTravelApplicationTask(task = {}) { + if (!task || typeof task !== 'object') { + return false + } + 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' +} + function findModelTravelApplicationTask(rawPlan = {}) { - const tasks = Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : [] - return tasks.find((task) => { + return resolveModelTasks(rawPlan).find(isModelTravelApplicationTask) || null +} + +function resolveModelRemainingTasks(rawPlan = {}, selectedTask = null) { + const tasks = resolveModelTasks(rawPlan) + const selectedIndex = tasks.findIndex((task) => task === selectedTask) + if (selectedIndex < 0) { + return [] + } + return tasks.slice(selectedIndex + 1).filter((task) => { + if (!task || typeof task !== 'object') { + return false + } 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 + return Boolean(taskType || assignedAgent) + }) } function resolveCandidateFlows(rawPlan = {}) { @@ -211,7 +235,7 @@ export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) { task.requested_action || task.requestedAction || rawPlan.requested_action || - rawPlan.requestedAction || + rawPlan.requestedAction || '' ).trim() || normalizePromptAction(prompt) const serverSteps = normalizeServerApplicationSteps(task.action_steps || task.actionSteps) @@ -226,7 +250,8 @@ export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) { missingFields: Array.isArray(task.missing_fields || task.missingFields) ? task.missing_fields || task.missingFields : [], - steps: serverSteps.length ? serverSteps : buildApplicationSteps(requestedAction) + steps: serverSteps.length ? serverSteps : buildApplicationSteps(requestedAction), + stewardRemainingTasks: resolveModelRemainingTasks(rawPlan, task) } } @@ -275,7 +300,7 @@ export function resolveExecutableTravelApplicationPlan(plan = null) { return null } const requestedSubmit = plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION) - return { + const request = { expenseType: 'travel', expenseTypeLabel: '差旅费', sourceText: String(plan.sourceText || '').trim(), @@ -285,6 +310,11 @@ export function resolveExecutableTravelApplicationPlan(plan = null) { requestedSubmit, submitRequiresConfirmation: requestedSubmit } + const stewardRemainingTasks = Array.isArray(plan.stewardRemainingTasks) ? plan.stewardRemainingTasks : [] + if (stewardRemainingTasks.length) { + request.stewardRemainingTasks = stewardRemainingTasks + } + return request } export function isLowConfidenceTravelApplicationPlan(plan = null) { diff --git a/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js b/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js index 49198da..4e6ba72 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js @@ -156,6 +156,9 @@ export function createWorkbenchAiMessageRuntime() { attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null), linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null), attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null), + // 多 task 推进上下文:申请预览/报销草稿消息上挂载剩余 task 列表, + // 刷新或消息重建后仍能继续推进,避免 task 链断裂。 + stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [], text: options.text || normalizedContent, createdAt: options.createdAt || Date.now() } @@ -175,6 +178,7 @@ export function createWorkbenchAiMessageRuntime() { attachmentAssociationJob: message.attachmentAssociationJob || null, linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null, attachmentOcrDetails: message.attachmentOcrDetails || null, + stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : [], text: message.text || message.content || '' }) } @@ -194,7 +198,8 @@ export function createWorkbenchAiMessageRuntime() { draftPayload: message.draftPayload || null, attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null), linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null), - attachmentOcrDetails: message.attachmentOcrDetails || null + attachmentOcrDetails: message.attachmentOcrDetails || null, + stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : [] } } diff --git a/web/src/services/aiApplicationPreviewActions.js b/web/src/services/aiApplicationPreviewActions.js index 3156790..9ee2a74 100644 --- a/web/src/services/aiApplicationPreviewActions.js +++ b/web/src/services/aiApplicationPreviewActions.js @@ -72,6 +72,9 @@ export function buildAiApplicationPreviewActionPayload({ : [] const draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId) const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT + const applicationEditableFields = Array.isArray(normalizedPreview.editableFields) + ? normalizedPreview.editableFields.map((field) => normalizeText(field)).filter(Boolean) + : [] return { source: 'user_message', @@ -107,6 +110,9 @@ export function buildAiApplicationPreviewActionPayload({ application_stage: 'expense_application', user_input_text: message, application_preview: normalizedPreview, + ...(applicationEditableFields.length + ? { application_editable_fields: applicationEditableFields } + : {}), ...(isSubmit ? {} : { diff --git a/web/src/utils/aiApplicationPrecheckModel.js b/web/src/utils/aiApplicationPrecheckModel.js index 568b169..81a834e 100644 --- a/web/src/utils/aiApplicationPrecheckModel.js +++ b/web/src/utils/aiApplicationPrecheckModel.js @@ -313,7 +313,9 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) { } lines.push( '', - '> **请先核对**:请先检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先处理或关联已有申请单,避免重复申请。', + '**后续行动建议**:', + '- 请检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;', + '- 若日期无误,请先处理或关联已有申请单,避免重复申请。', '', '我会先暂停本次申请表生成,不会开放保存草稿或提交入口。' ) @@ -323,18 +325,17 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) { const normalized = normalizeApplicationPreview(preview) const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : [] const missingText = missingFields.length ? missingFields.join('、') : '暂无' - const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '**预算管理者审核提示**' : '**预算与审批影响**' - const overlapPrefix = precheck?.overlap?.status === 'warning' ? '**时间重叠提醒**' : '**单据重叠核查**' + const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '预算管理者审核提示' : '预算与审批影响' + const overlapPrefix = precheck?.overlap?.status === 'warning' ? '时间重叠提醒' : '单据重叠核查' const lines = [ '### 出差申请表草稿已生成', '', '**我已完成发起前的单据与预算预审**,并为您生成一张完整的出差申请表。', '', - `> ${overlapPrefix}:${precheck?.overlap?.summary || '已完成已有单据核查。'}`, - '', - `> ${budgetPrefix}:${precheck?.budget?.summary || '已完成预算影响评估。'}`, - '', - `> **仍需补充**:${missingText}`, + '**发起前预审结果**:', + `- **${overlapPrefix}**:${precheck?.overlap?.summary || '已完成已有单据核查。'}`, + `- **${budgetPrefix}**:${precheck?.budget?.summary || '已完成预算影响评估。'}`, + `- **仍需补充**:${missingText}`, '', '请直接点击表格中的字段补充或修改;费用测算会根据地点、天数和出行方式自动更新。' ] @@ -363,14 +364,16 @@ export function buildAiApplicationSubmitConflictMessage(preview = {}, precheck = '', `> **相同日期提醒**:${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`, '', - `> **本次申请时间**:${currentRangeText}`, + `**本次申请时间**:${currentRangeText}`, ] if (matchTable) { lines.push('', matchTable) } lines.push( '', - '> **请先核对**:请先核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先查看或处理已有申请单,避免重复申请。', + '**后续行动建议**:', + '- 请核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;', + '- 若日期无误,请先查看或处理已有申请单,避免重复申请。', '', '我会先暂停本次提交,不会生成新的审批流。' ) diff --git a/web/src/utils/aiExpenseDraftModel.js b/web/src/utils/aiExpenseDraftModel.js index 76e4fc9..d7a26d7 100644 --- a/web/src/utils/aiExpenseDraftModel.js +++ b/web/src/utils/aiExpenseDraftModel.js @@ -26,13 +26,59 @@ export function getAiExpenseSteps() { return DEFAULT_FIELD_STEPS } -export function createAiExpenseDraft(expenseType, expenseTypeLabel) { +// 将 task 的 ontology_fields 映射到报销草稿字段。 +// 只映射草稿能识别的字段(amount/time_range/reason/location),未知字段忽略。 +export function buildAiExpenseDraftPrefillValues(ontologyFields = {}) { + const source = ontologyFields || {} + const values = {} + const amount = normalizeAnswer( + source.amount || source.application_amount || source.applicationAmount || source.estimated_amount + ) + if (amount) { + values.amount = amount + } + const timeRange = normalizeAnswer( + source.time_range || source.business_time || source.application_business_time || source.time + ) + if (timeRange) { + values.time_range = timeRange + } + const reason = normalizeAnswer(source.reason || source.application_reason || source.title || source.summary) + if (reason) { + values.reason = reason + } + const location = normalizeAnswer(source.location || source.application_location) + if (location) { + values.location = location + } + return values +} + +// 根据已填值推进 stepKey 到第一个未填字段,全部填满则到 summary。 +function resolveInitialStepKey(values = {}) { + for (const step of DEFAULT_FIELD_STEPS) { + if (!normalizeAnswer(values[step.key])) { + return step.key + } + } + return SUMMARY_STEP_KEY +} + +export function createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues = {}) { + const safePrefill = prefillValues && typeof prefillValues === 'object' ? prefillValues : {} + const values = {} + for (const step of DEFAULT_FIELD_STEPS) { + const value = normalizeAnswer(safePrefill[step.key]) + if (value) { + values[step.key] = value + } + } return { expenseType: normalizeAnswer(expenseType), expenseTypeLabel: normalizeAnswer(expenseTypeLabel), applicationClaim: null, - values: {}, - stepKey: DEFAULT_FIELD_STEPS[0].key + values, + stepKey: resolveInitialStepKey(values) } } diff --git a/web/src/utils/aiWorkbenchConversationStore.js b/web/src/utils/aiWorkbenchConversationStore.js index 83570da..99aebcb 100644 --- a/web/src/utils/aiWorkbenchConversationStore.js +++ b/web/src/utils/aiWorkbenchConversationStore.js @@ -263,7 +263,9 @@ function normalizeMessage(message = {}) { streamStatus: safeString(message.stewardPlan.streamStatus) || 'completed' } : null, - suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [] + suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], + // 保留多 task 推进上下文,刷新后申请预览/报销草稿消息仍能拿到剩余 task 列表。 + stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : [] } } diff --git a/web/src/utils/expenseApplicationPreview.js b/web/src/utils/expenseApplicationPreview.js index 38c60a2..332aea2 100644 --- a/web/src/utils/expenseApplicationPreview.js +++ b/web/src/utils/expenseApplicationPreview.js @@ -292,8 +292,20 @@ export function normalizeApplicationPreview(preview = {}) { ...resolveApplicationValidationIssues(fields), ...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview) ] + const editableFields = Array.isArray(preview?.editableFields) + ? preview.editableFields + : Array.isArray(preview?.editable_fields) + ? preview.editable_fields + : null return { ...preview, + ...(editableFields + ? { + editableFields: editableFields + .map((field) => String(field || '').trim()) + .filter(Boolean) + } + : {}), fields, missingFields, validationIssues, @@ -301,6 +313,37 @@ export function normalizeApplicationPreview(preview = {}) { } } +function resolveApplicationPreviewEditableFields(preview = {}) { + const source = Array.isArray(preview?.editableFields) + ? preview.editableFields + : Array.isArray(preview?.editable_fields) + ? preview.editable_fields + : null + if (!Array.isArray(source)) { + return null + } + const fields = new Set( + source + .map((field) => String(field || '').trim()) + .filter(Boolean) + ) + if (fields.has('time')) { + fields.add('time_return') + } + return fields +} + +function isApplicationPreviewFieldEditable(preview = {}, item = {}, rowKey = '') { + if (item.editable === false) { + return false + } + const editableFields = resolveApplicationPreviewEditableFields(preview) + if (!editableFields) { + return true + } + return editableFields.has(rowKey) +} + export function applyApplicationBusinessTimeContext(preview = {}, businessTimeContext = null) { if (!businessTimeContext || typeof businessTimeContext !== 'object') { return normalizeApplicationPreview(preview) @@ -394,7 +437,7 @@ export function buildApplicationPreviewRows(preview = {}) { ...item, label: '出发时间', value: tripDates.startDate || '待补充', - editable: item.editable !== false, + editable: isApplicationPreviewFieldEditable(normalized, item, 'time'), highlight: Boolean(item.highlight), missing }, @@ -402,7 +445,7 @@ export function buildApplicationPreviewRows(preview = {}) { key: 'time_return', label: '返回时间', value: tripDates.endDate || '待补充', - editable: item.editable !== false, + editable: isApplicationPreviewFieldEditable(normalized, item, 'time_return'), highlight: Boolean(item.highlight), missing } @@ -415,7 +458,7 @@ export function buildApplicationPreviewRows(preview = {}) { ...item, label: resolveApplicationFieldLabel(item, fields), value, - editable: item.editable !== false, + editable: isApplicationPreviewFieldEditable(normalized, item, item.key), highlight: Boolean(item.highlight), missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue) }] @@ -484,6 +527,10 @@ export function buildApplicationTemplatePreview(currentUser = {}) { export function buildLocalApplicationPreviewMessage(preview) { const normalized = normalizeApplicationPreview(preview) const modelReviewStatus = String(normalized.modelReviewStatus || '').trim() + const editMode = Boolean(normalized.applicationEditMode || normalized.application_edit_mode) + if (editMode) { + return '我已载入原申请信息。请只修改事由、时间、地点和出行方式;职级、负责人、标准和费用会按规则带入或重新测算。' + } return [ modelReviewStatus === 'completed' ? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。' diff --git a/web/tests/ai-application-preview-actions.test.mjs b/web/tests/ai-application-preview-actions.test.mjs index 4094729..fc25b77 100644 --- a/web/tests/ai-application-preview-actions.test.mjs +++ b/web/tests/ai-application-preview-actions.test.mjs @@ -95,9 +95,62 @@ async function testSaveDraftActionUsesFastPreviewEndpoint() { assert.equal(body.context_json.application_stage, 'expense_application') } +async function testEditDraftActionCarriesClaimAndEditableFields() { + let capturedOptions = null + + global.fetch = async (_url, options) => { + capturedOptions = options + return { + ok: true, + async json() { + return { + status: 'succeeded', + result: { + draft_payload: { + claim_id: 'claim-edit-application', + claim_no: 'AP-20260620-EDIT', + status: 'draft', + approval_stage: '待提交' + } + } + } + } + } + } + + await runAiApplicationPreviewAction({ + actionType: AI_APPLICATION_ACTION_SAVE_DRAFT, + applicationPreview: { + applicationEditMode: true, + editableFields: ['reason', 'time', 'location', 'transportMode'], + fields: { + applicationType: '差旅费用申请', + time: '2026-07-01 至 2026-07-03', + location: '北京', + reason: '项目实施', + days: '3天', + transportMode: '火车', + amount: '1000元' + } + }, + currentUser: { username: 'zhangsan@example.com', name: '张三' }, + draftPayload: { + claim_id: 'claim-edit-application', + claim_no: 'AP-20260620-EDIT', + status: 'returned' + } + }) + + const body = JSON.parse(capturedOptions.body) + assert.equal(body.context_json.application_edit_claim_id, 'claim-edit-application') + assert.equal(body.context_json.application_edit_mode, true) + assert.deepEqual(body.context_json.application_editable_fields, ['reason', 'time', 'location', 'transportMode']) +} + async function run() { await testSubmitActionUsesFastPreviewEndpoint() await testSaveDraftActionUsesFastPreviewEndpoint() + await testEditDraftActionCarriesClaimAndEditableFields() console.log('ai-application-preview-actions tests passed') } diff --git a/web/tests/ai-expense-draft-model.test.mjs b/web/tests/ai-expense-draft-model.test.mjs index a90653a..a4262a1 100644 --- a/web/tests/ai-expense-draft-model.test.mjs +++ b/web/tests/ai-expense-draft-model.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test' import { applyAiExpenseAnswer, + buildAiExpenseDraftPrefillValues, buildAiExpenseStepPrompt, buildAiExpenseSummary, createAiExpenseDraft, @@ -71,3 +72,41 @@ test('summary lists every filled field and the linked application', () => { assert.match(summary, /AP-202606-001/) assert.match(summary, /85元/) }) + +test('buildAiExpenseDraftPrefillValues maps task ontology fields onto draft fields', () => { + const values = buildAiExpenseDraftPrefillValues({ + expense_type: 'meal', + amount: '2000元', + time_range: '昨天', + reason: '客户招待', + location: '上海', + unrelated_field: 'ignore me' + }) + assert.equal(values.amount, '2000元') + assert.equal(values.time_range, '昨天') + assert.equal(values.reason, '客户招待') + assert.equal(values.location, '上海') + assert.equal(values.unrelated_field, undefined) +}) + +test('createAiExpenseDraft with prefillValues skips already filled steps', () => { + const draft = createAiExpenseDraft('meal', '业务招待费', { + amount: '2000元', + reason: '客户招待' + }) + // reason 已填,跳到下一个未填字段 time_range + assert.equal(draft.values.amount, '2000元') + assert.equal(draft.values.reason, '客户招待') + assert.equal(draft.stepKey, 'time_range') +}) + +test('createAiExpenseDraft with all prefillValues lands on summary', () => { + const draft = createAiExpenseDraft('meal', '业务招待费', { + reason: '客户招待', + time_range: '昨天', + location: '上海', + amount: '2000元', + attachments: '稍后上传' + }) + assert.ok(isAiExpenseDraftComplete(draft)) +}) diff --git a/web/tests/ai-workbench-conversation-store.test.mjs b/web/tests/ai-workbench-conversation-store.test.mjs index 8ceb9a6..0d49a3e 100644 --- a/web/tests/ai-workbench-conversation-store.test.mjs +++ b/web/tests/ai-workbench-conversation-store.test.mjs @@ -67,3 +67,37 @@ test('AI workbench conversation store persists scoped history for sidebar sessio assert.equal(nextHistory.length, 1) assert.equal(nextHistory[0].id, 'conv-first') }) + +test('AI workbench conversation store preserves stewardRemainingTasks on messages', () => { + installLocalStorageMock() + const user = { username: 'caoxiaozhu' } + const remainingTasks = [ + { task_id: 't2', task_type: 'reimbursement', ontology_fields: { expense_type: 'meal' } } + ] + + saveAiWorkbenchConversation(user, { + id: 'conv-multi-task', + title: '出差+招待费', + updatedAt: Date.now(), + messages: [ + { id: 'u1', role: 'user', content: '出差+报销招待费' }, + { + id: 'a1', + role: 'assistant', + content: '申请草稿已保存', + stewardRemainingTasks: remainingTasks + } + ] + }) + + const history = loadAiWorkbenchConversationHistory(user) + assert.equal(history.length, 1) + // 历史摘要不要求保留 stewardRemainingTasks,但加载完整会话时消息上应保留。 + // 这里通过 saveAiWorkbenchConversation 的往返确认 normalizeMessage 不会丢弃该字段。 + const stored = JSON.parse(globalThis.window.localStorage.getItem( + 'x-financial:workbench-ai-conversations:caoxiaozhu' + )) + const conversation = stored.find((item) => item.id === 'conv-multi-task') + const persistedMessage = conversation.messages.find((m) => m.id === 'a1') + assert.deepEqual(persistedMessage.stewardRemainingTasks, remainingTasks) +}) diff --git a/web/tests/expense-application-fast-preview.test.mjs b/web/tests/expense-application-fast-preview.test.mjs index 7630a94..e9bf1d2 100644 --- a/web/tests/expense-application-fast-preview.test.mjs +++ b/web/tests/expense-application-fast-preview.test.mjs @@ -362,6 +362,40 @@ test('travel application submit can continue with conversational planning recomm assert.match(recommendation, /AP-202606030001-ABCDE123/) }) +test('application edit preview only allows reason time location and transport changes', () => { + const preview = normalizeApplicationPreview({ + sourceText: '修改申请', + applicationEditMode: true, + editableFields: ['reason', 'time', 'location', 'transportMode'], + fields: { + applicationType: '差旅费用申请', + applicant: '李文静', + grade: 'P5', + department: '财务部', + position: '财务分析师', + managerName: '王强', + time: '2026-05-25 至 2026-05-28', + location: '上海', + reason: '客户现场项目支持', + days: '4天', + transportMode: '火车', + lodgingDailyCap: '450元/天', + subsidyDailyCap: '100元/天', + transportPolicy: '按规则测算', + policyEstimate: '交通 300元 + 住宿 1800元 + 补贴 400元 = 2500元', + amount: '2500元' + } + }) + + const rows = buildApplicationPreviewRows(preview) + const editableKeys = rows.filter((row) => row.editable).map((row) => row.key) + assert.deepEqual(editableKeys, ['time', 'time_return', 'location', 'reason', 'transportMode']) + assert.equal(rows.find((row) => row.key === 'applicationType')?.editable, false) + assert.equal(rows.find((row) => row.key === 'days')?.editable, false) + assert.equal(rows.find((row) => row.key === 'amount')?.editable, false) + assert.match(buildLocalApplicationPreviewMessage(preview), /只修改事由、时间、地点和出行方式/) +}) + test('application preview renders ordered editable rows and submit text uses edited values', () => { const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', { name: '李文静', diff --git a/web/tests/workbench-ai-action-router.test.mjs b/web/tests/workbench-ai-action-router.test.mjs index 8df9444..4e22669 100644 --- a/web/tests/workbench-ai-action-router.test.mjs +++ b/web/tests/workbench-ai-action-router.test.mjs @@ -93,6 +93,65 @@ test('workbench steward application confirmation opens inline application previe assert.equal(preview.fields.transportMode, '') }) +test('workbench low-confidence application confirmation forwards remaining tasks', () => { + let previewCall = null + const remainingTasks = [{ + task_id: 'task-reimbursement-2', + task_type: 'reimbursement', + assigned_agent: 'reimbursement_assistant', + ontology_fields: { + expense_type: 'entertainment', + expense_type_label: '业务招待费', + amount: '2000元', + time_range: '2026-06-25', + reason: '业务招待' + } + }] + const router = useWorkbenchAiActionRouter({ + aiExpenseDraft: { value: null }, + applicationFlow: { + isInlineSuggestedActionDisabled: () => false, + executeInlineApplicationPreviewAction: () => {}, + startAiApplicationPreview: (...args) => { + previewCall = args + } + }, + assistantDraft: { value: '' }, + attachmentFlow: { + confirmAiAttachmentAssociation: () => {} + }, + emit: () => {}, + expenseFlow: { + linkAiExpenseApplication: () => {}, + pushInlineExpenseSceneSelectionPrompt: () => {}, + startAiApplicationPreviewFromAction: () => {}, + startAiExpenseDraft: () => {} + }, + focusAiModeInput: () => {}, + hasInlineAttachmentOcrDetails: () => false, + resolveLatestInlineUserPrompt: () => '', + selectedFiles: { value: [] }, + startInlineConversation: () => {}, + toast: () => {}, + toggleInlineAttachmentOcrDetails: () => {} + }) + + router.handleInlineSuggestedAction({ + label: '确认发起出差申请', + action_type: 'ai_application_confirm_intent', + payload: { + sourceText: '2月20-23日去上海出差3天,服务国网服务器部署,并且报销昨天的业务招待费2000元', + ontologyFields: { location: '上海', reason: '服务国网服务器部署' }, + stewardRemainingTasks: remainingTasks + } + }) + + assert.ok(previewCall, 'startAiApplicationPreview 应被调用') + assert.deepEqual(previewCall[3].stewardRemainingTasks, remainingTasks) + assert.equal(typeof previewCall[3].onPreviewReadyForNextTask, 'function') + assert.equal(typeof previewCall[3].onApplicationActionCompleted, 'function') +}) + test('workbench reimbursement skip link action opens new reimbursement flow', () => { let sceneSelectionPayload = null let fallbackConversationStarted = false @@ -389,3 +448,70 @@ test('workbench steward executable submit action runs precheck before submit and globalThis.fetch = originalFetch } }) + +test('workbench steward continue-next-task reimbursement prefills ontology and forwards remaining tasks', () => { + let expenseDraftCall = null + const router = useWorkbenchAiActionRouter({ + aiExpenseDraft: { value: null }, + applicationFlow: { + isInlineSuggestedActionDisabled: () => false, + executeInlineApplicationPreviewAction: () => {} + }, + assistantDraft: { value: '' }, + attachmentFlow: { + confirmAiAttachmentAssociation: () => {} + }, + emit: () => {}, + expenseFlow: { + linkAiExpenseApplication: () => {}, + promptAiReimbursementDraftContinuation: () => {}, + promptStandaloneReimbursementDraftCreation: () => {}, + pushInlineExpenseSceneSelectionPrompt: () => {}, + startAiApplicationPreviewFromAction: () => {}, + startAiExpenseDraft: (expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options) => { + expenseDraftCall = { expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options } + }, + startAiReimbursementAssociationGate: () => {} + }, + focusAiModeInput: () => {}, + hasInlineAttachmentOcrDetails: () => false, + resolveLatestInlineUserPrompt: () => '', + selectedFiles: { value: [] }, + startInlineConversation: () => {}, + toast: () => {}, + toggleInlineAttachmentOcrDetails: () => {} + }) + + router.handleInlineSuggestedAction({ + label: '继续处理费用报销', + action_type: 'steward_continue_next_task', + payload: { + steward_confirm_flow: true, + flow_id: 'travel_reimbursement', + steward_current_task: { + task_id: 'task-meal-1', + task_type: 'reimbursement', + title: '业务招待费报销', + summary: '报销昨天业务招待费2000元', + ontology_fields: { + expense_type: 'meal', + expense_type_label: '业务招待费', + amount: '2000元', + time_range: '昨天', + reason: '客户招待' + } + }, + steward_remaining_tasks: [] + } + }) + + // task2(招待费报销)启动时:费用类型正确、语义预填到草稿、remaining tasks 透传 + assert.ok(expenseDraftCall, 'startAiExpenseDraft 应被调用') + assert.equal(expenseDraftCall.expenseType, 'meal') + assert.equal(expenseDraftCall.expenseTypeLabel, '业务招待费') + assert.equal(expenseDraftCall.requiresApplicationBeforeReimbursement, true) + assert.equal(expenseDraftCall.options.prefillValues.amount, '2000元') + assert.equal(expenseDraftCall.options.prefillValues.reason, '客户招待') + assert.equal(expenseDraftCall.options.prefillValues.time_range, '昨天') + assert.deepEqual(expenseDraftCall.options.stewardRemainingTasks, []) +}) diff --git a/web/tests/workbench-ai-command-intent-model.test.mjs b/web/tests/workbench-ai-command-intent-model.test.mjs index c4f2571..0324fb2 100644 --- a/web/tests/workbench-ai-command-intent-model.test.mjs +++ b/web/tests/workbench-ai-command-intent-model.test.mjs @@ -4,8 +4,10 @@ import test from 'node:test' import { fileURLToPath } from 'node:url' import { + buildWorkbenchDocumentCommandFollowupGuidance, buildWorkbenchDraftDeletionGuidance, isWorkbenchDraftDeletionIntent, + resolveLatestWorkbenchDocumentCommandContext, resolveLatestWorkbenchDraftPayload } from '../src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js' @@ -87,8 +89,45 @@ test('workbench draft deletion guidance opens detail instead of deleting directl assert.equal(guidance.suggestedActions[0].payload.claim_no, 'ALATEST1') }) +test('workbench command intent reuses previous approval candidates for follow-up approval command', () => { + const context = resolveLatestWorkbenchDocumentCommandContext([ + { + role: 'assistant', + content: [ + '### 已查询到相关单据', + '', + '
', + '查看详情', + '
', + '
', + '查看详情', + '
' + ].join('\n') + } + ], { action: 'approve', safetyLevel: 'confirm_required' }) + + assert.equal(context?.candidates.length, 2) + assert.deepEqual(context.candidates[0], { + claimId: 'approval-1', + claimNo: 'AP-APPROVAL-001', + documentType: 'application', + actionLabel: '查看详情' + }) + + const guidance = buildWorkbenchDocumentCommandFollowupGuidance(context, { action: 'approve' }) + assert.match(guidance.content, /已接上刚才查询到的待审单据/) + assert.match(guidance.content, /AP-APPROVAL-001/) + assert.match(guidance.content, /RE-APPROVAL-002/) + assert.equal(guidance.suggestedActions.length, 2) + assert.equal(guidance.suggestedActions[0].action_type, 'open_application_detail') + assert.equal(guidance.suggestedActions[0].payload.claim_id, 'approval-1') + assert.equal(guidance.suggestedActions[0].payload.command_action, 'approve') +}) + test('workbench draft deletion intent is wired before draft slot continuation', () => { assert.match(commandIntentsScript, /isWorkbenchDraftDeletionIntent/) + assert.match(commandIntentsScript, /resolveLatestWorkbenchDocumentCommandContext/) + assert.match(commandIntentsScript, /buildWorkbenchDocumentCommandFollowupGuidance/) assert.match(commandIntentsScript, /function handleInlineDraftDeletionIntent\(cleanPrompt, entry = \{\}\)/) assert.match(commandIntentsScript, /resolveLatestWorkbenchDraftPayload\(conversationMessages\.value\)/) assert.match(commandIntentsScript, /buildWorkbenchDraftDeletionGuidance\(draftPayload\)/) diff --git a/web/tests/workbench-ai-intent-planner-model.test.mjs b/web/tests/workbench-ai-intent-planner-model.test.mjs index 45b7566..cf501aa 100644 --- a/web/tests/workbench-ai-intent-planner-model.test.mjs +++ b/web/tests/workbench-ai-intent-planner-model.test.mjs @@ -79,6 +79,48 @@ test('workbench AI intent planner normalizes model travel application submit pla }) }) +test('workbench AI intent planner keeps reimbursement task after first application task', () => { + const reimbursementTask = { + task_id: 'task-reimbursement-2', + task_type: 'reimbursement', + assigned_agent: 'reimbursement_assistant', + title: '业务招待费报销', + summary: '报销昨天的业务招待费 2000 元', + requested_action: 'preview', + confidence: 0.9, + ontology_fields: { + expense_type: 'entertainment', + expense_type_label: '业务招待费', + time_range: '2026-06-25', + amount: '2000元', + reason: '业务招待' + }, + missing_fields: [] + } + const plan = normalizeWorkbenchAiIntentPlan({ + planning_source: 'llm_function_call', + tasks: [{ + task_id: 'task-application-1', + task_type: 'expense_application', + assigned_agent: 'application_assistant', + requested_action: 'preview', + confidence: 0.93, + ontology_fields: { + expense_type: 'travel', + time_range: '2026-02-20 至 2026-02-23', + location: '上海', + reason: '服务国网服务器部署' + }, + missing_fields: ['transport_mode'] + }, reimbursementTask] + }, { + prompt: '2月20-23日去上海出差3天,服务国网服务器部署,并且报销昨天的业务招待费2000元' + }) + + assert.deepEqual(plan.stewardRemainingTasks, [reimbursementTask]) + assert.deepEqual(resolveExecutableTravelApplicationPlan(plan).stewardRemainingTasks, [reimbursementTask]) +}) + test('workbench AI intent planner prefers server action steps when present', () => { const plan = normalizeWorkbenchAiIntentPlan({ planning_source: 'llm_function_call', @@ -304,7 +346,12 @@ test('workbench AI mode asks steward model plan before fallback execution', () = assert.match(personalWorkbenchAiModeScript, /requestedSubmit:\s*travelApplicationRequest\.requestedSubmit/) assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/) assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/) + assert.match(personalWorkbenchAiModeScript, /stewardRemainingTasks:\s*travelApplicationRequest\.stewardRemainingTasks/) + assert.match(personalWorkbenchAiModeScript, /onPreviewReadyForNextTask:\s*startModelPlannedNextTask/) + assert.match(personalWorkbenchAiModeScript, /onApplicationActionCompleted:\s*startModelPlannedNextTask/) assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/) + assert.match(applicationPreviewFlowScript, /options\.onPreviewReadyForNextTask/) + assert.match(applicationPreviewFlowScript, /onApplicationActionCompleted\(\s*targetMessage\.stewardRemainingTasks/) assert.doesNotMatch(applicationPreviewFlowScript, /options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit/) assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/) assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)