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

2944 lines
106 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TravelReimbursementInsightPanel from '../../components/travel/TravelReimbursementInsightPanel.vue'
import TravelReimbursementMessageItem from '../../components/travel/TravelReimbursementMessageItem.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 { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
import {
buildStewardFieldItems,
formatStewardMissingFieldList,
formatStewardOntologyFields
} from './stewardPlanModel.js'
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import {
buildOperationFeedbackPayload,
normalizeOperationFeedbackContext
} from '../../composables/useOperationFeedback.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { createOperationFeedback } from '../../services/operationFeedback.js'
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
import { fetchStewardPlan, fetchStewardPlanStream } from '../../services/steward.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 { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../../utils/assistantSuggestedActionPrefill.js'
import {
buildApplicationPreviewFooterMessage,
buildApplicationPreviewSubmitText,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP,
buildTravelPlanningNudgeMessage,
buildTravelPlanningRecommendation,
buildTravelPlanningSuggestedActions
} from '../../utils/travelApplicationPlanning.js'
import {
calculateTravelReimbursement,
createExpenseClaimItem,
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,
buildReviewNextStepRichCopy,
buildReviewRiskLevelCounts,
resolveReviewFooterActions,
resolveReviewSaveDraftAction,
resolveReviewNextStepAction,
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,
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildFileIdentity,
buildFilePreviews,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFilePreviewsFromReviewPayload,
extractReviewAttachmentNames,
isTemporaryPreviewUrl,
mergeFilePreviews,
mergeFilesWithLimit,
mergeUploadAttachmentNames,
mergeUploadOcrDocuments,
normalizeOcrDocuments,
resolveAttachmentPreviewKind,
resolveDocumentPreview
} from './travelReimbursementAttachmentModel.js'
import {
ASSISTANT_SESSION_MODE_OPTIONS,
ASSISTANT_DISPLAY_NAME,
FLOW_STEP_FALLBACKS,
HOT_KNOWLEDGE_QUESTIONS,
INTENT_LABELS,
SCENARIO_LABELS,
SESSION_TYPE_BUDGET,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
SESSION_TYPE_STEWARD,
canUseBudgetAssistantSession,
aiAvatar,
buildExpenseIntentConfirmationMessage,
buildExpenseSceneSelectionMessage,
buildMessageMeta,
buildWelcomeInsight,
createMessage,
filterAssistantSessionModes,
hasMeaningfulSessionMessages,
resolveAssistantSessionMode,
resolveKnowledgeRankLabel,
resolveKnowledgeRankTone,
sanitizeRequest,
summarizeSemanticParseDetail,
userAvatar
} from './travelReimbursementConversationModel.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 14
const REVIEW_RISK_LEVEL_META = {
high: {
label: '高风险',
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
medium: {
label: '中风险',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
icon: 'mdi mdi-information-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 REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const REVIEW_NEXT_STEP_HREF = '#review-next-step'
const APPLICATION_SUBMIT_HREF = '#application-submit'
const REVIEW_RISK_PANEL_HREF_PREFIX = '#review-risk'
const REVIEW_QUICK_EDIT_HREF = '#review-quick-edit'
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 normalizeReviewPanelScope(scope) {
const normalized = String(scope || '').trim()
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
? normalized
: ''
}
function canExposeReviewPanelScope(scope) {
return Boolean(normalizeReviewPanelScope(scope))
}
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: 'attachments',
label: '票据状态',
value: attachmentStatus,
icon: 'mdi mdi-file-document-outline',
editor: 'upload',
modelKey: 'attachment_names',
placeholder: ''
}
]
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
cards.splice(cards.length - 1, 0, {
key: 'customer_name',
label: '关联客户',
value: String(inlineState.customer_name || '').trim() || '待补充',
icon: 'mdi mdi-domain',
editor: 'text',
modelKey: 'customer_name',
placeholder: '请输入客户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
cards.splice(cards.length - 1, 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, detailTarget = {}) {
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 isInfo = String(item?.level || '').trim() === 'info'
const detailHref = String(detailTarget?.href || '').trim()
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
const lines = [`${title}`]
if (summary) {
lines.push('', `${isInfo ? '提示内容' : '风险点'}${summary}`)
}
if (detail && detail !== summary) {
lines.push('', `规则依据:${detail}`)
}
if (suggestion) {
lines.push('', `${isInfo ? '处理建议' : '修改建议'}${suggestion}`)
}
if (detailHref) {
lines.push('', `[${detailLabel}](${detailHref})`)
}
return lines.join('\n')
}
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
function buildReviewMainMessageText(message) {
const text = String(message?.text || '')
if (!message?.reviewPayload) {
return text
}
return text
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export default {
name: 'TravelReimbursementCreateView',
components: {
ElDialog,
ConfirmDialog,
TravelReimbursementInsightPanel,
TravelReimbursementMessageItem
},
props: {
initialPrompt: {
type: String,
default: ''
},
initialPromptAutoSubmit: {
type: Boolean,
default: true
},
initialApplicationPreview: {
type: Object,
default: null
},
initialFiles: {
type: Array,
default: () => []
},
initialConversation: {
type: Object,
default: null
},
initialBudgetContext: {
type: Object,
default: null
},
initialSessionType: {
type: String,
default: ''
},
entrySource: {
type: String,
default: 'requests'
},
requestContext: {
type: Object,
default: null
},
invalidatedDraftClaimId: {
type: String,
default: ''
},
reopenToken: {
type: Number,
default: 0
}
},
emits: ['close', 'draft-saved', 'request-updated'],
setup(props, { emit }) {
const router = useRouter()
const { currentUser, refreshCurrentUserFromBackend } = 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 closeAfterBusy = ref(false)
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS
let sessionRuntimeRefs = {}
const {
activeSessionType,
messages,
conversationId,
draftClaimId,
sessionSnapshots,
currentInsight,
reviewFilePreviews,
composerUploadIntent,
guidedFlowState,
insightPanelCollapsed,
sessionSwitchBusy,
buildEmptySessionState,
resolveCurrentUserId,
persistSessionState,
applySessionState,
switchSessionType
} = useTravelReimbursementSessionState({
props,
currentUser,
linkedRequest,
toast,
composerDraft,
adjustComposerTextareaHeight,
scrollToBottom,
getSessionRuntimeRefs: () => sessionRuntimeRefs
})
const deleteSessionDialogOpen = ref(false)
const applicationSubmitConfirmDialog = ref({
open: false,
message: null
})
const nextStepConfirmDialog = ref({
open: false,
message: null,
action: null
})
const reviewActionBusy = ref(false)
const deleteSessionBusy = ref(false)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const {
applicationPreviewEditor,
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
isApplicationPreviewEditing,
isApplicationPreviewDateEditorOpen,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
commitApplicationPreviewDateEditor,
cancelApplicationPreviewEditor,
setApplicationPreviewDateMode,
canApplyApplicationPreviewDateSelection,
handleApplicationPreviewEditorKeydown
} = useApplicationPreviewEditor({
persistSessionState,
toast,
calculateTravelReimbursement,
currentUser
})
function applyLinkedApplicationPreviewDateSelection(selection) {
const editor = applicationPreviewEditor.value
if (editor.fieldKey !== 'time' || !editor.messageId) {
return false
}
const targetMessage = messages.value.find((item) =>
String(item.id || '') === String(editor.messageId || '')
)
if (!targetMessage?.applicationPreview) {
return false
}
applicationPreviewEditor.value = {
...editor,
dateMode: selection.mode === 'range' ? 'range' : 'single',
singleDate: selection.startDate,
rangeStartDate: selection.startDate,
rangeEndDate: selection.endDate || selection.startDate
}
return commitApplicationPreviewDateEditor(targetMessage)
}
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
const isStewardSession = computed(() => activeSessionType.value === SESSION_TYPE_STEWARD)
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
const assistantHeaderTitle = computed(() => (isStewardSession.value ? '小财管家' : '个人工作台'))
const assistantHeaderDescription = computed(() =>
isStewardSession.value ? '统一财务任务编排入口' : '个人工作窗,一站式费控解决枢纽'
)
const {
flowRunId,
flowSteps,
activeFlowSteps,
visibleFlowSteps,
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 hasScopedReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && canExposeReviewPanelScope(agent.reviewPanelScope)) {
return true
}
if (currentInsight.value.intent === 'agent' && agent) {
return false
}
return messages.value.some((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
)
})
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
const hasInsightPanelContent = computed(() => {
return isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || activeFlowSteps.value.length > 0
})
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
const insightPanelToggleLabel = computed(() =>
showInsightPanel.value ? '隐藏详细信息' : '展开详细信息'
)
const composerPlaceholder = computed(() => {
if (isStewardSession.value) {
return '例如申请7月2日去北京出差同时报销昨天交通费和6月3日上海出差费用。'
}
if (isKnowledgeSession.value) {
return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?'
}
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
}
if (activeSessionType.value === SESSION_TYPE_APPLICATION) {
return '例如:我想先申请一笔差旅费用,去上海支持项目部署。'
}
if (activeSessionType.value === SESSION_TYPE_APPROVAL) {
return '例如:查一下待我审核的单据,或帮我生成这张单据的审核意见。'
}
if (activeSessionType.value === SESSION_TYPE_BUDGET) {
return '例如:查询市场部 Q1 预算编制情况,重点看差旅、通信、招待费和办公用品。'
}
return '例如查一下近10日报销金额、解释酒店超标风险或根据附件整理报销核对信息。'
})
const currentIntentLabel = computed(() => {
if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') {
return '热门问题'
}
const labels = isKnowledgeSession.value
? {
welcome: '热门问题',
agent: '知识回答'
}
: {
welcome: activeAssistantMode.value?.label || '财务助手',
agent: '处理中'
}
return labels[currentInsight.value.intent] ?? 'AI 处理中'
})
const canDeleteCurrentSession = computed(
() => Boolean(conversationId.value) || hasMeaningfulSessionMessages(messages.value)
)
const latestReviewMessage = computed(() =>
[...messages.value].reverse().find((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
) ?? null
)
const activeReviewPanelScope = computed(() => {
const agent = currentInsight.value.agent || null
const agentScope = normalizeReviewPanelScope(agent?.reviewPanelScope)
if (agent?.reviewPayload && agentScope) {
return agentScope
}
if (currentInsight.value.intent === 'agent' && agent) {
return ''
}
return normalizeReviewPanelScope(latestReviewMessage.value?.reviewPanelScope)
})
const activeReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && normalizeReviewPanelScope(agent.reviewPanelScope)) {
return agent.reviewPayload
}
if (currentInsight.value.intent === 'agent' && agent) {
return null
}
return 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,
reviewOverviewDrawerAvailable,
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,
activeReviewPanelScope,
reviewFilePreviews,
flowSteps: activeFlowSteps,
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,
resolveComposerDisplaySubmitText,
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,
handleComposerDateInputChange,
removeComposerBusinessTimeTag,
handleComposerDatePickerOutside,
applyComposerDateSelection,
resolveTravelCalculatorInitialDays,
resolveTravelCalculatorInitialLocation,
openTravelCalculator: openTravelCalculatorInternal,
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,
onComposerDateSelection: applyLinkedApplicationPreviewDateSelection
})
function syncComposerDateFromApplicationEditor() {
const editor = applicationPreviewEditor.value
const today = formatDateInputValue()
composerDateMode.value = editor.dateMode === 'range' ? 'range' : 'single'
composerSingleDate.value = editor.singleDate || today
composerRangeStartDate.value = editor.rangeStartDate || composerSingleDate.value || today
composerRangeEndDate.value = editor.rangeEndDate || composerRangeStartDate.value || today
composerDatePickerOpen.value = true
travelCalculatorOpen.value = false
}
function openApplicationPreviewEditorFromUi(message, fieldKey, value) {
openApplicationPreviewEditor(message, fieldKey, value)
if (fieldKey === 'time' && isApplicationPreviewEditing(message, 'time')) {
syncComposerDateFromApplicationEditor()
}
}
watch(composerDatePickerOpen, (open, previousOpen) => {
if (!open && previousOpen && applicationPreviewEditor.value.fieldKey === 'time') {
cancelApplicationPreviewEditor()
}
})
const canShowTravelCalculator = computed(() => activeSessionType.value === SESSION_TYPE_EXPENSE)
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,
createExpenseClaimItem,
fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimAttachmentAsset,
uploadExpenseClaimItemAttachment,
extractReviewAttachmentNames,
mergeFilesWithLimit,
mergeFilePreviews,
isTemporaryPreviewUrl,
resolveAttachmentPreviewKind,
resolveDocumentPreview,
buildFilePreviews,
buildFileIdentity,
MAX_ATTACHMENTS,
VISIBLE_ATTACHMENT_CHIPS,
clearInlineReviewFieldError
})
sessionRuntimeRefs = {
attachedFiles,
composerFilesExpanded,
guidedFlowState
}
const promptedOperationFeedbackRunIds = new Set()
function emitOperationCompleted(payload = {}, extras = {}) {
const runId = String(payload?.run_id || payload?.runId || '').trim()
const operationStatus = String(payload?.status || '').trim()
if (!runId || promptedOperationFeedbackRunIds.has(runId) || operationStatus !== 'succeeded') {
return null
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
promptedOperationFeedbackRunIds.add(runId)
return normalizeOperationFeedbackContext({
run_id: runId,
conversation_id: String(payload?.conversation_id || payload?.conversationId || conversationId.value || '').trim(),
user_id: resolveCurrentUserId(),
selected_agent: String(payload?.selected_agent || payload?.selectedAgent || '').trim(),
source: 'user_message',
session_type: activeSessionType.value,
operation_type: String(extras.operationType || 'assistant_round').trim(),
operation_status: operationStatus,
status: operationStatus,
route_reason: String(payload?.route_reason || payload?.routeReason || '').trim(),
entry_source: props.entrySource,
trace_summary: payload?.trace_summary || payload?.traceSummary || null,
result_summary: String(result.answer || result.message || '').trim()
}, currentUser.value || {})
}
const {
confirmPendingAttachmentAssociationInternal,
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,
refreshCurrentUserFromBackend,
draftClaimId,
extractReviewAttachmentNames,
failCurrentFlowStep,
fetchExpenseClaims,
fileInputRef,
flowRunId,
insightPanelCollapsed,
isKnowledgeSession,
linkedRequest,
mergeBusinessTimeIntoExtraContext,
mergeFilePreviews,
mergeFilesWithLimit,
mergeUploadAttachmentNames,
mergeUploadOcrDocuments,
messages,
nextTick,
normalizeExpenseQueryPayload,
normalizeOcrDocuments,
persistSessionState,
props,
recognizeOcrFiles,
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
resolveComposerDisplaySubmitText,
resetFlowRun,
resolveComposerSubmitText,
reviewInlineForm,
runOrchestrator,
scrollToBottom,
sessionSwitchBusy,
shouldRequestExpenseIntentConfirmation,
shouldRequestExpenseSceneSelection,
startExpenseClaimDraftFlowStep,
startExpenseIntentConfirmationFlowPreview,
startExpenseSceneSelectionFlowPreview,
startFlowStep,
startSemanticFlowPreview,
submitting,
syncComposerFilesToDraft,
emitOperationCompleted,
emitDraftSaved: (payload) => emit('draft-saved', payload),
emitRequestUpdated: (payload) => emit('request-updated', payload),
toast
})
const canSubmit = computed(
() =>
!submitting.value
&& !sessionSwitchBusy.value
&& Boolean(
composerDraft.value.trim()
|| attachedFiles.value.length
|| composerBusinessTimeTags.value.length
)
)
const {
handleGuidedShortcut,
handleGuidedComposerSubmit,
handleGuidedSuggestedAction,
handleSceneSelectionApplicationGate,
resetGuidedFlowState
} = useTravelReimbursementGuidedFlow({
guidedFlowState,
messages,
composerDraft,
attachedFiles,
composerBusinessTimeTags,
composerBusinessTimeDraftTouched,
fileInputRef,
submitting,
reviewActionBusy,
sessionSwitchBusy,
createMessage,
nextTick,
scrollToBottom,
persistSessionState,
clearAttachedFiles,
adjustComposerTextareaHeight,
buildComposerBusinessTimeContext,
openTravelCalculator,
lockSuggestedActionMessage,
submitExistingComposer: submitComposerInternal,
currentUser,
refreshCurrentUserFromBackend,
toast
})
function openTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return openTravelCalculatorInternal()
}
function toggleTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return toggleTravelCalculatorInternal()
}
function submitTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
// 兼容旧测试的源码锚点;真实 calculateTravelReimbursement 调用在 composable 内。
// calculateTravelReimbursement({ grade: String(user.grade || '').trim() })
// 根据您输入的地点和天数,匹配到您要出差的地区为,参考可报销合计
// 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元
// 鏍规嵁鎮ㄨ緭鍏ョ殑鍦扮偣鍜屽ぉ鏁帮紝鍖归厤鍒版偍瑕佸嚭宸殑鍦板尯涓猴紝鍙傝€冨彲鎶ラ攢鍚堣
// 浣忓璐癸細${hotelRate} 脳 ${days} = ${hotelAmount} 鍏
// messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload)
return submitTravelCalculatorInternal()
}
watch(canShowTravelCalculator, (visible) => {
if (!visible && travelCalculatorOpen.value) {
closeTravelCalculator()
}
})
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const shortcuts = computed(() => {
if (isStewardSession.value) {
return []
}
const accessibleModes = filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value)
const visibleModes = props.entrySource === 'budget'
? accessibleModes.filter((mode) => mode.key === SESSION_TYPE_BUDGET)
: accessibleModes
return visibleModes.map((mode) => ({
label: mode.label,
icon: mode.icon,
action: 'switch_view',
targetSessionType: mode.key,
active: mode.key === activeSessionType.value
}))
})
watch(
() => [activeReviewPayload.value, activeReviewPanelScope.value],
([payload]) => {
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
// ? REVIEW_DRAWER_MODE_RISK
// : REVIEW_DRAWER_MODE_REVIEW
const shouldKeepFlowDrawer = reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && activeFlowSteps.value.length > 0
resetReviewDrawerFromPayload(payload)
if (shouldKeepFlowDrawer) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
}
},
{ 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(
() => [activeSessionType.value, activeFlowSteps.value.length],
([, activeCount], [, previousActiveCount] = []) => {
if (activeCount <= 0 || previousActiveCount > 0) {
return
}
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
insightPanelCollapsed.value = false
}
)
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,
guidedFlowState: guidedFlowState.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 }
)
watch(
() => props.invalidatedDraftClaimId,
(claimId) => {
clearExpenseSessionForDeletedClaim(claimId)
},
{ immediate: true }
)
watch(
() => workbenchVisible.value,
(visible) => {
if (visible) {
scrollToBottom()
} else {
maybeFinalizeDeferredClose()
}
}
)
watch(
() => props.reopenToken,
(token, previousToken) => {
if (token === previousToken) {
return
}
closeAfterBusy.value = false
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
)
watch(
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
() => {
maybeFinalizeDeferredClose()
}
)
watch(
() => messages.value.length,
() => {
if (!workbenchVisible.value) {
return
}
scrollToBottom()
}
)
onMounted(() => {
document.addEventListener('click', handleComposerDatePickerOutside)
startFlowTick()
nextTick(() => {
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
})
currentInsight.value =
currentInsight.value
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
meta: ['修改申请'],
applicationPreview
}))
persistSessionState()
}
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} 份。`)
}
nextTick(() => {
adjustComposerTextareaHeight()
})
if (props.initialPromptAutoSubmit !== false) {
submitComposer()
}
}
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleComposerDatePickerOutside)
clearStewardThinkingTimers()
stopFlowRuntime()
stopAttachmentRuntime()
})
function scrollToBottom() {
const scrollOnce = () => {
const list = messageListRef.value
if (!list) {
return false
}
list.scrollTop = list.scrollHeight
return true
}
nextTick(() => {
if (scrollOnce()) {
return
}
requestAnimationFrame(() => {
scrollOnce()
requestAnimationFrame(scrollOnce)
})
})
}
function handleAssistantModalAfterEnter() {
scrollToBottom()
requestAnimationFrame(() => {
scrollToBottom()
})
}
function resetCurrentSessionState() {
const emptyState = buildEmptySessionState(activeSessionType.value)
sessionSnapshots.value[activeSessionType.value] = emptyState
resetGuidedFlowState()
applySessionState(emptyState)
resetFlowRun({ startedAt: 0, openDrawer: false })
}
function clearExpenseSessionForDeletedClaim(claimId) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return
}
const expenseSnapshot = sessionSnapshots.value[SESSION_TYPE_EXPENSE]
const snapshotMatchesDeletedClaim = String(expenseSnapshot?.draftClaimId || '').trim() === normalizedClaimId
const currentMatchesDeletedClaim =
activeSessionType.value === SESSION_TYPE_EXPENSE
&& String(resolveActiveClaimId() || '').trim() === normalizedClaimId
if (!snapshotMatchesDeletedClaim && !currentMatchesDeletedClaim) {
return
}
clearAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
if (currentMatchesDeletedClaim) {
resetCurrentSessionState()
toast('该草稿单据已删除,相关财务助手会话已清空。')
return
}
sessionSnapshots.value[SESSION_TYPE_EXPENSE] = buildEmptySessionState(SESSION_TYPE_EXPENSE)
}
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)
}
const { submitStewardPlan, clearStewardThinkingTimers } = useStewardPlanFlow({
activeSessionType,
attachedFiles,
composerDraft,
currentUser,
fileInputRef,
messages,
createMessage,
fetchStewardPlan,
fetchStewardPlanStream,
nextTick,
persistSessionState,
replaceMessage,
scrollToBottom,
adjustComposerTextareaHeight,
executeStewardSuggestedAction: (message, action) => handleSuggestedAction(message, action),
submitting,
reviewActionBusy,
sessionSwitchBusy,
toast
})
async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
if (shortcut.targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
toast('目前暂无权限访问预算编制助手')
return
}
if (shortcut.active) {
return
}
await switchSessionType(shortcut.targetSessionType)
return
}
if (await handleGuidedShortcut(shortcut)) {
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()
}
function applySuggestedActionPrefill(action) {
const prefillText = resolveSuggestedActionPrefill(action)
if (!prefillText) {
return false
}
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
nextTick(() => {
adjustComposerTextareaHeight()
composerTextareaRef.value?.focus()
})
persistSessionState()
return true
}
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 (applySuggestedActionPrefill(action)) return
if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(message, action)) return
if (actionType === 'open_receipt_folder') {
if (!lockSuggestedActionMessage(message, action)) return
await router.push({ name: 'app-receiptFolder' })
emit('close')
return
}
if (actionType === 'continue_upload_with_unlinked_receipts') {
if (!lockSuggestedActionMessage(message, action)) return
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
await submitComposer({
rawText: String(actionPayload.raw_text || composerDraft.value || '').trim(),
files: Array.from(attachedFiles.value || []),
skipReceiptFolderUnlinkedPrompt: true
})
return
}
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
if (!lockSuggestedActionMessage(message, action)) return
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
if (recommendation) {
messages.value.push(createMessage('user', '生成行程规划'))
messages.value.push(createMessage('assistant', recommendation, [], {
meta: ['行程规划建议']
}))
nextTick(scrollToBottom)
persistSessionState()
}
return
}
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
if (!lockSuggestedActionMessage(message, action)) return
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
meta: ['暂不规划']
}))
nextTick(scrollToBottom)
persistSessionState()
return
}
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const targetSessionType = String(actionPayload.session_type || '').trim()
if (!targetSessionType) return
if (targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
toast('目前暂无权限访问预算编制助手')
return
}
const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText
await submitComposerInternal({
rawText: carryText,
userText: action.label || '确定',
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
? '小财管家正在调用申请助手生成申请单核对结果...'
: '小财管家正在调用报销助手整理报销核对结果...',
files: carryFiles,
skipScopeGuard: true,
skipStewardPlan: true,
skipUserMessage: confirmedByText,
sessionTypeOverride: targetSessionType,
stewardContinuation: {
planId: String(actionPayload.steward_plan_id || '').trim(),
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
? actionPayload.steward_remaining_tasks
: []
}
})
return
}
await switchSessionType(targetSessionType)
if (carryText) {
composerDraft.value = carryText
}
if (carryFiles.length) {
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
attachedFiles.value = fileMergeResult.files
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
if (actionPayload.auto_submit && carryText) {
await submitComposer({
rawText: carryText,
userText: action.label || '确认继续处理',
pendingText: '正在按确认内容继续处理...',
files: carryFiles,
skipScopeGuard: true
})
}
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() {
if (!reviewOverviewDrawerAvailable.value) {
return
}
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, resolveReviewRiskDetailTarget()), [], {
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
metaTone: item.level || 'low'
}))
nextTick(scrollToBottom)
}
function appendExpenseQueryRiskToConversation(record, risk) {
if (!record || !risk) return
const claimId = String(record.claimId || '').trim()
const claimNo = String(record.claimNo || '该单据').trim()
const route = claimId
? router.resolve({
name: 'app-document-detail',
params: { requestId: claimId }
})
: null
messages.value.push(createMessage(
'assistant',
buildReviewRiskConversationText(
{
title: `${claimNo} ${risk.levelLabel || '风险提示'}${risk.title || '风险提示'}`,
summary: risk.summary,
detail: risk.detail,
suggestion: '请进入单据详情核对费用明细、票据附件和附加说明;如属于合理例外,请补充业务说明后再继续流程。',
sourceLabel: risk.levelLabel,
level: risk.level
},
route?.href
? {
href: route.href,
label: `进入 ${claimNo} 详情重新填写`
}
: {}
),
[],
{
meta: [`${claimNo} 风险详情`],
metaTone: risk.level || 'medium'
}
))
nextTick(scrollToBottom)
}
function resolveReviewDetailTarget(message = null) {
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
const candidates = [
message?.draftPayload,
currentInsight.value.agent?.draftPayload,
latestReviewMessage.value?.draftPayload,
latestDraftMessage?.draftPayload,
linkedRequest.value
].filter(Boolean)
const claimTarget = candidates.find((item) => String(item?.claim_id || item?.claimId || item?.id || '').trim())
const claimId = String(claimTarget?.claim_id || claimTarget?.claimId || claimTarget?.id || draftClaimId.value || resolveActiveClaimId() || '').trim()
if (!claimId) {
return {}
}
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
const route = router.resolve({
name: 'app-document-detail',
params: { requestId: claimId }
})
return {
href: route.href,
label: claimNo ? `进入 ${claimNo} 详情重新填写` : '进入该单据详情重新填写'
}
}
function resolveReviewRiskDetailTarget() {
return resolveReviewDetailTarget()
}
function buildReviewNextStepRichCopyForMessage(message) {
const target = resolveReviewDetailTarget(message)
return buildReviewNextStepRichCopy(message?.reviewPayload, {
detailHref: target.href || ''
})
}
function buildMessageBubbleClass(message) {
if (message?.role === 'assistant' && message?.budgetReport) {
return 'message-bubble-budget-report'
}
if (message?.role === 'assistant' && message?.applicationPreview) {
return 'message-bubble-application-preview'
}
if (message?.role !== 'assistant' || !resolveReviewNextStepAction(message?.reviewPayload)) {
return ''
}
const counts = buildReviewRiskLevelCounts(message.reviewPayload)
if (counts.high > 0) {
return 'message-bubble-review-risk-high'
}
if (counts.medium > 0) {
return 'message-bubble-review-risk-medium'
}
if (counts.low > 0) {
return 'message-bubble-review-risk-low'
}
return ''
}
function openReviewNextStepConfirm(message) {
const action = resolveReviewNextStepAction(message?.reviewPayload)
if (!action) {
return
}
nextStepConfirmDialog.value = {
open: true,
message,
action
}
}
function closeReviewNextStepConfirm() {
if (reviewActionBusy.value) {
return
}
nextStepConfirmDialog.value = {
open: false,
message: null,
action: null
}
}
async function confirmReviewNextStepSubmit() {
const message = nextStepConfirmDialog.value.message
const action = nextStepConfirmDialog.value.action
if (!message || !action || reviewActionBusy.value) {
return
}
try {
await handleReviewActionInternal(message, action)
} finally {
nextStepConfirmDialog.value = {
open: false,
message: null,
action: null
}
}
}
function buildApplicationPreviewFooterText(message) {
if (!message?.applicationPreview) {
return ''
}
return buildApplicationPreviewFooterMessage(message.applicationPreview)
}
function isApplicationDraftPayload(draftPayload) {
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
}
function resolveDraftPayloadBodyField(draftPayload, label) {
const body = String(draftPayload?.body || '')
const pattern = new RegExp(`^${label}(.+)$`, 'm')
return String(body.match(pattern)?.[1] || '').trim()
}
function resolveApplicationDraftStatusLabel(draftPayload) {
const status = String(draftPayload?.status || '').trim()
if (status === 'submitted') return '审批中'
return status || '已生成'
}
function buildApplicationDraftSummaryItems(draftPayload) {
if (!isApplicationDraftPayload(draftPayload)) {
return []
}
return [
{ label: '单号', value: String(draftPayload?.claim_no || '').trim() || '待生成' },
{ label: '类型', value: String(draftPayload?.title || '').trim() || '费用申请' },
{ label: '节点', value: String(draftPayload?.approval_stage || '').trim() || '直属领导审批' },
{ label: '时间', value: resolveDraftPayloadBodyField(draftPayload, '发生时间') },
{ label: '费用', value: resolveDraftPayloadBodyField(draftPayload, '用户预估费用') }
].filter((item) => String(item.value || '').trim() && item.value !== '待补充')
}
function shouldShowDraftSavedCard(message) {
const draftPayload = message?.draftPayload || null
return Boolean(
draftPayload
&& (
String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|| String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|| String(draftPayload.title || '').trim()
|| String(draftPayload.body || '').trim()
)
)
}
function resolveReimbursementDraftClaimNo(draftPayload) {
return String(
draftPayload?.claim_no
|| draftPayload?.claimNo
|| draftPayload?.claim_id
|| draftPayload?.claimId
|| ''
).trim() || '待生成'
}
function updateMessageOperationFeedback(message, patch = {}) {
if (!message?.id) {
return
}
messages.value = messages.value.map((item) => (
item.id === message.id
? {
...item,
operationFeedback: {
...(item.operationFeedback || {}),
...patch
}
}
: item
))
}
function isOperationFeedbackVisible(message) {
const feedback = message?.operationFeedback || null
return Boolean(
feedback?.context
&& !feedback.dismissed
)
}
function dismissOperationFeedbackForMessage(message) {
updateMessageOperationFeedback(message, {
dismissed: true,
error: ''
})
persistSessionState()
}
async function submitOperationFeedbackForMessage(message, feedback = {}) {
const rating = Number(feedback.rating || 0)
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
updateMessageOperationFeedback(message, { error: '请选择 1 到 5 星评分。' })
return
}
const context = message?.operationFeedback?.context || null
if (!context) {
return
}
updateMessageOperationFeedback(message, {
submitting: true,
rating,
reason: String(feedback.reason || '').trim(),
error: ''
})
try {
await createOperationFeedback(
buildOperationFeedbackPayload(context, feedback, currentUser.value || {})
)
updateMessageOperationFeedback(message, {
submitting: false,
submitted: true,
dismissed: false,
rating,
reason: String(feedback.reason || '').trim(),
error: ''
})
persistSessionState()
} catch (error) {
updateMessageOperationFeedback(message, {
submitting: false,
error: error?.message || '评价提交失败,请稍后重试。'
})
}
}
async function openApplicationDraftDetail(message) {
const draftPayload = message?.draftPayload || {}
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
if (!claimId) {
toast('暂未获取到单据 ID稍后可在单据中心查看。')
return
}
await router.push({
name: 'app-document-detail',
params: { requestId: claimId }
})
emit('close')
}
function resolveApplicationPreviewMissingFields(message) {
if (!message?.applicationPreview) {
return []
}
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
return Array.isArray(normalizedPreview.missingFields) ? normalizedPreview.missingFields : []
}
function openApplicationSubmitConfirm(message) {
if (!message) {
return
}
if (message.applicationPreview) {
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
message.applicationPreview = normalizedPreview
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
if (!normalizedPreview.readyToSubmit) {
toast(`请先补充:${normalizedPreview.missingFields.join('、')}`)
persistSessionState()
return
}
}
applicationSubmitConfirmDialog.value = {
open: true,
message
}
}
function closeApplicationSubmitConfirm() {
if (reviewActionBusy.value) {
return
}
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
}
function resolveApplicationEditClaimId() {
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
return ''
}
const request = linkedRequest.value || {}
if (!request.applicationEditMode) {
return ''
}
return String(request.claimId || request.claim_id || '').trim()
}
function buildStewardContinuationAfterAction(message, completedLabel = '当前动作已完成') {
const continuation = message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
if (!remainingTasks.length) {
return null
}
const nextTask = remainingTasks[0]
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const targetSessionType = nextTaskType === 'expense_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION
? '继续创建申请单'
: '继续填写报销单'
const restTasks = remainingTasks.slice(1)
return createMessage(
'assistant',
[
`**${completedLabel}。**`,
'',
'我会重新检查剩余任务队列。',
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}`,
'请回复“确定”,我再继续执行。'
].join('\n'),
[],
{
assistantName: '小财管家',
meta: ['小财管家', '等待用户确认'],
suggestedActions: [
{
label: nextLabel,
description: '确认后小财管家继续调用对应助手完成下一步。',
icon: targetSessionType === SESSION_TYPE_APPLICATION
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
auto_submit: true,
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
steward_remaining_tasks: restTasks
}
}
]
}
)
}
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
return {
planId: planId || `steward-followup-${Date.now()}`,
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function buildStewardFollowupThinkingEvents() {
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
return [
{
eventId: `${eventPrefix}-review`,
title: '复盘结果',
content: '当前动作已完成,小财管家正在检查剩余任务队列。'
},
{
eventId: `${eventPrefix}-next`,
title: '选择下一步',
content: '我会继续保持一步一步推进,先说明下一步,再等你确认后执行。'
}
]
}
function waitStewardFollowupTick(intervalMs) {
return new Promise((resolve) => {
window.setTimeout(resolve, intervalMs)
})
}
async function pushStewardContinuationMessage(finalMessage) {
if (!finalMessage) {
return
}
const finalText = String(finalMessage.text || '')
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const finalActions = Array.isArray(finalMessage.suggestedActions)
? finalMessage.suggestedActions
: []
finalMessage.text = ''
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
finalMessage.suggestedActions = []
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
messages.value.push(finalMessage)
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
for (const eventData of buildStewardFollowupThinkingEvents()) {
const event = {
eventId: eventData.eventId,
stage: 'steward_followup',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(eventData.content)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
event.content = chars.slice(0, index + 1).join('')
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
event.content = eventData.content
event.status = 'completed'
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
}
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
const chars = Array.from(finalText)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
finalMessage.text = chars.slice(0, index + 1).join('')
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
finalMessage.text = finalText
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
finalMessage.suggestedActions = finalActions
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
}
function buildStewardContinuationCarryText(task, restTasks = []) {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
const missingFields = formatStewardMissingFieldList(task?.missing_fields || task?.missingFields || [], taskType)
const lines = [
taskType === 'expense_application'
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}`
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields ? `已识别信息:${fields}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
'请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
]
if (restTasks.length) {
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
restTasks.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
})
}
return lines.filter(Boolean).join('\n')
}
function resolveStewardMissingFieldItems(task) {
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) {
return task.missingFieldItems
}
const fields = task?.missingFields || task?.missing_fields || []
const taskType = String(task?.taskType || task?.task_type || '').trim()
return buildStewardFieldItems(fields, taskType)
}
async function confirmApplicationSubmit() {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
return
}
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
? normalizeApplicationPreview(message.applicationPreview)
: null
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
const applicationEditClaimId = resolveApplicationEditClaimId()
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: applicationSubmitText,
userText: '确认提交',
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
skipStewardPlan: true,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claim_id || '').trim()
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
emit('draft-saved', {
claimId,
claimNo,
status: 'submitted',
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
documentType: 'application'
})
}
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
...action,
payload: {
...(action.payload || {}),
applicationPreview,
draftPayload
}
}))
if (planningText && planningActions.length) {
messages.value.push(createMessage('assistant', planningText, [], {
meta: ['行程规划推荐'],
suggestedActions: planningActions
}))
persistSessionState()
nextTick(scrollToBottom)
}
const stewardFollowup = buildStewardContinuationAfterAction(message, '申请单已完成')
if (stewardFollowup) {
await pushStewardContinuationMessage(stewardFollowup)
}
} finally {
reviewActionBusy.value = false
}
}
function isWorkbenchBusy() {
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
}
function maybeFinalizeDeferredClose() {
if (!closeAfterBusy.value || workbenchVisible.value || isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close')
}
function requestCloseWorkbench() {
persistSessionState()
closeAfterBusy.value = isWorkbenchBusy()
workbenchVisible.value = false
}
function emitCloseAfterLeave() {
if (workbenchVisible.value) {
return
}
if (closeAfterBusy.value && isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close')
}
function openExpenseQueryRecord(record) {
const claimId = String(record?.claimId || '').trim()
if (!claimId) {
return
}
router.push({
name: 'app-document-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',
extraContext: {
draft_claim_id: claimId,
selected_claim_id: claimId,
selected_claim_no: String(record?.claimNo || '').trim()
}
})
}
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
if (isStewardSession.value && !options.skipStewardPlan && await submitStewardPlan(options)) {
return null
}
if (await handleGuidedComposerSubmit(options)) {
return null
}
return submitComposerInternal(options)
}
async function handleAssistantMarkdownClick(event, message) {
const anchor = event?.target?.closest?.('a')
if (!anchor || !message || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
return
}
const href = String(anchor.getAttribute('href') || '').trim()
if (href === APPLICATION_SUBMIT_HREF) {
event.preventDefault()
openApplicationSubmitConfirm(message)
return
}
if (href === REVIEW_NEXT_STEP_HREF) {
event.preventDefault()
openReviewNextStepConfirm(message)
return
}
if (href.startsWith(REVIEW_RISK_PANEL_HREF_PREFIX)) {
event.preventDefault()
if (reviewRiskDrawerAvailable.value) {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
} else {
toast('当前没有需要额外处理的风险信息。')
}
return
}
if (href === REVIEW_QUICK_EDIT_HREF) {
event.preventDefault()
if (reviewOverviewDrawerAvailable.value) {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
toast('已打开右侧核对信息,可以直接修改当前单据。')
}
return
}
if (href.startsWith('/app/')) {
event.preventDefault()
router.push(href)
return
}
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) {
return
}
event.preventDefault()
reviewActionBusy.value = true
try {
await confirmPendingAttachmentAssociationInternal(message)
} finally {
reviewActionBusy.value = false
}
}
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 isDraftSavedReviewMessage(message) {
if (!message?.reviewPayload) {
return false
}
return Boolean(
String(message?.draftPayload?.claim_no || message?.draftPayload?.claim_id || '').trim()
|| String(draftClaimId.value || '').trim()
|| String(resolveActiveClaimId() || '').trim()
)
}
function buildReviewPlainFollowupForMessage(message) {
return buildReviewPlainFollowupCopy(message?.reviewPayload, {
savedDraft: isDraftSavedReviewMessage(message)
})
}
function canUseInlineSaveDraft(message) {
if (!message?.reviewPayload || isDraftSavedReviewMessage(message)) {
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')
}
const messageItemUi = computed(() => ({
ASSISTANT_DISPLAY_NAME,
aiAvatar,
userAvatar,
submitting: submitting.value,
reviewActionBusy: reviewActionBusy.value,
sessionSwitchBusy: sessionSwitchBusy.value,
applicationPreviewEditor: applicationPreviewEditor.value,
buildMessageBubbleClass,
resolveStewardMissingFieldItems,
buildReviewMainMessageText,
renderMarkdown,
handleAssistantMarkdownClick,
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveApplicationPreviewMissingFields,
isApplicationPreviewEditing,
isApplicationPreviewDateEditorOpen,
openApplicationPreviewEditor: openApplicationPreviewEditorFromUi,
commitApplicationPreviewEditor,
commitApplicationPreviewDateEditor,
setApplicationPreviewDateMode,
canApplyApplicationPreviewDateSelection,
handleApplicationPreviewEditorKeydown,
buildApplicationPreviewFooterText,
isApplicationDraftPayload,
resolveApplicationDraftStatusLabel,
buildApplicationDraftSummaryItems,
shouldShowDraftSavedCard,
resolveReimbursementDraftClaimNo,
openApplicationDraftDetail,
isOperationFeedbackVisible,
dismissOperationFeedbackForMessage,
submitOperationFeedbackForMessage,
runWelcomeQuickAction: runShortcut,
handleSuggestedAction,
isSuggestedActionSelected,
buildExpenseQueryWindowLabel,
buildExpenseQueryHint,
getExpenseQueryActivePage,
getExpenseQueryTotalPages,
getExpenseQueryVisibleRecords,
handleExpenseQueryRecordClick,
appendExpenseQueryRiskToConversation,
shiftExpenseQueryPage,
setExpenseQueryPage,
buildReviewPlainFollowupForMessage,
canUseInlineSaveDraft,
handleInlineSaveDraft,
buildReviewNextStepRichCopyForMessage,
resolveReviewFooterActions,
handleReviewAction,
buildReviewPrimaryButtonLabel
}))
const insightPanelUi = computed(() => ({
showInsightPanel: showInsightPanel.value,
isKnowledgeSession: isKnowledgeSession.value,
activeReviewPayload: activeReviewPayload.value,
isReviewFlowDrawer: isReviewFlowDrawer.value,
currentInsight: currentInsight.value,
currentIntentLabel: currentIntentLabel.value,
reviewDrawerTitle: reviewDrawerTitle.value,
reviewOverviewDrawerAvailable: reviewOverviewDrawerAvailable.value,
isReviewOverviewDrawer: isReviewOverviewDrawer.value,
submitting: submitting.value,
reviewActionBusy: reviewActionBusy.value,
switchToReviewOverviewDrawer,
reviewDocumentDrawerAvailable: reviewDocumentDrawerAvailable.value,
isReviewDocumentDrawer: isReviewDocumentDrawer.value,
toggleReviewDocumentDrawer,
reviewDocumentDrawerIcon: reviewDocumentDrawerIcon.value,
reviewRiskDrawerAvailable: reviewRiskDrawerAvailable.value,
isReviewRiskDrawer: isReviewRiskDrawer.value,
toggleReviewRiskDrawer,
reviewRiskDrawerIcon: reviewRiskDrawerIcon.value,
reviewFlowDrawerAvailable: reviewFlowDrawerAvailable.value,
flowOverallStatusTone: flowOverallStatusTone.value,
toggleReviewFlowDrawer,
reviewFlowDrawerIcon: reviewFlowDrawerIcon.value,
activeSessionType: activeSessionType.value,
reviewDrawerMode: reviewDrawerMode.value,
hotKnowledgeQuestions,
deleteSessionBusy: deleteSessionBusy.value,
sessionSwitchBusy: sessionSwitchBusy.value,
askHotKnowledgeQuestion,
resolveKnowledgeRankTone,
resolveKnowledgeRankLabel,
flowOverallStatusText: flowOverallStatusText.value,
flowTotalDurationText: flowTotalDurationText.value,
flowRunId: flowRunId.value,
flowRefreshBusy: flowRefreshBusy.value,
refreshFlowRunDetail,
flowSteps: activeFlowSteps.value,
visibleFlowSteps: visibleFlowSteps.value,
resolveFlowStepStatusLabel,
formatFlowStepDuration,
resolveFlowStepDetail,
reviewIntentText: reviewIntentText.value,
reviewFactCards: reviewFactCards.value,
reviewInlineEditorKey: reviewInlineEditorKey.value,
reviewInlineErrors: reviewInlineErrors.value,
reviewInlineForm: reviewInlineForm.value,
DATE_INPUT_FORMAT,
REVIEW_SCENE_OPTIONS,
REVIEW_SCENE_OTHER_OPTION,
clearInlineReviewFieldError,
commitInlineReviewEditor,
selectInlineScene,
reviewInlinePendingFiles: reviewInlinePendingFiles.value,
openInlineReviewEditor,
reviewPanelConfidence: reviewPanelConfidence.value,
reviewCategoryOptions: reviewCategoryOptions.value,
selectReviewCategory,
reviewSelectedOtherCategory: reviewSelectedOtherCategory.value,
reviewOtherCategoryOpen: reviewOtherCategoryOpen.value,
reviewOtherCategoryOptions: reviewOtherCategoryOptions.value,
selectReviewOtherCategory,
activeReviewDocumentIndex: activeReviewDocumentIndex.value,
reviewDocumentCount: reviewDocumentCount.value,
goReviewDocument,
activeReviewDocument: activeReviewDocument.value,
activeReviewDocumentPreview: activeReviewDocumentPreview.value,
canPreviewActiveReviewDocument: canPreviewActiveReviewDocument.value,
openActiveReviewDocumentPreview,
reviewRiskSummary: reviewRiskSummary.value,
reviewRiskItems: reviewRiskItems.value,
appendReviewRiskBriefToConversation,
reviewRiskEmpty: reviewRiskEmpty.value,
reviewHasUnsavedChanges: reviewHasUnsavedChanges.value,
saveInlineReviewChanges
}))
return {
emit, messageItemUi, insightPanelUi, 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, isStewardSession, hotKnowledgeQuestions,
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, assistantHeaderTitle, assistantHeaderDescription, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
reviewDrawerTitle, reviewOverviewDrawerAvailable, 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,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, canShowTravelCalculator, deleteSessionDialogOpen, applicationSubmitConfirmDialog, applicationPreviewEditor, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
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, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, shouldShowDraftSavedCard, resolveReimbursementDraftClaimNo, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}