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/)