diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index ddfdf2104..e5218c627 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -4,6 +4,7 @@ import { themeColorFromValue, useThemeColor } from '@proj-airi/stage-layouts/com import { ToasterRoot } from '@proj-airi/stage-ui/components' import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics' import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character' +import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store' import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models' import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server' import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge' @@ -33,6 +34,7 @@ const onboardingStore = useOnboardingStore() const router = useRouter() const route = useRoute() const cardStore = useAiriCardStore() +const chatSessionStore = useChatSessionStore() const serverChannelStore = useModsServerChannelStore() const characterOrchestratorStore = useCharacterOrchestratorStore() const analyticsStore = useSharedAnalyticsStore() @@ -53,6 +55,7 @@ onMounted(async () => { cardStore.initialize() onboardingStore.initializeSetupCheck() + await chatSessionStore.initialize() await displayModelsStore.loadDisplayModelsFromIndexedDB() await settingsStore.initializeStageModel() diff --git a/apps/stage-web/src/App.vue b/apps/stage-web/src/App.vue index 1ab34b1e6..e37ad5940 100644 --- a/apps/stage-web/src/App.vue +++ b/apps/stage-web/src/App.vue @@ -2,6 +2,7 @@ import { OnboardingDialog, ToasterRoot } from '@proj-airi/stage-ui/components' import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics' import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character' +import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store' import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models' import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server' import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge' @@ -28,6 +29,7 @@ const displayModelsStore = useDisplayModelsStore() const settingsStore = useSettings() const settings = storeToRefs(settingsStore) const onboardingStore = useOnboardingStore() +const chatSessionStore = useChatSessionStore() const serverChannelStore = useModsServerChannelStore() const characterOrchestratorStore = useCharacterOrchestratorStore() const { shouldShowSetup } = storeToRefs(onboardingStore) @@ -76,6 +78,7 @@ onMounted(async () => { onboardingStore.initializeSetupCheck() + await chatSessionStore.initialize() await serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err)) await contextBridgeStore.initialize() characterOrchestratorStore.initialize() diff --git a/packages/stage-pages/src/pages/settings/data/index.vue b/packages/stage-pages/src/pages/settings/data/index.vue index ce0f8fb77..6429f6e6f 100644 --- a/packages/stage-pages/src/pages/settings/data/index.vue +++ b/packages/stage-pages/src/pages/settings/data/index.vue @@ -39,9 +39,9 @@ async function runAction(action: () => Promise | void, successKey: string) } } -function triggerExport() { +async function triggerExport() { try { - const blob = exportChatSessions() + const blob = await exportChatSessions() const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url @@ -70,7 +70,7 @@ async function handleImport(event: Event) { try { const raw = await file.text() const parsed = JSON.parse(raw) as Record - importChatSessions(parsed) + await importChatSessions(parsed) setStatus(t('settings.pages.data.status.imported')) importError.value = '' } diff --git a/packages/stage-ui/src/composables/use-data-maintenance.ts b/packages/stage-ui/src/composables/use-data-maintenance.ts index 128612745..c8113a77d 100644 --- a/packages/stage-ui/src/composables/use-data-maintenance.ts +++ b/packages/stage-ui/src/composables/use-data-maintenance.ts @@ -1,4 +1,4 @@ -import type { ChatHistoryItem } from '../types/chat' +import type { ChatSessionsExport } from '../types/chat-session' import { isStageTamagotchi } from '@proj-airi/stage-shared' import { useLive2d } from '@proj-airi/stage-ui-live2d' @@ -63,21 +63,21 @@ export function useDataMaintenance() { chatStore.resetAllSessions() } - function exportChatSessions() { - const data = chatStore.getAllSessions() + async function exportChatSessions() { + const data = await chatStore.exportSessions() return new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) } - function importChatSessions(payload: Record) { - const normalizedPayload = payload as Record - const sessions: Record = {} + function isChatSessionsPayload(payload: unknown): payload is ChatSessionsExport { + if (!payload || typeof payload !== 'object') + return false + return (payload as { format?: string }).format === 'chat-sessions-index:v1' + } - for (const [sessionId, messages] of Object.entries(normalizedPayload)) { - if (Array.isArray(messages)) - sessions[sessionId] = messages as ChatHistoryItem[] - } - - chatStore.replaceSessions(sessions) + async function importChatSessions(payload: Record) { + if (!isChatSessionsPayload(payload)) + throw new Error('Invalid chat session export format') + await chatStore.importSessions(payload) } async function resetSettingsState() { diff --git a/packages/stage-ui/src/database/repos/chat-sessions.repo.ts b/packages/stage-ui/src/database/repos/chat-sessions.repo.ts new file mode 100644 index 000000000..db74323d6 --- /dev/null +++ b/packages/stage-ui/src/database/repos/chat-sessions.repo.ts @@ -0,0 +1,30 @@ +import type { ChatSessionRecord, ChatSessionsIndex } from '../../types/chat-session' + +import { storage } from '../storage' + +export const chatSessionsRepo = { + async getIndex(userId: string) { + const key = `local:chat/index/${userId}` + return await storage.getItemRaw(key) + }, + + async saveIndex(index: ChatSessionsIndex) { + const key = `local:chat/index/${index.userId}` + await storage.setItemRaw(key, index) + }, + + async getSession(sessionId: string) { + const key = `local:chat/sessions/${sessionId}` + return await storage.getItemRaw(key) + }, + + async saveSession(sessionId: string, record: ChatSessionRecord) { + const key = `local:chat/sessions/${sessionId}` + await storage.setItemRaw(key, record) + }, + + // Cleanup + async deleteSession(sessionId: string) { + await storage.removeItem(`local:chat/sessions/${sessionId}`) + }, +} diff --git a/packages/stage-ui/src/stores/chat.ts b/packages/stage-ui/src/stores/chat.ts index 8fc489582..e13eb1b71 100644 --- a/packages/stage-ui/src/stores/chat.ts +++ b/packages/stage-ui/src/stores/chat.ts @@ -29,6 +29,13 @@ interface SendOptions { input?: WebSocketEventInputs } +interface ForkOptions { + fromSessionId?: string + atIndex?: number + reason?: string + hidden?: boolean +} + interface QueuedSend { sendingMessage: string options: SendOptions @@ -164,6 +171,7 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => { const sessionMessagesForSend = chatSession.getSessionMessages(sessionId) sessionMessagesForSend.push({ role: 'user', content: finalContent }) + chatSession.persistSessionMessages(sessionId) const categorizer = createStreamingCategorizer(activeProvider.value) let streamPosition = 0 @@ -320,6 +328,7 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => { if (!isStaleGeneration() && buildingMessage.slices.length > 0) { sessionMessagesForSend.push(toRaw(buildingMessage)) + chatSession.persistSessionMessages(sessionId) } await hooks.emitStreamEndHooks(streamingMessageContext) @@ -365,6 +374,24 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => { }) } + async function ingestOnFork( + sendingMessage: string, + options: SendOptions, + forkOptions?: ForkOptions, + ) { + const baseSessionId = forkOptions?.fromSessionId ?? activeSessionId.value + if (!forkOptions) + return ingest(sendingMessage, options, baseSessionId) + + const forkSessionId = await chatSession.forkSession({ + fromSessionId: baseSessionId, + atIndex: forkOptions.atIndex, + reason: forkOptions.reason, + hidden: forkOptions.hidden, + }) + return ingest(sendingMessage, options, forkSessionId || baseSessionId) + } + function cancelPendingSends(sessionId?: string) { for (const queued of pendingQueuedSends.value) { if (sessionId && queued.sessionId !== sessionId) @@ -385,6 +412,7 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => { discoverToolsCompatibility: llmStore.discoverToolsCompatibility, ingest, + ingestOnFork, cancelPendingSends, clearHooks: hooks.clearHooks, diff --git a/packages/stage-ui/src/stores/chat/session-store.ts b/packages/stage-ui/src/stores/chat/session-store.ts index 25a62ebf9..307d915c1 100644 --- a/packages/stage-ui/src/stores/chat/session-store.ts +++ b/packages/stage-ui/src/stores/chat/session-store.ts @@ -1,37 +1,58 @@ import type { SystemMessage } from '@xsai/shared-chat' import type { ChatHistoryItem } from '../../types/chat' +import type { ChatSessionMeta, ChatSessionRecord, ChatSessionsExport, ChatSessionsIndex } from '../../types/chat-session' -import { useLocalStorage } from '@vueuse/core' +import { nanoid } from 'nanoid' import { defineStore, storeToRefs } from 'pinia' import { computed, ref, watch } from 'vue' -import { useCharacterStore } from '../character' -import { ACTIVE_SESSION_STORAGE_KEY, CHAT_STORAGE_KEY } from './constants' -import { createChatDataStore } from './data-store' +import { chatSessionsRepo } from '../../database/repos/chat-sessions.repo' +import { useAuthStore } from '../auth' +import { useAiriCardStore } from '../modules/airi-card' export const useChatSessionStore = defineStore('chat-session', () => { - const { systemPrompt } = storeToRefs(useCharacterStore()) + const { userId } = storeToRefs(useAuthStore()) + const { activeCardId, systemPrompt } = storeToRefs(useAiriCardStore()) - const activeSessionId = useLocalStorage(ACTIVE_SESSION_STORAGE_KEY, 'default') - const sessionMessages = useLocalStorage>(CHAT_STORAGE_KEY, {}) + const activeSessionId = ref('') + const sessionMessages = ref>({}) + const sessionMetas = ref>({}) const sessionGenerations = ref>({}) + const index = ref(null) - const dataStore = createChatDataStore({ - getActiveSessionId: () => activeSessionId.value, - setActiveSessionId: sessionId => activeSessionId.value = sessionId, - getSessions: () => sessionMessages.value, - setSessions: sessions => sessionMessages.value = sessions, - getGenerations: () => sessionGenerations.value, - setGenerations: generations => sessionGenerations.value = generations, - }) + const ready = ref(false) + const isReady = computed(() => ready.value) + const initializing = ref(false) + let initializePromise: Promise | null = null + + let persistQueue = Promise.resolve() + const loadedSessions = new Set() + const loadingSessions = new Map>() // I know this nu uh, better than loading all language on rehypeShiki const codeBlockSystemPrompt = '- For any programming code block, always specify the programming language that supported on @shikijs/rehype on the rendered markdown, eg. ```python ... ```\n' const mathSyntaxSystemPrompt = '- For any math equation, use LaTeX format, eg: $ x^3 $, always escape dollar sign outside math equation\n' - function generateInitialMessage() { - const content = codeBlockSystemPrompt + mathSyntaxSystemPrompt + systemPrompt.value + function getCurrentUserId() { + return userId.value || 'local' + } + + function getCurrentCharacterId() { + return activeCardId.value || 'default' + } + + function enqueuePersist(task: () => Promise) { + persistQueue = persistQueue.then(task, task) + return persistQueue + } + + function snapshotMessages(messages: ChatHistoryItem[]) { + return JSON.parse(JSON.stringify(messages)) as ChatHistoryItem[] + } + + function generateInitialMessageFromPrompt(prompt: string) { + const content = codeBlockSystemPrompt + mathSyntaxSystemPrompt + prompt return { role: 'system', @@ -39,55 +60,372 @@ export const useChatSessionStore = defineStore('chat-session', () => { } satisfies SystemMessage } - function ensureSession(sessionId: string) { - dataStore.ensureSession(sessionId, generateInitialMessage) + function generateInitialMessage() { + return generateInitialMessageFromPrompt(systemPrompt.value) } - ensureSession(activeSessionId.value) + function ensureGeneration(sessionId: string) { + if (sessionGenerations.value[sessionId] === undefined) + sessionGenerations.value[sessionId] = 0 + } + + async function loadIndexForUser(currentUserId: string) { + const stored = await chatSessionsRepo.getIndex(currentUserId) + index.value = stored ?? { + userId: currentUserId, + characters: {}, + } + } + + function getCharacterIndex(characterId: string) { + if (!index.value) + return null + return index.value.characters[characterId] ?? null + } + + async function persistIndex() { + if (!index.value) + return + const snapshot = JSON.parse(JSON.stringify(index.value)) as ChatSessionsIndex + await enqueuePersist(() => chatSessionsRepo.saveIndex(snapshot)) + } + + async function persistSession(sessionId: string) { + const meta = sessionMetas.value[sessionId] + if (!meta) + return + const messages = snapshotMessages(sessionMessages.value[sessionId] ?? []) + const now = Date.now() + const updatedMeta = { + ...meta, + updatedAt: now, + } + + sessionMetas.value[sessionId] = updatedMeta + const characterIndex = index.value?.characters[meta.characterId] + if (characterIndex) + characterIndex.sessions[sessionId] = updatedMeta + + const record: ChatSessionRecord = { + meta: updatedMeta, + messages, + } + + await enqueuePersist(() => chatSessionsRepo.saveSession(sessionId, record)) + await persistIndex() + } + + function persistSessionMessages(sessionId: string) { + void persistSession(sessionId) + } + + function setSessionMessages(sessionId: string, next: ChatHistoryItem[]) { + sessionMessages.value[sessionId] = next + void persistSession(sessionId) + } + + async function loadSession(sessionId: string) { + if (loadedSessions.has(sessionId)) + return + if (loadingSessions.has(sessionId)) { + await loadingSessions.get(sessionId) + return + } + + const loadPromise = (async () => { + const stored = await chatSessionsRepo.getSession(sessionId) + if (stored) { + sessionMetas.value[sessionId] = stored.meta + sessionMessages.value[sessionId] = stored.messages + ensureGeneration(sessionId) + } + loadedSessions.add(sessionId) + })() + + loadingSessions.set(sessionId, loadPromise) + await loadPromise + loadingSessions.delete(sessionId) + } + + async function createSession(characterId: string, options?: { setActive?: boolean, messages?: ChatHistoryItem[], title?: string }) { + const currentUserId = getCurrentUserId() + const sessionId = nanoid() + const now = Date.now() + const meta: ChatSessionMeta = { + sessionId, + userId: currentUserId, + characterId, + title: options?.title, + createdAt: now, + updatedAt: now, + } + + const initialMessages = options?.messages?.length ? options.messages : [generateInitialMessage()] + + sessionMetas.value[sessionId] = meta + sessionMessages.value[sessionId] = initialMessages + ensureGeneration(sessionId) + + if (!index.value) + index.value = { userId: currentUserId, characters: {} } + + const characterIndex = index.value.characters[characterId] ?? { + activeSessionId: sessionId, + sessions: {}, + } + characterIndex.sessions[sessionId] = meta + if (options?.setActive !== false) + characterIndex.activeSessionId = sessionId + index.value.characters[characterId] = characterIndex + + await enqueuePersist(() => chatSessionsRepo.saveSession(sessionId, { meta, messages: initialMessages })) + await persistIndex() + + if (options?.setActive !== false) + activeSessionId.value = sessionId + + return sessionId + } + + async function ensureActiveSessionForCharacter() { + const currentUserId = getCurrentUserId() + const characterId = getCurrentCharacterId() + + if (!index.value || index.value.userId !== currentUserId) + await loadIndexForUser(currentUserId) + + const characterIndex = getCharacterIndex(characterId) + if (!characterIndex) { + await createSession(characterId) + return + } + + if (!characterIndex.activeSessionId) { + await createSession(characterId) + return + } + + activeSessionId.value = characterIndex.activeSessionId + await loadSession(characterIndex.activeSessionId) + ensureSession(characterIndex.activeSessionId) + } + + async function initialize() { + if (ready.value) + return + if (initializePromise) + return initializePromise + initializing.value = true + initializePromise = (async () => { + await ensureActiveSessionForCharacter() + ready.value = true + })() + + try { + await initializePromise + } + finally { + initializePromise = null + initializing.value = false + } + } + + function ensureSession(sessionId: string) { + ensureGeneration(sessionId) + if (!sessionMessages.value[sessionId] || sessionMessages.value[sessionId].length === 0) { + sessionMessages.value[sessionId] = [generateInitialMessage()] + void persistSession(sessionId) + } + } const messages = computed({ - get: () => dataStore.getSessionMessages(activeSessionId.value, generateInitialMessage), - set: value => dataStore.setSessionMessages(activeSessionId.value, value), + get: () => { + if (!activeSessionId.value) + return [] + ensureSession(activeSessionId.value) + if (ready.value) + void loadSession(activeSessionId.value) + return sessionMessages.value[activeSessionId.value] ?? [] + }, + set: (value) => { + if (!activeSessionId.value) + return + sessionMessages.value[activeSessionId.value] = value + void persistSession(activeSessionId.value) + }, }) function setActiveSession(sessionId: string) { - dataStore.setActiveSession(sessionId, generateInitialMessage) + activeSessionId.value = sessionId + ensureSession(sessionId) + + const characterId = getCurrentCharacterId() + const characterIndex = index.value?.characters[characterId] + if (characterIndex) { + characterIndex.activeSessionId = sessionId + void persistIndex() + } + + if (ready.value) + void loadSession(sessionId) } function cleanupMessages(sessionId = activeSessionId.value) { - dataStore.resetSession(sessionId, generateInitialMessage) + ensureGeneration(sessionId) + sessionGenerations.value[sessionId] += 1 + setSessionMessages(sessionId, [generateInitialMessage()]) } function getAllSessions() { - return dataStore.getAllSessions() + return JSON.parse(JSON.stringify(sessionMessages.value)) as Record } - function replaceSessions(sessions: Record) { - dataStore.replaceSessions(sessions, generateInitialMessage) + async function resetAllSessions() { + const currentUserId = getCurrentUserId() + const characterId = getCurrentCharacterId() + const sessionIds = new Set() + + if (index.value?.userId === currentUserId) { + for (const character of Object.values(index.value.characters)) { + for (const sessionId of Object.keys(character.sessions)) + sessionIds.add(sessionId) + } + } + + for (const sessionId of sessionIds) + await enqueuePersist(() => chatSessionsRepo.deleteSession(sessionId)) + + sessionMessages.value = {} + sessionMetas.value = {} + sessionGenerations.value = {} + loadedSessions.clear() + loadingSessions.clear() + + index.value = { + userId: currentUserId, + characters: {}, + } + + await createSession(characterId) } - function resetAllSessions() { - dataStore.resetAllSessions(generateInitialMessage) + function getSessionMessages(sessionId: string) { + ensureSession(sessionId) + if (ready.value) + void loadSession(sessionId) + return sessionMessages.value[sessionId] ?? [] } - watch(systemPrompt, () => { - dataStore.refreshSystemMessages(generateInitialMessage) - }, { immediate: true }) + function getSessionGeneration(sessionId: string) { + ensureGeneration(sessionId) + return sessionGenerations.value[sessionId] ?? 0 + } + + function bumpSessionGeneration(sessionId: string) { + ensureGeneration(sessionId) + sessionGenerations.value[sessionId] += 1 + return sessionGenerations.value[sessionId] + } + + function getSessionGenerationValue(sessionId?: string) { + const target = sessionId ?? activeSessionId.value + return getSessionGeneration(target) + } + + async function forkSession(options: { fromSessionId: string, atIndex?: number, reason?: string, hidden?: boolean }) { + const characterId = getCurrentCharacterId() + const parentMessages = getSessionMessages(options.fromSessionId) + const forkIndex = options.atIndex ?? parentMessages.length + const nextMessages = parentMessages.slice(0, forkIndex) + return await createSession(characterId, { setActive: false, messages: nextMessages }) + } + + async function exportSessions(): Promise { + if (!ready.value) + await initialize() + + if (!index.value) { + return { + format: 'chat-sessions-index:v1', + index: { userId: getCurrentUserId(), characters: {} }, + sessions: {}, + } + } + + const sessions: Record = {} + for (const character of Object.values(index.value.characters)) { + for (const sessionId of Object.keys(character.sessions)) { + const stored = await chatSessionsRepo.getSession(sessionId) + if (stored) { + sessions[sessionId] = stored + continue + } + const meta = sessionMetas.value[sessionId] + const messages = sessionMessages.value[sessionId] + if (meta && messages) + sessions[sessionId] = { meta, messages } + } + } + + return { + format: 'chat-sessions-index:v1', + index: index.value, + sessions, + } + } + + async function importSessions(payload: ChatSessionsExport) { + if (payload.format !== 'chat-sessions-index:v1') + return + + index.value = payload.index + sessionMessages.value = {} + sessionMetas.value = {} + sessionGenerations.value = {} + loadedSessions.clear() + loadingSessions.clear() + + await enqueuePersist(() => chatSessionsRepo.saveIndex(payload.index)) + + for (const [sessionId, record] of Object.entries(payload.sessions)) { + sessionMetas.value[sessionId] = record.meta + sessionMessages.value[sessionId] = record.messages + ensureGeneration(sessionId) + await enqueuePersist(() => chatSessionsRepo.saveSession(sessionId, record)) + } + + await ensureActiveSessionForCharacter() + } + + watch([userId, activeCardId], () => { + if (!ready.value) + return + void ensureActiveSessionForCharacter() + }) return { + ready, + isReady, + initialize, + activeSessionId, messages, setActiveSession, cleanupMessages, getAllSessions, - replaceSessions, resetAllSessions, ensureSession, - getSessionMessages: (sessionId: string) => dataStore.getSessionMessages(sessionId, generateInitialMessage), - getSessionGeneration: (sessionId: string) => dataStore.getSessionGeneration(sessionId), - bumpSessionGeneration: (sessionId: string) => dataStore.bumpSessionGeneration(sessionId), - getSessionGenerationValue: (sessionId?: string) => dataStore.getSessionGenerationValue(sessionId), + setSessionMessages, + persistSessionMessages, + getSessionMessages, + getSessionGeneration, + bumpSessionGeneration, + getSessionGenerationValue, + + forkSession, + exportSessions, + importSessions, } }) diff --git a/packages/stage-ui/src/stores/chat/stream-store.ts b/packages/stage-ui/src/stores/chat/stream-store.ts index 030e72d3b..81a5afaa9 100644 --- a/packages/stage-ui/src/stores/chat/stream-store.ts +++ b/packages/stage-ui/src/stores/chat/stream-store.ts @@ -33,6 +33,7 @@ export const useChatStreamStore = defineStore('chat-stream', () => { const sessionMessagesForSend = chatSession.getSessionMessages(sessionId) if (streamingMessage.value.slices.length > 0) sessionMessagesForSend.push(streamingMessage.value) + chatSession.persistSessionMessages(sessionId) streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] } if (fullText) streamingMessage.value.content = fullText diff --git a/packages/stage-ui/src/types/chat-session.ts b/packages/stage-ui/src/types/chat-session.ts new file mode 100644 index 000000000..1e81d9776 --- /dev/null +++ b/packages/stage-ui/src/types/chat-session.ts @@ -0,0 +1,31 @@ +import type { ChatHistoryItem } from './chat' + +export interface ChatSessionMeta { + sessionId: string + userId: string + characterId: string + title?: string + createdAt: number + updatedAt: number +} + +export interface ChatSessionRecord { + meta: ChatSessionMeta + messages: ChatHistoryItem[] +} + +export interface ChatCharacterSessionsIndex { + activeSessionId: string + sessions: Record +} + +export interface ChatSessionsIndex { + userId: string + characters: Record +} + +export interface ChatSessionsExport { + format: 'chat-sessions-index:v1' + index: ChatSessionsIndex + sessions: Record +}