feat: 小财管家意图规划与报销提交编排增强

- 完善管家意图识别、模型计划构建与规划器调度
- 重构差旅报销提交编排器与管家计划流程前端交互
- 优化报销消息项样式与文档中心视图
- 新增小财管家与附件上传风险前置复核设计文档
- 补充管家规划器与文档中心测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 14:25:14 +08:00
parent 1cbf3fee44
commit f60cebadb8
19 changed files with 2337 additions and 196 deletions

View File

@@ -16,6 +16,11 @@ import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSu
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
import {
buildStewardFieldItems,
formatStewardMissingFieldList,
formatStewardOntologyFields
} from './stewardPlanModel.js'
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import {
buildOperationFeedbackPayload,
@@ -202,6 +207,10 @@ import {
userAvatar
} from './travelReimbursementConversationModel.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 14
const REVIEW_RISK_LEVEL_META = {
high: {
label: '高风险',
@@ -1546,6 +1555,7 @@ export default {
replaceMessage,
scrollToBottom,
adjustComposerTextareaHeight,
executeStewardSuggestedAction: (message, action) => handleSuggestedAction(message, action),
submitting,
reviewActionBusy,
sessionSwitchBusy,
@@ -1695,6 +1705,30 @@ export default {
const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText
await submitComposerInternal({
rawText: carryText,
userText: action.label || '确定',
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
? '小财管家正在调用申请助手生成申请单核对结果...'
: '小财管家正在调用报销助手整理报销核对结果...',
files: carryFiles,
skipScopeGuard: true,
skipStewardPlan: true,
skipUserMessage: confirmedByText,
sessionTypeOverride: targetSessionType,
stewardContinuation: {
planId: String(actionPayload.steward_plan_id || '').trim(),
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
? actionPayload.steward_remaining_tasks
: []
}
})
return
}
await switchSessionType(targetSessionType)
if (carryText) {
composerDraft.value = carryText
@@ -2161,6 +2195,195 @@ export default {
return String(request.claimId || request.claim_id || '').trim()
}
function buildStewardContinuationAfterAction(message, completedLabel = '当前动作已完成') {
const continuation = message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
if (!remainingTasks.length) {
return null
}
const nextTask = remainingTasks[0]
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const targetSessionType = nextTaskType === 'expense_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION
? '继续创建申请单'
: '继续填写报销单'
const restTasks = remainingTasks.slice(1)
return createMessage(
'assistant',
[
`**${completedLabel}。**`,
'',
'我会重新检查剩余任务队列。',
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}`,
'请回复“确定”,我再继续执行。'
].join('\n'),
[],
{
assistantName: '小财管家',
meta: ['小财管家', '等待用户确认'],
suggestedActions: [
{
label: nextLabel,
description: '确认后小财管家继续调用对应助手完成下一步。',
icon: targetSessionType === SESSION_TYPE_APPLICATION
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
auto_submit: true,
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
steward_remaining_tasks: restTasks
}
}
]
}
)
}
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
return {
planId: planId || `steward-followup-${Date.now()}`,
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function buildStewardFollowupThinkingEvents() {
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
return [
{
eventId: `${eventPrefix}-review`,
title: '复盘结果',
content: '当前动作已完成,小财管家正在检查剩余任务队列。'
},
{
eventId: `${eventPrefix}-next`,
title: '选择下一步',
content: '我会继续保持一步一步推进,先说明下一步,再等你确认后执行。'
}
]
}
function waitStewardFollowupTick(intervalMs) {
return new Promise((resolve) => {
window.setTimeout(resolve, intervalMs)
})
}
async function pushStewardContinuationMessage(finalMessage) {
if (!finalMessage) {
return
}
const finalText = String(finalMessage.text || '')
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const finalActions = Array.isArray(finalMessage.suggestedActions)
? finalMessage.suggestedActions
: []
finalMessage.text = ''
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
finalMessage.suggestedActions = []
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
messages.value.push(finalMessage)
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
for (const eventData of buildStewardFollowupThinkingEvents()) {
const event = {
eventId: eventData.eventId,
stage: 'steward_followup',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(eventData.content)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
event.content = chars.slice(0, index + 1).join('')
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
event.content = eventData.content
event.status = 'completed'
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
}
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
const chars = Array.from(finalText)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
finalMessage.text = chars.slice(0, index + 1).join('')
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
finalMessage.text = finalText
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
finalMessage.suggestedActions = finalActions
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
}
function buildStewardContinuationCarryText(task, restTasks = []) {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
const missingFields = formatStewardMissingFieldList(task?.missing_fields || task?.missingFields || [], taskType)
const lines = [
taskType === 'expense_application'
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}`
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields ? `已识别信息:${fields}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
'请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
]
if (restTasks.length) {
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
restTasks.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
})
}
return lines.filter(Boolean).join('\n')
}
function resolveStewardMissingFieldItems(task) {
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) {
return task.missingFieldItems
}
const fields = task?.missingFields || task?.missing_fields || []
const taskType = String(task?.taskType || task?.task_type || '').trim()
return buildStewardFieldItems(fields, taskType)
}
async function confirmApplicationSubmit() {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
@@ -2185,6 +2408,8 @@ export default {
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
skipStewardPlan: true,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
@@ -2229,6 +2454,10 @@ export default {
persistSessionState()
nextTick(scrollToBottom)
}
const stewardFollowup = buildStewardContinuationAfterAction(message, '申请单已完成')
if (stewardFollowup) {
await pushStewardContinuationMessage(stewardFollowup)
}
} finally {
reviewActionBusy.value = false
}
@@ -2449,7 +2678,7 @@ export default {
// submitting.value = true
// recognizeOcrFiles(files)
// submitting.value = false
if (isStewardSession.value && await submitStewardPlan(options)) {
if (isStewardSession.value && !options.skipStewardPlan && await submitStewardPlan(options)) {
return null
}
if (await handleGuidedComposerSubmit(options)) {
@@ -2570,6 +2799,7 @@ export default {
sessionSwitchBusy: sessionSwitchBusy.value,
applicationPreviewEditor: applicationPreviewEditor.value,
buildMessageBubbleClass,
resolveStewardMissingFieldItems,
buildReviewMainMessageText,
renderMarkdown,
handleAssistantMarkdownClick,