import { buildApplicationPreviewFooterMessage, buildApplicationPreviewRows, buildLocalApplicationPreviewMessage, normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js' import { buildAiApplicationPrecheck, buildAiApplicationSubmitConflictMessage, isAiApplicationPrecheckBlocking } from '../../utils/aiApplicationPrecheckModel.js' import { fetchExpenseClaims } from '../../services/reimbursements.js' import { AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT, runAiApplicationPreviewAction } from '../../services/aiApplicationPreviewActions.js' import { buildFailedInlineApplicationSubmitThinkingEvents, buildInitialInlineApplicationSubmitThinkingEvents, buildInlineApplicationDetailAction, buildInlineApplicationPreview, buildInlineApplicationPreviewActionResultText, buildInlineApplicationSubmitPrecheckPayload, buildInlineApplicationSubmitThinkingEvents, completeInlineThinkingEvents, extractInlineApplicationDraftPayload } from './workbenchAiApplicationPreviewModel.js' import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js' import { completeWorkbenchAiThinkingEvents, mergeWorkbenchAiThinkingEvents } from './workbenchAiPlanningThinkingModel.js' import { isOrphanInlineApplicationPreviewMessage, resolveInlineApplicationPreviewTextAction, resolveLatestApplicationPreviewMessage, resolveLatestOrphanApplicationPreviewMessage } from './workbenchAiApplicationGateModel.js' function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) { const fields = normalizeApplicationPreview(applicationPreview).fields || {} return [ fields.transportPolicy, fields.policyEstimate, fields.transportEstimatedAmount, fields.amount ].some((value) => /正在|查询中/.test(String(value || ''))) } function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') { return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(String(fieldKey || '').trim()) } export function useWorkbenchAiApplicationPreviewFlow({ activateInlineConversation, applicationPreviewEditor, applicationSubmitConfirmContext, applicationSubmitConfirmOpen, assistantDraft, cancelApplicationPreviewEditor, clearAiModeFiles, closeWorkbenchDatePicker, commitApplicationPreviewEditor: commitBaseApplicationPreviewEditor, conversationId, conversationMessages, conversationStarted, createInlineMessage, currentUser, handleApplicationPreviewEditorKeydown, inlineConversationAutoScrollPinned, isApplicationPreviewEditing, openApplicationPreviewEditor, persistCurrentConversation, pushInlineApplicationActionUserMessage, pushInlineUserMessage, refreshApplicationPreviewEstimate, removeWorkbenchDateTag, replaceInlineMessage, resolveApplicationPreviewEditorDateMax, resolveApplicationPreviewEditorDateMin, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveInlineThinkingEvents, resolveLatestInlineUserPrompt, scrollInlineConversationToBottom, sending, toast }) { function isApplicationPreviewEstimatePending(message = {}) { return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview)) } function canShowInlineSuggestedActions(message = {}) { return Boolean(message?.suggestedActions?.length) && !isApplicationPreviewEstimatePending(message) } function isInlineSuggestedActionDisabled(action = {}, message = {}) { const actionType = String(action?.action_type || '').trim() return ( Boolean(action?.disabled) || (actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION && sending.value) || ( [AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) && isApplicationPreviewEstimatePending(message) ) ) } function resolveInlineApplicationPreviewRows(message) { return buildApplicationPreviewRows(message?.applicationPreview || {}) } function resolveInlineApplicationPreviewMissingFields(message) { return normalizeApplicationPreview(message?.applicationPreview || {}).missingFields || [] } function resolveInlineApplicationPreviewEditorControl(fieldKey) { return resolveApplicationPreviewEditorControl(fieldKey) } function resolveInlineApplicationPreviewEditorDateMin(message, fieldKey) { return resolveApplicationPreviewEditorDateMin?.(message, fieldKey) || '' } function resolveInlineApplicationPreviewEditorDateMax(message, fieldKey) { return resolveApplicationPreviewEditorDateMax?.(message, fieldKey) || '' } function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) { if (isApplicationPreviewEstimatePendingPreview(applicationPreview)) { return [] } const normalized = normalizeApplicationPreview(applicationPreview) const actions = [{ label: '保存草稿', description: '先保存当前申请表,后续可以继续补充或提交。', icon: 'mdi mdi-content-save-outline', action_type: AI_APPLICATION_ACTION_SAVE_DRAFT, payload: { draftPayload } }] if (normalized.readyToSubmit) { actions.push({ label: '直接提交', description: '提交前先核查相同日期申请单,确认通过后进入审批流程。', icon: 'mdi mdi-send-check-outline', action_type: AI_APPLICATION_ACTION_SUBMIT, payload: { draftPayload } }) } return actions } function syncInlineApplicationPreviewMessageContent(message) { if (!message?.applicationPreview) { return } const nextContent = buildLocalApplicationPreviewMessage(message.applicationPreview) message.content = nextContent message.text = nextContent message.suggestedActions = buildInlineApplicationPreviewSuggestedActions(message.applicationPreview, message.draftPayload) } async function commitInlineApplicationPreviewEditor(message) { const shouldLockForEstimate = shouldRefreshInlineApplicationPreviewEstimate(applicationPreviewEditor.value.fieldKey) if (shouldLockForEstimate) { message.suggestedActions = [] persistCurrentConversation() } const committed = await commitBaseApplicationPreviewEditor(message) syncInlineApplicationPreviewMessageContent(message) persistCurrentConversation() return committed } function handleInlineApplicationPreviewEditorKeydown(event, message) { if (event.key === 'Enter') { event.preventDefault() void commitInlineApplicationPreviewEditor(message) return } if (event.key === 'Escape') { event.preventDefault() cancelApplicationPreviewEditor() return } handleApplicationPreviewEditorKeydown(event, message) } function buildInlineApplicationPreviewFooterText(message) { const normalized = normalizeApplicationPreview(message?.applicationPreview || {}) if (isApplicationPreviewEstimatePending(message)) { return '费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。' } if (normalized.validationIssues?.length || normalized.missingFields?.length) { return buildApplicationPreviewFooterMessage(normalized) } if (message?.submitRequiresConfirmation) { return '已识别到您希望直接提交。系统不会自动提交申请,请先核对申请核对表;确认无误后,点击下方“直接提交”按钮再进入提交确认。' } return '申请核对表已补齐,费用测算已同步。您仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。' } function buildInlineApplicationActionFailureText(error, isSubmit) { return [ isSubmit ? '### 申请提交失败' : '### 申请草稿保存失败', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), '我已保留当前申请核对表,您可以修改后重试,也可以稍后再次保存。' ].join('\n\n') } function hasSavedInlineApplicationDraft(message = {}, options = {}) { const draftPayload = message?.draftPayload || options.draftPayload || null if (!draftPayload || typeof draftPayload !== 'object') { return false } const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim() const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim() const status = String(draftPayload.status || '').trim().toLowerCase() return Boolean((claimId || claimNo) && status !== 'submitted') } function isContextualInlineApplicationSubmitText(text = '') { const normalized = String(text || '').replace(/\s+/g, '').trim() return Boolean( normalized && /提交/.test(normalized) && /(这个|这张|当前|上面|上述|刚才|申请单|单据|草稿|领导|审核|审批)/.test(normalized) ) } function resolveLatestInlineApplicationPreviewMessage() { return resolveLatestApplicationPreviewMessage(conversationMessages.value) } function resolveLatestOrphanInlineApplicationPreviewMessage() { return resolveLatestOrphanApplicationPreviewMessage(conversationMessages.value) } function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) { applicationSubmitConfirmContext.value = { messageId: String(targetMessage?.id || '').trim(), draftPayload: targetMessage?.draftPayload || options.draftPayload || null, userText: String(options.userText || '直接提交').trim() || '直接提交' } applicationSubmitConfirmOpen.value = true persistCurrentConversation() } function cancelInlineApplicationSubmitConfirm() { applicationSubmitConfirmOpen.value = false applicationSubmitConfirmContext.value = null } function confirmInlineApplicationSubmit() { const context = applicationSubmitConfirmContext.value || {} applicationSubmitConfirmOpen.value = false applicationSubmitConfirmContext.value = null const sourceMessage = conversationMessages.value.find((message) => message.id === context.messageId) if (!sourceMessage?.applicationPreview) { toast('当前申请表已变化,请重新点击直接提交。') return } void executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, sourceMessage, { confirmed: true, skipUserMessage: false, draftPayload: context.draftPayload || null, userText: context.userText || '直接提交' }) } async function runInlineApplicationSubmitPrecheck(targetMessage, pendingMessage, normalizedPreview, options = {}) { try { const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 }) const precheck = buildAiApplicationPrecheck(normalizedPreview, { claimsPayload: buildInlineApplicationSubmitPrecheckPayload( claimsPayload, targetMessage.draftPayload || options.draftPayload || null ), currentUser: currentUser.value || {}, expenseType: 'travel' }) const thinkingEvents = buildInlineApplicationSubmitThinkingEvents(precheck) const blocked = isAiApplicationPrecheckBlocking(precheck) if (blocked) { replaceInlineMessage( pendingMessage.id, createInlineMessage('assistant', buildAiApplicationSubmitConflictMessage(normalizedPreview, precheck), { id: pendingMessage.id, stewardPlan: { streamStatus: 'completed', thinkingEvents } }) ) persistCurrentConversation() scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) return false } const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage message.content = '提交前核查通过,正在提交申请并进入审批流程...' message.paragraphs = ['提交前核查通过,正在提交申请并进入审批流程...'] message.stewardPlan = { ...(message.stewardPlan || {}), streamStatus: 'streaming', thinkingEvents } scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) return true } catch (error) { replaceInlineMessage( pendingMessage.id, createInlineMessage('assistant', [ '### 提交前核查失败', '系统未能完成相同日期申请单查询,所以本次申请没有提交。', '请稍后重试;如果仍然失败,请先到单据中心核对是否已有同日期申请单。' ].join('\n\n'), { id: pendingMessage.id, stewardPlan: { streamStatus: 'failed', thinkingEvents: buildFailedInlineApplicationSubmitThinkingEvents(error) } }) ) toast('提交前核查失败,已暂停提交。') persistCurrentConversation() return false } } function buildApplicationPreviewNextTaskAction(targetMessage) { // 多 task 串行推进:申请草稿保存/提交成功后,检查是否有剩余 task(如报销), // 有则生成"继续处理下一个任务"按钮,让用户一键推进。 const remainingTasks = Array.isArray(targetMessage?.stewardRemainingTasks) ? targetMessage.stewardRemainingTasks : [] const nextTask = remainingTasks[0] if (!nextTask || !nextTask.task_type) { return null } const taskType = String(nextTask.task_type || '').trim() const isApplication = taskType === 'expense_application' const flowId = isApplication ? 'travel_application' : 'travel_reimbursement' const taskLabel = isApplication ? '出差申请' : '费用报销' const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {} return { label: `继续处理${taskLabel}`, description: `接下来处理${taskLabel}:${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`, icon: isApplication ? 'mdi mdi-file-plus-outline' : 'mdi mdi-receipt-text-plus-outline', action_type: 'steward_continue_next_task', payload: { steward_confirm_flow: true, flow_id: flowId, steward_current_task: nextTask, expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel', expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费', ontology_fields: ontologyFields, original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim() } } } async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) { const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestInlineApplicationPreviewMessage() if (!targetMessage?.applicationPreview) { toast('当前没有可提交的申请表。') return false } const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview) const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim() if (isSubmit && !normalizedPreview.readyToSubmit) { if (!options.skipUserMessage) { pushInlineApplicationActionUserMessage(userText) } const missingText = normalizedPreview.missingFields?.length ? `当前还缺少:${normalizedPreview.missingFields.join('、')}。` : '' const validationText = normalizedPreview.validationIssues?.length ? normalizedPreview.validationIssues.map((item) => item.message).join(';') : '' conversationMessages.value.push(createInlineMessage('assistant', [ '### 暂不能提交申请', missingText || validationText || '当前申请表还未通过提交校验。', '请先点击表格中的字段补充或修正,补齐后我会开放“直接提交”入口。' ].filter(Boolean).join('\n\n'))) persistCurrentConversation() scrollInlineConversationToBottom() return true } const shouldSubmitSavedDraftDirectly = isSubmit && !options.confirmed && hasSavedInlineApplicationDraft(targetMessage, options) && isContextualInlineApplicationSubmitText(userText) if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly) { requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText }) return true } if (!options.skipUserMessage) { pushInlineApplicationActionUserMessage(userText) } sending.value = true const pendingMessage = createInlineMessage( 'assistant', isSubmit ? '正在提交前核查相同日期申请单...' : '正在保存申请草稿...', { pending: true, stewardPlan: { streamStatus: 'streaming', thinkingEvents: isSubmit ? buildInitialInlineApplicationSubmitThinkingEvents() : [ { eventId: 'application-save-draft', title: '保存申请草稿', content: '正在按当前申请表内容保存草稿。', status: 'running' } ] } } ) conversationMessages.value.push(pendingMessage) scrollInlineConversationToBottom() try { if (isSubmit) { const precheckPassed = await runInlineApplicationSubmitPrecheck( targetMessage, pendingMessage, normalizedPreview, options ) if (!precheckPassed) { return true } } const payload = await runAiApplicationPreviewAction({ actionType, applicationPreview: normalizedPreview, currentUser: currentUser.value || {}, conversationId: conversationId.value, draftPayload: targetMessage.draftPayload || options.draftPayload || null }) const draftPayload = extractInlineApplicationDraftPayload(payload) if (draftPayload) { targetMessage.draftPayload = draftPayload } targetMessage.suggestedActions = [] const detailActions = buildInlineApplicationDetailAction(draftPayload) const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage) replaceInlineMessage( pendingMessage.id, createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), { id: pendingMessage.id, stewardPlan: { streamStatus: 'completed', thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage)) }, suggestedActions: nextTaskAction ? [...detailActions, nextTaskAction] : detailActions }) ) persistCurrentConversation() scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) return true } catch (error) { replaceInlineMessage( pendingMessage.id, createInlineMessage('assistant', buildInlineApplicationActionFailureText(error, isSubmit), { id: pendingMessage.id, applicationPreview: targetMessage.applicationPreview, draftPayload: targetMessage.draftPayload || options.draftPayload || null, suggestedActions: buildInlineApplicationPreviewSuggestedActions( targetMessage.applicationPreview, targetMessage.draftPayload || options.draftPayload || null ), stewardPlan: { streamStatus: 'failed', thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({ ...item, status: 'failed' })) } }) ) toast(error?.message || (isSubmit ? '申请提交失败。' : '申请草稿保存失败。')) persistCurrentConversation() return true } finally { sending.value = false scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) } } function handleInlineApplicationPreviewTextAction(prompt, applicationPreviewEstimatePending) { if (applicationPreviewEstimatePending.value) { toast('请等待费用测算完成后再继续操作。') return true } const actionType = resolveInlineApplicationPreviewTextAction(prompt) if (!actionType) { return false } if (!resolveLatestInlineApplicationPreviewMessage()) { const orphanPreviewMessage = resolveLatestOrphanInlineApplicationPreviewMessage() if (!orphanPreviewMessage) { return false } const previewSourceText = resolveLatestInlineUserPrompt() pushInlineApplicationActionUserMessage(prompt) toast('当前申请核对表状态不完整,我先重新生成可编辑表格。') void startAiApplicationPreview('travel', '差旅费', previewSourceText, { pushUserMessage: false }) return true } void executeInlineApplicationPreviewAction(actionType, null, { userText: prompt }) return true } async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceText = '', options = {}) { if (!conversationStarted.value) { activateInlineConversation({ title: String(expenseTypeLabel || expenseType || '申请').trim().slice(0, 18) || '申请' }) } const previewSourceText = String(sourceText || resolveLatestInlineUserPrompt()).trim() assistantDraft.value = '' removeWorkbenchDateTag() closeWorkbenchDatePicker() clearAiModeFiles() if (options.pushUserMessage !== false) { pushInlineUserMessage(options.userMessage || '确认发起出差申请') } const existingPendingMessage = options.pendingMessageId ? conversationMessages.value.find((item) => item.id === options.pendingMessageId) : null const previousThinkingEvents = completeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(existingPendingMessage)) const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', { id: options.pendingMessageId, pending: true, stewardPlan: { streamStatus: 'streaming', thinkingEvents: mergeWorkbenchAiThinkingEvents(previousThinkingEvents, [ { eventId: 'application-preview-build', title: '整理申请表字段', content: '正在把已识别的时间、地点、事由和差旅类型整理成可编辑表格。', status: 'running' }, { eventId: 'application-preview-estimate', title: '同步费用测算', content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。', status: 'pending' } ]) } }) if (options.pendingMessageId) { replaceInlineMessage(options.pendingMessageId, pendingMessage) } else { conversationMessages.value.push(pendingMessage) } persistCurrentConversation() scrollInlineConversationToBottom() try { const preview = await refreshApplicationPreviewEstimate( buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {}, { ontologyFields: options.ontologyFields }) ) const content = buildLocalApplicationPreviewMessage(preview) const previewMessage = createInlineMessage('assistant', content, { id: pendingMessage.id, applicationPreview: preview, suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview), requestedSubmit: Boolean(options.requestedSubmit), submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation), stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [], stewardPlan: { streamStatus: 'completed', thinkingEvents: completeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(pendingMessage)) }, text: content }) replaceInlineMessage(pendingMessage.id, previewMessage) if (options.autoSaveDraft) { await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, previewMessage, { skipUserMessage: true, userText: options.userMessage || '保存草稿' }) } } catch (error) { replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', { id: pendingMessage.id, stewardPlan: { streamStatus: 'failed', thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({ ...item, status: 'failed' })) } })) toast(error?.message || '申请核对表生成失败。') } finally { persistCurrentConversation() scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) } } return { buildInlineApplicationPreviewFooterText, buildInlineApplicationPreviewSuggestedActions, canShowInlineSuggestedActions, cancelInlineApplicationSubmitConfirm, commitInlineApplicationPreviewEditor, confirmInlineApplicationSubmit, executeInlineApplicationPreviewAction, handleInlineApplicationPreviewEditorKeydown, handleInlineApplicationPreviewTextAction, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineSuggestedActionDisabled, openApplicationPreviewEditor, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, startAiApplicationPreview } }