export const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association' export const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details' function normalizeParagraphs(content) { return String(content || '') .split(/\n{2,}|\n/) .map((item) => item.trim()) .filter(Boolean) } function stripInlineAssociationMarkdown(value = '') { return String(value || '') .replace(/\*\*/g, '') .replace(/`/g, '') .trim() } export function resolveLegacyAiAttachmentAssociationPayload(content = '') { const text = String(content || '') if (!/我已先识别票据,并(?:匹配到最可能的报销单|找到一张可能关联的报销单)/.test(text)) { return null } const claimNo = stripInlineAssociationMarkdown( text.match(/推荐关联[::]\s*([^\n]+)/u)?.[1] || '' ) if (!claimNo) { return null } return { claim_no: claimNo, document_type: 'expense' } } export function hydrateInlineAttachmentAssociationSuggestedActions(actions = [], content = '') { const safeActions = Array.isArray(actions) ? actions : [] const hasConfirmAction = safeActions.some( (action) => String(action?.action_type || '').trim() === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION ) if (hasConfirmAction) { return safeActions } const payload = resolveLegacyAiAttachmentAssociationPayload(content) if (!payload) { return safeActions } return [ { label: '确认自动关联', description: '将本次票据自动归集到推荐单据。', icon: 'mdi mdi-link-variant', action_type: AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION, payload }, ...safeActions ] } function normalizeInlineAttachmentOcrField(field = {}) { if (!field || typeof field !== 'object') { return null } const value = String(field.value ?? field.text ?? '').trim() if (!value) { return null } return { label: String(field.label || field.key || field.name || '识别字段').trim() || '识别字段', value } } function normalizeInlineAttachmentOcrDocument(document = {}, index = 0) { const fields = (Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || []) .map((field) => normalizeInlineAttachmentOcrField(field)) .filter(Boolean) .slice(0, 12) const summary = String(document?.summary || document?.text || '').replace(/\s+/g, ' ').trim() const filename = String(document?.filename || document?.name || '').trim() if (!filename && !summary && !fields.length) { return null } return { filename: filename || `附件 ${index + 1}`, summary, fields } } export function normalizeInlineAttachmentOcrDetails(details = null) { if (!details || typeof details !== 'object') { return null } const documents = (Array.isArray(details.documents) ? details.documents : details.ocrDocuments || []) .map((document, index) => normalizeInlineAttachmentOcrDocument(document, index)) .filter(Boolean) const fileNames = (Array.isArray(details.fileNames) ? details.fileNames : []) .map((name) => String(name || '').trim()) .filter(Boolean) if (!documents.length && !fileNames.length) { return null } return { fileNames, documents } } export function buildInlineAttachmentOcrDetails(collected = {}, files = []) { return normalizeInlineAttachmentOcrDetails({ fileNames: files.map((file) => file?.name || '').filter(Boolean), documents: collected?.ocrDocuments || [] }) } export function formatMessageTime(timestamp) { if (!timestamp) return '' return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } export function createWorkbenchAiMessageRuntime() { let messageSeq = 0 function nextMessageId() { messageSeq += 1 return `${Date.now()}-${messageSeq}` } function createAiAttachmentAssociationId() { messageSeq += 1 return `ai-attachment-${Date.now()}-${messageSeq}` } function createInlineMessage(role, content, options = {}) { const normalizedContent = String(content || '').trim() const suggestedActions = Array.isArray(options.suggestedActions) ? options.suggestedActions : [] return { id: options.id || nextMessageId(), role, content: normalizedContent, paragraphs: normalizeParagraphs(normalizedContent), pending: Boolean(options.pending), feedback: String(options.feedback || ''), stewardPlan: options.stewardPlan || null, suggestedActions: role === 'assistant' ? hydrateInlineAttachmentAssociationSuggestedActions(suggestedActions, normalizedContent) : suggestedActions, applicationPreview: options.applicationPreview || null, requestedSubmit: Boolean(options.requestedSubmit), submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation), draftPayload: options.draftPayload || null, attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null), linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null), attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null), text: options.text || normalizedContent, createdAt: options.createdAt || Date.now() } } function normalizeRuntimeMessage(message = {}) { return createInlineMessage(message.role || 'assistant', message.content || '', { id: message.id, pending: false, feedback: message.feedback || '', stewardPlan: message.stewardPlan || null, suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], applicationPreview: message.applicationPreview || null, requestedSubmit: Boolean(message.requestedSubmit), submitRequiresConfirmation: Boolean(message.submitRequiresConfirmation), draftPayload: message.draftPayload || null, attachmentAssociationJob: message.attachmentAssociationJob || null, linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null, attachmentOcrDetails: message.attachmentOcrDetails || null, text: message.text || message.content || '' }) } function serializeRuntimeMessage(message = {}) { return { id: message.id, role: message.role, content: message.content, text: message.text || message.content || '', feedback: message.feedback || '', stewardPlan: message.stewardPlan || null, suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], applicationPreview: message.applicationPreview || null, requestedSubmit: Boolean(message.requestedSubmit), submitRequiresConfirmation: Boolean(message.submitRequiresConfirmation), draftPayload: message.draftPayload || null, attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null), linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null), attachmentOcrDetails: message.attachmentOcrDetails || null } } return { createAiAttachmentAssociationId, createInlineMessage, normalizeRuntimeMessage, serializeRuntimeMessage } } export function normalizeInlineAttachmentAssociationJob(job = null) { if (!job || typeof job !== 'object') { return null } const jobId = String(job.jobId || job.job_id || '').trim() if (!jobId) { return null } const status = String(job.status || 'queued').trim() || 'queued' const receiptIds = (Array.isArray(job.receiptIds) ? job.receiptIds : job.receipt_ids || []) .map((item) => String(item || '').trim()) .filter(Boolean) return { jobId, status, message: String(job.message || '').trim(), receiptIds, claimId: String(job.claimId || job.claim_id || '').trim(), claimNo: String(job.claimNo || job.claim_no || '').trim(), uploadedCount: Number(job.uploadedCount ?? job.uploaded_count ?? 0) || 0, skippedCount: Number(job.skippedCount ?? job.skipped_count ?? 0) || 0, error: String(job.error || '').trim() } } export function normalizeInlineLinkedReimbursementDraftJob(job = null) { if (!job || typeof job !== 'object') { return null } const jobId = String(job.jobId || job.job_id || '').trim() if (!jobId) { return null } const draftPayload = job.draftPayload && typeof job.draftPayload === 'object' ? job.draftPayload : job.draft_payload && typeof job.draft_payload === 'object' ? job.draft_payload : null return { jobId, status: String(job.status || 'queued').trim() || 'queued', message: String(job.message || '').trim(), error: String(job.error || '').trim(), runId: String(job.runId || job.run_id || '').trim(), applicationClaimNo: String(job.applicationClaimNo || job.application_claim_no || '').trim(), draftPayload } }