import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js' import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js' import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js' import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js' import { isBudgetMonitorUser, isExecutiveUser, isPlatformAdminUser } from '../../utils/accessControl.js' import { GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR, GUIDED_ACTION_START_APPLICATION, GUIDED_ACTION_START_REIMBURSEMENT, GUIDED_ACTION_START_STATUS_QUERY } from './travelReimbursementGuidedFlowModel.js' export const SESSION_TYPE_EXPENSE = 'expense' export const SESSION_TYPE_APPLICATION = 'application' export const SESSION_TYPE_APPROVAL = 'approval' export const SESSION_TYPE_KNOWLEDGE = 'knowledge' export const SESSION_TYPE_BUDGET = 'budget' export const ASSISTANT_SESSION_TYPES = [ SESSION_TYPE_APPLICATION, SESSION_TYPE_EXPENSE, SESSION_TYPE_APPROVAL, SESSION_TYPE_KNOWLEDGE, SESSION_TYPE_BUDGET ] export const ASSISTANT_SESSION_MODE_OPTIONS = [ { key: SESSION_TYPE_APPLICATION, label: '申请助手', icon: 'mdi mdi-file-plus-outline', description: '只处理费用申请、事前审批、申请材料和申请状态' }, { key: SESSION_TYPE_EXPENSE, label: '报销助手', icon: 'mdi mdi-receipt-text-plus-outline', description: '只处理报销发起、票据识别、草稿归集和报销状态' }, { key: SESSION_TYPE_APPROVAL, label: '审核助手', icon: 'mdi mdi-clipboard-check-outline', description: '只处理待审单据、风险解释、审批动作和审核意见' }, { key: SESSION_TYPE_KNOWLEDGE, label: '财务知识助手', icon: 'mdi mdi-book-open-page-variant-outline', description: '只处理财务制度、标准规则、票据要求和政策解释' }, { key: SESSION_TYPE_BUDGET, label: '预算编制助手', icon: 'mdi mdi-calculator-variant-outline', description: '帮助你进行预算编制与预算相关问题的整理' } ] export function canUseBudgetAssistantSession(user = null) { return Boolean(isPlatformAdminUser(user) || isBudgetMonitorUser(user) || isExecutiveUser(user)) } function canUseAssistantSessionType(sessionType, user = null) { const normalized = String(sessionType || '').trim() if (normalized === SESSION_TYPE_BUDGET) { return canUseBudgetAssistantSession(user) } return true } export function filterAssistantSessionModes(sessionModes = [], user = null) { return Array.isArray(sessionModes) ? sessionModes.filter((mode) => canUseAssistantSessionType(mode?.key, user)) : [] } export function filterAssistantSessionTypes(sessionTypes = [], user = null) { return Array.isArray(sessionTypes) ? sessionTypes.filter((sessionType) => canUseAssistantSessionType(String(sessionType || '').trim(), user)) : [] } export function normalizeAssistantSessionType(sessionType, fallback = SESSION_TYPE_EXPENSE) { const normalized = String(sessionType || '').trim() if (ASSISTANT_SESSION_TYPES.includes(normalized)) { return normalized } const fallbackType = String(fallback || '').trim() return ASSISTANT_SESSION_TYPES.includes(fallbackType) ? fallbackType : SESSION_TYPE_EXPENSE } export function resolveAssistantSessionMode(sessionType) { const normalized = normalizeAssistantSessionType(sessionType) return ASSISTANT_SESSION_MODE_OPTIONS.find((item) => item.key === normalized) || ASSISTANT_SESSION_MODE_OPTIONS[1] } export const aiAvatar = '/assets/header.png' export const userAvatar = '/assets/person.png' export const SOURCE_LABELS = { workbench: '来自个人工作台', topbar: '来自发起报销', application: '来自发起申请', budget: '来自预算中心', detail: '来自智能录入', upload: '来自附件上传', requests: '来自报销列表' } export const SCENARIO_LABELS = { expense: '报销', accounts_receivable: '应收', accounts_payable: '应付', budget: '预算', knowledge: '知识', unknown: '通用' } export const INTENT_LABELS = { query: '查询', explain: '解释', compare: '对比', risk_check: '风险检查', draft: '信息核对', operate: '动作请求' } export const FLOW_STEP_FALLBACKS = { intent: { title: '意图识别', tool: 'IntentRecognizer', runningText: '正在识别业务意图...', completedText: '意图识别完成' }, extraction: { title: '信息提取', tool: 'SemanticExtractor', runningText: '正在提取时间、金额、费用类型和待补项...', completedText: '信息提取完成' }, ocr: { title: '票据/OCR识别', tool: 'OCRService', runningText: '正在识别票据附件...', completedText: '票据识别完成' }, 'expense-review-preview': { title: '报销信息核对', tool: 'user_agent.expense_review_preview', runningText: '正在整理识别结果和右侧核对信息...', completedText: '核对信息已整理' }, 'expense-claim-draft': { title: '保存报销草稿', tool: 'database.expense_claims.save_or_submit', runningText: '正在把已确认信息保存为草稿...', completedText: '草稿已保存' }, 'attachment-association': { title: '票据关联草稿', tool: 'database.expense_claims.save_or_submit', runningText: '正在把本次票据关联到已保存草稿...', completedText: '票据已归集到草稿' }, 'expense-scene-selection': { title: '报销场景确认', tool: 'UserConfirmation', runningText: '等待用户选择报销场景...', completedText: '已进入场景选择,等待用户确认' }, 'expense-intent-confirmation': { title: '报销意图确认', tool: 'UserConfirmation', runningText: '等待用户确认是否发起报销...', completedText: '用户已确认报销意图' } } export const ASSISTANT_DISPLAY_NAME = '财务助手' export const EXPENSE_WELCOME_QUICK_ACTIONS = [ { label: '快速发起报销', action: GUIDED_ACTION_START_REIMBURSEMENT, icon: 'mdi mdi-receipt-text-plus-outline' }, { label: '查询单据状态', action: GUIDED_ACTION_START_STATUS_QUERY, icon: 'mdi mdi-file-search-outline' }, { label: '差旅计算器', action: GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR, icon: 'mdi mdi-calculator-variant-outline' } ] export const APPLICATION_WELCOME_QUICK_ACTIONS = [ { label: '快速发起申请', action: GUIDED_ACTION_START_APPLICATION, icon: 'mdi mdi-file-plus-outline' }, { label: '查询申请状态', prompt: '帮我查询我的费用申请单状态,筛选最近的 5 条记录。', icon: 'mdi mdi-file-search-outline' }, { label: '申请材料清单', prompt: '请告诉我发起费用申请通常需要准备哪些关键信息和附件。', icon: 'mdi mdi-clipboard-text-search-outline' } ] export const APPROVAL_WELCOME_QUICK_ACTIONS = [ { label: '待我审核', prompt: '帮我查询当前待我审核的单据,筛选最近的 5 条记录。', icon: 'mdi mdi-clipboard-list-outline' }, { label: '审核风险说明', prompt: '帮我梳理待审核单据中需要重点关注的风险,并按高、中、低风险分类说明。', icon: 'mdi mdi-alert-circle-outline' }, { label: '生成审核意见', prompt: '请根据当前待审核单据的风险点,帮我生成一段专业、克制的审核意见草稿。', icon: 'mdi mdi-text-box-edit-outline' } ] export const BUDGET_WELCOME_QUICK_ACTIONS = [ { label: '预算编制查询', prompt: '帮我查询当前部门本季度预算编制情况,重点看差旅、通信、招待费和办公用品。', icon: 'mdi mdi-calculator-variant-outline' }, { label: '阈值风险检查', prompt: '帮我检查当前预算的提醒阈值、告警阈值和风险阈值设置是否合理,并指出需要关注的费用类型。', icon: 'mdi mdi-alert-decagram-outline' }, { label: '预算调整建议', prompt: '请根据已发生、已占用和剩余预算,帮我整理下一轮预算调整建议。', icon: 'mdi mdi-chart-box-plus-outline' } ] export const HOT_KNOWLEDGE_QUESTIONS = [ '差旅住宿标准按什么规则执行?', '酒店超标后如何申请例外报销?', '招待费报销需要哪些凭证?', '发票抬头不一致还能报销吗?', '电子发票验真失败怎么处理?', '借款多久内需要冲销?', '预算不足还能先提交报销吗?', '会议费和招待费如何区分?', '跨部门项目费用应该怎么归集?', '员工退票手续费是否可以报销?' ] export const FLOW_MISSING_SLOT_LABELS = { expense_type: '报销类型', customer_name: '客户名称', time_range: '发生时间', location: '地点', merchant_name: '酒店/商户', amount: '金额', reason: '事由说明', participants: '参与人员', attachments: '票据附件' } let messageSeed = 0 export function nowTime() { return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } export function createMessage(role, text, attachments = [], extras = {}) { messageSeed += 1 return { id: `msg-${messageSeed}`, role, text, attachments, time: nowTime(), meta: [], citations: [], suggestedActions: [], suggestedActionsLocked: false, selectedSuggestedActionKey: '', selectedSuggestedActionLabel: '', querySelectionLocked: false, selectedQueryRecordId: '', queryPayload: null, draftPayload: null, reviewPayload: null, reviewPanelScope: '', riskFlags: [], pendingAttachmentAssociation: null, applicationPreview: null, budgetReport: null, ...extras } } export function buildExpenseIntentConfirmationMessage(rawText) { const text = String(rawText || '').trim() return [ text ? `我看到了「${text}」这类业务事项描述。` : '我看到了这类业务事项描述。', '但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。', '如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。' ].join('\n') } export function buildExpenseSceneSelectionMessage(rawText) { const text = String(rawText || '').trim() const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text) const prefix = hasBusinessTime ? '我已看到你提供了业务发生时间和报销意图。' : '我已识别到这是报销申请。' return [ `${prefix}但现在还不能确定具体报销场景,所以我先暂停信息抽取。`, '请先选择本次要发起的报销场景,选择后我再按对应规则继续识别并整理核对信息。' ].join('\n') } export function formatMessageTime(value) { if (!value) { return nowTime() } const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) { return nowTime() } return parsed.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } export function formatSemanticEntityValue(entity) { const normalizedValue = String(entity?.normalized_value || '').trim() const rawValue = String(entity?.value || '').trim() const entityType = String(entity?.type || '').trim() if (entityType === 'amount') { const numericValue = Number(normalizedValue || rawValue) if (Number.isFinite(numericValue) && numericValue > 0) { return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` } } return rawValue || normalizedValue } export function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) { if (!semanticParse || typeof semanticParse !== 'object') { return FLOW_STEP_FALLBACKS.extraction.completedText } const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : [] const entityMap = new Map() for (const item of entities) { const entityType = String(item?.type || '').trim() if (!entityType || entityMap.has(entityType)) continue entityMap.set(entityType, item) } const extractedParts = [] const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object' ? semanticParse.time_range_json : {} const startDate = String(timeRange.start_date || '').trim() const endDate = String(timeRange.end_date || '').trim() if (startDate) { extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`) } const amountEntity = entityMap.get('amount') if (amountEntity) { const amountValue = formatSemanticEntityValue(amountEntity) if (amountValue) { extractedParts.push(`金额 ${amountValue}`) } } const expenseTypeEntity = entityMap.get('expense_type') if (expenseTypeEntity) { const expenseTypeLabel = resolveExpenseTypeLabel( String(expenseTypeEntity?.normalized_value || '').trim(), String(expenseTypeEntity?.value || '').trim() ) if (expenseTypeLabel) { extractedParts.push(`费用类型 ${expenseTypeLabel}`) } } const customerEntity = entityMap.get('customer') if (customerEntity) { const customerValue = formatSemanticEntityValue(customerEntity) if (customerValue) { extractedParts.push(`客户 ${customerValue}`) } } const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : [] const missingLabels = missingSlots .map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()) .filter(Boolean) if (extractedParts.length && missingLabels.length) { return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}` } if (extractedParts.length) { return `已提取${extractedParts.join('、')}` } if (missingLabels.length) { return `已完成信息提取;待补充 ${missingLabels.join('、')}` } return FLOW_STEP_FALLBACKS.extraction.completedText } export function sanitizeRequest(request) { if (!request || typeof request !== 'object') return null const normalized = { claimId: String(request.claimId || request.claim_id || '').trim(), id: String(request.id || '').trim(), typeLabel: String(request.typeLabel || request.category || '').trim(), reason: String(request.reason || request.title || '').trim(), entity: String(request.entity || '').trim(), city: String(request.city || request.location || '').trim(), period: String(request.period || '').trim(), applyTime: String(request.applyTime || request.occurredAt || '').trim(), amount: String(request.amount || '').trim(), node: String(request.node || '').trim(), approval: String(request.approval || '').trim(), travel: String(request.travel || '').trim() } return Object.values(normalized).some(Boolean) ? normalized : null } export function resolveStatusLabel(status) { if (status === 'succeeded') return '已完成' if (status === 'blocked') return '已阻断' return '失败' } export function resolveStatusTone(status) { if (status === 'succeeded') return 'success' if (status === 'blocked') return 'warning' return 'note' } export function buildMessageMeta(payload, fileNames = []) { const items = [] if (payload?.selected_agent) { items.push(`Agent: ${payload.selected_agent}`) } if (payload?.permission_level) { items.push(`权限: ${payload.permission_level}`) } if (payload?.trace_summary?.tool_count) { items.push(`工具: ${payload.trace_summary.tool_count}`) } if (payload?.trace_summary?.degraded) { items.push('已降级') } if (payload?.requires_confirmation) { items.push('待确认') } if (payload?.run_id) { items.push(`Run: ${payload.run_id}`) } if (fileNames.length) { items.push(`附件: ${fileNames.length}`) } return items } export function buildStoredMessageMeta(messageJson, attachmentNames = []) { const payload = messageJson?.orchestrator_payload if (payload) { return buildMessageMeta(payload, attachmentNames) } const items = [] if (messageJson?.status) { items.push(`状态: ${messageJson.status}`) } if (attachmentNames.length) { items.push(`附件: ${attachmentNames.length}`) } return items } export function buildWelcomeUserContext(user = {}) { const username = String(user.username || '').trim() const name = String(user.name || username || '同事').trim() const grade = String(user.grade || '').trim() const position = String(user.position || '').trim() const role = String(user.role || '').trim() const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : [] const isAdmin = Boolean(user.isAdmin) || username.toLowerCase() === 'admin' || roleCodes.some((item) => /admin|manager/i.test(String(item || ''))) || /管理员|系统管理/.test(position) || /管理员|系统管理/.test(role) const now = new Date() const dateLine = now.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }) let honorific = name if (isAdmin) { honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员' } else { const prefix = [grade, position].filter(Boolean).join(' ') honorific = prefix ? `${prefix} ${name}`.trim() : name } return { name, username, grade, position, role, isAdmin, honorific, dateLine } } export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) { const normalizedSessionType = normalizeAssistantSessionType(sessionType) if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) { return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({ label: question.length > 20 ? `${question.slice(0, 20)}…` : question, prompt: question, icon: 'mdi mdi-comment-question-outline' })) } if (normalizedSessionType === SESSION_TYPE_APPLICATION) { return APPLICATION_WELCOME_QUICK_ACTIONS } if (normalizedSessionType === SESSION_TYPE_APPROVAL) { return APPROVAL_WELCOME_QUICK_ACTIONS } if (normalizedSessionType === SESSION_TYPE_BUDGET) { return BUDGET_WELCOME_QUICK_ACTIONS } return EXPENSE_WELCOME_QUICK_ACTIONS } export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { const normalizedSessionType = normalizeAssistantSessionType(sessionType) const ctx = buildWelcomeUserContext(user || {}) const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}` if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) { return [ `${greeting}!今日是 **${ctx.dateLine}**。`, '', '**欢迎来到个人财务中心 · 财务知识助手。** 我可以帮您查制度、报销标准、票据要求和常见财务问题,并保持知识问答对话独立记录。', '', '业务范围:财务制度、标准规则、票据要求和政策口径解释。发起申请、报销处理或审核动作请切换到对应助手。', '', '您可以直接输入问题,或点击下方「猜你想问」快速开始。' ].join('\n') } if (normalizedSessionType === SESSION_TYPE_APPLICATION) { return [ `${greeting}!今日是 **${ctx.dateLine}**。`, '', '**欢迎来到个人财务中心 · 申请助手。** 我会先判断您要处理的是费用申请、报销申请还是其他财务事项,再按对应流程引导补充信息。', '', '业务范围:费用申请、事前审批、申请材料清单和申请单状态。报销票据、审核处理和制度问答请切换到对应助手。', '', '您可以直接描述申请事项,或点击下方快捷操作开始发起申请。' ].join('\n') } if (normalizedSessionType === SESSION_TYPE_APPROVAL) { return [ `${greeting}!今日是 **${ctx.dateLine}**。`, '', '**欢迎来到个人财务中心 · 审核助手。** 我可以帮您查询待审单据、解释风险点、整理审核意见,并保持审核对话独立记录。', '', '业务范围:待审单据查询、审批动作、风险解释和审核意见草稿。申请、报销和制度问答请切换到对应助手。', '', '您可以直接输入要审核或查询的内容,或点击下方快捷操作快速开始。' ].join('\n') } if (normalizedSessionType === SESSION_TYPE_BUDGET) { return [ `${greeting}!今日是 **${ctx.dateLine}**。`, '', '**欢迎来到个人财务中心 · 预算编制助手。** 我可以帮您查询预算编制情况、整理费用类型预算、检查提醒/告警/风险阈值,并保持预算对话独立记录。', '', '业务范围:预算编制查询、部门预算检查、费用类型额度梳理、预算占用说明和阈值风险分析。报销发起、审核动作和制度问答请切换到对应助手。', '', '您可以直接输入预算问题,或点击下方快捷操作快速开始。' ].join('\n') } if (entrySource === 'detail' && linkedRequest?.id) { return [ `${greeting}!今日是 **${ctx.dateLine}**。`, '', `我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`, '', '如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。' ].join('\n') } return [ `${greeting}!今日是 **${ctx.dateLine}**。`, '', '**欢迎来到个人财务中心 · 报销助手。** 我可以陪您完成报销发起、票据识别、草稿归集、报销信息核对、待补项提醒和风险说明,并保持报销对话独立记录。', '', '业务范围:发起报销、票据识别、草稿归集、报销状态查询和报销信息核对。申请、审核和制度问答请切换到对应助手。', '', '您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。' ].join('\n') } export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { const normalizedSessionType = normalizeAssistantSessionType(sessionType) const ctx = buildWelcomeUserContext(user || {}) if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) { return { intent: 'welcome', metricLabel: '今日', metricValue: ctx.dateLine.split(' ')[0] || '—', title: '财务知识问答', summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`, agent: null } } if (normalizedSessionType === SESSION_TYPE_APPLICATION) { return { intent: 'welcome', metricLabel: '当前助手', metricValue: '申请助手', title: '申请助手', summary: `${ctx.honorific},这里会单独保存费用申请相关对话,不会混入报销、审核或知识问答记录。`, agent: null } } if (normalizedSessionType === SESSION_TYPE_APPROVAL) { return { intent: 'welcome', metricLabel: '当前助手', metricValue: '审核助手', title: '审核助手', summary: `${ctx.honorific},这里会单独保存审核相关对话,适合查询待审单据、风险点和审核意见。`, agent: null } } if (normalizedSessionType === SESSION_TYPE_BUDGET) { return { intent: 'welcome', metricLabel: '当前助手', metricValue: '预算编制助手', title: '预算编制助手', summary: `${ctx.honorific},这里会单独保存预算相关对话,适合查询预算编制、预算占用和阈值风险。`, agent: null } } return { intent: 'welcome', metricLabel: '当前助手', metricValue: '报销助手', title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '报销助手', summary: entrySource === 'detail' && linkedRequest?.id ? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。` : `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息与风险。`, agent: null } } export function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], { assistantName: ASSISTANT_DISPLAY_NAME, isWelcome: true, welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) }) } export function resolveInitialSessionType(conversation, fallback = SESSION_TYPE_EXPENSE) { const stateJson = conversation?.state_json || conversation?.stateJson || {} const sessionType = String(stateJson?.session_type || '').trim() return normalizeAssistantSessionType(sessionType, fallback) } export function buildInitialInsightFromConversation(conversation) { const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] for (let index = rawMessages.length - 1; index >= 0; index -= 1) { const item = rawMessages[index] const messageJson = item?.message_json || item?.messageJson || {} const orchestratorPayload = messageJson?.orchestrator_payload || null if (!orchestratorPayload) continue const attachmentNames = Array.isArray(messageJson?.attachment_names) ? messageJson.attachment_names.filter(Boolean) : [] return buildAgentInsight( orchestratorPayload, attachmentNames, buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload) ) } return null } export function resolveInitialConversationId(conversation) { return String(conversation?.conversation_id || conversation?.conversationId || '').trim() } export function resolveInitialDraftClaimId(conversation) { return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim() } export function resolveKnowledgeRankLabel(index) { return String(index + 1) } export function resolveKnowledgeRankTone(index) { if (index === 0) return 'gold' if (index === 1) return 'silver' if (index === 2) return 'bronze' return 'default' } export function parseConversationMessageSequence(message) { const messageJson = message?.message_json || message?.messageJson || {} const sequence = Number.parseInt(messageJson?.sequence, 10) return Number.isFinite(sequence) && sequence > 0 ? sequence : null } export function parseConversationMessageTime(message) { const rawValue = message?.created_at || message?.createdAt || '' const timestamp = new Date(rawValue).getTime() return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER } export function resolveConversationMessageRolePriority(message) { return String(message?.role || '').trim() === 'user' ? 0 : 1 } export function sortConversationMessages(messages) { return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => { const leftSequence = parseConversationMessageSequence(left) const rightSequence = parseConversationMessageSequence(right) if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) { return leftSequence - rightSequence } const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right) if (timeDiff !== 0) { return timeDiff } const leftRunId = String(left?.run_id || left?.runId || '').trim() const rightRunId = String(right?.run_id || right?.runId || '').trim() if (leftRunId && rightRunId && leftRunId === rightRunId) { const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right) if (roleDiff !== 0) { return roleDiff } } return String(left?.id || '').localeCompare(String(right?.id || '')) }) } export function normalizeInitialConversationMessages(conversation) { const rawMessages = sortConversationMessages(conversation?.messages) const restoredMessages = rawMessages.map((item) => { const messageJson = item?.message_json || item?.messageJson || {} const attachmentNames = Array.isArray(messageJson?.attachment_names) ? messageJson.attachment_names.filter(Boolean) : [] const orchestratorPayload = messageJson?.orchestrator_payload || null const result = orchestratorPayload?.result || {} return createMessage(item.role, item.content, attachmentNames, { id: `restored-${item.id || ++messageSeed}`, time: formatMessageTime(item.created_at || item.createdAt), meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [], citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [], suggestedActions: item.role === 'assistant' && Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null, draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null, reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null, riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] }) }) return markResolvedSuggestedActionMessages(restoredMessages) } export function normalizeSnapshotMessage(message) { const extras = message && typeof message === 'object' ? { ...message } : {} const role = String(extras.role || 'assistant').trim() || 'assistant' const text = String(extras.text || '') const attachments = Array.isArray(extras.attachments) ? extras.attachments.filter(Boolean) : [] delete extras.role delete extras.text delete extras.attachments return createMessage(role, text, attachments, extras) } export function normalizeSnapshotMessages(messages) { return Array.isArray(messages) ? markResolvedSuggestedActionMessages(messages.map(normalizeSnapshotMessage)) : [] } export function serializeSessionMessages(messages) { return (Array.isArray(messages) ? messages : []).map((message) => ({ id: message.id, role: message.role, text: message.text, attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [], time: message.time, meta: Array.isArray(message.meta) ? message.meta.filter(Boolean) : [], metaTone: message.metaTone || '', citations: Array.isArray(message.citations) ? message.citations : [], suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], suggestedActionsLocked: Boolean(message.suggestedActionsLocked), selectedSuggestedActionKey: String(message.selectedSuggestedActionKey || ''), selectedSuggestedActionLabel: String(message.selectedSuggestedActionLabel || ''), querySelectionLocked: Boolean(message.querySelectionLocked), selectedQueryRecordId: String(message.selectedQueryRecordId || ''), queryPayload: message.queryPayload || null, draftPayload: message.draftPayload || null, reviewPayload: message.reviewPayload || null, riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [], pendingAttachmentAssociation: message.pendingAttachmentAssociation || null, applicationPreview: message.applicationPreview || null, budgetReport: message.budgetReport || null, assistantName: message.assistantName || '', isWelcome: Boolean(message.isWelcome), welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : [] })) } export function hasMeaningfulSessionMessages(messages) { return (Array.isArray(messages) ? messages : []).some((message) => { if (!message || message.isWelcome) { return false } if (message.role === 'user') { return true } return Boolean( String(message.text || '').trim() || (Array.isArray(message.suggestedActions) && message.suggestedActions.length) || message.reviewPayload || message.queryPayload || message.draftPayload || message.applicationPreview || message.budgetReport || message.pendingAttachmentAssociation || (Array.isArray(message.riskFlags) && message.riskFlags.length) ) }) } export function hasActiveSuggestedActionMessage(messages) { return (Array.isArray(messages) ? messages : []).some( (message) => message?.role === 'assistant' && Array.isArray(message.suggestedActions) && message.suggestedActions.length > 0 && !message.suggestedActionsLocked ) } export function resolveConversationUpdatedAt(conversation) { const timestamp = new Date(conversation?.updated_at || conversation?.updatedAt || 0).getTime() return Number.isFinite(timestamp) ? timestamp : 0 } export function shouldPreferPersistedSessionState(persistedState, snapshot, conversation) { if (!persistedState) { return false } if (!conversation) { return true } if (hasActiveSuggestedActionMessage(persistedState.messages)) { return true } const snapshotUpdatedAt = Number(snapshot?.updatedAt || 0) return snapshotUpdatedAt >= resolveConversationUpdatedAt(conversation) } export function markResolvedSuggestedActionMessages(messages) { const items = Array.isArray(messages) ? messages : [] const selectedLabels = new Set() for (const message of items) { if (message?.role !== 'user') { continue } const text = String(message.text || '').trim() const selectedMatch = text.match(/^选择(.+)$/) || text.match(/用户选择报销场景[::]\s*([^\n\r]+)/) if (selectedMatch?.[1]) { selectedLabels.add(selectedMatch[1].trim()) } else if (text === '我要报销') { selectedLabels.add(text) } } if (!selectedLabels.size) { return items } return items.map((message) => { if ( message?.role !== 'assistant' || message.suggestedActionsLocked || !Array.isArray(message.suggestedActions) || !message.suggestedActions.length ) { return message } const selectedAction = message.suggestedActions.find((action) => selectedLabels.has(String(action?.label || action?.payload?.expense_type_label || '').trim()) ) if (!selectedAction) { return message } return { ...message, suggestedActionsLocked: true, selectedSuggestedActionKey: buildSuggestedActionKey(selectedAction), selectedSuggestedActionLabel: String(selectedAction.label || selectedAction?.payload?.expense_type_label || '').trim() } }) }