feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源 - 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore 及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿 - 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局 - 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
class="app"
|
||||
:class="{
|
||||
'sidebar-collapsed': sidebarCollapsed,
|
||||
'workbench-ai-sidebar-active': isAiShellMode,
|
||||
'mobile-sidebar-open': mobileSidebarOpen,
|
||||
'login-entry-active': loginEntryAnimating
|
||||
}"
|
||||
@@ -29,18 +30,39 @@
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="app-sidebar">
|
||||
<SidebarRail
|
||||
:nav-items="filteredNavItems"
|
||||
:active-view="activeView"
|
||||
:company-name="PRODUCT_DISPLAY_NAME"
|
||||
:company-logo="companyProfile.logo"
|
||||
:current-user="currentUser"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@open-chat="openSmartEntry"
|
||||
@logout="handleLogout"
|
||||
@toggle-collapse="toggleSidebarCollapsed"
|
||||
@navigate="handleNavigateWithMobileClose"
|
||||
/>
|
||||
<Transition name="sidebar-mode-fade" mode="out-in">
|
||||
<AiSidebarRail
|
||||
v-if="isAiShellMode"
|
||||
key="ai-sidebar"
|
||||
:nav-items="filteredNavItems"
|
||||
:active-view="activeView"
|
||||
:active-conversation-id="aiActiveConversationId"
|
||||
:conversation-history="aiConversationHistory"
|
||||
:current-user="currentUser"
|
||||
:brand-name="PRODUCT_DISPLAY_NAME"
|
||||
:brand-logo="companyProfile.logo"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@navigate="handleNavigateWithMobileClose"
|
||||
@new-chat="openAiSidebarNewChat"
|
||||
@open-recent="openAiSidebarRecent"
|
||||
@rename-conversation="handleAiConversationRename"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
<SidebarRail
|
||||
v-else
|
||||
key="standard-sidebar"
|
||||
:nav-items="filteredNavItems"
|
||||
:active-view="activeView"
|
||||
:company-name="PRODUCT_DISPLAY_NAME"
|
||||
:company-logo="companyProfile.logo"
|
||||
:current-user="currentUser"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@open-chat="openSmartEntry"
|
||||
@logout="handleLogout"
|
||||
@toggle-collapse="toggleSidebarCollapsed"
|
||||
@navigate="handleNavigateWithMobileClose"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<main
|
||||
@@ -72,6 +94,7 @@
|
||||
:request-summary="requestSummary"
|
||||
:document-summary="documentSummary"
|
||||
:workbench-summary="workbenchSummary"
|
||||
:workbench-mode="workbenchMode"
|
||||
:digital-employee-summary="digitalEmployeeSummary"
|
||||
:company-name="ENTERPRISE_DISPLAY_NAME"
|
||||
:detail-mode="resolvedDetailMode"
|
||||
@@ -87,6 +110,7 @@
|
||||
@new-application="openExpenseApplicationCreate"
|
||||
@open-document="openWorkbenchDocument"
|
||||
@navigate="handleNavigate"
|
||||
@toggle-workbench-mode="toggleWorkbenchMode"
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
@@ -104,6 +128,7 @@
|
||||
'documents-workarea': activeView === 'documents',
|
||||
'receipt-folder-workarea': activeView === 'receiptFolder',
|
||||
'workbench-workarea': activeView === 'workbench',
|
||||
'workbench-workarea-ai-mode': isWorkbenchAiMode,
|
||||
'budget-workarea': activeView === 'budget',
|
||||
'policies-workarea': activeView === 'policies',
|
||||
'audit-workarea': activeView === 'audit',
|
||||
@@ -126,6 +151,10 @@
|
||||
v-else-if="activeView === 'workbench'"
|
||||
:assistant-modal-open="smartEntryOpen"
|
||||
:workbench-summary="workbenchSummary"
|
||||
:workbench-mode="workbenchMode"
|
||||
:ai-sidebar-command="aiSidebarCommand"
|
||||
@ai-conversation-change="handleAiConversationChange"
|
||||
@ai-conversation-history-change="handleAiConversationHistoryChange"
|
||||
@open-assistant="openSmartEntry"
|
||||
@open-document="openWorkbenchDocument"
|
||||
/>
|
||||
@@ -207,8 +236,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
|
||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||
import TopBar from '../components/layout/TopBar.vue'
|
||||
import FilterBar from '../components/layout/FilterBar.vue'
|
||||
@@ -229,6 +259,7 @@ import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||
import { useAppShell } from '../composables/useAppShell.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
||||
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
|
||||
import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js'
|
||||
|
||||
const employeeSummary = ref(null)
|
||||
@@ -241,9 +272,15 @@ const digitalEmployeeDetailOpen = ref(false)
|
||||
const receiptFolderDetailOpen = ref(false)
|
||||
const budgetDetailOpen = ref(false)
|
||||
const loginEntryAnimating = ref(false)
|
||||
const sidebarCollapsed = ref(true)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const sidebarCollapsedBeforeAiMode = ref(false)
|
||||
const mobileSidebarOpen = ref(false)
|
||||
const overviewDashboard = ref('finance')
|
||||
const workbenchMode = ref('traditional')
|
||||
const aiSidebarCommandSeq = ref(0)
|
||||
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
|
||||
const aiActiveConversationId = ref('')
|
||||
const aiConversationHistory = ref([])
|
||||
let loginEntryTimer = null
|
||||
|
||||
function stopLoginEntryAnimation() {
|
||||
@@ -269,10 +306,23 @@ function toggleSidebarCollapsed() {
|
||||
}
|
||||
|
||||
function handleNavigateWithMobileClose(viewId) {
|
||||
handleNavigate(viewId)
|
||||
void handleNavigate(viewId)
|
||||
mobileSidebarOpen.value = false
|
||||
}
|
||||
|
||||
function toggleWorkbenchMode() {
|
||||
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
|
||||
if (nextMode === 'ai') {
|
||||
sidebarCollapsedBeforeAiMode.value = sidebarCollapsed.value
|
||||
workbenchMode.value = nextMode
|
||||
sidebarCollapsed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
workbenchMode.value = nextMode
|
||||
sidebarCollapsed.value = sidebarCollapsedBeforeAiMode.value
|
||||
}
|
||||
|
||||
const {
|
||||
activeRange,
|
||||
activeView,
|
||||
@@ -319,6 +369,8 @@ const { companyProfile, currentUser, logout } = useSystemState()
|
||||
const PRODUCT_DISPLAY_NAME = '易财费控'
|
||||
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
|
||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||
const isAiShellMode = computed(() => workbenchMode.value === 'ai')
|
||||
const isWorkbenchAiMode = computed(() => activeView.value === 'workbench' && workbenchMode.value === 'ai')
|
||||
const DETAIL_TOPBAR_FALLBACKS = {
|
||||
audit: {
|
||||
title: '规则中心详情',
|
||||
@@ -382,10 +434,82 @@ function openWorkbenchDocument(payload = {}) {
|
||||
openRequestDetail(request || payload, { returnTo })
|
||||
}
|
||||
|
||||
function dispatchAiSidebarCommand(type, payload = null) {
|
||||
aiSidebarCommandSeq.value += 1
|
||||
aiSidebarCommand.value = {
|
||||
seq: aiSidebarCommandSeq.value,
|
||||
type,
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
async function openAiConversationWorkspace(type, payload = null) {
|
||||
if (activeView.value !== 'workbench') {
|
||||
const navigation = handleNavigate('workbench')
|
||||
if (navigation && typeof navigation.then === 'function') {
|
||||
await navigation
|
||||
}
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
dispatchAiSidebarCommand(type, payload)
|
||||
}
|
||||
|
||||
function openAiSidebarNewChat() {
|
||||
aiActiveConversationId.value = ''
|
||||
void openAiConversationWorkspace('new-chat')
|
||||
}
|
||||
|
||||
function openAiSidebarRecent(item = {}) {
|
||||
aiActiveConversationId.value = String(item.id || '').trim()
|
||||
void openAiConversationWorkspace('open-recent', item)
|
||||
}
|
||||
|
||||
function handleAiConversationChange(payload = {}) {
|
||||
aiActiveConversationId.value = String(payload.id || '').trim()
|
||||
}
|
||||
|
||||
function handleAiConversationHistoryChange(payload = []) {
|
||||
aiConversationHistory.value = Array.isArray(payload) ? payload : []
|
||||
}
|
||||
|
||||
function handleAiConversationRename(payload = {}) {
|
||||
const conversationId = String(payload.id || '').trim()
|
||||
const title = String(payload.title || '').trim()
|
||||
if (!conversationId || !title) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = aiConversationHistory.value.find((item) => String(item.id || '').trim() === conversationId)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
aiConversationHistory.value = saveAiWorkbenchConversation(currentUser.value || {}, {
|
||||
...target,
|
||||
title
|
||||
})
|
||||
|
||||
if (aiActiveConversationId.value === conversationId) {
|
||||
dispatchAiSidebarCommand('open-recent', {
|
||||
...target,
|
||||
title
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout('manual')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentUser.value,
|
||||
(user) => {
|
||||
aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
playLoginEntryAnimation()
|
||||
})
|
||||
@@ -393,10 +517,4 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
stopLoginEntryAnimation()
|
||||
})
|
||||
|
||||
watch(activeView, (newView) => {
|
||||
if (newView === 'workbench') {
|
||||
sidebarCollapsed.value = true
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
<template>
|
||||
<PersonalWorkbench
|
||||
:show-header="false"
|
||||
:assistant-modal-open="assistantModalOpen"
|
||||
:workbench-summary="workbenchSummary"
|
||||
@open-assistant="emit('open-assistant', $event)"
|
||||
@open-document="emit('open-document', $event)"
|
||||
/>
|
||||
<Transition name="workbench-mode-fade" mode="out-in" appear>
|
||||
<PersonalWorkbenchAiMode
|
||||
v-if="workbenchMode === 'ai'"
|
||||
key="ai"
|
||||
:sidebar-command="aiSidebarCommand"
|
||||
@conversation-change="emit('ai-conversation-change', $event)"
|
||||
@conversation-history-change="emit('ai-conversation-history-change', $event)"
|
||||
/>
|
||||
<PersonalWorkbench
|
||||
v-else
|
||||
key="traditional"
|
||||
:show-header="false"
|
||||
:assistant-modal-open="assistantModalOpen"
|
||||
:workbench-summary="workbenchSummary"
|
||||
@open-assistant="emit('open-assistant', $event)"
|
||||
@open-document="emit('open-document', $event)"
|
||||
/>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PersonalWorkbenchAiMode from '../components/business/PersonalWorkbenchAiMode.vue'
|
||||
import PersonalWorkbench from '../components/business/PersonalWorkbench.vue'
|
||||
|
||||
defineProps({
|
||||
assistantModalOpen: { type: Boolean, default: false },
|
||||
workbenchSummary: { type: Object, default: () => ({}) }
|
||||
workbenchSummary: { type: Object, default: () => ({}) },
|
||||
workbenchMode: { type: String, default: 'traditional' },
|
||||
aiSidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open-assistant', 'open-document'])
|
||||
const emit = defineEmits(['open-assistant', 'open-document', 'ai-conversation-change', 'ai-conversation-history-change'])
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/personal-workbench-view.css"></style>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` : '',
|
||||
'',
|
||||
'已识别信息:',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 : []
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user