- 新增差旅报销会话状态管理与对话模型重构 - 增强风险观测服务与运行时聊天上下文作用域 - 优化工作台图标资源、助理意图识别与摘要工具 - 完善报销创建视图样式与差旅详情页标准调整交互 - 补充风险观测、运行时聊天与报销端点测试覆盖
423 lines
16 KiB
JavaScript
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
|
|
}
|
|
}
|