- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
397 lines
12 KiB
JavaScript
397 lines
12 KiB
JavaScript
import {
|
|
buildStewardPlanMessageText,
|
|
buildStewardPlanRequest,
|
|
buildStewardSuggestedActions,
|
|
normalizeStewardPlan
|
|
} from './stewardPlanModel.js'
|
|
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
|
|
|
|
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
|
|
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
|
|
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
|
|
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
|
|
|
|
export function useStewardPlanFlow({
|
|
activeSessionType,
|
|
attachedFiles,
|
|
composerDraft,
|
|
currentUser,
|
|
fileInputRef,
|
|
messages,
|
|
createMessage,
|
|
fetchStewardPlan,
|
|
fetchStewardPlanStream,
|
|
nextTick,
|
|
persistSessionState,
|
|
replaceMessage,
|
|
scrollToBottom,
|
|
adjustComposerTextareaHeight,
|
|
executeStewardSuggestedAction,
|
|
submitting,
|
|
reviewActionBusy,
|
|
sessionSwitchBusy,
|
|
toast
|
|
}) {
|
|
const stewardTypewriterTimers = new Map()
|
|
let stewardTypewriterRunId = 0
|
|
let stewardThinkingQueue = Promise.resolve()
|
|
|
|
function isStewardSession() {
|
|
return String(activeSessionType.value || '').trim() === SESSION_TYPE_STEWARD
|
|
}
|
|
|
|
function clearStewardThinkingTimers() {
|
|
stewardTypewriterRunId += 1
|
|
stewardThinkingQueue = Promise.resolve()
|
|
for (const [timerId, resolve] of stewardTypewriterTimers.entries()) {
|
|
globalThis.clearTimeout(timerId)
|
|
resolve()
|
|
}
|
|
stewardTypewriterTimers.clear()
|
|
}
|
|
|
|
async function submitStewardPlan(options = {}) {
|
|
if (!isStewardSession()) return false
|
|
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return true
|
|
|
|
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
|
|
const files = Array.from(options.files ?? attachedFiles.value ?? [])
|
|
if (!rawText && !files.length) return true
|
|
|
|
const fileNames = files.map((file) => file.name).filter(Boolean)
|
|
const userText = String(options.userText || rawText || `我上传了 ${fileNames.length} 份附件,请小财管家先归集任务。`).trim()
|
|
if (isStewardConfirmationText(rawText) && !files.length) {
|
|
const pendingContext = findPendingStewardAction()
|
|
if (pendingContext) {
|
|
if (!options.skipUserMessage) {
|
|
messages.value.push(createMessage('user', userText))
|
|
}
|
|
pendingContext.action.confirmedByText = true
|
|
composerDraft.value = ''
|
|
persistSessionState()
|
|
nextTick(() => {
|
|
adjustComposerTextareaHeight()
|
|
scrollToBottom()
|
|
})
|
|
await executeStewardSuggestedAction?.(pendingContext.message, pendingContext.action)
|
|
return true
|
|
}
|
|
}
|
|
|
|
submitting.value = true
|
|
const streamRunId = beginStewardStreamRun()
|
|
|
|
if (!options.skipUserMessage) {
|
|
messages.value.push(createMessage('user', userText, fileNames))
|
|
}
|
|
const pendingPlan = normalizeStewardPlan({
|
|
plan_status: 'streaming',
|
|
summary: '',
|
|
thinking_events: []
|
|
})
|
|
const pendingMessage = createMessage('assistant', '', [], {
|
|
assistantName: '小财管家',
|
|
meta: ['小财管家', '流式分析中'],
|
|
stewardPlan: {
|
|
...pendingPlan,
|
|
streamStatus: 'streaming'
|
|
}
|
|
})
|
|
messages.value.push(pendingMessage)
|
|
composerDraft.value = ''
|
|
nextTick(() => {
|
|
adjustComposerTextareaHeight()
|
|
scrollToBottom()
|
|
})
|
|
|
|
try {
|
|
const requestPayload = buildStewardPlanRequest({
|
|
rawText,
|
|
files,
|
|
currentUser: currentUser.value || {}
|
|
})
|
|
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload, streamRunId)
|
|
await waitForStewardThinkingQueue(streamRunId)
|
|
const typedThinkingEvents = resolveStewardThinkingEvents(pendingMessage.id)
|
|
const normalizedPlan = normalizeStewardPlan(plan, {
|
|
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
|
initialSummaryOnly: true
|
|
})
|
|
if (typedThinkingEvents.length) {
|
|
normalizedPlan.thinkingEvents = typedThinkingEvents
|
|
normalizedPlan.visibleThinkingEventCount = Number.MAX_SAFE_INTEGER
|
|
}
|
|
const finalText = buildStewardPlanMessageText(plan)
|
|
const suggestedActions = buildStewardSuggestedActions(plan)
|
|
replaceMessage(pendingMessage.id, createMessage('assistant', '', [], {
|
|
id: pendingMessage.id,
|
|
assistantName: '小财管家',
|
|
meta: ['小财管家', '输出中'],
|
|
stewardPlan: {
|
|
...normalizedPlan,
|
|
streamStatus: 'typing'
|
|
}
|
|
}))
|
|
await typeStewardPlanText(pendingMessage.id, finalText, normalizedPlan, suggestedActions, streamRunId)
|
|
persistSessionState()
|
|
nextTick(scrollToBottom)
|
|
} catch (error) {
|
|
replaceMessage(pendingMessage.id, createMessage('assistant', error?.message || '小财管家规划失败,请稍后重试。', [], {
|
|
id: pendingMessage.id,
|
|
assistantName: '小财管家',
|
|
meta: ['小财管家', '规划失败']
|
|
}))
|
|
toast(error?.message || '小财管家规划失败,请稍后重试。')
|
|
persistSessionState()
|
|
} finally {
|
|
submitting.value = false
|
|
if (fileInputRef.value) {
|
|
fileInputRef.value.value = ''
|
|
}
|
|
nextTick(() => {
|
|
adjustComposerTextareaHeight()
|
|
scrollToBottom()
|
|
})
|
|
}
|
|
return true
|
|
}
|
|
|
|
function beginStewardStreamRun() {
|
|
const runId = stewardTypewriterRunId + 1
|
|
stewardTypewriterRunId = runId
|
|
stewardThinkingQueue = Promise.resolve()
|
|
return runId
|
|
}
|
|
|
|
async function typeStewardPlanText(messageId, finalText, normalizedPlan, suggestedActions = [], runId = stewardTypewriterRunId) {
|
|
const chars = Array.from(String(finalText || ''))
|
|
const total = chars.length
|
|
let index = 0
|
|
|
|
while (index < total) {
|
|
if (runId !== stewardTypewriterRunId) {
|
|
return
|
|
}
|
|
await waitStewardTypewriterTick(STEWARD_TYPEWRITER_INTERVAL_MS)
|
|
if (runId !== stewardTypewriterRunId) {
|
|
return
|
|
}
|
|
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
|
|
const message = messages.value.find((item) => item.id === messageId)
|
|
if (!message) {
|
|
return
|
|
}
|
|
message.text = chars.slice(0, index).join('')
|
|
message.meta = ['小财管家', '输出中']
|
|
message.stewardPlan = {
|
|
...(message.stewardPlan || normalizedPlan),
|
|
...normalizedPlan,
|
|
streamStatus: 'typing'
|
|
}
|
|
if (index % 4 === 0 || index === total) {
|
|
nextTick(scrollToBottom)
|
|
}
|
|
}
|
|
|
|
const message = messages.value.find((item) => item.id === messageId)
|
|
if (!message || runId !== stewardTypewriterRunId) {
|
|
return
|
|
}
|
|
message.text = finalText
|
|
message.meta = ['小财管家', '等待用户确认']
|
|
message.stewardPlan = {
|
|
...(message.stewardPlan || normalizedPlan),
|
|
...normalizedPlan,
|
|
streamStatus: 'completed'
|
|
}
|
|
message.suggestedActions = suggestedActions
|
|
persistSessionState()
|
|
nextTick(scrollToBottom)
|
|
}
|
|
|
|
function waitStewardTypewriterTick(intervalMs = STEWARD_TYPEWRITER_INTERVAL_MS) {
|
|
return new Promise((resolve) => {
|
|
const timerId = globalThis.setTimeout(() => {
|
|
stewardTypewriterTimers.delete(timerId)
|
|
resolve()
|
|
}, intervalMs)
|
|
stewardTypewriterTimers.set(timerId, resolve)
|
|
})
|
|
}
|
|
|
|
function fetchPlanWithStreaming(messageId, requestPayload, runId) {
|
|
if (typeof fetchStewardPlanStream === 'function') {
|
|
return fetchStewardPlanStream(requestPayload, {
|
|
onEvent: (event) => handleStreamEvent(messageId, event, runId)
|
|
}, {
|
|
timeoutMs: 12000,
|
|
idleTimeoutMs: 120000,
|
|
timeoutMessage: '小财管家任务规划超时,请稍后重试。'
|
|
})
|
|
}
|
|
|
|
return fetchStewardPlan(requestPayload, {
|
|
timeoutMs: 16000,
|
|
timeoutMessage: '小财管家任务规划超时,请稍后重试。'
|
|
})
|
|
}
|
|
|
|
function handleStreamEvent(messageId, event, runId = stewardTypewriterRunId) {
|
|
if (event.event !== 'thinking') {
|
|
return
|
|
}
|
|
stewardThinkingQueue = stewardThinkingQueue
|
|
.then(() => typeStewardThinkingEvent(messageId, event.data, runId))
|
|
.catch(() => {})
|
|
}
|
|
|
|
async function typeStewardThinkingEvent(messageId, eventData, runId) {
|
|
if (runId !== stewardTypewriterRunId) {
|
|
return
|
|
}
|
|
const eventId = String(eventData?.event_id || eventData?.eventId || `thinking-${Date.now()}`).trim()
|
|
const title = String(eventData?.title || '').trim()
|
|
const fullContent = String(eventData?.content || '').trim()
|
|
appendStewardThinkingEvent(messageId, {
|
|
...eventData,
|
|
event_id: eventId,
|
|
eventId,
|
|
title,
|
|
content: '',
|
|
status: 'running'
|
|
}, runId)
|
|
|
|
const chars = Array.from(fullContent)
|
|
let index = 0
|
|
while (index < chars.length) {
|
|
if (runId !== stewardTypewriterRunId) {
|
|
return
|
|
}
|
|
await waitStewardTypewriterTick(STEWARD_THINKING_TYPEWRITER_INTERVAL_MS)
|
|
if (runId !== stewardTypewriterRunId) {
|
|
return
|
|
}
|
|
index = Math.min(chars.length, index + STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE)
|
|
updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId)
|
|
}
|
|
|
|
updateStewardThinkingEvent(messageId, eventId, fullContent, 'completed', runId)
|
|
persistSessionState()
|
|
}
|
|
|
|
function waitForStewardThinkingQueue(runId) {
|
|
return stewardThinkingQueue.then(() => {
|
|
if (runId !== stewardTypewriterRunId) {
|
|
return
|
|
}
|
|
})
|
|
}
|
|
|
|
function resolveStewardThinkingEvents(messageId) {
|
|
const message = messages.value.find((item) => item.id === messageId)
|
|
return Array.isArray(message?.stewardPlan?.thinkingEvents)
|
|
? message.stewardPlan.thinkingEvents
|
|
: []
|
|
}
|
|
|
|
function appendStewardThinkingEvent(messageId, eventData, runId) {
|
|
if (runId !== stewardTypewriterRunId) {
|
|
return
|
|
}
|
|
const message = messages.value.find((item) => item.id === messageId)
|
|
if (!message?.stewardPlan) return
|
|
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
|
|
? message.stewardPlan.thinkingEvents
|
|
: []
|
|
const normalizedPlan = normalizeStewardPlan({
|
|
...message.stewardPlan,
|
|
thinking_events: [...existingEvents, eventData]
|
|
}, {
|
|
visibleThinkingEventCount: existingEvents.length + 1
|
|
})
|
|
message.stewardPlan = {
|
|
...message.stewardPlan,
|
|
...normalizedPlan,
|
|
streamStatus: 'streaming'
|
|
}
|
|
persistSessionState()
|
|
nextTick(scrollToBottom)
|
|
}
|
|
|
|
function updateStewardThinkingEvent(messageId, eventId, content, status, runId) {
|
|
if (runId !== stewardTypewriterRunId) {
|
|
return
|
|
}
|
|
const message = messages.value.find((item) => item.id === messageId)
|
|
if (!message?.stewardPlan) return
|
|
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
|
|
? message.stewardPlan.thinkingEvents
|
|
: []
|
|
const nextEvents = existingEvents.map((item) => (
|
|
String(item.eventId || item.event_id || '').trim() === eventId
|
|
? {
|
|
...item,
|
|
content,
|
|
status
|
|
}
|
|
: item
|
|
))
|
|
const normalizedPlan = normalizeStewardPlan({
|
|
...message.stewardPlan,
|
|
thinking_events: nextEvents
|
|
}, {
|
|
visibleThinkingEventCount: nextEvents.length
|
|
})
|
|
message.stewardPlan = {
|
|
...message.stewardPlan,
|
|
...normalizedPlan,
|
|
streamStatus: 'streaming'
|
|
}
|
|
if (content.length % 4 === 0 || status === 'completed') {
|
|
nextTick(scrollToBottom)
|
|
}
|
|
}
|
|
|
|
function findPendingStewardAction() {
|
|
for (const message of [...messages.value].reverse()) {
|
|
if (
|
|
message?.role === 'assistant'
|
|
&& isPendingStewardActionMessage(message)
|
|
&& !message.suggestedActionsLocked
|
|
&& Array.isArray(message.suggestedActions)
|
|
&& message.suggestedActions.length
|
|
) {
|
|
return {
|
|
message,
|
|
action: message.suggestedActions[0]
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function isPendingStewardActionMessage(message) {
|
|
if (message?.stewardPlan) {
|
|
return message.stewardPlan.streamStatus !== 'streaming'
|
|
}
|
|
return (
|
|
String(message?.assistantName || '').trim() === '小财管家'
|
|
&& Array.isArray(message?.suggestedActions)
|
|
&& message.suggestedActions.some((action) =>
|
|
String(action?.payload?.steward_plan_id || '').trim()
|
|
)
|
|
)
|
|
}
|
|
|
|
function isStewardConfirmationText(value) {
|
|
const normalized = String(value || '').replace(/\s+/g, '')
|
|
return /^(确定|确认|可以|好的|好|继续|继续执行|执行|开始执行|没问题|同意)$/.test(normalized)
|
|
}
|
|
|
|
return {
|
|
isStewardSession,
|
|
submitStewardPlan,
|
|
clearStewardThinkingTimers
|
|
}
|
|
}
|