Files
X-Financial/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js
2026-06-22 11:58:53 +08:00

358 lines
13 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 * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
import {
createExpenseClaimItem,
extractExpenseClaimItems,
fetchExpenseClaimDetail,
fetchExpenseClaims,
uploadExpenseClaimItemAttachment
} from '../../services/reimbursements.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import {
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
buildInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js'
import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
const completed = status === 'completed'
const failed = status === 'failed'
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
return [
{
eventId: 'attachment-ocr',
title: '识别上传票据',
content: '提取票据里的日期、地点和行程信息。',
status: eventStatus
},
{
eventId: 'claim-lookup',
title: '查询可关联报销单',
content: '查找草稿、待补充和退回状态的可归集单据。',
status: eventStatus
},
{
eventId: 'claim-match',
title: '匹配票据与报销单',
content: '根据票据时间、城市和报销事由判断最可能的关联单据。',
status: eventStatus
}
]
}
function resolveAiAttachmentAssociationClaimNo(payload = {}) {
return String(payload?.claim_no || payload?.claimNo || '').trim()
}
function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
const completed = status === 'completed'
const failed = status === 'failed'
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
return [
{
eventId: 'attachment-confirm',
title: '确认自动归集',
content: '正在读取匹配单据并准备写入附件。',
status: eventStatus
},
{
eventId: 'attachment-upload',
title: '归集票据附件',
content: '把本次上传的票据写入报销单明细。',
status: eventStatus
}
]
}
export function useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime,
conversationMessages,
createAiAttachmentAssociationId,
createInlineMessage,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
scrollInlineConversationToBottom,
sending,
streamOrSetInlineAssistantContent,
toast
}) {
async function collectAiModeReceiptContext(files = []) {
const safeFiles = Array.isArray(files) ? files : []
const attachmentNames = safeFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const ocrSourceFileNames = ocrFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const baseContext = {
attachmentNames,
attachmentCount: attachmentNames.length,
ocrSourceFileNames,
ocrSummary: '',
ocrDocuments: []
}
if (!ocrFiles.length) {
return baseContext
}
try {
const collected = await collectReceiptFiles({
files: ocrFiles,
recognizeOcrFiles
})
return {
...baseContext,
ocrSummary: String(collected.ocrSummary || '').trim(),
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
}
} catch (error) {
console.warn('AI mode OCR request failed:', error)
return {
...baseContext,
ocrError: error?.message || 'OCR识别失败已继续使用附件名称。'
}
}
}
function findAiAttachmentAssociationRuntime(options = {}) {
const normalizedAssociationId = String(options.associationId || options.association_id || '').trim()
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
if (normalizedAssociationId) {
const runtime = aiAttachmentAssociationRuntime.get(normalizedAssociationId)
if (runtime) {
return { associationId: normalizedAssociationId, runtime }
}
}
if (normalizedClaimNo) {
for (const [runtimeId, runtime] of aiAttachmentAssociationRuntime.entries()) {
if (String(runtime?.claimNo || '').trim() === normalizedClaimNo && runtime?.files?.length) {
return { associationId: runtimeId, runtime }
}
}
}
return { associationId: '', runtime: null }
}
function updateAiAttachmentAssociationActionState(message = {}, associationId = '', state = {}, options = {}) {
const normalizedAssociationId = String(associationId || '').trim()
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
if (!message || !Array.isArray(message.suggestedActions) || (!normalizedAssociationId && !normalizedClaimNo)) {
return
}
message.suggestedActions = message.suggestedActions.map((action) => {
const actionAssociationId = String(action?.payload?.association_id || action?.payload?.associationId || '').trim()
const actionClaimNo = resolveAiAttachmentAssociationClaimNo(action?.payload || {})
const isSameAssociation = normalizedAssociationId && actionAssociationId === normalizedAssociationId
const isSameClaim = normalizedClaimNo && actionClaimNo === normalizedClaimNo
if (String(action?.action_type || '').trim() !== AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION || (!isSameAssociation && !isSameClaim)) {
return action
}
return {
...action,
...state
}
})
}
function buildAiAttachmentAssociationDetailActions(runtime = {}) {
const claimNo = String(runtime.claimNo || '').trim()
const claimId = String(runtime.claimId || '').trim()
if (!claimNo && !claimId) {
return []
}
return [{
label: '查看单据',
description: '打开已归集票据的报销单。',
icon: 'mdi mdi-open-in-new',
action_type: 'open_application_detail',
payload: {
claim_id: claimId,
claim_no: claimNo,
document_type: 'expense'
}
}]
}
async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) {
const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
const runtimeResult = findAiAttachmentAssociationRuntime({
associationId: requestedAssociationId,
claimNo: payloadClaimNo
})
const associationId = runtimeResult.associationId
const runtime = runtimeResult.runtime
const actionClaimNo = payloadClaimNo || String(runtime?.claimNo || '').trim()
if (!associationId || !runtime?.files?.length) {
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
return
}
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
label: '正在归集...',
disabled: true
}, { claimNo: actionClaimNo })
persistCurrentConversation()
sending.value = true
const pendingMessage = createInlineMessage('assistant', '', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
const syncResult = await syncExpenseClaimFilesToDraft({
claimId: runtime.claimId,
files: runtime.files,
fetchExpenseClaimDetail,
createExpenseClaimItem,
uploadExpenseClaimItemAttachment
})
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
claimNo: runtime.claimNo,
fileNames: runtime.fileNames,
uploadedCount: syncResult.uploadedCount,
skippedCount: syncResult.skippedCount
})
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('completed')
},
suggestedActions: buildAiAttachmentAssociationDetailActions(runtime)
})
)
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
label: '已自动关联',
disabled: true
}, { claimNo: actionClaimNo })
aiAttachmentAssociationRuntime.delete(associationId)
persistCurrentConversation()
} catch (error) {
const finalMessageText = error?.message || '自动归集失败,请稍后重试。'
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('failed')
}
})
)
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
label: '重新自动关联',
disabled: false
}, { claimNo: actionClaimNo })
toast(finalMessageText)
persistCurrentConversation()
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
}
async function requestAiAttachmentAssociationReply(prompt, entry = {}, files = []) {
let shouldAutoScrollOnFinish = true
const pendingMessage = createInlineMessage('assistant', '', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('running')
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
const collected = await collectReceiptFiles({
files,
recognizeOcrFiles
})
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
const claims = extractExpenseClaimItems(claimsPayload)
const match = aiAttachmentAssociationModel.resolveAiAttachmentAssociationMatch(claims, collected.ocrDocuments)
const associationRecord = match.best?.record || match.recommended?.record || null
const associationId = associationRecord?.claimId
? createAiAttachmentAssociationId()
: ''
if (associationId) {
aiAttachmentAssociationRuntime.set(associationId, {
files,
fileNames: files.map((file) => file?.name || '').filter(Boolean),
claimId: String(associationRecord.claimId || '').trim(),
claimNo: String(associationRecord.claimNo || '').trim(),
ocrPayload: collected.ocrPayload,
ocrSummary: collected.ocrSummary,
ocrDocuments: collected.ocrDocuments,
ocrFilePreviews: collected.ocrFilePreviews
})
}
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({
match,
fileNames: files.map((file) => file?.name || ''),
ocrDocuments: collected.ocrDocuments
})
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed')
},
attachmentOcrDetails,
suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, {
includeOcrDetails: Boolean(attachmentOcrDetails)
})
})
)
persistCurrentConversation()
} catch (error) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('failed')
}
})
)
toast(finalMessageText)
persistCurrentConversation()
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
}
}
return {
collectAiModeReceiptContext,
confirmAiAttachmentAssociation,
requestAiAttachmentAssociationReply,
resolveAiAttachmentAssociationClaimNo
}
}