refactor: enforce 800 line source limits
This commit is contained in:
371
web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js
Normal file
371
web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js
Normal file
@@ -0,0 +1,371 @@
|
||||
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'
|
||||
|
||||
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 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)
|
||||
})
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user