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, extractReviewAttachmentNames, failCurrentFlowStep, fetchExpenseClaims, fileInputRef, flowRunId, isKnowledgeSession, linkedRequest, mergeBusinessTimeIntoExtraContext, mergeFilePreviews, mergeFilesWithLimit, mergeUploadAttachmentNames, mergeUploadOcrDocuments, messages, nextTick, normalizeExpenseQueryPayload, normalizeOcrDocuments, persistSessionState, props, recognizeOcrFiles, refreshFlowRunDetail, rememberFilePreviews, replaceMessage, resetFlowRun, resolveComposerSubmitText, reviewInlineForm, runOrchestrator, scrollToBottom, sessionSwitchBusy, shouldRequestExpenseIntentConfirmation, shouldRequestExpenseSceneSelection, startExpenseClaimDraftFlowStep, startExpenseIntentConfirmationFlowPreview, startExpenseSceneSelectionFlowPreview, startFlowStep, startSemanticFlowPreview, submitting, syncComposerFilesToDraft, uploadDecisionDialogOpen, toast } = ctx function buildBackendMessage(rawText, fileNames, ocrSummary = '') { const parts = [] const normalizedText = String(rawText || '').trim() if (normalizedText) { parts.push(normalizedText) } else if (fileNames.length) { parts.push( isKnowledgeSession.value ? `我上传了 ${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') } async function submitComposer(options = {}) { if (submitting.value || sessionSwitchBusy.value) return null const rawText = resolveComposerSubmitText(options.rawText).trim() const systemGenerated = Boolean(options.systemGenerated) const resolvedUploadDisposition = String(options.uploadDisposition || '').trim() || (composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value) const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS) const files = fileMergeResult.files if (fileMergeResult.overflowCount > 0) { toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) } if (!rawText && !files.length) return const fileNames = files.map((file) => file.name) const initialExtraContext = options.extraContext && typeof options.extraContext === 'object' ? { ...options.extraContext } : {} const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext() const extraContext = isKnowledgeSession.value ? initialExtraContext : mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext) const reviewAction = String(extraContext.review_action || '').trim() 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 = shouldRequestExpenseIntentConfirmation(rawText, { sessionType: activeSessionType.value, attachmentCount: files.length, reviewAction, hasSelectedExpenseType, hasConfirmedExpenseIntent }) const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, { sessionType: activeSessionType.value, attachmentCount: files.length, reviewAction, hasSelectedExpenseType }) const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value) const hasExistingDocumentEvent = Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0 const userText = String(options.userText || '').trim() || rawText || (isKnowledgeSession.value ? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。` : resolvedUploadDisposition === 'continue_existing' ? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。` : resolvedUploadDisposition === 'new_document' ? `新上传 ${fileNames.length} 份票据,请单独建立报销单。` : `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`) if ( !isKnowledgeSession.value && files.length && hasExistingDocumentEvent && !resolvedUploadDisposition && !options.skipUploadDecisionPrompt && !reviewAction ) { uploadDecisionDialogOpen.value = true return null } if ( !isKnowledgeSession.value && files.length && !hasExistingDocumentEvent && !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) toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。') } } resetFlowRun() if (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', options.pendingText || ( isKnowledgeSession.value ? '正在整理财务知识答案...' : '正在识别并整理右侧核对信息...' ), [], { 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 = [] if (files.length) { const ocrStartedAt = Date.now() startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt }) try { ocrPayload = await recognizeOcrFiles(files) ocrSummary = buildOcrSummary(ocrPayload) ocrDocuments = normalizeOcrDocuments(ocrPayload) ocrFilePreviews = buildOcrFilePreviews(ocrPayload) rememberFilePreviews(ocrFilePreviews) completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt) } catch (error) { console.warn('OCR request failed:', error) completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt) } } 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' } startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), { attachmentCount: effectiveFileNames.length, waitForSceneSelection: waitForExpenseSceneSelection }) const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary) 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 || '', grade: user.grade || '', employee_no: user.employeeNo || user.employee_no || '', manager_name: user.managerName || user.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: activeSessionType.value, entry_source: props.entrySource, user_input_text: systemGenerated ? '' : rawText, attachment_names: effectiveFileNames, attachment_count: effectiveFileNames.length, draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined, ocr_summary: effectiveOcrSummary, ocr_documents: effectiveOcrDocuments, ...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}), ...extraContext } }, isKnowledgeSession.value ? { timeoutMs: 18000, timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。' } : {} ) responsePayload = payload flowRunId.value = 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 = isKnowledgeSession.value ? '' : String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value replaceMessage( pendingMessage.id, createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], { 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, riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [] }) ) currentInsight.value = buildAgentInsight( payload, effectiveFileNames, mergeFilePreviews(filePreviews, ocrFilePreviews) ) completeFlowResult(payload, flowRunDetail) const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim() if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) { try { await syncComposerFilesToDraft(resolvedDraftClaimId, files) } catch (error) { console.warn('Failed to persist composer attachments to draft claim:', error) toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。') } } } catch (error) { clearFlowSimulationTimers() failCurrentFlowStep(error) replaceMessage( pendingMessage.id, createMessage( 'assistant', error?.message || '无法连接后端 Orchestrator,请稍后重试。', [], { meta: ['调用失败'] } ) ) currentInsight.value = buildErrorInsight(error, fileNames) } finally { submitting.value = false composerUploadIntent.value = '' nextTick(scrollToBottom) } return responsePayload } return { submitComposerInternal: submitComposer } }