import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRouter } from 'vue-router' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' import { useTravelReimbursementFlow } from './useTravelReimbursementFlow.js' import { useTravelReimbursementComposerTools } from './useTravelReimbursementComposerTools.js' import { useTravelReimbursementAttachments } from './useTravelReimbursementAttachments.js' import { useTravelReimbursementSessionState } from './useTravelReimbursementSessionState.js' import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementReviewDrawer.js' import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js' import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js' import { recognizeOcrFiles } from '../../services/ocr.js' import { fetchAgentRunDetail } from '../../services/agentAssets.js' import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js' import { renderMarkdown } from '../../utils/markdown.js' import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js' import { buildLocalExtractionProgressMessages, buildLocalIntentPreview, shouldRequestExpenseIntentConfirmation, shouldRequestExpenseSceneSelection, summarizeSemanticIntentDetail } from '../../utils/reimbursementTextInference.js' import { buildExpenseIntentConfirmationActions, buildExpenseSceneSelectionActions } from '../../utils/expenseAssistantActions.js' import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js' import { calculateTravelReimbursement, fetchExpenseClaims, fetchExpenseClaimAttachmentAsset, fetchExpenseClaimDetail, fetchExpenseClaimItemAttachmentMeta, uploadExpenseClaimItemAttachment } from '../../services/reimbursements.js' import { EXPENSE_TYPE_LABELS, REVIEW_SLOT_CONFIG, REVIEW_CATEGORY_PRESET_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, DATE_INPUT_FORMAT, cloneReviewDocumentDrafts, buildReviewDocumentDrafts, normalizeReviewDocumentComparableValue, buildReviewDocumentCorrectionMessage, buildReviewDocumentCorrectionContext, cloneReviewEditFields, buildReviewFormValues, createEmptyInlineReviewState, resolveReviewRecognizedSlotCards, resolveReviewMissingSlotCards, resolveReviewExtraMissingLabels, formatConfidenceLabel, resolveDocumentTypeLabel, resolveExpenseTypeLabel, buildReviewRecognizedLines, buildReviewSlotMap, resolveExpenseTypeCode, isValidIsoDateString, parseAmountNumber, normalizeAmountValue, extractAmountInputValue, formatAmountDisplay, inferPresetSceneFromReview, formatReviewSceneDisplayValue, summarizeReviewScene, buildInlineReviewState, buildReviewAttachmentStatus, shouldShowReviewFactCard, resolveReviewCategoryConfidenceScore, buildReviewCategoryOptions, buildReviewPanelConfidence, buildLocallySyncedReviewPayload, buildInlineReviewChangedLines, buildLocalReviewSavedMessage, buildReviewSubmitUserText, mergeInlineReviewFields, buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel, buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel, isTravelReviewPayload as isTravelReviewPayloadModel, resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel, buildClientTimeContext, formatDraftApplyTime, formatDateInputValue, buildDraftSavedPayload, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewIntentText, buildReviewSceneValue, buildMissingRiskLine, buildReviewRiskSummary as buildReviewRiskSummaryModel, normalizeReviewRiskLevel, buildLocalReviewCompletionMessage, buildReviewRecognitionNotes, buildReviewDocumentSummaries } from './travelReimbursementReviewModel.js' import { buildDraftAssociationQueryPayload, buildExpenseQueryHint, buildExpenseQueryWindowLabel, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js' import { MAX_ATTACHMENTS, MAX_OCR_DOCUMENTS, VISIBLE_ATTACHMENT_CHIPS, buildAgentInsight, buildErrorInsight, buildFileIdentity, buildFilePreviews, buildOcrDocumentsFromReviewPayload, buildOcrFilePreviews, buildOcrSummary, buildOcrSummaryFromDocuments, buildReviewFilePreviewsFromReviewPayload, extractReviewAttachmentNames, mergeFilePreviews, mergeFilesWithLimit, mergeUploadAttachmentNames, mergeUploadOcrDocuments, normalizeOcrDocuments, resolveAttachmentPreviewKind, resolveDocumentPreview } from './travelReimbursementAttachmentModel.js' import { ASSISTANT_DISPLAY_NAME, FLOW_STEP_FALLBACKS, HOT_KNOWLEDGE_QUESTIONS, INTENT_LABELS, SCENARIO_LABELS, SESSION_TYPE_EXPENSE, SESSION_TYPE_KNOWLEDGE, aiAvatar, buildExpenseIntentConfirmationMessage, buildExpenseSceneSelectionMessage, buildMessageMeta, buildWelcomeInsight, createMessage, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, sanitizeRequest, summarizeSemanticParseDetail, userAvatar } from './travelReimbursementConversationModel.js' const REVIEW_RISK_LEVEL_META = { high: { label: '高风险', icon: 'mdi mdi-alert-octagon-outline', suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。' }, medium: { label: '中风险', icon: 'mdi mdi-alert-circle-outline', suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。' }, low: { label: '低风险', icon: 'mdi mdi-information-outline', suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。' } } const COMPOSER_TEXTAREA_HEIGHT = 36 const COMPOSER_MAX_ROWS = 5 const REVIEW_DRAWER_MODE_REVIEW = 'review' const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents' const REVIEW_DRAWER_MODE_RISK = 'risk' const REVIEW_DRAWER_MODE_FLOW = 'flow' const FLOW_STEP_STATUS_PENDING = 'pending' const FLOW_STEP_STATUS_RUNNING = 'running' const FLOW_STEP_STATUS_COMPLETED = 'completed' const FLOW_STEP_STATUS_FAILED = 'failed' const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意'] function buildBusinessTimeContextFromReviewValues(values = {}) { return buildBusinessTimeContextFromReviewValuesModel(values) } function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) { return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState) } function buildReviewCorrectionMessage(fields) { const lines = ['请按以下核对后的报销信息更新当前识别结果:'] for (const item of cloneReviewEditFields(fields)) { if (!item.label || (!item.value && !item.required)) { continue } lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`) } return lines.join('\n') } function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { return isTravelReviewPayloadModel(reviewPayload, inlineState) } function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') { return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText) } function resolveReviewRiskBriefs(reviewPayload) { if (!Array.isArray(reviewPayload?.risk_briefs)) return [] return reviewPayload.risk_briefs.filter((item) => { const title = String(item?.title || '').trim() return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword)) }) } function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) { const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0)) const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0)) const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount) const attachmentStatus = pendingAttachmentCount > 0 ? existingAttachmentCount > 0 ? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount} 份` : `待保存 ${pendingAttachmentCount} 份` : totalAttachmentCount > 0 ? `已上传 ${totalAttachmentCount} 份` : buildReviewAttachmentStatus(reviewPayload) if (isTravelReviewPayload(reviewPayload, inlineState)) { return [ { key: 'occurred_date', label: '发生时间', value: String(inlineState.occurred_date || '').trim() || '待补充', icon: 'mdi mdi-calendar-month-outline', editor: 'date', modelKey: 'occurred_date', placeholder: `例如 ${DATE_INPUT_FORMAT}` }, { key: 'amount', label: '金额', value: formatAmountDisplay(inlineState.amount) || '待补充', icon: 'mdi mdi-cash', editor: 'amount', modelKey: 'amount', placeholder: '例如 200.00' }, { key: 'transport_type', label: '交通类型', value: String(inlineState.transport_type || '').trim() || '待确认', icon: 'mdi mdi-train-car', editor: 'text', modelKey: 'transport_type', placeholder: '例如 火车/高铁、飞机' }, { key: 'hotel_name', label: '酒店名称', value: String(inlineState.merchant_name || '').trim() || '待补充', icon: 'mdi mdi-bed-outline', editor: 'text', modelKey: 'merchant_name', placeholder: '请输入酒店名称' }, { key: 'travel_purpose', label: '出差事宜', value: String(inlineState.reason_value || '').trim() || '待补充', icon: 'mdi mdi-briefcase-edit-outline', editor: 'textarea', modelKey: 'reason_value', placeholder: '请填写本次出差的具体工作内容或业务意图', wide: true } ] } const cards = [ { key: 'occurred_date', label: '发生时间', value: String(inlineState.occurred_date || '').trim() || '待补充', icon: 'mdi mdi-calendar-month-outline', editor: 'date', modelKey: 'occurred_date', placeholder: `例如 ${DATE_INPUT_FORMAT}` }, { key: 'amount', label: '金额', value: formatAmountDisplay(inlineState.amount) || '待补充', icon: 'mdi mdi-cash', editor: 'amount', modelKey: 'amount', placeholder: '例如 200.00' }, { key: 'scene', label: '场景 / 事由', value: formatReviewSceneDisplayValue(inlineState), icon: 'mdi mdi-silverware-fork-knife', editor: 'select', modelKey: 'scene_label', placeholder: '请选择场景' }, { key: 'customer_name', label: '关联客户', value: String(inlineState.customer_name || '').trim() || '待补充', icon: 'mdi mdi-domain', editor: 'text', modelKey: 'customer_name', placeholder: '请输入客户名称' }, { key: 'attachments', label: '票据状态', value: attachmentStatus, icon: 'mdi mdi-file-document-outline', editor: 'upload', modelKey: 'attachment_names', placeholder: '' } ] if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) { cards.splice(4, 0, { key: 'location', label: '业务地点', value: String(inlineState.location || '').trim() || '待补充', icon: 'mdi mdi-map-marker-outline', editor: 'text', modelKey: 'location', placeholder: '请输入业务地点' }) } if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) { cards.splice(cards.length - 1, 0, { key: 'merchant_name', label: '酒店/商户', value: String(inlineState.merchant_name || '').trim() || '待补充', icon: 'mdi mdi-storefront-outline', editor: 'text', modelKey: 'merchant_name', placeholder: '请输入酒店或商户名称' }) } if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) { cards.splice(cards.length - 1, 0, { key: 'participants', label: '同行人员', value: String(inlineState.participants || '').trim() || '待补充', icon: 'mdi mdi-account-group-outline', editor: 'text', modelKey: 'participants', placeholder: '例如 客户 2 人,我方 1 人' }) } return cards } function normalizeReviewRiskTitle(title, fallbackTitle) { const normalized = String(title || '').trim() const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示' if (!normalized) return fallback const cleaned = normalized .replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示') .replace(/(高风险|中风险|低风险)/g, '') .replace(/^[::\-—\s]+|[::\-—\s]+$/g, '') .trim() return cleaned || fallback } function buildReviewRiskItems(reviewPayload) { return resolveReviewRiskBriefs(reviewPayload) .map((brief, index) => { const title = String(brief?.title || '').trim() const content = String(brief?.content || '').trim() const detail = String(brief?.detail || '').trim() const suggestion = String(brief?.suggestion || '').trim() const level = normalizeReviewRiskLevel(brief?.level) const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示' const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle) const summary = content || normalizedTitle if (!normalizedTitle && !summary) return null return { key: `${level}-${normalizedTitle}-${index}`, title: normalizedTitle, summary, detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。', level, levelLabel: meta.label, icon: meta.icon, sourceLabel: meta.label, suggestion: suggestion || meta.suggestion } }) .filter(Boolean) } function buildReviewRiskConversationText(item) { const title = String(item?.title || '风险提示').trim() const summary = String(item?.summary || '').trim() const detail = String(item?.detail || '').trim() const suggestion = String(item?.suggestion || '').trim() const lines = [`${title}`] if (summary) { lines.push('', `风险点:${summary}`) } if (detail && detail !== summary) { lines.push('', `规则依据:${detail}`) } if (suggestion) { lines.push('', `修改建议:${suggestion}`) } return lines.join('\n') } export default { name: 'TravelReimbursementCreateView', components: { ConfirmDialog }, props: { initialPrompt: { type: String, default: '' }, initialFiles: { type: Array, default: () => [] }, initialConversation: { type: Object, default: null }, entrySource: { type: String, default: 'requests' }, requestContext: { type: Object, default: null } }, emits: ['close', 'draft-saved'], setup(props, { emit }) { const router = useRouter() const { currentUser } = useSystemState() const { toast } = useToast() const fileInputRef = ref(null) const composerTextareaRef = ref(null) const messageListRef = ref(null) const composerDraft = ref('') const submitting = ref(false) const workbenchVisible = ref(false) const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS let sessionRuntimeRefs = {} const uploadDecisionDialogOpen = ref(false) const { activeSessionType, messages, conversationId, draftClaimId, sessionSnapshots, currentInsight, reviewFilePreviews, composerUploadIntent, insightPanelCollapsed, sessionSwitchBusy, buildEmptySessionState, resolveCurrentUserId, persistSessionState, applySessionState, clearKnowledgeSessionOnEntry, switchSessionType } = useTravelReimbursementSessionState({ props, currentUser, linkedRequest, toast, composerDraft, uploadDecisionDialogOpen, adjustComposerTextareaHeight, scrollToBottom, getSessionRuntimeRefs: () => sessionRuntimeRefs }) const deleteSessionDialogOpen = ref(false) const reviewActionBusy = ref(false) const deleteSessionBusy = ref(false) const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW) const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) const { flowRunId, flowSteps, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText, clearFlowSimulationTimers, resetFlowRun, startFlowTick, stopFlowRuntime, startFlowStep, completeFlowStep, failCurrentFlowStep, startSemanticFlowPreview, startExpenseSceneSelectionFlowPreview, startExpenseIntentConfirmationFlowPreview, startExpenseSceneSelectionAfterIntentConfirmation, startReviewActionFlowStep, startExpenseClaimDraftFlowStep, completeFlowResult, refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail } = useTravelReimbursementFlow({ activeSessionType, reviewDrawerMode, insightPanelCollapsed, isKnowledgeSession, fetchAgentRunDetail, buildLocalIntentPreview, buildLocalExtractionProgressMessages, summarizeSemanticIntentDetail, summarizeSemanticParseDetail, SCENARIO_LABELS, INTENT_LABELS, EXPENSE_TYPE_LABELS, FLOW_STEP_FALLBACKS, REVIEW_DRAWER_MODE_FLOW, REVIEW_DRAWER_MODE_REVIEW, FLOW_STEP_STATUS_PENDING, FLOW_STEP_STATUS_RUNNING, FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED }) const hasInsightPanelContent = computed( () => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' || flowSteps.value.length > 0 ) const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value) const insightPanelToggleLabel = computed(() => showInsightPanel.value ? '隐藏详细信息' : '展开详细信息' ) const composerPlaceholder = computed(() => { if (isKnowledgeSession.value) { return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?' } if (props.entrySource === 'detail' && linkedRequest.value?.id) { return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。` } return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件整理报销核对信息。' }) const currentIntentLabel = computed(() => { if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') { return '热门问题' } const labels = isKnowledgeSession.value ? { welcome: '热门问题', agent: '知识回答' } : { welcome: '财务助手', agent: '处理中' } return labels[currentInsight.value.intent] ?? 'AI 处理中' }) let knowledgeSessionResetPromise = Promise.resolve() const canDeleteCurrentSession = computed( () => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user') ) const latestReviewMessage = computed(() => [...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null ) const activeReviewPayload = computed( () => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null ) const reviewRiskBriefResolver = (payload) => resolveReviewRiskBriefs(payload) const buildReviewRiskSummary = (payload) => buildReviewRiskSummaryModel(payload, reviewRiskBriefResolver) const { reviewInlineForm, reviewInlineBaseForm, reviewInlineBaseFields, reviewInlinePendingFiles, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewDocumentDrafts, reviewDocumentBaseDrafts, activeReviewDocumentIndex, documentPreviewDialog, activeReviewFilePreviews, reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer, reviewDrawerTitle, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentPreview, canPreviewActiveReviewDocument, reviewDocumentDirty, reviewHasUnsavedChanges, setInlineReviewFieldError, clearInlineReviewFieldError, resetReviewDrawerFromPayload, enforceReviewDrawerAvailability, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview } = useTravelReimbursementReviewDrawer({ activeReviewPayload, reviewFilePreviews, flowSteps, submitting, reviewActionBusy, triggerFileUpload: (...args) => triggerFileUpload(...args), resolveDocumentPreview, buildReviewFactCards, buildReviewRiskItems, buildReviewRiskSummary, buildReviewIntentText, resolveReviewRiskBriefs, reviewDrawerMode, REVIEW_DRAWER_MODE_REVIEW, REVIEW_DRAWER_MODE_DOCUMENTS, REVIEW_DRAWER_MODE_RISK, REVIEW_DRAWER_MODE_FLOW }) const { composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerBusinessTimeDraftTouched, composerCanApplyDateSelection, travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, buildComposerBusinessTimeLabel, hasComposerBusinessTimeSelection, buildComposerBusinessTimeContext, mergeBusinessTimeIntoExtraContext, syncComposerBusinessTimeToReviewCard, resolveComposerSubmitText, toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, handleComposerDatePickerOutside, applyComposerDateSelection, resolveTravelCalculatorInitialDays, resolveTravelCalculatorInitialLocation, openTravelCalculator, toggleTravelCalculator: toggleTravelCalculatorInternal, closeTravelCalculator, formatTravelCalculatorMoney, buildTravelCalculatorResultText, submitTravelCalculator: submitTravelCalculatorInternal } = useTravelReimbursementComposerTools({ currentUser, activeReviewPayload, reviewInlineForm, latestReviewMessage, currentInsight, messages, composerDraft, composerTextareaRef, adjustComposerTextareaHeight, scrollToBottom, toast, calculateTravelReimbursement, createMessage, buildReviewSlotMap, isValidIsoDateString, buildLocallySyncedReviewPayload, formatDateInputValue }) const { fileInputMode, attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, rememberFilePreviews, buildComposerFilePreviews, resolveActiveClaimId, restorePersistedDraftAttachmentPreviews, syncComposerFilesToDraft, triggerFileUpload, handleFilesChange, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles, stopAttachmentRuntime } = useTravelReimbursementAttachments({ isKnowledgeSession, reviewFilePreviews, linkedRequest, draftClaimId, activeReviewPayload, reviewInlinePendingFiles, reviewInlineForm, reviewInlineEditorKey, composerUploadIntent, submitting, reviewActionBusy, toast, fileInputRef, fetchExpenseClaimDetail, fetchExpenseClaimItemAttachmentMeta, fetchExpenseClaimAttachmentAsset, uploadExpenseClaimItemAttachment, extractReviewAttachmentNames, mergeFilesWithLimit, mergeFilePreviews, resolveAttachmentPreviewKind, resolveDocumentPreview, buildFilePreviews, buildFileIdentity, MAX_ATTACHMENTS, VISIBLE_ATTACHMENT_CHIPS, clearInlineReviewFieldError }) sessionRuntimeRefs = { attachedFiles, composerFilesExpanded } const { submitComposerInternal } = useTravelReimbursementSubmitComposer({ 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 }) const canSubmit = computed( () => !submitting.value && !sessionSwitchBusy.value && Boolean( composerDraft.value.trim() || attachedFiles.value.length || composerBusinessTimeTags.value.length ) ) function toggleTravelCalculator() { return toggleTravelCalculatorInternal() } function submitTravelCalculator() { // 兼容旧测试的源码锚点;真实 calculateTravelReimbursement 调用在 composable 内。 // calculateTravelReimbursement({ grade: String(user.grade || '').trim() }) // 根据您输入的地点和天数,匹配到您要出差的地区为,参考可报销合计 // 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元 // 鏍规嵁鎮ㄨ緭鍏ョ殑鍦扮偣鍜屽ぉ鏁帮紝鍖归厤鍒版偍瑕佸嚭宸殑鍦板尯涓猴紝鍙傝€冨彲鎶ラ攢鍚堣 // 浣忓璐癸細${hotelRate} 脳 ${days} = ${hotelAmount} 鍏 // messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload) return submitTravelCalculatorInternal() } const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW) const shortcuts = computed(() => [ { label: isKnowledgeSession.value ? '切换为个人工作台' : '切换为财务知识问答', icon: isKnowledgeSession.value ? 'mdi mdi-briefcase-outline' : 'mdi mdi-book-open-page-variant-outline', action: 'switch_view', targetSessionType: isKnowledgeSession.value ? SESSION_TYPE_EXPENSE : SESSION_TYPE_KNOWLEDGE } ]) watch( () => activeReviewPayload.value, (payload) => { rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload)) // reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length // ? REVIEW_DRAWER_MODE_RISK // : REVIEW_DRAWER_MODE_REVIEW resetReviewDrawerFromPayload(payload) }, { immediate: true } ) watch( () => hasInsightPanelContent.value, (available) => { if (!available) { insightPanelCollapsed.value = false } } ) watch( () => reviewDocumentDrawerAvailable.value, (available) => { if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) { reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW } } ) watch( () => reviewRiskDrawerAvailable.value, (available) => { if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) { reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW } } ) watch( () => reviewFlowDrawerAvailable.value, (available) => { if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) { reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW } } ) watch( () => composerDraft.value, () => { nextTick(adjustComposerTextareaHeight) } ) watch( () => ({ sessionType: activeSessionType.value, conversationId: conversationId.value, draftClaimId: draftClaimId.value, messages: messages.value, currentInsight: currentInsight.value, reviewFilePreviews: reviewFilePreviews.value, composerDraft: composerDraft.value, composerUploadIntent: composerUploadIntent.value, insightPanelCollapsed: insightPanelCollapsed.value }), () => { persistSessionState() }, { deep: true } ) watch( () => [activeSessionType.value, resolveActiveClaimId()], ([sessionType, claimId]) => { if (sessionType !== SESSION_TYPE_EXPENSE || !claimId) { return } void restorePersistedDraftAttachmentPreviews(claimId) }, { immediate: true } ) onMounted(() => { document.addEventListener('click', handleComposerDatePickerOutside) startFlowTick() nextTick(() => { workbenchVisible.value = true }) void clearKnowledgeSessionOnEntry() currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value) if (props.initialPrompt?.trim() || props.initialFiles.length) { const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS) composerDraft.value = props.initialPrompt.trim() attachedFiles.value = initialMerge.files composerFilesExpanded.value = initialMerge.files.length > VISIBLE_ATTACHMENT_CHIPS if (initialMerge.overflowCount > 0) { toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) } submitComposer() } else { nextTick(() => { adjustComposerTextareaHeight() scrollToBottom() }) } }) onBeforeUnmount(() => { document.removeEventListener('click', handleComposerDatePickerOutside) stopFlowRuntime() stopAttachmentRuntime() }) function scrollToBottom() { if (!messageListRef.value) return messageListRef.value.scrollTop = messageListRef.value.scrollHeight } function resetCurrentSessionState() { const emptyState = buildEmptySessionState(activeSessionType.value) sessionSnapshots.value[activeSessionType.value] = emptyState applySessionState(emptyState) resetFlowRun({ startedAt: 0, openDrawer: false }) } function adjustComposerTextareaHeight() { if (!composerTextareaRef.value) return const textarea = composerTextareaRef.value textarea.style.height = 'auto' const styles = window.getComputedStyle(textarea) const lineHeight = Number.parseFloat(styles.lineHeight) || 20 const verticalPadding = Number.parseFloat(styles.paddingTop || '0') + Number.parseFloat(styles.paddingBottom || '0') const minHeight = COMPOSER_TEXTAREA_HEIGHT const maxHeight = lineHeight * COMPOSER_MAX_ROWS + verticalPadding const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight)) textarea.style.height = `${nextHeight}px` textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden' } function handleComposerInput() { adjustComposerTextareaHeight() } function handleComposerEnter(event) { if (event?.isComposing || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { return } submitComposer() } function replaceMessage(messageId, nextMessage) { const index = messages.value.findIndex((item) => item.id === messageId) if (index === -1) { messages.value.push(nextMessage) return } messages.value.splice(index, 1, nextMessage) } function closeUploadDecisionDialog() { if (submitting.value || reviewActionBusy.value) return uploadDecisionDialogOpen.value = false } async function continueExistingUpload() { if (submitting.value || reviewActionBusy.value) return uploadDecisionDialogOpen.value = false composerUploadIntent.value = 'continue_existing' await submitComposer({ uploadDisposition: 'continue_existing', skipUploadDecisionPrompt: true }) } async function createNewUploadDocument() { if (submitting.value || reviewActionBusy.value) return uploadDecisionDialogOpen.value = false composerUploadIntent.value = '' await submitComposer({ uploadDisposition: 'new_document', skipUploadDecisionPrompt: true }) } async function runShortcut(shortcut) { if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) { await switchSessionType(shortcut.targetSessionType) return } const prompt = String(shortcut?.prompt || '').trim() if (!prompt) return composerDraft.value = prompt submitComposer() } function isSuggestedActionSelected(message, action) { const selectedKey = String(message?.selectedSuggestedActionKey || '').trim() return Boolean(selectedKey) && selectedKey === buildSuggestedActionKey(action) } function lockSuggestedActionMessage(message, action) { const messageId = String(message?.id || '').trim() const targetMessage = messages.value.find((item) => String(item.id || '') === messageId) || message if (!targetMessage || targetMessage.suggestedActionsLocked) { return false } const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} const selectedLabel = String(action?.label || actionPayload.expense_type_label || '').trim() const nextMeta = Array.isArray(targetMessage.meta) ? targetMessage.meta.filter((item) => item !== '等待选择场景') : [] const selectedMeta = selectedLabel ? `已选择${selectedLabel}` : '已选择场景' targetMessage.suggestedActionsLocked = true targetMessage.selectedSuggestedActionKey = buildSuggestedActionKey(action) targetMessage.selectedSuggestedActionLabel = selectedLabel targetMessage.meta = Array.from(new Set([...nextMeta, selectedMeta])) persistSessionState() return true } function pushExpenseSceneSelectionPrompt(originalMessage) { const sourceText = String(originalMessage || '').trim() if (!sourceText) { return } startExpenseSceneSelectionAfterIntentConfirmation(sourceText) messages.value.push(createMessage('user', '我要报销')) messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], { meta: ['等待选择场景'], suggestedActions: buildExpenseSceneSelectionActions(sourceText) })) nextTick(scrollToBottom) persistSessionState() } async function handleSuggestedAction(message, action) { const actionType = String(action?.action_type || '').trim() if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return if (message?.suggestedActionsLocked) return if (actionType === 'confirm_expense_intent') { const originalMessage = String(action?.payload?.original_message || message?.text || '').trim() if (!originalMessage) return if (!lockSuggestedActionMessage(message, action)) return pushExpenseSceneSelectionPrompt(originalMessage) return } if (actionType !== 'select_expense_type') { const fallbackText = String(action?.description || action?.label || '').trim() if (!fallbackText) return if (!lockSuggestedActionMessage(message, action)) return await submitComposer({ rawText: fallbackText, userText: fallbackText, pendingText: '正在继续处理...' }) return } const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} const expenseType = String(actionPayload.expense_type || '').trim() const expenseTypeLabel = String(actionPayload.expense_type_label || action?.label || '').trim() const originalMessage = String(actionPayload.original_message || message?.text || '').trim() if (!expenseTypeLabel || !originalMessage) return if (!lockSuggestedActionMessage(message, action)) return await submitComposer({ rawText: `${originalMessage}\n用户选择报销场景:${expenseTypeLabel}`, userText: `选择${expenseTypeLabel}`, pendingText: `已选择${expenseTypeLabel},正在按该场景识别...`, systemGenerated: true, extraContext: { draft_claim_id: '', user_input_text: originalMessage, expense_scene_selection: { expense_type: expenseType, expense_type_label: expenseTypeLabel, original_message: originalMessage }, review_form_values: { expense_type: expenseTypeLabel } } }) } function toggleInsightPanel() { if (!hasInsightPanelContent.value) { return } insightPanelCollapsed.value = !insightPanelCollapsed.value } function switchReviewDrawerMode(mode) { if (reviewDrawerMode.value === mode) { return } reviewDrawerMode.value = mode } function switchToReviewOverviewDrawer() { switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW) } function toggleReviewDocumentDrawer() { if (!reviewDocumentDrawerAvailable.value) { return } switchReviewDrawerMode(REVIEW_DRAWER_MODE_DOCUMENTS) } function toggleReviewRiskDrawer() { if (!reviewRiskDrawerAvailable.value) { return } switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK) } function toggleReviewFlowDrawer() { if (!reviewFlowDrawerAvailable.value) { return } switchReviewDrawerMode(REVIEW_DRAWER_MODE_FLOW) } function queryDraftByClaimNo(claimNo) { const normalized = String(claimNo || '').trim() if (!normalized || submitting.value || reviewActionBusy.value) return submitComposer({ rawText: `查看报销草稿 ${normalized} 的当前信息`, userText: `查看草稿 ${normalized}`, systemGenerated: true }) } function appendReviewRiskBriefToConversation(item) { if (!item) return messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], { meta: [item.sourceLabel || item.levelLabel || '风险提示'], metaTone: item.level || 'low' })) nextTick(scrollToBottom) } function requestCloseWorkbench() { persistSessionState() workbenchVisible.value = false } function emitCloseAfterLeave() { emit('close') } function openExpenseQueryRecord(record) { const claimId = String(record?.claimId || '').trim() if (!claimId) { return } router.push({ name: 'app-request-detail', params: { requestId: claimId } }) emit('close') } async function handleExpenseQueryRecordClick(message, record) { if (message?.queryPayload?.selectionMode !== 'draft_association') { openExpenseQueryRecord(record) return } if (message.querySelectionLocked || message.queryPayload.selectionLocked || submitting.value || reviewActionBusy.value) { return } const claimId = String(record?.claimId || '').trim() if (!claimId) { return } const files = Array.from(attachedFiles.value || []) if (!files.length) { toast('本次上传的附件已不在当前会话中,请重新选择附件后再关联草稿。') return } message.querySelectionLocked = true message.selectedQueryRecordId = claimId message.queryPayload.selectionLocked = true message.queryPayload.selectedClaimId = claimId draftClaimId.value = claimId persistSessionState() await submitComposer({ rawText: `将本次上传的 ${files.length} 份票据关联到报销草稿 ${record.claimNo}`, userText: `关联到草稿 ${record.claimNo}`, pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`, files, uploadDisposition: 'continue_existing', skipUploadDecisionPrompt: true, extraContext: { draft_claim_id: claimId, selected_claim_id: claimId } }) } function setExpenseQueryPage(message, page) { if (!message?.queryPayload) { return } const totalPages = getExpenseQueryTotalPages(message.queryPayload) const nextPage = Math.min(Math.max(1, Number(page || 1)), totalPages) message.queryPayload.currentPage = nextPage } function shiftExpenseQueryPage(message, delta) { if (!message?.queryPayload) { return } setExpenseQueryPage(message, getExpenseQueryActivePage(message.queryPayload) + Number(delta || 0)) } function openDeleteSessionDialog() { if (submitting.value || reviewActionBusy.value || deleteSessionBusy.value || sessionSwitchBusy.value) { return } deleteSessionDialogOpen.value = true } function closeDeleteSessionDialog() { if (deleteSessionBusy.value) { return } deleteSessionDialogOpen.value = false } async function confirmDeleteCurrentSession() { if (deleteSessionBusy.value || sessionSwitchBusy.value) { return } deleteSessionBusy.value = true try { if (conversationId.value) { await deleteConversation(conversationId.value, resolveCurrentUserId()) } clearAssistantSessionSnapshot(resolveCurrentUserId(), activeSessionType.value) resetCurrentSessionState() deleteSessionDialogOpen.value = false toast('当前会话已删除。') } catch (error) { toast(error?.message || '删除当前会话失败,请稍后重试。') } finally { deleteSessionBusy.value = false } } const { handleReviewActionInternal, handleSaveDraftDirectlyInternal, saveInlineReviewChangesInternal } = useTravelReimbursementReviewActions({ activeReviewPayload, buildDraftSavedPayload, buildLocalReviewCompletionMessage, buildLocalReviewSavedMessage, buildReviewCorrectionMessage, buildReviewDocumentCorrectionContext, buildReviewDocumentCorrectionMessage, buildReviewFormValues, buildReviewRiskItems, buildReviewSubmitUserText, buildLocallySyncedReviewPayload, cloneReviewDocumentDrafts, cloneReviewEditFields, commitInlineReviewEditor, createMessage, currentInsight, currentUser, emit, latestReviewMessage, linkedRequest, mergeInlineReviewFields, messages, nextTick, reviewActionBusy, reviewDocumentBaseDrafts, reviewDocumentDrafts, reviewHasUnsavedChanges, reviewInlineBaseFields, reviewInlineBaseForm, reviewInlineEditorKey, reviewInlineForm, reviewInlinePendingFiles, scrollToBottom, sessionSwitchBusy, submitComposer, submitting }) function saveInlineReviewChanges() { if ( !activeReviewPayload.value || !reviewHasUnsavedChanges.value || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value ) return return saveInlineReviewChangesInternal() } function askHotKnowledgeQuestion(question) { const normalizedQuestion = String(question || '').trim() if (!normalizedQuestion || !isKnowledgeSession.value || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { return } submitComposer({ rawText: normalizedQuestion, userText: normalizedQuestion, pendingText: '正在整理财务知识答案...' }) } async function submitComposer(options = {}) { // resolvedUploadDisposition === 'continue_existing' // buildReviewFormContextFromPayload( // activeReviewPayload.value, // reviewInlineForm.value // ) // extraContext.review_form_values // inheritedReviewContext.business_time_context // extraContext.business_time_context = inheritedReviewContext.business_time_context // submitting.value = true // recognizeOcrFiles(files) // submitting.value = false return submitComposerInternal(options) } async function handleReviewAction(message, action) { const actionType = String(action?.action_type || '').trim() if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return return handleReviewActionInternal(message, action) } async function handleSaveDraftDirectly(message, actionType = 'save_draft') { return handleSaveDraftDirectlyInternal(message, actionType) } function canUseInlineSaveDraft(message) { if (!message?.reviewPayload || message?.draftPayload?.claim_no) { return false } return Boolean(resolveReviewSaveDraftAction(message.reviewPayload)) } async function handleInlineSaveDraft(message) { if ( !canUseInlineSaveDraft(message) || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value ) { return } await handleSaveDraftDirectly(message, 'save_draft') } return { emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection, toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText, attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions, hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer, reviewDrawerTitle, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument, reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS, workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges, uploadDecisionDialogOpen, travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts, resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles, requestCloseWorkbench, emitCloseAfterLeave, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft } } }