import { computed, nextTick, onMounted, ref } from 'vue' import { useSystemState } from '../../composables/useSystemState.js' import { recognizeOcrFiles } from '../../services/ocr.js' import { runOrchestrator } from '../../services/orchestrator.js' const DEFAULT_REQUEST = { id: 'BR240712001', reason: '客户方案汇报', city: '上海', period: '07-08 ~ 07-11', applyTime: '2024-07-07', amount: '¥3,680.00', node: '财务复核', approval: '主管审批中', travel: '已订酒店 / 机票' } 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, riskFlags: [], ...extras } } function sanitizeRequest(request) { if (!request) return { ...DEFAULT_REQUEST } return { id: request.id ?? DEFAULT_REQUEST.id, reason: request.reason ?? DEFAULT_REQUEST.reason, city: request.city ?? DEFAULT_REQUEST.city, period: request.period ?? DEFAULT_REQUEST.period, applyTime: request.applyTime ?? DEFAULT_REQUEST.applyTime, amount: request.amount ?? DEFAULT_REQUEST.amount, node: request.node ?? DEFAULT_REQUEST.node, approval: request.approval ?? DEFAULT_REQUEST.approval, travel: request.travel ?? DEFAULT_REQUEST.travel } } 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 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 buildWelcomeInsight(entrySource, linkedRequest) { return { intent: 'welcome', metricLabel: '运行模式', metricValue: 'Ready', title: entrySource === 'detail' ? `已关联 ${linkedRequest.id}` : '已接入真实智能体对话', summary: entrySource === 'detail' ? '发送消息后会直接调用 Orchestrator,并返回真实的规则引用、建议动作和草稿结果。' : '这里不再使用前端本地意图模拟,所有发送内容都会进入真实 Orchestrator 调度链路。', agent: null } } 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, riskFlags: [], toolCount: 0, failedToolCount: 0, selectedCapabilityCodes: [], statusLabel: '失败', statusTone: 'note' } } } function buildAgentInsight(payload, fileNames = []) { 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, 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 : [], statusLabel, statusTone: resolveStatusTone(payload?.status) } } } export default { name: 'TravelReimbursementCreateView', props: { initialPrompt: { type: String, default: '' }, initialFiles: { type: Array, default: () => [] }, 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 messages = ref([]) const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const currentInsight = ref(buildWelcomeInsight(props.entrySource, linkedRequest.value)) 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') { return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。` } return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。' }) const currentIntentLabel = computed(() => { const labels = { welcome: '等待输入', agent: '真实智能体' } return labels[currentInsight.value.intent] ?? 'AI 处理中' }) const shortcuts = computed(() => { if (props.entrySource === 'detail') { 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待付款多少' } ] }) messages.value = [ createMessage( 'assistant', props.entrySource === 'detail' ? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你的提问会直接进入真实智能体链路。` : '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。' ) ] onMounted(() => { currentInsight.value = 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) } }) 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') { parts.push(`关联单号:${linkedRequest.value.id}`) } return parts.join('\n') } async function submitComposer() { if (!canSubmit.value) return const rawText = composerDraft.value.trim() const files = Array.from(attachedFiles.value) const fileNames = files.map((file) => file.name) const userText = rawText || `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。` messages.value.push(createMessage('user', userText, fileNames)) const pendingMessage = createMessage('assistant', 'Orchestrator 正在处理中...', [], { 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', 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, request_context: linkedRequest.value, attachment_names: fileNames, attachment_count: fileNames.length, ocr_summary: ocrSummary, ocr_documents: ocrDocuments } }) 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, riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [] }) ) currentInsight.value = buildAgentInsight(payload, fileNames) } catch (error) { replaceMessage( pendingMessage.id, createMessage( 'assistant', error?.message || '无法连接后端 Orchestrator,请稍后重试。', [], { meta: ['调用失败'] } ) ) currentInsight.value = buildErrorInsight(error, fileNames) } finally { submitting.value = false nextTick(scrollToBottom) } } return { emit, fileInputRef, messageListRef, composerDraft, attachedFiles, submitting, messages, currentInsight, linkedRequest, sourceLabel, canSubmit, showInsightPanel, composerPlaceholder, currentIntentLabel, shortcuts, triggerFileUpload, handleFilesChange, runShortcut, submitComposer } } }