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, applicationDateRangesOverlap, normalizeApplicationPreview, normalizeTransportModeOption, resolveApplicationDateRange, shouldRequireApplicationModelReview, shouldUseLocalApplicationPreview } from '../../utils/expenseApplicationPreview.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 { fetchStewardSlotDecision } from '../../services/steward.js' import { handleBudgetCompileReportSubmit, shouldUseBudgetCompileReport } from './budgetAssistantReportModel.js' import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js' const STEWARD_ASSISTANT_NAME = '小财管家' const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10 const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8 const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5 const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field' const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = { applicationType: 'expense_type', time: 'time_range', location: 'location', reason: 'reason', amount: 'amount', transportMode: 'transport_mode', transportEstimatedAmount: 'transport_estimated_amount', trainEstimatedAmount: 'train_estimated_amount', flightEstimatedAmount: 'flight_estimated_amount', hotelAmount: 'hotel_amount', allowanceAmount: 'allowance_amount', policyTotalAmount: 'policy_total_amount', reimbursementAmount: 'reimbursement_amount', department: 'department_name', applicant: 'employee_name', grade: 'employee_grade' } const APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP = { 费用类型: 'expense_type', 申请类型: 'expense_type', 发生时间: 'time_range', 出发时间: 'time_range', 申请时间: 'time_range', 地点: 'location', 事由: 'reason', 金额: 'amount', 系统预估费用: 'amount', 出行方式: 'transport_mode', 附件: 'attachments', '附件/凭证': 'attachments', 商户: 'merchant_name', '商户/开票方': 'merchant_name', 客户: 'customer_name', 客户或项目对象: 'customer_name' } const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = { expense_type: 'applicationType', time_range: 'time', location: 'location', reason: 'reason', amount: 'amount', transport_mode: 'transportMode', transport_estimated_amount: 'transportEstimatedAmount', train_estimated_amount: 'trainEstimatedAmount', flight_estimated_amount: 'flightEstimatedAmount', hotel_amount: 'hotelAmount', allowance_amount: 'allowanceAmount', policy_total_amount: 'policyTotalAmount', reimbursement_amount: 'reimbursementAmount', department_name: 'department', employee_name: 'applicant', employee_grade: 'grade' } const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = { expense_type: '费用类型', time_range: '时间', location: '地点', reason: '事由', amount: '金额', transport_mode: '出行方式', transport_estimated_amount: '交通费用预估', train_estimated_amount: '火车费用预估', flight_estimated_amount: '飞机费用预估', hotel_amount: '住宿测算金额', allowance_amount: '出差补贴金额', policy_total_amount: '规则测算合计', reimbursement_amount: '实际报销金额', attachments: '附件/凭证', customer_name: '客户或项目对象', merchant_name: '商户/开票方', department_name: '所属部门', employee_name: '申请人', employee_grade: '职级' } const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([ 'amount', 'transport_estimated_amount', 'train_estimated_amount', 'flight_estimated_amount', 'hotel_amount', 'allowance_amount', 'policy_total_amount', 'reimbursement_amount', 'attachments', 'employee_no', 'department_name', 'employee_name' ]) const APPLICATION_DUPLICATE_IGNORED_STATUSES = new Set([ 'cancelled', 'canceled', 'void', 'voided', 'deleted', '已取消', '已作废', '作废', '已删除' ]) function normalizeClaimListPayload(payload) { if (Array.isArray(payload)) { return payload } return Array.isArray(payload?.items) ? payload.items : [] } function normalizeClaimRiskFlags(claim) { const flags = claim?.risk_flags_json || claim?.riskFlagsJson || claim?.riskFlags || [] if (Array.isArray(flags)) { return flags } return flags && typeof flags === 'object' ? [flags] : [] } function extractApplicationDetailFromClaim(claim) { return normalizeClaimRiskFlags(claim).reduce((found, item) => { if (found || !item || typeof item !== 'object') { return found } const detail = item.application_detail || item.applicationDetail return detail && typeof detail === 'object' ? detail : null }, null) } function isApplicationClaimRecord(claim) { const expenseType = String(claim?.expense_type || claim?.expenseType || '').trim().toLowerCase() const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase() return ( expenseType === 'application' || expenseType === 'expense_application' || expenseType.endsWith('_application') || claimNo.startsWith('AP-') || claimNo.startsWith('APP-') || Boolean(extractApplicationDetailFromClaim(claim)) ) } function normalizeApplicationExpenseType(value) { const text = String(value || '').trim().toLowerCase() if (!text) { return '' } if (text === 'travel_application' || /差旅|出差/.test(text)) { return 'travel_application' } if (text === 'purchase_application' || /采购/.test(text)) { return 'purchase_application' } if (text === 'meeting_application' || /会务|会议/.test(text)) { return 'meeting_application' } if (text === 'expense_application' || text === 'application' || text.endsWith('_application')) { return text === 'application' ? 'expense_application' : text } return 'expense_application' } function resolveClaimApplicationExpenseType(claim) { const detail = extractApplicationDetailFromClaim(claim) || {} return normalizeApplicationExpenseType( claim?.expense_type || claim?.expenseType || detail.application_type || detail.applicationType || '' ) } function isIgnoredApplicationDuplicateStatus(status) { return APPLICATION_DUPLICATE_IGNORED_STATUSES.has(String(status || '').trim().toLowerCase()) } function resolveClaimApplicationDateRange(claim) { const detail = extractApplicationDetailFromClaim(claim) || {} return ( resolveApplicationDateRange( detail.time || detail.time_range || detail.timeRange || detail.application_time || detail.applicationTime || detail.application_business_time || detail.applicationBusinessTime || detail.application_date || detail.applicationDate, detail.days || detail.application_days || detail.applicationDays ) || resolveApplicationDateRange(claim?.occurred_at || claim?.occurredAt || '') ) } function formatApplicationDateRangeLabel(range) { if (!range?.startDate) { return '待确认' } return range.startDate === range.endDate ? range.startDate : `${range.startDate} 至 ${range.endDate}` } function findOverlappingApplicationClaim(applicationPreview, claimsPayload) { const preview = normalizeApplicationPreview(applicationPreview) const fields = preview.fields || {} const currentRange = resolveApplicationDateRange(fields.time, fields.days) if (!currentRange) { return null } const currentExpenseType = normalizeApplicationExpenseType(fields.applicationType) const claims = normalizeClaimListPayload(claimsPayload) for (const claim of claims) { if (!isApplicationClaimRecord(claim) || isIgnoredApplicationDuplicateStatus(claim?.status)) { continue } const existingExpenseType = resolveClaimApplicationExpenseType(claim) if (currentExpenseType && existingExpenseType && currentExpenseType !== existingExpenseType) { continue } const existingRange = resolveClaimApplicationDateRange(claim) if (!existingRange || !applicationDateRangesOverlap(currentRange, existingRange)) { continue } return { claim, currentRange, existingRange, claimId: String(claim?.id || claim?.claim_id || claim?.claimId || '').trim(), claimNo: String(claim?.claim_no || claim?.claimNo || claim?.id || '').trim(), status: String(claim?.approval_stage || claim?.approvalStage || claim?.status || '').trim(), reason: String(claim?.reason || '').trim(), location: String(claim?.location || '').trim() } } return null } function buildApplicationDateConflictMessage(conflict) { const claimNo = conflict?.claimNo || '已有申请' return [ '我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。', '', '已有申请:', `- **单号**:${claimNo}`, `- **申请时间**:${formatApplicationDateRangeLabel(conflict?.existingRange)}`, conflict?.location ? `- **地点**:${conflict.location}` : '', conflict?.reason ? `- **事由**:${conflict.reason}` : '', `- **当前节点**:${conflict?.status || '处理中'}`, '', `本次识别时间:${formatApplicationDateRangeLabel(conflict?.currentRange)}`, '', '请先查看已有申请,或修改本次出差时间后再继续。' ].filter(Boolean).join('\n') } function buildApplicationDateConflictActions(conflict) { const actions = [] if (conflict?.claimId) { actions.push({ action_type: 'open_application_detail', label: '查看已有申请', description: conflict.claimNo ? `进入 ${conflict.claimNo} 单据详情。` : '进入已有申请单据详情。', icon: 'mdi mdi-file-search-outline', payload: { claim_id: conflict.claimId, claim_no: conflict.claimNo } }) } actions.push({ action_type: 'prefill_composer', label: '修改出差时间', description: '在输入框中补充新的出差日期后继续。', icon: 'mdi mdi-calendar-edit-outline', payload: { prompt_prefill: '修改出差时间为:' } }) return actions } 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 isBlockingApplicationOntologyField(key = '') { const normalizedKey = String(key || '').trim() return Boolean(normalizedKey && !APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS.has(normalizedKey)) } function resolveBlockingApplicationMissingFieldsForSteward(preview = {}) { return resolveApplicationPreviewMissingFieldsForSteward(preview).filter((label) => { const ontologyKey = APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '' return !ontologyKey || isBlockingApplicationOntologyField(ontologyKey) }) } function buildStewardApplicationPreviewSuggestedActions(preview = {}) { const normalized = normalizeApplicationPreview(preview) const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized) 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, applicationPreview: normalized, steward_delegated_field_completion: true } })) } function resolveStewardContinuationCurrentTask(continuation = null) { const task = continuation?.currentTask || continuation?.current_task || null return task && typeof task === 'object' ? task : null } function normalizeCanonicalFieldList(fields = []) { const normalized = [] if (!Array.isArray(fields)) { return normalized } fields.forEach((field) => { const key = String(field || '').trim() if (key && !normalized.includes(key)) { normalized.push(key) } }) return normalized } function buildStewardSlotDecisionOntologyFields(preview = {}, continuation = null) { const normalizedPreview = normalizeApplicationPreview(preview) const previewFields = normalizedPreview.fields || {} const task = resolveStewardContinuationCurrentTask(continuation) const taskFields = task?.ontology_fields || task?.ontologyFields || {} const fields = {} Object.entries(taskFields || {}).forEach(([key, value]) => { const normalizedKey = String(key || '').trim() const normalizedValue = String(value || '').trim() if (normalizedKey && normalizedValue) { fields[normalizedKey] = normalizedValue } }) Object.entries(APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP).forEach(([previewKey, ontologyKey]) => { const value = String(previewFields[previewKey] || '').trim() if (value && value !== '待补充' && !fields[ontologyKey]) { fields[ontologyKey] = value } }) return fields } function buildStewardSlotDecisionMissingFields(preview = {}, continuation = null, ontologyFields = {}) { const task = resolveStewardContinuationCurrentTask(continuation) const taskMissingFields = normalizeCanonicalFieldList(task?.missing_fields || task?.missingFields || []) .filter((key) => isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim()) if (taskMissingFields.length) { return taskMissingFields } return resolveApplicationPreviewMissingFieldsForSteward(preview) .map((label) => APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '') .filter((key, index, list) => key && isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim() && list.indexOf(key) === index ) } async function fetchStewardApplicationSlotDecision(preview = {}, rawText = '', continuation = null) { const ontologyFields = buildStewardSlotDecisionOntologyFields(preview, continuation) const missingFields = buildStewardSlotDecisionMissingFields(preview, continuation, ontologyFields) try { return await fetchStewardSlotDecision({ task_type: 'expense_application', user_message: String(rawText || '').trim(), ontology_fields: ontologyFields, missing_fields: missingFields, task_context: { steward_continuation: continuation || null, application_preview: normalizeApplicationPreview(preview) } }, { timeoutMs: 45000, timeoutMessage: '小财管家字段决策超时,已按当前本体缺口继续追问。' }) } catch (error) { console.warn('Steward slot decision failed:', error) return null } } function formatStewardDecisionUserText(text = '') { let formatted = String(text || '').trim() Object.entries(ONTOLOGY_FIELD_DISPLAY_LABEL_MAP).forEach(([fieldKey, label]) => { const escapedKey = fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') formatted = formatted .replace(new RegExp(`(\\s*${escapedKey}\\s*)`, 'g'), '') .replace(new RegExp(`\\(\\s*${escapedKey}\\s*\\)`, 'g'), '') .replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), label) }) return formatted.replace(/\s{2,}/g, ' ').trim() } function buildStewardSlotDecisionMessage(decision = null, preview = {}, fallbackText = '') { if (!decision || String(decision.next_action || '').trim() !== 'ask_user') { return fallbackText } const question = formatStewardDecisionUserText(decision.question || '') const rationale = formatStewardDecisionUserText(decision.rationale || '') const parts = [ '我已经识别出这一步要先处理申请单,但当前还不能直接生成可提交的申请核对表。', '', rationale ? `**原因是:${rationale}**` : '', '', question || buildStewardApplicationPreviewMessage(preview, fallbackText) ].filter((item) => item !== '') return parts.join('\n') } function buildStewardSlotDecisionSuggestedActions(decision = null, preview = {}) { if (!decision || String(decision.next_action || '').trim() !== 'ask_user') { return [] } const normalizedPreview = normalizeApplicationPreview(preview) const iconMap = { 火车: 'mdi mdi-train', 飞机: 'mdi mdi-airplane', 轮船: 'mdi mdi-ferry' } const actions = Array.isArray(decision.options) ? decision.options : [] return actions.map((option) => { const canonicalField = String(option?.field_key || option?.fieldKey || '').trim() if (canonicalField && !isBlockingApplicationOntologyField(canonicalField)) { return null } const fieldKey = ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP[canonicalField] || canonicalField const value = String(option?.value || option?.label || '').trim() const label = String(option?.label || value).trim() const normalizedValue = fieldKey === 'transportMode' ? normalizeTransportModeOption(value || label, '') : value if (!fieldKey || !value || !label) { return null } if (fieldKey === 'transportMode' && !normalizedValue) { return null } return { action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET, label, description: String(option?.description || '').trim() || `选择${label}后,由小财管家继续测算并生成核对结果。`, icon: iconMap[label] || iconMap[value] || 'mdi mdi-form-select', payload: { field_key: fieldKey, field_label: ONTOLOGY_FIELD_DISPLAY_LABEL_MAP[canonicalField] || label, value: normalizedValue, applicationPreview: normalizedPreview, steward_delegated_field_completion: true } } }).filter(Boolean) } function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') { const normalized = normalizeApplicationPreview(preview) const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized) if (!missingFields.length) { return fallbackText } return [ '我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。', '', `**还需要你补充:${missingFields.join('、')}。**`, '', `请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。` ].join('\n') } function shouldPauseStewardApplicationPreview(preview = {}) { return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0 } function extractStewardCarryLine(text = '', label = '') { const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[::]([^\\n]+)`, 'u')) return match ? match[1].trim() : '' } function extractStewardDelegatedTaskTitle(text = '', sessionType = '') { const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[::]([^。\n]+)/u) if (taskMatch?.[1]) { return taskMatch[1].trim() } return String(sessionType || '').trim() === 'application' ? '本次出差申请' : '本次费用报销' } function sanitizeStewardDelegatedTaskSummary(summary = '', sessionType = '') { const text = String(summary || '').trim() if (String(sessionType || '').trim() !== 'application') { return text } return text .replace(/交通方式和(?:预算|预计)?金额待补充/g, '交通方式待补充') .replace(/出行方式和(?:预算|预计)?金额待补充/g, '出行方式待补充') .replace(/交通方式及(?:预估|预计|预算)?费用/g, '交通方式') .replace(/出行方式及(?:预估|预计|预算)?费用/g, '出行方式') .replace(/(?:费用预算|预算费用|出差费用预算)(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)?[,,、;;\s]*/g, '') .replace(/[,,、;;\s]*(?:预估|预计|预算)费用(?:待补充|待确认|待填写|需补充|需要补充|需确认|需要确认|未补充|缺失)?/g, '') .replace(/[,,、;;\s]*(?:预算|预计)?金额(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)/g, '') .replace(/([,,、;;。])\1+/g, '$1') .replace(/[,,、;;\s]+。/g, '。') .replace(/[,,、;;\s]+$/g, '') .trim() } function summarizeApplicationPreviewForSteward(preview = {}) { const normalized = normalizeApplicationPreview(preview) const fields = normalized.fields || {} return [ fields.time ? `时间:${fields.time}` : '', fields.location ? `地点:${fields.location}` : '', fields.reason ? `事由:${fields.reason}` : '', fields.applicationType ? `类型:${fields.applicationType}` : '' ].filter(Boolean).join(';') } function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) { const actionLabel = resolveStewardDelegatedActionLabel(sessionType) const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}` const rawText = String(context.rawText || '').trim() const taskTitle = extractStewardDelegatedTaskTitle(rawText, sessionType) const taskSummary = sanitizeStewardDelegatedTaskSummary( extractStewardCarryLine(rawText, '任务摘要'), sessionType ) const identifiedInfo = summarizeApplicationPreviewForSteward(context.applicationPreview) || extractStewardCarryLine(rawText, '已识别信息') const carryMissingInfo = extractStewardCarryLine(rawText, '还需要补充') const applicationMissingFields = context.applicationPreview ? resolveBlockingApplicationMissingFieldsForSteward(context.applicationPreview) : [] const missingInfo = applicationMissingFields.length ? applicationMissingFields.join('、') : carryMissingInfo const events = [ { eventId: `${eventPrefix}-intent`, title: '理解当前任务', content: taskSummary ? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}。` : `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。` }, { eventId: `${eventPrefix}-known`, title: '核对已知信息', content: identifiedInfo ? `当前已识别到:${identifiedInfo}。` : `当前先围绕“${taskTitle}”生成可核对内容,具体缺口会在核对结果里继续判断。` } ] if (missingInfo) { events.push({ eventId: `${eventPrefix}-gap`, title: '判断待补充信息', content: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。` }) } else { events.push({ eventId: `${eventPrefix}-ready`, title: '判断下一步动作', content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。` }) } 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 pendingSuggestedActions = Array.isArray(finalExtras.suggestedActions) ? finalExtras.suggestedActions : [] 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;) { await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS) index = Math.min(chars.length, index + STEWARD_DELEGATED_THINKING_CHUNK_SIZE) event.content = chars.slice(0, index).join('') message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming') if (index % STEWARD_DELEGATED_THINKING_CHUNK_SIZE === 0 || index === chars.length) { 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.suggestedActions = pendingSuggestedActions message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing') const chars = Array.from(text) for (let index = 0; index < chars.length;) { await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS) index = resolveStewardTypewriterNextIndex(chars, index) message.text = chars.slice(0, index).join('') message.meta = [STEWARD_ASSISTANT_NAME, '输出中'] message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing') 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 isApplicationDraftPayload(draftPayload) { return String(draftPayload?.draft_type || '').trim() === 'expense_application' } function isSubmittedApplicationDraftPayload(draftPayload) { return ( isApplicationDraftPayload(draftPayload) && String(draftPayload?.status || '').trim() === 'submitted' ) } function shouldExposeReviewPayloadForMessage(payload, options = {}) { const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} if (options.isApplicationSubmitOperation || isApplicationDraftPayload(result.draft_payload)) { return false } return true } function buildPresentationPayload(payload, { exposeReviewPayload = true } = {}) { if (exposeReviewPayload) { return payload } const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} return { ...payload, result: { ...result, review_payload: null } } } 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 = '', options = {} ) { 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 result = await calculateTravelReimbursement(estimateRequest.payload) return applyApplicationPolicyEstimateResult(preview, result, user) } catch (error) { console.warn('Application policy estimate failed:', error) return applyApplicationPolicyEstimateError(preview, error, user) } } const requireModelReview = shouldRequireApplicationModelReview(rawText) if (options.skipModelReview && !requireModelReview) { return { applicationPreview: await enrichWithPolicyEstimate({ ...localPreview, modelReviewStatus: 'skipped' }), meta: ['申请核对预览', '结构化快路径'] } } 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 (!stewardDelegated && 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, { skipModelReview: Boolean(stewardDelegated && options.skipApplicationModelReview) } ) const reviewStatus = String(meta?.[1] || '').trim() let applicationDateConflict = null try { const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 }) applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims) } catch (error) { console.warn('Failed to check overlapping application dates:', error) } if (applicationDateConflict) { const conflictText = buildApplicationDateConflictMessage(applicationDateConflict) const conflictActions = buildApplicationDateConflictActions(applicationDateConflict) if (!stewardDelegated) { completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt) completeFlowStep( 'application-review-preview', '检测到同日期已有申请,已停止重复创建', Date.now() - reviewStartedAt ) replaceMessage(pendingMessage.id, createMessage( 'assistant', conflictText, [], { meta: ['申请日期冲突'], suggestedActions: conflictActions, stewardContinuation: options.stewardContinuation || null } )) } else { await typeStewardDelegatedMessage( pendingMessage.id, conflictText, { meta: [STEWARD_ASSISTANT_NAME, '申请日期冲突'], suggestedActions: conflictActions, stewardContinuation: options.stewardContinuation || null }, { sessionType: effectiveSessionType, rawText, applicationPreview, stewardContinuation: options.stewardContinuation || null } ) } persistSessionState() nextTick(scrollToBottom) return null } if (!stewardDelegated) { completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt) completeFlowStep( 'application-review-preview', reviewStatus === '模型复核完成' ? '模型复核完成,已生成申请核对表' : reviewStatus === '模型复核失败' ? '模型复核失败,已生成临时核对表' : '模型未返回稳定结果,已完成规则兜底核对', Date.now() - reviewStartedAt ) } if (stewardDelegated) { const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage( applicationPreview, buildLocalApplicationPreviewMessage(applicationPreview) ) const localPauseForMissingFields = shouldPauseStewardApplicationPreview(applicationPreview) const shouldFetchSlotDecision = localPauseForMissingFields && !options.skipStewardSlotDecision const slotDecision = shouldFetchSlotDecision ? await fetchStewardApplicationSlotDecision( applicationPreview, rawText, options.stewardContinuation || null ) : null const slotDecisionActions = buildStewardSlotDecisionSuggestedActions(slotDecision, applicationPreview) const pauseForMissingFields = slotDecision ? String(slotDecision.next_action || '').trim() === 'ask_user' : localPauseForMissingFields const stewardApplicationText = buildStewardSlotDecisionMessage( slotDecision, applicationPreview, fallbackStewardApplicationText ) await typeStewardDelegatedMessage( pendingMessage.id, stewardApplicationText, { meta, applicationPreview: pauseForMissingFields ? null : applicationPreview, suggestedActions: slotDecisionActions.length ? slotDecisionActions : buildStewardApplicationPreviewSuggestedActions(applicationPreview), stewardContinuation: options.stewardContinuation || null }, { sessionType: effectiveSessionType, rawText, applicationPreview, 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 exposeReviewPayload = shouldExposeReviewPayloadForMessage(payload, { isApplicationSubmitOperation }) const presentationPayload = buildPresentationPayload(payload, { exposeReviewPayload }) const presentationResult = presentationPayload?.result && typeof presentationPayload.result === 'object' ? presentationPayload.result : {} const resultReviewPayload = presentationResult.review_payload || null const resultSuggestedActions = exposeReviewPayload && Array.isArray(presentationResult.suggested_actions) ? presentationResult.suggested_actions : [] const assistantMessage = createMessage('assistant', resolveAssistantResultText(presentationPayload, fallbackAnswer), [], { meta: buildMessageMeta(presentationPayload, effectiveFileNames), citations: Array.isArray(presentationResult.citations) ? presentationResult.citations : [], suggestedActions: resultSuggestedActions, queryPayload: normalizeExpenseQueryPayload(presentationResult.query_payload), draftPayload: presentationResult.draft_payload || null, reviewPayload: resultReviewPayload, reviewPanelScope: stewardDelegated ? '' : resolveReviewPanelScope({ reviewPayload: resultReviewPayload, reviewAction: reviewActionResult, fileCount: files.length, rawText }), riskFlags: Array.isArray(presentationResult.risk_flags) ? presentationResult.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, rawText, fileNames: effectiveFileNames, stewardContinuation: options.stewardContinuation || null } ) resetStewardDelegatedInsightState() } else { replaceMessage(pendingMessage.id, assistantMessage) const nextInsight = buildAgentInsight( presentationPayload, 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 } }