2026-06-22 11:58:53 +08:00
|
|
|
import {
|
|
|
|
|
fetchStewardPlan,
|
|
|
|
|
fetchStewardPlanStream
|
|
|
|
|
} from '../../services/steward.js'
|
2026-06-24 10:42:50 +08:00
|
|
|
import {
|
|
|
|
|
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
|
|
|
|
fetchExpenseClaims
|
|
|
|
|
} from '../../services/reimbursements.js'
|
2026-06-22 11:58:53 +08:00
|
|
|
import {
|
|
|
|
|
buildStewardPlanMessageText,
|
|
|
|
|
buildStewardPlanRequest,
|
|
|
|
|
buildStewardSuggestedActions,
|
|
|
|
|
normalizeStewardPlan
|
|
|
|
|
} from '../../views/scripts/stewardPlanModel.js'
|
|
|
|
|
import {
|
|
|
|
|
buildRequiredApplicationActions,
|
|
|
|
|
buildRequiredApplicationMissingText,
|
|
|
|
|
buildRequiredApplicationSelectionText,
|
|
|
|
|
filterRequiredApplicationCandidates
|
|
|
|
|
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
2026-06-23 09:42:13 +08:00
|
|
|
import {
|
|
|
|
|
buildInlineAttachmentOcrDetails
|
|
|
|
|
} from './workbenchAiMessageModel.js'
|
2026-06-22 11:58:53 +08:00
|
|
|
|
|
|
|
|
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,
|
2026-06-24 10:42:50 +08:00
|
|
|
'这类操作需要您手动确认。请点击下方 **确认发起出差申请**,我会在当前对话里生成完整申请表,并把已识别的信息自动预填。'
|
2026-06-22 11:58:53 +08:00
|
|
|
].filter(Boolean).join('\n\n')
|
|
|
|
|
}
|
|
|
|
|
if (flow?.flowId === 'travel_reimbursement') {
|
|
|
|
|
return [
|
|
|
|
|
contextText || baseText,
|
2026-06-24 10:42:50 +08:00
|
|
|
'这类操作需要您手动确认。请点击下方 **确认关联已有申请单**,我会继续查询并展示可关联单据。'
|
2026-06-22 11:58:53 +08:00
|
|
|
].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: '确认关联已有申请单',
|
2026-06-24 10:42:50 +08:00
|
|
|
description: '确认后查询您名下可关联的差旅申请单,并进入关联步骤。',
|
2026-06-22 11:58:53 +08:00
|
|
|
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 {
|
2026-06-24 10:42:50 +08:00
|
|
|
const claims = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
2026-06-22 11:58:53 +08:00
|
|
|
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,
|
2026-06-24 10:42:50 +08:00
|
|
|
timeoutMessage: '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。'
|
2026-06-22 11:58:53 +08:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (String(error?.message || '').includes('流式服务')) {
|
|
|
|
|
return fetchStewardPlan(payload, {
|
|
|
|
|
timeoutMs: 75000,
|
2026-06-24 10:42:50 +08:00
|
|
|
timeoutMessage: '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。'
|
2026-06-22 11:58:53 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
|
|
|
|
|
let shouldAutoScrollOnFinish = true
|
|
|
|
|
const pendingMessage = createInlineMessage('assistant', '', {
|
|
|
|
|
pending: true,
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
streamStatus: 'streaming',
|
|
|
|
|
thinkingEvents: [
|
|
|
|
|
{
|
|
|
|
|
eventId: 'init',
|
|
|
|
|
title: '小财管家正在接入业务流程',
|
2026-06-24 10:42:50 +08:00
|
|
|
content: '正在识别您的意图、上下文和附件信息。',
|
2026-06-22 11:58:53 +08:00
|
|
|
status: 'running'
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
conversationMessages.value.push(pendingMessage)
|
|
|
|
|
scrollInlineConversationToBottom()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) {
|
|
|
|
|
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const receiptContext = await collectAiModeReceiptContext(files)
|
2026-06-23 09:42:13 +08:00
|
|
|
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(receiptContext, files)
|
2026-06-22 11:58:53 +08:00
|
|
|
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)
|
2026-06-23 09:42:13 +08:00
|
|
|
: buildStewardSuggestedActions(plan),
|
|
|
|
|
attachmentOcrDetails
|
2026-06-22 11:58:53 +08:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|