Files
archy/packages/app/src/stores/chat.ts
T
DorianandClaude Opus 4.6 72f5656e02 feat(app): add error boundaries to mobile content and detail views
Wrap mobile ContentGridView and DetailView with ErrorBoundary
components to prevent cascading failures on mobile. Fix prefer-const
lint error in chat store.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:13:08 +00:00

257 lines
7.6 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import type { Message, Conversation, WebSearchResult } from '@aiui/core/types/message'
import {
saveConversation as idbSave,
loadAllConversations as idbLoadAll,
deleteConversation as idbDelete,
isIDBAvailable,
} from '@/utils/idb-storage'
const isDev = import.meta.env.DEV
const useIDB = isIDBAvailable()
let saveTimer: ReturnType<typeof setTimeout> | null = null
const SAVE_DEBOUNCE = 800
// Server-side persistence via Vite dev middleware (fallback for dev mode without IDB)
async function loadServerChats(): Promise<{ conversations: Map<string, Conversation>; activeId: string | null }> {
const empty = { conversations: new Map<string, Conversation>(), activeId: null }
if (!isDev) return empty
try {
const res = await fetch('/api/dev-chats')
if (!res.ok) return empty
const data = await res.json() as {
conversations?: Record<string, Conversation>
activeConversationId?: string | null
}
if (!data.conversations) return empty
const conversations = new Map(Object.entries(data.conversations))
return {
conversations,
activeId: data.activeConversationId ?? null,
}
} catch {
return empty
}
}
let _loaded = false
function saveServerChats(conversations: Map<string, Conversation>, activeId: string | null) {
if (!isDev || !_loaded) return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
const obj = Object.fromEntries(conversations)
fetch('/api/dev-chats', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversations: obj, activeConversationId: activeId }),
}).catch(() => {})
}, SAVE_DEBOUNCE)
}
// Debounced IDB save for a single conversation
const idbSaveTimers = new Map<string, ReturnType<typeof setTimeout>>()
function debouncedIDBSave(conv: Conversation) {
if (!useIDB) return
const existing = idbSaveTimers.get(conv.id)
if (existing) clearTimeout(existing)
idbSaveTimers.set(conv.id, setTimeout(() => {
idbSave(conv).catch(() => {})
idbSaveTimers.delete(conv.id)
}, SAVE_DEBOUNCE))
}
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Map<string, Conversation>>(new Map())
const activeConversationId = ref<string | null>(null)
const isStreaming = ref(false)
const loaded = ref(false)
const savedSide = localStorage.getItem('aiui-panel-side') as 'left' | 'right' | null
const panelSide = ref<'left' | 'right'>(savedSide ?? 'left')
const webSearchEnabled = ref(localStorage.getItem('aiui-web-search') !== 'false')
const chatCollapsed = ref(localStorage.getItem('aiui-chat-collapsed') !== 'false')
// Load chats: try IndexedDB first, fall back to dev-chats middleware
async function loadChats() {
if (useIDB) {
try {
const idbConversations = await idbLoadAll()
if (idbConversations.size > 0) {
conversations.value = idbConversations
const savedActiveId = localStorage.getItem('aiui-active-conversation')
activeConversationId.value =
savedActiveId && idbConversations.has(savedActiveId)
? savedActiveId
: [...idbConversations.keys()].pop() ?? null
loaded.value = true
_loaded = true
return
}
} catch {
// IDB failed, fall through to dev-chats
}
}
if (isDev) {
const data = await loadServerChats()
if (data.conversations.size > 0) {
conversations.value = data.conversations
activeConversationId.value =
data.activeId && data.conversations.has(data.activeId)
? data.activeId
: [...data.conversations.keys()][0] ?? null
// Migrate existing dev-chats to IndexedDB
if (useIDB) {
for (const conv of data.conversations.values()) {
idbSave(conv).catch(() => {})
}
}
}
}
loaded.value = true
_loaded = true
}
loadChats()
// Persist active conversation ID to localStorage
watch(activeConversationId, (id) => {
if (id) localStorage.setItem('aiui-active-conversation', id)
else localStorage.removeItem('aiui-active-conversation')
})
watch(panelSide, (val) => {
localStorage.setItem('aiui-panel-side', val)
})
watch(webSearchEnabled, (val) => {
localStorage.setItem('aiui-web-search', String(val))
})
watch(chatCollapsed, (val) => {
localStorage.setItem('aiui-chat-collapsed', String(val))
})
watch(
[conversations, activeConversationId],
([conv, active]) => {
// Dev-chats middleware fallback
saveServerChats(conv as Map<string, Conversation>, active as string | null)
},
{ deep: true }
)
const activeConversation = computed(() => {
if (!activeConversationId.value) return null
return conversations.value.get(activeConversationId.value) ?? null
})
const messages = computed(() => activeConversation.value?.messages ?? [])
const conversationList = computed(() =>
Array.from(conversations.value.values()).sort((a, b) => b.updatedAt - a.updatedAt)
)
function createConversation(title = 'New Chat'): string {
const id = crypto.randomUUID()
const conversation: Conversation = {
id,
title,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
conversations.value.set(id, conversation)
activeConversationId.value = id
debouncedIDBSave(conversation)
return id
}
function addMessage(conversationId: string, message: Omit<Message, 'id' | 'timestamp'>) {
const conv = conversations.value.get(conversationId)
if (!conv) return
const msg: Message = {
...message,
id: crypto.randomUUID(),
timestamp: Date.now(),
}
conv.messages.push(msg)
conv.updatedAt = Date.now()
if (conv.messages.length === 1 && message.role === 'user') {
conv.title = message.content.slice(0, 60) + (message.content.length > 60 ? '...' : '')
}
debouncedIDBSave(conv)
return msg
}
function appendToLastMessage(conversationId: string, text: string) {
const conv = conversations.value.get(conversationId)
if (!conv || conv.messages.length === 0) return
const last = conv.messages[conv.messages.length - 1]
last.content += text
debouncedIDBSave(conv)
}
function setMessageWebResults(conversationId: string, messageId: string, results: WebSearchResult[]) {
const conv = conversations.value.get(conversationId)
if (!conv) return
const msg = conv.messages.find((m: { id: string }) => m.id === messageId)
if (msg) {
msg.webResults = results
debouncedIDBSave(conv)
}
}
function switchSide() {
panelSide.value = panelSide.value === 'right' ? 'left' : 'right'
}
function toggleChatCollapse() {
chatCollapsed.value = !chatCollapsed.value
}
function setActiveConversation(id: string) {
if (conversations.value.has(id)) {
activeConversationId.value = id
}
}
function deleteConversation(id: string) {
conversations.value.delete(id)
if (useIDB) idbDelete(id).catch(() => {})
if (activeConversationId.value === id) {
const remaining = conversationList.value
activeConversationId.value = remaining.length > 0 ? remaining[0].id : null
}
}
return {
conversations,
activeConversationId,
activeConversation,
messages,
conversationList,
isStreaming,
loaded,
panelSide,
webSearchEnabled,
chatCollapsed,
createConversation,
addMessage,
appendToLastMessage,
setMessageWebResults,
switchSide,
toggleChatCollapse,
setActiveConversation,
deleteConversation,
}
})