Files
X-Financial/web/src/views/scripts/useTravelReimbursementSubmitComposer.js
caoxiaozhu f60cebadb8 feat: 小财管家意图规划与报销提交编排增强
- 完善管家意图识别、模型计划构建与规划器调度
- 重构差旅报销提交编排器与管家计划流程前端交互
- 优化报销消息项样式与文档中心视图
- 新增小财管家与附件上传风险前置复核设计文档
- 补充管家规划器与文档中心测试覆盖
2026-06-04 14:25:14 +08:00

1572 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage,
buildUnsavedDraftAttachmentConfirmationMessage
} from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
applyApplicationBusinessTimeContext,
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
normalizeApplicationPreview,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
import {
handleBudgetCompileReportSubmit,
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,
activeReviewPayload,
activeSessionType,
adjustComposerTextareaHeight,
attachedFiles,
buildAgentInsight,
buildClientTimeContext,
buildComposerBusinessTimeContext,
buildComposerFilePreviews,
buildDraftAssociationQueryPayload,
buildErrorInsight,
buildExpenseIntentConfirmationActions,
buildExpenseIntentConfirmationMessage,
buildExpenseSceneSelectionActions,
buildExpenseSceneSelectionMessage,
buildMessageMeta,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFormContextFromPayload,
clearAttachedFiles,
clearFlowSimulationTimers,
completeFlowResult,
completeFlowStep,
composerBusinessTimeDraftTouched,
composerBusinessTimeTags,
composerDraft,
composerUploadIntent,
conversationId,
createMessage,
currentInsight,
currentUser,
draftClaimId,
emitDraftSaved,
emitOperationCompleted,
emitRequestUpdated,
extractReviewAttachmentNames,
failCurrentFlowStep,
fetchExpenseClaims,
fileInputRef,
flowRunId,
insightPanelCollapsed,
isKnowledgeSession,
linkedRequest,
mergeBusinessTimeIntoExtraContext,
mergeFilePreviews,
mergeFilesWithLimit,
mergeUploadAttachmentNames,
mergeUploadOcrDocuments,
messages,
nextTick,
normalizeExpenseQueryPayload,
normalizeOcrDocuments,
persistSessionState,
props,
recognizeOcrFiles,
refreshCurrentUserFromBackend,
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
resolveComposerDisplaySubmitText,
resetFlowRun,
resolveComposerSubmitText,
reviewInlineForm,
runOrchestrator,
scrollToBottom,
sessionSwitchBusy,
shouldRequestExpenseIntentConfirmation,
shouldRequestExpenseSceneSelection,
startExpenseClaimDraftFlowStep,
startExpenseIntentConfirmationFlowPreview,
startExpenseSceneSelectionFlowPreview,
startFlowStep,
startSemanticFlowPreview,
submitting,
syncComposerFilesToDraft,
toast
} = 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'
&& String(draftPayload?.status || '').trim() === 'submitted'
)
}
function buildOperationFeedbackState(context) {
if (!context) {
return null
}
return {
context,
submitting: false,
submitted: false,
dismissed: false,
rating: 0,
reason: '',
error: ''
}
}
function resolveAssistantResultText(payload, fallbackAnswer) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
if (isSubmittedApplicationDraftPayload(result.draft_payload)) {
return ''
}
return result.answer || result.message || fallbackAnswer
}
function createPendingAttachmentAssociationId() {
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function emitSavedDraftRefresh(draftPayload) {
if (!emitDraftSaved || isKnowledgeSession.value || !draftPayload?.claim_no) {
return
}
const draftType = String(draftPayload.draft_type || '').trim()
emitDraftSaved({
claimId: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
claimNo: String(draftPayload.claim_no || draftPayload.claimNo || '').trim(),
status: String(draftPayload.status || '').trim(),
approvalStage: String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim(),
documentType: draftType === 'expense_application' ? 'application' : 'reimbursement'
})
}
function normalizeRecognizedAttachmentData(data) {
if (!data || typeof data !== 'object') {
return null
}
const documents = Array.isArray(data.ocrDocuments) ? data.ocrDocuments : []
if (!documents.length) {
return null
}
return {
ocrPayload: data.ocrPayload || null,
ocrSummary: String(data.ocrSummary || '').trim(),
ocrDocuments: documents,
ocrFilePreviews: Array.isArray(data.ocrFilePreviews) ? data.ocrFilePreviews : []
}
}
function hasReceiptFolderSourceFile(files) {
return files.some((file) => String(file?.receiptId || '').trim())
}
async function promptUnlinkedReceiptFolderIfNeeded({
detailScopedClaimId,
files,
fileNames,
options,
rawText,
resolvedUploadDisposition,
reviewAction,
systemGenerated,
userText
}) {
if (
isKnowledgeSession.value ||
systemGenerated ||
!files.length ||
detailScopedClaimId ||
resolvedUploadDisposition ||
options.skipReceiptFolderUnlinkedPrompt ||
options.skipDraftAssociationPrompt ||
reviewAction ||
hasReceiptFolderSourceFile(files)
) {
return false
}
let unlinkedReceipts = []
try {
unlinkedReceipts = await fetchReceiptFolderItems('unlinked')
} catch (error) {
console.warn('Failed to load unlinked receipt folder items before attachment upload:', error)
return false
}
const count = Array.isArray(unlinkedReceipts) ? unlinkedReceipts.length : 0
if (!count) {
return false
}
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
`票据夹中还有 ${count} 份未关联票据。建议先处理这些票据再上传新附件,避免重复保存或遗漏关联。`,
[],
{
meta: ['票据夹待关联'],
suggestedActions: [
{
action_type: 'open_receipt_folder',
label: '去票据夹关联',
icon: 'mdi mdi-folder-open-outline',
payload: { target_view: 'receiptFolder' }
},
{
action_type: 'continue_upload_with_unlinked_receipts',
label: '继续上传新附件',
icon: 'mdi mdi-upload-outline',
payload: { raw_text: rawText }
}
]
}
))
nextTick(scrollToBottom)
persistSessionState()
return true
}
function buildConfirmedAssociationText(message) {
return String(message?.text || '')
.replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认')
.replace(`[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确定')
}
function resolveReviewPanelScope({
reviewPayload = null,
reviewAction = '',
fileCount = 0,
rawText = ''
} = {}) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return ''
}
const normalizedAction = String(reviewAction || '').trim()
const documentCount = Array.isArray(reviewPayload.document_cards) ? reviewPayload.document_cards.length : 0
const riskCount = Array.isArray(reviewPayload.risk_briefs) ? reviewPayload.risk_briefs.length : 0
const asksRisk = /风险|隐患|超标|异常|重复|待整改|风险项|高风险|中风险|低风险/.test(String(rawText || ''))
if (fileCount > 0 && documentCount > 0) {
return 'documents'
}
if (riskCount > 0 && (asksRisk || ['next_step', 'submit', 'submit_claim'].includes(normalizedAction))) {
return 'risk'
}
if (!normalizedAction && fileCount === 0) {
return 'overview'
}
return ''
}
async function confirmPendingAttachmentAssociation(message) {
if (submitting.value || sessionSwitchBusy.value) return null
const pending = message?.pendingAttachmentAssociation && typeof message.pendingAttachmentAssociation === 'object'
? message.pendingAttachmentAssociation
: null
const associationId = String(pending?.id || '').trim()
if (!associationId || pending?.status === 'confirmed') {
return null
}
const runtime = pendingAttachmentAssociations.get(associationId)
if (!runtime || !Array.isArray(runtime.files) || !runtime.files.length) {
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
return null
}
pending.status = 'confirmed'
message.pendingAttachmentAssociation = pending
message.text = buildConfirmedAssociationText(message)
message.meta = ['已确认归集']
persistSessionState()
if (pending.mode === 'save_then_associate') {
const inheritedReviewContext = buildReviewFormContextFromPayload(
activeReviewPayload.value,
reviewInlineForm.value
)
const savePayload = await submitComposer({
rawText: '请先把当前已识别的报销信息保存为草稿,随后继续归集本次上传的附件。',
userText: '',
files: [],
skipUserMessage: true,
pendingText: '正在先保存未保存单据...',
systemGenerated: true,
extraContext: {
...runtime.extraContext,
...inheritedReviewContext,
review_action: 'save_draft'
}
})
const savedClaimId = String(savePayload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
const savedClaimNo = String(savePayload?.result?.draft_payload?.claim_no || '').trim()
if (!savedClaimId) {
toast('当前单据还没有保存成功,请稍后重试。')
return savePayload
}
return submitComposer({
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${savedClaimNo || '当前草稿'}`,
userText: `保存草稿并归集 ${runtime.fileNames.length} 份票据`,
files: runtime.files,
uploadDisposition: 'continue_existing',
skipDraftAssociationPrompt: true,
skipUserMessage: true,
appendToCurrentFlow: true,
systemGenerated: true,
pendingText: savedClaimNo
? `草稿 ${savedClaimNo} 已保存,正在识别并归集附件...`
: '草稿已保存,正在识别并归集附件...',
associationConfirmed: true,
extraContext: {
...runtime.extraContext,
review_action: 'link_to_existing_draft',
draft_claim_id: savedClaimId,
selected_claim_id: savedClaimId,
selected_claim_no: savedClaimNo,
attachment_association_confirmed: true
}
})
}
return submitComposer({
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
files: runtime.files,
uploadDisposition: 'continue_existing',
skipDraftAssociationPrompt: true,
pendingText: runtime.claimNo
? `正在将票据归集到草稿 ${runtime.claimNo}...`
: '正在将票据归集到当前草稿...',
associationConfirmed: true,
recognizedAttachmentData: {
ocrPayload: runtime.ocrPayload,
ocrSummary: runtime.ocrSummary,
ocrDocuments: runtime.ocrDocuments,
ocrFilePreviews: runtime.ocrFilePreviews
},
extraContext: {
...runtime.extraContext,
review_action: 'link_to_existing_draft',
draft_claim_id: runtime.claimId,
selected_claim_id: runtime.claimId,
selected_claim_no: runtime.claimNo,
attachment_association_confirmed: true
}
})
}
function buildBackendMessage(rawText, fileNames, ocrSummary = '', sessionTypeOverride = '') {
const parts = []
const normalizedText = String(rawText || '').trim()
const sessionType = String(sessionTypeOverride || activeSessionType.value || '').trim()
const isKnowledgeMessage = sessionType === 'knowledge'
if (normalizedText) {
parts.push(normalizedText)
} else if (fileNames.length) {
parts.push(
isKnowledgeMessage
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
: sessionType === 'application'
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
: sessionType === 'approval'
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理审核风险和处理建议。`
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
)
}
if (fileNames.length) {
parts.push(`附件名称:${fileNames.join('、')}`)
}
if (ocrSummary) {
parts.push(`OCR摘要${ocrSummary}`)
}
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
parts.push(`关联单号:${linkedRequest.value.id}`)
}
return parts.join('\n')
}
function resolveDetailScopedClaimId() {
if (props.entrySource !== 'detail' || isKnowledgeSession.value) {
return ''
}
return String(
linkedRequest.value?.claimId ||
linkedRequest.value?.claim_id ||
''
).trim()
}
function buildApplicationPreviewReviewMeta(ontology) {
return [
'申请核对预览',
String(ontology?.parse_strategy || '').trim() === 'llm_primary'
? '模型复核完成'
: '规则兜底复核'
]
}
async function resolveApplicationPreviewUser() {
const user = currentUser.value || {}
if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') {
return user
}
await refreshCurrentUserFromBackend({ silent: true })
return currentUser.value || user
}
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
const user = await resolveApplicationPreviewUser()
const localPreview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(rawText, user),
businessTimeContext
)
const enrichWithPolicyEstimate = async (preview) => {
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
if (!estimateRequest.canCalculate) {
return preview
}
try {
const fields = preview?.fields || {}
await waitForMockApplicationTransportQuote({
transportMode: fields.transportMode,
location: fields.location,
time: fields.time
})
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
console.warn('Application policy estimate failed:', error)
return applyApplicationPolicyEstimateError(preview, error, user)
}
}
try {
const ontology = await fetchOntologyParse(
{
query: rawText,
user_id: user.username || user.name || 'anonymous',
context_json: {
...buildExpenseApplicationOntologyContext(user),
session_type: String(sessionTypeOverride || activeSessionType.value || '').trim(),
entry_source: props.entrySource,
user_input_text: rawText
}
},
{
timeoutMs: 45000,
timeoutMessage: '模型抽取申请字段超时,已保留当前本地预览。'
}
)
const refinedPreview = applyApplicationBusinessTimeContext(
buildModelRefinedApplicationPreview(
localPreview,
ontology,
rawText,
user
),
businessTimeContext
)
return {
applicationPreview: await enrichWithPolicyEstimate(refinedPreview),
meta: buildApplicationPreviewReviewMeta(ontology)
}
} catch (error) {
console.warn('Application preview model refinement failed:', error)
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
modelReviewStatus: 'failed'
}),
meta: ['申请核对预览', '模型复核失败']
}
}
}
async function submitComposer(options = {}) {
if (submitting.value || sessionSwitchBusy.value) return null
const rawText = resolveComposerSubmitText(options.rawText).trim()
const systemGenerated = Boolean(options.systemGenerated)
const appendToCurrentFlow = Boolean(options.appendToCurrentFlow)
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()
const detailScopedUpload = Boolean(detailScopedClaimId && files.length)
if (detailScopedClaimId) {
draftClaimId.value = detailScopedClaimId
}
const resolvedUploadDisposition =
String(options.uploadDisposition || '').trim() ||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') ||
(detailScopedUpload ? 'continue_existing' : '')
if (fileMergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (!rawText && !files.length) return
const fileNames = files.map((file) => file.name)
const optionExtraContext = options.extraContext && typeof options.extraContext === 'object'
? { ...options.extraContext }
: {}
const detailScopedClaimNo = String(
linkedRequest.value?.documentNo ||
linkedRequest.value?.id ||
''
).trim()
const initialExtraContext = detailScopedClaimId
? {
...optionExtraContext,
draft_claim_id: detailScopedClaimId,
selected_claim_id: detailScopedClaimId,
selected_claim_no: detailScopedClaimNo,
detail_scope_claim_id: detailScopedClaimId
}
: optionExtraContext
const selectedBusinessTimeContext = effectiveIsKnowledgeSession ? null : buildComposerBusinessTimeContext()
const extraContext = effectiveIsKnowledgeSession
? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim()
const feedbackOperationType = String(options.feedbackOperationType || '').trim()
const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'
const attachmentAssociationConfirmed = Boolean(
options.associationConfirmed ||
extraContext.attachment_association_confirmed ||
reviewAction === 'link_to_existing_draft' ||
detailScopedUpload
)
const hasSelectedExpenseType = Boolean(
extraContext.expense_scene_selection ||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
)
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
const waitForExpenseIntentConfirmation = !stewardDelegated && shouldRequestExpenseIntentConfirmation(rawText, {
sessionType: effectiveSessionType,
attachmentCount: files.length,
reviewAction,
hasSelectedExpenseType,
hasConfirmedExpenseIntent
})
const waitForExpenseSceneSelection = !stewardDelegated && !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
sessionType: effectiveSessionType,
attachmentCount: files.length,
reviewAction,
hasSelectedExpenseType
})
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
const userText =
String(options.userText || '').trim() ||
resolveComposerDisplaySubmitText(rawText) ||
rawText ||
(effectiveIsKnowledgeSession
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
: resolvedUploadDisposition === 'continue_existing'
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
: resolvedUploadDisposition === 'new_document'
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (shouldUseBudgetCompileReport(rawText, {
sessionType: effectiveSessionType,
entrySource: props.entrySource,
budgetContext: props.initialBudgetContext
}) && !reviewAction) {
return handleBudgetCompileReportSubmit({
adjustComposerTextareaHeight,
clearAttachedFiles,
completeFlowStep,
composerBusinessTimeDraftTouched,
composerBusinessTimeTags,
composerDraft,
createMessage,
currentUser,
fileInputRef,
fileNames,
messages,
nextTick,
options,
persistSessionState,
rawText,
replaceMessage,
resetFlowRun,
refreshCurrentUserFromBackend,
budgetContext: props.initialBudgetContext,
scrollToBottom,
startFlowStep,
submitting,
userText
})
}
const scopeGuard = resolveAssistantScopeGuard(rawText, effectiveSessionType, {
attachmentCount: files.length,
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
reviewAction
})
if (scopeGuard && !systemGenerated && !reviewAction && !options.skipScopeGuard) {
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage('assistant', scopeGuard.text, [], {
meta: scopeGuard.meta,
suggestedActions: scopeGuard.suggestedActions
}))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
return null
}
if (shouldUseLocalApplicationPreview(rawText, {
sessionType: effectiveSessionType,
attachmentCount: files.length,
reviewAction,
systemGenerated
})) {
const intentStartedAt = Date.now()
const reviewStartedAt = intentStartedAt
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 ? '' : '正在复核申请信息,并查询交通票价,请稍候。',
[],
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 = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
submitting.value = true
try {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
rawText,
selectedBusinessTimeContext,
effectiveSessionType
)
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 {
submitting.value = false
}
return null
}
if (!stewardDelegated && await promptUnlinkedReceiptFolderIfNeeded({
detailScopedClaimId,
files,
fileNames,
options,
rawText,
resolvedUploadDisposition,
reviewAction,
systemGenerated,
userText
})) {
return null
}
const hasUnsavedReviewDraft = Boolean(
!stewardDelegated &&
!effectiveIsKnowledgeSession &&
files.length &&
activeReviewPayload.value &&
!String(draftClaimId.value || '').trim() &&
!detailScopedClaimId &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
!reviewAction
)
if (hasUnsavedReviewDraft) {
const associationId = createPendingAttachmentAssociationId()
pendingAttachmentAssociations.set(associationId, {
files,
fileNames,
filePreviews: buildComposerFilePreviews(files),
extraContext
})
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
buildUnsavedDraftAttachmentConfirmationMessage({ fileNames }),
[],
{
meta: ['等待确认保存并归集'],
pendingAttachmentAssociation: {
id: associationId,
mode: 'save_then_associate',
status: 'pending',
fileNames
}
}
))
nextTick(scrollToBottom)
persistSessionState()
return null
}
if (
!stewardDelegated &&
!effectiveIsKnowledgeSession &&
files.length &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
!reviewAction
) {
try {
const claims = await fetchExpenseClaims()
const queryPayload = buildDraftAssociationQueryPayload(claims)
if (queryPayload?.records?.length) {
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
`我找到 ${queryPayload.records.length} 张可关联的草稿/待补单据。请先选择这批附件要归集到哪张单据,我再开始识别附件。`,
[],
{
meta: ['等待选择关联单据'],
queryPayload
}
))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
return null
}
} catch (error) {
console.warn('Failed to load draft claims before attachment recognition:', error)
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
'我暂时没能查询到可关联的草稿/待补单据,所以先不识别这批附件。请稍后重试,或从对应草稿进入后继续上传票据。',
[],
{
meta: ['单据查询失败']
}
))
nextTick(scrollToBottom)
persistSessionState()
toast(error?.message || '查询可关联草稿失败,请稍后重试。')
return null
}
}
if (stewardDelegated) {
resetStewardDelegatedInsightState()
} else if (!appendToCurrentFlow) {
resetFlowRun()
} else {
clearFlowSimulationTimers()
}
if (!stewardDelegated && isApplicationSubmitOperation) {
startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
} else if (!stewardDelegated && rawText && !reviewAction) {
startFlowStep('intent', '正在识别业务意图...')
if (waitForExpenseIntentConfirmation) {
startExpenseIntentConfirmationFlowPreview(rawText)
} else if (waitForExpenseSceneSelection) {
startExpenseSceneSelectionFlowPreview(rawText)
} else {
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
}
}
const filePreviews = buildComposerFilePreviews(files)
rememberFilePreviews(filePreviews)
// 只有在非静默模式下才添加用户消息
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
if (waitForExpenseIntentConfirmation) {
messages.value.push(createMessage('assistant', buildExpenseIntentConfirmationMessage(rawText), [], {
meta: ['等待确认意图'],
suggestedActions: buildExpenseIntentConfirmationActions(rawText)
}))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return null
}
if (waitForExpenseSceneSelection) {
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], {
meta: ['等待选择场景'],
suggestedActions: buildExpenseSceneSelectionActions(rawText)
}))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return null
}
const pendingMessage = createMessage(
'assistant',
stewardDelegated ? '' : options.pendingText || (
effectiveIsKnowledgeSession
? '正在整理财务知识答案...'
: effectiveSessionType === 'application'
? '正在识别申请信息并查询交通票价...'
: effectiveSessionType === 'approval'
? '正在查询审核上下文并整理风险提示...'
: '正在识别并整理右侧核对信息...'
),
[],
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 = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(adjustComposerTextareaHeight)
submitting.value = true
nextTick(scrollToBottom)
let responsePayload = null
try {
const user = currentUser.value || {}
let ocrPayload = null
let ocrSummary = ''
let ocrDocuments = []
let ocrFilePreviews = []
const recognizedAttachmentData = normalizeRecognizedAttachmentData(options.recognizedAttachmentData)
if (files.length) {
const ocrStartedAt = Date.now()
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)
if (!stewardDelegated) {
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
}
} else {
try {
ocrPayload = await recognizeOcrFiles(files, {
timeoutMs: 90000,
timeoutMessage: '票据 OCR 识别超时,已继续使用附件名称处理。'
})
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
rememberFilePreviews(ocrFilePreviews)
if (!stewardDelegated) {
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
}
} catch (error) {
console.warn('OCR request failed:', error)
if (!stewardDelegated) {
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
}
}
}
if (!stewardDelegated && resolvedUploadDisposition === 'continue_existing') {
replaceMessage(pendingMessage.id, {
...pendingMessage,
text: attachmentAssociationConfirmed
? '票据识别已完成,正在把本次附件归集到已选择的草稿...'
: '票据识别已完成,正在整理归集前确认信息...',
meta: attachmentAssociationConfirmed ? ['正在归集'] : ['等待确认归集']
})
persistSessionState()
}
}
const associationTargetClaimId = String(extraContext.draft_claim_id || draftClaimId.value || '').trim()
const associationTargetClaimNo = String(
extraContext.selected_claim_no ||
extraContext.draft_claim_no ||
''
).trim()
if (
files.length &&
resolvedUploadDisposition === 'continue_existing' &&
associationTargetClaimId &&
!attachmentAssociationConfirmed
) {
const associationId = createPendingAttachmentAssociationId()
const pendingAssociation = {
id: associationId,
status: 'pending',
claimId: associationTargetClaimId,
claimNo: associationTargetClaimNo,
fileNames
}
pendingAttachmentAssociations.set(associationId, {
files,
fileNames,
ocrPayload,
ocrSummary,
ocrDocuments,
ocrFilePreviews,
filePreviews,
claimId: associationTargetClaimId,
claimNo: associationTargetClaimNo,
extraContext: {
...extraContext,
draft_claim_id: associationTargetClaimId,
selected_claim_id: associationTargetClaimId,
selected_claim_no: associationTargetClaimNo
}
})
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildAttachmentAssociationConfirmationMessage({
claimNo: associationTargetClaimNo,
fileNames,
ocrDocuments
}),
[],
{
meta: ['等待确认归集'],
pendingAttachmentAssociation: pendingAssociation
}
))
persistSessionState()
nextTick(scrollToBottom)
return null
}
let effectiveFileNames = [...fileNames]
let effectiveOcrDocuments = [...ocrDocuments]
let effectiveOcrSummary = ocrSummary
if (resolvedUploadDisposition === 'continue_existing') {
extraContext.review_action = 'link_to_existing_draft'
const inheritedReviewContext = buildReviewFormContextFromPayload(
activeReviewPayload.value,
reviewInlineForm.value
)
if (inheritedReviewContext.review_form_values) {
extraContext.review_form_values = {
...inheritedReviewContext.review_form_values,
...(extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
? extraContext.review_form_values
: {})
}
}
if (inheritedReviewContext.business_time_context && !extraContext.business_time_context) {
extraContext.business_time_context = inheritedReviewContext.business_time_context
}
effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames)
effectiveOcrDocuments = mergeUploadOcrDocuments(
buildOcrDocumentsFromReviewPayload(activeReviewPayload.value),
ocrDocuments
)
effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments)
} else if (resolvedUploadDisposition === 'new_document') {
extraContext.review_action = 'create_new_claim_from_documents'
}
if (!isApplicationSubmitOperation && !stewardDelegated) {
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
attachmentCount: effectiveFileNames.length,
waitForSceneSelection: waitForExpenseSceneSelection
})
}
const backendMessage = buildBackendMessage(
rawText,
effectiveFileNames,
effectiveOcrSummary,
effectiveSessionType
)
const orchestratorOptions = effectiveIsKnowledgeSession
? {
timeoutMs: 75000,
timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。'
}
: {
timeoutMs: 120000,
timeoutMessage: '票据归集处理超时,当前仍停留在原草稿,请稍后重试或重新选择附件。'
}
const payload = await runOrchestrator(
{
source: 'user_message',
user_id: user.username || user.name || 'anonymous',
conversation_id: conversationId.value || null,
message: backendMessage,
context_json: {
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
is_admin: Boolean(user.isAdmin),
name: user.name || '',
role: user.role || '',
department: user.department || user.departmentName || '',
department_name: user.department || user.departmentName || '',
position: user.position || '',
employee_position: user.position || user.employeePosition || user.employee_position || '',
employeePosition: user.position || user.employeePosition || user.employee_position || '',
grade: user.grade || user.employeeGrade || user.employee_grade || '',
employee_grade: user.grade || user.employeeGrade || user.employee_grade || '',
employeeGrade: user.grade || user.employeeGrade || user.employee_grade || '',
employee_no: user.employeeNo || user.employee_no || '',
employeeNo: user.employeeNo || user.employee_no || '',
manager_name: user.managerName || user.manager_name || '',
managerName: user.managerName || user.manager_name || '',
direct_manager_name: user.managerName || user.manager_name || user.directManagerName || user.direct_manager_name || '',
directManagerName: user.managerName || user.manager_name || user.directManagerName || user.direct_manager_name || '',
employee_location: user.location || '',
cost_center: user.costCenter || user.cost_center || '',
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
...buildClientTimeContext(),
session_type: effectiveSessionType,
entry_source: props.entrySource,
user_input_text: systemGenerated ? '' : rawText,
attachment_names: effectiveFileNames,
attachment_count: effectiveFileNames.length,
draft_claim_id: effectiveIsKnowledgeSession ? undefined : draftClaimId.value || undefined,
ocr_summary: effectiveOcrSummary,
ocr_documents: effectiveOcrDocuments,
...(linkedRequest.value && !effectiveIsKnowledgeSession ? { request_context: linkedRequest.value } : {}),
...extraContext
}
},
orchestratorOptions
)
responsePayload = payload
flowRunId.value = stewardDelegated ? '' : String(payload?.run_id || '').trim()
let flowRunDetail = null
if (flowRunId.value) {
flowRunDetail = await refreshFlowRunDetail()
}
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
draftClaimId.value =
effectiveIsKnowledgeSession
? ''
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
const reviewActionResult = String(extraContext.review_action || '').trim()
const resultClaimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}` : '已将本次上传的票据关联到现有草稿。')
: '智能体已完成处理。'
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
? emitOperationCompleted?.(payload, {
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
})
: null
const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
meta: buildMessageMeta(payload, effectiveFileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions
: [],
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
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),
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
stewardContinuation: options.stewardContinuation || null
})
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)
}
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 (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
const persistComposerFilesToDraft = async () => {
try {
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
persistSessionState()
if (detailScopedUpload) {
emitRequestUpdated?.({
claimId: resolvedDraftClaimId,
source: 'detail-smart-entry-attachment-sync',
uploadedCount: Number(syncResult?.uploadedCount || 0),
skippedCount: Number(syncResult?.skippedCount || 0)
})
}
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
}
}
const persistTask = persistComposerFilesToDraft()
if (detailScopedUpload) {
await persistTask
} else {
void persistTask
}
}
} catch (error) {
clearFlowSimulationTimers()
if (!stewardDelegated) {
failCurrentFlowStep(error)
}
replaceMessage(
pendingMessage.id,
createMessage(
'assistant',
error?.message || '无法连接后端 Orchestrator请稍后重试。',
[],
stewardDelegated
? {
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '调用失败'],
stewardContinuation: options.stewardContinuation || null
}
: {
meta: ['调用失败']
}
)
)
if (stewardDelegated) {
resetStewardDelegatedInsightState()
} else {
currentInsight.value = buildErrorInsight(error, fileNames)
}
persistSessionState()
} finally {
submitting.value = false
composerUploadIntent.value = ''
nextTick(scrollToBottom)
}
return responsePayload
}
return {
confirmPendingAttachmentAssociationInternal: confirmPendingAttachmentAssociation,
submitComposerInternal: submitComposer
}
}