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

377 lines
14 KiB
JavaScript
Raw Normal View History

import {
fetchStewardPlan,
fetchStewardPlanStream
} from '../../services/steward.js'
import { fetchExpenseClaims } from '../../services/reimbursements.js'
import {
buildStewardPlanMessageText,
buildStewardPlanRequest,
buildStewardSuggestedActions,
normalizeStewardPlan
} from '../../views/scripts/stewardPlanModel.js'
import {
buildRequiredApplicationActions,
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
import {
buildInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js'
function shouldCheckAiRequiredApplicationGate(prompt) {
const compact = String(prompt || '').replace(/\s+/g, '')
if (!compact || !/(出差|差旅|部署|实施|支撑|支持|协助|拜访|调研|驻场|上线|验收)/.test(compact)) {
return false
}
if (!/\d{1,2}月\d{1,2}|昨天|前天|上周|上月/.test(compact)) {
return false
}
return !/(申请|报销|草稿|提交|审批|保存|发起|创建)/.test(compact)
}
function serializeRequiredApplicationCandidate(candidate = {}) {
return {
id: String(candidate.id || '').trim(),
claim_no: String(candidate.claim_no || '').trim(),
reason: String(candidate.reason || '').trim(),
location: String(candidate.location || '').trim(),
business_time: String(candidate.business_time || '').trim(),
status_label: String(candidate.status_label || '').trim()
}
}
function resolveRequiredApplicationGateContinuationFlow(normalizedPlan) {
if (String(normalizedPlan?.pendingFlowConfirmation?.status || '').trim() !== 'pending') {
return null
}
const flows = Array.isArray(normalizedPlan?.candidateFlows) ? normalizedPlan.candidateFlows : []
const applicationFlow = flows.find((flow) => flow.flowId === 'travel_application')
if (flows.length === 1 && applicationFlow && /先发起出差申请/.test(applicationFlow.label)) {
return applicationFlow
}
return flows.find((flow) => (
flow.flowId === 'travel_reimbursement' &&
/关联已有申请单/.test(flow.label)
)) || null
}
function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
const baseText = buildStewardPlanMessageText({
planStatus: normalizedPlan?.planStatus,
nextAction: normalizedPlan?.nextAction,
summary: normalizedPlan?.summary,
pendingFlowConfirmation: normalizedPlan?.pendingFlowConfirmation,
candidateFlows: normalizedPlan?.candidateFlows
})
const contextText = String(baseText || '')
.split(/\n\n1\. \*\*/)[0]
.trim()
.replace('### 需要先确认流程方向', '### 我已先查询申请单')
if (flow?.flowId === 'travel_application') {
return [
contextText || baseText,
'这类操作需要你手动确认。请点击下方 **确认发起出差申请**,我再在当前对话里生成完整申请表,并把已识别的信息自动预填。'
].filter(Boolean).join('\n\n')
}
if (flow?.flowId === 'travel_reimbursement') {
return [
contextText || baseText,
'这类操作需要你手动确认。请点击下方 **确认关联已有申请单**,我再继续查询并展示可关联单据。'
].filter(Boolean).join('\n\n')
}
return baseText
}
function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
if (flow.flowId === 'travel_application') {
return [{
label: '确认发起出差申请',
description: '确认后生成完整申请表,并预填已识别的时间、地点和事由。',
icon: 'mdi mdi-file-plus-outline',
action_type: 'ai_application_start_inline',
payload: {
expense_type: 'travel',
expense_type_label: '差旅费',
carry_text: prompt
}
}]
}
if (flow.flowId === 'travel_reimbursement') {
return [{
label: '确认关联已有申请单',
description: '确认后查询你名下可关联的差旅申请单,并进入关联步骤。',
icon: 'mdi mdi-link-variant',
action_type: 'steward_confirm_flow',
payload: {
steward_confirm_flow: true,
flow_id: 'travel_reimbursement',
expense_type: 'travel',
expense_type_label: '差旅费',
carry_text: prompt
}
}]
}
return []
}
function normalizeStreamThinkingEvent(event = {}) {
const data = event?.data && typeof event.data === 'object' ? event.data : {}
const eventId = String(data.event_id || data.eventId || data.stage || `thinking-${Date.now()}`).trim()
return {
eventId,
stage: String(data.stage || '').trim(),
title: String(data.title || '小财管家正在分析').trim(),
content: String(data.content || '').trim(),
status: String(data.status || 'running').trim() || 'running'
}
}
export function useWorkbenchAiStewardFlow({
activeConversationTitle,
collectAiModeReceiptContext,
conversationId,
conversationMessages,
createInlineMessage,
currentUser,
deleteAiWorkbenchConversation,
emit,
handleAiDocumentQueryIntent,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
resolveInlineThinkingEvents,
scrollInlineConversationToBottom,
sending,
stewardState,
streamInlineAssistantContent,
updateInlineMessageContent,
appendInlineMessageContent,
toast
}) {
async function attachAiRequiredApplicationGate(planRequest, prompt) {
if (!shouldCheckAiRequiredApplicationGate(prompt)) {
return planRequest
}
try {
const claims = await fetchExpenseClaims()
const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {})
planRequest.context_json = {
...(planRequest.context_json || {}),
required_application_gate: {
...((planRequest.context_json || {}).required_application_gate || {}),
travel: {
checked: true,
candidate_count: candidates.length,
candidates: candidates.slice(0, 5).map((candidate) => serializeRequiredApplicationCandidate(candidate))
}
}
}
} catch (error) {
console.warn('AI mode required application lookup failed:', error)
planRequest.context_json = {
...(planRequest.context_json || {}),
required_application_gate: {
...((planRequest.context_json || {}).required_application_gate || {}),
travel: {
checked: false,
query_failed: true
}
}
}
}
return planRequest
}
function handleInlineStewardStreamEvent(messageId, event) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message) {
return
}
if (event?.event === 'answer_delta') {
const data = event?.data && typeof event.data === 'object' ? event.data : {}
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
appendInlineMessageContent(message, data.delta || data.content || data.text || '')
message.stewardPlan = {
...(message.stewardPlan || {}),
streamStatus: 'streaming'
}
scrollInlineConversationToBottom({ force: shouldAutoScroll })
return
}
if (event?.event !== 'thinking') {
return
}
const nextEvent = normalizeStreamThinkingEvent(event)
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
const currentPlan = message.stewardPlan || {}
const currentEvents = Array.isArray(currentPlan.thinkingEvents) ? currentPlan.thinkingEvents : []
const eventIndex = currentEvents.findIndex((item) => item.eventId && item.eventId === nextEvent.eventId)
const nextEvents = eventIndex >= 0
? currentEvents.map((item, index) => (index === eventIndex ? { ...item, ...nextEvent } : item))
: [...currentEvents, nextEvent]
message.stewardPlan = {
...currentPlan,
thinkingEvents: nextEvents,
streamStatus: 'streaming'
}
scrollInlineConversationToBottom({ force: shouldAutoScroll })
}
async function fetchInlineStewardPlan(messageId, payload) {
try {
return await fetchStewardPlanStream(
payload,
{
onEvent: (event) => handleInlineStewardStreamEvent(messageId, event)
},
{
idleTimeoutMs: 90000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
}
)
} catch (error) {
if (String(error?.message || '').includes('流式服务')) {
return fetchStewardPlan(payload, {
timeoutMs: 75000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
})
}
throw error
}
}
async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
let shouldAutoScrollOnFinish = true
const pendingMessage = createInlineMessage('assistant', '', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: [
{
eventId: 'init',
title: '小财管家正在接入业务流程',
content: '正在识别你的意图、上下文和附件信息。',
status: 'running'
}
]
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
return
}
const receiptContext = await collectAiModeReceiptContext(files)
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(receiptContext, files)
const planRequest = buildStewardPlanRequest({
rawText: prompt,
files,
currentUser: currentUser.value || {},
conversationId: conversationId.value,
stewardState: stewardState.value
})
planRequest.context_json = {
...planRequest.context_json,
entry_source: 'workbench_ai_inline',
source: entry.source || 'workbench',
attachment_names: receiptContext.attachmentNames,
attachment_count: receiptContext.attachmentCount,
ocr_summary: receiptContext.ocrSummary,
ocr_documents: receiptContext.ocrDocuments,
ocr_source_file_names: receiptContext.ocrSourceFileNames,
...(receiptContext.ocrError ? { ocr_error: receiptContext.ocrError } : {})
}
await attachAiRequiredApplicationGate(planRequest, prompt)
const plan = await fetchInlineStewardPlan(pendingMessage.id, planRequest)
const normalizedPlan = normalizeStewardPlan(plan, {
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true
})
const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage)
const nextThinkingEvents = normalizedPlan.thinkingEvents.length
? normalizedPlan.thinkingEvents
: previousThinkingEvents.map((item) => ({ ...item, status: 'completed' }))
const previousConversationId = conversationId.value
const nextConversationId = String(normalizedPlan.conversationId || '').trim()
if (nextConversationId) {
conversationId.value = nextConversationId
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
if (previousConversationId && previousConversationId !== nextConversationId) {
deleteAiWorkbenchConversation(currentUser.value || {}, previousConversationId)
}
}
if (normalizedPlan.stewardState) {
stewardState.value = normalizedPlan.stewardState
}
const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
const finalMessageText = requiredApplicationContinuationFlow
? buildAiRequiredApplicationGateAutoMessage(normalizedPlan, requiredApplicationContinuationFlow)
: buildStewardPlanMessageText(plan)
const hasServerStreamedContent = Boolean(String(pendingMessage.content || '').trim())
if (!hasServerStreamedContent) {
await streamInlineAssistantContent(pendingMessage.id, finalMessageText)
}
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
...normalizedPlan,
thinkingEvents: nextThinkingEvents,
streamStatus: 'completed'
},
suggestedActions: requiredApplicationContinuationFlow
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
: buildStewardSuggestedActions(plan),
attachmentOcrDetails
})
)
persistCurrentConversation()
} catch (error) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage(
'assistant',
error?.message || '小财管家暂时无法完成规划,请稍后再试。',
{
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
...item,
status: 'failed'
}))
}
}
)
)
toast(error?.message || '小财管家暂时无法完成规划。')
persistCurrentConversation()
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
}
}
return {
buildRequiredApplicationActions,
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates,
requestInlineAssistantReply
}
}