feat(web): 工作台 AI 模式与差旅/风险建议交互优化

- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
caoxiaozhu
2026-06-18 22:12:24 +08:00
parent a6674a1e76
commit 0cde1f8990
65 changed files with 8011 additions and 1608 deletions

View File

@@ -24,7 +24,8 @@ import {
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import {
buildStewardFieldCompletionContinuation,
buildStewardFieldCompletionRawText
buildStewardFieldCompletionRawText,
resolveStewardRuntimeFieldCompletion
} from './stewardFieldCompletionModel.js'
import {
buildOperationFeedbackPayload,
@@ -169,8 +170,6 @@ import {
buildFileIdentity,
buildFilePreviews,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFilePreviewsFromReviewPayload,
extractReviewAttachmentNames,
@@ -179,7 +178,6 @@ import {
mergeFilesWithLimit,
mergeUploadAttachmentNames,
mergeUploadOcrDocuments,
normalizeOcrDocuments,
resolveAttachmentPreviewKind,
resolveDocumentPreview
} from './travelReimbursementAttachmentModel.js'
@@ -1121,8 +1119,6 @@ export default {
buildExpenseSceneSelectionMessage,
buildMessageMeta,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFormContextFromPayload,
clearAttachedFiles,
@@ -1155,7 +1151,6 @@ export default {
messages,
nextTick,
normalizeExpenseQueryPayload,
normalizeOcrDocuments,
persistSessionState,
props,
recognizeOcrFiles,
@@ -1904,6 +1899,10 @@ export default {
})
return
}
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
pushExpenseSceneSelectionPrompt(carryText)
return
}
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText
@@ -2141,6 +2140,9 @@ export default {
}
function buildMessageBubbleClass(message) {
if (message?.role === 'assistant' && message?.assistantVariant === 'compact_guidance') {
return 'message-bubble-compact-guidance'
}
if (message?.role === 'assistant' && message?.budgetReport) {
return 'message-bubble-budget-report'
}
@@ -2965,6 +2967,10 @@ export default {
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
}
}
const fieldCompletionDecision = resolveStewardRuntimeFieldCompletion(normalizedText, runtimeState)
if (fieldCompletionDecision) {
return fieldCompletionDecision
}
}
return null
}
@@ -3082,6 +3088,39 @@ export default {
})
return true
}
if (nextAction === 'fill_current_application_field') {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetMessage = targetMessageId
? messages.value.find((message) => String(message.id || '') === targetMessageId)
: findLatestApplicationPreviewMessage()
if (!targetMessage?.applicationPreview) {
return false
}
const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim()
const fieldLabel = String(decision?.field_label || decision?.fieldLabel || '').trim()
const fieldValue = String(decision?.field_value || decision?.fieldValue || rawText).trim()
if (!fieldKey || !fieldValue) {
return false
}
await continueStewardApplicationFieldCompletion({
targetMessage,
action: {
label: fieldValue,
suppressUserEcho: userMessageAlreadyAdded,
payload: {
steward_delegated_field_completion: true,
field_key: fieldKey,
field_label: fieldLabel,
value: fieldValue
}
},
sourcePreview: targetMessage.applicationPreview,
fieldKey,
fieldLabel,
value: fieldValue
})
return true
}
if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') {
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
return true

View File

@@ -1751,12 +1751,12 @@ export default {
const aiAdviceTitle = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
return '报销风险提示'
return '风险提示'
}
if (isEditableRequest.value && isApplicationDocument.value) {
return '表单自查提示'
}
return isEditableRequest.value ? 'AI建议' : 'AI提示'
return isEditableRequest.value ? 'AI建议' : '风险提示'
})
const aiAdviceHint = computed(() => (
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value

View File

@@ -24,6 +24,35 @@ const APPLICATION_PREVIEW_FIELD_LABEL_MAP = {
grade: '职级'
}
const STEWARD_RUNTIME_FIELD_COMPLETION_RULES = [
{ fieldKey: 'reason', fieldLabel: '事由', pattern: /事由|申请事由|出差事由|原因|用途/ },
{ fieldKey: 'transportMode', fieldLabel: '出行方式', pattern: /出行方式|交通方式|交通工具|出行工具/ },
{ fieldKey: 'time', fieldLabel: '申请时间', pattern: /申请时间|发生时间|业务发生时间|出发时间|返回时间|时间/ },
{ fieldKey: 'location', fieldLabel: '地点', pattern: /地点|业务地点|发生地点|目的地/ },
{ fieldKey: 'days', fieldLabel: '天数', pattern: /天数|出差天数|申请天数/ },
{ fieldKey: 'amount', fieldLabel: '系统预估费用', pattern: /系统预估费用|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额/ }
]
const APPLICATION_TYPE_DISPLAY_MAP = {
travel: '差旅费用申请',
travel_application: '差旅费用申请',
expense_application: '费用申请',
application: '费用申请',
transportation: '交通费用申请',
traffic: '交通费用申请',
transport: '交通费用申请',
accommodation: '住宿费用申请',
hotel: '住宿费用申请',
meeting: '会务费用申请',
conference: '会务费用申请',
purchase: '采购费用申请',
procurement: '采购费用申请',
training: '培训费用申请',
business_entertainment: '业务招待申请',
entertainment: '业务招待申请',
office: '办公费用申请'
}
function compactValue(value = '') {
return String(value || '').trim()
}
@@ -48,6 +77,22 @@ function resolveFieldValue(...candidates) {
return ''
}
function resolveApplicationTypeDisplay(value = '') {
const rawValue = compactValue(value)
if (!rawValue) return ''
const normalizedKey = rawValue.toLowerCase()
if (APPLICATION_TYPE_DISPLAY_MAP[normalizedKey]) {
return APPLICATION_TYPE_DISPLAY_MAP[normalizedKey]
}
if (/^(?:差旅费|差旅|出差)$/.test(rawValue)) return '差旅费用申请'
if (/^(?:交通费|交通)$/.test(rawValue)) return '交通费用申请'
if (/^(?:住宿费|住宿|酒店)$/.test(rawValue)) return '住宿费用申请'
if (/^(?:会务|会议|会务费)$/.test(rawValue)) return '会务费用申请'
if (/^(?:采购|采购费|办公用品)$/.test(rawValue)) return '采购费用申请'
return rawValue
}
function buildUpdatedTask(task = null, fieldKey = '', value = '') {
if (!task || typeof task !== 'object') {
return null
@@ -75,6 +120,29 @@ function buildUpdatedTask(task = null, fieldKey = '', value = '') {
}
}
function buildFieldCompletionScopeHints(fieldKey = '', selectedValue = '') {
const hints = [
'本轮是对当前申请单字段的补充/更新,不是新建申请或切换任务。'
]
if (fieldKey === 'reason') {
hints.push(
`请将“${compactValue(selectedValue)}”作为当前出差申请的事由继续处理,不要把它改判为新的 IT 部署申请。`
)
}
return hints
}
function resolveFieldRuleByKey(fieldKey = '') {
const normalizedKey = compactValue(fieldKey)
return STEWARD_RUNTIME_FIELD_COMPLETION_RULES.find((rule) => rule.fieldKey === normalizedKey) || null
}
function resolveFieldRuleByLabel(label = '') {
const normalizedLabel = compactValue(label)
if (!normalizedLabel) return null
return STEWARD_RUNTIME_FIELD_COMPLETION_RULES.find((rule) => rule.pattern.test(normalizedLabel)) || null
}
export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') {
const source = continuation && typeof continuation === 'object' ? continuation : {}
const currentTask = resolveStewardCurrentTask(source)
@@ -89,6 +157,50 @@ export function buildStewardFieldCompletionContinuation(continuation = null, fie
}
}
export function resolveStewardRuntimeFieldCompletion(rawText = '', runtimeState = {}) {
const value = compactValue(rawText)
if (!value || compactValue(runtimeState?.waiting_for) !== 'application_field_completion') {
return null
}
const slotAction = runtimeState?.pending_slot_action || runtimeState?.pendingSlotAction || null
const slotPayload = slotAction?.payload && typeof slotAction.payload === 'object' ? slotAction.payload : {}
const slotFieldKey = compactValue(slotPayload.field_key || slotPayload.fieldKey || slotAction?.field_key || slotAction?.fieldKey)
const slotRule = resolveFieldRuleByKey(slotFieldKey)
if (slotRule) {
return {
next_action: 'fill_current_application_field',
target_message_id: compactValue(slotAction?.message_id || slotAction?.messageId),
field_key: slotRule.fieldKey,
field_label: slotRule.fieldLabel,
field_value: value
}
}
const pendingApplication = runtimeState?.pending_application || runtimeState?.pendingApplication || null
const missingFields = Array.isArray(pendingApplication?.missing_fields)
? pendingApplication.missing_fields
: Array.isArray(pendingApplication?.missingFields)
? pendingApplication.missingFields
: []
if (missingFields.length !== 1) {
return null
}
const rule = resolveFieldRuleByLabel(missingFields[0])
if (!rule) {
return null
}
return {
next_action: 'fill_current_application_field',
target_message_id: compactValue(pendingApplication?.message_id || pendingApplication?.messageId),
field_key: rule.fieldKey,
field_label: rule.fieldLabel,
field_value: value
}
}
export function buildStewardFieldCompletionRawText({
preview = {},
fieldKey = '',
@@ -107,7 +219,12 @@ export function buildStewardFieldCompletionRawText({
: resolveFieldValue(fields.transportMode, ontologyFields.transport_mode)
const knownLines = [
['申请类型', resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')],
[
'申请类型',
resolveApplicationTypeDisplay(
resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')
)
],
['时间', resolveFieldValue(fields.time, ontologyFields.time_range)],
['地点', resolveFieldValue(fields.location, ontologyFields.location)],
['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)],
@@ -120,6 +237,7 @@ export function buildStewardFieldCompletionRawText({
return [
'小财管家继续执行申请单字段补齐。',
`用户已补充:${selectedLabel}${selectedValue}`,
...buildFieldCompletionScopeHints(fieldKey, selectedValue),
currentTask?.summary ? `任务摘要:${currentTask.summary}` : '',
'',
'已识别信息:',

View File

@@ -99,6 +99,10 @@ const FIELD_VALUE_DISPLAY_CONFIG = {
}
}
const FLOW_EXPENSE_TYPE_LABELS = {
travel: '差旅费'
}
export function buildStewardPlanRequest({
rawText = '',
files = [],
@@ -216,6 +220,10 @@ export function buildStewardPlanMessageText(plan) {
if (isPendingFlowConfirmationPlan(normalized)) {
return buildPendingFlowConfirmationMessageText(normalized)
}
const genericReimbursementTask = normalized.tasks.find((task) => isGenericReimbursementTask(task))
if (genericReimbursementTask && normalized.tasks.length === 1) {
return buildGenericReimbursementIntentMessageText(genericReimbursementTask)
}
const nextContext = resolveNextActionContext(normalized)
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
const taskLines = orderedTasks.map((task, index) =>
@@ -289,6 +297,42 @@ export function formatStewardOntologyFields(fields = {}, taskType = '') {
.join('')
}
function buildStewardOntologyFieldRows(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return {
label: field.label,
value: formatStewardFieldDisplayValue(field.key, value)
}
})
}
function escapeMarkdownTableCell(value) {
return String(value || '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
}
function formatStewardOntologyFieldsTable(fields = {}, taskType = '') {
const rows = buildStewardOntologyFieldRows(fields, taskType)
if (!rows.length) {
return ''
}
return [
'| 字段 | 内容 |',
'| --- | --- |',
...rows.map((row) => `| ${escapeMarkdownTableCell(row.label)} | ${escapeMarkdownTableCell(row.value)} |`)
].join('\n')
}
function resolveCandidateFlowExpenseType(flow = {}) {
const rawType = String(flow?.ontologyFields?.expense_type || flow?.ontologyFields?.expenseType || '').trim()
if (rawType === '差旅' || rawType === 'travel') {
return 'travel'
}
return rawType
}
export function buildStewardSuggestedActions(plan) {
const normalized = normalizeStewardPlan(plan)
if (isOffTopicPlan(normalized)) {
@@ -304,26 +348,32 @@ export function buildStewardSuggestedActions(plan) {
}))
}
if (isPendingFlowConfirmationPlan(normalized)) {
return normalized.candidateFlows.map((flow) => ({
label: flow.label,
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
icon: flow.flowId === 'travel_application'
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
steward_confirm_flow: true,
steward_plan_id: normalized.planId,
flow_id: flow.flowId,
session_type: flow.flowId === 'travel_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE,
selected_flow_label: flow.label,
carry_text: flow.label,
auto_submit: true,
steward_state: normalized.stewardState || null
return normalized.candidateFlows.map((flow) => {
const expenseType = resolveCandidateFlowExpenseType(flow)
return {
label: flow.label,
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
icon: flow.flowId === 'travel_application'
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
steward_confirm_flow: true,
steward_plan_id: normalized.planId,
flow_id: flow.flowId,
session_type: flow.flowId === 'travel_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE,
selected_flow_label: flow.label,
expense_type: expenseType,
expense_type_label: FLOW_EXPENSE_TYPE_LABELS[expenseType] || '',
requires_application_before_reimbursement: flow.flowId === 'travel_reimbursement' && expenseType === 'travel',
carry_text: flow.flowId === 'travel_reimbursement' && expenseType === 'travel' ? '我要报销' : flow.label,
auto_submit: true,
steward_state: normalized.stewardState || null
}
}
}))
})
}
const nextContext = resolveNextActionContext(normalized)
if (!nextContext) {
@@ -335,7 +385,7 @@ export function buildStewardSuggestedActions(plan) {
: SESSION_TYPE_EXPENSE
return [
{
label: buildNextActionLabel(actionType),
label: buildNextActionLabel(actionType, task),
description: buildNextActionDescription(actionType, normalized, task, group),
icon: actionType === 'confirm_create_application'
? 'mdi mdi-file-plus-outline'
@@ -411,40 +461,58 @@ export function isOffTopicStewardPlan(rawPlan) {
}
function buildOffTopicMessageText(normalized) {
// off_topic 计划的引导文案完全由后端生成(含 ### 标题 + 正文 + 引导句),
// 前端透传 summary 即可,避免重复拼接导致与后端表达不一致。
const summary = String(normalized?.summary || '').trim()
const summaryLine = summary && summary !== '这看起来跟财务任务没什么关系...'
? summary
: '这看起来跟财务任务没什么关系,我目前只能帮你处理**费用申请**和**费用报销**两类事项。'
return [
'### 小财管家没看懂这件事',
'',
summaryLine,
'',
'你可以试试下面这些方式告诉我:'
].join('\n')
if (summary) {
return summary
}
return (
'### 这句话我暂时没识别到财务事项\n\n' +
'很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n' +
'要不您换种说法告诉我:'
)
}
function buildPendingFlowConfirmationMessageText(normalized) {
const fields = normalized.candidateFlows[0]?.ontologyFields || {}
const knownParts = formatStewardOntologyFields(fields, 'expense_application')
const knownTable = formatStewardOntologyFieldsTable(fields, 'expense_application')
const candidateLines = normalized.candidateFlows.map((flow, index) =>
`${index + 1}. **${flow.label}**${flow.reason ? `\n - ${flow.reason}` : ''}`
)
const singleCandidate = normalized.candidateFlows.length === 1
return [
'### 需要先确认流程方向',
'',
knownParts
? `我识别到这是一项财务事项,已提取到:**${knownParts}**。`
knownTable
? ['我识别到这是一项财务事项,已提取到:', '', knownTable].join('\n')
: '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。',
'',
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。',
'',
...candidateLines,
'',
'请先选择一个方向,我会继续整理对应材料。'
singleCandidate
? `请先点击下方 **${normalized.candidateFlows[0].label}**,我会继续整理对应材料。`
: '请先选择一个方向,我会继续整理对应材料。'
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
}
function buildGenericReimbursementIntentMessageText() {
return [
'### 我来带你发起报销',
'',
'你现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带你填。',
'',
'1. **先选报销场景**',
' - 例如差旅费、交通费、住宿费、业务招待费或办公用品费,不同场景需要的材料不一样。',
'2. **再补关键材料**',
' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮你核对是否需要关联事前申请。',
'',
'点击下面的 **确定,选择报销场景**,我会进入报销助手继续引导。'
].join('\n')
}
function resolveNextActionContext(normalized) {
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
const applicationAction = applicationTask
@@ -566,6 +634,9 @@ function buildTaskOrderActionDescription(task) {
return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。`
}
if (task.taskType === 'reimbursement') {
if (isGenericReimbursementTask(task)) {
return `我会请${agent}先带你选择报销场景,再逐步补齐事由、时间、金额和票据。`
}
return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。`
}
return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。`
@@ -603,13 +674,16 @@ function buildNextTaskLead(task) {
return `处理“${task.title || task.taskTypeLabel}`
}
function buildNextActionLabel(actionType) {
function buildNextActionLabel(actionType, task = null) {
if (actionType === 'confirm_create_application') {
return '确定,先创建申请单'
}
if (actionType === 'confirm_attachment_group') {
return '确定,确认附件归集'
}
if (isGenericReimbursementTask(task)) {
return '确定,选择报销场景'
}
return '确定,继续填写报销单'
}
@@ -627,7 +701,29 @@ function buildNextActionDescription(actionType, normalized, task, group) {
}
return group?.attachmentNames?.length
? `报销助手会带入 ${group.attachmentNames.length} 份相关附件生成核对结果。`
: '报销助手会根据当前任务生成报销核对结果。'
: isGenericReimbursementTask(task)
? '先进入报销助手选择具体费用类型,再按场景补齐事由、时间、金额和票据。'
: '报销助手会根据当前任务生成报销核对结果。'
}
function isGenericReimbursementTask(task) {
if (!task || task.taskType !== 'reimbursement') {
return false
}
const fields = task.ontologyFields || {}
const expenseType = String(fields.expense_type || '').trim()
const hasSpecificField = ['time_range', 'location', 'amount', 'attachments', 'transport_mode']
.some((key) => String(fields[key] || '').trim())
|| isSpecificReimbursementReason(fields.reason)
return !hasSpecificField && (!expenseType || expenseType === 'other')
}
function isSpecificReimbursementReason(value) {
const text = String(value || '').trim().replace(/\s+/g, '')
if (!text) {
return false
}
return !/^(?:我想要|我想|我要|还需要|需要|请帮我|帮我)?报销(?:费用|报销单|报销流程)?$/.test(text)
}
function buildStewardCarryText(actionType, task, group, normalized = null) {
@@ -644,6 +740,9 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
if (!task) {
return '我确认继续处理这项财务任务,请按现有流程核对信息。'
}
if (actionType === 'confirm_create_reimbursement_draft' && isGenericReimbursementTask(task)) {
return '我要报销'
}
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
const missingFields = formatStewardMissingFieldList(

View File

@@ -74,6 +74,10 @@ export function normalizeOcrDocuments(payload) {
preview_kind: String(item.preview_kind || '').trim(),
preview_data_url: String(item.preview_data_url || '').trim(),
preview_url: String(item.preview_url || '').trim(),
receipt_id: String(item.receipt_id || item.receiptId || '').trim(),
receipt_status: String(item.receipt_status || item.receiptStatus || '').trim(),
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
document_fields: Array.isArray(item.document_fields)
? item.document_fields
.map((field) => ({
@@ -87,6 +91,87 @@ export function normalizeOcrDocuments(payload) {
}))
}
function defineFileReceiptId(file, receiptId) {
const normalizedReceiptId = String(receiptId || '').trim()
if (!file || !normalizedReceiptId) {
return false
}
try {
Object.defineProperty(file, 'receiptId', {
value: normalizedReceiptId,
enumerable: false,
configurable: true
})
return true
} catch {
try {
file.receiptId = normalizedReceiptId
return String(file.receiptId || '').trim() === normalizedReceiptId
} catch {
return false
}
}
}
export function attachReceiptFolderIdsToFiles(files = [], payload = null) {
const safeFiles = Array.isArray(files) ? files : []
const documents = Array.isArray(payload?.documents) ? payload.documents : []
let attachedCount = 0
safeFiles.slice(0, documents.length).forEach((file, index) => {
const document = documents[index] || {}
const receiptId = String(document.receipt_id || document.receiptId || '').trim()
if (receiptId && defineFileReceiptId(file, receiptId)) {
attachedCount += 1
}
})
return attachedCount
}
export async function collectReceiptFiles({
files = [],
recognizedAttachmentData = null,
recognizeOcrFiles,
timeoutMs = 90000,
timeoutMessage = '票据 OCR 识别超时,已继续使用附件名称处理。'
} = {}) {
const safeFiles = Array.isArray(files) ? files : []
const reusedData = recognizedAttachmentData && typeof recognizedAttachmentData === 'object'
? recognizedAttachmentData
: null
if (reusedData) {
const ocrDocuments = Array.isArray(reusedData.ocrDocuments) ? [...reusedData.ocrDocuments] : []
const ocrPayload = reusedData.ocrPayload || { documents: ocrDocuments }
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: String(reusedData.ocrSummary || '').trim() || buildOcrSummaryFromDocuments(ocrDocuments),
ocrDocuments,
ocrFilePreviews: Array.isArray(reusedData.ocrFilePreviews) ? [...reusedData.ocrFilePreviews] : []
}
}
if (typeof recognizeOcrFiles !== 'function') {
throw new Error('票据采集服务未配置。')
}
const ocrPayload = await recognizeOcrFiles(safeFiles, {
timeoutMs,
timeoutMessage
})
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: buildOcrSummary(ocrPayload),
ocrDocuments: normalizeOcrDocuments(ocrPayload),
ocrFilePreviews: buildOcrFilePreviews(ocrPayload)
}
}
export function buildOcrSummary(payload) {
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
}

View File

@@ -358,8 +358,9 @@ export function buildExpenseSceneSelectionMessage(rawText) {
: '我已识别到这是报销申请。'
return [
`${prefix}但现在还不能确定具体报销场景,所以我先暂停信息抽取`,
'请先选择本次要发起的报销场景,选择后我再按对应规则继续识别并整理核对信息。'
`${prefix}先选一下这笔费用属于哪一类,我再按对应流程继续`,
'差旅和业务招待通常需要先关联申请单;交通、住宿、办公用品这类一般可以直接继续填写。',
'选完后我会把下一步需要准备的内容整理给你。'
].join('\n')
}
@@ -882,6 +883,8 @@ export function normalizeInitialConversationMessages(conversation) {
return createMessage(item.role, item.content, attachmentNames, {
id: `restored-${item.id || ++messageSeed}`,
time: formatMessageTime(item.created_at || item.createdAt),
assistantName: String(messageJson?.assistant_name || messageJson?.assistantName || '').trim(),
assistantVariant: String(messageJson?.assistant_variant || messageJson?.assistantVariant || '').trim(),
meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [],
citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [],
suggestedActions:
@@ -940,6 +943,7 @@ export function serializeSessionMessages(messages) {
stewardPlan: message.stewardPlan || null,
operationFeedback: message.operationFeedback || null,
assistantName: message.assistantName || '',
assistantVariant: message.assistantVariant || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
}))

View File

@@ -1,7 +1,8 @@
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage,
buildUnsavedDraftAttachmentConfirmationMessage
buildUnsavedDraftAttachmentConfirmationMessage,
collectReceiptFiles
} from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import {
@@ -312,8 +313,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
buildExpenseSceneSelectionMessage,
buildMessageMeta,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFormContextFromPayload,
clearAttachedFiles,
@@ -348,7 +347,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
messages,
nextTick,
normalizeExpenseQueryPayload,
normalizeOcrDocuments,
persistSessionState,
props,
recognizeOcrFiles,
@@ -1825,23 +1823,28 @@ export function useTravelReimbursementSubmitComposer(ctx) {
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
}
if (recognizedAttachmentData) {
ocrPayload = recognizedAttachmentData.ocrPayload
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
const collected = await collectReceiptFiles({
files,
recognizedAttachmentData
})
ocrPayload = collected.ocrPayload
ocrSummary = collected.ocrSummary
ocrDocuments = collected.ocrDocuments
ocrFilePreviews = collected.ocrFilePreviews
rememberFilePreviews(ocrFilePreviews)
if (!stewardDelegated) {
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
}
} else {
try {
ocrPayload = await recognizeOcrFiles(files, {
timeoutMs: 90000,
timeoutMessage: '票据 OCR 识别超时,已继续使用附件名称处理。'
const collected = await collectReceiptFiles({
files,
recognizeOcrFiles
})
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
ocrPayload = collected.ocrPayload
ocrSummary = collected.ocrSummary
ocrDocuments = collected.ocrDocuments
ocrFilePreviews = collected.ocrFilePreviews
rememberFilePreviews(ocrFilePreviews)
if (!stewardDelegated) {
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)

View File

@@ -339,6 +339,10 @@ export function useTravelReimbursementSuggestedActions({
const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
pushExpenseSceneSelectionPrompt(carryText)
return
}
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText