2026-05-21 23:53:03 +08:00
|
|
|
|
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
|
|
|
|
|
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
|
|
|
|
|
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
|
|
|
|
|
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
2026-05-27 10:32:08 +08:00
|
|
|
|
import { isBudgetMonitorUser, isExecutiveUser } from '../../utils/accessControl.js'
|
2026-05-23 19:54:42 +08:00
|
|
|
|
import {
|
|
|
|
|
|
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
GUIDED_ACTION_START_APPLICATION,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
GUIDED_ACTION_START_REIMBURSEMENT,
|
|
|
|
|
|
GUIDED_ACTION_START_STATUS_QUERY
|
|
|
|
|
|
} from './travelReimbursementGuidedFlowModel.js'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
|
|
|
|
|
|
export const SESSION_TYPE_EXPENSE = 'expense'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
export const SESSION_TYPE_APPLICATION = 'application'
|
|
|
|
|
|
export const SESSION_TYPE_APPROVAL = 'approval'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
2026-05-27 10:32:08 +08:00
|
|
|
|
export const SESSION_TYPE_BUDGET = 'budget'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
export const ASSISTANT_SESSION_TYPES = [
|
|
|
|
|
|
SESSION_TYPE_APPLICATION,
|
|
|
|
|
|
SESSION_TYPE_EXPENSE,
|
|
|
|
|
|
SESSION_TYPE_APPROVAL,
|
2026-05-27 10:32:08 +08:00
|
|
|
|
SESSION_TYPE_KNOWLEDGE,
|
|
|
|
|
|
SESSION_TYPE_BUDGET
|
2026-05-25 13:35:39 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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: '只处理财务制度、标准规则、票据要求和政策解释'
|
2026-05-27 10:32:08 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: SESSION_TYPE_BUDGET,
|
|
|
|
|
|
label: '预算编制助手',
|
|
|
|
|
|
icon: 'mdi mdi-calculator-variant-outline',
|
|
|
|
|
|
description: '帮助你进行预算编制与预算相关问题的整理'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-27 10:32:08 +08:00
|
|
|
|
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))
|
|
|
|
|
|
: []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
export const aiAvatar = '/assets/header.png'
|
|
|
|
|
|
export const userAvatar = '/assets/person.png'
|
|
|
|
|
|
|
|
|
|
|
|
export const SOURCE_LABELS = {
|
|
|
|
|
|
workbench: '来自个人工作台',
|
|
|
|
|
|
topbar: '来自发起报销',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
application: '来自发起申请',
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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: '草稿已保存'
|
|
|
|
|
|
},
|
2026-05-22 23:47:28 +08:00
|
|
|
|
'attachment-association': {
|
|
|
|
|
|
title: '票据关联草稿',
|
|
|
|
|
|
tool: 'database.expense_claims.save_or_submit',
|
|
|
|
|
|
runningText: '正在把本次票据关联到已保存草稿...',
|
|
|
|
|
|
completedText: '票据已归集到草稿'
|
|
|
|
|
|
},
|
2026-05-21 23:53:03 +08:00
|
|
|
|
'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 = [
|
|
|
|
|
|
{
|
2026-05-23 19:54:42 +08:00
|
|
|
|
label: '快速发起报销',
|
|
|
|
|
|
action: GUIDED_ACTION_START_REIMBURSEMENT,
|
|
|
|
|
|
icon: 'mdi mdi-receipt-text-plus-outline'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-05-23 19:54:42 +08:00
|
|
|
|
label: '查询单据状态',
|
|
|
|
|
|
action: GUIDED_ACTION_START_STATUS_QUERY,
|
|
|
|
|
|
icon: 'mdi mdi-file-search-outline'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-05-23 19:54:42 +08:00
|
|
|
|
label: '差旅计算器',
|
|
|
|
|
|
action: GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
|
|
|
|
|
icon: 'mdi mdi-calculator-variant-outline'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
export const APPLICATION_WELCOME_QUICK_ACTIONS = [
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '快速发起申请',
|
2026-05-26 09:15:14 +08:00
|
|
|
|
action: GUIDED_ACTION_START_APPLICATION,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
reviewPanelScope: '',
|
2026-05-21 23:53:03 +08:00
|
|
|
|
riskFlags: [],
|
2026-05-22 08:58:59 +08:00
|
|
|
|
pendingAttachmentAssociation: null,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
applicationPreview: null,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
...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 = {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
claimId: String(request.claimId || request.claim_id || '').trim(),
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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) {
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
|
|
|
|
|
|
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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'
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
|
|
|
|
|
return APPLICATION_WELCOME_QUICK_ACTIONS
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
|
|
|
|
|
|
return APPROVAL_WELCOME_QUICK_ACTIONS
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
return EXPENSE_WELCOME_QUICK_ACTIONS
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const ctx = buildWelcomeUserContext(user || {})
|
|
|
|
|
|
const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}`
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
return [
|
|
|
|
|
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
|
|
|
|
|
'',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
'**欢迎来到个人财务中心 · 财务知识助手。** 我可以帮您查制度、报销标准、票据要求和常见财务问题,并保持知识问答对话独立记录。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'业务范围:财务制度、标准规则、票据要求和政策口径解释。发起申请、报销处理或审核动作请切换到对应助手。',
|
2026-05-21 23:53:03 +08:00
|
|
|
|
'',
|
|
|
|
|
|
'您可以直接输入问题,或点击下方「猜你想问」快速开始。'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'**欢迎来到个人财务中心 · 申请助手。** 我会先判断您要处理的是费用申请、报销申请还是其他财务事项,再按对应流程引导补充信息。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'业务范围:费用申请、事前审批、申请材料清单和申请单状态。报销票据、审核处理和制度问答请切换到对应助手。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'您可以直接描述申请事项,或点击下方快捷操作开始发起申请。'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'**欢迎来到个人财务中心 · 审核助手。** 我可以帮您查询待审单据、解释风险点、整理审核意见,并保持审核对话独立记录。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'业务范围:待审单据查询、审批动作、风险解释和审核意见草稿。申请、报销和制度问答请切换到对应助手。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'您可以直接输入要审核或查询的内容,或点击下方快捷操作快速开始。'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (entrySource === 'detail' && linkedRequest?.id) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
`我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
|
|
|
|
|
'',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
'**欢迎来到个人财务中心 · 报销助手。** 我可以陪您完成报销发起、票据识别、草稿归集、报销信息核对、待补项提醒和风险说明,并保持报销对话独立记录。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'业务范围:发起报销、票据识别、草稿归集、报销状态查询和报销信息核对。申请、审核和制度问答请切换到对应助手。',
|
2026-05-21 23:53:03 +08:00
|
|
|
|
'',
|
|
|
|
|
|
'您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const ctx = buildWelcomeUserContext(user || {})
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
return {
|
|
|
|
|
|
intent: 'welcome',
|
|
|
|
|
|
metricLabel: '今日',
|
|
|
|
|
|
metricValue: ctx.dateLine.split(' ')[0] || '—',
|
|
|
|
|
|
title: '财务知识问答',
|
|
|
|
|
|
summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`,
|
|
|
|
|
|
agent: null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
return {
|
|
|
|
|
|
intent: 'welcome',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
metricLabel: '当前助手',
|
|
|
|
|
|
metricValue: '报销助手',
|
|
|
|
|
|
title:
|
|
|
|
|
|
entrySource === 'detail' && linkedRequest?.id
|
|
|
|
|
|
? `已关联 ${linkedRequest.id}`
|
|
|
|
|
|
: '报销助手',
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
export function resolveInitialSessionType(conversation, fallback = SESSION_TYPE_EXPENSE) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const stateJson = conversation?.state_json || conversation?.stateJson || {}
|
|
|
|
|
|
const sessionType = String(stateJson?.session_type || '').trim()
|
2026-05-25 13:35:39 +08:00
|
|
|
|
return normalizeAssistantSessionType(sessionType, fallback)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 : [],
|
2026-05-22 08:58:59 +08:00
|
|
|
|
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
applicationPreview: message.applicationPreview || null,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|