feat: 小财管家意图规划与报销提交编排增强

- 完善管家意图识别、模型计划构建与规划器调度
- 重构差旅报销提交编排器与管家计划流程前端交互
- 优化报销消息项样式与文档中心视图
- 新增小财管家与附件上传风险前置复核设计文档
- 补充管家规划器与文档中心测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 14:25:14 +08:00
parent 1cbf3fee44
commit f60cebadb8
19 changed files with 2337 additions and 196 deletions

View File

@@ -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