import { buildStewardPlanMessageText, buildStewardPlanRequest, buildStewardSuggestedActions, normalizeStewardPlan } from './stewardPlanModel.js' import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js' import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js' const STEWARD_TYPEWRITER_INTERVAL_MS = 10 const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8 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 = resolveStewardTypewriterNextIndex(chars, index) 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' } 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 } }