feat: 小财管家意图规划与报销提交编排增强
- 完善管家意图识别、模型计划构建与规划器调度 - 重构差旅报销提交编排器与管家计划流程前端交互 - 优化报销消息项样式与文档中心视图 - 新增小财管家与附件上传风险前置复核设计文档 - 补充管家规划器与文档中心测试覆盖
This commit is contained in:
@@ -6,6 +6,9 @@ import {
|
||||
} from './stewardPlanModel.js'
|
||||
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
|
||||
|
||||
const STEWARD_TYPEWRITER_INTERVAL_MS = 18
|
||||
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 14
|
||||
|
||||
export function useStewardPlanFlow({
|
||||
activeSessionType,
|
||||
attachedFiles,
|
||||
@@ -21,17 +24,28 @@ export function useStewardPlanFlow({
|
||||
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 = {}) {
|
||||
@@ -44,7 +58,26 @@ export function useStewardPlanFlow({
|
||||
|
||||
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))
|
||||
@@ -75,23 +108,34 @@ export function useStewardPlanFlow({
|
||||
files,
|
||||
currentUser: currentUser.value || {}
|
||||
})
|
||||
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload)
|
||||
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
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||||
initialSummaryOnly: true
|
||||
})
|
||||
replaceMessage(pendingMessage.id, createMessage('assistant', buildStewardPlanMessageText(plan), [], {
|
||||
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: ['小财管家', '等待确认'],
|
||||
meta: ['小财管家', '输出中'],
|
||||
stewardPlan: {
|
||||
...normalizedPlan,
|
||||
streamStatus: 'completed'
|
||||
},
|
||||
suggestedActions: buildStewardSuggestedActions(plan)
|
||||
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: ['小财管家', '规划失败']
|
||||
}))
|
||||
@@ -110,12 +154,76 @@ export function useStewardPlanFlow({
|
||||
return true
|
||||
}
|
||||
|
||||
function fetchPlanWithStreaming(messageId, requestPayload) {
|
||||
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 += 1
|
||||
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)
|
||||
onEvent: (event) => handleStreamEvent(messageId, event, runId)
|
||||
}, {
|
||||
timeoutMs: 20000,
|
||||
timeoutMs: 12000,
|
||||
idleTimeoutMs: 120000,
|
||||
timeoutMessage: '小财管家任务规划超时,请稍后重试。'
|
||||
})
|
||||
}
|
||||
@@ -126,10 +234,68 @@ export function useStewardPlanFlow({
|
||||
})
|
||||
}
|
||||
|
||||
function handleStreamEvent(messageId, event) {
|
||||
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 += 1
|
||||
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)
|
||||
@@ -137,7 +303,7 @@ export function useStewardPlanFlow({
|
||||
: []
|
||||
const normalizedPlan = normalizeStewardPlan({
|
||||
...message.stewardPlan,
|
||||
thinking_events: [...existingEvents, event.data]
|
||||
thinking_events: [...existingEvents, eventData]
|
||||
}, {
|
||||
visibleThinkingEventCount: existingEvents.length + 1
|
||||
})
|
||||
@@ -150,6 +316,76 @@ export function useStewardPlanFlow({
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user