feat(stage-*): store chat session to indexdb (#987)

* feat(stage-*): store chat session to indexdb

* refactor: remove prompt hash

* fix(stage-ui): enhance type guard for chat sessions export validation

* refactor(stage-ui): simplify chat session export and import logic

* refactor(stage-ui): update chat session management to use new data structure and improve export/import functionality

* refactor(stage-ui): enhance chat session persistence and management methods

* refactor(stage-ui): set and get idb value raw

* fix: type
This commit is contained in:
RainbowBird
2026-01-23 18:15:22 +08:00
committed by GitHub
parent de48eb0f16
commit de9df5eae8
9 changed files with 486 additions and 52 deletions
@@ -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()
+3
View File
@@ -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()
@@ -39,9 +39,9 @@ async function runAction(action: () => Promise<void> | 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<string, unknown>
importChatSessions(parsed)
await importChatSessions(parsed)
setStatus(t('settings.pages.data.status.imported'))
importError.value = ''
}
@@ -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<string, unknown>) {
const normalizedPayload = payload as Record<string, unknown>
const sessions: Record<string, ChatHistoryItem[]> = {}
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<string, unknown>) {
if (!isChatSessionsPayload(payload))
throw new Error('Invalid chat session export format')
await chatStore.importSessions(payload)
}
async function resetSettingsState() {
@@ -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<ChatSessionsIndex>(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<ChatSessionRecord>(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}`)
},
}
+28
View File
@@ -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,
@@ -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<string>(ACTIVE_SESSION_STORAGE_KEY, 'default')
const sessionMessages = useLocalStorage<Record<string, ChatHistoryItem[]>>(CHAT_STORAGE_KEY, {})
const activeSessionId = ref<string>('')
const sessionMessages = ref<Record<string, ChatHistoryItem[]>>({})
const sessionMetas = ref<Record<string, ChatSessionMeta>>({})
const sessionGenerations = ref<Record<string, number>>({})
const index = ref<ChatSessionsIndex | null>(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<void> | null = null
let persistQueue = Promise.resolve()
const loadedSessions = new Set<string>()
const loadingSessions = new Map<string, Promise<void>>()
// 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<void>) {
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<ChatHistoryItem[]>({
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<string, ChatHistoryItem[]>
}
function replaceSessions(sessions: Record<string, ChatHistoryItem[]>) {
dataStore.replaceSessions(sessions, generateInitialMessage)
async function resetAllSessions() {
const currentUserId = getCurrentUserId()
const characterId = getCurrentCharacterId()
const sessionIds = new Set<string>()
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<ChatSessionsExport> {
if (!ready.value)
await initialize()
if (!index.value) {
return {
format: 'chat-sessions-index:v1',
index: { userId: getCurrentUserId(), characters: {} },
sessions: {},
}
}
const sessions: Record<string, ChatSessionRecord> = {}
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,
}
})
@@ -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
@@ -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<string, ChatSessionMeta>
}
export interface ChatSessionsIndex {
userId: string
characters: Record<string, ChatCharacterSessionsIndex>
}
export interface ChatSessionsExport {
format: 'chat-sessions-index:v1'
index: ChatSessionsIndex
sessions: Record<string, ChatSessionRecord>
}