import { REIMBURSEMENT_LIST_PREVIEW_PARAMS, fetchExpenseClaims } from '../../services/reimbursements.js' import { runOrchestrator } from '../../services/orchestrator.js' import { createLinkedReimbursementDraftJob, fetchLinkedReimbursementDraftJob } from '../../services/linkedReimbursementDraftJobs.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, buildReimbursementDraftContinuationText, buildReimbursementDraftSelectionText, buildStandaloneReimbursementDraftConfirmationActions, buildStandaloneReimbursementDraftConfirmationText, buildViewReimbursementDraftAction, 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 const LINKED_DRAFT_JOB_POLL_INTERVAL_MS = 1200 const LINKED_DRAFT_JOB_MAX_POLLS = 100 const LINKED_DRAFT_JOB_PENDING_STATUSES = new Set(['queued', 'running']) const LINKED_DRAFT_RUNNING_PHRASE = '正在后台生成报销草稿' function waitForReimbursementAssociationStep() { return new Promise((resolve) => { globalThis.setTimeout(resolve, AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS) }) } export function buildLinkedDraftRunningText(job = {}, claimNo = '') { const statusText = String(job?.message || '').trim() const shouldShowStatusText = Boolean( statusText && !statusText.includes(LINKED_DRAFT_RUNNING_PHRASE) ) return [ `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在后台生成报销草稿...`, shouldShowStatusText ? '' : null, shouldShowStatusText ? `处理状态:${statusText}` : null, '', '您可以先离开当前会话,回来后我会继续查询任务结果。' ].filter((line) => line !== null).join('\n') } 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, createLinkedReimbursementDraftJobForAi = createLinkedReimbursementDraftJob, fetchLinkedReimbursementDraftJobForAi = fetchLinkedReimbursementDraftJob, linkedDraftJobPollIntervalMs = LINKED_DRAFT_JOB_POLL_INTERVAL_MS, linkedDraftJobMaxPolls = LINKED_DRAFT_JOB_MAX_POLLS, 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, linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || 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() ) } function normalizeDraftActionPayload(payload = {}) { return { id: String(payload.id || payload.claim_id || payload.claimId || '').trim(), claim_no: String(payload.claim_no || payload.claimNo || '').trim(), original_message: String(payload.original_message || payload.originalMessage || '我要报销').trim() || '我要报销' } } function pushPromptConversationUserMessage(text = '') { const normalizedText = String(text || '').trim() if (normalizedText) { pushInlineUserMessage(normalizedText) } } function promptAiReimbursementDraftContinuation(payload = {}) { const draft = normalizeDraftActionPayload(payload) const claimNo = draft.claim_no || '当前草稿' if (!conversationStarted.value) { activateInlineConversation({ title: `继续草稿 ${claimNo}`.trim().slice(0, 18) || '继续草稿' }) } assistantDraft.value = '' closeWorkbenchDatePicker() pushPromptConversationUserMessage(`继续关联草稿 ${claimNo}`) conversationMessages.value.push(createInlineMessage('assistant', buildReimbursementDraftContinuationText(draft), { meta: ['等待上传附件或说明'], suggestedActions: [buildViewReimbursementDraftAction(draft, draft.original_message)] })) persistCurrentConversation() scrollInlineConversationToBottom() } function promptStandaloneReimbursementDraftCreation(originalMessage = '我要报销', selectedLabel = '独立新建报销单') { const sourceText = String(originalMessage || '我要报销').trim() || '我要报销' const userText = String(selectedLabel || '独立新建报销单').trim() || '独立新建报销单' if (!conversationStarted.value) { activateInlineConversation({ title: userText.slice(0, 18) || '新建报销' }) } assistantDraft.value = '' closeWorkbenchDatePicker() pushPromptConversationUserMessage(userText) conversationMessages.value.push(createInlineMessage('assistant', buildStandaloneReimbursementDraftConfirmationText(), { meta: ['等待确认新建草稿'], suggestedActions: buildStandaloneReimbursementDraftConfirmationActions(sourceText) })) persistCurrentConversation() scrollInlineConversationToBottom() } function cancelStandaloneReimbursementDraftCreation() { conversationMessages.value.push(createInlineMessage('assistant', '好的,本次先不新建报销草稿。您可以继续查看已有草稿,或补充新的报销说明。', { meta: ['已取消新建'] })) persistCurrentConversation() scrollInlineConversationToBottom() } 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(REIMBURSEMENT_LIST_PREVIEW_PARAMS) } 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 waitForLinkedDraftJobPoll() { return new Promise((resolve) => { globalThis.setTimeout(resolve, linkedDraftJobPollIntervalMs) }) } function normalizeLinkedDraftJob(job = {}) { const jobId = String(job?.job_id || job?.jobId || '').trim() if (!jobId) { return null } return { jobId, status: String(job?.status || 'queued').trim() || 'queued', message: String(job?.message || '').trim(), error: String(job?.error || '').trim(), runId: String(job?.run_id || job?.runId || '').trim(), applicationClaimNo: String(job?.application_claim_no || job?.applicationClaimNo || '').trim(), draftPayload: job?.draft_payload && typeof job.draft_payload === 'object' ? job.draft_payload : job?.draftPayload && typeof job.draftPayload === 'object' ? job.draftPayload : null } } function buildLinkedDraftFailedText(job = {}) { return String(job?.message || job?.error || '').trim() || '生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,您可以稍后重试,或单独新建报销单。' } const activeLinkedDraftJobPolls = new Set() async function pollLinkedDraftJob({ jobId, pendingMessageId, claimNo = '', initialJob = null }) { const normalizedJobId = String(jobId || '').trim() if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) { return } activeLinkedDraftJobPolls.add(normalizedJobId) let currentJob = initialJob ? normalizeLinkedDraftJob(initialJob) : null try { for (let index = 0; index <= linkedDraftJobMaxPolls; index += 1) { if (!currentJob && index > 0) { currentJob = normalizeLinkedDraftJob(await fetchLinkedReimbursementDraftJobForAi(normalizedJobId)) } if (currentJob && !LINKED_DRAFT_JOB_PENDING_STATUSES.has(currentJob.status)) { if (currentJob.status === 'succeeded') { const draftPayload = currentJob.draftPayload || null const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim() const content = draftClaimNo ? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。` : `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。` replaceInlineAssistantMessage(pendingMessageId, content, { draftPayload, linkedReimbursementDraftJob: { ...currentJob, applicationClaimNo: claimNo }, suggestedActions: buildLinkedDraftAction(draftPayload) }) aiExpenseDraft.value = null persistCurrentConversation() scrollInlineConversationToBottom() return } throw new Error(buildLinkedDraftFailedText(currentJob)) } if (currentJob) { replaceInlineAssistantMessage(pendingMessageId, buildLinkedDraftRunningText(currentJob, claimNo), { pending: true, linkedReimbursementDraftJob: { ...currentJob, applicationClaimNo: claimNo }, suggestedActions: [] }) persistCurrentConversation() } await waitForLinkedDraftJobPoll() currentJob = normalizeLinkedDraftJob(await fetchLinkedReimbursementDraftJobForAi(normalizedJobId)) } throw new Error('报销草稿仍在后台生成中,稍后回到会话会继续刷新结果。') } finally { activeLinkedDraftJobPolls.delete(normalizedJobId) } } function resumePendingLinkedReimbursementDraftJobs() { conversationMessages.value.forEach((message) => { const job = normalizeLinkedDraftJob(message.linkedReimbursementDraftJob || null) if (!job || !LINKED_DRAFT_JOB_PENDING_STATUSES.has(job.status)) { return } void pollLinkedDraftJob({ jobId: job.jobId, pendingMessageId: message.id, claimNo: job.applicationClaimNo, initialJob: job }).catch((error) => { replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), { linkedReimbursementDraftJob: { ...job, status: 'failed', message: error?.message || '报销草稿生成状态查询失败。' } }) persistCurrentConversation() }) }) } 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 job = await createLinkedReimbursementDraftJobForAi({ message: submitOptions.rawText, conversation_id: '', context_json: { ...buildWorkbenchUserContext(), ...submitOptions.extraContext } }) const normalizedJob = normalizeLinkedDraftJob(job) if (!normalizedJob) { throw new Error('报销草稿生成任务创建失败,请稍后重试。') } await pollLinkedDraftJob({ jobId: normalizedJob.jobId, pendingMessageId, claimNo, initialJob: normalizedJob }) } catch (error) { replaceInlineAssistantMessage( pendingMessageId, buildLinkedDraftFailedText(error), { suggestedActions: [] } ) persistCurrentConversation() scrollInlineConversationToBottom() } } return { advanceAiExpenseDraft, cancelStandaloneReimbursementDraftCreation, linkAiExpenseApplication, promptAiReimbursementDraftContinuation, promptStandaloneReimbursementDraftCreation, pushInlineExpenseSceneSelectionPrompt, resumePendingLinkedReimbursementDraftJobs, startAiApplicationPreviewFromAction, startAiReimbursementAssociationGate, startAiExpenseDraft } }