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:
@@ -5,7 +5,12 @@ import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useToast } from './useToast.js'
|
||||
import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchApprovalExpenseClaims,
|
||||
fetchExpenseClaimDetail
|
||||
} from '../services/reimbursements.js'
|
||||
import { fetchOntologyParse } from '../services/ontology.js'
|
||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||
import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js'
|
||||
@@ -125,10 +130,8 @@ export function useAppShell() {
|
||||
|
||||
async function reloadWorkbenchApprovalRequests() {
|
||||
try {
|
||||
const payload = await fetchAllApprovalExpenseClaims()
|
||||
workbenchApprovalRequests.value = Array.isArray(payload)
|
||||
? payload.map((item) => mapExpenseClaimToRequest(item))
|
||||
: []
|
||||
const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
workbenchApprovalRequests.value = extractExpenseClaimItems(payload).map((item) => mapExpenseClaimToRequest(item))
|
||||
} catch {
|
||||
workbenchApprovalRequests.value = []
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function useChat(activeView) {
|
||||
? `${c.id} 建议审批意见:费用归属与预算中心基本匹配,请补充必要说明后通过。`
|
||||
: '建议审批意见:当前单据存在待确认项,请先完成风险核查和附件补齐后再审批。'
|
||||
}
|
||||
return '收到。我可以继续帮你拆解异常原因、比较部门趋势、生成审批意见,或整理一份今日报销运营简报。'
|
||||
return '收到。我可以继续帮您拆解异常原因、比较部门趋势、生成审批意见,或整理一份今日报销运营简报。'
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { fetchAllExpenseClaims } from '../services/reimbursements.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchExpenseClaims
|
||||
} from '../services/reimbursements.js'
|
||||
import { formatDate, toDate } from './requests/requestShared.js'
|
||||
import { mapExpenseClaimToRequest } from './requests/requestClaimMapper.js'
|
||||
|
||||
@@ -103,8 +107,8 @@ export function useRequests() {
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await fetchAllExpenseClaims()
|
||||
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : []
|
||||
const payload = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
requests.value = extractExpenseClaimItems(payload).map((item) => mapExpenseClaimToRequest(item))
|
||||
loaded.value = true
|
||||
} catch (nextError) {
|
||||
if (!silent) {
|
||||
|
||||
@@ -111,6 +111,10 @@ export function useWorkbenchComposerDate({ draft, focusInput } = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
if (part === 'range-end') {
|
||||
return
|
||||
}
|
||||
|
||||
applyWorkbenchDateSelection()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '我要报销',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ export function useWorkbenchAiComposerFiles({
|
||||
fileInputRef,
|
||||
focusAiModeInput,
|
||||
isInputLocked,
|
||||
resolveInputLockedMessage = () => '请等待费用测算完成后再继续操作。',
|
||||
selectedFiles,
|
||||
toast
|
||||
}) {
|
||||
function triggerAiModeFileUpload() {
|
||||
if (isInputLocked()) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
toast(resolveInputLockedMessage() || '请等待当前任务完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
fileInputRef.value?.click()
|
||||
|
||||
@@ -111,7 +111,7 @@ export function useWorkbenchAiDocumentQueryFlow({
|
||||
{
|
||||
eventId: 'document-query-parse',
|
||||
title: '解析自然语言筛选条件',
|
||||
content: `正在从你的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||
content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
183
web/src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js
Normal file
183
web/src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user