import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import aiAvatar from '../../assets/header.svg' import userAvatar from '../../assets/person.svg' import { useSystemState } from '../../composables/useSystemState.js' import { recognizeOcrFiles } from '../../services/ocr.js' import { runOrchestrator } from '../../services/orchestrator.js' const SOURCE_LABELS = { workbench: '来自个人工作台', topbar: '来自发起报销', detail: '来自智能录入', upload: '来自附件上传', requests: '来自报销列表' } const SCENARIO_LABELS = { expense: '报销', accounts_receivable: '应收', accounts_payable: '应付', knowledge: '知识', unknown: '通用' } const INTENT_LABELS = { query: '查询', explain: '解释', compare: '对比', risk_check: '风险检查', draft: '草稿生成', operate: '动作请求' } let messageSeed = 0 function nowTime() { return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } function createMessage(role, text, attachments = [], extras = {}) { messageSeed += 1 return { id: `msg-${messageSeed}`, role, text, attachments, time: nowTime(), meta: [], citations: [], suggestedActions: [], draftPayload: null, reviewPayload: null, riskFlags: [], ...extras } } function formatMessageTime(value) { if (!value) { return nowTime() } const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) { return nowTime() } return parsed.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } function sanitizeRequest(request) { if (!request || typeof request !== 'object') return null const normalized = { id: String(request.id || '').trim(), reason: String(request.reason || request.title || '').trim(), city: String(request.city || request.location || '').trim(), period: String(request.period || '').trim(), applyTime: String(request.applyTime || request.occurredAt || '').trim(), amount: String(request.amount || '').trim(), node: String(request.node || '').trim(), approval: String(request.approval || '').trim(), travel: String(request.travel || '').trim() } return Object.values(normalized).some(Boolean) ? normalized : null } function resolveStatusLabel(status) { if (status === 'succeeded') return '已完成' if (status === 'blocked') return '已阻断' return '失败' } function resolveStatusTone(status) { if (status === 'succeeded') return 'success' if (status === 'blocked') return 'warning' return 'note' } function buildMessageMeta(payload, fileNames = []) { const items = [] if (payload?.selected_agent) { items.push(`Agent: ${payload.selected_agent}`) } if (payload?.permission_level) { items.push(`权限: ${payload.permission_level}`) } if (payload?.trace_summary?.tool_count) { items.push(`工具: ${payload.trace_summary.tool_count}`) } if (payload?.trace_summary?.degraded) { items.push('已降级') } if (payload?.requires_confirmation) { items.push('待确认') } if (payload?.run_id) { items.push(`Run: ${payload.run_id}`) } if (fileNames.length) { items.push(`附件: ${fileNames.length}`) } return items } function buildStoredMessageMeta(messageJson, attachmentNames = []) { const payload = messageJson?.orchestrator_payload if (payload) { return buildMessageMeta(payload, attachmentNames) } const items = [] if (messageJson?.status) { items.push(`状态: ${messageJson.status}`) } if (attachmentNames.length) { items.push(`附件: ${attachmentNames.length}`) } return items } function normalizeOcrDocuments(payload) { const documents = Array.isArray(payload?.documents) ? payload.documents : [] return documents.slice(0, 5).map((item) => ({ filename: item.filename, summary: item.summary, text: String(item.text || '').slice(0, 240), avg_score: Number(item.avg_score || 0), line_count: Number(item.line_count || 0), warnings: Array.isArray(item.warnings) ? item.warnings : [] })) } function buildOcrSummary(payload) { const parts = normalizeOcrDocuments(payload) .map((item) => `${item.filename}:${item.summary || item.text}`) .filter(Boolean) return parts.join(';') } function inferPreviewKind(file) { const mediaType = String(file?.type || '').toLowerCase() const filename = String(file?.name || '').toLowerCase() if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) { return 'image' } if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) { return 'pdf' } return 'file' } function buildFilePreviews(files, previewRegistry) { return files.map((file) => { const kind = inferPreviewKind(file) if (kind !== 'image') { return { filename: file.name, kind } } const url = URL.createObjectURL(file) previewRegistry.push(url) return { filename: file.name, kind, url } }) } function resolveDocumentPreview(filePreviews, filename) { if (!Array.isArray(filePreviews)) return null return filePreviews.find((item) => item.filename === filename) ?? null } function buildWelcomeInsight(entrySource, linkedRequest) { return { intent: 'welcome', metricLabel: '当前状态', metricValue: '待识别', title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '等待识别内容', summary: entrySource === 'detail' && linkedRequest?.id ? '发送消息后会直接结合当前单据上下文识别报销语义,并在右侧展示可核对字段。' : '请输入费用场景或上传票据,右侧会展示识别出的报销类型、时间、金额和待补字段。', agent: null } } function buildInitialInsightFromConversation(conversation) { const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] for (let index = rawMessages.length - 1; index >= 0; index -= 1) { const item = rawMessages[index] const messageJson = item?.message_json || item?.messageJson || {} const orchestratorPayload = messageJson?.orchestrator_payload || null if (!orchestratorPayload) continue const attachmentNames = Array.isArray(messageJson?.attachment_names) ? messageJson.attachment_names.filter(Boolean) : [] return buildAgentInsight(orchestratorPayload, attachmentNames, []) } return null } function resolveInitialConversationId(conversation) { return String(conversation?.conversation_id || conversation?.conversationId || '').trim() } function resolveInitialDraftClaimId(conversation) { return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim() } function normalizeInitialConversationMessages(conversation) { const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] return rawMessages.map((item) => { const messageJson = item?.message_json || item?.messageJson || {} const attachmentNames = Array.isArray(messageJson?.attachment_names) ? messageJson.attachment_names.filter(Boolean) : [] const orchestratorPayload = messageJson?.orchestrator_payload || null const result = orchestratorPayload?.result || {} return createMessage(item.role, item.content, attachmentNames, { id: `restored-${item.id || ++messageSeed}`, time: formatMessageTime(item.created_at || item.createdAt), meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [], citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [], suggestedActions: item.role === 'assistant' && Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null, reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null, riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] }) }) } function cloneReviewEditFields(fields) { const items = Array.isArray(fields) ? fields : [] return items.map((item) => ({ key: String(item?.key || '').trim(), label: String(item?.label || '').trim(), value: String(item?.value || ''), placeholder: String(item?.placeholder || ''), required: Boolean(item?.required), field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text', group: String(item?.group || 'basic').trim() || 'basic' })) } function buildReviewFormValues(fields) { return cloneReviewEditFields(fields).reduce((result, item) => { if (!item.key) { return result } result[item.key] = String(item.value || '').trim() return result }, {}) } function buildReviewCorrectionMessage(fields) { const lines = ['请按以下核对后的报销信息更新当前识别结果:'] for (const item of cloneReviewEditFields(fields)) { if (!item.label || (!item.value && !item.required)) { continue } lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`) } return lines.join('\n') } function buildErrorInsight(error, fileNames = []) { return { intent: 'agent', metricLabel: '运行状态', metricValue: '失败', title: '智能体调用失败', summary: error?.message || '无法连接后端 Orchestrator。', agent: { runId: '未生成', selectedAgent: 'orchestrator', scenario: '未知', intent: '未知', permissionLevel: 'unknown', routeReason: 'request_failed', requiresConfirmation: false, degraded: false, fileNames, citations: [], suggestedActions: [], draftPayload: null, reviewPayload: null, riskFlags: [], toolCount: 0, failedToolCount: 0, selectedCapabilityCodes: [], filePreviews: [], statusLabel: '失败', statusTone: 'note' } } } function buildAgentInsight(payload, fileNames = [], filePreviews = []) { const trace = payload?.trace_summary || {} const result = payload?.result || {} const statusLabel = resolveStatusLabel(payload?.status) return { intent: 'agent', metricLabel: '运行状态', metricValue: statusLabel, title: result?.draft_payload?.title || `${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`, summary: result?.answer || result?.message || '智能体已完成处理。', agent: { runId: payload?.run_id || '未生成', selectedAgent: payload?.selected_agent || 'orchestrator', scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知', intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知', permissionLevel: payload?.permission_level || 'unknown', routeReason: payload?.route_reason || 'unknown', requiresConfirmation: Boolean(payload?.requires_confirmation), degraded: Boolean(trace?.degraded), fileNames, citations: Array.isArray(result?.citations) ? result.citations : [], suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], draftPayload: result?.draft_payload || null, reviewPayload: result?.review_payload || null, riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [], toolCount: Number(trace?.tool_count || 0), failedToolCount: Number(trace?.failed_tool_count || 0), selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes) ? trace.selected_capability_codes : [], filePreviews, statusLabel, statusTone: resolveStatusTone(payload?.status) } } } export default { name: 'TravelReimbursementCreateView', props: { initialPrompt: { type: String, default: '' }, initialFiles: { type: Array, default: () => [] }, initialConversation: { type: Object, default: null }, entrySource: { type: String, default: 'requests' }, requestContext: { type: Object, default: null } }, emits: ['close'], setup(props, { emit }) { const { currentUser } = useSystemState() const fileInputRef = ref(null) const messageListRef = ref(null) const composerDraft = ref('') const attachedFiles = ref([]) const submitting = ref(false) const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const restoredMessages = normalizeInitialConversationMessages(props.initialConversation) const initialInsight = buildInitialInsightFromConversation(props.initialConversation) const messages = ref( restoredMessages.length ? restoredMessages : [ createMessage( 'assistant', props.entrySource === 'detail' && linkedRequest.value?.id ? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。请描述费用场景或补充票据。` : '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。' ) ] ) const conversationId = ref(resolveInitialConversationId(props.initialConversation)) const draftClaimId = ref(resolveInitialDraftClaimId(props.initialConversation)) const previewRegistry = [] const currentInsight = ref(initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value)) const reviewCancelDialogOpen = ref(false) const reviewEditDialogOpen = ref(false) const reviewActionBusy = ref(false) const reviewEditFields = ref([]) const reviewActionMessageId = ref('') const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台') const canSubmit = computed( () => !submitting.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length) ) const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome') const composerPlaceholder = computed(() => { if (props.entrySource === 'detail' && linkedRequest.value?.id) { return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。` } return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。' }) const currentIntentLabel = computed(() => { const labels = { welcome: '等待输入', agent: '真实智能体' } return labels[currentInsight.value.intent] ?? 'AI 处理中' }) const latestReviewMessage = computed(() => [...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null ) const activeReviewPayload = computed( () => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null ) const activeReviewFilePreviews = computed( () => currentInsight.value.agent?.filePreviews || [] ) const recognizedSlotCards = computed(() => Array.isArray(activeReviewPayload.value?.slot_cards) ? activeReviewPayload.value.slot_cards.filter((item) => item.status !== 'missing') : [] ) const missingSlotCards = computed(() => Array.isArray(activeReviewPayload.value?.slot_cards) ? activeReviewPayload.value.slot_cards.filter((item) => item.status === 'missing') : [] ) const shortcuts = computed(() => { if (props.entrySource === 'detail' && linkedRequest.value?.id) { return [ { label: '解释风险原因', icon: 'mdi mdi-shield-alert-outline', prompt: `解释一下 ${linkedRequest.value.id} 为什么会被拦截` }, { label: '生成处理意见', icon: 'mdi mdi-file-document-edit-outline', prompt: `帮我给 ${linkedRequest.value.id} 生成处理意见草稿` }, { label: '列出补件清单', icon: 'mdi mdi-format-list-checks', prompt: `帮我列出 ${linkedRequest.value.id} 还需要补哪些附件` }, { label: '引用相关制度', icon: 'mdi mdi-book-open-variant-outline', prompt: `解释一下 ${linkedRequest.value.id} 相关的报销制度依据` } ] } return [ { label: '查本周报销金额', icon: 'mdi mdi-cash-multiple', prompt: '查一下本周报销金额' }, { label: '解释报销风险', icon: 'mdi mdi-shield-alert-outline', prompt: '为什么酒店超标报销不能直接通过' }, { label: '生成报销草稿', icon: 'mdi mdi-file-document-edit-outline', prompt: '帮我生成一份差旅报销草稿' }, { label: '查待付款金额', icon: 'mdi mdi-bank-transfer-out', prompt: '供应商B待付款多少' } ] }) onMounted(() => { currentInsight.value = initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value) if (props.initialPrompt?.trim() || props.initialFiles.length) { composerDraft.value = props.initialPrompt.trim() attachedFiles.value = Array.from(props.initialFiles) submitComposer() } else { nextTick(scrollToBottom) } }) onBeforeUnmount(() => { for (const url of previewRegistry) { URL.revokeObjectURL(url) } }) function scrollToBottom() { if (!messageListRef.value) return messageListRef.value.scrollTop = messageListRef.value.scrollHeight } function replaceMessage(messageId, nextMessage) { const index = messages.value.findIndex((item) => item.id === messageId) if (index === -1) { messages.value.push(nextMessage) return } messages.value.splice(index, 1, nextMessage) } function triggerFileUpload() { if (submitting.value) return fileInputRef.value?.click() } function handleFilesChange(event) { attachedFiles.value = Array.from(event.target.files ?? []) } function runShortcut(prompt) { composerDraft.value = prompt submitComposer() } function buildBackendMessage(rawText, fileNames, ocrSummary = '') { const parts = [] const normalizedText = String(rawText || '').trim() if (normalizedText) { parts.push(normalizedText) } else if (fileNames.length) { parts.push(`我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并尽量生成草稿。`) } if (fileNames.length) { parts.push(`附件名称:${fileNames.join('、')}`) } if (ocrSummary) { parts.push(`OCR摘要:${ocrSummary}`) } if (props.entrySource === 'detail' && linkedRequest.value?.id) { parts.push(`关联单号:${linkedRequest.value.id}`) } return parts.join('\n') } async function submitComposer(options = {}) { const rawText = String(options.rawText ?? composerDraft.value).trim() const files = Array.from(options.files ?? attachedFiles.value) if (!rawText && !files.length) return const fileNames = files.map((file) => file.name) const filePreviews = buildFilePreviews(files, previewRegistry) const userText = String(options.userText || '').trim() || rawText || `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。` const extraContext = options.extraContext && typeof options.extraContext === 'object' ? options.extraContext : {} messages.value.push(createMessage('user', userText, fileNames)) const pendingMessage = createMessage('assistant', options.pendingText || '正在识别并更新右侧核对信息...', [], { meta: ['处理中'] }) messages.value.push(pendingMessage) composerDraft.value = '' attachedFiles.value = [] if (fileInputRef.value) { fileInputRef.value.value = '' } submitting.value = true nextTick(scrollToBottom) try { const user = currentUser.value || {} let ocrPayload = null let ocrSummary = '' let ocrDocuments = [] if (files.length) { try { ocrPayload = await recognizeOcrFiles(files) ocrSummary = buildOcrSummary(ocrPayload) ocrDocuments = normalizeOcrDocuments(ocrPayload) } catch (error) { console.warn('OCR request failed:', error) } } const backendMessage = buildBackendMessage(rawText, fileNames, ocrSummary) const payload = await runOrchestrator({ source: 'user_message', user_id: user.username || user.name || 'anonymous', conversation_id: conversationId.value || null, message: backendMessage, context_json: { role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [], is_admin: Boolean(user.isAdmin), name: user.name || '', role: user.role || '', entry_source: props.entrySource, attachment_names: fileNames, attachment_count: fileNames.length, draft_claim_id: draftClaimId.value || undefined, ocr_summary: ocrSummary, ocr_documents: ocrDocuments, ...(linkedRequest.value ? { request_context: linkedRequest.value } : {}), ...extraContext } }) conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value draftClaimId.value = String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value replaceMessage( pendingMessage.id, createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], { meta: buildMessageMeta(payload, fileNames), citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [], suggestedActions: Array.isArray(payload?.result?.suggested_actions) ? payload.result.suggested_actions : [], draftPayload: payload?.result?.draft_payload || null, reviewPayload: payload?.result?.review_payload || null, riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [] }) ) currentInsight.value = buildAgentInsight(payload, fileNames, filePreviews) } catch (error) { replaceMessage( pendingMessage.id, createMessage( 'assistant', error?.message || '无法连接后端 Orchestrator,请稍后重试。', [], { meta: ['调用失败'] } ) ) currentInsight.value = buildErrorInsight(error, fileNames) } finally { submitting.value = false nextTick(scrollToBottom) } } function openCancelReviewDialog(message) { reviewActionMessageId.value = String(message?.id || '') reviewCancelDialogOpen.value = true } function closeCancelReviewDialog() { if (reviewActionBusy.value) return reviewCancelDialogOpen.value = false reviewActionMessageId.value = '' } function confirmCancelReview() { if (reviewActionBusy.value) return reviewCancelDialogOpen.value = false emit('close') } function openEditReviewDialog(message) { reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields) reviewActionMessageId.value = String(message?.id || '') reviewEditDialogOpen.value = true } function closeEditReviewDialog() { if (reviewActionBusy.value) return reviewEditDialogOpen.value = false reviewEditFields.value = [] reviewActionMessageId.value = '' } async function applyEditedReview() { if (reviewActionBusy.value) return reviewActionBusy.value = true try { const fields = cloneReviewEditFields(reviewEditFields.value) await submitComposer({ rawText: buildReviewCorrectionMessage(fields), userText: '我已修改识别信息,请按最新内容更新。', pendingText: '正在根据修改内容重新识别...', extraContext: { review_action: 'edit_review', review_form_values: buildReviewFormValues(fields) } }) } finally { reviewActionBusy.value = false } closeEditReviewDialog() } async function handleReviewAction(message, action) { const actionType = String(action?.action_type || '').trim() if (!actionType || reviewActionBusy.value) return if (actionType === 'cancel_review') { openCancelReviewDialog(message) return } if (actionType === 'edit_review') { openEditReviewDialog(message) return } if (!['save_draft', 'next_step'].includes(actionType)) { return } reviewActionBusy.value = true try { const fields = cloneReviewEditFields(message?.reviewPayload?.edit_fields) await submitComposer({ rawText: actionType === 'save_draft' ? '请按当前已识别信息先保存草稿,缺失字段后续再补。' : '我已核对右侧识别结果,请进入下一步。', userText: actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。', pendingText: actionType === 'save_draft' ? '正在保存当前草稿...' : '正在进入下一步...', extraContext: { review_action: actionType, review_form_values: buildReviewFormValues(fields) } }) } finally { reviewActionBusy.value = false } } return { emit, aiAvatar, userAvatar, fileInputRef, messageListRef, composerDraft, attachedFiles, submitting, messages, currentInsight, linkedRequest, sourceLabel, canSubmit, showInsightPanel, composerPlaceholder, currentIntentLabel, latestReviewMessage, activeReviewPayload, activeReviewFilePreviews, recognizedSlotCards, missingSlotCards, reviewCancelDialogOpen, reviewEditDialogOpen, reviewActionBusy, reviewEditFields, shortcuts, resolveDocumentPreview, triggerFileUpload, handleFilesChange, runShortcut, submitComposer, handleReviewAction, closeCancelReviewDialog, confirmCancelReview, closeEditReviewDialog, applyEditedReview } } }