Files
X-Financial/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js

429 lines
16 KiB
JavaScript
Raw Normal View History

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
}
}