refactor(travel): split reimbursement create workflow
完整修改内容: - 拆分 TravelReimbursementCreateView:提取审核面板纯模型、消息操作、建议动作处理、生命周期 watcher/UI 映射、小财管家运行时、续办流程和运行时文本模型,减少主组件继续堆叠业务分支。 - 调整申请预览链路:新增本地申请意图 gate,完善复杂差旅申请的大模型复核判断、交通方式缺失/候选识别、规则中心交通费用预估合并和申请冲突处理。 - 优化小财管家流程:抽出 steward typewriter 分段策略,避免 Markdown 表格逐字闪烁;补齐跨助手 carry、字段补齐续办、文本确认提交和行程规划推荐动作。 - 调整消息与样式:移除申请预览日期 chip 样式,收敛申请卡片/报销草稿消息的展示与复制、朗读、反馈入口逻辑。 - 更新测试:将源码锚点迁移到新模块,覆盖申请预览、提交确认、小财管家续办、引导流和审核抽屉相关断言。 验证: - node --check web/src/views/scripts/TravelReimbursementCreateView.js 及新增拆分模块 - npm --prefix web run build - node --test web/tests/expense-application-fast-preview.test.mjs web/tests/expense-application-submit-rich-confirm.test.mjs web/tests/travel-reimbursement-guided-flow.test.mjs 说明: - 后端/规则/容器配置/Audit 页面等工作区已有改动未纳入本提交。 - 容器内后端定向 pytest 曾运行 timeout 180s /tmp/x-financial-server-venv/bin/pytest -q <相关后端测试>,180 秒超时且超时前已有失败标记,未作为通过项记录。 - TravelReimbursementCreateView 当前仍超过 800 行,后续仍需继续拆分;本提交先把新增职责模块控制在 800 行内,阻止主类/主模块继续膨胀。
This commit is contained in:
255
web/src/views/scripts/travelReimbursementStewardFollowupFlow.js
Normal file
255
web/src/views/scripts/travelReimbursementStewardFollowupFlow.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
|
||||
import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
import {
|
||||
buildStewardFieldItems,
|
||||
formatStewardMissingFieldList,
|
||||
formatStewardOntologyFields
|
||||
} from './stewardPlanModel.js'
|
||||
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
|
||||
import { STEWARD_ASSISTANT_NAME } from './travelReimbursementStewardRuntimeTextModel.js'
|
||||
|
||||
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 10
|
||||
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 8
|
||||
const STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5
|
||||
|
||||
export function buildStewardContinuationAfterAction({
|
||||
createMessage,
|
||||
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: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME, '等待用户确认'],
|
||||
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_current_task: nextTask,
|
||||
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 extractStewardCarryLine(text = '', label = '') {
|
||||
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[::]([^\\n]+)`, 'u'))
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractStewardFollowupNextTitle(text = '') {
|
||||
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[::]([^。\n]+)/u)
|
||||
if (taskMatch?.[1]) {
|
||||
return taskMatch[1].trim()
|
||||
}
|
||||
const nextMatch = String(text || '').match(/下一步[::]([^。\n]+)/u)
|
||||
return nextMatch?.[1]?.trim() || '下一项财务任务'
|
||||
}
|
||||
|
||||
function buildStewardFollowupThinkingEvents(finalMessage = null, actions = []) {
|
||||
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const firstAction = Array.isArray(actions) ? actions[0] : null
|
||||
const actionPayload = firstAction?.payload && typeof firstAction.payload === 'object' ? firstAction.payload : {}
|
||||
const carryText = String(actionPayload.carry_text || '').trim()
|
||||
const finalText = String(finalMessage?.text || '').trim()
|
||||
const nextTitle = extractStewardFollowupNextTitle(carryText || finalText)
|
||||
const nextSummary = extractStewardCarryLine(carryText, '任务摘要')
|
||||
const nextMissing = extractStewardCarryLine(carryText, '还需要补充')
|
||||
return [
|
||||
{
|
||||
eventId: `${eventPrefix}-review`,
|
||||
title: '复盘结果',
|
||||
content: finalText.includes('申请单已完成')
|
||||
? '申请单已经完成,我把当前出差申请标记为已处理,不会重复创建。'
|
||||
: '当前动作已经完成,我会把已完成事项从任务队列中移除。'
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-next`,
|
||||
title: '读取剩余任务',
|
||||
content: nextSummary
|
||||
? `剩余队列里的下一项是“${nextTitle}”:${nextSummary}。`
|
||||
: `剩余队列里的下一项是“${nextTitle}”。`
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-gate`,
|
||||
title: '判断下一步条件',
|
||||
content: nextMissing
|
||||
? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。`
|
||||
: '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function waitStewardFollowupTick(intervalMs) {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, intervalMs)
|
||||
})
|
||||
}
|
||||
|
||||
export async function pushStewardContinuationMessage({
|
||||
finalMessage,
|
||||
messages,
|
||||
nextTick,
|
||||
persistSessionState,
|
||||
scrollToBottom
|
||||
}) {
|
||||
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(finalMessage, finalActions)) {
|
||||
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;) {
|
||||
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
|
||||
index = Math.min(chars.length, index + STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE)
|
||||
event.content = chars.slice(0, index).join('')
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
if (index % STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
|
||||
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;) {
|
||||
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
|
||||
index = resolveStewardTypewriterNextIndex(chars, index)
|
||||
finalMessage.text = chars.slice(0, index).join('')
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
finalMessage.text = finalText
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
|
||||
finalMessage.suggestedActions = finalActions
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
export 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,
|
||||
{ includeHints: false }
|
||||
)
|
||||
const lines = [
|
||||
taskType === 'expense_application'
|
||||
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}。`
|
||||
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}。`,
|
||||
task.summary ? `任务摘要:${task.summary}` : '',
|
||||
fields ? `已识别信息:${fields}` : '',
|
||||
missingFields ? `还需要补充:${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')
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
Reference in New Issue
Block a user