feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

View File

@@ -13,7 +13,10 @@ import {
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
applicationDateRangesOverlap,
normalizeApplicationPreview,
normalizeTransportModeOption,
resolveApplicationDateRange,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
@@ -21,16 +24,275 @@ import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
import { fetchStewardSlotDecision } from '../../services/steward.js'
import {
handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
applicationType: 'expense_type',
time: 'time_range',
location: 'location',
reason: 'reason',
amount: 'amount',
transportMode: 'transport_mode',
department: 'department_name',
applicant: 'employee_name',
grade: 'employee_grade'
}
const APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP = {
费用类型: 'expense_type',
申请类型: 'expense_type',
发生时间: 'time_range',
出发时间: 'time_range',
申请时间: 'time_range',
地点: 'location',
事由: 'reason',
金额: 'amount',
系统预估费用: 'amount',
出行方式: 'transport_mode',
附件: 'attachments',
'附件/凭证': 'attachments',
商户: 'merchant_name',
'商户/开票方': 'merchant_name',
客户: 'customer_name',
客户或项目对象: 'customer_name'
}
const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
expense_type: 'applicationType',
time_range: 'time',
location: 'location',
reason: 'reason',
amount: 'amount',
transport_mode: 'transportMode',
department_name: 'department',
employee_name: 'applicant',
employee_grade: 'grade'
}
const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
expense_type: '费用类型',
time_range: '时间',
location: '地点',
reason: '事由',
amount: '金额',
transport_mode: '出行方式',
attachments: '附件/凭证',
customer_name: '客户或项目对象',
merchant_name: '商户/开票方',
department_name: '所属部门',
employee_name: '申请人',
employee_grade: '职级'
}
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
'amount',
'attachments',
'employee_no',
'department_name',
'employee_name'
])
const APPLICATION_DUPLICATE_IGNORED_STATUSES = new Set([
'cancelled',
'canceled',
'void',
'voided',
'deleted',
'已取消',
'已作废',
'作废',
'已删除'
])
function normalizeClaimListPayload(payload) {
if (Array.isArray(payload)) {
return payload
}
return Array.isArray(payload?.items) ? payload.items : []
}
function normalizeClaimRiskFlags(claim) {
const flags = claim?.risk_flags_json || claim?.riskFlagsJson || claim?.riskFlags || []
if (Array.isArray(flags)) {
return flags
}
return flags && typeof flags === 'object' ? [flags] : []
}
function extractApplicationDetailFromClaim(claim) {
return normalizeClaimRiskFlags(claim).reduce((found, item) => {
if (found || !item || typeof item !== 'object') {
return found
}
const detail = item.application_detail || item.applicationDetail
return detail && typeof detail === 'object' ? detail : null
}, null)
}
function isApplicationClaimRecord(claim) {
const expenseType = String(claim?.expense_type || claim?.expenseType || '').trim().toLowerCase()
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
return (
expenseType === 'application' ||
expenseType === 'expense_application' ||
expenseType.endsWith('_application') ||
claimNo.startsWith('AP-') ||
claimNo.startsWith('APP-') ||
Boolean(extractApplicationDetailFromClaim(claim))
)
}
function normalizeApplicationExpenseType(value) {
const text = String(value || '').trim().toLowerCase()
if (!text) {
return ''
}
if (text === 'travel_application' || /差旅|出差/.test(text)) {
return 'travel_application'
}
if (text === 'purchase_application' || /采购/.test(text)) {
return 'purchase_application'
}
if (text === 'meeting_application' || /会务|会议/.test(text)) {
return 'meeting_application'
}
if (text === 'expense_application' || text === 'application' || text.endsWith('_application')) {
return text === 'application' ? 'expense_application' : text
}
return 'expense_application'
}
function resolveClaimApplicationExpenseType(claim) {
const detail = extractApplicationDetailFromClaim(claim) || {}
return normalizeApplicationExpenseType(
claim?.expense_type ||
claim?.expenseType ||
detail.application_type ||
detail.applicationType ||
''
)
}
function isIgnoredApplicationDuplicateStatus(status) {
return APPLICATION_DUPLICATE_IGNORED_STATUSES.has(String(status || '').trim().toLowerCase())
}
function resolveClaimApplicationDateRange(claim) {
const detail = extractApplicationDetailFromClaim(claim) || {}
return (
resolveApplicationDateRange(
detail.time ||
detail.time_range ||
detail.timeRange ||
detail.application_time ||
detail.applicationTime ||
detail.application_business_time ||
detail.applicationBusinessTime ||
detail.application_date ||
detail.applicationDate,
detail.days || detail.application_days || detail.applicationDays
) ||
resolveApplicationDateRange(claim?.occurred_at || claim?.occurredAt || '')
)
}
function formatApplicationDateRangeLabel(range) {
if (!range?.startDate) {
return '待确认'
}
return range.startDate === range.endDate ? range.startDate : `${range.startDate}${range.endDate}`
}
function findOverlappingApplicationClaim(applicationPreview, claimsPayload) {
const preview = normalizeApplicationPreview(applicationPreview)
const fields = preview.fields || {}
const currentRange = resolveApplicationDateRange(fields.time, fields.days)
if (!currentRange) {
return null
}
const currentExpenseType = normalizeApplicationExpenseType(fields.applicationType)
const claims = normalizeClaimListPayload(claimsPayload)
for (const claim of claims) {
if (!isApplicationClaimRecord(claim) || isIgnoredApplicationDuplicateStatus(claim?.status)) {
continue
}
const existingExpenseType = resolveClaimApplicationExpenseType(claim)
if (currentExpenseType && existingExpenseType && currentExpenseType !== existingExpenseType) {
continue
}
const existingRange = resolveClaimApplicationDateRange(claim)
if (!existingRange || !applicationDateRangesOverlap(currentRange, existingRange)) {
continue
}
return {
claim,
currentRange,
existingRange,
claimId: String(claim?.id || claim?.claim_id || claim?.claimId || '').trim(),
claimNo: String(claim?.claim_no || claim?.claimNo || claim?.id || '').trim(),
status: String(claim?.approval_stage || claim?.approvalStage || claim?.status || '').trim(),
reason: String(claim?.reason || '').trim(),
location: String(claim?.location || '').trim()
}
}
return null
}
function buildApplicationDateConflictMessage(conflict) {
const claimNo = conflict?.claimNo || '已有申请'
return [
'我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
'',
'已有申请:',
`- **单号**${claimNo}`,
`- **申请时间**${formatApplicationDateRangeLabel(conflict?.existingRange)}`,
conflict?.location ? `- **地点**${conflict.location}` : '',
conflict?.reason ? `- **事由**${conflict.reason}` : '',
`- **当前节点**${conflict?.status || '处理中'}`,
'',
`本次识别时间:${formatApplicationDateRangeLabel(conflict?.currentRange)}`,
'',
'请先查看已有申请,或修改本次出差时间后再继续。'
].filter(Boolean).join('\n')
}
function buildApplicationDateConflictActions(conflict) {
const actions = []
if (conflict?.claimId) {
actions.push({
action_type: 'open_application_detail',
label: '查看已有申请',
description: conflict.claimNo ? `进入 ${conflict.claimNo} 单据详情。` : '进入已有申请单据详情。',
icon: 'mdi mdi-file-search-outline',
payload: {
claim_id: conflict.claimId,
claim_no: conflict.claimNo
}
})
}
actions.push({
action_type: 'prefill_composer',
label: '修改出差时间',
description: '在输入框中补充新的出差日期后继续。',
icon: 'mdi mdi-calendar-edit-outline',
payload: {
prompt_prefill: '修改出差时间为:'
}
})
return actions
}
export function useTravelReimbursementSubmitComposer(ctx) {
const {
MAX_ATTACHMENTS,
@@ -145,8 +407,21 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
}
function isBlockingApplicationOntologyField(key = '') {
const normalizedKey = String(key || '').trim()
return Boolean(normalizedKey && !APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS.has(normalizedKey))
}
function resolveBlockingApplicationMissingFieldsForSteward(preview = {}) {
return resolveApplicationPreviewMissingFieldsForSteward(preview).filter((label) => {
const ontologyKey = APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || ''
return !ontologyKey || isBlockingApplicationOntologyField(ontologyKey)
})
}
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview)
const normalized = normalizeApplicationPreview(preview)
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
if (!missingFields.includes('出行方式')) {
return []
}
@@ -158,77 +433,298 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label: mode,
description: `选择${mode}作为本次出行方式,并同步费用测算`,
description: `选择${mode}后,由小财管家继续查询票价并测算费用`,
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
payload: {
field_key: 'transportMode',
field_label: '出行方式',
value: mode
value: mode,
applicationPreview: normalized,
steward_delegated_field_completion: true
}
}))
}
function resolveStewardContinuationCurrentTask(continuation = null) {
const task = continuation?.currentTask || continuation?.current_task || null
return task && typeof task === 'object' ? task : null
}
function normalizeCanonicalFieldList(fields = []) {
const normalized = []
if (!Array.isArray(fields)) {
return normalized
}
fields.forEach((field) => {
const key = String(field || '').trim()
if (key && !normalized.includes(key)) {
normalized.push(key)
}
})
return normalized
}
function buildStewardSlotDecisionOntologyFields(preview = {}, continuation = null) {
const normalizedPreview = normalizeApplicationPreview(preview)
const previewFields = normalizedPreview.fields || {}
const task = resolveStewardContinuationCurrentTask(continuation)
const taskFields = task?.ontology_fields || task?.ontologyFields || {}
const fields = {}
Object.entries(taskFields || {}).forEach(([key, value]) => {
const normalizedKey = String(key || '').trim()
const normalizedValue = String(value || '').trim()
if (normalizedKey && normalizedValue) {
fields[normalizedKey] = normalizedValue
}
})
Object.entries(APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP).forEach(([previewKey, ontologyKey]) => {
const value = String(previewFields[previewKey] || '').trim()
if (value && value !== '待补充' && !fields[ontologyKey]) {
fields[ontologyKey] = value
}
})
return fields
}
function buildStewardSlotDecisionMissingFields(preview = {}, continuation = null, ontologyFields = {}) {
const task = resolveStewardContinuationCurrentTask(continuation)
const taskMissingFields = normalizeCanonicalFieldList(task?.missing_fields || task?.missingFields || [])
.filter((key) => isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim())
if (taskMissingFields.length) {
return taskMissingFields
}
return resolveApplicationPreviewMissingFieldsForSteward(preview)
.map((label) => APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '')
.filter((key, index, list) =>
key &&
isBlockingApplicationOntologyField(key) &&
!String(ontologyFields[key] || '').trim() &&
list.indexOf(key) === index
)
}
async function fetchStewardApplicationSlotDecision(preview = {}, rawText = '', continuation = null) {
const ontologyFields = buildStewardSlotDecisionOntologyFields(preview, continuation)
const missingFields = buildStewardSlotDecisionMissingFields(preview, continuation, ontologyFields)
try {
return await fetchStewardSlotDecision({
task_type: 'expense_application',
user_message: String(rawText || '').trim(),
ontology_fields: ontologyFields,
missing_fields: missingFields,
task_context: {
steward_continuation: continuation || null,
application_preview: normalizeApplicationPreview(preview)
}
}, {
timeoutMs: 45000,
timeoutMessage: '小财管家字段决策超时,已按当前本体缺口继续追问。'
})
} catch (error) {
console.warn('Steward slot decision failed:', error)
return null
}
}
function formatStewardDecisionUserText(text = '') {
let formatted = String(text || '').trim()
Object.entries(ONTOLOGY_FIELD_DISPLAY_LABEL_MAP).forEach(([fieldKey, label]) => {
const escapedKey = fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
formatted = formatted
.replace(new RegExp(`\\s*${escapedKey}\\s*`, 'g'), '')
.replace(new RegExp(`\\(\\s*${escapedKey}\\s*\\)`, 'g'), '')
.replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), label)
})
return formatted.replace(/\s{2,}/g, ' ').trim()
}
function buildStewardSlotDecisionMessage(decision = null, preview = {}, fallbackText = '') {
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
return fallbackText
}
const question = formatStewardDecisionUserText(decision.question || '')
const rationale = formatStewardDecisionUserText(decision.rationale || '')
const parts = [
'我已经识别出这一步要先处理申请单,但当前还不能直接生成可提交的申请核对表。',
'',
rationale ? `**原因是:${rationale}**` : '',
'',
question || buildStewardApplicationPreviewMessage(preview, fallbackText)
].filter((item) => item !== '')
return parts.join('\n')
}
function buildStewardSlotDecisionSuggestedActions(decision = null, preview = {}) {
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
return []
}
const normalizedPreview = normalizeApplicationPreview(preview)
const iconMap = {
火车: 'mdi mdi-train',
飞机: 'mdi mdi-airplane',
轮船: 'mdi mdi-ferry'
}
const actions = Array.isArray(decision.options) ? decision.options : []
return actions.map((option) => {
const canonicalField = String(option?.field_key || option?.fieldKey || '').trim()
if (canonicalField && !isBlockingApplicationOntologyField(canonicalField)) {
return null
}
const fieldKey = ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP[canonicalField] || canonicalField
const value = String(option?.value || option?.label || '').trim()
const label = String(option?.label || value).trim()
const normalizedValue = fieldKey === 'transportMode'
? normalizeTransportModeOption(value || label, '')
: value
if (!fieldKey || !value || !label) {
return null
}
if (fieldKey === 'transportMode' && !normalizedValue) {
return null
}
return {
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label,
description: String(option?.description || '').trim() || `选择${label}后,由小财管家继续测算并生成核对结果。`,
icon: iconMap[label] || iconMap[value] || 'mdi mdi-form-select',
payload: {
field_key: fieldKey,
field_label: ONTOLOGY_FIELD_DISPLAY_LABEL_MAP[canonicalField] || label,
value: normalizedValue,
applicationPreview: normalizedPreview,
steward_delegated_field_completion: true
}
}
}).filter(Boolean)
}
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
if (!missingFields.length) {
return fallbackText
}
if (missingFields.includes('出行方式')) {
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。'
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
'',
`**还需要你补充:${missingFields.join('、')}。**`,
'',
`请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。`
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。`
].join('\n')
}
function shouldPauseStewardApplicationPreview(preview = {}) {
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
}
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 extractStewardDelegatedTaskTitle(text = '', sessionType = '') {
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[:]([^。\n]+)/u)
if (taskMatch?.[1]) {
return taskMatch[1].trim()
}
return String(sessionType || '').trim() === 'application' ? '本次出差申请' : '本次费用报销'
}
function sanitizeStewardDelegatedTaskSummary(summary = '', sessionType = '') {
const text = String(summary || '').trim()
if (String(sessionType || '').trim() !== 'application') {
return text
}
return text
.replace(/交通方式和(?:预算|预计)?金额待补充/g, '交通方式待补充')
.replace(/出行方式和(?:预算|预计)?金额待补充/g, '出行方式待补充')
.replace(/交通方式及(?:预估|预计|预算)?费用/g, '交通方式')
.replace(/出行方式及(?:预估|预计|预算)?费用/g, '出行方式')
.replace(/(?:费用预算|预算费用|出差费用预算)(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)?[,、;;\s]*/g, '')
.replace(/[,、;;\s]*(?:预估|预计|预算)费用(?:待补充|待确认|待填写|需补充|需要补充|需确认|需要确认|未补充|缺失)?/g, '')
.replace(/[,、;;\s]*(?:预算|预计)?金额(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)/g, '')
.replace(/([,、;;。])\1+/g, '$1')
.replace(/[,、;;\s]+。/g, '。')
.replace(/[,、;;\s]+$/g, '')
.trim()
}
function summarizeApplicationPreviewForSteward(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
return [
fields.time ? `时间:${fields.time}` : '',
fields.location ? `地点:${fields.location}` : '',
fields.reason ? `事由:${fields.reason}` : '',
fields.applicationType ? `类型:${fields.applicationType}` : ''
].filter(Boolean).join('')
}
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
const rawText = String(context.rawText || '').trim()
const taskTitle = extractStewardDelegatedTaskTitle(rawText, sessionType)
const taskSummary = sanitizeStewardDelegatedTaskSummary(
extractStewardCarryLine(rawText, '任务摘要'),
sessionType
)
const identifiedInfo = summarizeApplicationPreviewForSteward(context.applicationPreview)
|| extractStewardCarryLine(rawText, '已识别信息')
const carryMissingInfo = extractStewardCarryLine(rawText, '还需要补充')
const applicationMissingFields = context.applicationPreview
? resolveBlockingApplicationMissingFieldsForSteward(context.applicationPreview)
: []
const missingInfo = applicationMissingFields.length
? applicationMissingFields.join('、')
: carryMissingInfo
const events = [
{
eventId: `${eventPrefix}-confirm`,
title: '接收确认',
content: '已收到你的确认,小财管家继续推进当前任务。'
eventId: `${eventPrefix}-intent`,
title: '理解当前任务',
content: taskSummary
? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}`
: `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
},
{
eventId: `${eventPrefix}-coordinate`,
title: '协调能力',
content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。`
eventId: `${eventPrefix}-known`,
title: '核对已知信息',
content: identifiedInfo
? `当前已识别到:${identifiedInfo}`
: `当前先围绕“${taskTitle}”生成可核对内容,具体缺口会在核对结果里继续判断。`
}
]
const applicationMissingFields = context.applicationPreview
? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview)
: []
if (applicationMissingFields.length) {
if (missingInfo) {
const transportMissing = /出行方式/.test(missingInfo)
events.push({
eventId: `${eventPrefix}-gap`,
title: '识别缺口',
content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。`
title: '判断待补充信息',
content: transportMissing
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
})
} else {
events.push({
eventId: `${eventPrefix}-ready`,
title: '判断下一步动作',
content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
})
}
events.push(
{
eventId: `${eventPrefix}-output`,
title: '准备输出',
content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。'
}
)
return events
}
@@ -257,6 +753,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
const pendingSuggestedActions = Array.isArray(finalExtras.suggestedActions)
? finalExtras.suggestedActions
: []
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
@@ -287,11 +786,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
nextTick(scrollToBottom)
const chars = Array.from(String(eventData.content || ''))
for (let index = 0; index < chars.length; index += 1) {
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
event.content = chars.slice(0, index + 1).join('')
index = Math.min(chars.length, index + STEWARD_DELEGATED_THINKING_CHUNK_SIZE)
event.content = chars.slice(0, index).join('')
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
if (index % STEWARD_DELEGATED_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
@@ -304,14 +804,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const text = String(finalText || '')
message.text = ''
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.suggestedActions = pendingSuggestedActions
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
const chars = Array.from(text)
for (let index = 0; index < chars.length; index += 1) {
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
message.text = chars.slice(0, index + 1).join('')
index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
message.text = chars.slice(0, index).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
@@ -670,7 +1172,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return currentUser.value || user
}
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
async function buildApplicationPreviewWithModelReview(
rawText,
businessTimeContext = null,
sessionTypeOverride = '',
options = {}
) {
const user = await resolveApplicationPreviewUser()
const localPreview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(rawText, user),
@@ -697,6 +1204,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
if (options.skipModelReview) {
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
modelReviewStatus: 'skipped'
}),
meta: ['申请核对预览', '结构化快路径']
}
}
try {
const ontology = await fetchOntologyParse(
{
@@ -828,7 +1345,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (shouldUseBudgetCompileReport(rawText, {
if (!stewardDelegated && shouldUseBudgetCompileReport(rawText, {
sessionType: effectiveSessionType,
entrySource: props.entrySource,
budgetContext: props.initialBudgetContext
@@ -944,9 +1461,62 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
rawText,
selectedBusinessTimeContext,
effectiveSessionType
effectiveSessionType,
{
skipModelReview: Boolean(stewardDelegated && options.skipApplicationModelReview)
}
)
const reviewStatus = String(meta?.[1] || '').trim()
let applicationDateConflict = null
try {
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
} catch (error) {
console.warn('Failed to check overlapping application dates:', error)
}
if (applicationDateConflict) {
const conflictText = buildApplicationDateConflictMessage(applicationDateConflict)
const conflictActions = buildApplicationDateConflictActions(applicationDateConflict)
if (!stewardDelegated) {
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
'application-review-preview',
'检测到同日期已有申请,已停止重复创建',
Date.now() - reviewStartedAt
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
conflictText,
[],
{
meta: ['申请日期冲突'],
suggestedActions: conflictActions,
stewardContinuation: options.stewardContinuation || null
}
))
} else {
await typeStewardDelegatedMessage(
pendingMessage.id,
conflictText,
{
meta: [STEWARD_ASSISTANT_NAME, '申请日期冲突'],
suggestedActions: conflictActions,
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
rawText,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
)
}
persistSessionState()
nextTick(scrollToBottom)
return null
}
if (!stewardDelegated) {
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
@@ -960,16 +1530,43 @@ export function useTravelReimbursementSubmitComposer(ctx) {
)
}
if (stewardDelegated) {
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
applicationPreview,
buildLocalApplicationPreviewMessage(applicationPreview)
)
const localPauseForMissingFields = shouldPauseStewardApplicationPreview(applicationPreview)
const shouldFetchSlotDecision = localPauseForMissingFields && !options.skipStewardSlotDecision
const slotDecision = shouldFetchSlotDecision
? await fetchStewardApplicationSlotDecision(
applicationPreview,
rawText,
options.stewardContinuation || null
)
: null
const slotDecisionActions = buildStewardSlotDecisionSuggestedActions(slotDecision, applicationPreview)
const pauseForMissingFields = slotDecision
? String(slotDecision.next_action || '').trim() === 'ask_user'
: localPauseForMissingFields
const stewardApplicationText = buildStewardSlotDecisionMessage(
slotDecision,
applicationPreview,
fallbackStewardApplicationText
)
await typeStewardDelegatedMessage(
pendingMessage.id,
buildLocalApplicationPreviewMessage(applicationPreview),
stewardApplicationText,
{
meta,
applicationPreview,
applicationPreview: pauseForMissingFields ? null : applicationPreview,
suggestedActions: slotDecisionActions.length
? slotDecisionActions
: buildStewardApplicationPreviewSuggestedActions(applicationPreview),
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
rawText,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
)
@@ -1478,6 +2075,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
},
{
sessionType: effectiveSessionType,
rawText,
fileNames: effectiveFileNames,
stewardContinuation: options.stewardContinuation || null
}
)