import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useSystemState } from '../useSystemState.js' import { useToast } from '../useToast.js' import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js' import { fetchSettings } from '../../services/settings.js' import { calculateTravelReimbursement, fetchExpenseClaimDetail } from '../../services/reimbursements.js' import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js' import { deleteAiWorkbenchConversation, loadAiWorkbenchConversationHistory, markAiWorkbenchConversationDocumentDeleted, saveAiWorkbenchConversation } from '../../utils/aiWorkbenchConversationStore.js' import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js' import { buildAiDocumentDetailRequest, parseAiApplicationDetailHref, parseAiDocumentDetailHref } from '../../utils/aiDocumentDetailReference.js' import { AI_MODE_ACTION_ITEMS, shouldRunAiAttachmentAutoAssociation } from './workbenchAiComposerModel.js' import { createWorkbenchAiMessageRuntime, formatMessageTime } from './workbenchAiMessageModel.js' import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js' import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js' import { useWorkbenchAiApplicationPreviewFlow } from './useWorkbenchAiApplicationPreviewFlow.js' 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' import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js' import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js' import { useWorkbenchAiCommandIntents } from './useWorkbenchAiCommandIntents.js' import { buildRuleFallbackWorkbenchAiIntentPlan, isLowConfidenceTravelApplicationPlan, normalizeWorkbenchAiIntentPlan, resolveExecutableTravelApplicationPlan, shouldRequestWorkbenchAiIntentPlan } from './workbenchAiIntentPlannerModel.js' import { buildInitialModelPlanningThinkingEvents, buildModelPlanningProgressSchedule, mergeWorkbenchAiThinkingEvents } from './workbenchAiPlanningThinkingModel.js' const AI_SEARCH_CONVERSATION_ID = 'ai-search' const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6 const INLINE_ANSWER_STREAM_DELAY_MS = 24 const INLINE_AUTO_SCROLL_THRESHOLD = 96 const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260 export function usePersonalWorkbenchAiMode(props, emit) { const { currentUser } = useSystemState() const { toast } = useToast() const assistantDraft = ref('') const assistantInputRef = ref(null) const fileInputRef = ref(null) const conversationScrollRef = ref(null) const inlineConversationAutoScrollPinned = ref(true) const selectedFiles = ref([]) const systemSettings = ref(null) const conversationStarted = ref(false) const conversationMessages = ref([]) const conversationId = ref('') const activeConversationTitle = ref('') const sending = ref(false) const stewardState = ref(null) const aiExpenseDraft = ref(null) const thinkingExpandedMessageIds = ref(new Set()) const thinkingCollapsedMessageIds = ref(new Set()) const attachmentOcrExpandedMessageIds = ref(new Set()) const deleteDialogOpen = ref(false) const applicationSubmitConfirmOpen = ref(false) const applicationSubmitConfirmContext = ref(null) const aiAttachmentAssociationRuntime = new Map() const messageRuntime = createWorkbenchAiMessageRuntime() const { createAiAttachmentAssociationId, createInlineMessage, normalizeRuntimeMessage, serializeRuntimeMessage } = messageRuntime const { applicationPreviewEditor, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorDateMax, resolveApplicationPreviewEditorDateMin, resolveApplicationPreviewEditorOptions, refreshApplicationPreviewEstimate, isApplicationPreviewEditing, openApplicationPreviewEditor, commitApplicationPreviewEditor, cancelApplicationPreviewEditor, handleApplicationPreviewEditorKeydown } = useApplicationPreviewEditor({ persistSessionState: () => persistCurrentConversation(), toast, calculateTravelReimbursement, currentUser }) const { workbenchDatePickerOpen, workbenchDateMode, workbenchSingleDate, workbenchRangeStartDate, workbenchRangeEndDate, workbenchDateTagLabel, workbenchCanApplyDateSelection, clearWorkbenchDateSelection, toggleWorkbenchDatePicker, closeWorkbenchDatePicker, setWorkbenchDateMode, handleWorkbenchDatePickerOutside, applyWorkbenchDateSelection, handleWorkbenchDateInputChange, removeWorkbenchDateTag, buildWorkbenchPromptText } = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput }) const aiModeActionItems = AI_MODE_ACTION_ITEMS const displayUserName = computed(() => { const user = currentUser.value || {} return String(user.name || user.username || '同事').trim() || '同事' }) const displayModelName = computed(() => { const llmForm = systemSettings.value?.llmForm if (!llmForm) return 'Axiom Ultra 3.1' const model = llmForm.mainModel || '' const provider = llmForm.mainProvider || '' if (!model) return 'Axiom Ultra 3.1' return provider ? `${provider} / ${model}` : model }) const modelSelectorTitle = computed(() => { const llmForm = systemSettings.value?.llmForm if (!llmForm) return '当前模型:Axiom Ultra 3.1' const model = llmForm.mainModel || 'Axiom Ultra 3.1' const provider = llmForm.mainProvider || '' return provider ? `当前模型:${provider} / ${model}` : `当前模型:${model}` }) const filesFlow = useWorkbenchAiComposerFiles({ fileInputRef, focusAiModeInput, isInputLocked: () => isAiModeInputLocked.value, resolveInputLockedMessage: () => resolveAiModeInputLockMessage(), selectedFiles, toast }) const documentQueryFlow = useWorkbenchAiDocumentQueryFlow({ conversationMessages, createInlineMessage, inlineConversationAutoScrollPinned, persistCurrentConversation, replaceInlineMessage, scrollInlineConversationToBottom }) const commandIntents = useWorkbenchAiCommandIntents({ activateInlineConversation, activeConversationTitle, assistantDraft, clearAiModeFiles: filesFlow.clearAiModeFiles, closeWorkbenchDatePicker, conversationId, conversationMessages, createInlineMessage, documentQueryFlow, inlineConversationAutoScrollPinned, persistCurrentConversation, removeWorkbenchDateTag, scrollInlineConversationToBottom, searchConversationId: AI_SEARCH_CONVERSATION_ID, sending }) const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({ aiAttachmentAssociationRuntime, conversationId, conversationMessages, createAiAttachmentAssociationId, createInlineMessage, inlineConversationAutoScrollPinned, persistCurrentConversation, replaceInlineMessage, scrollInlineConversationToBottom, sending, streamOrSetInlineAssistantContent, notifyRequestUpdated: (payload) => emit('request-updated', payload), toast }) const filePreview = useWorkbenchAiFilePreview({ attachmentFlow, conversationStarted, scrollInlineConversationToBottom, selectedFiles }) const { hasInlineAttachmentOcrDetails, hasInlineThinking, isInlineAttachmentOcrExpanded, isInlineThinkingExpanded, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, toggleInlineAttachmentOcrDetails, toggleInlineThinking } = useWorkbenchAiMessageExpansion({ attachmentOcrExpandedMessageIds, inlineConversationAutoScrollPinned, scrollInlineConversationToBottom, thinkingCollapsedMessageIds, thinkingExpandedMessageIds }) const applicationFlow = useWorkbenchAiApplicationPreviewFlow({ activateInlineConversation, applicationPreviewEditor, applicationSubmitConfirmContext, applicationSubmitConfirmOpen, assistantDraft, cancelApplicationPreviewEditor, clearAiModeFiles: filesFlow.clearAiModeFiles, closeWorkbenchDatePicker, commitApplicationPreviewEditor, conversationId, conversationMessages, conversationStarted, createInlineMessage, currentUser, handleApplicationPreviewEditorKeydown, inlineConversationAutoScrollPinned, isApplicationPreviewEditing, openApplicationPreviewEditor, persistCurrentConversation, pushInlineApplicationActionUserMessage, pushInlineUserMessage, refreshApplicationPreviewEstimate, removeWorkbenchDateTag, replaceInlineMessage, resolveApplicationPreviewEditorDateMax, resolveApplicationPreviewEditorDateMin, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveInlineThinkingEvents, resolveLatestInlineUserPrompt, scrollInlineConversationToBottom, sending, toast }) const expenseFlow = useWorkbenchAiExpenseFlow({ activateInlineConversation, aiExpenseDraft, assistantDraft, clearAiModeFiles: filesFlow.clearAiModeFiles, closeWorkbenchDatePicker, conversationMessages, conversationStarted, createInlineMessage, currentUser, persistCurrentConversation, pushInlineUserMessage, replaceInlineMessage, removeWorkbenchDateTag, resolveLatestInlineUserPrompt, scrollInlineConversationToBottom, startAiApplicationPreview: applicationFlow.startAiApplicationPreview }) const actionRouter = useWorkbenchAiActionRouter({ aiExpenseDraft, applicationFlow, assistantDraft, attachmentFlow, conversationMessages, createInlineMessage, emit, expenseFlow, focusAiModeInput, hasInlineAttachmentOcrDetails, persistCurrentConversation, replaceInlineMessage, resolveLatestInlineUserPrompt, scrollInlineConversationToBottom, selectedFiles, startInlineConversation, toast, toggleInlineAttachmentOcrDetails }) const sessionCommands = useWorkbenchAiSessionCommands({ activeConversationTitle, attachmentOcrExpandedMessageIds, conversationId, conversationMessages, conversationStarted, createInlineMessage, currentUser, deleteAiWorkbenchConversation, emit, focusAiModeInput, inlineConversationAutoScrollPinned, normalizeRuntimeMessage, refreshConversationHistory, resetInlineConversationState, scrollInlineConversationToBottom, stewardState, thinkingCollapsedMessageIds, thinkingExpandedMessageIds, toast }) const messageActions = useWorkbenchAiMessageActions({ assistantDraft, focusAiModeInput, persistCurrentConversation, toast }) const stewardFlow = useWorkbenchAiStewardFlow({ activeConversationTitle, collectAiModeReceiptContext: attachmentFlow.collectAiModeReceiptContext, conversationId, conversationMessages, createInlineMessage, currentUser, deleteAiWorkbenchConversation, emit, handleAiDocumentQueryIntent: documentQueryFlow.handleAiDocumentQueryIntent, inlineConversationAutoScrollPinned, persistCurrentConversation, replaceInlineMessage, resolveInlineThinkingEvents, scrollInlineConversationToBottom, sending, stewardState, streamInlineAssistantContent, updateInlineMessageContent, appendInlineMessageContent, toast }) const applicationPreviewEstimatePending = computed(() => ( conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message)) )) 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 && !hasAiModeReceiptRecognitionFailure.value && ( Boolean(assistantDraft.value.trim()) || selectedFiles.value.length > 0 || Boolean(workbenchDateTagLabel.value) ) )) async function loadSystemSettings() { try { systemSettings.value = await fetchSettings() } catch { systemSettings.value = { llmForm: {} } } } function focusAiModeInput() { nextTick(() => { assistantInputRef.value?.focus() }) } function setAssistantInputRef(element) { assistantInputRef.value = element } function isInlineConversationNearBottom() { const el = conversationScrollRef.value if (!el) { return true } return el.scrollHeight - el.clientHeight - el.scrollTop <= INLINE_AUTO_SCROLL_THRESHOLD } function handleInlineConversationScroll() { inlineConversationAutoScrollPinned.value = isInlineConversationNearBottom() } function forceInlineConversationToBottom() { const el = conversationScrollRef.value if (el) { el.scrollTop = el.scrollHeight inlineConversationAutoScrollPinned.value = true } } function scrollInlineConversationToBottom(options = {}) { const shouldScroll = options.force !== false nextTick(() => { if (!shouldScroll) { return } forceInlineConversationToBottom() window.requestAnimationFrame(() => { forceInlineConversationToBottom() }) window.setTimeout(() => { if (inlineConversationAutoScrollPinned.value) { forceInlineConversationToBottom() } }, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS) }) } function scrollInlineConversationToTop() { nextTick(() => { const el = conversationScrollRef.value if (el) { inlineConversationAutoScrollPinned.value = false el.scrollTo({ top: 0, behavior: 'smooth' }) } }) } function updateInlineMessageContent(message, content) { if (!message) { return } message.content = String(content || '') message.paragraphs = String(message.content || '') .split(/\n{2,}|\n/) .map((item) => item.trim()) .filter(Boolean) } function appendInlineMessageContent(message, delta) { const nextDelta = String(delta || '') if (!nextDelta) { return } updateInlineMessageContent(message, `${message.content || ''}${nextDelta}`) } function waitInlineAnswerStreamFrame() { return new Promise((resolve) => { window.setTimeout(resolve, INLINE_ANSWER_STREAM_DELAY_MS) }) } async function streamInlineAssistantContent(messageId, content) { const targetContent = String(content || '').trim() let streamedContent = '' for (let index = 0; index < targetContent.length; index += INLINE_ANSWER_STREAM_CHUNK_SIZE) { const message = conversationMessages.value.find((item) => item.id === messageId) if (!message || !message.pending) { return } const shouldAutoScroll = inlineConversationAutoScrollPinned.value streamedContent += targetContent.slice(index, index + INLINE_ANSWER_STREAM_CHUNK_SIZE) updateInlineMessageContent(message, streamedContent) scrollInlineConversationToBottom({ force: shouldAutoScroll }) await waitInlineAnswerStreamFrame() } } async function streamOrSetInlineAssistantContent(messageId, content) { const targetContent = String(content || '').trim() if (//.test(targetContent)) { const message = conversationMessages.value.find((item) => item.id === messageId) if (message?.pending) { updateInlineMessageContent(message, targetContent) scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) } return } await streamInlineAssistantContent(messageId, targetContent) } function refreshConversationHistory() { const history = loadAiWorkbenchConversationHistory(currentUser.value || {}) emit('conversation-history-change', history) return history } function isPersistableInlineConversation() { return Boolean( conversationId.value && conversationId.value !== AI_SEARCH_CONVERSATION_ID && conversationMessages.value.length ) } function persistCurrentConversation() { if (!isPersistableInlineConversation()) { refreshConversationHistory() return [] } const history = saveAiWorkbenchConversation(currentUser.value || {}, { id: conversationId.value, conversationId: conversationId.value, title: activeConversationTitle.value, source: 'workbench', sessionType: 'steward', stewardState: stewardState.value, messages: conversationMessages.value.map((message) => serializeRuntimeMessage(message)) }) emit('conversation-history-change', history) return history } function resetInlineConversationState() { conversationStarted.value = false conversationMessages.value = [] conversationId.value = '' stewardState.value = null activeConversationTitle.value = '' assistantDraft.value = '' thinkingExpandedMessageIds.value = new Set() thinkingCollapsedMessageIds.value = new Set() attachmentOcrExpandedMessageIds.value = new Set() deleteDialogOpen.value = false applicationSubmitConfirmOpen.value = false applicationSubmitConfirmContext.value = null clearWorkbenchDateSelection() filesFlow.clearAiModeFiles() } function replaceInlineMessage(id, nextMessage) { const index = conversationMessages.value.findIndex((item) => item.id === id) if (index === -1) { conversationMessages.value.push(nextMessage) return } conversationMessages.value.splice(index, 1, nextMessage) } function activateInlineConversation(options = {}) { conversationStarted.value = true if (!conversationId.value) { conversationId.value = options.id || `inline-${Date.now()}` } activeConversationTitle.value = options.title || activeConversationTitle.value || '新对话' emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value }) } function renderInlineConversationHtml(content) { return renderAiConversationHtml(content) } function isUnavailableDocumentDetailError(error) { const message = String(error?.message || '').trim() return /not\s*found|不存在|已删除|不可访问|无权|forbidden|404/i.test(message) } function resolveDocumentDetailLookupClaimId(detailRequest = {}) { return String(detailRequest.claimId || detailRequest.claim_id || '').trim() } function markActiveAiDocumentDetailLinkDeleted(detailRequest = {}) { const nextHistory = markAiWorkbenchConversationDocumentDeleted(currentUser.value || {}, { claimId: detailRequest.claimId, claim_id: detailRequest.claimId, claimNo: detailRequest.claimNo, claim_no: detailRequest.claimNo, documentNo: detailRequest.documentNo, document_no: detailRequest.documentNo, id: detailRequest.id }) emit('conversation-history-change', nextHistory) const activeConversation = nextHistory.find((item) => ( String(item.id || item.conversationId || '').trim() === String(conversationId.value || '').trim() )) if (activeConversation?.messages?.length) { conversationMessages.value = activeConversation.messages.map((message) => normalizeRuntimeMessage(message)) scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) } } async function ensureAiDocumentDetailStillAvailable(detailRequest = {}) { const claimId = resolveDocumentDetailLookupClaimId(detailRequest) if (!claimId) { return true } try { await fetchExpenseClaimDetail(claimId) return true } catch (error) { if (!isUnavailableDocumentDetailError(error)) { console.warn('AI document detail availability check failed, continuing navigation:', error) return true } markActiveAiDocumentDetailLinkDeleted(detailRequest) toast('该单据已经删除或不可访问,已将这条历史入口标记为不可查看。') return false } } function buildInlinePromptText(rawPrompt, files = []) { const prompt = buildWorkbenchPromptText(rawPrompt) 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 isModelPlannedReimbursementTask(modelPlan = {}) { const tasks = Array.isArray(modelPlan?.tasks) ? modelPlan.tasks : [] return tasks.some((task) => { const taskType = String(task?.task_type || task?.taskType || '').trim() const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim() return taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant' }) } function updateModelPlanningThinkingEvent(messageId, event) { const message = conversationMessages.value.find((item) => item.id === messageId) if (!message) { return } const currentPlan = message.stewardPlan || {} message.stewardPlan = { ...currentPlan, streamStatus: 'streaming', thinkingEvents: mergeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(message), [event]) } persistCurrentConversation() scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) } function startModelPlanningProgressUpdates(messageId) { const timerIds = buildModelPlanningProgressSchedule().map(({ delayMs, event }) => ( globalThis.setTimeout(() => { updateModelPlanningThinkingEvent(messageId, event) }, delayMs) )) return () => { timerIds.forEach((timerId) => globalThis.clearTimeout(timerId)) } } function startModelPlanningConversation(cleanPrompt, entry = {}) { if (conversationId.value === AI_SEARCH_CONVERSATION_ID) { conversationId.value = '' conversationMessages.value = [] activeConversationTitle.value = '' } activateInlineConversation({ title: entry.label || cleanPrompt.slice(0, 18) || '新对话' }) inlineConversationAutoScrollPinned.value = true conversationMessages.value.push(createInlineMessage('user', cleanPrompt)) assistantDraft.value = '' removeWorkbenchDateTag() closeWorkbenchDatePicker() filesFlow.clearAiModeFiles() const pendingMessage = createInlineMessage('assistant', '正在识别意图,准备拆解申请、报销和附件任务。', { pending: true, stewardPlan: { streamStatus: 'streaming', thinkingEvents: buildInitialModelPlanningThinkingEvents() } }) conversationMessages.value.push(pendingMessage) scrollInlineConversationToBottom() persistCurrentConversation() return pendingMessage } function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) { void applicationFlow.startAiApplicationPreview( travelApplicationRequest.expenseType, travelApplicationRequest.expenseTypeLabel, travelApplicationRequest.sourceText, { userMessage: travelApplicationRequest.sourceText, pushUserMessage: !plannerPendingMessage, pendingMessageId: plannerPendingMessage?.id, ontologyFields: travelApplicationRequest.ontologyFields, autoSubmit: travelApplicationRequest.autoSubmit, autoSaveDraft: travelApplicationRequest.autoSaveDraft, requestedSubmit: travelApplicationRequest.requestedSubmit, submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation } ) } function startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, plan, plannerPendingMessage) { const confirmText = buildLowConfidenceTravelApplicationConfirmationText(travelApplicationRequest, plan) const confirmAction = { label: '确认发起出差申请', description: '根据上面识别到的信息生成出差申请预览。', icon: 'mdi mdi-check-circle-outline', action_type: 'ai_application_confirm_intent', payload: { ontologyFields: travelApplicationRequest.ontologyFields, sourceText: travelApplicationRequest.sourceText, autoSubmit: travelApplicationRequest.autoSubmit, autoSaveDraft: travelApplicationRequest.autoSaveDraft, requestedSubmit: travelApplicationRequest.requestedSubmit, submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation } } replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, { id: plannerPendingMessage.id, suggestedActions: [confirmAction], stewardPlan: { streamStatus: 'completed', thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' })) } })) persistCurrentConversation() scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) } function buildLowConfidenceTravelApplicationConfirmationText(request, plan) { const fields = request.ontologyFields || {} const summaryParts = [] if (fields.time_range) { summaryParts.push(`时间:${fields.time_range}`) } if (fields.location) { summaryParts.push(`地点:${fields.location}`) } if (fields.reason) { summaryParts.push(`事由:${fields.reason}`) } if (fields.transport_mode) { summaryParts.push(`交通:${fields.transport_mode}`) } const summary = summaryParts.length ? `\n\n${summaryParts.join(';')}` : '' const confidenceNote = Number.isFinite(Number(plan?.confidence)) ? `(模型识别置信度较低,约 ${Math.round(Number(plan.confidence) * 100)}%)` : '(模型识别置信度较低)' return [ '### 需要确认:您是要发起出差申请吗?', '', `小财管家把这句话理解成了“发起差旅申请”${confidenceNote},为避免误操作,先请您确认。`, summary, '', '点击下方「确认发起出差申请」即可继续;如果理解有误,请补充说明您的实际需求。' ].filter(Boolean).join('\n') } async function executeModelPlannedWorkbenchIntent(cleanPrompt, entry = {}, files = []) { let intentPlan = null let modelPlan = null const plannerPendingMessage = startModelPlanningConversation(cleanPrompt, entry) const stopPlanningProgressUpdates = startModelPlanningProgressUpdates(plannerPendingMessage.id) sending.value = true try { modelPlan = await stewardFlow.resolveInlineExecutionPlan(cleanPrompt, entry, files, { pendingMessageId: plannerPendingMessage.id }) intentPlan = normalizeWorkbenchAiIntentPlan(modelPlan, { prompt: cleanPrompt }) } catch (error) { console.warn('AI mode intent planner failed, using local fallback:', error) const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan(cleanPrompt) const ruleRequest = resolveExecutableTravelApplicationPlan(rulePlan) if (ruleRequest) { sending.value = false startModelPlannedApplicationPreview(ruleRequest, plannerPendingMessage) return } } finally { stopPlanningProgressUpdates() sending.value = false } const travelApplicationRequest = resolveExecutableTravelApplicationPlan(intentPlan) if (travelApplicationRequest) { if (isLowConfidenceTravelApplicationPlan(intentPlan)) { startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, intentPlan, plannerPendingMessage) return } startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage) return } if (isModelPlannedReimbursementTask(modelPlan) || isReimbursementCreationIntent(cleanPrompt)) { replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', '已识别为报销任务,正在进入报销流程。', { id: plannerPendingMessage.id, stewardPlan: { streamStatus: 'completed', thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' })) } })) void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || cleanPrompt) return } void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files, { pendingMessageId: plannerPendingMessage.id }) } async function handleAiAnswerMarkdownClick(event) { const target = event?.target const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]') if (!link) { return } const href = link.getAttribute('href') const detailReference = parseAiDocumentDetailHref(href) || parseAiApplicationDetailHref(href) if (!detailReference) { return } event.preventDefault() event.stopPropagation() const detailRequest = buildAiDocumentDetailRequest(detailReference) if (!(await ensureAiDocumentDetailStillAvailable(detailRequest))) { return } emit('open-document', detailRequest) } function startInlineConversation(prompt, entry = {}, files = []) { if (!ensureAiModeCanStartConversation()) { return } const cleanPrompt = buildInlinePromptText(prompt, files) if (!cleanPrompt || sending.value) { return } if (commandIntents.handleInlineDraftDeletionIntent(cleanPrompt, entry)) { return } if (applicationFlow.handleInlineApplicationPreviewTextAction(cleanPrompt, applicationPreviewEstimatePending)) { return } if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) { expenseFlow.advanceAiExpenseDraft(cleanPrompt, files) return } if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) { void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files) return } if (isReimbursementCreationIntent(cleanPrompt)) { void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || '我要报销') return } if (conversationId.value === AI_SEARCH_CONVERSATION_ID) { conversationId.value = '' conversationMessages.value = [] activeConversationTitle.value = '' } sending.value = true activateInlineConversation({ title: entry.label || cleanPrompt.slice(0, 18) || '新对话' }) inlineConversationAutoScrollPinned.value = true conversationMessages.value.push(createInlineMessage('user', cleanPrompt)) assistantDraft.value = '' removeWorkbenchDateTag() closeWorkbenchDatePicker() filesFlow.clearAiModeFiles() scrollInlineConversationToBottom() persistCurrentConversation() if (shouldRunAiAttachmentAutoAssociation(entry, files, cleanPrompt)) { void attachmentFlow.requestAiAttachmentAssociationReply(cleanPrompt, entry, files) return } void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files) } function submitAiModePrompt() { if (!ensureAiModeCanStartConversation()) { return } if (!canSubmitAiModePrompt.value) { toast('请输入需求后再发送。') focusAiModeInput() return } startInlineConversation(assistantDraft.value, { source: 'workbench', sessionType: 'steward' }, Array.from(selectedFiles.value)) } function runAiModeAction(item) { if (!ensureAiModeCanStartConversation()) { return } if (String(item?.label || '').trim() === '发起报销') { void expenseFlow.startAiReimbursementAssociationGate(item.prompt, item.label) return } startInlineConversation(item.prompt, item, Array.from(selectedFiles.value)) } function regenerateLastReply() { const lastUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user') if (!lastUserMessage || sending.value) { return } const lastAssistantIndex = conversationMessages.value.map((message) => message.role).lastIndexOf('assistant') if (lastAssistantIndex >= 0) { conversationMessages.value.splice(lastAssistantIndex, 1) } sending.value = true void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, []) } function pushInlineUserMessage(text) { conversationMessages.value.push(createInlineMessage('user', String(text || '').trim())) } function pushInlineApplicationActionUserMessage(text) { pushInlineUserMessage(text) assistantDraft.value = '' removeWorkbenchDateTag() closeWorkbenchDatePicker() filesFlow.clearAiModeFiles() } function resolveLatestInlineUserPrompt() { return String([...conversationMessages.value].reverse().find((message) => message.role === 'user')?.content || '').trim() } function handleVoiceInput() { if (!ensureAiModeCanStartConversation()) { return } toast('语音输入正在准备中,您可以先输入文字需求。') focusAiModeInput() } watch( () => props.sidebarCommand?.seq, () => { const command = props.sidebarCommand || {} if (command.type === 'new-chat') { sessionCommands.startNewInlineConversation() return } if (command.type === 'search-chat') { sessionCommands.openInlineSearchConversation(activateInlineConversation) return } if (command.type === 'open-recent') { sessionCommands.openInlineRecentConversation(command.payload || {}) attachmentFlow.resumePendingAiAttachmentAssociationJobs() expenseFlow.resumePendingLinkedReimbursementDraftJobs() } } ) onMounted(() => { loadSystemSettings() refreshConversationHistory() attachmentFlow.resumePendingAiAttachmentAssociationJobs() expenseFlow.resumePendingLinkedReimbursementDraftJobs() document.addEventListener('click', handleWorkbenchDatePickerOutside) }) onBeforeUnmount(() => { document.removeEventListener('click', handleWorkbenchDatePickerOutside) }) return { activeConversationTitle, aiModeActionItems, aiModeInputLockMessage, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantInputRef, assistantDraft, canShowInlineSuggestedActions: applicationFlow.canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation: () => sessionCommands.cancelDeleteConversation(deleteDialogOpen), cancelInlineApplicationSubmitConfirm: applicationFlow.cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor: applicationFlow.commitInlineApplicationPreviewEditor, confirmDeleteConversation: () => sessionCommands.confirmDeleteConversation(deleteDialogOpen), confirmInlineApplicationSubmit: applicationFlow.confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange: filesFlow.handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown: applicationFlow.handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction: actionRouter.handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing: applicationFlow.isApplicationPreviewEditing, isApplicationPreviewEstimatePending: applicationFlow.isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled: applicationFlow.isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback: messageActions.markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor: applicationFlow.openApplicationPreviewEditor, quoteInlineMessage: messageActions.quoteInlineMessage, regenerateLastReply, removeAiModeFile: filesFlow.removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen), resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax: applicationFlow.resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin: applicationFlow.resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, ...filePreview, sending, setAssistantInputRef, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload: filesFlow.triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText: applicationFlow.buildInlineApplicationPreviewFooterText, copyInlineMessage: messageActions.copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange } }