feat(web): AI 工作台文件预览/附件关联任务与草稿分支
- 新增 WorkbenchAiFilePreviewDialog 附件预览对话框及 useWorkbenchAiFilePreview,附件支持点击预览 - 新增 attachmentAssociationJobs/linkedReimbursementDraftJobs 前端服务与对应 composable,接入后台任务轮询与状态展示 - 新增 travelReimbursementDraftBranchModel 草稿分支模型,报销关联门控支持跳过/选择草稿 - PersonalWorkbenchAiMode 及各 composable(expense/document/steward/application-preview/attachment-association)重构适配,WorkbenchAiComposer/FileStrip 样式与交互完善 - DocumentsCenter/ReceiptFolder/TravelReimbursementCreate 等视图及 scripts 重构,风险/差旅规划/审批等工具适配 - 新增/更新前端测试:application-result-card、reimbursement-list-preview-fetch、guided-flow、composer-components 等
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
||||
import { fetchAttachmentAssociationJob } from '../../services/attachmentAssociationJobs.js'
|
||||
|
||||
const ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS = 1200
|
||||
const ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS = 90
|
||||
const ATTACHMENT_ASSOCIATION_JOB_PENDING_STATUSES = new Set(['queued', 'running'])
|
||||
|
||||
export function useWorkbenchAiAttachmentAssociationJobs({
|
||||
conversationMessages,
|
||||
createInlineMessage,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
streamOrSetInlineAssistantContent,
|
||||
notifyRequestUpdated,
|
||||
toast,
|
||||
buildDetailActions,
|
||||
buildThinkingEvents
|
||||
}) {
|
||||
const activeJobPolls = new Set()
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, milliseconds)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeJob(job = {}) {
|
||||
const jobId = String(job?.job_id || job?.jobId || '').trim()
|
||||
if (!jobId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
jobId,
|
||||
status: String(job?.status || 'queued').trim() || 'queued',
|
||||
message: String(job?.message || '').trim(),
|
||||
receiptIds: (Array.isArray(job?.receipt_ids) ? job.receipt_ids : job?.receiptIds || [])
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean),
|
||||
claimId: String(job?.claim_id || job?.claimId || '').trim(),
|
||||
claimNo: String(job?.claim_no || job?.claimNo || '').trim(),
|
||||
uploadedCount: Number(job?.uploaded_count ?? job?.uploadedCount ?? 0) || 0,
|
||||
skippedCount: Number(job?.skipped_count ?? job?.skippedCount ?? 0) || 0,
|
||||
error: String(job?.error || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function isPending(job = {}) {
|
||||
return ATTACHMENT_ASSOCIATION_JOB_PENDING_STATUSES.has(String(job?.status || '').trim())
|
||||
}
|
||||
|
||||
function extractReceiptIdsFromOcrDocuments(documents = []) {
|
||||
return Array.from(new Set(
|
||||
(Array.isArray(documents) ? documents : [])
|
||||
.map((document) => String(document?.receipt_id || document?.receiptId || '').trim())
|
||||
.filter(Boolean)
|
||||
))
|
||||
}
|
||||
|
||||
function buildRunningMessage(job = {}, fileNames = []) {
|
||||
const names = fileNames.filter(Boolean)
|
||||
const attachmentLabel = names.length
|
||||
? `${names.length} 份:${names.slice(0, 2).join('、')}${names.length > 2 ? ' 等' : ''}`
|
||||
: '已识别票据附件'
|
||||
const statusText = String(job?.message || '').trim() || '正在后台匹配并归集票据附件。'
|
||||
return [
|
||||
'我已收到附件关联请求,正在后台继续处理。',
|
||||
'',
|
||||
`本次附件:${attachmentLabel}`,
|
||||
`处理状态:${statusText}`,
|
||||
'',
|
||||
'您可以先离开当前会话,回来后我会继续查询任务结果。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildFailedMessage(job = {}) {
|
||||
return String(job?.message || job?.error || '').trim() || '自动归集失败,请补充说明或重新上传附件后再试。'
|
||||
}
|
||||
|
||||
function findMessage(messageId) {
|
||||
return conversationMessages.value.find((message) => message.id === messageId) || null
|
||||
}
|
||||
|
||||
function replaceJobMessage(messageId, content, options = {}) {
|
||||
const sourceMessage = findMessage(messageId)
|
||||
if (!sourceMessage) {
|
||||
return false
|
||||
}
|
||||
replaceInlineMessage(
|
||||
messageId,
|
||||
createInlineMessage('assistant', content, {
|
||||
id: messageId,
|
||||
attachmentAssociationJob: options.attachmentAssociationJob || sourceMessage?.attachmentAssociationJob || null,
|
||||
attachmentOcrDetails: options.attachmentOcrDetails || sourceMessage?.attachmentOcrDetails || null,
|
||||
pending: Boolean(options.pending),
|
||||
stewardPlan: options.stewardPlan || null,
|
||||
suggestedActions: options.suggestedActions || []
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async function updateJobMessage({
|
||||
job,
|
||||
messageId,
|
||||
fileNames = [],
|
||||
attachmentOcrDetails = null
|
||||
}) {
|
||||
const normalizedJob = normalizeJob(job)
|
||||
if (!normalizedJob) {
|
||||
return false
|
||||
}
|
||||
if (!findMessage(messageId)) {
|
||||
return true
|
||||
}
|
||||
if (normalizedJob.status === 'succeeded') {
|
||||
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
|
||||
claimNo: normalizedJob.claimNo,
|
||||
fileNames,
|
||||
uploadedCount: normalizedJob.uploadedCount,
|
||||
skippedCount: normalizedJob.skippedCount
|
||||
})
|
||||
await streamOrSetInlineAssistantContent(messageId, finalMessageText)
|
||||
replaceJobMessage(messageId, finalMessageText, {
|
||||
attachmentAssociationJob: normalizedJob,
|
||||
attachmentOcrDetails,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: buildThinkingEvents('completed')
|
||||
},
|
||||
suggestedActions: buildDetailActions({
|
||||
claimId: normalizedJob.claimId,
|
||||
claimNo: normalizedJob.claimNo
|
||||
})
|
||||
})
|
||||
notifyRequestUpdated?.({
|
||||
claimId: normalizedJob.claimId,
|
||||
claimNo: normalizedJob.claimNo,
|
||||
source: 'ai-workbench-attachment-association-job',
|
||||
uploadedCount: normalizedJob.uploadedCount,
|
||||
skippedCount: normalizedJob.skippedCount
|
||||
})
|
||||
persistCurrentConversation()
|
||||
return true
|
||||
}
|
||||
if (normalizedJob.status === 'failed') {
|
||||
replaceJobMessage(messageId, buildFailedMessage(normalizedJob), {
|
||||
attachmentAssociationJob: normalizedJob,
|
||||
attachmentOcrDetails,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: buildThinkingEvents('failed')
|
||||
}
|
||||
})
|
||||
persistCurrentConversation()
|
||||
return true
|
||||
}
|
||||
|
||||
replaceJobMessage(messageId, buildRunningMessage(normalizedJob, fileNames), {
|
||||
attachmentAssociationJob: normalizedJob,
|
||||
attachmentOcrDetails,
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildThinkingEvents('running')
|
||||
}
|
||||
})
|
||||
persistCurrentConversation()
|
||||
return false
|
||||
}
|
||||
|
||||
async function pollJob({
|
||||
jobId,
|
||||
messageId,
|
||||
fileNames = [],
|
||||
attachmentOcrDetails = null,
|
||||
initialJob = null
|
||||
} = {}) {
|
||||
const normalizedJobId = String(jobId || '').trim()
|
||||
if (!normalizedJobId || activeJobPolls.has(normalizedJobId)) {
|
||||
return
|
||||
}
|
||||
activeJobPolls.add(normalizedJobId)
|
||||
try {
|
||||
let currentJob = initialJob ? normalizeJob(initialJob) : null
|
||||
if (currentJob) {
|
||||
const done = await updateJobMessage({ job: currentJob, messageId, fileNames, attachmentOcrDetails })
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS; index += 1) {
|
||||
await delay(ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS)
|
||||
currentJob = normalizeJob(await fetchAttachmentAssociationJob(normalizedJobId))
|
||||
if (!currentJob) {
|
||||
throw new Error('附件关联任务不存在或已失效。')
|
||||
}
|
||||
const done = await updateJobMessage({ job: currentJob, messageId, fileNames, attachmentOcrDetails })
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
}
|
||||
throw new Error('附件关联任务仍在后台处理中,稍后回到会话会继续刷新结果。')
|
||||
} catch (error) {
|
||||
const message = error?.message || '自动归集状态查询失败,请稍后回到会话查看。'
|
||||
replaceJobMessage(messageId, message, {
|
||||
attachmentAssociationJob: {
|
||||
jobId: normalizedJobId,
|
||||
status: 'failed',
|
||||
message,
|
||||
error: message
|
||||
},
|
||||
attachmentOcrDetails,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: buildThinkingEvents('failed')
|
||||
}
|
||||
})
|
||||
toast(message)
|
||||
persistCurrentConversation()
|
||||
} finally {
|
||||
activeJobPolls.delete(normalizedJobId)
|
||||
}
|
||||
}
|
||||
|
||||
function resumePendingJobs() {
|
||||
conversationMessages.value.forEach((message) => {
|
||||
const job = normalizeJob(message.attachmentAssociationJob || null)
|
||||
if (!job || !isPending(job)) {
|
||||
return
|
||||
}
|
||||
void pollJob({
|
||||
jobId: job.jobId,
|
||||
messageId: message.id,
|
||||
fileNames: message.attachmentOcrDetails?.fileNames || [],
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null,
|
||||
initialJob: job
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
buildRunningMessage,
|
||||
extractReceiptIdsFromOcrDocuments,
|
||||
normalizeJob,
|
||||
pollJob,
|
||||
resumePendingJobs
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user