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 } }