Files
X-Financial/web/src/views/scripts/travelReimbursementAttachmentModel.js
caoxiaozhu 0cde1f8990 feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
2026-06-18 22:12:24 +08:00

675 lines
21 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 {
buildReviewSlotMap,
resolveDocumentTypeLabel,
resolveExpenseTypeCode
} from './travelReimbursementReviewModel.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
const SCENARIO_LABELS = {
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
knowledge: '知识',
unknown: '通用'
}
const INTENT_LABELS = {
query: '查询',
explain: '解释',
compare: '对比',
risk_check: '风险检查',
draft: '信息核对',
operate: '动作请求'
}
function resolveStatusLabel(status) {
if (status === 'succeeded') return '已完成'
if (status === 'blocked') return '已阻断'
return '处理中'
}
function resolveStatusTone(status) {
if (status === 'succeeded') return 'success'
if (status === 'blocked') return 'warning'
return 'note'
}
export const MAX_ATTACHMENTS = 10
export const MAX_OCR_DOCUMENTS = 10
export const VISIBLE_ATTACHMENT_CHIPS = 2
export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association'
export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = [] } = {}) {
const names = (Array.isArray(fileNames) ? fileNames : [])
.map((item) => String(item || '').trim())
.filter(Boolean)
const attachmentLine = names.length
? `本次待归集附件:${names.length} 份(${names.join('、')}`
: '本次待归集附件:待识别'
return [
'当前这笔报销信息还没有保存为草稿。',
'',
'如果继续上传票据,我需要先把当前已识别的信息保存成一张草稿单据,再识别并归集本次附件。',
'',
attachmentLine,
'',
'',
`如果 **[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})**,我会先保存这笔未保存单据,再把此次上传的附件归集到该单据。`
].join('\n').trim()
}
export function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
filename: item.filename,
summary: item.summary,
text: String(item.text || '').slice(0, 240),
avg_score: Number(item.avg_score || 0),
line_count: Number(item.line_count || 0),
document_type: String(item.document_type || 'other').trim() || 'other',
document_type_label: String(item.document_type_label || '').trim(),
scene_code: String(item.scene_code || 'other').trim() || 'other',
scene_label: String(item.scene_label || '').trim(),
preview_kind: String(item.preview_kind || '').trim(),
preview_data_url: String(item.preview_data_url || '').trim(),
preview_url: String(item.preview_url || '').trim(),
receipt_id: String(item.receipt_id || item.receiptId || '').trim(),
receipt_status: String(item.receipt_status || item.receiptStatus || '').trim(),
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
document_fields: Array.isArray(item.document_fields)
? item.document_fields
.map((field) => ({
key: String(field?.key || '').trim(),
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.key && field.label && field.value)
: [],
warnings: Array.isArray(item.warnings) ? item.warnings : []
}))
}
function defineFileReceiptId(file, receiptId) {
const normalizedReceiptId = String(receiptId || '').trim()
if (!file || !normalizedReceiptId) {
return false
}
try {
Object.defineProperty(file, 'receiptId', {
value: normalizedReceiptId,
enumerable: false,
configurable: true
})
return true
} catch {
try {
file.receiptId = normalizedReceiptId
return String(file.receiptId || '').trim() === normalizedReceiptId
} catch {
return false
}
}
}
export function attachReceiptFolderIdsToFiles(files = [], payload = null) {
const safeFiles = Array.isArray(files) ? files : []
const documents = Array.isArray(payload?.documents) ? payload.documents : []
let attachedCount = 0
safeFiles.slice(0, documents.length).forEach((file, index) => {
const document = documents[index] || {}
const receiptId = String(document.receipt_id || document.receiptId || '').trim()
if (receiptId && defineFileReceiptId(file, receiptId)) {
attachedCount += 1
}
})
return attachedCount
}
export async function collectReceiptFiles({
files = [],
recognizedAttachmentData = null,
recognizeOcrFiles,
timeoutMs = 90000,
timeoutMessage = '票据 OCR 识别超时,已继续使用附件名称处理。'
} = {}) {
const safeFiles = Array.isArray(files) ? files : []
const reusedData = recognizedAttachmentData && typeof recognizedAttachmentData === 'object'
? recognizedAttachmentData
: null
if (reusedData) {
const ocrDocuments = Array.isArray(reusedData.ocrDocuments) ? [...reusedData.ocrDocuments] : []
const ocrPayload = reusedData.ocrPayload || { documents: ocrDocuments }
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: String(reusedData.ocrSummary || '').trim() || buildOcrSummaryFromDocuments(ocrDocuments),
ocrDocuments,
ocrFilePreviews: Array.isArray(reusedData.ocrFilePreviews) ? [...reusedData.ocrFilePreviews] : []
}
}
if (typeof recognizeOcrFiles !== 'function') {
throw new Error('票据采集服务未配置。')
}
const ocrPayload = await recognizeOcrFiles(safeFiles, {
timeoutMs,
timeoutMessage
})
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: buildOcrSummary(ocrPayload),
ocrDocuments: normalizeOcrDocuments(ocrPayload),
ocrFilePreviews: buildOcrFilePreviews(ocrPayload)
}
}
export function buildOcrSummary(payload) {
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
}
export function buildOcrSummaryFromDocuments(documents) {
return (Array.isArray(documents) ? documents : [])
.slice(0, MAX_OCR_DOCUMENTS)
.map((item) => {
const filename = String(item?.filename || '').trim()
const summary = String(item?.summary || item?.text || '').trim()
if (filename && summary) {
return `${filename}${summary}`
}
return filename || summary
})
.filter(Boolean)
.join('')
}
function resolveAssociationDocumentTypeLabel(document) {
const explicitLabel = String(document?.document_type_label || '').trim()
if (explicitLabel) {
return explicitLabel
}
const sceneLabel = String(document?.scene_label || '').trim()
if (sceneLabel) {
return sceneLabel
}
const typeLabel = resolveDocumentTypeLabel(document?.document_type)
return String(typeLabel || '').trim() || '其他票据'
}
function buildAssociationDocumentContentLines(document) {
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
const fieldLines = fields
.map((field) => {
const label = String(field?.label || '').trim()
const value = String(field?.value || '').trim()
return label && value ? `- ${label}${value}` : ''
})
.filter(Boolean)
if (fieldLines.length) {
return fieldLines.slice(0, 8)
}
const summary = String(document?.summary || document?.text || '').trim()
if (summary) {
return [`- 识别内容:${summary}`]
}
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
}
function buildAssociationDocumentCard(lines) {
return (Array.isArray(lines) ? lines : [])
.map((line) => String(line || '').trim() ? `> ${line}` : '>')
.join('\n')
}
export function buildAttachmentAssociationConfirmationMessage({
claimNo = '',
claimTitle = '',
fileNames = [],
ocrDocuments = []
} = {}) {
const documents = Array.isArray(ocrDocuments) && ocrDocuments.length
? ocrDocuments
: (Array.isArray(fileNames) ? fileNames : [])
.map((filename) => ({ filename }))
.filter((item) => String(item.filename || '').trim())
const targetLines = [
claimNo ? `- 草稿单号:${claimNo}` : '',
claimTitle ? `- 单据说明:${claimTitle}` : '',
`- 本次待归集附件:${documents.length || fileNames.length || 0}`
].filter(Boolean)
const documentBlocks = documents.map((document, index) => {
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
const typeLabel = resolveAssociationDocumentTypeLabel(document)
const contentLines = buildAssociationDocumentContentLines(document)
.map((line) => String(line || '').replace(/^-\s*/, ''))
return buildAssociationDocumentCard([
`**附件 ${index + 1}${filename}**`,
'',
`附件类型:${typeLabel}`,
'',
...contentLines
])
})
return [
'已识别附件信息:',
'',
documentBlocks.join('\n\n'),
'',
'',
'请问是否确定将票据信息归集到单据:',
'',
targetLines.join('\n'),
'',
'',
`如果 **[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})** 该信息,我将直接将票据进行归集。`
]
.join('\n')
.replace(/\n{4,}/g, '\n\n\n')
.trim()
}
export function normalizeReviewDocumentFieldKey(label) {
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
if (!compact) return ''
if (
['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) =>
compact.includes(token.toLowerCase())
)
) {
return 'amount'
}
if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) {
return 'date'
}
if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) {
return 'merchant_name'
}
if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) {
return 'invoice_number'
}
if (compact.includes('发票代码')) {
return 'invoice_code'
}
if (compact.includes('车次') || compact.includes('航班')) {
return 'trip_no'
}
if (compact.includes('行程') || compact.includes('路线')) {
return 'route'
}
return compact
}
export function buildOcrDocumentsFromReviewPayload(reviewPayload) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => {
const fields = Array.isArray(item?.fields)
? item.fields
.map((field) => {
const label = String(field?.label || '').trim()
const value = String(field?.value || '').trim()
if (!label || !value) {
return null
}
return {
key: normalizeReviewDocumentFieldKey(label),
label,
value
}
})
.filter(Boolean)
: []
return {
filename: String(item?.filename || '').trim(),
summary: String(item?.summary || '').trim(),
text: [
String(item?.scene_label || '').trim(),
String(item?.summary || '').trim(),
...fields.map((field) => `${field.label}${field.value}`)
]
.filter(Boolean)
.join(' ')
.slice(0, 240),
avg_score: Number(item?.avg_score || 0),
document_type: String(item?.document_type || 'other').trim() || 'other',
document_type_label: resolveDocumentTypeLabel(item?.document_type),
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
scene_label: String(item?.scene_label || '').trim(),
preview_kind: String(item?.preview_kind || '').trim(),
preview_data_url: String(item?.preview_data_url || '').trim(),
preview_url: String(item?.preview_url || '').trim(),
document_fields: fields,
warnings: Array.isArray(item?.warnings) ? item.warnings : []
}
}).filter((item) => item.filename)
}
export function mergeUploadAttachmentNames(existingNames, incomingNames) {
const merged = []
const seen = new Set()
for (const value of [...(existingNames || []), ...(incomingNames || [])]) {
const normalized = String(value || '').trim()
if (!normalized || seen.has(normalized)) continue
seen.add(normalized)
merged.push(normalized)
if (merged.length >= MAX_ATTACHMENTS) {
break
}
}
return merged
}
export function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) {
const merged = []
const seen = new Set()
for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) {
const filename = String(item?.filename || '').trim()
if (!filename || seen.has(filename)) continue
seen.add(filename)
merged.push(item)
if (merged.length >= MAX_OCR_DOCUMENTS) {
break
}
}
return merged
}
export function inferPreviewKind(file) {
const mediaType = String(file?.type || '').toLowerCase()
const filename = String(file?.name || '').toLowerCase()
if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) {
return 'image'
}
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
return 'pdf'
}
return 'file'
}
export function buildFilePreviews(files, previewRegistry) {
return files.map((file) => {
const kind = inferPreviewKind(file)
if (!['image', 'pdf'].includes(kind)) {
return {
filename: file.name,
kind
}
}
const url = URL.createObjectURL(file)
previewRegistry.push(url)
return {
filename: file.name,
kind,
url
}
})
}
export function resolveDocumentPreview(filePreviews, filename) {
if (!Array.isArray(filePreviews)) return null
const matches = filePreviews.filter((item) => item.filename === filename)
if (!matches.length) {
return null
}
return (
matches.find((item) => item.kind === 'image' && item.url) ||
matches.find((item) => item.url) ||
matches[0]
)
}
export function isTemporaryPreviewUrl(url) {
return String(url || '').trim().toLowerCase().startsWith('blob:')
}
export function buildFileIdentity(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
}
export function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) {
const nextFiles = []
const seen = new Set()
for (const file of Array.isArray(existingFiles) ? existingFiles : []) {
const key = buildFileIdentity(file)
if (seen.has(key)) continue
seen.add(key)
nextFiles.push(file)
}
let duplicateCount = 0
let overflowCount = 0
for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) {
const key = buildFileIdentity(file)
if (seen.has(key)) {
duplicateCount += 1
continue
}
if (nextFiles.length >= limit) {
overflowCount += 1
continue
}
seen.add(key)
nextFiles.push(file)
}
return {
files: nextFiles,
duplicateCount,
overflowCount
}
}
export function mergeFilePreviews(existingPreviews, incomingPreviews) {
const result = []
const indexByKey = new Map()
for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) {
const key = [preview?.filename, preview?.kind].join('__')
if (!preview?.filename) continue
const existingIndex = indexByKey.get(key)
if (existingIndex === undefined) {
indexByKey.set(key, result.length)
result.push(preview)
continue
}
const existingPreview = result[existingIndex]
const nextUrl = String(preview?.url || '').trim()
const existingUrl = String(existingPreview?.url || '').trim()
if (nextUrl && (!existingUrl || isTemporaryPreviewUrl(existingUrl) || nextUrl !== existingUrl)) {
result[existingIndex] = preview
}
}
return result
}
export function filterPersistableFilePreviews(filePreviews) {
return (Array.isArray(filePreviews) ? filePreviews : [])
.filter((preview) => {
const filename = String(preview?.filename || '').trim()
const url = String(preview?.url || '').trim()
return filename && !isTemporaryPreviewUrl(url)
})
}
function inferPreviewKindFromUrl(url) {
const normalized = String(url || '').trim().toLowerCase()
if (!normalized) return ''
if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) {
return 'image'
}
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
return 'pdf'
}
return ''
}
function resolveDocumentPreviewKind(item) {
const explicit = String(item?.preview_kind || '').trim()
if (explicit) {
return explicit
}
return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim())
}
export function buildOcrFilePreviews(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents
.map((item) => ({
filename: String(item?.filename || '').trim(),
kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim()
}))
.filter((item) => item.filename && item.kind === 'image' && item.url)
}
export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
return documents
.map((item) => ({
filename: String(item?.filename || '').trim(),
kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim()
}))
.filter((item) => item.filename && item.kind === 'image' && item.url)
}
export function buildReviewFilePreviewsFromMessages(messages) {
const previews = []
for (const message of Array.isArray(messages) ? messages : []) {
previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload))
}
return mergeFilePreviews([], previews)
}
export function resolveAttachmentPreviewKind(metadata) {
const explicitKind = String(metadata?.preview_kind || '').trim()
if (explicitKind) {
return explicitKind
}
const mediaType = String(metadata?.media_type || '').trim().toLowerCase()
if (mediaType.startsWith('image/')) {
return 'image'
}
if (mediaType === 'application/pdf') {
return 'pdf'
}
return ''
}
export function extractReviewAttachmentNames(reviewPayload) {
const documentNames = Array.isArray(reviewPayload?.document_cards)
? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean)
: []
if (documentNames.length) {
return documentNames
}
const slotMap = buildReviewSlotMap(reviewPayload)
const attachmentValue = String(slotMap.attachments?.value || '').trim()
if (!attachmentValue) {
return []
}
return attachmentValue.split(/[、,]/).map((item) => item.trim()).filter(Boolean)
}
export function buildErrorInsight(error, fileNames = []) {
return {
intent: 'agent',
metricLabel: '运行状态',
metricValue: '失败',
title: '智能体调用失败',
summary: error?.message || '无法连接后端 Orchestrator。',
agent: {
runId: '未生成',
selectedAgent: 'orchestrator',
scenario: '未知',
intent: '未知',
permissionLevel: 'unknown',
routeReason: 'request_failed',
requiresConfirmation: false,
degraded: false,
fileNames,
citations: [],
suggestedActions: [],
queryPayload: null,
draftPayload: null,
reviewPayload: null,
riskFlags: [],
toolCount: 0,
failedToolCount: 0,
selectedCapabilityCodes: [],
filePreviews: [],
statusLabel: '失败',
statusTone: 'note'
}
}
}
export function buildAgentInsight(payload, fileNames = [], filePreviews = []) {
const trace = payload?.trace_summary || {}
const result = payload?.result || {}
const statusLabel = resolveStatusLabel(payload?.status)
return {
intent: 'agent',
metricLabel: '运行状态',
metricValue: statusLabel,
title:
result?.draft_payload?.title ||
`${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`,
summary: result?.answer || result?.message || '智能体已完成处理。',
agent: {
runId: payload?.run_id || '未生成',
selectedAgent: payload?.selected_agent || 'orchestrator',
scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知',
intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知',
permissionLevel: payload?.permission_level || 'unknown',
routeReason: payload?.route_reason || 'unknown',
requiresConfirmation: Boolean(payload?.requires_confirmation),
degraded: Boolean(trace?.degraded),
fileNames,
citations: Array.isArray(result?.citations) ? result.citations : [],
suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [],
queryPayload: normalizeExpenseQueryPayload(result?.query_payload),
draftPayload: result?.draft_payload || null,
reviewPayload: result?.review_payload || null,
riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [],
toolCount: Number(trace?.tool_count || 0),
failedToolCount: Number(trace?.failed_tool_count || 0),
selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes)
? trace.selected_capability_codes
: [],
filePreviews,
statusLabel,
statusTone: resolveStatusTone(payload?.status)
}
}
}