This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/ai-history-and-enhancement in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit fec4c8b370139c71472fc4d8e7396ba562cc6b6c Author: Wu Sheng <[email protected]> AuthorDate: Wed Jul 8 11:24:14 2026 +0800 fix(ai): client chat history now actually persists to IndexedDB Live testing surfaced three bugs that blocked every write: - DataCloneError: conversations are Vue reactive proxies, which IndexedDB's structured clone can't serialize — unwrap to plain objects (JSON round-trip) before put, the same unwrap the old localStorage impl got for free. - Transaction auto-commit: an IndexedDB transaction commits the instant you await a resolved request inside it, so the subsequent put/delete threw — read existing keys in a separate read tx, then write in a fresh tx with no await between the ops (save/remove/clear). - /ai never re-hydrated: the standalone full-page route hydrated once on mount with no auth-change retry, so history stayed unloaded — re-load when auth settles, mirroring the launcher. --- apps/ui/src/ai/AiFullPageView.vue | 14 ++++++++++---- apps/ui/src/ai/historyStore.ts | 24 ++++++++++++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/ui/src/ai/AiFullPageView.vue b/apps/ui/src/ai/AiFullPageView.vue index 57655f6..c513cd5 100644 --- a/apps/ui/src/ai/AiFullPageView.vue +++ b/apps/ui/src/ai/AiFullPageView.vue @@ -24,6 +24,7 @@ import Icon from '@/components/icons/Icon.vue'; import ChatTranscript from './ChatTranscript.vue'; import ChatComposer from './ChatComposer.vue'; import ChatScopeBar from './ChatScopeBar.vue'; +import { useAuthStore } from '@/state/auth'; import { useAiChat } from './useAiChat'; import { useAiConversations } from './useAiConversations'; import { useChatScroll } from './useChatScroll'; @@ -31,11 +32,14 @@ import type { Conversation } from './types'; const { t } = useI18n({ useScope: 'global' }); const router = useRouter(); +const auth = useAuthStore(); const chat = useAiChat(); const conv = useAiConversations(); -// This route is fullscreen (outside AppShell, so the launcher isn't mounted) — -// load the AI config + this user's history here too. -onMounted(() => { +// This route is fullscreen (outside AppShell, so the launcher — which owns the +// config probe + hydrate — isn't mounted). Load config + this user's history +// here on mount AND whenever auth settles, so a reload before the session has +// bootstrapped still hydrates history once the user is known. +function loadHistory(): void { void chat .ensureConfig() .then(() => conv.hydrate()) @@ -43,7 +47,9 @@ onMounted(() => { savingHistory.value = conv.historyEnabled(); refreshUsage(); }); -}); +} +onMounted(loadHistory); +watch(() => auth.isAuthenticated, loadHistory); const ordered = computed<Conversation[]>(() => [...conv.conversations.value].sort((a, b) => b.updatedAt - a.updatedAt)); diff --git a/apps/ui/src/ai/historyStore.ts b/apps/ui/src/ai/historyStore.ts index 06c8ccc..3f81a46 100644 --- a/apps/ui/src/ai/historyStore.ts +++ b/apps/ui/src/ai/historyStore.ts @@ -122,12 +122,20 @@ export function indexedDbHistory(maxBytes: number): HistoryStore { const db = await openDb(); if (!db) return kept; try { + // Read existing keys in a SEPARATE read tx first: an IndexedDB tx + // auto-commits the instant you await a resolved request inside it, so + // the write tx below must do its delete+put with NO await between them. + const existing = await reqToPromise<IDBValidKey[]>( + db.transaction(STORE, 'readonly').objectStore(STORE).index('owner').getAllKeys(owner), + ); const keepIds = new Set(kept.map((c) => c.id)); + // IndexedDB's structured clone can't clone Vue reactive proxies — unwrap + // to plain objects first (the JSON round-trip the localStorage impl used). + const plain = JSON.parse(JSON.stringify(kept)) as Conversation[]; const tx = db.transaction(STORE, 'readwrite'); const s = tx.objectStore(STORE); - const existing = await reqToPromise<IDBValidKey[]>(s.index('owner').getAllKeys(owner)); for (const id of existing) if (!keepIds.has(String(id))) s.delete(id); - for (const c of kept) s.put({ ...c, owner }); + for (const c of plain) s.put({ ...c, owner }); await txDone(tx); } catch { /* degrade: this write just won't persist */ @@ -138,10 +146,12 @@ export function indexedDbHistory(maxBytes: number): HistoryStore { const db = await openDb(); if (!db) return; try { + const rec = await reqToPromise<StoredConversation | undefined>( + db.transaction(STORE, 'readonly').objectStore(STORE).get(id), + ); + if (!rec || rec.owner !== owner) return; const tx = db.transaction(STORE, 'readwrite'); - const s = tx.objectStore(STORE); - const rec = await reqToPromise<StoredConversation | undefined>(s.get(id)); - if (rec && rec.owner === owner) s.delete(id); + tx.objectStore(STORE).delete(id); await txDone(tx); } catch { /* ignore */ @@ -151,9 +161,11 @@ export function indexedDbHistory(maxBytes: number): HistoryStore { const db = await openDb(); if (!db) return; try { + const ids = await reqToPromise<IDBValidKey[]>( + db.transaction(STORE, 'readonly').objectStore(STORE).index('owner').getAllKeys(owner), + ); const tx = db.transaction(STORE, 'readwrite'); const s = tx.objectStore(STORE); - const ids = await reqToPromise<IDBValidKey[]>(s.index('owner').getAllKeys(owner)); for (const id of ids) s.delete(id); await txDone(tx); } catch {
