feat(web): AI 工作台多 task 串行推进与会话适配

- useWorkbenchAiApplicationPreviewFlow/useWorkbenchAiActionRouter/useWorkbenchAiCommandIntents 支持 task1 完成后自动推进 task2,确认按钮直接拉起申请预览,草稿/提交成功后继续推进下一 task
- workbenchAiIntentPlannerModel/workbenchAiMessageModel/workbenchAiCommandIntentModel 适配多 task 意图规划与消息结构
- aiApplicationPreviewActions/aiApplicationPrecheckModel/aiExpenseDraftModel/aiWorkbenchConversationStore 草稿与会话存储适配
- PersonalWorkbenchAiMode 与样式适配,更新 preview-actions/expense-draft/conversation-store/fast-preview/action-router/command-intent/intent-planner 测试
This commit is contained in:
caoxiaozhu
2026-06-26 22:42:23 +08:00
parent 5753899eb3
commit c4b5fcc067
22 changed files with 1171 additions and 144 deletions

View File

@@ -1274,71 +1274,71 @@
} }
.workbench-ai-answer-markdown :deep(li::marker) { .workbench-ai-answer-markdown :deep(li::marker) {
color: #2563eb; color: #64748b;
font-weight: 850; font-weight: 600;
} }
.workbench-ai-answer-markdown :deep(strong) { .workbench-ai-answer-markdown :deep(strong) {
color: #0f172a; color: #0f172a;
font-weight: 850; font-weight: 600;
} }
.workbench-ai-answer-markdown :deep(hr) { .workbench-ai-answer-markdown :deep(hr) {
margin: 26px 0; margin: 26px 0;
border: 0; border: 0;
border-top: 1px solid rgba(226, 232, 240, 0.9); border-top: 1px solid #e2e8f0;
} }
.workbench-ai-answer-markdown :deep(blockquote) { .workbench-ai-answer-markdown :deep(blockquote) {
margin: 18px 0 0; margin: 18px 0 0;
padding: 14px 16px; padding: 14px 16px;
border-left: 3px solid rgba(37, 99, 235, 0.5); border-left: 3px solid #cbd5e1;
border-radius: 12px; border-radius: 8px;
background: rgba(239, 246, 255, 0.62); background: #f8fafc;
color: #475569; color: #334155;
} }
.workbench-ai-answer-markdown :deep(.ai-html-callout) { .workbench-ai-answer-markdown :deep(.ai-html-callout) {
margin: 0; margin: 0;
padding: 14px 16px; padding: 14px 16px;
border-left: 3px solid rgba(37, 99, 235, 0.5); border-left: 3px solid #cbd5e1;
border-radius: 12px; border-radius: 8px;
background: rgba(239, 246, 255, 0.62); background: #f8fafc;
color: #475569; color: #334155;
} }
.workbench-ai-answer-markdown :deep(.ai-html-focus-grid) { .workbench-ai-answer-markdown :deep(.ai-html-focus-grid) {
display: grid; display: grid;
gap: 0; gap: 0;
margin: 2px 0 18px; margin: 2px 0 18px;
padding-left: 22px; padding-left: 20px;
border-left: 3px solid rgba(96, 165, 250, 0.66); border-left: 3px solid #cbd5e1;
} }
.workbench-ai-answer-markdown :deep(.ai-html-focus-card) { .workbench-ai-answer-markdown :deep(.ai-html-focus-card) {
padding: 11px 0 16px; padding: 8px 0 12px;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
} }
.workbench-ai-answer-markdown :deep(.ai-html-focus-card + .ai-html-focus-card) { .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) { .workbench-ai-answer-markdown :deep(.ai-html-focus-label) {
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
color: #1d4ed8; color: #475569;
font-size: 15px; font-size: 14px;
font-weight: 900; font-weight: 600;
} }
.workbench-ai-answer-markdown :deep(.ai-html-focus-card p) { .workbench-ai-answer-markdown :deep(.ai-html-focus-card p) {
color: #475569; color: #1e293b;
font-size: 16px; font-size: 15px;
font-weight: 650; font-weight: 500;
line-height: 1.72; line-height: 1.6;
} }
.workbench-ai-answer-markdown :deep(.ai-html-steps), .workbench-ai-answer-markdown :deep(.ai-html-steps),
@@ -1366,9 +1366,9 @@
padding-top: 1px; padding-top: 1px;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
color: #1d4ed8; color: #64748b;
font-size: 17px; font-size: 15px;
font-weight: 900; font-weight: 600;
line-height: 1.45; line-height: 1.45;
} }
@@ -1482,34 +1482,27 @@
} }
.workbench-ai-answer-markdown :deep(.ai-document-card) { .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; position: relative;
display: grid; display: grid;
gap: 0; gap: 0;
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
border: 0; border: 1px solid #e2e8f0;
border-radius: 14px; border-radius: 12px;
background-color: #ffffff; 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: box-shadow:
inset 0 0 0 1px rgba(203, 213, 225, 0.5), 0 1px 2px 0 rgba(15, 23, 42, 0.05);
0 1px 2px rgba(15, 23, 42, 0.035),
0 14px 34px rgba(15, 23, 42, 0.05);
color: #334155; color: #334155;
animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both; 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) { .workbench-ai-answer-markdown :deep(.ai-document-card:hover) {
border-color: #cbd5e1;
box-shadow: box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.46), 0 4px 6px -1px rgba(15, 23, 42, 0.08),
0 1px 2px rgba(15, 23, 42, 0.04), 0 2px 4px -2px rgba(15, 23, 42, 0.08);
0 18px 38px rgba(15, 23, 42, 0.07);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -1532,8 +1525,9 @@
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
min-width: 0; min-width: 0;
padding: 13px 18px 13px 20px; padding: 12px 18px;
background: var(--ai-document-card-head-bg); background: var(--ai-document-card-head-bg);
border-bottom: 1px solid #f1f5f9;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card__status) {
@@ -1543,31 +1537,31 @@
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
color: #1d4ed8; color: #475569;
font-size: 15px; font-size: 14px;
font-weight: 860; font-weight: 600;
line-height: 1.3; line-height: 1.3;
white-space: nowrap; white-space: nowrap;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__head) { .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) { .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) { .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) { .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) { .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) { .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) { .workbench-ai-answer-markdown :deep(.ai-document-card__reason) {
display: -webkit-box; display: -webkit-box;
min-width: 0; min-width: 0;
color: #1e40af; color: #1e293b;
font-size: 15px; font-size: 15px;
font-weight: 760; font-weight: 600;
line-height: 1.45; line-height: 1.45;
overflow: hidden; overflow: hidden;
-webkit-line-clamp: 1; -webkit-line-clamp: 1;
@@ -1598,19 +1592,19 @@
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__reason) { .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) { .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) { .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) { .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) { .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) { .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) { .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) { .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) { .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) { .workbench-ai-answer-markdown :deep(.ai-document-card--approval-task) {
--ai-document-card-head-bg: rgba(245, 158, 11, 0.1); --ai-document-card-head-bg: rgba(254, 243, 199, 0.5);
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);
} }
.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__head) { .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) { .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) { .workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__status) {
min-height: 26px; min-height: 22px;
padding: 0 10px; padding: 0 8px;
border-radius: 999px; border-radius: 4px;
background: rgba(245, 158, 11, 0.18); background: rgba(217, 119, 6, 0.1);
color: #b45309; color: #b45309;
font-size: 13px;
font-weight: 600;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__summary), .workbench-ai-answer-markdown :deep(.ai-document-card__summary),
@@ -1666,7 +1658,7 @@
.workbench-ai-answer-markdown :deep(.ai-document-card__summary) { .workbench-ai-answer-markdown :deep(.ai-document-card__summary) {
padding-bottom: 14px; 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) { .workbench-ai-answer-markdown :deep(.ai-document-card__details) {
@@ -1690,26 +1682,26 @@
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__label) { .workbench-ai-answer-markdown :deep(.ai-document-card__label) {
color: #8a94a6; color: #64748b;
font-size: 13px; font-size: 13px;
font-weight: 640; font-weight: 500;
line-height: 1.4; line-height: 1.4;
white-space: nowrap; white-space: nowrap;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__value) { .workbench-ai-answer-markdown :deep(.ai-document-card__value) {
min-width: 0; min-width: 0;
color: #334155; color: #1e293b;
font-size: 14px; font-size: 14px;
font-weight: 720; font-weight: 500;
line-height: 1.45; line-height: 1.45;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__amount) { .workbench-ai-answer-markdown :deep(.ai-document-card__amount) {
color: #0f172a; color: #0f172a;
font-size: 18px; font-size: 17px;
font-weight: 900; font-weight: 700;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
} }
@@ -1717,33 +1709,30 @@
.workbench-ai-answer-markdown :deep(.ai-document-card__number) { .workbench-ai-answer-markdown :deep(.ai-document-card__number) {
color: #64748b; color: #64748b;
font-size: 13px; font-size: 13px;
font-weight: 740; font-weight: 500;
letter-spacing: 0; letter-spacing: 0;
} }
.workbench-ai-answer-markdown :deep(.ai-attachment-association-card) { .workbench-ai-answer-markdown :deep(.ai-attachment-association-card) {
background-image: background-image: none;
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(248, 250, 252, 0.94)), background-color: #ffffff;
url("../../ai-document-card-bg.png");
} }
.workbench-ai-answer-markdown :deep(.ai-attachment-association-card .ai-document-card__head) { .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) { .workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card) {
box-shadow: border-color: #cbd5e1;
inset 0 0 0 1px rgba(147, 197, 253, 0.42), box-shadow: 0 1px 2px 0 rgba(15, 23, 42, 0.05);
0 1px 2px rgba(15, 23, 42, 0.03),
0 12px 28px rgba(37, 99, 235, 0.045);
} }
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__head) { .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) { .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) { .workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__status) {
@@ -1781,16 +1770,16 @@
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
color: #1d4ed8; color: #2563eb;
font-size: 14px; font-size: 14px;
font-weight: 820; font-weight: 600;
box-shadow: none; box-shadow: none;
white-space: nowrap; white-space: nowrap;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__action:hover) { .workbench-ai-answer-markdown :deep(.ai-document-card__action:hover) {
background: transparent; background: transparent;
color: #1e40af; color: #1d4ed8;
text-decoration: underline; text-decoration: underline;
} }
@@ -1798,10 +1787,10 @@
.workbench-ai-answer-markdown :deep(.ai-html-table-wrap) { .workbench-ai-answer-markdown :deep(.ai-html-table-wrap) {
overflow-x: auto; overflow-x: auto;
margin-top: 18px; margin-top: 18px;
border: 1px solid rgba(226, 232, 240, 0.9); border: 1px solid #e2e8f0;
border-radius: 14px; border-radius: 8px;
background: #ffffff; 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) { .workbench-ai-answer-markdown :deep(table) {
@@ -1813,7 +1802,7 @@
.workbench-ai-answer-markdown :deep(th), .workbench-ai-answer-markdown :deep(th),
.workbench-ai-answer-markdown :deep(td) { .workbench-ai-answer-markdown :deep(td) {
padding: 11px 14px; padding: 11px 14px;
border-bottom: 1px solid rgba(226, 232, 240, 0.9); border-bottom: 1px solid #f1f5f9;
text-align: left; 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) { @media (prefers-reduced-motion: reduce) {
.workbench-ai-answer-markdown :deep(.ai-document-card) { .workbench-ai-answer-markdown :deep(.ai-document-card) {
animation: none; animation: none;

View File

@@ -22,3 +22,9 @@ const {
</script> </script>
<style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style> <style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style>
<style scoped>
/* Force styling cache invalidation and hot update */
.force-reload-css-cache-v1 {
display: block;
}
</style>

View File

@@ -258,7 +258,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
resolveLatestInlineUserPrompt, resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom, scrollInlineConversationToBottom,
sending, sending,
toast toast,
onApplicationActionCompleted: startModelPlannedNextTask
}) })
const expenseFlow = useWorkbenchAiExpenseFlow({ const expenseFlow = useWorkbenchAiExpenseFlow({
@@ -710,6 +711,46 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return pendingMessage 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) { function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) {
void applicationFlow.startAiApplicationPreview( void applicationFlow.startAiApplicationPreview(
travelApplicationRequest.expenseType, travelApplicationRequest.expenseType,
@@ -723,7 +764,10 @@ export function usePersonalWorkbenchAiMode(props, emit) {
autoSubmit: travelApplicationRequest.autoSubmit, autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft, autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit, 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, autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft, autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit, requestedSubmit: travelApplicationRequest.requestedSubmit,
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks
} }
} }
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, { replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, {

View File

@@ -4,6 +4,8 @@ import {
} from '../../services/aiApplicationPreviewActions.js' } from '../../services/aiApplicationPreviewActions.js'
import { executeStewardAction } from '../../services/steward.js' import { executeStewardAction } from '../../services/steward.js'
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js' import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
import { buildAiExpenseDraftPrefillValues } from '../../utils/aiExpenseDraftModel.js'
import { requiresApplicationBeforeReimbursement } from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
import { import {
mergeComposerPrefill, mergeComposerPrefill,
resolveSuggestedActionPrefill resolveSuggestedActionPrefill
@@ -82,6 +84,9 @@ export function useWorkbenchAiActionRouter({
} }
if (actionType === 'ai_application_confirm_intent') { if (actionType === 'ai_application_confirm_intent') {
aiExpenseDraft.value = null 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(), { void applicationFlow.startAiApplicationPreview('travel', '差旅费', String(actionPayload.sourceText || '').trim(), {
userMessage: String(actionPayload.sourceText || '').trim() || '确认发起出差申请', userMessage: String(actionPayload.sourceText || '').trim() || '确认发起出差申请',
pushUserMessage: true, pushUserMessage: true,
@@ -89,7 +94,20 @@ export function useWorkbenchAiActionRouter({
autoSubmit: Boolean(actionPayload.autoSubmit), autoSubmit: Boolean(actionPayload.autoSubmit),
autoSaveDraft: Boolean(actionPayload.autoSaveDraft), autoSaveDraft: Boolean(actionPayload.autoSaveDraft),
requestedSubmit: Boolean(actionPayload.requestedSubmit), 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 return
} }
@@ -104,9 +122,21 @@ export function useWorkbenchAiActionRouter({
return return
} }
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') { if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel' const currentTask = actionPayload.steward_current_task || {}
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费' const ontologyFields = currentTask.ontology_fields || currentTask.ontologyFields || actionPayload.ontology_fields || {}
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, true) 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 return
} }
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') { if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
@@ -176,7 +206,18 @@ export function useWorkbenchAiActionRouter({
if (actionType === 'ai_application_start_inline') { if (actionType === 'ai_application_start_inline') {
aiExpenseDraft.value = null 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 return
} }
@@ -398,6 +439,9 @@ export function useWorkbenchAiActionRouter({
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement' const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销' const taskLabel = isApplication ? '出差申请' : '费用报销'
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {} const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
// 透传去掉当前 nextTask 之后的剩余 task 列表,保证 task2 完成后 task3 也能继续推进,
// 避免 3+ task 场景在 task2 处断链。
const furtherRemainingTasks = remainingTasks.slice(1)
return { return {
label: `继续处理${taskLabel}`, label: `继续处理${taskLabel}`,
description: `接下来处理${taskLabel}: ${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`, 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: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费', expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields, 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
} }
} }
} }

View File

@@ -85,7 +85,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
resolveLatestInlineUserPrompt, resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom, scrollInlineConversationToBottom,
sending, sending,
toast toast,
onApplicationActionCompleted = null
}) { }) {
function isApplicationPreviewEstimatePending(message = {}) { function isApplicationPreviewEstimatePending(message = {}) {
return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview)) return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
@@ -345,6 +346,9 @@ export function useWorkbenchAiApplicationPreviewFlow({
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement' const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销' const taskLabel = isApplication ? '出差申请' : '费用报销'
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {} const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
// 透传去掉当前 nextTask 之后的剩余 task 列表,保证 task2 完成后 task3 也能继续推进,
// 避免 3+ task 场景在 task2 处断链。
const furtherRemainingTasks = remainingTasks.slice(1)
return { return {
label: `继续处理${taskLabel}`, label: `继续处理${taskLabel}`,
description: `接下来处理${taskLabel}${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`, 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: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费', expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields, 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 = [] targetMessage.suggestedActions = []
const detailActions = buildInlineApplicationDetailAction(draftPayload) const detailActions = buildInlineApplicationDetailAction(draftPayload)
const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage) const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage)
const shouldAutoContinueNextTask = Boolean(
nextTaskAction &&
typeof onApplicationActionCompleted === 'function' &&
Array.isArray(targetMessage.stewardRemainingTasks) &&
targetMessage.stewardRemainingTasks.length
)
replaceInlineMessage( replaceInlineMessage(
pendingMessage.id, pendingMessage.id,
createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), { createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
@@ -466,11 +477,16 @@ export function useWorkbenchAiApplicationPreviewFlow({
streamStatus: 'completed', streamStatus: 'completed',
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage)) thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
}, },
suggestedActions: nextTaskAction ? [...detailActions, nextTaskAction] : detailActions suggestedActions: shouldAutoContinueNextTask
? detailActions
: (nextTaskAction ? [...detailActions, nextTaskAction] : detailActions)
}) })
) )
persistCurrentConversation() persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
if (shouldAutoContinueNextTask) {
onApplicationActionCompleted(targetMessage.stewardRemainingTasks, targetMessage)
}
return true return true
} catch (error) { } catch (error) {
replaceInlineMessage( replaceInlineMessage(
@@ -599,6 +615,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
skipUserMessage: true, skipUserMessage: true,
userText: options.userMessage || '保存草稿' userText: options.userMessage || '保存草稿'
}) })
} else if (
typeof options.onPreviewReadyForNextTask === 'function' &&
Array.isArray(previewMessage.stewardRemainingTasks) &&
previewMessage.stewardRemainingTasks.length
) {
options.onPreviewReadyForNextTask(previewMessage.stewardRemainingTasks, previewMessage)
} }
} catch (error) { } catch (error) {
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', { replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {

View File

@@ -1,6 +1,8 @@
import { import {
buildWorkbenchDocumentCommandFollowupGuidance,
buildWorkbenchDraftDeletionGuidance, buildWorkbenchDraftDeletionGuidance,
isWorkbenchDraftDeletionIntent, isWorkbenchDraftDeletionIntent,
resolveLatestWorkbenchDocumentCommandContext,
resolveLatestWorkbenchDraftPayload resolveLatestWorkbenchDraftPayload
} from './workbenchAiCommandIntentModel.js' } from './workbenchAiCommandIntentModel.js'
import { resolveWorkbenchIntentActionRoute } from './workbenchIntentActionPolicy.js' import { resolveWorkbenchIntentActionRoute } from './workbenchIntentActionPolicy.js'
@@ -58,6 +60,9 @@ export function useWorkbenchAiCommandIntents({
if (!handlesWorkbenchCommand) { if (!handlesWorkbenchCommand) {
return false return false
} }
const documentCommandContext = route.nextStep === 'query_candidates'
? resolveLatestWorkbenchDocumentCommandContext(conversationMessages.value, frame)
: null
prepareInlineCommandConversation(cleanPrompt, entry) prepareInlineCommandConversation(cleanPrompt, entry)
const draftPayload = frame?.targetMode === 'current_context' || legacyDraftDelete const draftPayload = frame?.targetMode === 'current_context' || legacyDraftDelete
? resolveLatestWorkbenchDraftPayload(conversationMessages.value) ? resolveLatestWorkbenchDraftPayload(conversationMessages.value)
@@ -72,6 +77,16 @@ export function useWorkbenchAiCommandIntents({
return true 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 queryPrompt = route.queryPrompt || frame?.normalizedQuery || '我的草稿单据'
const pendingText = frame?.safetyLevel === 'confirm_required' const pendingText = frame?.safetyLevel === 'confirm_required'
? '正在先筛选候选单据,不会直接执行删除或审核动作...' ? '正在先筛选候选单据,不会直接执行删除或审核动作...'

View File

@@ -9,6 +9,7 @@ import {
} from '../../services/linkedReimbursementDraftJobs.js' } from '../../services/linkedReimbursementDraftJobs.js'
import { import {
applyAiExpenseAnswer, applyAiExpenseAnswer,
buildAiExpenseDraftPrefillValues,
buildAiExpenseStepPrompt, buildAiExpenseStepPrompt,
buildAiExpenseSummary, buildAiExpenseSummary,
createAiExpenseDraft, createAiExpenseDraft,
@@ -113,6 +114,7 @@ export function useWorkbenchAiExpenseFlow({
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [], suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
draftPayload: options.draftPayload || null, draftPayload: options.draftPayload || null,
linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null, linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null,
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
text: options.text || content text: options.text || content
}) })
replaceInlineMessage(messageId, nextMessage) replaceInlineMessage(messageId, nextMessage)
@@ -323,7 +325,40 @@ export function useWorkbenchAiExpenseFlow({
scrollInlineConversationToBottom() 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) { if (!conversationStarted.value) {
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' }) activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
} }
@@ -333,12 +368,25 @@ export function useWorkbenchAiExpenseFlow({
clearAiModeFiles() clearAiModeFiles()
pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`) pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
const prefillValues = options.prefillValues && typeof options.prefillValues === 'object'
? options.prefillValues
: null
const stewardRemainingTasks = Array.isArray(options.stewardRemainingTasks)
? options.stewardRemainingTasks
: []
if (requiresApplicationBeforeReimbursement) { if (requiresApplicationBeforeReimbursement) {
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel, {
prefillValues,
stewardRemainingTasks
})
return return
} }
const draft = createAiExpenseDraft(expenseType, expenseTypeLabel) const draft = attachStewardRemainingTasks(
createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues),
stewardRemainingTasks
)
aiExpenseDraft.value = draft aiExpenseDraft.value = draft
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft))) conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
persistCurrentConversation() persistCurrentConversation()
@@ -351,7 +399,11 @@ export function useWorkbenchAiExpenseFlow({
assistantDraft.value = '' assistantDraft.value = ''
clearAiModeFiles() 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 aiExpenseDraft.value = next
if (isAiExpenseDraftComplete(next)) { if (isAiExpenseDraftComplete(next)) {
@@ -364,7 +416,14 @@ export function useWorkbenchAiExpenseFlow({
scrollInlineConversationToBottom() 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 let claims = null
try { try {
claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS) claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
@@ -377,18 +436,30 @@ export function useWorkbenchAiExpenseFlow({
} }
const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {}) 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) { 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), { conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
suggestedActions: [{ suggestedActions: [{
label: '确认发起出差申请', label: `确认发起${applicationLabel}申请`,
description: '生成完整申请表,并预填已识别的时间、地点和事由', description: '生成完整申请表,并预填已识别的时间、地点和事由',
icon: 'mdi mdi-file-plus-outline', icon: 'mdi mdi-file-plus-outline',
action_type: 'ai_application_start_inline', action_type: 'ai_application_start_inline',
payload: { payload: {
expense_type: expenseType, 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, jobId,
pendingMessageId, pendingMessageId,
claimNo = '', claimNo = '',
initialJob = null initialJob = null,
stewardRemainingTasks = []
}) { }) {
const normalizedJobId = String(jobId || '').trim() const normalizedJobId = String(jobId || '').trim()
if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) { if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) {
@@ -479,13 +551,17 @@ export function useWorkbenchAiExpenseFlow({
const content = draftClaimNo const content = draftClaimNo
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。` ? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。` : `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
// 多 task 推进:报销草稿生成成功后,若还有剩余 task,补一个"继续处理"按钮。
const nextTaskAction = buildExpenseDraftNextTaskAction(stewardRemainingTasks)
replaceInlineAssistantMessage(pendingMessageId, content, { replaceInlineAssistantMessage(pendingMessageId, content, {
draftPayload, draftPayload,
linkedReimbursementDraftJob: { linkedReimbursementDraftJob: {
...currentJob, ...currentJob,
applicationClaimNo: claimNo applicationClaimNo: claimNo
}, },
suggestedActions: buildLinkedDraftAction(draftPayload) suggestedActions: nextTaskAction
? [...buildLinkedDraftAction(draftPayload), nextTaskAction]
: buildLinkedDraftAction(draftPayload)
}) })
aiExpenseDraft.value = null aiExpenseDraft.value = null
persistCurrentConversation() persistCurrentConversation()
@@ -524,7 +600,9 @@ export function useWorkbenchAiExpenseFlow({
jobId: job.jobId, jobId: job.jobId,
pendingMessageId: message.id, pendingMessageId: message.id,
claimNo: job.applicationClaimNo, claimNo: job.applicationClaimNo,
initialJob: job initialJob: job,
// 刷新恢复时从消息上读回 remaining tasks,保证报销完成后仍能补出"继续处理"按钮。
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : []
}).catch((error) => { }).catch((error) => {
replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), { replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), {
linkedReimbursementDraftJob: { 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 = {}) { async function linkAiExpenseApplication(application = {}) {
const draft = aiExpenseDraft.value || (() => { const draft = aiExpenseDraft.value || (() => {
const resolved = resolveRequiredApplicationReimbursementType(application) const resolved = resolveRequiredApplicationReimbursementType(application)
@@ -577,9 +687,14 @@ export function useWorkbenchAiExpenseFlow({
stepKey: 'attachments' stepKey: 'attachments'
} }
aiExpenseDraft.value = linked aiExpenseDraft.value = linked
// 关联申请单时,保留 draft 上的 remaining tasks 上下文,透传给后续轮询,
// 这样报销草稿生成成功后能补出"继续处理 task3"按钮。
const stewardRemainingTasks = resolveStewardRemainingTasks(linked) || []
const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, { const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, {
pending: true, pending: true,
suggestedActions: [] suggestedActions: [],
// 把 remaining tasks 挂到 pending 消息上,刷新后 resume 轮询能读回并透传给 poll 成功分支。
stewardRemainingTasks
}) })
conversationMessages.value.push(pendingMessage) conversationMessages.value.push(pendingMessage)
const pendingMessageId = pendingMessage.id const pendingMessageId = pendingMessage.id
@@ -607,7 +722,8 @@ export function useWorkbenchAiExpenseFlow({
jobId: normalizedJob.jobId, jobId: normalizedJob.jobId,
pendingMessageId, pendingMessageId,
claimNo, claimNo,
initialJob: normalizedJob initialJob: normalizedJob,
stewardRemainingTasks
}) })
} catch (error) { } catch (error) {
replaceInlineAssistantMessage( replaceInlineAssistantMessage(

View File

@@ -1,3 +1,7 @@
import {
parseAiDocumentDetailHref
} from '../../utils/aiDocumentDetailReference.js'
const DRAFT_DELETION_ACTION_PATTERN = /删除|删掉|删了|移除|作废|撤销/ const DRAFT_DELETION_ACTION_PATTERN = /删除|删掉|删了|移除|作废|撤销/
const DRAFT_DELETION_TARGET_PATTERN = ( const DRAFT_DELETION_TARGET_PATTERN = (
/草稿|这个单据|这张单据|当前单据|当前申请|当前报销|刚才保存的草稿|刚才的草稿|上面的单据|最近的单据|申请单|报销单/ /草稿|这个单据|这张单据|当前单据|当前申请|当前报销|刚才保存的草稿|刚才的草稿|上面的单据|最近的单据|申请单|报销单/
@@ -22,11 +26,26 @@ const SUBMITTED_OR_FINAL_STATUS = new Set([
'已驳回', '已驳回',
'已退回' '已退回'
]) ])
const DOCUMENT_DETAIL_LINK_RE = /<a\b[^>]*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 = '') { function normalizeCompactText(value = '') {
return String(value || '').replace(/\s+/g, '').trim() return String(value || '').replace(/\s+/g, '').trim()
} }
function normalizeText(value = '') {
return String(value || '').replace(/\s+/g, ' ').trim()
}
function normalizeDraftDocumentType(payload = {}, claimNo = '') { function normalizeDraftDocumentType(payload = {}, claimNo = '') {
const rawType = String(payload.document_type || payload.documentType || payload.draft_type || payload.draftType || '').trim() const rawType = String(payload.document_type || payload.documentType || payload.draft_type || payload.draftType || '').trim()
if (/application|expense_application|申请/.test(rawType)) { if (/application|expense_application|申请/.test(rawType)) {
@@ -77,6 +96,60 @@ function extractDraftPayloadFromSuggestedActions(message = {}) {
return null 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 = '') { export function isWorkbenchDraftDeletionIntent(prompt = '') {
const compact = normalizeCompactText(prompt) const compact = normalizeCompactText(prompt)
if (!compact || !DRAFT_DELETION_ACTION_PATTERN.test(compact)) { if (!compact || !DRAFT_DELETION_ACTION_PATTERN.test(compact)) {
@@ -104,6 +177,29 @@ export function resolveLatestWorkbenchDraftPayload(messages = []) {
return null 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 = {}) { export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) {
const claimNo = String(draftPayload.claimNo || draftPayload.claim_no || '').trim() const claimNo = String(draftPayload.claimNo || draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claimId || draftPayload.claim_id || '').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
}
}
})
}
}

View File

@@ -143,13 +143,37 @@ function normalizeServerApplicationSteps(rawSteps = []) {
return [...new Set(mappedSteps)] return [...new Set(mappedSteps)]
} }
function findModelTravelApplicationTask(rawPlan = {}) { function resolveModelTasks(rawPlan = {}) {
const tasks = Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : [] return Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : []
return tasks.find((task) => { }
function isModelTravelApplicationTask(task = {}) {
if (!task || typeof task !== 'object') {
return false
}
const taskType = String(task?.task_type || task?.taskType || '').trim() const taskType = String(task?.task_type || task?.taskType || '').trim()
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim() const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
return taskType === 'expense_application' || assignedAgent === 'application_assistant' return taskType === 'expense_application' || assignedAgent === 'application_assistant'
}) || null }
function findModelTravelApplicationTask(rawPlan = {}) {
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 Boolean(taskType || assignedAgent)
})
} }
function resolveCandidateFlows(rawPlan = {}) { function resolveCandidateFlows(rawPlan = {}) {
@@ -226,7 +250,8 @@ export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) {
missingFields: Array.isArray(task.missing_fields || task.missingFields) missingFields: Array.isArray(task.missing_fields || task.missingFields)
? 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 return null
} }
const requestedSubmit = plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION) const requestedSubmit = plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION)
return { const request = {
expenseType: 'travel', expenseType: 'travel',
expenseTypeLabel: '差旅费', expenseTypeLabel: '差旅费',
sourceText: String(plan.sourceText || '').trim(), sourceText: String(plan.sourceText || '').trim(),
@@ -285,6 +310,11 @@ export function resolveExecutableTravelApplicationPlan(plan = null) {
requestedSubmit, requestedSubmit,
submitRequiresConfirmation: requestedSubmit submitRequiresConfirmation: requestedSubmit
} }
const stewardRemainingTasks = Array.isArray(plan.stewardRemainingTasks) ? plan.stewardRemainingTasks : []
if (stewardRemainingTasks.length) {
request.stewardRemainingTasks = stewardRemainingTasks
}
return request
} }
export function isLowConfidenceTravelApplicationPlan(plan = null) { export function isLowConfidenceTravelApplicationPlan(plan = null) {

View File

@@ -156,6 +156,9 @@ export function createWorkbenchAiMessageRuntime() {
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null), attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null),
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null), linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null),
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null), attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
// 多 task 推进上下文:申请预览/报销草稿消息上挂载剩余 task 列表,
// 刷新或消息重建后仍能继续推进,避免 task 链断裂。
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
text: options.text || normalizedContent, text: options.text || normalizedContent,
createdAt: options.createdAt || Date.now() createdAt: options.createdAt || Date.now()
} }
@@ -175,6 +178,7 @@ export function createWorkbenchAiMessageRuntime() {
attachmentAssociationJob: message.attachmentAssociationJob || null, attachmentAssociationJob: message.attachmentAssociationJob || null,
linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null, linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null,
attachmentOcrDetails: message.attachmentOcrDetails || null, attachmentOcrDetails: message.attachmentOcrDetails || null,
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : [],
text: message.text || message.content || '' text: message.text || message.content || ''
}) })
} }
@@ -194,7 +198,8 @@ export function createWorkbenchAiMessageRuntime() {
draftPayload: message.draftPayload || null, draftPayload: message.draftPayload || null,
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null), attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null),
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null), linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null),
attachmentOcrDetails: message.attachmentOcrDetails || null attachmentOcrDetails: message.attachmentOcrDetails || null,
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : []
} }
} }

View File

@@ -72,6 +72,9 @@ export function buildAiApplicationPreviewActionPayload({
: [] : []
const draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId) const draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId)
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
const applicationEditableFields = Array.isArray(normalizedPreview.editableFields)
? normalizedPreview.editableFields.map((field) => normalizeText(field)).filter(Boolean)
: []
return { return {
source: 'user_message', source: 'user_message',
@@ -107,6 +110,9 @@ export function buildAiApplicationPreviewActionPayload({
application_stage: 'expense_application', application_stage: 'expense_application',
user_input_text: message, user_input_text: message,
application_preview: normalizedPreview, application_preview: normalizedPreview,
...(applicationEditableFields.length
? { application_editable_fields: applicationEditableFields }
: {}),
...(isSubmit ...(isSubmit
? {} ? {}
: { : {

View File

@@ -313,7 +313,9 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
} }
lines.push( lines.push(
'', '',
'> **请先核对**:请先检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先处理或关联已有申请单,避免重复申请。', '**后续行动建议**',
'- 请检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
'- 若日期无误,请先处理或关联已有申请单,避免重复申请。',
'', '',
'我会先暂停本次申请表生成,不会开放保存草稿或提交入口。' '我会先暂停本次申请表生成,不会开放保存草稿或提交入口。'
) )
@@ -323,18 +325,17 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
const normalized = normalizeApplicationPreview(preview) const normalized = normalizeApplicationPreview(preview)
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : [] const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
const missingText = missingFields.length ? missingFields.join('、') : '暂无' const missingText = missingFields.length ? missingFields.join('、') : '暂无'
const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '**预算管理者审核提示**' : '**预算与审批影响**' const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '预算管理者审核提示' : '预算与审批影响'
const overlapPrefix = precheck?.overlap?.status === 'warning' ? '**时间重叠提醒**' : '**单据重叠核查**' const overlapPrefix = precheck?.overlap?.status === 'warning' ? '时间重叠提醒' : '单据重叠核查'
const lines = [ const lines = [
'### 出差申请表草稿已生成', '### 出差申请表草稿已生成',
'', '',
'**我已完成发起前的单据与预算预审**,并为您生成一张完整的出差申请表。', '**我已完成发起前的单据与预算预审**,并为您生成一张完整的出差申请表。',
'', '',
`> ${overlapPrefix}${precheck?.overlap?.summary || '已完成已有单据核查。'}`, '**发起前预审结果**',
'', `- **${overlapPrefix}**${precheck?.overlap?.summary || '已完成已有单据核查。'}`,
`> ${budgetPrefix}${precheck?.budget?.summary || '已完成预算影响评估。'}`, `- **${budgetPrefix}**${precheck?.budget?.summary || '已完成预算影响评估。'}`,
'', `- **仍需补充**${missingText}`,
`> **仍需补充**${missingText}`,
'', '',
'请直接点击表格中的字段补充或修改;费用测算会根据地点、天数和出行方式自动更新。' '请直接点击表格中的字段补充或修改;费用测算会根据地点、天数和出行方式自动更新。'
] ]
@@ -363,14 +364,16 @@ export function buildAiApplicationSubmitConflictMessage(preview = {}, precheck =
'', '',
`> **相同日期提醒**${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`, `> **相同日期提醒**${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`,
'', '',
`> **本次申请时间**${currentRangeText}`, `**本次申请时间**${currentRangeText}`,
] ]
if (matchTable) { if (matchTable) {
lines.push('', matchTable) lines.push('', matchTable)
} }
lines.push( lines.push(
'', '',
'> **请先核对**:请先核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先查看或处理已有申请单,避免重复申请。', '**后续行动建议**',
'- 请核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
'- 若日期无误,请先查看或处理已有申请单,避免重复申请。',
'', '',
'我会先暂停本次提交,不会生成新的审批流。' '我会先暂停本次提交,不会生成新的审批流。'
) )

View File

@@ -26,13 +26,59 @@ export function getAiExpenseSteps() {
return DEFAULT_FIELD_STEPS 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 { return {
expenseType: normalizeAnswer(expenseType), expenseType: normalizeAnswer(expenseType),
expenseTypeLabel: normalizeAnswer(expenseTypeLabel), expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
applicationClaim: null, applicationClaim: null,
values: {}, values,
stepKey: DEFAULT_FIELD_STEPS[0].key stepKey: resolveInitialStepKey(values)
} }
} }

View File

@@ -263,7 +263,9 @@ function normalizeMessage(message = {}) {
streamStatus: safeString(message.stewardPlan.streamStatus) || 'completed' streamStatus: safeString(message.stewardPlan.streamStatus) || 'completed'
} }
: null, : null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [] suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
// 保留多 task 推进上下文,刷新后申请预览/报销草稿消息仍能拿到剩余 task 列表。
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : []
} }
} }

View File

@@ -292,8 +292,20 @@ export function normalizeApplicationPreview(preview = {}) {
...resolveApplicationValidationIssues(fields), ...resolveApplicationValidationIssues(fields),
...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview) ...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview)
] ]
const editableFields = Array.isArray(preview?.editableFields)
? preview.editableFields
: Array.isArray(preview?.editable_fields)
? preview.editable_fields
: null
return { return {
...preview, ...preview,
...(editableFields
? {
editableFields: editableFields
.map((field) => String(field || '').trim())
.filter(Boolean)
}
: {}),
fields, fields,
missingFields, missingFields,
validationIssues, 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) { export function applyApplicationBusinessTimeContext(preview = {}, businessTimeContext = null) {
if (!businessTimeContext || typeof businessTimeContext !== 'object') { if (!businessTimeContext || typeof businessTimeContext !== 'object') {
return normalizeApplicationPreview(preview) return normalizeApplicationPreview(preview)
@@ -394,7 +437,7 @@ export function buildApplicationPreviewRows(preview = {}) {
...item, ...item,
label: '出发时间', label: '出发时间',
value: tripDates.startDate || '待补充', value: tripDates.startDate || '待补充',
editable: item.editable !== false, editable: isApplicationPreviewFieldEditable(normalized, item, 'time'),
highlight: Boolean(item.highlight), highlight: Boolean(item.highlight),
missing missing
}, },
@@ -402,7 +445,7 @@ export function buildApplicationPreviewRows(preview = {}) {
key: 'time_return', key: 'time_return',
label: '返回时间', label: '返回时间',
value: tripDates.endDate || '待补充', value: tripDates.endDate || '待补充',
editable: item.editable !== false, editable: isApplicationPreviewFieldEditable(normalized, item, 'time_return'),
highlight: Boolean(item.highlight), highlight: Boolean(item.highlight),
missing missing
} }
@@ -415,7 +458,7 @@ export function buildApplicationPreviewRows(preview = {}) {
...item, ...item,
label: resolveApplicationFieldLabel(item, fields), label: resolveApplicationFieldLabel(item, fields),
value, value,
editable: item.editable !== false, editable: isApplicationPreviewFieldEditable(normalized, item, item.key),
highlight: Boolean(item.highlight), highlight: Boolean(item.highlight),
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue) missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
}] }]
@@ -484,6 +527,10 @@ export function buildApplicationTemplatePreview(currentUser = {}) {
export function buildLocalApplicationPreviewMessage(preview) { export function buildLocalApplicationPreviewMessage(preview) {
const normalized = normalizeApplicationPreview(preview) const normalized = normalizeApplicationPreview(preview)
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim() const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
const editMode = Boolean(normalized.applicationEditMode || normalized.application_edit_mode)
if (editMode) {
return '我已载入原申请信息。请只修改事由、时间、地点和出行方式;职级、负责人、标准和费用会按规则带入或重新测算。'
}
return [ return [
modelReviewStatus === 'completed' modelReviewStatus === 'completed'
? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。' ? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。'

View File

@@ -95,9 +95,62 @@ async function testSaveDraftActionUsesFastPreviewEndpoint() {
assert.equal(body.context_json.application_stage, 'expense_application') 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() { async function run() {
await testSubmitActionUsesFastPreviewEndpoint() await testSubmitActionUsesFastPreviewEndpoint()
await testSaveDraftActionUsesFastPreviewEndpoint() await testSaveDraftActionUsesFastPreviewEndpoint()
await testEditDraftActionCarriesClaimAndEditableFields()
console.log('ai-application-preview-actions tests passed') console.log('ai-application-preview-actions tests passed')
} }

View File

@@ -3,6 +3,7 @@ import test from 'node:test'
import { import {
applyAiExpenseAnswer, applyAiExpenseAnswer,
buildAiExpenseDraftPrefillValues,
buildAiExpenseStepPrompt, buildAiExpenseStepPrompt,
buildAiExpenseSummary, buildAiExpenseSummary,
createAiExpenseDraft, createAiExpenseDraft,
@@ -71,3 +72,41 @@ test('summary lists every filled field and the linked application', () => {
assert.match(summary, /AP-202606-001/) assert.match(summary, /AP-202606-001/)
assert.match(summary, /85元/) 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))
})

View File

@@ -67,3 +67,37 @@ test('AI workbench conversation store persists scoped history for sidebar sessio
assert.equal(nextHistory.length, 1) assert.equal(nextHistory.length, 1)
assert.equal(nextHistory[0].id, 'conv-first') 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)
})

View File

@@ -362,6 +362,40 @@ test('travel application submit can continue with conversational planning recomm
assert.match(recommendation, /AP-202606030001-ABCDE123/) 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', () => { test('application preview renders ordered editable rows and submit text uses edited values', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆伊犁出差服务美团业务部署火车预计费用1800元', { const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆伊犁出差服务美团业务部署火车预计费用1800元', {
name: '李文静', name: '李文静',

View File

@@ -93,6 +93,65 @@ test('workbench steward application confirmation opens inline application previe
assert.equal(preview.fields.transportMode, '') 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', () => { test('workbench reimbursement skip link action opens new reimbursement flow', () => {
let sceneSelectionPayload = null let sceneSelectionPayload = null
let fallbackConversationStarted = false let fallbackConversationStarted = false
@@ -389,3 +448,70 @@ test('workbench steward executable submit action runs precheck before submit and
globalThis.fetch = originalFetch 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, [])
})

View File

@@ -4,8 +4,10 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { import {
buildWorkbenchDocumentCommandFollowupGuidance,
buildWorkbenchDraftDeletionGuidance, buildWorkbenchDraftDeletionGuidance,
isWorkbenchDraftDeletionIntent, isWorkbenchDraftDeletionIntent,
resolveLatestWorkbenchDocumentCommandContext,
resolveLatestWorkbenchDraftPayload resolveLatestWorkbenchDraftPayload
} from '../src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js' } 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') assert.equal(guidance.suggestedActions[0].payload.claim_no, 'ALATEST1')
}) })
test('workbench command intent reuses previous approval candidates for follow-up approval command', () => {
const context = resolveLatestWorkbenchDocumentCommandContext([
{
role: 'assistant',
content: [
'### 已查询到相关单据',
'',
'<article class="ai-document-card ai-document-card--application ai-document-card--approval-task is-pending">',
'<a class="ai-document-card__action" href="#ai-open-document-detail:claim_id%3Dapproval-1%26claim_no%3DAP-APPROVAL-001">查看详情</a>',
'</article>',
'<article class="ai-document-card ai-document-card--reimbursement ai-document-card--approval-task is-pending">',
'<a class="ai-document-card__action" href="#ai-open-document-detail:claim_id%3Dapproval-2%26claim_no%3DRE-APPROVAL-002">查看详情</a>',
'</article>'
].join('\n')
}
], { action: 'approve', safetyLevel: 'confirm_required' })
assert.equal(context?.candidates.length, 2)
assert.deepEqual(context.candidates[0], {
claimId: 'approval-1',
claimNo: 'AP-APPROVAL-001',
documentType: 'application',
actionLabel: '查看详情'
})
const guidance = buildWorkbenchDocumentCommandFollowupGuidance(context, { action: 'approve' })
assert.match(guidance.content, /已接上刚才查询到的待审单据/)
assert.match(guidance.content, /AP-APPROVAL-001/)
assert.match(guidance.content, /RE-APPROVAL-002/)
assert.equal(guidance.suggestedActions.length, 2)
assert.equal(guidance.suggestedActions[0].action_type, 'open_application_detail')
assert.equal(guidance.suggestedActions[0].payload.claim_id, 'approval-1')
assert.equal(guidance.suggestedActions[0].payload.command_action, 'approve')
})
test('workbench draft deletion intent is wired before draft slot continuation', () => { test('workbench draft deletion intent is wired before draft slot continuation', () => {
assert.match(commandIntentsScript, /isWorkbenchDraftDeletionIntent/) assert.match(commandIntentsScript, /isWorkbenchDraftDeletionIntent/)
assert.match(commandIntentsScript, /resolveLatestWorkbenchDocumentCommandContext/)
assert.match(commandIntentsScript, /buildWorkbenchDocumentCommandFollowupGuidance/)
assert.match(commandIntentsScript, /function handleInlineDraftDeletionIntent\(cleanPrompt, entry = \{\}\)/) assert.match(commandIntentsScript, /function handleInlineDraftDeletionIntent\(cleanPrompt, entry = \{\}\)/)
assert.match(commandIntentsScript, /resolveLatestWorkbenchDraftPayload\(conversationMessages\.value\)/) assert.match(commandIntentsScript, /resolveLatestWorkbenchDraftPayload\(conversationMessages\.value\)/)
assert.match(commandIntentsScript, /buildWorkbenchDraftDeletionGuidance\(draftPayload\)/) assert.match(commandIntentsScript, /buildWorkbenchDraftDeletionGuidance\(draftPayload\)/)

View File

@@ -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', () => { test('workbench AI intent planner prefers server action steps when present', () => {
const plan = normalizeWorkbenchAiIntentPlan({ const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'llm_function_call', 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, /requestedSubmit:\s*travelApplicationRequest\.requestedSubmit/)
assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/) assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/)
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/) 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\.autoSaveDraft/)
assert.match(applicationPreviewFlowScript, /options\.onPreviewReadyForNextTask/)
assert.match(applicationPreviewFlowScript, /onApplicationActionCompleted\(\s*targetMessage\.stewardRemainingTasks/)
assert.doesNotMatch(applicationPreviewFlowScript, /options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit/) assert.doesNotMatch(applicationPreviewFlowScript, /options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit/)
assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/) assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/)
assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/) assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)