Files
X-Financial/web/src/views/scripts/useTravelReimbursementSessionState.js
caoxiaozhu 1cbf3fee44 feat: 报销预审会话状态管理与工作台交互增强
- 新增差旅报销会话状态管理与对话模型重构
- 增强风险观测服务与运行时聊天上下文作用域
- 优化工作台图标资源、助理意图识别与摘要工具
- 完善报销创建视图样式与差旅详情页标准调整交互
- 补充风险观测、运行时聊天与报销端点测试覆盖
2026-06-04 11:03:29 +08:00

423 lines
16 KiB
JavaScript

import { nextTick, ref } from 'vue'
import { fetchLatestConversation } from '../../services/orchestrator.js'
import {
clearAssistantSessionSnapshot,
readAssistantSessionSnapshot,
writeAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
import {
buildReviewFilePreviewsFromMessages,
filterPersistableFilePreviews
} from './travelReimbursementAttachmentModel.js'
import {
ASSISTANT_SESSION_TYPES,
filterAssistantSessionTypes,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_BUDGET,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_STEWARD,
buildInitialInsightFromConversation,
buildWelcomeInsight,
buildWelcomeQuickActions,
createWelcomeAssistantMessage,
hasMeaningfulSessionMessages,
normalizeInitialConversationMessages,
normalizeSnapshotMessages,
normalizeAssistantSessionType,
resolveInitialConversationId,
resolveInitialDraftClaimId,
resolveInitialSessionType,
serializeSessionMessages,
shouldPreferPersistedSessionState
} from './travelReimbursementConversationModel.js'
import {
createEmptyGuidedFlowState,
normalizeGuidedFlowState
} from './travelReimbursementGuidedFlowModel.js'
const STEWARD_IDLE_INSIGHT = {
intent: 'idle',
metricLabel: '',
metricValue: '',
title: '',
summary: '',
agent: null
}
export function useTravelReimbursementSessionState({
props,
currentUser,
linkedRequest,
toast,
composerDraft,
adjustComposerTextareaHeight,
scrollToBottom,
getSessionRuntimeRefs = () => ({})
}) {
function resolveAccessibleSessionTypes() {
return filterAssistantSessionTypes(ASSISTANT_SESSION_TYPES, currentUser.value)
}
function resolveAccessibleSessionType(rawSessionType, fallback = resolveDefaultSessionTypeFromEntry()) {
const normalized = normalizeAssistantSessionType(rawSessionType, fallback)
const accessible = resolveAccessibleSessionTypes()
if (accessible.includes(normalized)) {
return normalized
}
const fallbackNormalized = String(fallback || '').trim()
if (accessible.includes(fallbackNormalized)) {
return fallbackNormalized
}
return accessible[0] || SESSION_TYPE_APPLICATION
}
function resolveDefaultSessionTypeFromEntry() {
const initialSessionType = String(props.initialSessionType || '').trim()
if (initialSessionType) {
return initialSessionType
}
if (props.entrySource === 'budget') {
return SESSION_TYPE_BUDGET
}
return props.entrySource === 'application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
}
function refreshWelcomeQuickActions(messages, sessionType) {
if (!Array.isArray(messages) || !messages.length) {
return []
}
if (isStewardSessionType(sessionType)) {
return messages.filter((message) => !message?.isWelcome)
}
const currentActions = buildWelcomeQuickActions(
sessionType,
currentUser.value,
props.entrySource,
linkedRequest.value
)
return messages.map((message) => (
message?.isWelcome
? { ...message, welcomeQuickActions: currentActions }
: message
))
}
function isStewardSessionType(sessionType) {
return normalizeAssistantSessionType(sessionType) === SESSION_TYPE_STEWARD
}
function buildSessionMessages(restoredMessages, sessionType) {
if (Array.isArray(restoredMessages) && restoredMessages.length) {
return restoredMessages
}
if (isStewardSessionType(sessionType)) {
return []
}
return [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)]
}
function buildSessionInsight(sessionType) {
if (isStewardSessionType(sessionType)) {
return { ...STEWARD_IDLE_INSIGHT }
}
return buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
}
function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
const sessionType = resolveAccessibleSessionType(
resolveInitialSessionType(conversation, fallbackSessionType),
fallbackSessionType
)
const restoredMessages = refreshWelcomeQuickActions(normalizeInitialConversationMessages(conversation), sessionType)
const initialInsight = buildInitialInsightFromConversation(conversation)
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
return {
sessionType,
messages: buildSessionMessages(restoredMessages, sessionType),
conversationId: resolveInitialConversationId(conversation),
draftClaimId: resolveInitialDraftClaimId(conversation),
currentInsight: isStewardSessionType(sessionType)
? buildSessionInsight(sessionType)
: initialInsight || buildSessionInsight(sessionType),
reviewFilePreviews: restoredReviewFilePreviews,
composerDraft: '',
attachedFiles: [],
composerFilesExpanded: false,
composerUploadIntent: '',
guidedFlowState: createEmptyGuidedFlowState(),
insightPanelCollapsed: false
}
}
function buildEmptySessionState(sessionType) {
const normalizedSessionType = resolveAccessibleSessionType(
sessionType,
resolveDefaultSessionTypeFromEntry()
)
return {
sessionType: normalizedSessionType,
messages: buildSessionMessages([], normalizedSessionType),
conversationId: '',
draftClaimId: '',
currentInsight: buildSessionInsight(normalizedSessionType),
reviewFilePreviews: [],
composerDraft: '',
attachedFiles: [],
composerFilesExpanded: false,
composerUploadIntent: '',
guidedFlowState: createEmptyGuidedFlowState(),
insightPanelCollapsed: false
}
}
function buildPersistedSessionState(snapshot, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null
if (!state) {
return null
}
const sessionType = resolveAccessibleSessionType(
state.sessionType || snapshot.sessionType || fallbackSessionType,
fallbackSessionType
)
const restoredMessages = refreshWelcomeQuickActions(normalizeSnapshotMessages(state.messages), sessionType)
if (
!hasMeaningfulSessionMessages(restoredMessages)
&& !String(state.conversationId || '').trim()
&& !String(state.draftClaimId || '').trim()
) {
return null
}
return {
sessionType,
messages: buildSessionMessages(restoredMessages, sessionType),
conversationId: String(state.conversationId || '').trim(),
draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight: isStewardSessionType(sessionType)
? buildSessionInsight(sessionType)
: state.currentInsight || buildSessionInsight(sessionType),
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
composerDraft: String(state.composerDraft || ''),
attachedFiles: [],
composerFilesExpanded: false,
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
guidedFlowState: normalizeGuidedFlowState(state.guidedFlowState),
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
}
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
const defaultInitialSessionType = resolveDefaultSessionTypeFromEntry()
const initialSessionType = resolveAccessibleSessionType(
props.initialConversation
? resolveInitialSessionType(props.initialConversation, defaultInitialSessionType)
: defaultInitialSessionType,
defaultInitialSessionType
)
const shouldPersistLocalSnapshot = props.entrySource !== 'detail'
const conversationInitialState = props.initialConversation
? buildConversationSessionState(props.initialConversation, initialSessionType)
: buildEmptySessionState(initialSessionType)
const canRestorePersistedInitialState =
shouldPersistLocalSnapshot
&& props.entrySource !== 'budget'
&& !String(props.initialPrompt || '').trim()
&& !props.initialApplicationPreview
&& !props.initialFiles.length
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
const persistedInitialState = canRestorePersistedInitialState
? buildPersistedSessionState(persistedInitialSnapshot, initialSessionType)
: null
const initialSessionState = canRestorePersistedInitialState && shouldPreferPersistedSessionState(
persistedInitialState,
persistedInitialSnapshot,
props.initialConversation
)
? persistedInitialState
: conversationInitialState
const activeSessionType = ref(initialSessionState.sessionType)
const messages = ref(initialSessionState.messages)
const conversationId = ref(initialSessionState.conversationId)
const draftClaimId = ref(initialSessionState.draftClaimId)
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
const sessionSnapshots = ref(
resolveAccessibleSessionTypes().reduce((result, sessionType) => {
result[sessionType] = null
return result
}, {})
)
const currentInsight = ref(initialSessionState.currentInsight)
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
const guidedFlowState = ref(normalizeGuidedFlowState(initialSessionState.guidedFlowState))
const insightPanelCollapsed = ref(false)
const sessionSwitchBusy = ref(false)
function buildPersistableSessionState(sessionState) {
const state = sessionState || captureCurrentSessionState()
return {
sessionType: resolveAccessibleSessionType(state.sessionType, resolveDefaultSessionTypeFromEntry()),
messages: serializeSessionMessages(state.messages),
conversationId: String(state.conversationId || '').trim(),
draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight: state.currentInsight || null,
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
composerDraft: String(state.composerDraft || ''),
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
guidedFlowState: normalizeGuidedFlowState(state.guidedFlowState),
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
}
}
function persistSessionState(sessionState = null) {
if (!shouldPersistLocalSnapshot) {
return
}
const state = sessionState || captureCurrentSessionState()
const persistedState = buildPersistableSessionState(state)
const meaningful = Boolean(
String(persistedState.conversationId || '').trim()
|| String(persistedState.draftClaimId || '').trim()
|| hasMeaningfulSessionMessages(persistedState.messages)
|| String(persistedState.composerDraft || '').trim()
)
if (!meaningful) {
clearAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType)
return
}
writeAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType, persistedState)
}
function captureCurrentSessionState() {
const runtimeRefs = getSessionRuntimeRefs()
return {
sessionType: activeSessionType.value,
messages: messages.value,
conversationId: conversationId.value,
draftClaimId: draftClaimId.value,
currentInsight: currentInsight.value,
reviewFilePreviews: reviewFilePreviews.value,
composerDraft: composerDraft.value,
attachedFiles: runtimeRefs.attachedFiles?.value ?? [],
composerFilesExpanded: runtimeRefs.composerFilesExpanded?.value ?? false,
composerUploadIntent: composerUploadIntent.value,
guidedFlowState: runtimeRefs.guidedFlowState?.value ?? guidedFlowState.value,
insightPanelCollapsed: insightPanelCollapsed.value
}
}
function applySessionState(sessionState) {
const runtimeRefs = getSessionRuntimeRefs()
const nextState = sessionState || buildEmptySessionState(activeSessionType.value)
activeSessionType.value = resolveAccessibleSessionType(
nextState.sessionType,
resolveDefaultSessionTypeFromEntry()
)
messages.value = buildSessionMessages(
refreshWelcomeQuickActions(nextState.messages, activeSessionType.value),
activeSessionType.value
)
conversationId.value = String(nextState.conversationId || '').trim()
draftClaimId.value = String(nextState.draftClaimId || '').trim()
currentInsight.value = isStewardSessionType(activeSessionType.value)
? buildSessionInsight(activeSessionType.value)
: nextState.currentInsight || buildSessionInsight(activeSessionType.value)
reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : []
composerDraft.value = String(nextState.composerDraft || '')
if (runtimeRefs.attachedFiles) {
runtimeRefs.attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : []
}
if (runtimeRefs.composerFilesExpanded) {
runtimeRefs.composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded)
}
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
const nextGuidedFlowState = normalizeGuidedFlowState(nextState.guidedFlowState)
guidedFlowState.value = nextGuidedFlowState
if (runtimeRefs.guidedFlowState) {
runtimeRefs.guidedFlowState.value = nextGuidedFlowState
}
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
async function loadLatestSessionState(targetSessionType) {
const normalizedTarget = resolveAccessibleSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
const payload = await fetchLatestConversation(resolveCurrentUserId(), normalizedTarget, {
preferRecoverable: normalizedTarget === SESSION_TYPE_EXPENSE
})
if (payload?.found && payload.conversation) {
return buildConversationSessionState(payload.conversation, normalizedTarget)
}
return buildEmptySessionState(normalizedTarget)
}
async function switchSessionType(targetSessionType) {
const normalizedTarget = resolveAccessibleSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) {
return
}
sessionSnapshots.value[activeSessionType.value] = captureCurrentSessionState()
if (sessionSnapshots.value[normalizedTarget]) {
applySessionState(sessionSnapshots.value[normalizedTarget])
return
}
sessionSwitchBusy.value = true
try {
const nextState = await loadLatestSessionState(normalizedTarget)
sessionSnapshots.value[normalizedTarget] = nextState
applySessionState(nextState)
} catch (error) {
const emptyState = buildEmptySessionState(normalizedTarget)
sessionSnapshots.value[normalizedTarget] = emptyState
applySessionState(emptyState)
toast(error?.message || '切换助手失败,请稍后重试。')
} finally {
sessionSwitchBusy.value = false
}
}
sessionSnapshots.value[initialSessionState.sessionType] = captureCurrentSessionState()
return {
activeSessionType,
messages,
conversationId,
draftClaimId,
sessionSnapshots,
currentInsight,
reviewFilePreviews,
composerUploadIntent,
guidedFlowState,
insightPanelCollapsed,
sessionSwitchBusy,
initialSessionState,
buildConversationSessionState,
buildEmptySessionState,
buildPersistedSessionState,
resolveCurrentUserId,
buildPersistableSessionState,
persistSessionState,
captureCurrentSessionState,
applySessionState,
loadLatestSessionState,
switchSessionType
}
}