2026-06-22 11:58:53 +08:00
|
|
|
|
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,
|
2026-06-25 10:55:49 +08:00
|
|
|
|
requestedSubmit: Boolean(options.requestedSubmit),
|
|
|
|
|
|
submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation),
|
2026-06-22 11:58:53 +08:00
|
|
|
|
draftPayload: options.draftPayload || null,
|
2026-06-24 10:42:50 +08:00
|
|
|
|
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null),
|
|
|
|
|
|
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null),
|
2026-06-22 11:58:53 +08:00
|
|
|
|
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,
|
2026-06-25 10:55:49 +08:00
|
|
|
|
requestedSubmit: Boolean(message.requestedSubmit),
|
|
|
|
|
|
submitRequiresConfirmation: Boolean(message.submitRequiresConfirmation),
|
2026-06-22 11:58:53 +08:00
|
|
|
|
draftPayload: message.draftPayload || null,
|
2026-06-24 10:42:50 +08:00
|
|
|
|
attachmentAssociationJob: message.attachmentAssociationJob || null,
|
|
|
|
|
|
linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null,
|
2026-06-22 11:58:53 +08:00
|
|
|
|
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,
|
2026-06-25 10:55:49 +08:00
|
|
|
|
requestedSubmit: Boolean(message.requestedSubmit),
|
|
|
|
|
|
submitRequiresConfirmation: Boolean(message.submitRequiresConfirmation),
|
2026-06-22 11:58:53 +08:00
|
|
|
|
draftPayload: message.draftPayload || null,
|
2026-06-24 10:42:50 +08:00
|
|
|
|
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null),
|
|
|
|
|
|
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null),
|
2026-06-22 11:58:53 +08:00
|
|
|
|
attachmentOcrDetails: message.attachmentOcrDetails || null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
createAiAttachmentAssociationId,
|
|
|
|
|
|
createInlineMessage,
|
|
|
|
|
|
normalizeRuntimeMessage,
|
|
|
|
|
|
serializeRuntimeMessage
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-24 10:42:50 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|