feat: 小财管家意图规划与报销提交编排增强
- 完善管家意图识别、模型计划构建与规划器调度 - 重构差旅报销提交编排器与管家计划流程前端交互 - 优化报销消息项样式与文档中心视图 - 新增小财管家与附件上传风险前置复核设计文档 - 补充管家规划器与文档中心测试覆盖
This commit is contained in:
@@ -29,6 +29,10 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-row.has-steward-plan .message-stack {
|
||||
width: min(100%, 760px);
|
||||
}
|
||||
|
||||
.message-row.user .message-stack {
|
||||
order: 1;
|
||||
justify-items: end;
|
||||
@@ -64,8 +68,15 @@
|
||||
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.message-row.has-steward-plan .message-bubble {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.steward-intent-bubble {
|
||||
width: min(100%, 680px);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #c9ddea;
|
||||
border-radius: 4px;
|
||||
background: #eef6fb;
|
||||
@@ -144,6 +155,17 @@
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.steward-intent-event-list li span.typing::after {
|
||||
content: "";
|
||||
width: 5px;
|
||||
height: 12px;
|
||||
display: inline-block;
|
||||
margin-left: 3px;
|
||||
vertical-align: -2px;
|
||||
background: #3a7ca5;
|
||||
animation: steward-plan-caret 900ms steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.steward-intent-empty {
|
||||
margin: 0;
|
||||
padding: 0 12px 12px;
|
||||
@@ -231,6 +253,229 @@
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.steward-plan-markdown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.steward-plan-markdown :deep(h2) {
|
||||
margin: 0 0 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #dbe4ee;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 860;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.steward-plan-markdown :deep(hr) {
|
||||
margin: 10px 0 14px;
|
||||
border: 0;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.steward-plan-markdown :deep(h3) {
|
||||
margin: 16px 0 8px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid var(--theme-primary, #3a7ca5);
|
||||
color: #183247;
|
||||
font-size: 13px;
|
||||
font-weight: 840;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.steward-plan-markdown :deep(h3:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.steward-plan-markdown :deep(p) {
|
||||
margin: 0 0 10px;
|
||||
color: #334155;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.steward-plan-markdown :deep(ul) {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.steward-plan-markdown :deep(li) {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.steward-plan-typing::after {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 15px;
|
||||
display: inline-block;
|
||||
margin-left: 3px;
|
||||
vertical-align: -2px;
|
||||
background: var(--theme-primary, #3a7ca5);
|
||||
animation: steward-plan-caret 900ms steps(1, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes steward-plan-caret {
|
||||
0%,
|
||||
45% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
46%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.steward-plan-block {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.steward-task-list,
|
||||
.steward-attachment-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.steward-task-card,
|
||||
.steward-attachment-card {
|
||||
padding: 12px;
|
||||
border: 1px solid #dbe7f2;
|
||||
border-radius: 4px;
|
||||
background: #f8fbfe;
|
||||
}
|
||||
|
||||
.steward-task-header,
|
||||
.steward-attachment-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.steward-task-type,
|
||||
.steward-attachment-card header span {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 7px;
|
||||
border: 1px solid #c7deef;
|
||||
border-radius: 4px;
|
||||
background: #eef7fc;
|
||||
color: #24618a;
|
||||
font-size: 12px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.steward-task-agent,
|
||||
.steward-attachment-card header small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.steward-task-body {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.steward-task-title {
|
||||
display: block;
|
||||
color: #172a3a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.steward-task-summary,
|
||||
.steward-attachment-card p {
|
||||
margin: 0;
|
||||
color: #5c7185;
|
||||
font-size: 12px;
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.steward-task-meta,
|
||||
.steward-attachment-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.steward-task-meta span,
|
||||
.steward-attachment-chip {
|
||||
border: 1px solid #d5e2ee;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #49677f;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.steward-task-missing {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #d5e2ee;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.steward-task-missing-label {
|
||||
color: #425c72;
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.steward-task-missing-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.steward-task-missing-list li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(86px, 120px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.steward-task-missing-list strong {
|
||||
color: #1f3448;
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.steward-task-missing-list small {
|
||||
color: #6b7f92;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.steward-attachment-chip.include {
|
||||
border-color: #c7deef;
|
||||
background: #eef7fc;
|
||||
color: #24618a;
|
||||
}
|
||||
|
||||
.steward-attachment-chip.exclude {
|
||||
border-color: #ecd6c4;
|
||||
background: #fff8f2;
|
||||
color: #8a5a24;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(.markdown-table-wrap) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
@@ -811,4 +1056,9 @@
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.steward-task-missing-list li {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,42 @@
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.steward-task-missing {
|
||||
margin-top: 9px;
|
||||
padding-top: 9px;
|
||||
border-top: 1px dashed #d5e2ee;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.steward-task-missing-label {
|
||||
color: #5d7489;
|
||||
font-size: 12px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.steward-task-missing-chip {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.steward-task-missing-chip strong {
|
||||
color: #1f3448;
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.steward-task-missing-chip small {
|
||||
color: #6b7f92;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.steward-attachment-chip.include {
|
||||
border-color: #c7deef;
|
||||
background: #eef7fc;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<article
|
||||
class="message-row"
|
||||
:class="message.role"
|
||||
:class="[message.role, { 'has-steward-plan': message.stewardPlan }]"
|
||||
>
|
||||
<span class="message-avatar">
|
||||
<img
|
||||
@@ -31,7 +31,7 @@
|
||||
:key="`${message.id}-${event.eventId}`"
|
||||
>
|
||||
<strong>{{ event.title }}</strong>
|
||||
<span>{{ event.content }}</span>
|
||||
<span :class="{ 'typing': event.status === 'running' }">{{ event.content }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="steward-intent-empty">正在建立任务上下文...</p>
|
||||
@@ -62,7 +62,14 @@
|
||||
|
||||
<div
|
||||
v-else-if="message.text && message.role === 'assistant'"
|
||||
class="message-answer-content message-answer-markdown"
|
||||
:class="[
|
||||
'message-answer-content',
|
||||
'message-answer-markdown',
|
||||
{
|
||||
'steward-plan-markdown': message.stewardPlan,
|
||||
'steward-plan-typing': message.stewardPlan?.streamStatus === 'typing'
|
||||
}
|
||||
]"
|
||||
v-html="ui.renderMarkdown(message.text)"
|
||||
@click="ui.handleAssistantMarkdownClick($event, message)"
|
||||
></div>
|
||||
@@ -73,7 +80,7 @@
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="message.role === 'assistant' && message.stewardPlan && message.stewardPlan.streamStatus !== 'streaming'"
|
||||
v-if="message.role === 'assistant' && message.stewardPlan && message.stewardPlan.streamStatus !== 'streaming' && !message.stewardPlan.initialSummaryOnly"
|
||||
class="steward-plan-block"
|
||||
role="group"
|
||||
aria-label="小财管家任务计划"
|
||||
@@ -84,16 +91,32 @@
|
||||
:key="`${message.id}-${task.taskId}`"
|
||||
class="steward-task-card"
|
||||
>
|
||||
<header>
|
||||
<span>{{ task.taskTypeLabel }}</span>
|
||||
<small>{{ task.assignedAgentLabel }}</small>
|
||||
<header class="steward-task-header">
|
||||
<span class="steward-task-type">{{ task.taskTypeLabel }}</span>
|
||||
<span class="steward-task-agent">{{ task.assignedAgentLabel }}</span>
|
||||
</header>
|
||||
<strong>{{ task.title }}</strong>
|
||||
<p>{{ task.summary }}</p>
|
||||
<div class="steward-task-body">
|
||||
<strong class="steward-task-title">{{ task.title }}</strong>
|
||||
<p class="steward-task-summary">{{ task.summary }}</p>
|
||||
</div>
|
||||
<div class="steward-task-meta">
|
||||
<span>置信度 {{ Math.round((task.confidence || 0) * 100) }}%</span>
|
||||
<span v-if="task.missingFields?.length">待补充 {{ task.missingFields.join('、') }}</span>
|
||||
<span v-else>字段已齐备</span>
|
||||
<span v-if="!ui.resolveStewardMissingFieldItems(task).length">信息已基本齐备</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="ui.resolveStewardMissingFieldItems(task).length"
|
||||
class="steward-task-missing"
|
||||
>
|
||||
<span class="steward-task-missing-label">还需要补充</span>
|
||||
<ul class="steward-task-missing-list">
|
||||
<li
|
||||
v-for="field in ui.resolveStewardMissingFieldItems(task)"
|
||||
:key="`${task.taskId}-missing-${field.key}`"
|
||||
>
|
||||
<strong>{{ field.label }}</strong>
|
||||
<small v-if="field.hint">{{ field.hint }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -11,12 +11,28 @@ export function fetchStewardPlan(payload, options = {}) {
|
||||
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
|
||||
const {
|
||||
timeoutMs = 0,
|
||||
idleTimeoutMs = 90000,
|
||||
timeoutMessage = '小财管家任务规划超时,请稍后重试。'
|
||||
} = options
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
|
||||
const timeoutId = controller && Number(timeoutMs) > 0
|
||||
? globalThis.setTimeout(() => controller.abort(), Number(timeoutMs))
|
||||
: 0
|
||||
let timeoutId = 0
|
||||
const armAbortTimer = (delayMs) => {
|
||||
if (!controller) return
|
||||
if (timeoutId) {
|
||||
globalThis.clearTimeout(timeoutId)
|
||||
timeoutId = 0
|
||||
}
|
||||
if (Number(delayMs) > 0) {
|
||||
timeoutId = globalThis.setTimeout(() => controller.abort(), Number(delayMs))
|
||||
}
|
||||
}
|
||||
const clearAbortTimer = () => {
|
||||
if (timeoutId) {
|
||||
globalThis.clearTimeout(timeoutId)
|
||||
timeoutId = 0
|
||||
}
|
||||
}
|
||||
armAbortTimer(timeoutMs)
|
||||
|
||||
let response
|
||||
try {
|
||||
@@ -29,9 +45,7 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
|
||||
signal: controller?.signal
|
||||
})
|
||||
} catch (error) {
|
||||
if (timeoutId) {
|
||||
globalThis.clearTimeout(timeoutId)
|
||||
}
|
||||
clearAbortTimer()
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(timeoutMessage)
|
||||
}
|
||||
@@ -39,17 +53,13 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (timeoutId) {
|
||||
globalThis.clearTimeout(timeoutId)
|
||||
}
|
||||
clearAbortTimer()
|
||||
throw new Error(await resolveStreamError(response))
|
||||
}
|
||||
|
||||
if (!response.body?.getReader) {
|
||||
const text = await response.text()
|
||||
if (timeoutId) {
|
||||
globalThis.clearTimeout(timeoutId)
|
||||
}
|
||||
clearAbortTimer()
|
||||
return consumeNdjsonText(text, handlers)
|
||||
}
|
||||
|
||||
@@ -59,11 +69,13 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
|
||||
let finalPlan = null
|
||||
|
||||
try {
|
||||
armAbortTimer(idleTimeoutMs)
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
armAbortTimer(idleTimeoutMs)
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
@@ -87,9 +99,7 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
globalThis.clearTimeout(timeoutId)
|
||||
}
|
||||
clearAbortTimer()
|
||||
}
|
||||
|
||||
if (!finalPlan) {
|
||||
|
||||
@@ -562,9 +562,17 @@ const filteredRows = computed(() => {
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
|
||||
const pageSummary = computed(() => `共 ${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`)
|
||||
|
||||
function resolveLiveDocumentRow(row) {
|
||||
return {
|
||||
...row,
|
||||
isNewDocument: isNewDocument(row, viewedDocumentKeys.value)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredRows.value.slice(start, start + pageSize.value)
|
||||
return filteredRows.value.slice(start, start + pageSize.value).map(resolveLiveDocumentRow)
|
||||
})
|
||||
|
||||
const documentLoadingSource = computed(() => (props.loading || supportingLoading.value) && !visibleRows.value.length)
|
||||
|
||||
@@ -16,6 +16,11 @@ import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSu
|
||||
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
|
||||
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
|
||||
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
|
||||
import {
|
||||
buildStewardFieldItems,
|
||||
formatStewardMissingFieldList,
|
||||
formatStewardOntologyFields
|
||||
} from './stewardPlanModel.js'
|
||||
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
|
||||
import {
|
||||
buildOperationFeedbackPayload,
|
||||
@@ -202,6 +207,10 @@ import {
|
||||
userAvatar
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
|
||||
const STEWARD_ASSISTANT_NAME = '小财管家'
|
||||
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 18
|
||||
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 14
|
||||
|
||||
const REVIEW_RISK_LEVEL_META = {
|
||||
high: {
|
||||
label: '高风险',
|
||||
@@ -1546,6 +1555,7 @@ export default {
|
||||
replaceMessage,
|
||||
scrollToBottom,
|
||||
adjustComposerTextareaHeight,
|
||||
executeStewardSuggestedAction: (message, action) => handleSuggestedAction(message, action),
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
@@ -1695,6 +1705,30 @@ export default {
|
||||
const carryText = String(actionPayload.carry_text || '').trim()
|
||||
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
if (String(actionPayload.steward_plan_id || '').trim()) {
|
||||
const confirmedByText = Boolean(action.confirmedByText)
|
||||
delete action.confirmedByText
|
||||
await submitComposerInternal({
|
||||
rawText: carryText,
|
||||
userText: action.label || '确定',
|
||||
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
|
||||
? '小财管家正在调用申请助手生成申请单核对结果...'
|
||||
: '小财管家正在调用报销助手整理报销核对结果...',
|
||||
files: carryFiles,
|
||||
skipScopeGuard: true,
|
||||
skipStewardPlan: true,
|
||||
skipUserMessage: confirmedByText,
|
||||
sessionTypeOverride: targetSessionType,
|
||||
stewardContinuation: {
|
||||
planId: String(actionPayload.steward_plan_id || '').trim(),
|
||||
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
|
||||
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
|
||||
? actionPayload.steward_remaining_tasks
|
||||
: []
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
await switchSessionType(targetSessionType)
|
||||
if (carryText) {
|
||||
composerDraft.value = carryText
|
||||
@@ -2161,6 +2195,195 @@ export default {
|
||||
return String(request.claimId || request.claim_id || '').trim()
|
||||
}
|
||||
|
||||
function buildStewardContinuationAfterAction(message, completedLabel = '当前动作已完成') {
|
||||
const continuation = message?.stewardContinuation || null
|
||||
const remainingTasks = Array.isArray(continuation?.remainingTasks)
|
||||
? continuation.remainingTasks
|
||||
: []
|
||||
if (!remainingTasks.length) {
|
||||
return null
|
||||
}
|
||||
const nextTask = remainingTasks[0]
|
||||
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
|
||||
const targetSessionType = nextTaskType === 'expense_application'
|
||||
? SESSION_TYPE_APPLICATION
|
||||
: SESSION_TYPE_EXPENSE
|
||||
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION
|
||||
? '继续创建申请单'
|
||||
: '继续填写报销单'
|
||||
const restTasks = remainingTasks.slice(1)
|
||||
return createMessage(
|
||||
'assistant',
|
||||
[
|
||||
`**${completedLabel}。**`,
|
||||
'',
|
||||
'我会重新检查剩余任务队列。',
|
||||
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}。`,
|
||||
'请回复“确定”,我再继续执行。'
|
||||
].join('\n'),
|
||||
[],
|
||||
{
|
||||
assistantName: '小财管家',
|
||||
meta: ['小财管家', '等待用户确认'],
|
||||
suggestedActions: [
|
||||
{
|
||||
label: nextLabel,
|
||||
description: '确认后小财管家继续调用对应助手完成下一步。',
|
||||
icon: targetSessionType === SESSION_TYPE_APPLICATION
|
||||
? 'mdi mdi-file-plus-outline'
|
||||
: 'mdi mdi-receipt-text-plus-outline',
|
||||
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
payload: {
|
||||
session_type: targetSessionType,
|
||||
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
|
||||
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
|
||||
auto_submit: true,
|
||||
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
|
||||
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
|
||||
steward_remaining_tasks: restTasks
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
|
||||
return {
|
||||
planId: planId || `steward-followup-${Date.now()}`,
|
||||
planStatus: 'delegating',
|
||||
summary: '',
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||||
initialSummaryOnly: true,
|
||||
thinkingEvents,
|
||||
tasks: [],
|
||||
attachmentGroups: [],
|
||||
confirmationGroups: [],
|
||||
streamStatus
|
||||
}
|
||||
}
|
||||
|
||||
function buildStewardFollowupThinkingEvents() {
|
||||
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
return [
|
||||
{
|
||||
eventId: `${eventPrefix}-review`,
|
||||
title: '复盘结果',
|
||||
content: '当前动作已完成,小财管家正在检查剩余任务队列。'
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-next`,
|
||||
title: '选择下一步',
|
||||
content: '我会继续保持一步一步推进,先说明下一步,再等你确认后执行。'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function waitStewardFollowupTick(intervalMs) {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, intervalMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function pushStewardContinuationMessage(finalMessage) {
|
||||
if (!finalMessage) {
|
||||
return
|
||||
}
|
||||
const finalText = String(finalMessage.text || '')
|
||||
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const finalActions = Array.isArray(finalMessage.suggestedActions)
|
||||
? finalMessage.suggestedActions
|
||||
: []
|
||||
finalMessage.text = ''
|
||||
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
|
||||
finalMessage.suggestedActions = []
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
|
||||
messages.value.push(finalMessage)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const typedEvents = []
|
||||
for (const eventData of buildStewardFollowupThinkingEvents()) {
|
||||
const event = {
|
||||
eventId: eventData.eventId,
|
||||
stage: 'steward_followup',
|
||||
title: eventData.title,
|
||||
content: '',
|
||||
status: 'running'
|
||||
}
|
||||
typedEvents.push(event)
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const chars = Array.from(eventData.content)
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
|
||||
event.content = chars.slice(0, index + 1).join('')
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
event.content = eventData.content
|
||||
event.status = 'completed'
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
|
||||
const chars = Array.from(finalText)
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
|
||||
finalMessage.text = chars.slice(0, index + 1).join('')
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
|
||||
finalMessage.text = finalText
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
|
||||
finalMessage.suggestedActions = finalActions
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function buildStewardContinuationCarryText(task, restTasks = []) {
|
||||
const taskType = String(task?.task_type || task?.taskType || '').trim()
|
||||
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
|
||||
const missingFields = formatStewardMissingFieldList(task?.missing_fields || task?.missingFields || [], taskType)
|
||||
const lines = [
|
||||
taskType === 'expense_application'
|
||||
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}。`
|
||||
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}。`,
|
||||
task.summary ? `任务摘要:${task.summary}` : '',
|
||||
fields ? `已识别信息:${fields}` : '',
|
||||
missingFields ? `还需要补充:${missingFields}` : '',
|
||||
'请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
|
||||
]
|
||||
if (restTasks.length) {
|
||||
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
|
||||
restTasks.forEach((item, index) => {
|
||||
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
|
||||
})
|
||||
}
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function resolveStewardMissingFieldItems(task) {
|
||||
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) {
|
||||
return task.missingFieldItems
|
||||
}
|
||||
const fields = task?.missingFields || task?.missing_fields || []
|
||||
const taskType = String(task?.taskType || task?.task_type || '').trim()
|
||||
return buildStewardFieldItems(fields, taskType)
|
||||
}
|
||||
|
||||
async function confirmApplicationSubmit() {
|
||||
const message = applicationSubmitConfirmDialog.value.message
|
||||
if (!message || submitting.value || reviewActionBusy.value) {
|
||||
@@ -2185,6 +2408,8 @@ export default {
|
||||
pendingText: '正在提交费用申请...',
|
||||
systemGenerated: true,
|
||||
skipScopeGuard: true,
|
||||
skipStewardPlan: true,
|
||||
sessionTypeOverride: SESSION_TYPE_APPLICATION,
|
||||
feedbackOperationType: 'submit_application',
|
||||
extraContext: {
|
||||
application_preview: applicationPreview,
|
||||
@@ -2229,6 +2454,10 @@ export default {
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
const stewardFollowup = buildStewardContinuationAfterAction(message, '申请单已完成')
|
||||
if (stewardFollowup) {
|
||||
await pushStewardContinuationMessage(stewardFollowup)
|
||||
}
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
@@ -2449,7 +2678,7 @@ export default {
|
||||
// submitting.value = true
|
||||
// recognizeOcrFiles(files)
|
||||
// submitting.value = false
|
||||
if (isStewardSession.value && await submitStewardPlan(options)) {
|
||||
if (isStewardSession.value && !options.skipStewardPlan && await submitStewardPlan(options)) {
|
||||
return null
|
||||
}
|
||||
if (await handleGuidedComposerSubmit(options)) {
|
||||
@@ -2570,6 +2799,7 @@ export default {
|
||||
sessionSwitchBusy: sessionSwitchBusy.value,
|
||||
applicationPreviewEditor: applicationPreviewEditor.value,
|
||||
buildMessageBubbleClass,
|
||||
resolveStewardMissingFieldItems,
|
||||
buildReviewMainMessageText,
|
||||
renderMarkdown,
|
||||
handleAssistantMarkdownClick,
|
||||
|
||||
@@ -13,7 +13,70 @@ const TASK_TYPE_LABELS = {
|
||||
|
||||
const AGENT_LABELS = {
|
||||
application_assistant: '申请助手',
|
||||
reimbursement_assistant: '报销助手'
|
||||
application: '申请助手',
|
||||
expense_application: '申请助手',
|
||||
reimbursement_assistant: '报销助手',
|
||||
reimbursement: '报销助手',
|
||||
expense: '报销助手'
|
||||
}
|
||||
|
||||
const FIELD_DISPLAY_CONFIG = {
|
||||
expense_type: {
|
||||
label: '费用类型',
|
||||
hint: '例如差旅、交通、住宿、业务招待'
|
||||
},
|
||||
time_range: {
|
||||
label: '发生时间',
|
||||
hint: '申请时填出差起止日期,报销时填费用发生日期'
|
||||
},
|
||||
location: {
|
||||
label: '地点',
|
||||
hint: '出差城市或费用发生地点'
|
||||
},
|
||||
reason: {
|
||||
label: '事由',
|
||||
hint: '出差、报销或业务活动的具体原因'
|
||||
},
|
||||
amount: {
|
||||
label: '金额',
|
||||
hint: '申请时为预计金额,报销时为实际报销金额'
|
||||
},
|
||||
transport_mode: {
|
||||
label: '出行方式',
|
||||
hint: '例如高铁、飞机、自驾、出租车'
|
||||
},
|
||||
attachments: {
|
||||
label: '附件/凭证',
|
||||
hint: '发票、行程单、付款截图或其他证明材料'
|
||||
},
|
||||
customer_name: {
|
||||
label: '客户或项目对象',
|
||||
hint: '涉及的客户、单位或项目名称'
|
||||
},
|
||||
merchant_name: {
|
||||
label: '商户/开票方',
|
||||
hint: '发票或付款凭证上的商户名称'
|
||||
},
|
||||
department_name: {
|
||||
label: '所属部门',
|
||||
hint: '申请人或费用归属部门'
|
||||
},
|
||||
employee_name: {
|
||||
label: '申请人',
|
||||
hint: '发起申请或报销的员工姓名'
|
||||
},
|
||||
employee_no: {
|
||||
label: '员工编号',
|
||||
hint: '公司内部员工编号'
|
||||
}
|
||||
}
|
||||
|
||||
const FIELD_ALIASES = {
|
||||
occurred_date: 'time_range',
|
||||
business_time: 'time_range',
|
||||
reason_value: 'reason',
|
||||
transport_type: 'transport_mode',
|
||||
application_transport_mode: 'transport_mode'
|
||||
}
|
||||
|
||||
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
|
||||
@@ -47,6 +110,7 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
|
||||
planStatus: String(rawPlan.plan_status || rawPlan.planStatus || ''),
|
||||
summary: String(rawPlan.summary || ''),
|
||||
visibleThinkingEventCount,
|
||||
initialSummaryOnly: Boolean(rawPlan.initial_summary_only || rawPlan.initialSummaryOnly || options.initialSummaryOnly),
|
||||
thinkingEvents: Array.isArray(rawPlan.thinking_events)
|
||||
? rawPlan.thinking_events.map((item) => ({
|
||||
eventId: String(item.event_id || item.eventId || ''),
|
||||
@@ -57,22 +121,30 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
|
||||
}))
|
||||
: [],
|
||||
tasks: Array.isArray(rawPlan.tasks)
|
||||
? rawPlan.tasks.map((item) => ({
|
||||
taskId: String(item.task_id || item.taskId || ''),
|
||||
taskType: String(item.task_type || item.taskType || ''),
|
||||
taskTypeLabel: TASK_TYPE_LABELS[String(item.task_type || item.taskType || '')] || '财务任务',
|
||||
assignedAgent: String(item.assigned_agent || item.assignedAgent || ''),
|
||||
assignedAgentLabel: AGENT_LABELS[String(item.assigned_agent || item.assignedAgent || '')] || '财务助手',
|
||||
title: String(item.title || ''),
|
||||
summary: String(item.summary || ''),
|
||||
status: String(item.status || ''),
|
||||
confidence: Number(item.confidence || 0),
|
||||
ontologyFields: item.ontology_fields || item.ontologyFields || {},
|
||||
missingFields: Array.isArray(item.missing_fields || item.missingFields)
|
||||
? rawPlan.tasks.map((item) => {
|
||||
const taskType = String(item.task_type || item.taskType || '')
|
||||
const missingFields = Array.isArray(item.missing_fields || item.missingFields)
|
||||
? item.missing_fields || item.missingFields
|
||||
: [],
|
||||
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
|
||||
}))
|
||||
: []
|
||||
return {
|
||||
taskId: String(item.task_id || item.taskId || ''),
|
||||
taskType,
|
||||
taskTypeLabel: TASK_TYPE_LABELS[taskType] || '财务任务',
|
||||
assignedAgent: String(item.assigned_agent || item.assignedAgent || ''),
|
||||
assignedAgentLabel:
|
||||
AGENT_LABELS[String(item.assigned_agent || item.assignedAgent || '')] ||
|
||||
AGENT_LABELS[taskType] ||
|
||||
'小财管家',
|
||||
title: String(item.title || ''),
|
||||
summary: String(item.summary || ''),
|
||||
status: String(item.status || ''),
|
||||
confidence: Number(item.confidence || 0),
|
||||
ontologyFields: item.ontology_fields || item.ontologyFields || {},
|
||||
missingFields,
|
||||
missingFieldItems: buildStewardFieldItems(missingFields, taskType),
|
||||
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
|
||||
}
|
||||
})
|
||||
: [],
|
||||
attachmentGroups: Array.isArray(rawPlan.attachment_groups)
|
||||
? rawPlan.attachment_groups.map((item) => ({
|
||||
@@ -99,34 +171,67 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
|
||||
|
||||
export function buildStewardPlanMessageText(plan) {
|
||||
const normalized = normalizeStewardPlan(plan)
|
||||
const taskLines = normalized.tasks.map((task, index) =>
|
||||
`${index + 1}. ${task.title || task.taskTypeLabel},交给${task.assignedAgentLabel}。`
|
||||
const nextContext = resolveNextActionContext(normalized)
|
||||
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
|
||||
const taskLines = orderedTasks.map((task, index) =>
|
||||
`${index + 1}. **${buildTaskOrderVerb(index)}${buildTaskOrderTarget(task)}**\n - ${buildTaskOrderActionDescription(task)}`
|
||||
)
|
||||
return [
|
||||
'**小财管家已完成任务拆解。**',
|
||||
'### 我会这样推进',
|
||||
'',
|
||||
normalized.summary || `我识别到 ${normalized.tasks.length} 个待处理任务,请确认后继续执行。`,
|
||||
`我识别到 **${normalized.tasks.length} 个财务事项**,会按顺序逐步处理,不会一次性把所有动作都执行掉。`,
|
||||
'',
|
||||
...taskLines
|
||||
].join('\n')
|
||||
...taskLines,
|
||||
'',
|
||||
'如果这个顺序没问题,请回复 **确定**。我会先进入第一步,并在具体步骤里再判断需要你补充哪些信息。'
|
||||
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
|
||||
}
|
||||
|
||||
export function buildStewardFieldItems(fields = [], taskType = '') {
|
||||
const safeFields = Array.isArray(fields) ? fields : []
|
||||
const seen = new Set()
|
||||
return safeFields
|
||||
.map((field) => normalizeFieldKey(field))
|
||||
.filter((field) => {
|
||||
if (!field || seen.has(field)) {
|
||||
return false
|
||||
}
|
||||
seen.add(field)
|
||||
return true
|
||||
})
|
||||
.map((field) => resolveFieldDisplay(field, taskType))
|
||||
}
|
||||
|
||||
export function formatStewardMissingFieldList(fields = [], taskType = '') {
|
||||
return buildStewardFieldItems(fields, taskType)
|
||||
.map((item) => item.hint ? `${item.label}(${item.hint})` : item.label)
|
||||
.join('、')
|
||||
}
|
||||
|
||||
export function formatStewardOntologyFields(fields = {}, taskType = '') {
|
||||
return Object.entries(fields || {})
|
||||
.filter(([, value]) => String(value || '').trim())
|
||||
.map(([key, value]) => {
|
||||
const field = resolveFieldDisplay(key, taskType)
|
||||
return `${field.label}:${value}`
|
||||
})
|
||||
.join(';')
|
||||
}
|
||||
|
||||
export function buildStewardSuggestedActions(plan) {
|
||||
const normalized = normalizeStewardPlan(plan)
|
||||
const taskById = new Map(normalized.tasks.map((task) => [task.taskId, task]))
|
||||
const groupById = new Map(normalized.attachmentGroups.map((group) => [group.groupId, group]))
|
||||
return normalized.confirmationGroups.map((action) => {
|
||||
const actionType = String(action.action_type || action.actionType || '').trim()
|
||||
const taskId = String(action.target_task_id || action.targetTaskId || '').trim()
|
||||
const groupId = String(action.attachment_group_id || action.attachmentGroupId || '').trim()
|
||||
const task = taskById.get(taskId)
|
||||
const group = groupById.get(groupId)
|
||||
const targetSessionType = actionType === 'confirm_create_application'
|
||||
? SESSION_TYPE_APPLICATION
|
||||
: SESSION_TYPE_EXPENSE
|
||||
return {
|
||||
label: String(action.label || '确认继续处理'),
|
||||
description: String(action.description || ''),
|
||||
const nextContext = resolveNextActionContext(normalized)
|
||||
if (!nextContext) {
|
||||
return []
|
||||
}
|
||||
const { action, actionType, task, group } = nextContext
|
||||
const targetSessionType = actionType === 'confirm_create_application'
|
||||
? SESSION_TYPE_APPLICATION
|
||||
: SESSION_TYPE_EXPENSE
|
||||
return [
|
||||
{
|
||||
label: buildNextActionLabel(actionType),
|
||||
description: buildNextActionDescription(actionType, normalized, task, group),
|
||||
icon: actionType === 'confirm_create_application'
|
||||
? 'mdi mdi-file-plus-outline'
|
||||
: actionType === 'confirm_attachment_group'
|
||||
@@ -135,17 +240,198 @@ export function buildStewardSuggestedActions(plan) {
|
||||
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
payload: {
|
||||
session_type: targetSessionType,
|
||||
carry_text: buildStewardCarryText(actionType, task, group),
|
||||
carry_text: buildStewardCarryText(actionType, task, group, normalized),
|
||||
carry_files: actionType !== 'confirm_create_application',
|
||||
auto_submit: true,
|
||||
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
|
||||
steward_plan_id: normalized.planId
|
||||
steward_plan_id: normalized.planId,
|
||||
steward_next_task_id: task?.taskId || '',
|
||||
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
|
||||
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
function buildStewardCarryText(actionType, task, group) {
|
||||
function resolveNextActionContext(normalized) {
|
||||
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
|
||||
const applicationAction = applicationTask
|
||||
? findConfirmationAction(normalized, 'confirm_create_application', applicationTask.taskId)
|
||||
: null
|
||||
if (applicationAction) {
|
||||
return {
|
||||
action: applicationAction,
|
||||
actionType: 'confirm_create_application',
|
||||
task: applicationTask,
|
||||
group: null
|
||||
}
|
||||
}
|
||||
|
||||
const reimbursementTask = normalized.tasks.find((task) => task.taskType === 'reimbursement')
|
||||
const reimbursementAction = reimbursementTask
|
||||
? findConfirmationAction(normalized, 'confirm_create_reimbursement_draft', reimbursementTask.taskId)
|
||||
: null
|
||||
if (reimbursementAction) {
|
||||
return {
|
||||
action: reimbursementAction,
|
||||
actionType: 'confirm_create_reimbursement_draft',
|
||||
task: reimbursementTask,
|
||||
group: findAttachmentGroupForTask(normalized, reimbursementTask.taskId)
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentAction = normalized.confirmationGroups.find((action) =>
|
||||
normalizeActionType(action) === 'confirm_attachment_group'
|
||||
)
|
||||
if (attachmentAction) {
|
||||
const groupId = String(attachmentAction.attachment_group_id || attachmentAction.attachmentGroupId || '').trim()
|
||||
const group = normalized.attachmentGroups.find((item) => item.groupId === groupId)
|
||||
const task = normalized.tasks.find((item) => item.taskId === group?.targetTaskId)
|
||||
return {
|
||||
action: attachmentAction,
|
||||
actionType: 'confirm_attachment_group',
|
||||
task,
|
||||
group
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackAction = normalized.confirmationGroups[0]
|
||||
if (!fallbackAction) {
|
||||
return null
|
||||
}
|
||||
const actionType = normalizeActionType(fallbackAction)
|
||||
const taskId = String(fallbackAction.target_task_id || fallbackAction.targetTaskId || '').trim()
|
||||
return {
|
||||
action: fallbackAction,
|
||||
actionType,
|
||||
task: normalized.tasks.find((task) => task.taskId === taskId),
|
||||
group: null
|
||||
}
|
||||
}
|
||||
|
||||
function findConfirmationAction(normalized, actionType, taskId) {
|
||||
return normalized.confirmationGroups.find((action) =>
|
||||
normalizeActionType(action) === actionType
|
||||
&& String(action.target_task_id || action.targetTaskId || '').trim() === taskId
|
||||
) || normalized.confirmationGroups.find((action) => normalizeActionType(action) === actionType)
|
||||
}
|
||||
|
||||
function findAttachmentGroupForTask(normalized, taskId) {
|
||||
return normalized.attachmentGroups.find((group) => group.targetTaskId === taskId)
|
||||
|| normalized.attachmentGroups[0]
|
||||
|| null
|
||||
}
|
||||
|
||||
function normalizeActionType(action) {
|
||||
return String(action?.action_type || action?.actionType || '').trim()
|
||||
}
|
||||
|
||||
function buildStewardExecutionSummary(normalized) {
|
||||
const attachmentCount = normalized.attachmentGroups
|
||||
.reduce((total, group) => total + group.attachmentNames.length, 0)
|
||||
const summary = [`我识别到 **${normalized.tasks.length} 个待处理任务**`]
|
||||
if (attachmentCount) {
|
||||
summary.push(`并形成 ${attachmentCount} 份附件的归集建议`)
|
||||
}
|
||||
summary.push(`。${buildTaskOrderDescription(normalized)}`)
|
||||
return summary.join('')
|
||||
}
|
||||
|
||||
function buildOrderedStewardTasks(normalized, nextTask = null) {
|
||||
if (!nextTask?.taskId) {
|
||||
return normalized.tasks
|
||||
}
|
||||
return [
|
||||
nextTask,
|
||||
...normalized.tasks.filter((task) => task.taskId !== nextTask.taskId)
|
||||
]
|
||||
}
|
||||
|
||||
function buildTaskOrderVerb(index) {
|
||||
if (index === 0) {
|
||||
return '先'
|
||||
}
|
||||
if (index === 1) {
|
||||
return '再'
|
||||
}
|
||||
return '然后'
|
||||
}
|
||||
|
||||
function buildTaskOrderTarget(task) {
|
||||
const title = task.title || task.taskTypeLabel
|
||||
if (task.taskType === 'expense_application') {
|
||||
return `创建“${title}”`
|
||||
}
|
||||
if (task.taskType === 'reimbursement') {
|
||||
return `处理“${title}”`
|
||||
}
|
||||
return `处理“${title}”`
|
||||
}
|
||||
|
||||
function buildTaskOrderActionDescription(task) {
|
||||
const agent = task.assignedAgentLabel || '对应助手'
|
||||
if (task.taskType === 'expense_application') {
|
||||
return `交给${agent}生成申请单核对结果,确认无误后再进入后续动作。`
|
||||
}
|
||||
if (task.taskType === 'reimbursement') {
|
||||
return `交给${agent}整理报销核对结果,等前一步完成后再继续推进。`
|
||||
}
|
||||
return `交给${agent}处理,执行前会先让你确认。`
|
||||
}
|
||||
|
||||
function buildTaskOrderDescription(normalized) {
|
||||
const hasApplication = normalized.tasks.some((task) => task.taskType === 'expense_application')
|
||||
const hasReimbursement = normalized.tasks.some((task) => task.taskType === 'reimbursement')
|
||||
if (hasApplication && hasReimbursement) {
|
||||
return '处理顺序是:先创建申请单,再引导填写报销单。'
|
||||
}
|
||||
if (hasApplication) {
|
||||
return '我会先引导创建申请单并等待你确认。'
|
||||
}
|
||||
if (hasReimbursement) {
|
||||
return '我会引导填写报销单并等待你确认。'
|
||||
}
|
||||
return '我会按识别顺序逐项推进,并在执行前等待你确认。'
|
||||
}
|
||||
|
||||
function buildNextTaskLead(task) {
|
||||
if (task.taskType === 'expense_application') {
|
||||
return `先创建“${task.title || task.taskTypeLabel}”`
|
||||
}
|
||||
if (task.taskType === 'reimbursement') {
|
||||
return `继续填写“${task.title || task.taskTypeLabel}”`
|
||||
}
|
||||
return `处理“${task.title || task.taskTypeLabel}”`
|
||||
}
|
||||
|
||||
function buildNextActionLabel(actionType) {
|
||||
if (actionType === 'confirm_create_application') {
|
||||
return '确定,先创建申请单'
|
||||
}
|
||||
if (actionType === 'confirm_attachment_group') {
|
||||
return '确定,确认附件归集'
|
||||
}
|
||||
return '确定,继续填写报销单'
|
||||
}
|
||||
|
||||
function buildNextActionDescription(actionType, normalized, task, group) {
|
||||
const remainingCount = normalized.tasks.filter((item) => item.taskId !== task?.taskId).length
|
||||
if (actionType === 'confirm_create_application') {
|
||||
return remainingCount > 0
|
||||
? '申请助手会先生成申请单核对结果,完成后再继续引导后续报销。'
|
||||
: '申请助手会生成申请单核对结果,入库前仍需确认。'
|
||||
}
|
||||
if (actionType === 'confirm_attachment_group') {
|
||||
return group?.attachmentNames?.length
|
||||
? `先归集 ${group.attachmentNames.length} 份附件,再进入报销核对。`
|
||||
: '先确认附件归集,再进入报销核对。'
|
||||
}
|
||||
return group?.attachmentNames?.length
|
||||
? `报销助手会带入 ${group.attachmentNames.length} 份相关附件生成核对结果。`
|
||||
: '报销助手会根据当前任务生成报销核对结果。'
|
||||
}
|
||||
|
||||
function buildStewardCarryText(actionType, task, group, normalized = null) {
|
||||
if (actionType === 'confirm_attachment_group' && group) {
|
||||
return [
|
||||
`我确认将以下附件归集为${group.sceneLabel || '当前报销任务'},请继续整理报销核对信息。`,
|
||||
@@ -160,14 +446,79 @@ function buildStewardCarryText(actionType, task, group) {
|
||||
return '我确认继续处理这项财务任务,请按现有流程核对信息。'
|
||||
}
|
||||
|
||||
const fields = Object.entries(task.ontologyFields || {})
|
||||
.filter(([, value]) => String(value || '').trim())
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
return [
|
||||
`我确认处理“小财管家”识别的任务:${task.title || task.taskTypeLabel}。`,
|
||||
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
|
||||
const missingFields = formatStewardMissingFieldList(task.missingFields || [], task.taskType)
|
||||
const lines = [
|
||||
actionType === 'confirm_create_application'
|
||||
? `小财管家已完成意图识别,请先创建申请单:${task.title || task.taskTypeLabel}。`
|
||||
: `小财管家已完成意图识别,请继续填写报销单:${task.title || task.taskTypeLabel}。`,
|
||||
task.summary ? `任务摘要:${task.summary}` : '',
|
||||
fields.length ? `本体字段:${fields.join(';')}` : '',
|
||||
task.missingFields.length ? `待补充字段:${task.missingFields.join('、')}` : '',
|
||||
'请按现有流程生成核对结果,并在需要入库、绑定附件或提交审批前让我再次确认。'
|
||||
].filter(Boolean).join('\n')
|
||||
fields ? `已识别信息:${fields}` : '',
|
||||
group?.attachmentNames?.length ? `相关附件:${group.attachmentNames.join('、')}` : '',
|
||||
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
|
||||
missingFields ? `还需要补充:${missingFields}` : '',
|
||||
actionType === 'confirm_create_application'
|
||||
? '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
|
||||
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
|
||||
]
|
||||
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
|
||||
if (remainingTaskText) {
|
||||
lines.push(remainingTaskText)
|
||||
}
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function normalizeFieldKey(field) {
|
||||
const key = String(field || '').trim()
|
||||
return FIELD_ALIASES[key] || key
|
||||
}
|
||||
|
||||
function resolveFieldDisplay(field, taskType = '') {
|
||||
const key = normalizeFieldKey(field)
|
||||
const config = FIELD_DISPLAY_CONFIG[key] || {
|
||||
label: key.replace(/_/g, ' '),
|
||||
hint: ''
|
||||
}
|
||||
if (key === 'amount') {
|
||||
return {
|
||||
key,
|
||||
label: taskType === 'expense_application' ? '预计金额' : '报销金额',
|
||||
hint: taskType === 'expense_application'
|
||||
? '本次申请预计发生的费用'
|
||||
: '本次需要报销的实际金额'
|
||||
}
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: config.label,
|
||||
hint: config.hint
|
||||
}
|
||||
}
|
||||
|
||||
function buildRemainingTaskText(normalized, currentTaskId) {
|
||||
const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId)
|
||||
if (!remainingTasks.length) {
|
||||
return ''
|
||||
}
|
||||
const taskLines = remainingTasks.map((task, index) =>
|
||||
`${index + 1}. ${task.title || task.taskTypeLabel}(${task.assignedAgentLabel}):${task.summary || '待继续核对'}`
|
||||
)
|
||||
return [
|
||||
'当前步骤完成后,请继续引导我处理后续任务:',
|
||||
...taskLines
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildRemainingTaskPayload(normalized, currentTaskId) {
|
||||
return normalized.tasks
|
||||
.filter((task) => task.taskId !== currentTaskId)
|
||||
.map((task) => ({
|
||||
task_id: task.taskId,
|
||||
task_type: task.taskType,
|
||||
title: task.title,
|
||||
summary: task.summary,
|
||||
assigned_agent: task.assignedAgent,
|
||||
ontology_fields: task.ontologyFields || {},
|
||||
missing_fields: task.missingFields || []
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
} from './stewardPlanModel.js'
|
||||
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
|
||||
|
||||
const STEWARD_TYPEWRITER_INTERVAL_MS = 18
|
||||
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 14
|
||||
|
||||
export function useStewardPlanFlow({
|
||||
activeSessionType,
|
||||
attachedFiles,
|
||||
@@ -21,17 +24,28 @@ export function useStewardPlanFlow({
|
||||
replaceMessage,
|
||||
scrollToBottom,
|
||||
adjustComposerTextareaHeight,
|
||||
executeStewardSuggestedAction,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
toast
|
||||
}) {
|
||||
const stewardTypewriterTimers = new Map()
|
||||
let stewardTypewriterRunId = 0
|
||||
let stewardThinkingQueue = Promise.resolve()
|
||||
|
||||
function isStewardSession() {
|
||||
return String(activeSessionType.value || '').trim() === SESSION_TYPE_STEWARD
|
||||
}
|
||||
|
||||
function clearStewardThinkingTimers() {
|
||||
// 保留给页面卸载调用;流式版不再使用前端延时器。
|
||||
stewardTypewriterRunId += 1
|
||||
stewardThinkingQueue = Promise.resolve()
|
||||
for (const [timerId, resolve] of stewardTypewriterTimers.entries()) {
|
||||
globalThis.clearTimeout(timerId)
|
||||
resolve()
|
||||
}
|
||||
stewardTypewriterTimers.clear()
|
||||
}
|
||||
|
||||
async function submitStewardPlan(options = {}) {
|
||||
@@ -44,7 +58,26 @@ export function useStewardPlanFlow({
|
||||
|
||||
const fileNames = files.map((file) => file.name).filter(Boolean)
|
||||
const userText = String(options.userText || rawText || `我上传了 ${fileNames.length} 份附件,请小财管家先归集任务。`).trim()
|
||||
if (isStewardConfirmationText(rawText) && !files.length) {
|
||||
const pendingContext = findPendingStewardAction()
|
||||
if (pendingContext) {
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText))
|
||||
}
|
||||
pendingContext.action.confirmedByText = true
|
||||
composerDraft.value = ''
|
||||
persistSessionState()
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
await executeStewardSuggestedAction?.(pendingContext.message, pendingContext.action)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const streamRunId = beginStewardStreamRun()
|
||||
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
@@ -75,23 +108,34 @@ export function useStewardPlanFlow({
|
||||
files,
|
||||
currentUser: currentUser.value || {}
|
||||
})
|
||||
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload)
|
||||
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload, streamRunId)
|
||||
await waitForStewardThinkingQueue(streamRunId)
|
||||
const typedThinkingEvents = resolveStewardThinkingEvents(pendingMessage.id)
|
||||
const normalizedPlan = normalizeStewardPlan(plan, {
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||||
initialSummaryOnly: true
|
||||
})
|
||||
replaceMessage(pendingMessage.id, createMessage('assistant', buildStewardPlanMessageText(plan), [], {
|
||||
if (typedThinkingEvents.length) {
|
||||
normalizedPlan.thinkingEvents = typedThinkingEvents
|
||||
normalizedPlan.visibleThinkingEventCount = Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
const finalText = buildStewardPlanMessageText(plan)
|
||||
const suggestedActions = buildStewardSuggestedActions(plan)
|
||||
replaceMessage(pendingMessage.id, createMessage('assistant', '', [], {
|
||||
id: pendingMessage.id,
|
||||
assistantName: '小财管家',
|
||||
meta: ['小财管家', '等待确认'],
|
||||
meta: ['小财管家', '输出中'],
|
||||
stewardPlan: {
|
||||
...normalizedPlan,
|
||||
streamStatus: 'completed'
|
||||
},
|
||||
suggestedActions: buildStewardSuggestedActions(plan)
|
||||
streamStatus: 'typing'
|
||||
}
|
||||
}))
|
||||
await typeStewardPlanText(pendingMessage.id, finalText, normalizedPlan, suggestedActions, streamRunId)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
} catch (error) {
|
||||
replaceMessage(pendingMessage.id, createMessage('assistant', error?.message || '小财管家规划失败,请稍后重试。', [], {
|
||||
id: pendingMessage.id,
|
||||
assistantName: '小财管家',
|
||||
meta: ['小财管家', '规划失败']
|
||||
}))
|
||||
@@ -110,12 +154,76 @@ export function useStewardPlanFlow({
|
||||
return true
|
||||
}
|
||||
|
||||
function fetchPlanWithStreaming(messageId, requestPayload) {
|
||||
function beginStewardStreamRun() {
|
||||
const runId = stewardTypewriterRunId + 1
|
||||
stewardTypewriterRunId = runId
|
||||
stewardThinkingQueue = Promise.resolve()
|
||||
return runId
|
||||
}
|
||||
|
||||
async function typeStewardPlanText(messageId, finalText, normalizedPlan, suggestedActions = [], runId = stewardTypewriterRunId) {
|
||||
const chars = Array.from(String(finalText || ''))
|
||||
const total = chars.length
|
||||
let index = 0
|
||||
|
||||
while (index < total) {
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
await waitStewardTypewriterTick(STEWARD_TYPEWRITER_INTERVAL_MS)
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
index += 1
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
message.text = chars.slice(0, index).join('')
|
||||
message.meta = ['小财管家', '输出中']
|
||||
message.stewardPlan = {
|
||||
...(message.stewardPlan || normalizedPlan),
|
||||
...normalizedPlan,
|
||||
streamStatus: 'typing'
|
||||
}
|
||||
if (index % 4 === 0 || index === total) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message || runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
message.text = finalText
|
||||
message.meta = ['小财管家', '等待用户确认']
|
||||
message.stewardPlan = {
|
||||
...(message.stewardPlan || normalizedPlan),
|
||||
...normalizedPlan,
|
||||
streamStatus: 'completed'
|
||||
}
|
||||
message.suggestedActions = suggestedActions
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function waitStewardTypewriterTick(intervalMs = STEWARD_TYPEWRITER_INTERVAL_MS) {
|
||||
return new Promise((resolve) => {
|
||||
const timerId = globalThis.setTimeout(() => {
|
||||
stewardTypewriterTimers.delete(timerId)
|
||||
resolve()
|
||||
}, intervalMs)
|
||||
stewardTypewriterTimers.set(timerId, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
function fetchPlanWithStreaming(messageId, requestPayload, runId) {
|
||||
if (typeof fetchStewardPlanStream === 'function') {
|
||||
return fetchStewardPlanStream(requestPayload, {
|
||||
onEvent: (event) => handleStreamEvent(messageId, event)
|
||||
onEvent: (event) => handleStreamEvent(messageId, event, runId)
|
||||
}, {
|
||||
timeoutMs: 20000,
|
||||
timeoutMs: 12000,
|
||||
idleTimeoutMs: 120000,
|
||||
timeoutMessage: '小财管家任务规划超时,请稍后重试。'
|
||||
})
|
||||
}
|
||||
@@ -126,10 +234,68 @@ export function useStewardPlanFlow({
|
||||
})
|
||||
}
|
||||
|
||||
function handleStreamEvent(messageId, event) {
|
||||
function handleStreamEvent(messageId, event, runId = stewardTypewriterRunId) {
|
||||
if (event.event !== 'thinking') {
|
||||
return
|
||||
}
|
||||
stewardThinkingQueue = stewardThinkingQueue
|
||||
.then(() => typeStewardThinkingEvent(messageId, event.data, runId))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async function typeStewardThinkingEvent(messageId, eventData, runId) {
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
const eventId = String(eventData?.event_id || eventData?.eventId || `thinking-${Date.now()}`).trim()
|
||||
const title = String(eventData?.title || '').trim()
|
||||
const fullContent = String(eventData?.content || '').trim()
|
||||
appendStewardThinkingEvent(messageId, {
|
||||
...eventData,
|
||||
event_id: eventId,
|
||||
eventId,
|
||||
title,
|
||||
content: '',
|
||||
status: 'running'
|
||||
}, runId)
|
||||
|
||||
const chars = Array.from(fullContent)
|
||||
let index = 0
|
||||
while (index < chars.length) {
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
await waitStewardTypewriterTick(STEWARD_THINKING_TYPEWRITER_INTERVAL_MS)
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
index += 1
|
||||
updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId)
|
||||
}
|
||||
|
||||
updateStewardThinkingEvent(messageId, eventId, fullContent, 'completed', runId)
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
function waitForStewardThinkingQueue(runId) {
|
||||
return stewardThinkingQueue.then(() => {
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resolveStewardThinkingEvents(messageId) {
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
return Array.isArray(message?.stewardPlan?.thinkingEvents)
|
||||
? message.stewardPlan.thinkingEvents
|
||||
: []
|
||||
}
|
||||
|
||||
function appendStewardThinkingEvent(messageId, eventData, runId) {
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message?.stewardPlan) return
|
||||
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
|
||||
@@ -137,7 +303,7 @@ export function useStewardPlanFlow({
|
||||
: []
|
||||
const normalizedPlan = normalizeStewardPlan({
|
||||
...message.stewardPlan,
|
||||
thinking_events: [...existingEvents, event.data]
|
||||
thinking_events: [...existingEvents, eventData]
|
||||
}, {
|
||||
visibleThinkingEventCount: existingEvents.length + 1
|
||||
})
|
||||
@@ -150,6 +316,76 @@ export function useStewardPlanFlow({
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function updateStewardThinkingEvent(messageId, eventId, content, status, runId) {
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message?.stewardPlan) return
|
||||
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
|
||||
? message.stewardPlan.thinkingEvents
|
||||
: []
|
||||
const nextEvents = existingEvents.map((item) => (
|
||||
String(item.eventId || item.event_id || '').trim() === eventId
|
||||
? {
|
||||
...item,
|
||||
content,
|
||||
status
|
||||
}
|
||||
: item
|
||||
))
|
||||
const normalizedPlan = normalizeStewardPlan({
|
||||
...message.stewardPlan,
|
||||
thinking_events: nextEvents
|
||||
}, {
|
||||
visibleThinkingEventCount: nextEvents.length
|
||||
})
|
||||
message.stewardPlan = {
|
||||
...message.stewardPlan,
|
||||
...normalizedPlan,
|
||||
streamStatus: 'streaming'
|
||||
}
|
||||
if (content.length % 4 === 0 || status === 'completed') {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
|
||||
function findPendingStewardAction() {
|
||||
for (const message of [...messages.value].reverse()) {
|
||||
if (
|
||||
message?.role === 'assistant'
|
||||
&& isPendingStewardActionMessage(message)
|
||||
&& !message.suggestedActionsLocked
|
||||
&& Array.isArray(message.suggestedActions)
|
||||
&& message.suggestedActions.length
|
||||
) {
|
||||
return {
|
||||
message,
|
||||
action: message.suggestedActions[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isPendingStewardActionMessage(message) {
|
||||
if (message?.stewardPlan) {
|
||||
return message.stewardPlan.streamStatus !== 'streaming'
|
||||
}
|
||||
return (
|
||||
String(message?.assistantName || '').trim() === '小财管家'
|
||||
&& Array.isArray(message?.suggestedActions)
|
||||
&& message.suggestedActions.some((action) =>
|
||||
String(action?.payload?.steward_plan_id || '').trim()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function isStewardConfirmationText(value) {
|
||||
const normalized = String(value || '').replace(/\s+/g, '')
|
||||
return /^(确定|确认|可以|好的|好|继续|继续执行|执行|开始执行|没问题|同意)$/.test(normalized)
|
||||
}
|
||||
|
||||
return {
|
||||
isStewardSession,
|
||||
submitStewardPlan,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
||||
import {
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
applyApplicationBusinessTimeContext,
|
||||
applyApplicationPolicyEstimateError,
|
||||
applyApplicationPolicyEstimateResult,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
normalizeApplicationPreview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
||||
@@ -24,6 +26,11 @@ import {
|
||||
shouldUseBudgetCompileReport
|
||||
} from './budgetAssistantReportModel.js'
|
||||
|
||||
const STEWARD_ASSISTANT_NAME = '小财管家'
|
||||
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18
|
||||
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14
|
||||
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||||
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
MAX_ATTACHMENTS,
|
||||
@@ -108,6 +115,228 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
const pendingAttachmentAssociations = new Map()
|
||||
|
||||
function isStewardDelegatedRun(options = {}) {
|
||||
return Boolean(options?.stewardContinuation && typeof options.stewardContinuation === 'object')
|
||||
}
|
||||
|
||||
function resolveStewardDelegatedActionLabel(sessionType = '') {
|
||||
return String(sessionType || '').trim() === 'application'
|
||||
? '申请单核对'
|
||||
: '报销单核对'
|
||||
}
|
||||
|
||||
function buildStewardDelegatedPlan(continuation = null, thinkingEvents = [], streamStatus = 'streaming') {
|
||||
return {
|
||||
planId: String(continuation?.planId || continuation?.plan_id || 'steward_delegation').trim(),
|
||||
planStatus: 'delegating',
|
||||
summary: '',
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||||
initialSummaryOnly: true,
|
||||
thinkingEvents,
|
||||
tasks: [],
|
||||
attachmentGroups: [],
|
||||
confirmationGroups: [],
|
||||
streamStatus
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewMissingFieldsForSteward(preview = {}) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||||
}
|
||||
|
||||
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview)
|
||||
if (!missingFields.includes('出行方式')) {
|
||||
return []
|
||||
}
|
||||
const iconMap = {
|
||||
火车: 'mdi mdi-train',
|
||||
飞机: 'mdi mdi-airplane',
|
||||
轮船: 'mdi mdi-ferry'
|
||||
}
|
||||
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
|
||||
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
|
||||
label: mode,
|
||||
description: `选择${mode}作为本次出行方式,并同步费用测算。`,
|
||||
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
|
||||
payload: {
|
||||
field_key: 'transportMode',
|
||||
field_label: '出行方式',
|
||||
value: mode
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
|
||||
if (!missingFields.length) {
|
||||
return fallbackText
|
||||
}
|
||||
|
||||
if (missingFields.includes('出行方式')) {
|
||||
return [
|
||||
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
|
||||
'',
|
||||
'**原因是:还缺少“出行方式”。**',
|
||||
'',
|
||||
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
|
||||
'',
|
||||
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
|
||||
'',
|
||||
`**还需要你补充:${missingFields.join('、')}。**`,
|
||||
'',
|
||||
`请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
|
||||
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
|
||||
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const events = [
|
||||
{
|
||||
eventId: `${eventPrefix}-confirm`,
|
||||
title: '接收确认',
|
||||
content: '已收到你的确认,小财管家继续推进当前任务。'
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-coordinate`,
|
||||
title: '协调能力',
|
||||
content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。`
|
||||
}
|
||||
]
|
||||
const applicationMissingFields = context.applicationPreview
|
||||
? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview)
|
||||
: []
|
||||
if (applicationMissingFields.length) {
|
||||
events.push({
|
||||
eventId: `${eventPrefix}-gap`,
|
||||
title: '识别缺口',
|
||||
content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。`
|
||||
})
|
||||
}
|
||||
events.push(
|
||||
{
|
||||
eventId: `${eventPrefix}-output`,
|
||||
title: '准备输出',
|
||||
content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。'
|
||||
}
|
||||
)
|
||||
return events
|
||||
}
|
||||
|
||||
function resolveStewardDelegatedFinalMeta(finalExtras = {}) {
|
||||
const sourceMeta = Array.isArray(finalExtras.meta) ? finalExtras.meta : []
|
||||
const sourceLabel = sourceMeta.find((item) =>
|
||||
String(item || '').trim() && String(item || '').trim() !== STEWARD_ASSISTANT_NAME
|
||||
)
|
||||
const requiresConfirmation = Boolean(
|
||||
finalExtras.applicationPreview ||
|
||||
finalExtras.reviewPayload ||
|
||||
(Array.isArray(finalExtras.suggestedActions) && finalExtras.suggestedActions.length)
|
||||
)
|
||||
return [
|
||||
STEWARD_ASSISTANT_NAME,
|
||||
requiresConfirmation ? '等待用户确认' : '已完成',
|
||||
sourceLabel || ''
|
||||
].filter(Boolean).slice(0, 3)
|
||||
}
|
||||
|
||||
function waitStewardDelegatedTick(intervalMs) {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, intervalMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
|
||||
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
message.text = ''
|
||||
message.assistantName = STEWARD_ASSISTANT_NAME
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '思考中']
|
||||
message.suggestedActions = []
|
||||
message.stewardContinuation = continuation
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [], 'streaming')
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const typedEvents = []
|
||||
const thinkingEvents = buildStewardDelegatedThinkingEvents(context.sessionType, continuation, context)
|
||||
for (const eventData of thinkingEvents) {
|
||||
const event = {
|
||||
eventId: eventData.eventId,
|
||||
stage: 'delegated_action',
|
||||
title: eventData.title,
|
||||
content: '',
|
||||
status: 'running'
|
||||
}
|
||||
typedEvents.push(event)
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const chars = Array.from(String(eventData.content || ''))
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
|
||||
event.content = chars.slice(0, index + 1).join('')
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
event.content = String(eventData.content || '')
|
||||
event.status = 'completed'
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
const text = String(finalText || '')
|
||||
message.text = ''
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||||
const chars = Array.from(text)
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
|
||||
message.text = chars.slice(0, index + 1).join('')
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(message, finalExtras, {
|
||||
id: messageId,
|
||||
text,
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: resolveStewardDelegatedFinalMeta(finalExtras),
|
||||
stewardContinuation: continuation,
|
||||
stewardPlan: buildStewardDelegatedPlan(continuation, [...typedEvents], 'completed')
|
||||
})
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function resetStewardDelegatedInsightState() {
|
||||
resetFlowRun({ startedAt: 0, openDrawer: false })
|
||||
insightPanelCollapsed.value = true
|
||||
currentInsight.value = {
|
||||
intent: 'welcome',
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
function isSubmittedApplicationDraftPayload(draftPayload) {
|
||||
return (
|
||||
String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||||
@@ -376,16 +605,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
||||
function buildBackendMessage(rawText, fileNames, ocrSummary = '', sessionTypeOverride = '') {
|
||||
const parts = []
|
||||
const normalizedText = String(rawText || '').trim()
|
||||
const sessionType = String(activeSessionType.value || '').trim()
|
||||
const sessionType = String(sessionTypeOverride || activeSessionType.value || '').trim()
|
||||
const isKnowledgeMessage = sessionType === 'knowledge'
|
||||
|
||||
if (normalizedText) {
|
||||
parts.push(normalizedText)
|
||||
} else if (fileNames.length) {
|
||||
parts.push(
|
||||
isKnowledgeSession.value
|
||||
isKnowledgeMessage
|
||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
|
||||
: sessionType === 'application'
|
||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
|
||||
@@ -440,7 +670,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return currentUser.value || user
|
||||
}
|
||||
|
||||
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null) {
|
||||
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
|
||||
const user = await resolveApplicationPreviewUser()
|
||||
const localPreview = applyApplicationBusinessTimeContext(
|
||||
buildLocalApplicationPreview(rawText, user),
|
||||
@@ -474,7 +704,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
context_json: {
|
||||
...buildExpenseApplicationOntologyContext(user),
|
||||
session_type: activeSessionType.value,
|
||||
session_type: String(sessionTypeOverride || activeSessionType.value || '').trim(),
|
||||
entry_source: props.entrySource,
|
||||
user_input_text: rawText
|
||||
}
|
||||
@@ -516,7 +746,10 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const rawText = resolveComposerSubmitText(options.rawText).trim()
|
||||
const systemGenerated = Boolean(options.systemGenerated)
|
||||
const appendToCurrentFlow = Boolean(options.appendToCurrentFlow)
|
||||
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
|
||||
const effectiveSessionType = String(options.sessionTypeOverride || activeSessionType.value || '').trim()
|
||||
const stewardDelegated = isStewardDelegatedRun(options)
|
||||
const effectiveIsKnowledgeSession = effectiveSessionType === 'knowledge'
|
||||
const normalizedFiles = effectiveIsKnowledgeSession ? [] : Array.from(options.files ?? attachedFiles.value)
|
||||
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
|
||||
const files = fileMergeResult.files
|
||||
const detailScopedClaimId = resolveDetailScopedClaimId()
|
||||
@@ -551,8 +784,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
detail_scope_claim_id: detailScopedClaimId
|
||||
}
|
||||
: optionExtraContext
|
||||
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = isKnowledgeSession.value
|
||||
const selectedBusinessTimeContext = effectiveIsKnowledgeSession ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = effectiveIsKnowledgeSession
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
@@ -569,15 +802,15 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
|
||||
)
|
||||
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
|
||||
const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
const waitForExpenseIntentConfirmation = !stewardDelegated && shouldRequestExpenseIntentConfirmation(rawText, {
|
||||
sessionType: effectiveSessionType,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
hasSelectedExpenseType,
|
||||
hasConfirmedExpenseIntent
|
||||
})
|
||||
const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
const waitForExpenseSceneSelection = !stewardDelegated && !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
|
||||
sessionType: effectiveSessionType,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
hasSelectedExpenseType
|
||||
@@ -587,7 +820,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
String(options.userText || '').trim() ||
|
||||
resolveComposerDisplaySubmitText(rawText) ||
|
||||
rawText ||
|
||||
(isKnowledgeSession.value
|
||||
(effectiveIsKnowledgeSession
|
||||
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
|
||||
: resolvedUploadDisposition === 'continue_existing'
|
||||
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
|
||||
@@ -596,7 +829,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||
|
||||
if (shouldUseBudgetCompileReport(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
sessionType: effectiveSessionType,
|
||||
entrySource: props.entrySource,
|
||||
budgetContext: props.initialBudgetContext
|
||||
}) && !reviewAction) {
|
||||
@@ -627,7 +860,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
})
|
||||
}
|
||||
|
||||
const scopeGuard = resolveAssistantScopeGuard(rawText, activeSessionType.value, {
|
||||
const scopeGuard = resolveAssistantScopeGuard(rawText, effectiveSessionType, {
|
||||
attachmentCount: files.length,
|
||||
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
|
||||
reviewAction
|
||||
@@ -652,34 +885,45 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
|
||||
if (shouldUseLocalApplicationPreview(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
sessionType: effectiveSessionType,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
systemGenerated
|
||||
})) {
|
||||
const intentStartedAt = Date.now()
|
||||
const reviewStartedAt = intentStartedAt
|
||||
resetFlowRun()
|
||||
startFlowStep('intent', {
|
||||
title: '业务意图识别',
|
||||
tool: 'ontology.intent_detection',
|
||||
detail: '正在识别是否为费用申请事项...'
|
||||
})
|
||||
startFlowStep('application-review-preview', {
|
||||
title: '申请信息核对',
|
||||
tool: 'ontology.application_review',
|
||||
detail: '正在复核申请信息,并查询交通票价...'
|
||||
})
|
||||
if (stewardDelegated) {
|
||||
resetStewardDelegatedInsightState()
|
||||
} else {
|
||||
resetFlowRun()
|
||||
startFlowStep('intent', {
|
||||
title: '业务意图识别',
|
||||
tool: 'ontology.intent_detection',
|
||||
detail: '正在识别是否为费用申请事项...'
|
||||
})
|
||||
startFlowStep('application-review-preview', {
|
||||
title: '申请信息核对',
|
||||
tool: 'ontology.application_review',
|
||||
detail: '正在复核申请信息,并查询交通票价...'
|
||||
})
|
||||
}
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
const pendingMessage = createMessage(
|
||||
'assistant',
|
||||
'正在复核申请信息,并查询交通票价,请稍候。',
|
||||
stewardDelegated ? '' : '正在复核申请信息,并查询交通票价,请稍候。',
|
||||
[],
|
||||
{
|
||||
meta: ['模型复核中']
|
||||
}
|
||||
stewardDelegated
|
||||
? {
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
|
||||
stewardContinuation: options.stewardContinuation || null,
|
||||
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
|
||||
}
|
||||
: {
|
||||
meta: ['模型复核中']
|
||||
}
|
||||
)
|
||||
messages.value.push(pendingMessage)
|
||||
composerDraft.value = ''
|
||||
@@ -697,27 +941,50 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText, selectedBusinessTimeContext)
|
||||
const reviewStatus = String(meta?.[1] || '').trim()
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
'application-review-preview',
|
||||
reviewStatus === '模型复核完成'
|
||||
? '模型复核完成,已生成申请核对表'
|
||||
: reviewStatus === '模型复核失败'
|
||||
? '模型复核失败,已生成临时核对表'
|
||||
: '模型未返回稳定结果,已完成规则兜底核对',
|
||||
Date.now() - reviewStartedAt
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
|
||||
rawText,
|
||||
selectedBusinessTimeContext,
|
||||
effectiveSessionType
|
||||
)
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
[],
|
||||
{
|
||||
meta,
|
||||
applicationPreview
|
||||
}
|
||||
))
|
||||
const reviewStatus = String(meta?.[1] || '').trim()
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
'application-review-preview',
|
||||
reviewStatus === '模型复核完成'
|
||||
? '模型复核完成,已生成申请核对表'
|
||||
: reviewStatus === '模型复核失败'
|
||||
? '模型复核失败,已生成临时核对表'
|
||||
: '模型未返回稳定结果,已完成规则兜底核对',
|
||||
Date.now() - reviewStartedAt
|
||||
)
|
||||
}
|
||||
if (stewardDelegated) {
|
||||
await typeStewardDelegatedMessage(
|
||||
pendingMessage.id,
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
{
|
||||
meta,
|
||||
applicationPreview,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
} else {
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
[],
|
||||
{
|
||||
meta,
|
||||
applicationPreview,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
))
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
@@ -726,7 +993,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (await promptUnlinkedReceiptFolderIfNeeded({
|
||||
if (!stewardDelegated && await promptUnlinkedReceiptFolderIfNeeded({
|
||||
detailScopedClaimId,
|
||||
files,
|
||||
fileNames,
|
||||
@@ -741,7 +1008,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
|
||||
const hasUnsavedReviewDraft = Boolean(
|
||||
!isKnowledgeSession.value &&
|
||||
!stewardDelegated &&
|
||||
!effectiveIsKnowledgeSession &&
|
||||
files.length &&
|
||||
activeReviewPayload.value &&
|
||||
!String(draftClaimId.value || '').trim() &&
|
||||
@@ -782,7 +1050,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
|
||||
if (
|
||||
!isKnowledgeSession.value &&
|
||||
!stewardDelegated &&
|
||||
!effectiveIsKnowledgeSession &&
|
||||
files.length &&
|
||||
!resolvedUploadDisposition &&
|
||||
!options.skipDraftAssociationPrompt &&
|
||||
@@ -836,18 +1105,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!appendToCurrentFlow) {
|
||||
if (stewardDelegated) {
|
||||
resetStewardDelegatedInsightState()
|
||||
} else if (!appendToCurrentFlow) {
|
||||
resetFlowRun()
|
||||
} else {
|
||||
clearFlowSimulationTimers()
|
||||
}
|
||||
if (isApplicationSubmitOperation) {
|
||||
if (!stewardDelegated && isApplicationSubmitOperation) {
|
||||
startFlowStep('application-submit-success', {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
detail: '正在提交费用申请...'
|
||||
})
|
||||
} else if (rawText && !reviewAction) {
|
||||
} else if (!stewardDelegated && rawText && !reviewAction) {
|
||||
startFlowStep('intent', '正在识别业务意图...')
|
||||
if (waitForExpenseIntentConfirmation) {
|
||||
startExpenseIntentConfirmationFlowPreview(rawText)
|
||||
@@ -906,19 +1177,26 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
const pendingMessage = createMessage(
|
||||
'assistant',
|
||||
options.pendingText || (
|
||||
isKnowledgeSession.value
|
||||
stewardDelegated ? '' : options.pendingText || (
|
||||
effectiveIsKnowledgeSession
|
||||
? '正在整理财务知识答案...'
|
||||
: activeSessionType.value === 'application'
|
||||
: effectiveSessionType === 'application'
|
||||
? '正在识别申请信息并查询交通票价...'
|
||||
: activeSessionType.value === 'approval'
|
||||
: effectiveSessionType === 'approval'
|
||||
? '正在查询审核上下文并整理风险提示...'
|
||||
: '正在识别并整理右侧核对信息...'
|
||||
),
|
||||
[],
|
||||
{
|
||||
meta: ['处理中']
|
||||
}
|
||||
stewardDelegated
|
||||
? {
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
|
||||
stewardContinuation: options.stewardContinuation || null,
|
||||
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
|
||||
}
|
||||
: {
|
||||
meta: ['处理中']
|
||||
}
|
||||
)
|
||||
messages.value.push(pendingMessage)
|
||||
|
||||
@@ -946,14 +1224,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
if (files.length) {
|
||||
const ocrStartedAt = Date.now()
|
||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||
if (!stewardDelegated) {
|
||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||
}
|
||||
if (recognizedAttachmentData) {
|
||||
ocrPayload = recognizedAttachmentData.ocrPayload
|
||||
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
|
||||
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
|
||||
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
ocrPayload = await recognizeOcrFiles(files, {
|
||||
@@ -964,14 +1246,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedUploadDisposition === 'continue_existing') {
|
||||
if (!stewardDelegated && resolvedUploadDisposition === 'continue_existing') {
|
||||
replaceMessage(pendingMessage.id, {
|
||||
...pendingMessage,
|
||||
text: attachmentAssociationConfirmed
|
||||
@@ -1069,15 +1355,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
extraContext.review_action = 'create_new_claim_from_documents'
|
||||
}
|
||||
|
||||
if (!isApplicationSubmitOperation) {
|
||||
if (!isApplicationSubmitOperation && !stewardDelegated) {
|
||||
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
|
||||
attachmentCount: effectiveFileNames.length,
|
||||
waitForSceneSelection: waitForExpenseSceneSelection
|
||||
})
|
||||
}
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const orchestratorOptions = isKnowledgeSession.value
|
||||
const backendMessage = buildBackendMessage(
|
||||
rawText,
|
||||
effectiveFileNames,
|
||||
effectiveOcrSummary,
|
||||
effectiveSessionType
|
||||
)
|
||||
const orchestratorOptions = effectiveIsKnowledgeSession
|
||||
? {
|
||||
timeoutMs: 75000,
|
||||
timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。'
|
||||
@@ -1117,22 +1408,22 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
|
||||
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
|
||||
...buildClientTimeContext(),
|
||||
session_type: activeSessionType.value,
|
||||
session_type: effectiveSessionType,
|
||||
entry_source: props.entrySource,
|
||||
user_input_text: systemGenerated ? '' : rawText,
|
||||
attachment_names: effectiveFileNames,
|
||||
attachment_count: effectiveFileNames.length,
|
||||
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
|
||||
draft_claim_id: effectiveIsKnowledgeSession ? undefined : draftClaimId.value || undefined,
|
||||
ocr_summary: effectiveOcrSummary,
|
||||
ocr_documents: effectiveOcrDocuments,
|
||||
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
|
||||
...(linkedRequest.value && !effectiveIsKnowledgeSession ? { request_context: linkedRequest.value } : {}),
|
||||
...extraContext
|
||||
}
|
||||
},
|
||||
orchestratorOptions
|
||||
)
|
||||
responsePayload = payload
|
||||
flowRunId.value = String(payload?.run_id || '').trim()
|
||||
flowRunId.value = stewardDelegated ? '' : String(payload?.run_id || '').trim()
|
||||
let flowRunDetail = null
|
||||
if (flowRunId.value) {
|
||||
flowRunDetail = await refreshFlowRunDetail()
|
||||
@@ -1140,7 +1431,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
|
||||
draftClaimId.value =
|
||||
isKnowledgeSession.value
|
||||
effectiveIsKnowledgeSession
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
@@ -1163,33 +1454,54 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
reviewPanelScope: resolveReviewPanelScope({
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
reviewAction: reviewActionResult,
|
||||
fileCount: files.length,
|
||||
rawText
|
||||
}),
|
||||
reviewPanelScope: stewardDelegated
|
||||
? ''
|
||||
: resolveReviewPanelScope({
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
reviewAction: reviewActionResult,
|
||||
fileCount: files.length,
|
||||
rawText
|
||||
}),
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
|
||||
operationFeedback: buildOperationFeedbackState(operationFeedbackContext)
|
||||
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
|
||||
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
})
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
const nextInsight = buildAgentInsight(
|
||||
payload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
if (nextInsight.agent) {
|
||||
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
|
||||
if (stewardDelegated) {
|
||||
await typeStewardDelegatedMessage(
|
||||
pendingMessage.id,
|
||||
assistantMessage.text,
|
||||
{
|
||||
...assistantMessage,
|
||||
id: pendingMessage.id,
|
||||
reviewPanelScope: ''
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
resetStewardDelegatedInsightState()
|
||||
} else {
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
const nextInsight = buildAgentInsight(
|
||||
payload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
if (nextInsight.agent) {
|
||||
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
|
||||
}
|
||||
currentInsight.value = nextInsight
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
}
|
||||
currentInsight.value = nextInsight
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewActionResult)) {
|
||||
emitSavedDraftRefresh(payload?.result?.draft_payload || null)
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
|
||||
const persistComposerFilesToDraft = async () => {
|
||||
try {
|
||||
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
@@ -1216,19 +1528,31 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
failCurrentFlowStep(error)
|
||||
if (!stewardDelegated) {
|
||||
failCurrentFlowStep(error)
|
||||
}
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage(
|
||||
'assistant',
|
||||
error?.message || '无法连接后端 Orchestrator,请稍后重试。',
|
||||
[],
|
||||
{
|
||||
meta: ['调用失败']
|
||||
}
|
||||
stewardDelegated
|
||||
? {
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME, '调用失败'],
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
: {
|
||||
meta: ['调用失败']
|
||||
}
|
||||
)
|
||||
)
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
if (stewardDelegated) {
|
||||
resetStewardDelegatedInsightState()
|
||||
} else {
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
}
|
||||
persistSessionState()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
|
||||
Reference in New Issue
Block a user