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:
caoxiaozhu
2026-06-24 10:42:50 +08:00
parent 0264a4b5b4
commit ee730aa31c
73 changed files with 2528 additions and 379 deletions

View File

@@ -1,5 +1,4 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSystemState } from '../useSystemState.js'
import { useToast } from '../useToast.js'
import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
@@ -19,7 +18,6 @@ import {
} from '../../utils/aiDocumentDetailReference.js'
import {
AI_MODE_ACTION_ITEMS,
buildSelectedFileCards,
shouldRunAiAttachmentAutoAssociation
} from './workbenchAiComposerModel.js'
import {
@@ -32,6 +30,7 @@ import { useWorkbenchAiApplicationPreviewFlow } from './useWorkbenchAiApplicatio
import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
import { useWorkbenchAiFilePreview } from './useWorkbenchAiFilePreview.js'
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
@@ -75,7 +74,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
normalizeRuntimeMessage,
serializeRuntimeMessage
} = messageRuntime
const {
applicationPreviewEditor,
resolveApplicationPreviewEditorControl,
@@ -139,6 +137,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
fileInputRef,
focusAiModeInput,
isInputLocked: () => isAiModeInputLocked.value,
resolveInputLockedMessage: () => resolveAiModeInputLockMessage(),
selectedFiles,
toast
})
@@ -154,6 +153,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime,
conversationId,
conversationMessages,
createAiAttachmentAssociationId,
createInlineMessage,
@@ -167,14 +167,12 @@ export function usePersonalWorkbenchAiMode(props, emit) {
toast
})
watch(selectedFiles, (files) => {
attachmentFlow.primeAiModeReceiptContext(files)
const filePreview = useWorkbenchAiFilePreview({
attachmentFlow,
conversationStarted,
scrollInlineConversationToBottom,
selectedFiles
})
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
})))
const {
hasInlineAttachmentOcrDetails,
hasInlineThinking,
@@ -319,9 +317,13 @@ export function usePersonalWorkbenchAiMode(props, emit) {
const applicationPreviewEstimatePending = computed(() => (
conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message))
))
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value)
const isAiModeReceiptRecognitionPending = computed(() => attachmentFlow.hasPendingAiModeReceiptRecognition(selectedFiles.value))
const hasAiModeReceiptRecognitionFailure = computed(() => attachmentFlow.hasFailedAiModeReceiptRecognition(selectedFiles.value))
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value || isAiModeReceiptRecognitionPending.value)
const aiModeInputLockMessage = computed(() => resolveAiModeInputLockMessage())
const canSubmitAiModePrompt = computed(() => (
!isAiModeInputLocked.value && (
!isAiModeInputLocked.value &&
!hasAiModeReceiptRecognitionFailure.value && (
Boolean(assistantDraft.value.trim()) ||
selectedFiles.value.length > 0 ||
Boolean(workbenchDateTagLabel.value)
@@ -517,18 +519,47 @@ export function usePersonalWorkbenchAiMode(props, emit) {
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
}
function renderInlineConversationHtml(content) {
return renderAiConversationHtml(content)
}
function renderInlineConversationHtml(content) { return renderAiConversationHtml(content) }
function buildInlinePromptText(rawPrompt, files = []) {
const prompt = buildWorkbenchPromptText(rawPrompt)
if (prompt) {
return prompt
}
if (prompt) return prompt
return files.length ? '请帮我处理已上传的附件。' : ''
}
function resolveAiModeInputLockMessage() {
if (isAiModeReceiptRecognitionPending.value) {
return '附件识别中,请稍等...'
}
if (applicationPreviewEstimatePending.value) {
return '费用测算中,请稍等...'
}
return ''
}
function resolveAiModeSubmitBlockedMessage() {
if (applicationPreviewEstimatePending.value) {
return '请等待费用测算完成后再继续操作。'
}
if (isAiModeReceiptRecognitionPending.value) {
return '附件 OCR 识别中,请稍等,识别完成后再继续对话。'
}
if (hasAiModeReceiptRecognitionFailure.value) {
return '请先移除识别失败的附件或重新上传。'
}
return ''
}
function ensureAiModeCanStartConversation() {
const blockedMessage = resolveAiModeSubmitBlockedMessage()
if (!blockedMessage) {
return true
}
toast(blockedMessage)
focusAiModeInput()
return false
}
function handleAiAnswerMarkdownClick(event) {
const target = event?.target
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
@@ -546,8 +577,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
function startInlineConversation(prompt, entry = {}, files = []) {
if (isAiModeInputLocked.value) {
toast('请等待费用测算完成后再继续操作。')
if (!ensureAiModeCanStartConversation()) {
return
}
const cleanPrompt = buildInlinePromptText(prompt, files)
@@ -595,6 +625,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
function submitAiModePrompt() {
if (!ensureAiModeCanStartConversation()) {
return
}
if (!canSubmitAiModePrompt.value) {
toast('请输入需求后再发送。')
focusAiModeInput()
@@ -604,6 +637,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
function runAiModeAction(item) {
if (!ensureAiModeCanStartConversation()) {
return
}
if (String(item?.label || '').trim() === '发起报销') {
void expenseFlow.startAiReimbursementAssociationGate(item.prompt, item.label)
return
@@ -624,9 +660,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
}
function pushInlineUserMessage(text) {
conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
}
function pushInlineUserMessage(text) { conversationMessages.value.push(createInlineMessage('user', String(text || '').trim())) }
function pushInlineApplicationActionUserMessage(text) {
pushInlineUserMessage(text)
@@ -637,13 +671,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
function resolveLatestInlineUserPrompt() {
const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
return String(latestUserMessage?.content || '').trim()
return String([...conversationMessages.value].reverse().find((message) => message.role === 'user')?.content || '').trim()
}
function handleVoiceInput() {
if (isAiModeInputLocked.value) {
toast('请等待费用测算完成后再继续操作。')
if (!ensureAiModeCanStartConversation()) {
return
}
toast('语音输入正在准备中,您可以先输入文字需求。')
@@ -664,6 +696,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
if (command.type === 'open-recent') {
sessionCommands.openInlineRecentConversation(command.payload || {})
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
}
}
)
@@ -671,6 +705,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
onMounted(() => {
loadSystemSettings()
refreshConversationHistory()
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
})
@@ -681,6 +717,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return {
activeConversationTitle,
aiModeActionItems,
aiModeInputLockMessage,
applicationPreviewEditor,
applicationSubmitConfirmOpen,
assistantInputRef,
@@ -734,7 +771,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
resolveInlineThinkingEvents,
runAiModeAction,
scrollInlineConversationToTop,
selectedFileCards,
...filePreview,
sending,
setAssistantInputRef,
setWorkbenchDateMode,

View File

@@ -13,6 +13,9 @@ import {
} from './workbenchAiMessageModel.js'
import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
import {
CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
SKIP_REQUIRED_APPLICATION_LINK_ACTION,
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION
} from '../../views/scripts/travelReimbursementAssociationGateModel.js'
@@ -102,6 +105,25 @@ export function useWorkbenchAiActionRouter({
return
}
if (actionType === CONTINUE_REIMBURSEMENT_DRAFT_ACTION) {
expenseFlow.promptAiReimbursementDraftContinuation(actionPayload)
focusAiModeInput()
return
}
if (actionType === CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION) {
expenseFlow.promptStandaloneReimbursementDraftCreation(
actionPayload.original_message || '我要报销',
action.label || '独立新建报销单'
)
return
}
if (actionType === CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION) {
expenseFlow.cancelStandaloneReimbursementDraftCreation()
return
}
if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) {
void expenseFlow.startAiReimbursementAssociationGate(
actionPayload.original_message || '我要报销',

View File

@@ -191,7 +191,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
if (normalized.validationIssues?.length || normalized.missingFields?.length) {
return buildApplicationPreviewFooterMessage(normalized)
}
return '申请核对表已补齐,费用测算已同步。仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
return '申请核对表已补齐,费用测算已同步。仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
}
function buildInlineApplicationActionFailureText(error, isSubmit) {
@@ -400,7 +400,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
streamStatus: 'completed',
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
},
suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : []
suggestedActions: buildInlineApplicationDetailAction(draftPayload)
})
)
persistCurrentConversation()

View File

@@ -8,17 +8,17 @@ import {
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
import {
createExpenseClaimItem,
extractExpenseClaimItems,
fetchExpenseClaimDetail,
fetchExpenseClaims,
uploadExpenseClaimItemAttachment
} from '../../services/reimbursements.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import { createAttachmentAssociationJob } from '../../services/attachmentAssociationJobs.js'
import {
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
buildInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js'
import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
import { useWorkbenchAiAttachmentAssociationJobs } from './useWorkbenchAiAttachmentAssociationJobs.js'
function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
const completed = status === 'completed'
@@ -72,6 +72,7 @@ function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
export function useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime,
conversationId,
conversationMessages,
createAiAttachmentAssociationId,
createInlineMessage,
@@ -151,8 +152,11 @@ export function useWorkbenchAiAttachmentAssociationFlow({
).trim()
return {
status: 'recognized',
label: detail ? `已识别票据 · ${detail}` : '已识别票据',
title: detail ? `智能录入已完成,识别为${detail}` : '智能录入已完成'
label: detail ? `当前会话已识别 · ${detail}` : '当前会话已识别',
title: detail
? `当前上传附件 OCR 已完成,识别为${detail}。本状态不代表票据夹已有记录。`
: '当前上传附件 OCR 已完成。本状态不代表票据夹已有记录。',
document: document && typeof document === 'object' ? document : null
}
}
@@ -171,6 +175,24 @@ export function useWorkbenchAiAttachmentAssociationFlow({
return key ? aiModeReceiptRecognitionState[key] || null : null
}
function collectAiModeReceiptRecognitionFiles(files = []) {
return (Array.isArray(files) ? files : [])
.filter((file) => isLikelyAiModeOcrFile(file))
}
function hasPendingAiModeReceiptRecognition(files = []) {
return collectAiModeReceiptRecognitionFiles(files).some((file) => {
const state = resolveAiModeReceiptRecognitionState(file)
return !state || state.status === 'recognizing'
})
}
function hasFailedAiModeReceiptRecognition(files = []) {
return collectAiModeReceiptRecognitionFiles(files).some((file) => (
resolveAiModeReceiptRecognitionState(file)?.status === 'failed'
))
}
function buildAiModeReceiptBaseContext(safeFiles = [], ocrFiles = []) {
const attachmentNames = safeFiles
.map((file) => String(file?.name || '').trim())
@@ -222,7 +244,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
}
}
function startAiModeReceiptRecognition(files = []) {
function startAiModeReceiptRecognition(files = [], options = {}) {
const safeFiles = Array.isArray(files) ? files : []
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles)
@@ -230,8 +252,9 @@ export function useWorkbenchAiAttachmentAssociationFlow({
return null
}
const forceRefresh = Boolean(options.forceRefresh)
const cached = aiModeReceiptContextCache.get(cacheKey)
if (cached?.status === 'resolved') {
if (!forceRefresh && cached?.status === 'resolved') {
applyAiModeReceiptRecognitionResult(ocrFiles, cached.context)
return null
}
@@ -272,7 +295,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
function primeAiModeReceiptContext(files = []) {
pruneAiModeReceiptRecognitionState(files)
const promise = startAiModeReceiptRecognition(files)
const promise = startAiModeReceiptRecognition(files, { forceRefresh: true })
if (promise && typeof promise.catch === 'function') {
promise.catch((error) => {
console.warn('AI mode OCR preload failed:', error)
@@ -399,6 +422,18 @@ export function useWorkbenchAiAttachmentAssociationFlow({
}]
}
const attachmentJobFlow = useWorkbenchAiAttachmentAssociationJobs({
conversationMessages,
createInlineMessage,
persistCurrentConversation,
replaceInlineMessage,
streamOrSetInlineAssistantContent,
notifyRequestUpdated,
toast,
buildDetailActions: buildAiAttachmentAssociationDetailActions,
buildThinkingEvents: buildAiAttachmentAssociationResultThinkingEvents
})
async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) {
const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
@@ -509,47 +544,42 @@ export function useWorkbenchAiAttachmentAssociationFlow({
try {
const collected = await collectAiModeReceiptContext(files)
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 receiptIds = attachmentJobFlow.extractReceiptIdsFromOcrDocuments(collected.ocrDocuments)
if (!receiptIds.length) {
throw new Error('当前附件没有持久化票据记录,请重新上传后再试。')
}
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({
match,
fileNames: files.map((file) => file?.name || ''),
ocrDocuments: collected.ocrDocuments
})
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
const fileNames = files.map((file) => file?.name || '').filter(Boolean)
const job = attachmentJobFlow.normalizeJob(await createAttachmentAssociationJob({
receipt_ids: receiptIds,
prompt,
conversation_id: conversationId?.value || ''
}))
if (!job) {
throw new Error('附件关联任务创建失败,请稍后重试。')
}
const runningMessageText = attachmentJobFlow.buildRunningMessage(job, fileNames)
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
createInlineMessage('assistant', runningMessageText, {
id: pendingMessage.id,
pending: true,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed')
streamStatus: 'streaming',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
},
attachmentOcrDetails,
suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, {
includeOcrDetails: Boolean(attachmentOcrDetails)
})
attachmentAssociationJob: job
})
)
persistCurrentConversation()
void attachmentJobFlow.pollJob({
jobId: job.jobId,
messageId: pendingMessage.id,
fileNames,
attachmentOcrDetails,
initialJob: job
})
} catch (error) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
@@ -574,8 +604,11 @@ export function useWorkbenchAiAttachmentAssociationFlow({
return {
collectAiModeReceiptContext,
confirmAiAttachmentAssociation,
hasFailedAiModeReceiptRecognition,
hasPendingAiModeReceiptRecognition,
primeAiModeReceiptContext,
requestAiAttachmentAssociationReply,
resumePendingAiAttachmentAssociationJobs: attachmentJobFlow.resumePendingJobs,
resolveAiModeReceiptRecognitionState,
resolveAiAttachmentAssociationClaimNo
}

View File

@@ -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
}
}

View File

@@ -8,12 +8,13 @@ export function useWorkbenchAiComposerFiles({
fileInputRef,
focusAiModeInput,
isInputLocked,
resolveInputLockedMessage = () => '请等待费用测算完成后再继续操作。',
selectedFiles,
toast
}) {
function triggerAiModeFileUpload() {
if (isInputLocked()) {
toast('请等待费用测算完成后再继续操作。')
toast(resolveInputLockedMessage() || '请等待当前任务完成后再继续操作。')
return
}
fileInputRef.value?.click()

View File

@@ -111,7 +111,7 @@ export function useWorkbenchAiDocumentQueryFlow({
{
eventId: 'document-query-parse',
title: '解析自然语言筛选条件',
content: `正在从的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}`,
content: `正在从的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}`,
status: 'running'
},
{

View File

@@ -1,5 +1,12 @@
import { fetchExpenseClaims } from '../../services/reimbursements.js'
import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
fetchExpenseClaims
} from '../../services/reimbursements.js'
import { runOrchestrator } from '../../services/orchestrator.js'
import {
createLinkedReimbursementDraftJob,
fetchLinkedReimbursementDraftJob
} from '../../services/linkedReimbursementDraftJobs.js'
import {
applyAiExpenseAnswer,
buildAiExpenseStepPrompt,
@@ -27,7 +34,11 @@ import {
buildReimbursementAssociationSelectionText,
buildReimbursementAssociationQueryFailedText,
buildReimbursementDraftActions,
buildReimbursementDraftContinuationText,
buildReimbursementDraftSelectionText,
buildStandaloneReimbursementDraftConfirmationActions,
buildStandaloneReimbursementDraftConfirmationText,
buildViewReimbursementDraftAction,
fetchReimbursementAssociationClaims,
filterReimbursementAssociationCandidates,
filterReimbursementDraftCandidates,
@@ -37,6 +48,10 @@ import {
export { SESSION_TYPE_EXPENSE }
const AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS = 320
const LINKED_DRAFT_JOB_POLL_INTERVAL_MS = 1200
const LINKED_DRAFT_JOB_MAX_POLLS = 100
const LINKED_DRAFT_JOB_PENDING_STATUSES = new Set(['queued', 'running'])
const LINKED_DRAFT_RUNNING_PHRASE = '正在后台生成报销草稿'
function waitForReimbursementAssociationStep() {
return new Promise((resolve) => {
@@ -44,6 +59,20 @@ function waitForReimbursementAssociationStep() {
})
}
export function buildLinkedDraftRunningText(job = {}, claimNo = '') {
const statusText = String(job?.message || '').trim()
const shouldShowStatusText = Boolean(
statusText && !statusText.includes(LINKED_DRAFT_RUNNING_PHRASE)
)
return [
`已关联申请单${claimNo ? ` ${claimNo}` : ''},正在后台生成报销草稿...`,
shouldShowStatusText ? '' : null,
shouldShowStatusText ? `处理状态:${statusText}` : null,
'',
'您可以先离开当前会话,回来后我会继续查询任务结果。'
].filter((line) => line !== null).join('\n')
}
export function useWorkbenchAiExpenseFlow({
activateInlineConversation,
aiExpenseDraft,
@@ -70,6 +99,10 @@ export function useWorkbenchAiExpenseFlow({
startAiApplicationPreview,
fetchExpenseClaimsForAi = fetchExpenseClaims,
runOrchestratorForAi = runOrchestrator,
createLinkedReimbursementDraftJobForAi = createLinkedReimbursementDraftJob,
fetchLinkedReimbursementDraftJobForAi = fetchLinkedReimbursementDraftJob,
linkedDraftJobPollIntervalMs = LINKED_DRAFT_JOB_POLL_INTERVAL_MS,
linkedDraftJobMaxPolls = LINKED_DRAFT_JOB_MAX_POLLS,
associationQueryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
}) {
function replaceInlineAssistantMessage(messageId, content = '', options = {}) {
@@ -79,6 +112,7 @@ export function useWorkbenchAiExpenseFlow({
stewardPlan: options.stewardPlan || null,
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
draftPayload: options.draftPayload || null,
linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null,
text: options.text || content
})
replaceInlineMessage(messageId, nextMessage)
@@ -113,6 +147,67 @@ export function useWorkbenchAiExpenseFlow({
)
}
function normalizeDraftActionPayload(payload = {}) {
return {
id: String(payload.id || payload.claim_id || payload.claimId || '').trim(),
claim_no: String(payload.claim_no || payload.claimNo || '').trim(),
original_message: String(payload.original_message || payload.originalMessage || '我要报销').trim() || '我要报销'
}
}
function pushPromptConversationUserMessage(text = '') {
const normalizedText = String(text || '').trim()
if (normalizedText) {
pushInlineUserMessage(normalizedText)
}
}
function promptAiReimbursementDraftContinuation(payload = {}) {
const draft = normalizeDraftActionPayload(payload)
const claimNo = draft.claim_no || '当前草稿'
if (!conversationStarted.value) {
activateInlineConversation({
title: `继续草稿 ${claimNo}`.trim().slice(0, 18) || '继续草稿'
})
}
assistantDraft.value = ''
closeWorkbenchDatePicker()
pushPromptConversationUserMessage(`继续关联草稿 ${claimNo}`)
conversationMessages.value.push(createInlineMessage('assistant', buildReimbursementDraftContinuationText(draft), {
meta: ['等待上传附件或说明'],
suggestedActions: [buildViewReimbursementDraftAction(draft, draft.original_message)]
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
function promptStandaloneReimbursementDraftCreation(originalMessage = '我要报销', selectedLabel = '独立新建报销单') {
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
const userText = String(selectedLabel || '独立新建报销单').trim() || '独立新建报销单'
if (!conversationStarted.value) {
activateInlineConversation({
title: userText.slice(0, 18) || '新建报销'
})
}
assistantDraft.value = ''
closeWorkbenchDatePicker()
pushPromptConversationUserMessage(userText)
conversationMessages.value.push(createInlineMessage('assistant', buildStandaloneReimbursementDraftConfirmationText(), {
meta: ['等待确认新建草稿'],
suggestedActions: buildStandaloneReimbursementDraftConfirmationActions(sourceText)
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
function cancelStandaloneReimbursementDraftCreation() {
conversationMessages.value.push(createInlineMessage('assistant', '好的,本次先不新建报销草稿。您可以继续查看已有草稿,或补充新的报销说明。', {
meta: ['已取消新建']
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
async function startAiReimbursementAssociationGate(originalMessage = '我要报销', selectedLabel = '', options = {}) {
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
if (!conversationStarted.value) {
@@ -255,7 +350,7 @@ export function useWorkbenchAiExpenseFlow({
aiExpenseDraft.value = next
if (isAiExpenseDraftComplete(next)) {
conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮生成报销草稿。`))
conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮生成报销草稿。`))
aiExpenseDraft.value = null
} else {
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next)))
@@ -267,7 +362,7 @@ export function useWorkbenchAiExpenseFlow({
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
let claims = null
try {
claims = await fetchExpenseClaimsForAi()
claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
} catch {
aiExpenseDraft.value = null
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
@@ -322,6 +417,122 @@ export function useWorkbenchAiExpenseFlow({
}
}
function waitForLinkedDraftJobPoll() {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, linkedDraftJobPollIntervalMs)
})
}
function normalizeLinkedDraftJob(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(),
error: String(job?.error || '').trim(),
runId: String(job?.run_id || job?.runId || '').trim(),
applicationClaimNo: String(job?.application_claim_no || job?.applicationClaimNo || '').trim(),
draftPayload: job?.draft_payload && typeof job.draft_payload === 'object'
? job.draft_payload
: job?.draftPayload && typeof job.draftPayload === 'object'
? job.draftPayload
: null
}
}
function buildLinkedDraftFailedText(job = {}) {
return String(job?.message || job?.error || '').trim()
|| '生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,您可以稍后重试,或单独新建报销单。'
}
const activeLinkedDraftJobPolls = new Set()
async function pollLinkedDraftJob({
jobId,
pendingMessageId,
claimNo = '',
initialJob = null
}) {
const normalizedJobId = String(jobId || '').trim()
if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) {
return
}
activeLinkedDraftJobPolls.add(normalizedJobId)
let currentJob = initialJob ? normalizeLinkedDraftJob(initialJob) : null
try {
for (let index = 0; index <= linkedDraftJobMaxPolls; index += 1) {
if (!currentJob && index > 0) {
currentJob = normalizeLinkedDraftJob(await fetchLinkedReimbursementDraftJobForAi(normalizedJobId))
}
if (currentJob && !LINKED_DRAFT_JOB_PENDING_STATUSES.has(currentJob.status)) {
if (currentJob.status === 'succeeded') {
const draftPayload = currentJob.draftPayload || null
const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
const content = draftClaimNo
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
replaceInlineAssistantMessage(pendingMessageId, content, {
draftPayload,
linkedReimbursementDraftJob: {
...currentJob,
applicationClaimNo: claimNo
},
suggestedActions: buildLinkedDraftAction(draftPayload)
})
aiExpenseDraft.value = null
persistCurrentConversation()
scrollInlineConversationToBottom()
return
}
throw new Error(buildLinkedDraftFailedText(currentJob))
}
if (currentJob) {
replaceInlineAssistantMessage(pendingMessageId, buildLinkedDraftRunningText(currentJob, claimNo), {
pending: true,
linkedReimbursementDraftJob: {
...currentJob,
applicationClaimNo: claimNo
},
suggestedActions: []
})
persistCurrentConversation()
}
await waitForLinkedDraftJobPoll()
currentJob = normalizeLinkedDraftJob(await fetchLinkedReimbursementDraftJobForAi(normalizedJobId))
}
throw new Error('报销草稿仍在后台生成中,稍后回到会话会继续刷新结果。')
} finally {
activeLinkedDraftJobPolls.delete(normalizedJobId)
}
}
function resumePendingLinkedReimbursementDraftJobs() {
conversationMessages.value.forEach((message) => {
const job = normalizeLinkedDraftJob(message.linkedReimbursementDraftJob || null)
if (!job || !LINKED_DRAFT_JOB_PENDING_STATUSES.has(job.status)) {
return
}
void pollLinkedDraftJob({
jobId: job.jobId,
pendingMessageId: message.id,
claimNo: job.applicationClaimNo,
initialJob: job
}).catch((error) => {
replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), {
linkedReimbursementDraftJob: {
...job,
status: 'failed',
message: error?.message || '报销草稿生成状态查询失败。'
}
})
persistCurrentConversation()
})
})
}
function buildLinkedDraftAction(draftPayload = {}) {
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
@@ -375,39 +586,28 @@ export function useWorkbenchAiExpenseFlow({
application,
application.original_message || resolveLatestInlineUserPrompt() || '我要报销'
)
const user = currentUser.value || {}
const payload = await runOrchestratorForAi(
{
source: 'user_message',
user_id: user.username || user.name || 'anonymous',
conversation_id: null,
message: submitOptions.rawText,
context_json: {
...buildWorkbenchUserContext(),
...submitOptions.extraContext
}
},
{
timeoutMs: 120000,
timeoutMessage: '生成报销草稿超时,请稍后重试。'
const job = await createLinkedReimbursementDraftJobForAi({
message: submitOptions.rawText,
conversation_id: '',
context_json: {
...buildWorkbenchUserContext(),
...submitOptions.extraContext
}
)
const draftPayload = payload?.result?.draft_payload || null
const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
const content = draftClaimNo
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
replaceInlineAssistantMessage(pendingMessageId, content, {
draftPayload,
suggestedActions: buildLinkedDraftAction(draftPayload)
})
aiExpenseDraft.value = null
persistCurrentConversation()
scrollInlineConversationToBottom()
} catch {
const normalizedJob = normalizeLinkedDraftJob(job)
if (!normalizedJob) {
throw new Error('报销草稿生成任务创建失败,请稍后重试。')
}
await pollLinkedDraftJob({
jobId: normalizedJob.jobId,
pendingMessageId,
claimNo,
initialJob: normalizedJob
})
} catch (error) {
replaceInlineAssistantMessage(
pendingMessageId,
'生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,你可以稍后重试或单独新建报销单。',
buildLinkedDraftFailedText(error),
{
suggestedActions: []
}
@@ -419,8 +619,12 @@ export function useWorkbenchAiExpenseFlow({
return {
advanceAiExpenseDraft,
cancelStandaloneReimbursementDraftCreation,
linkAiExpenseApplication,
promptAiReimbursementDraftContinuation,
promptStandaloneReimbursementDraftCreation,
pushInlineExpenseSceneSelectionPrompt,
resumePendingLinkedReimbursementDraftJobs,
startAiApplicationPreviewFromAction,
startAiReimbursementAssociationGate,
startAiExpenseDraft

View File

@@ -0,0 +1,183 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { buildSelectedFileCards } from './workbenchAiComposerModel.js'
function normalizePreviewText(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim()
}
function formatFileSize(size) {
const bytes = Number(size || 0)
if (!Number.isFinite(bytes) || bytes <= 0) {
return '-'
}
if (bytes < 1024 * 1024) {
return `${Math.max(1, Math.round(bytes / 1024))} KB`
}
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatFileTime(timestamp) {
const value = Number(timestamp || 0)
if (!Number.isFinite(value) || value <= 0) {
return '-'
}
return new Date(value).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function normalizePreviewField(field = {}) {
const value = normalizePreviewText(field.value ?? field.text)
if (!value) {
return null
}
return {
label: normalizePreviewText(field.label || field.key || field.name) || '识别字段',
value
}
}
function resolveDocumentPreviewUrl(document = null) {
return normalizePreviewText(document?.preview_data_url || document?.previewDataUrl)
}
function resolveSourceKind(sourceUrl, rawFile = {}) {
const type = normalizePreviewText(rawFile?.type).toLowerCase()
const name = normalizePreviewText(rawFile?.name).toLowerCase()
if (!sourceUrl) {
return 'unsupported'
}
if (sourceUrl.startsWith('data:image/') || type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/.test(name)) {
return 'image'
}
if (type === 'application/pdf' || /\.pdf$/.test(name)) {
return 'pdf'
}
return 'unsupported'
}
function createObjectUrl(rawFile) {
if (!rawFile || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
return ''
}
return URL.createObjectURL(rawFile)
}
export function useWorkbenchAiFilePreview({
attachmentFlow,
conversationStarted,
scrollInlineConversationToBottom,
selectedFiles
}) {
const filePreviewState = ref({ open: false, key: '', objectUrl: '' })
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
})))
function clearFilePreviewObjectUrl() {
const objectUrl = filePreviewState.value.objectUrl
if (objectUrl && objectUrl.startsWith('blob:') && typeof URL !== 'undefined') {
URL.revokeObjectURL(objectUrl)
}
}
function findSelectedFile(fileKey) {
const index = selectedFileCards.value.findIndex((file) => file.key === fileKey)
if (index < 0) {
return null
}
return {
card: selectedFileCards.value[index],
index,
rawFile: selectedFiles.value[index]
}
}
function openAiModeFilePreview(fileKey) {
const target = findSelectedFile(fileKey)
if (!target?.rawFile) {
return
}
clearFilePreviewObjectUrl()
filePreviewState.value = {
open: true,
key: fileKey,
objectUrl: createObjectUrl(target.rawFile)
}
}
function closeAiModeFilePreview() {
clearFilePreviewObjectUrl()
filePreviewState.value = { open: false, key: '', objectUrl: '' }
}
const activeAiModeFilePreview = computed(() => {
if (!filePreviewState.value.open || !filePreviewState.value.key) {
return null
}
const target = findSelectedFile(filePreviewState.value.key)
if (!target) {
return null
}
const rawFile = target.rawFile
const recognitionState = attachmentFlow.resolveAiModeReceiptRecognitionState(rawFile) || target.card.ocrState || null
const document = recognitionState?.document || null
const documentFields = Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || []
const ocrFields = documentFields.map((field) => normalizePreviewField(field)).filter(Boolean)
const documentPreviewUrl = resolveDocumentPreviewUrl(document)
const sourceUrl = documentPreviewUrl || filePreviewState.value.objectUrl
const sourceKind = documentPreviewUrl ? 'image' : resolveSourceKind(sourceUrl, rawFile)
const documentTypeLabel = normalizePreviewText(
document?.document_type_label ||
document?.scene_label ||
document?.document_type ||
target.card.typeLabel
)
return {
open: true,
key: target.card.key,
name: target.card.name,
sourceKind,
sourceUrl,
documentTypeLabel: documentTypeLabel || '待识别',
recognitionStatus: recognitionState?.status || 'idle',
recognitionStatusLabel: recognitionState?.label || '等待智能录入识别',
recognitionStatusTitle: recognitionState?.title || '',
fileInfoRows: [
{ label: '文件类型', value: target.card.typeLabel },
{ label: '文件大小', value: formatFileSize(rawFile?.size) },
{ label: '上传时间', value: formatFileTime(rawFile?.lastModified) }
],
ocrFields,
ocrSummary: normalizePreviewText(document?.summary),
rawText: normalizePreviewText(document?.text).slice(0, 600)
}
})
watch(selectedFiles, (files, previousFiles = []) => {
attachmentFlow.primeAiModeReceiptContext(files)
const fileCountChanged = files.length !== previousFiles.length
if (conversationStarted.value && fileCountChanged) {
scrollInlineConversationToBottom({ force: true })
}
if (filePreviewState.value.open && !findSelectedFile(filePreviewState.value.key)) {
closeAiModeFilePreview()
}
}, { flush: 'sync' })
onBeforeUnmount(() => {
closeAiModeFilePreview()
})
return {
activeAiModeFilePreview,
closeAiModeFilePreview,
openAiModeFilePreview,
selectedFileCards
}
}

View File

@@ -28,7 +28,7 @@ export function useWorkbenchAiSessionCommands({
function openInlineSearchConversation(activateInlineConversation) {
conversationMessages.value = [
createInlineMessage('assistant', '可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
createInlineMessage('assistant', '可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
]
stewardState.value = null
thinkingExpandedMessageIds.value = new Set()
@@ -54,7 +54,7 @@ export function useWorkbenchAiSessionCommands({
: [
createInlineMessage(
'assistant',
'这条历史对话没有保存完整消息。可以继续输入新的问题,小财管家会接着处理。'
'这条历史对话没有保存完整消息。可以继续输入新的问题,小财管家会接着处理。'
)
]
conversationStarted.value = true

View File

@@ -2,7 +2,10 @@ import {
fetchStewardPlan,
fetchStewardPlanStream
} from '../../services/steward.js'
import { fetchExpenseClaims } from '../../services/reimbursements.js'
import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
fetchExpenseClaims
} from '../../services/reimbursements.js'
import {
buildStewardPlanMessageText,
buildStewardPlanRequest,
@@ -71,13 +74,13 @@ function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
if (flow?.flowId === 'travel_application') {
return [
contextText || baseText,
'这类操作需要手动确认。请点击下方 **确认发起出差申请**,我在当前对话里生成完整申请表,并把已识别的信息自动预填。'
'这类操作需要手动确认。请点击下方 **确认发起出差申请**,我在当前对话里生成完整申请表,并把已识别的信息自动预填。'
].filter(Boolean).join('\n\n')
}
if (flow?.flowId === 'travel_reimbursement') {
return [
contextText || baseText,
'这类操作需要手动确认。请点击下方 **确认关联已有申请单**,我继续查询并展示可关联单据。'
'这类操作需要手动确认。请点击下方 **确认关联已有申请单**,我继续查询并展示可关联单据。'
].filter(Boolean).join('\n\n')
}
return baseText
@@ -100,7 +103,7 @@ function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
if (flow.flowId === 'travel_reimbursement') {
return [{
label: '确认关联已有申请单',
description: '确认后查询名下可关联的差旅申请单,并进入关联步骤。',
description: '确认后查询名下可关联的差旅申请单,并进入关联步骤。',
icon: 'mdi mdi-link-variant',
action_type: 'steward_confirm_flow',
payload: {
@@ -155,7 +158,7 @@ export function useWorkbenchAiStewardFlow({
}
try {
const claims = await fetchExpenseClaims()
const claims = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {})
planRequest.context_json = {
...(planRequest.context_json || {}),
@@ -232,14 +235,14 @@ export function useWorkbenchAiStewardFlow({
},
{
idleTimeoutMs: 90000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。可以稍后继续追问。'
timeoutMessage: '小财管家仍在规划任务,已停止等待。可以稍后继续追问。'
}
)
} catch (error) {
if (String(error?.message || '').includes('流式服务')) {
return fetchStewardPlan(payload, {
timeoutMs: 75000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。可以稍后继续追问。'
timeoutMessage: '小财管家仍在规划任务,已停止等待。可以稍后继续追问。'
})
}
throw error
@@ -256,7 +259,7 @@ export function useWorkbenchAiStewardFlow({
{
eventId: 'init',
title: '小财管家正在接入业务流程',
content: '正在识别的意图、上下文和附件信息。',
content: '正在识别的意图、上下文和附件信息。',
status: 'running'
}
]

View File

@@ -3,7 +3,6 @@ import {
buildLocalApplicationPreview,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { AI_APPLICATION_DETAIL_HREF_PREFIX } from '../../utils/aiDocumentDetailReference.js'
import {
buildAiApplicationPrecheckThinkingEvents,
isAiApplicationPrecheckBlocking
@@ -32,24 +31,6 @@ export function normalizeInlineApplicationStatusLabel(value, fallback = '') {
return INLINE_APPLICATION_STATUS_LABELS[text.toLowerCase()] || text
}
export function buildInlineApplicationActionDetailHref(reference = '') {
const source = reference && typeof reference === 'object' ? reference : { reference }
const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
const fallback = String(source.reference || '').trim()
if (claimId || claimNo) {
const params = new URLSearchParams()
if (claimId) {
params.set('claim_id', claimId)
}
if (claimNo) {
params.set('claim_no', claimNo)
}
return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
}
return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
}
export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {}
const body = String(source.body || source.markdown || '').trim()
@@ -124,13 +105,11 @@ export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
export function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
const reference = info.claimNo || info.claimId
const href = buildInlineApplicationActionDetailHref(info)
const actionText = href ? `[查看](${href})` : '-'
const statusLabel = normalizeInlineApplicationStatusLabel(info.statusLabel, options.statusLabel)
return [
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 | 操作 |',
'| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
`| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${normalizeInlineApplicationResultTableCell(info.dateLabel)} | ${normalizeInlineApplicationResultTableCell(info.locationLabel)} | ${normalizeInlineApplicationResultTableCell(info.reasonLabel)} | ${normalizeInlineApplicationResultTableCell(info.amountLabel, '-')} | ${actionText} |`
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 |',
'| --- | --- | --- | --- | --- | --- | --- | --- |',
`| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${normalizeInlineApplicationResultTableCell(info.dateLabel)} | ${normalizeInlineApplicationResultTableCell(info.locationLabel)} | ${normalizeInlineApplicationResultTableCell(info.reasonLabel)} | ${normalizeInlineApplicationResultTableCell(info.amountLabel, '-')} |`
].join('\n')
}
@@ -155,8 +134,7 @@ export function buildInlineApplicationPreviewActionResultText(actionType, payloa
statusLabel: '审批中',
stageLabel: approvalStage || '直属领导审批',
documentTypeLabel: '出差申请'
}),
'需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。'
})
].filter(Boolean).join('\n\n')
}
return [
@@ -166,8 +144,7 @@ export function buildInlineApplicationPreviewActionResultText(actionType, payloa
statusLabel: '草稿',
stageLabel: '待提交',
documentTypeLabel: '出差申请'
}),
'后续请点击卡片“操作”行的“查看”进入详情页继续核对。'
})
].filter(Boolean).join('\n\n')
}
@@ -266,7 +243,7 @@ export function buildInitialInlineApplicationSubmitThinkingEvents() {
{
eventId: 'application-precheck-overlap',
title: '核查同时间段申请单',
content: '正在查询名下可见申请单,检查是否存在相同或重叠日期。',
content: '正在查询名下可见申请单,检查是否存在相同或重叠日期。',
status: 'running'
},
{

View File

@@ -151,6 +151,8 @@ export function createWorkbenchAiMessageRuntime() {
: suggestedActions,
applicationPreview: options.applicationPreview || null,
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()
@@ -166,6 +168,8 @@ export function createWorkbenchAiMessageRuntime() {
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
applicationPreview: message.applicationPreview || null,
draftPayload: message.draftPayload || null,
attachmentAssociationJob: message.attachmentAssociationJob || null,
linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null,
attachmentOcrDetails: message.attachmentOcrDetails || null,
text: message.text || message.content || ''
})
@@ -182,6 +186,8 @@ export function createWorkbenchAiMessageRuntime() {
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
applicationPreview: message.applicationPreview || null,
draftPayload: message.draftPayload || null,
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null),
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null),
attachmentOcrDetails: message.attachmentOcrDetails || null
}
}
@@ -193,3 +199,52 @@ export function createWorkbenchAiMessageRuntime() {
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
}
}