import { fetchStewardPlan, fetchStewardPlanStream } from '../../services/steward.js' import { REIMBURSEMENT_LIST_PREVIEW_PARAMS, fetchExpenseClaims } from '../../services/reimbursements.js' import { buildStewardPlanMessageText, buildStewardPlanRequest, buildStewardSuggestedActions, normalizeStewardPlan } from '../../views/scripts/stewardPlanModel.js' import { buildRequiredApplicationActions, buildRequiredApplicationMissingText, buildRequiredApplicationSelectionText, filterRequiredApplicationCandidates } from '../../views/scripts/travelReimbursementApplicationLinkModel.js' import { buildInlineAttachmentOcrDetails } from './workbenchAiMessageModel.js' function shouldCheckAiRequiredApplicationGate(prompt) { const compact = String(prompt || '').replace(/\s+/g, '') if (!compact || !/(出差|差旅|部署|实施|支撑|支持|协助|拜访|调研|驻场|上线|验收)/.test(compact)) { return false } if (!/\d{1,2}月\d{1,2}|昨天|前天|上周|上月/.test(compact)) { return false } return !/(申请|报销|草稿|提交|审批|保存|发起|创建)/.test(compact) } function serializeRequiredApplicationCandidate(candidate = {}) { return { id: String(candidate.id || '').trim(), claim_no: String(candidate.claim_no || '').trim(), reason: String(candidate.reason || '').trim(), location: String(candidate.location || '').trim(), business_time: String(candidate.business_time || '').trim(), status_label: String(candidate.status_label || '').trim() } } function resolveRequiredApplicationGateContinuationFlow(normalizedPlan) { if (String(normalizedPlan?.pendingFlowConfirmation?.status || '').trim() !== 'pending') { return null } const flows = Array.isArray(normalizedPlan?.candidateFlows) ? normalizedPlan.candidateFlows : [] const applicationFlow = flows.find((flow) => flow.flowId === 'travel_application') if (flows.length === 1 && applicationFlow && /先发起出差申请/.test(applicationFlow.label)) { return applicationFlow } return flows.find((flow) => ( flow.flowId === 'travel_reimbursement' && /关联已有申请单/.test(flow.label) )) || null } function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) { const baseText = buildStewardPlanMessageText({ planStatus: normalizedPlan?.planStatus, nextAction: normalizedPlan?.nextAction, summary: normalizedPlan?.summary, pendingFlowConfirmation: normalizedPlan?.pendingFlowConfirmation, candidateFlows: normalizedPlan?.candidateFlows }) const contextText = String(baseText || '') .split(/\n\n1\. \*\*/)[0] .trim() .replace('### 需要先确认流程方向', '### 我已先查询申请单') 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 } function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') { if (flow.flowId === 'travel_application') { return [{ label: '确认发起出差申请', description: '确认后生成完整申请表,并预填已识别的时间、地点和事由。', icon: 'mdi mdi-file-plus-outline', action_type: 'ai_application_start_inline', payload: { expense_type: 'travel', expense_type_label: '差旅费', carry_text: prompt } }] } if (flow.flowId === 'travel_reimbursement') { return [{ label: '确认关联已有申请单', description: '确认后查询您名下可关联的差旅申请单,并进入关联步骤。', icon: 'mdi mdi-link-variant', action_type: 'steward_confirm_flow', payload: { steward_confirm_flow: true, flow_id: 'travel_reimbursement', expense_type: 'travel', expense_type_label: '差旅费', carry_text: prompt } }] } return [] } function normalizeStreamThinkingEvent(event = {}) { const data = event?.data && typeof event.data === 'object' ? event.data : {} const eventId = String(data.event_id || data.eventId || data.stage || `thinking-${Date.now()}`).trim() return { eventId, stage: String(data.stage || '').trim(), title: String(data.title || '小财管家正在分析').trim(), content: String(data.content || '').trim(), status: String(data.status || 'running').trim() || 'running' } } export function useWorkbenchAiStewardFlow({ activeConversationTitle, collectAiModeReceiptContext, conversationId, conversationMessages, createInlineMessage, currentUser, deleteAiWorkbenchConversation, emit, handleAiDocumentQueryIntent, inlineConversationAutoScrollPinned, persistCurrentConversation, replaceInlineMessage, resolveInlineThinkingEvents, scrollInlineConversationToBottom, sending, stewardState, streamInlineAssistantContent, updateInlineMessageContent, appendInlineMessageContent, toast }) { async function attachAiRequiredApplicationGate(planRequest, prompt) { if (!shouldCheckAiRequiredApplicationGate(prompt)) { return planRequest } try { const claims = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS) const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {}) planRequest.context_json = { ...(planRequest.context_json || {}), required_application_gate: { ...((planRequest.context_json || {}).required_application_gate || {}), travel: { checked: true, candidate_count: candidates.length, candidates: candidates.slice(0, 5).map((candidate) => serializeRequiredApplicationCandidate(candidate)) } } } } catch (error) { console.warn('AI mode required application lookup failed:', error) planRequest.context_json = { ...(planRequest.context_json || {}), required_application_gate: { ...((planRequest.context_json || {}).required_application_gate || {}), travel: { checked: false, query_failed: true } } } } return planRequest } function handleInlineStewardStreamEvent(messageId, event) { const message = conversationMessages.value.find((item) => item.id === messageId) if (!message) { return } if (event?.event === 'answer_delta') { const data = event?.data && typeof event.data === 'object' ? event.data : {} const shouldAutoScroll = inlineConversationAutoScrollPinned.value appendInlineMessageContent(message, data.delta || data.content || data.text || '') message.stewardPlan = { ...(message.stewardPlan || {}), streamStatus: 'streaming' } scrollInlineConversationToBottom({ force: shouldAutoScroll }) return } if (event?.event !== 'thinking') { return } const nextEvent = normalizeStreamThinkingEvent(event) const shouldAutoScroll = inlineConversationAutoScrollPinned.value const currentPlan = message.stewardPlan || {} const currentEvents = Array.isArray(currentPlan.thinkingEvents) ? currentPlan.thinkingEvents : [] const eventIndex = currentEvents.findIndex((item) => item.eventId && item.eventId === nextEvent.eventId) const nextEvents = eventIndex >= 0 ? currentEvents.map((item, index) => (index === eventIndex ? { ...item, ...nextEvent } : item)) : [...currentEvents, nextEvent] message.stewardPlan = { ...currentPlan, thinkingEvents: nextEvents, streamStatus: 'streaming' } scrollInlineConversationToBottom({ force: shouldAutoScroll }) } async function fetchInlineStewardPlan(messageId, payload) { try { return await fetchStewardPlanStream( payload, { onEvent: (event) => handleInlineStewardStreamEvent(messageId, event) }, { idleTimeoutMs: 90000, timeoutMessage: '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。' } ) } catch (error) { if (String(error?.message || '').includes('流式服务')) { return fetchStewardPlan(payload, { timeoutMs: 75000, timeoutMessage: '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。' }) } throw error } } async function requestInlineAssistantReply(prompt, entry = {}, files = []) { let shouldAutoScrollOnFinish = true const pendingMessage = createInlineMessage('assistant', '', { pending: true, stewardPlan: { streamStatus: 'streaming', thinkingEvents: [ { eventId: 'init', title: '小财管家正在接入业务流程', content: '正在识别您的意图、上下文和附件信息。', status: 'running' } ] } }) conversationMessages.value.push(pendingMessage) scrollInlineConversationToBottom() try { if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) { shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value return } const receiptContext = await collectAiModeReceiptContext(files) const attachmentOcrDetails = buildInlineAttachmentOcrDetails(receiptContext, files) const planRequest = buildStewardPlanRequest({ rawText: prompt, files, currentUser: currentUser.value || {}, conversationId: conversationId.value, stewardState: stewardState.value }) planRequest.context_json = { ...planRequest.context_json, entry_source: 'workbench_ai_inline', source: entry.source || 'workbench', attachment_names: receiptContext.attachmentNames, attachment_count: receiptContext.attachmentCount, ocr_summary: receiptContext.ocrSummary, ocr_documents: receiptContext.ocrDocuments, ocr_source_file_names: receiptContext.ocrSourceFileNames, ...(receiptContext.ocrError ? { ocr_error: receiptContext.ocrError } : {}) } await attachAiRequiredApplicationGate(planRequest, prompt) const plan = await fetchInlineStewardPlan(pendingMessage.id, planRequest) const normalizedPlan = normalizeStewardPlan(plan, { visibleThinkingEventCount: Number.MAX_SAFE_INTEGER, initialSummaryOnly: true }) const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage) const nextThinkingEvents = normalizedPlan.thinkingEvents.length ? normalizedPlan.thinkingEvents : previousThinkingEvents.map((item) => ({ ...item, status: 'completed' })) const previousConversationId = conversationId.value const nextConversationId = String(normalizedPlan.conversationId || '').trim() if (nextConversationId) { conversationId.value = nextConversationId emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value }) if (previousConversationId && previousConversationId !== nextConversationId) { deleteAiWorkbenchConversation(currentUser.value || {}, previousConversationId) } } if (normalizedPlan.stewardState) { stewardState.value = normalizedPlan.stewardState } const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan) const finalMessageText = requiredApplicationContinuationFlow ? buildAiRequiredApplicationGateAutoMessage(normalizedPlan, requiredApplicationContinuationFlow) : buildStewardPlanMessageText(plan) const hasServerStreamedContent = Boolean(String(pendingMessage.content || '').trim()) if (!hasServerStreamedContent) { await streamInlineAssistantContent(pendingMessage.id, finalMessageText) } shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value replaceInlineMessage( pendingMessage.id, createInlineMessage('assistant', finalMessageText, { id: pendingMessage.id, stewardPlan: { ...normalizedPlan, thinkingEvents: nextThinkingEvents, streamStatus: 'completed' }, suggestedActions: requiredApplicationContinuationFlow ? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt) : buildStewardSuggestedActions(plan), attachmentOcrDetails }) ) persistCurrentConversation() } catch (error) { shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value replaceInlineMessage( pendingMessage.id, createInlineMessage( 'assistant', error?.message || '小财管家暂时无法完成规划,请稍后再试。', { id: pendingMessage.id, stewardPlan: { streamStatus: 'failed', thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({ ...item, status: 'failed' })) } } ) ) toast(error?.message || '小财管家暂时无法完成规划。') persistCurrentConversation() } finally { sending.value = false scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish }) } } return { buildRequiredApplicationActions, buildRequiredApplicationMissingText, buildRequiredApplicationSelectionText, filterRequiredApplicationCandidates, requestInlineAssistantReply } }