Files
X-Financial/web/src/views/scripts/travelReimbursementConversationModel.js
caoxiaozhu b1a9c8a194 fix: 优化报销创建页面样式与洞察面板交互
修复侧边栏和审计视图样式细节,完善差旅报销洞察面板和消息
组件布局,优化报销创建页面会话管理和流程状态持久化,增强
申请预览工具函数和导航图标,补充单元测试。
2026-05-27 10:32:08 +08:00

939 lines
32 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 { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
import { isBudgetMonitorUser, isExecutiveUser } 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(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: '来自发起申请',
detail: '来自智能录入',
upload: '来自附件上传',
requests: '来自报销列表'
}
export const SCENARIO_LABELS = {
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
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 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,
...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
}
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 (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
}
}
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,
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
)
})
}
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()
}
})
}