feat: 小财管家意图规划与报销提交编排增强
- 完善管家意图识别、模型计划构建与规划器调度 - 重构差旅报销提交编排器与管家计划流程前端交互 - 优化报销消息项样式与文档中心视图 - 新增小财管家与附件上传风险前置复核设计文档 - 补充管家规划器与文档中心测试覆盖
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
||||
import {
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
applyApplicationBusinessTimeContext,
|
||||
applyApplicationPolicyEstimateError,
|
||||
applyApplicationPolicyEstimateResult,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
normalizeApplicationPreview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
||||
@@ -24,6 +26,11 @@ import {
|
||||
shouldUseBudgetCompileReport
|
||||
} from './budgetAssistantReportModel.js'
|
||||
|
||||
const STEWARD_ASSISTANT_NAME = '小财管家'
|
||||
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18
|
||||
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14
|
||||
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||||
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
MAX_ATTACHMENTS,
|
||||
@@ -108,6 +115,228 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
const pendingAttachmentAssociations = new Map()
|
||||
|
||||
function isStewardDelegatedRun(options = {}) {
|
||||
return Boolean(options?.stewardContinuation && typeof options.stewardContinuation === 'object')
|
||||
}
|
||||
|
||||
function resolveStewardDelegatedActionLabel(sessionType = '') {
|
||||
return String(sessionType || '').trim() === 'application'
|
||||
? '申请单核对'
|
||||
: '报销单核对'
|
||||
}
|
||||
|
||||
function buildStewardDelegatedPlan(continuation = null, thinkingEvents = [], streamStatus = 'streaming') {
|
||||
return {
|
||||
planId: String(continuation?.planId || continuation?.plan_id || 'steward_delegation').trim(),
|
||||
planStatus: 'delegating',
|
||||
summary: '',
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||||
initialSummaryOnly: true,
|
||||
thinkingEvents,
|
||||
tasks: [],
|
||||
attachmentGroups: [],
|
||||
confirmationGroups: [],
|
||||
streamStatus
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewMissingFieldsForSteward(preview = {}) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||||
}
|
||||
|
||||
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview)
|
||||
if (!missingFields.includes('出行方式')) {
|
||||
return []
|
||||
}
|
||||
const iconMap = {
|
||||
火车: 'mdi mdi-train',
|
||||
飞机: 'mdi mdi-airplane',
|
||||
轮船: 'mdi mdi-ferry'
|
||||
}
|
||||
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
|
||||
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
|
||||
label: mode,
|
||||
description: `选择${mode}作为本次出行方式,并同步费用测算。`,
|
||||
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
|
||||
payload: {
|
||||
field_key: 'transportMode',
|
||||
field_label: '出行方式',
|
||||
value: mode
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
|
||||
if (!missingFields.length) {
|
||||
return fallbackText
|
||||
}
|
||||
|
||||
if (missingFields.includes('出行方式')) {
|
||||
return [
|
||||
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
|
||||
'',
|
||||
'**原因是:还缺少“出行方式”。**',
|
||||
'',
|
||||
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
|
||||
'',
|
||||
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
|
||||
'',
|
||||
`**还需要你补充:${missingFields.join('、')}。**`,
|
||||
'',
|
||||
`请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
|
||||
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
|
||||
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const events = [
|
||||
{
|
||||
eventId: `${eventPrefix}-confirm`,
|
||||
title: '接收确认',
|
||||
content: '已收到你的确认,小财管家继续推进当前任务。'
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-coordinate`,
|
||||
title: '协调能力',
|
||||
content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。`
|
||||
}
|
||||
]
|
||||
const applicationMissingFields = context.applicationPreview
|
||||
? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview)
|
||||
: []
|
||||
if (applicationMissingFields.length) {
|
||||
events.push({
|
||||
eventId: `${eventPrefix}-gap`,
|
||||
title: '识别缺口',
|
||||
content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。`
|
||||
})
|
||||
}
|
||||
events.push(
|
||||
{
|
||||
eventId: `${eventPrefix}-output`,
|
||||
title: '准备输出',
|
||||
content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。'
|
||||
}
|
||||
)
|
||||
return events
|
||||
}
|
||||
|
||||
function resolveStewardDelegatedFinalMeta(finalExtras = {}) {
|
||||
const sourceMeta = Array.isArray(finalExtras.meta) ? finalExtras.meta : []
|
||||
const sourceLabel = sourceMeta.find((item) =>
|
||||
String(item || '').trim() && String(item || '').trim() !== STEWARD_ASSISTANT_NAME
|
||||
)
|
||||
const requiresConfirmation = Boolean(
|
||||
finalExtras.applicationPreview ||
|
||||
finalExtras.reviewPayload ||
|
||||
(Array.isArray(finalExtras.suggestedActions) && finalExtras.suggestedActions.length)
|
||||
)
|
||||
return [
|
||||
STEWARD_ASSISTANT_NAME,
|
||||
requiresConfirmation ? '等待用户确认' : '已完成',
|
||||
sourceLabel || ''
|
||||
].filter(Boolean).slice(0, 3)
|
||||
}
|
||||
|
||||
function waitStewardDelegatedTick(intervalMs) {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, intervalMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
|
||||
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
message.text = ''
|
||||
message.assistantName = STEWARD_ASSISTANT_NAME
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '思考中']
|
||||
message.suggestedActions = []
|
||||
message.stewardContinuation = continuation
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [], 'streaming')
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const typedEvents = []
|
||||
const thinkingEvents = buildStewardDelegatedThinkingEvents(context.sessionType, continuation, context)
|
||||
for (const eventData of thinkingEvents) {
|
||||
const event = {
|
||||
eventId: eventData.eventId,
|
||||
stage: 'delegated_action',
|
||||
title: eventData.title,
|
||||
content: '',
|
||||
status: 'running'
|
||||
}
|
||||
typedEvents.push(event)
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const chars = Array.from(String(eventData.content || ''))
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
|
||||
event.content = chars.slice(0, index + 1).join('')
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
event.content = String(eventData.content || '')
|
||||
event.status = 'completed'
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
const text = String(finalText || '')
|
||||
message.text = ''
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||||
const chars = Array.from(text)
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
|
||||
message.text = chars.slice(0, index + 1).join('')
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(message, finalExtras, {
|
||||
id: messageId,
|
||||
text,
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: resolveStewardDelegatedFinalMeta(finalExtras),
|
||||
stewardContinuation: continuation,
|
||||
stewardPlan: buildStewardDelegatedPlan(continuation, [...typedEvents], 'completed')
|
||||
})
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function resetStewardDelegatedInsightState() {
|
||||
resetFlowRun({ startedAt: 0, openDrawer: false })
|
||||
insightPanelCollapsed.value = true
|
||||
currentInsight.value = {
|
||||
intent: 'welcome',
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
function isSubmittedApplicationDraftPayload(draftPayload) {
|
||||
return (
|
||||
String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||||
@@ -376,16 +605,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
||||
function buildBackendMessage(rawText, fileNames, ocrSummary = '', sessionTypeOverride = '') {
|
||||
const parts = []
|
||||
const normalizedText = String(rawText || '').trim()
|
||||
const sessionType = String(activeSessionType.value || '').trim()
|
||||
const sessionType = String(sessionTypeOverride || activeSessionType.value || '').trim()
|
||||
const isKnowledgeMessage = sessionType === 'knowledge'
|
||||
|
||||
if (normalizedText) {
|
||||
parts.push(normalizedText)
|
||||
} else if (fileNames.length) {
|
||||
parts.push(
|
||||
isKnowledgeSession.value
|
||||
isKnowledgeMessage
|
||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
|
||||
: sessionType === 'application'
|
||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
|
||||
@@ -440,7 +670,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return currentUser.value || user
|
||||
}
|
||||
|
||||
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null) {
|
||||
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
|
||||
const user = await resolveApplicationPreviewUser()
|
||||
const localPreview = applyApplicationBusinessTimeContext(
|
||||
buildLocalApplicationPreview(rawText, user),
|
||||
@@ -474,7 +704,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
context_json: {
|
||||
...buildExpenseApplicationOntologyContext(user),
|
||||
session_type: activeSessionType.value,
|
||||
session_type: String(sessionTypeOverride || activeSessionType.value || '').trim(),
|
||||
entry_source: props.entrySource,
|
||||
user_input_text: rawText
|
||||
}
|
||||
@@ -516,7 +746,10 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const rawText = resolveComposerSubmitText(options.rawText).trim()
|
||||
const systemGenerated = Boolean(options.systemGenerated)
|
||||
const appendToCurrentFlow = Boolean(options.appendToCurrentFlow)
|
||||
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
|
||||
const effectiveSessionType = String(options.sessionTypeOverride || activeSessionType.value || '').trim()
|
||||
const stewardDelegated = isStewardDelegatedRun(options)
|
||||
const effectiveIsKnowledgeSession = effectiveSessionType === 'knowledge'
|
||||
const normalizedFiles = effectiveIsKnowledgeSession ? [] : Array.from(options.files ?? attachedFiles.value)
|
||||
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
|
||||
const files = fileMergeResult.files
|
||||
const detailScopedClaimId = resolveDetailScopedClaimId()
|
||||
@@ -551,8 +784,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
detail_scope_claim_id: detailScopedClaimId
|
||||
}
|
||||
: optionExtraContext
|
||||
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = isKnowledgeSession.value
|
||||
const selectedBusinessTimeContext = effectiveIsKnowledgeSession ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = effectiveIsKnowledgeSession
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
@@ -569,15 +802,15 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
|
||||
)
|
||||
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
|
||||
const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
const waitForExpenseIntentConfirmation = !stewardDelegated && shouldRequestExpenseIntentConfirmation(rawText, {
|
||||
sessionType: effectiveSessionType,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
hasSelectedExpenseType,
|
||||
hasConfirmedExpenseIntent
|
||||
})
|
||||
const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
const waitForExpenseSceneSelection = !stewardDelegated && !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
|
||||
sessionType: effectiveSessionType,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
hasSelectedExpenseType
|
||||
@@ -587,7 +820,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
String(options.userText || '').trim() ||
|
||||
resolveComposerDisplaySubmitText(rawText) ||
|
||||
rawText ||
|
||||
(isKnowledgeSession.value
|
||||
(effectiveIsKnowledgeSession
|
||||
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
|
||||
: resolvedUploadDisposition === 'continue_existing'
|
||||
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
|
||||
@@ -596,7 +829,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||
|
||||
if (shouldUseBudgetCompileReport(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
sessionType: effectiveSessionType,
|
||||
entrySource: props.entrySource,
|
||||
budgetContext: props.initialBudgetContext
|
||||
}) && !reviewAction) {
|
||||
@@ -627,7 +860,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
})
|
||||
}
|
||||
|
||||
const scopeGuard = resolveAssistantScopeGuard(rawText, activeSessionType.value, {
|
||||
const scopeGuard = resolveAssistantScopeGuard(rawText, effectiveSessionType, {
|
||||
attachmentCount: files.length,
|
||||
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
|
||||
reviewAction
|
||||
@@ -652,34 +885,45 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
|
||||
if (shouldUseLocalApplicationPreview(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
sessionType: effectiveSessionType,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
systemGenerated
|
||||
})) {
|
||||
const intentStartedAt = Date.now()
|
||||
const reviewStartedAt = intentStartedAt
|
||||
resetFlowRun()
|
||||
startFlowStep('intent', {
|
||||
title: '业务意图识别',
|
||||
tool: 'ontology.intent_detection',
|
||||
detail: '正在识别是否为费用申请事项...'
|
||||
})
|
||||
startFlowStep('application-review-preview', {
|
||||
title: '申请信息核对',
|
||||
tool: 'ontology.application_review',
|
||||
detail: '正在复核申请信息,并查询交通票价...'
|
||||
})
|
||||
if (stewardDelegated) {
|
||||
resetStewardDelegatedInsightState()
|
||||
} else {
|
||||
resetFlowRun()
|
||||
startFlowStep('intent', {
|
||||
title: '业务意图识别',
|
||||
tool: 'ontology.intent_detection',
|
||||
detail: '正在识别是否为费用申请事项...'
|
||||
})
|
||||
startFlowStep('application-review-preview', {
|
||||
title: '申请信息核对',
|
||||
tool: 'ontology.application_review',
|
||||
detail: '正在复核申请信息,并查询交通票价...'
|
||||
})
|
||||
}
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
const pendingMessage = createMessage(
|
||||
'assistant',
|
||||
'正在复核申请信息,并查询交通票价,请稍候。',
|
||||
stewardDelegated ? '' : '正在复核申请信息,并查询交通票价,请稍候。',
|
||||
[],
|
||||
{
|
||||
meta: ['模型复核中']
|
||||
}
|
||||
stewardDelegated
|
||||
? {
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
|
||||
stewardContinuation: options.stewardContinuation || null,
|
||||
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
|
||||
}
|
||||
: {
|
||||
meta: ['模型复核中']
|
||||
}
|
||||
)
|
||||
messages.value.push(pendingMessage)
|
||||
composerDraft.value = ''
|
||||
@@ -697,27 +941,50 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText, selectedBusinessTimeContext)
|
||||
const reviewStatus = String(meta?.[1] || '').trim()
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
'application-review-preview',
|
||||
reviewStatus === '模型复核完成'
|
||||
? '模型复核完成,已生成申请核对表'
|
||||
: reviewStatus === '模型复核失败'
|
||||
? '模型复核失败,已生成临时核对表'
|
||||
: '模型未返回稳定结果,已完成规则兜底核对',
|
||||
Date.now() - reviewStartedAt
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
|
||||
rawText,
|
||||
selectedBusinessTimeContext,
|
||||
effectiveSessionType
|
||||
)
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
[],
|
||||
{
|
||||
meta,
|
||||
applicationPreview
|
||||
}
|
||||
))
|
||||
const reviewStatus = String(meta?.[1] || '').trim()
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
'application-review-preview',
|
||||
reviewStatus === '模型复核完成'
|
||||
? '模型复核完成,已生成申请核对表'
|
||||
: reviewStatus === '模型复核失败'
|
||||
? '模型复核失败,已生成临时核对表'
|
||||
: '模型未返回稳定结果,已完成规则兜底核对',
|
||||
Date.now() - reviewStartedAt
|
||||
)
|
||||
}
|
||||
if (stewardDelegated) {
|
||||
await typeStewardDelegatedMessage(
|
||||
pendingMessage.id,
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
{
|
||||
meta,
|
||||
applicationPreview,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
} else {
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
[],
|
||||
{
|
||||
meta,
|
||||
applicationPreview,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
))
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
@@ -726,7 +993,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (await promptUnlinkedReceiptFolderIfNeeded({
|
||||
if (!stewardDelegated && await promptUnlinkedReceiptFolderIfNeeded({
|
||||
detailScopedClaimId,
|
||||
files,
|
||||
fileNames,
|
||||
@@ -741,7 +1008,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
|
||||
const hasUnsavedReviewDraft = Boolean(
|
||||
!isKnowledgeSession.value &&
|
||||
!stewardDelegated &&
|
||||
!effectiveIsKnowledgeSession &&
|
||||
files.length &&
|
||||
activeReviewPayload.value &&
|
||||
!String(draftClaimId.value || '').trim() &&
|
||||
@@ -782,7 +1050,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
|
||||
if (
|
||||
!isKnowledgeSession.value &&
|
||||
!stewardDelegated &&
|
||||
!effectiveIsKnowledgeSession &&
|
||||
files.length &&
|
||||
!resolvedUploadDisposition &&
|
||||
!options.skipDraftAssociationPrompt &&
|
||||
@@ -836,18 +1105,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!appendToCurrentFlow) {
|
||||
if (stewardDelegated) {
|
||||
resetStewardDelegatedInsightState()
|
||||
} else if (!appendToCurrentFlow) {
|
||||
resetFlowRun()
|
||||
} else {
|
||||
clearFlowSimulationTimers()
|
||||
}
|
||||
if (isApplicationSubmitOperation) {
|
||||
if (!stewardDelegated && isApplicationSubmitOperation) {
|
||||
startFlowStep('application-submit-success', {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
detail: '正在提交费用申请...'
|
||||
})
|
||||
} else if (rawText && !reviewAction) {
|
||||
} else if (!stewardDelegated && rawText && !reviewAction) {
|
||||
startFlowStep('intent', '正在识别业务意图...')
|
||||
if (waitForExpenseIntentConfirmation) {
|
||||
startExpenseIntentConfirmationFlowPreview(rawText)
|
||||
@@ -906,19 +1177,26 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
const pendingMessage = createMessage(
|
||||
'assistant',
|
||||
options.pendingText || (
|
||||
isKnowledgeSession.value
|
||||
stewardDelegated ? '' : options.pendingText || (
|
||||
effectiveIsKnowledgeSession
|
||||
? '正在整理财务知识答案...'
|
||||
: activeSessionType.value === 'application'
|
||||
: effectiveSessionType === 'application'
|
||||
? '正在识别申请信息并查询交通票价...'
|
||||
: activeSessionType.value === 'approval'
|
||||
: effectiveSessionType === 'approval'
|
||||
? '正在查询审核上下文并整理风险提示...'
|
||||
: '正在识别并整理右侧核对信息...'
|
||||
),
|
||||
[],
|
||||
{
|
||||
meta: ['处理中']
|
||||
}
|
||||
stewardDelegated
|
||||
? {
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
|
||||
stewardContinuation: options.stewardContinuation || null,
|
||||
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
|
||||
}
|
||||
: {
|
||||
meta: ['处理中']
|
||||
}
|
||||
)
|
||||
messages.value.push(pendingMessage)
|
||||
|
||||
@@ -946,14 +1224,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
if (files.length) {
|
||||
const ocrStartedAt = Date.now()
|
||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||
if (!stewardDelegated) {
|
||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||
}
|
||||
if (recognizedAttachmentData) {
|
||||
ocrPayload = recognizedAttachmentData.ocrPayload
|
||||
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
|
||||
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
|
||||
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
ocrPayload = await recognizeOcrFiles(files, {
|
||||
@@ -964,14 +1246,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedUploadDisposition === 'continue_existing') {
|
||||
if (!stewardDelegated && resolvedUploadDisposition === 'continue_existing') {
|
||||
replaceMessage(pendingMessage.id, {
|
||||
...pendingMessage,
|
||||
text: attachmentAssociationConfirmed
|
||||
@@ -1069,15 +1355,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
extraContext.review_action = 'create_new_claim_from_documents'
|
||||
}
|
||||
|
||||
if (!isApplicationSubmitOperation) {
|
||||
if (!isApplicationSubmitOperation && !stewardDelegated) {
|
||||
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
|
||||
attachmentCount: effectiveFileNames.length,
|
||||
waitForSceneSelection: waitForExpenseSceneSelection
|
||||
})
|
||||
}
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const orchestratorOptions = isKnowledgeSession.value
|
||||
const backendMessage = buildBackendMessage(
|
||||
rawText,
|
||||
effectiveFileNames,
|
||||
effectiveOcrSummary,
|
||||
effectiveSessionType
|
||||
)
|
||||
const orchestratorOptions = effectiveIsKnowledgeSession
|
||||
? {
|
||||
timeoutMs: 75000,
|
||||
timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。'
|
||||
@@ -1117,22 +1408,22 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
|
||||
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
|
||||
...buildClientTimeContext(),
|
||||
session_type: activeSessionType.value,
|
||||
session_type: effectiveSessionType,
|
||||
entry_source: props.entrySource,
|
||||
user_input_text: systemGenerated ? '' : rawText,
|
||||
attachment_names: effectiveFileNames,
|
||||
attachment_count: effectiveFileNames.length,
|
||||
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
|
||||
draft_claim_id: effectiveIsKnowledgeSession ? undefined : draftClaimId.value || undefined,
|
||||
ocr_summary: effectiveOcrSummary,
|
||||
ocr_documents: effectiveOcrDocuments,
|
||||
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
|
||||
...(linkedRequest.value && !effectiveIsKnowledgeSession ? { request_context: linkedRequest.value } : {}),
|
||||
...extraContext
|
||||
}
|
||||
},
|
||||
orchestratorOptions
|
||||
)
|
||||
responsePayload = payload
|
||||
flowRunId.value = String(payload?.run_id || '').trim()
|
||||
flowRunId.value = stewardDelegated ? '' : String(payload?.run_id || '').trim()
|
||||
let flowRunDetail = null
|
||||
if (flowRunId.value) {
|
||||
flowRunDetail = await refreshFlowRunDetail()
|
||||
@@ -1140,7 +1431,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
|
||||
draftClaimId.value =
|
||||
isKnowledgeSession.value
|
||||
effectiveIsKnowledgeSession
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
@@ -1163,33 +1454,54 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
reviewPanelScope: resolveReviewPanelScope({
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
reviewAction: reviewActionResult,
|
||||
fileCount: files.length,
|
||||
rawText
|
||||
}),
|
||||
reviewPanelScope: stewardDelegated
|
||||
? ''
|
||||
: resolveReviewPanelScope({
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
reviewAction: reviewActionResult,
|
||||
fileCount: files.length,
|
||||
rawText
|
||||
}),
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
|
||||
operationFeedback: buildOperationFeedbackState(operationFeedbackContext)
|
||||
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
|
||||
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
})
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
const nextInsight = buildAgentInsight(
|
||||
payload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
if (nextInsight.agent) {
|
||||
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
|
||||
if (stewardDelegated) {
|
||||
await typeStewardDelegatedMessage(
|
||||
pendingMessage.id,
|
||||
assistantMessage.text,
|
||||
{
|
||||
...assistantMessage,
|
||||
id: pendingMessage.id,
|
||||
reviewPanelScope: ''
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
resetStewardDelegatedInsightState()
|
||||
} else {
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
const nextInsight = buildAgentInsight(
|
||||
payload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
if (nextInsight.agent) {
|
||||
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
|
||||
}
|
||||
currentInsight.value = nextInsight
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
}
|
||||
currentInsight.value = nextInsight
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewActionResult)) {
|
||||
emitSavedDraftRefresh(payload?.result?.draft_payload || null)
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
|
||||
const persistComposerFilesToDraft = async () => {
|
||||
try {
|
||||
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
@@ -1216,19 +1528,31 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
failCurrentFlowStep(error)
|
||||
if (!stewardDelegated) {
|
||||
failCurrentFlowStep(error)
|
||||
}
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage(
|
||||
'assistant',
|
||||
error?.message || '无法连接后端 Orchestrator,请稍后重试。',
|
||||
[],
|
||||
{
|
||||
meta: ['调用失败']
|
||||
}
|
||||
stewardDelegated
|
||||
? {
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME, '调用失败'],
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
: {
|
||||
meta: ['调用失败']
|
||||
}
|
||||
)
|
||||
)
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
if (stewardDelegated) {
|
||||
resetStewardDelegatedInsightState()
|
||||
} else {
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
}
|
||||
persistSessionState()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
|
||||
Reference in New Issue
Block a user