import { fetchExpenseClaims } from '../../services/reimbursements.js' import { runOrchestrator } from '../../services/orchestrator.js' import { applyAiExpenseAnswer, buildAiExpenseStepPrompt, buildAiExpenseSummary, createAiExpenseDraft, isAiExpenseDraftComplete } from '../../utils/aiExpenseDraftModel.js' import { buildExpenseSceneSelectionMessage, SESSION_TYPE_EXPENSE } from '../../views/scripts/travelReimbursementConversationModel.js' import { buildExpenseSceneSelectionActions } from '../../utils/expenseAssistantActions.js' import { buildRequiredApplicationActions, buildRequiredApplicationMissingText, buildRequiredApplicationSelectionText, filterRequiredApplicationCandidates, resolveRequiredApplicationReimbursementType } from '../../views/scripts/travelReimbursementApplicationLinkModel.js' import { buildReimbursementAssociationActions, buildReimbursementAssociationMissingText, buildReimbursementAssociationSubmitOptions, buildReimbursementAssociationThinkingEvents, buildReimbursementAssociationSelectionText, buildReimbursementAssociationQueryFailedText, buildReimbursementDraftActions, buildReimbursementDraftSelectionText, fetchReimbursementAssociationClaims, filterReimbursementAssociationCandidates, filterReimbursementDraftCandidates, REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS } from '../../views/scripts/travelReimbursementAssociationGateModel.js' export { SESSION_TYPE_EXPENSE } const AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS = 320 function waitForReimbursementAssociationStep() { return new Promise((resolve) => { globalThis.setTimeout(resolve, AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS) }) } export function useWorkbenchAiExpenseFlow({ activateInlineConversation, aiExpenseDraft, assistantDraft, clearAiModeFiles, closeWorkbenchDatePicker, conversationMessages, conversationStarted, createInlineMessage, currentUser, persistCurrentConversation, pushInlineUserMessage, 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) }, removeWorkbenchDateTag, resolveLatestInlineUserPrompt, scrollInlineConversationToBottom, startAiApplicationPreview, fetchExpenseClaimsForAi = fetchExpenseClaims, runOrchestratorForAi = runOrchestrator, associationQueryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS }) { function replaceInlineAssistantMessage(messageId, content = '', options = {}) { const nextMessage = createInlineMessage('assistant', content, { id: messageId, pending: Boolean(options.pending), stewardPlan: options.stewardPlan || null, suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [], draftPayload: options.draftPayload || null, text: options.text || content }) replaceInlineMessage(messageId, nextMessage) return nextMessage } function pushInlineExpenseSceneSelectionPrompt(originalMessage, selectedLabel = '') { const sourceText = String(originalMessage || '我要报销').trim() if (!conversationStarted.value) { activateInlineConversation({ title: String(selectedLabel || sourceText || '报销').trim().slice(0, 18) || '报销' }) } assistantDraft.value = '' removeWorkbenchDateTag() closeWorkbenchDatePicker() conversationMessages.value.push(createInlineMessage('user', String(selectedLabel || sourceText).trim())) conversationMessages.value.push(createInlineMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), { suggestedActions: buildExpenseSceneSelectionActions(sourceText) })) persistCurrentConversation() scrollInlineConversationToBottom() } function startAiApplicationPreviewFromAction(payload = {}, fallbackLabel = '') { const expenseType = String(payload.expense_type || '').trim() const expenseTypeLabel = String(payload.expense_type_label || fallbackLabel || '').trim() return startAiApplicationPreview( expenseType, expenseTypeLabel, payload.carry_text || resolveLatestInlineUserPrompt() ) } async function startAiReimbursementAssociationGate(originalMessage = '我要报销', selectedLabel = '', options = {}) { const sourceText = String(originalMessage || '我要报销').trim() || '我要报销' if (!conversationStarted.value) { activateInlineConversation({ title: String(selectedLabel || sourceText || '报销').trim().slice(0, 18) || '报销' }) } assistantDraft.value = '' removeWorkbenchDateTag() closeWorkbenchDatePicker() clearAiModeFiles() aiExpenseDraft.value = null pushInlineUserMessage(String(selectedLabel || sourceText).trim()) const pendingMessage = createInlineMessage('assistant', '', { pending: true, stewardPlan: { streamStatus: 'streaming', thinkingEvents: buildReimbursementAssociationThinkingEvents('intent') }, suggestedActions: [] }) conversationMessages.value.push(pendingMessage) const pendingMessageId = pendingMessage.id persistCurrentConversation() scrollInlineConversationToBottom() await waitForReimbursementAssociationStep() replaceInlineAssistantMessage(pendingMessageId, '', { pending: true, stewardPlan: { streamStatus: 'streaming', thinkingEvents: buildReimbursementAssociationThinkingEvents('query') }, suggestedActions: [] }) scrollInlineConversationToBottom() let claims = null try { claims = await fetchReimbursementAssociationClaims({ fetchExpenseClaims: fetchExpenseClaimsForAi, timeoutMs: associationQueryTimeoutMs }) } catch (error) { replaceInlineAssistantMessage(pendingMessageId, buildReimbursementAssociationQueryFailedText(error), { stewardPlan: { streamStatus: 'failed', thinkingEvents: buildReimbursementAssociationThinkingEvents('failed') }, suggestedActions: buildReimbursementAssociationActions([], sourceText) }) persistCurrentConversation() scrollInlineConversationToBottom() return } const draftCandidates = options.skipDraftCheck ? [] : filterReimbursementDraftCandidates(claims, currentUser.value || {}) if (draftCandidates.length) { replaceInlineAssistantMessage(pendingMessageId, '', { pending: true, stewardPlan: { streamStatus: 'streaming', thinkingEvents: buildReimbursementAssociationThinkingEvents('filter', { candidateCount: draftCandidates.length }) }, suggestedActions: [] }) scrollInlineConversationToBottom() await waitForReimbursementAssociationStep() const content = buildReimbursementDraftSelectionText(draftCandidates) replaceInlineAssistantMessage(pendingMessageId, content, { stewardPlan: { streamStatus: 'completed', thinkingEvents: buildReimbursementAssociationThinkingEvents('completed', { candidateCount: draftCandidates.length }) }, suggestedActions: buildReimbursementDraftActions(draftCandidates, sourceText) }) persistCurrentConversation() scrollInlineConversationToBottom() return } const candidates = filterReimbursementAssociationCandidates(claims, currentUser.value || {}) replaceInlineAssistantMessage(pendingMessageId, '', { pending: true, stewardPlan: { streamStatus: 'streaming', thinkingEvents: buildReimbursementAssociationThinkingEvents('filter', { candidateCount: candidates.length }) }, suggestedActions: [] }) scrollInlineConversationToBottom() await waitForReimbursementAssociationStep() const content = candidates.length ? buildReimbursementAssociationSelectionText(candidates) : buildReimbursementAssociationMissingText() replaceInlineAssistantMessage(pendingMessageId, content, { stewardPlan: { streamStatus: 'completed', thinkingEvents: buildReimbursementAssociationThinkingEvents('completed', { candidateCount: candidates.length }) }, suggestedActions: buildReimbursementAssociationActions(candidates, sourceText) }) persistCurrentConversation() scrollInlineConversationToBottom() } function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) { if (!conversationStarted.value) { activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' }) } assistantDraft.value = '' removeWorkbenchDateTag() closeWorkbenchDatePicker() clearAiModeFiles() pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`) if (requiresApplicationBeforeReimbursement) { void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) return } const draft = createAiExpenseDraft(expenseType, expenseTypeLabel) aiExpenseDraft.value = draft conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft))) persistCurrentConversation() scrollInlineConversationToBottom() } function advanceAiExpenseDraft(answer, files = []) { const fileNames = Array.from(files || []) pushInlineUserMessage(answer || (fileNames.length ? `上传 ${fileNames.length} 份附件` : '')) assistantDraft.value = '' clearAiModeFiles() const next = applyAiExpenseAnswer(aiExpenseDraft.value, answer, fileNames) aiExpenseDraft.value = next if (isAiExpenseDraftComplete(next)) { conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮你生成报销草稿。`)) aiExpenseDraft.value = null } else { conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next))) } persistCurrentConversation() scrollInlineConversationToBottom() } async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) { let claims = null try { claims = await fetchExpenseClaimsForAi() } catch { aiExpenseDraft.value = null conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。')) persistCurrentConversation() scrollInlineConversationToBottom() return } const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {}) aiExpenseDraft.value = createAiExpenseDraft(expenseType, expenseTypeLabel) if (!candidates.length) { conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), { suggestedActions: [{ label: '确认发起出差申请', description: '生成完整申请表,并预填已识别的时间、地点和事由', icon: 'mdi mdi-file-plus-outline', action_type: 'ai_application_start_inline', payload: { expense_type: expenseType, expense_type_label: expenseTypeLabel } }] })) persistCurrentConversation() scrollInlineConversationToBottom() return } conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationSelectionText(expenseType, candidates), { suggestedActions: buildRequiredApplicationActions(candidates, 'select_required_application') })) persistCurrentConversation() scrollInlineConversationToBottom() } function buildWorkbenchUserContext() { const user = currentUser.value || {} return { role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [], is_admin: Boolean(user.isAdmin), name: user.name || '', role: user.role || '', department: user.department || user.departmentName || '', department_name: user.department || user.departmentName || '', position: user.position || '', employee_position: user.position || user.employeePosition || user.employee_position || '', employee_no: user.employeeNo || user.employee_no || '', employeeNo: user.employeeNo || user.employee_no || '', session_type: SESSION_TYPE_EXPENSE, entry_source: 'workbench-ai' } } function buildLinkedDraftAction(draftPayload = {}) { const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim() const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim() if (!claimNo && !claimId) { return [] } return [{ label: '查看报销草稿', description: '打开草稿详情继续上传票据或补充信息。', icon: 'mdi mdi-file-document-outline', action_type: 'open_application_detail', payload: { claim_id: claimId, claim_no: claimNo } }] } async function linkAiExpenseApplication(application = {}) { const draft = aiExpenseDraft.value || (() => { const resolved = resolveRequiredApplicationReimbursementType(application) return createAiExpenseDraft(resolved.expenseType, resolved.expenseTypeLabel) })() const claimNo = String(application.application_claim_no || '').trim() pushInlineUserMessage(`关联申请单 ${claimNo}`.trim()) const linked = { ...draft, applicationClaim: application, values: { ...draft.values, reason: String(application.application_reason || '').trim(), location: String(application.application_location || '').trim(), time_range: String(application.application_business_time || '').trim(), amount: String(application.application_amount_label || application.application_amount || '').trim() }, stepKey: 'attachments' } aiExpenseDraft.value = linked const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, { pending: true, suggestedActions: [] }) conversationMessages.value.push(pendingMessage) const pendingMessageId = pendingMessage.id persistCurrentConversation() scrollInlineConversationToBottom() try { const submitOptions = buildReimbursementAssociationSubmitOptions( application, application.original_message || resolveLatestInlineUserPrompt() || '我要报销' ) const user = currentUser.value || {} const payload = await runOrchestratorForAi( { source: 'user_message', user_id: user.username || user.name || 'anonymous', conversation_id: null, message: submitOptions.rawText, context_json: { ...buildWorkbenchUserContext(), ...submitOptions.extraContext } }, { timeoutMs: 120000, timeoutMessage: '生成报销草稿超时,请稍后重试。' } ) const draftPayload = payload?.result?.draft_payload || null const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim() const content = draftClaimNo ? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。` : `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。` replaceInlineAssistantMessage(pendingMessageId, content, { draftPayload, suggestedActions: buildLinkedDraftAction(draftPayload) }) aiExpenseDraft.value = null persistCurrentConversation() scrollInlineConversationToBottom() } catch { replaceInlineAssistantMessage( pendingMessageId, '生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,你可以稍后重试或单独新建报销单。', { suggestedActions: [] } ) persistCurrentConversation() scrollInlineConversationToBottom() } } return { advanceAiExpenseDraft, linkAiExpenseApplication, pushInlineExpenseSceneSelectionPrompt, startAiApplicationPreviewFromAction, startAiReimbursementAssociationGate, startAiExpenseDraft } }