Files
X-Financial/web/src/views/scripts/useStewardPlanFlow.js

397 lines
12 KiB
JavaScript
Raw Normal View History

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
}
}