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 e65bddf31856fcff97d4482fc50ac4e9aa888661 Author: Wu Sheng <[email protected]> AuthorDate: Wed Jul 8 09:55:37 2026 +0800 feat(ai): persist chat history per-user in IndexedDB with toggle, usage + clear Replace the single-key localStorage blob with an owner-scoped IndexedDB store: async HistoryStore + per-mode factory (client mode only for now) with byte-cap eviction. Records are keyed per username so a shared browser isolates each user; all ops degrade silently (resolve, never reject) when IndexedDB is unavailable. Add an ai.history.maxMb config knob and a /api/ai/config history block. The full-page History sidebar gains a Save-history toggle (on by default, per-user) with an unencrypted-storage warning, a usage meter against the client cap, and Clear-all with a confirm step; single-conversation delete and cross-tab sync (BroadcastChannel, guarded so a history-off tab is never wiped) are preserved. --- CHANGELOG.md | 1 + apps/bff/src/ai/http/chat.ts | 4 + apps/bff/src/ai/provider/model.test.ts | 2 + apps/bff/src/config/schema.ts | 10 ++ apps/ui/src/ai/AiChatLauncher.vue | 14 ++- apps/ui/src/ai/AiFullPageView.vue | 182 +++++++++++++++++++++++++--- apps/ui/src/ai/historyStore.ts | 213 ++++++++++++++++++++++++++++----- apps/ui/src/ai/useAiChat.ts | 12 +- apps/ui/src/ai/useAiConversations.ts | 125 ++++++++++++++++--- apps/ui/src/api/scopes/ai.ts | 5 + horizon.yaml | 3 + 11 files changed, 501 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c0c25..fd1fec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The version line is shared by every package in the monorepo (apps + shared packa - **Both prompts are yours.** The assistant's system prompt (`ai.systemPrompt`) and the starter example chips shown in an empty chat (`ai.starters`) ship with sensible defaults and can be replaced entirely in `horizon.yaml`. - **Starter chips can ask for a service first.** A starter that names a `<service>` (e.g. "Investigate latency for <service>") opens a one-field fill-in on click: type the service free-form — a partial name or a description is fine — and it's dropped into the prompt before sending. You don't have to know the exact name or its layer; the assistant matches what you typed to a real service through its own cross-layer search. Plain starters without a placeholder still send in one click [...] - **Your question stays in view while the answer streams.** The message you just sent pins to the top of the chat and stays there as a sticky header — while its answer streams in and as you scroll through it — so you never lose sight of what you asked; opening or switching to a past conversation pins that chat's last question the same way. Each turn now carries a timestamp: when you sent the question, and when its answer finished. +- **Conversation history now persists per user in your browser, with controls.** Past chats move to the browser's IndexedDB — far larger than before, so long conversations with embedded charts survive — and are scoped to your username, so a shared browser keeps each person's history separate. A **Save history** toggle (on by default) turns persistence off entirely; a usage meter shows how much of the client budget (default 500 MB, `HORIZON_AI_HISTORY_MAX_MB`) is in use; **Clear all** (wi [...] ### Traces & logs diff --git a/apps/bff/src/ai/http/chat.ts b/apps/bff/src/ai/http/chat.ts index 412f43a..b3211ff 100644 --- a/apps/bff/src/ai/http/chat.ts +++ b/apps/bff/src/ai/http/chat.ts @@ -78,6 +78,10 @@ export function registerAiRoutes(app: FastifyInstance, deps: AiChatRouteDeps): v ready: aiEffectivelyReady(ai), provider: ai.provider, starters: resolveStarters(ai), + history: { + mode: 'client' as const, + clientMaxBytes: ai.history.maxMb * 1024 * 1024, + }, }); }); diff --git a/apps/bff/src/ai/provider/model.test.ts b/apps/bff/src/ai/provider/model.test.ts index 3f08850..7f28b17 100644 --- a/apps/bff/src/ai/provider/model.test.ts +++ b/apps/bff/src/ai/provider/model.test.ts @@ -36,6 +36,7 @@ const AI_BEDROCK: AiConfig = { apiKey: 'secret', systemPrompt: '', starters: [], + history: { maxMb: 500 }, }; const AI_OAI: AiConfig = { ...AI_BEDROCK, @@ -56,6 +57,7 @@ describe('ai config schema', () => { apiKey: '', systemPrompt: '', starters: [], + history: { maxMb: 500 }, }); }); diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts index 0e48617..5caab46 100644 --- a/apps/bff/src/config/schema.ts +++ b/apps/bff/src/config/schema.ts @@ -377,6 +377,11 @@ const aiModelDefault = process.env.HORIZON_AI_MODEL ?? ''; const aiBaseUrlDefault = process.env.HORIZON_AI_BASE_URL ?? ''; const aiRegionDefault = process.env.HORIZON_AI_REGION ?? ''; const aiApiKeyDefault = process.env.HORIZON_AI_API_KEY ?? ''; +// Client IndexedDB conversation-history cap (MB); server-side history is a future mode. +const aiHistoryMaxMbDefault = ((): number => { + const n = Number(process.env.HORIZON_AI_HISTORY_MAX_MB); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 500; +})(); const aiSchema = z .object({ /** Master switch. When false, the chat route rejects (503) and the UI @@ -411,6 +416,11 @@ const aiSchema = z /** OVERRIDE the bundled starter prompts (the chat's example chips). Empty → * use the shipped defaults. Each string is one starter shown to the user. */ starters: z.array(z.string().min(1)).default([]), + /** Client IndexedDB conversation-history cap (MB). */ + history: z + .object({ maxMb: z.number().int().positive().default(aiHistoryMaxMbDefault) }) + .strict() + .default({}), }) .strict() .default({}); diff --git a/apps/ui/src/ai/AiChatLauncher.vue b/apps/ui/src/ai/AiChatLauncher.vue index 9020261..7dea820 100644 --- a/apps/ui/src/ai/AiChatLauncher.vue +++ b/apps/ui/src/ai/AiChatLauncher.vue @@ -22,21 +22,27 @@ import { useI18n } from 'vue-i18n'; import Icon from '@/components/icons/Icon.vue'; import { useAuthStore } from '@/state/auth'; import { useAiChat } from './useAiChat'; +import { useAiConversations } from './useAiConversations'; const { t } = useI18n({ useScope: 'global' }); const chat = useAiChat(); const auth = useAuthStore(); +const conv = useAiConversations(); // Probe the server AI config once authenticated (and re-probe after login) so -// the launcher gates on `ready` + seeds the starters. The component is always -// mounted in AppShell, so this fires regardless of `available`. +// the launcher gates on `ready` + seeds the starters, then load history for the +// current user. The component is always mounted in AppShell, so this fires +// regardless of `available`. onMounted(() => { - if (auth.isAuthenticated) void chat.ensureConfig(); + if (auth.isAuthenticated) void chat.ensureConfig().then(() => conv.hydrate()); }); watch( () => auth.isAuthenticated, (yes) => { - if (yes) void chat.ensureConfig(); + // On login load the new user's history; on logout re-hydrate to owner '' so + // the in-memory list is cleared for the next user. + if (yes) void chat.ensureConfig().then(() => conv.hydrate()); + else void conv.hydrate(); }, ); </script> diff --git a/apps/ui/src/ai/AiFullPageView.vue b/apps/ui/src/ai/AiFullPageView.vue index ae85dbc..57655f6 100644 --- a/apps/ui/src/ai/AiFullPageView.vue +++ b/apps/ui/src/ai/AiFullPageView.vue @@ -17,7 +17,7 @@ <!-- Full-page /ai: a fullscreen route outside AppShell — history sidebar + wide conversation column. Same conversation + history as the docked drawer. --> <script setup lang="ts"> -import { computed, onMounted, ref } from 'vue'; +import { computed, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; import Icon from '@/components/icons/Icon.vue'; @@ -32,12 +32,53 @@ import type { Conversation } from './types'; const { t } = useI18n({ useScope: 'global' }); const router = useRouter(); const chat = useAiChat(); -// This route is fullscreen (outside AppShell, so the launcher isn't mounted) — -// load the AI config here too so starters render on a direct /ai landing. -onMounted(() => void chat.ensureConfig()); 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(() => { + void chat + .ensureConfig() + .then(() => conv.hydrate()) + .then(() => { + savingHistory.value = conv.historyEnabled(); + refreshUsage(); + }); +}); const ordered = computed<Conversation[]>(() => [...conv.conversations.value].sort((a, b) => b.updatedAt - a.updatedAt)); + +const savingHistory = ref(conv.historyEnabled()); +const confirmingClear = ref(false); +const usedBytes = ref(0); +const maxBytes = computed(() => chat.history.value?.clientMaxBytes ?? 0); +const showUnencryptedWarning = computed(() => savingHistory.value && chat.history.value?.mode === 'client'); + +function refreshUsage(): void { + void conv.usageBytes().then((b) => (usedBytes.value = b)); +} +function toggleSaving(): void { + savingHistory.value = !savingHistory.value; + void conv.setHistoryEnabled(savingHistory.value).then(refreshUsage); +} +function clickClear(): void { + if (!confirmingClear.value) { + confirmingClear.value = true; + return; + } + confirmingClear.value = false; + void conv.clearAll().then(refreshUsage); +} +function fmtMb(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(1); +} +// Refresh usage when a turn finishes — covers growth WITHIN a conversation, not +// just a change in conversation count. +watch( + () => conv.streaming.value, + (v) => { + if (!v) refreshUsage(); + }, +); const messages = computed(() => conv.current.value?.messages ?? []); const body = ref<HTMLElement | null>(null); // Pin the message you just sent to the top of the scroll area; the answer @@ -89,19 +130,44 @@ function when(ts: number): string { <div v-else class="aifp__main"> <aside class="aifp__hist"> <div class="aifp__hist-head">{{ t('History') }}</div> - <div v-if="ordered.length === 0" class="aifp__hist-empty">{{ t('No conversations yet.') }}</div> - <button - v-for="c in ordered" - :key="c.id" - type="button" - class="aifp__hist-row" - :class="{ active: c.id === conv.currentId.value }" - @click="conv.select(c.id)" - > - <span class="aifp__hist-title">{{ c.title || t('New chat') }}</span> - <span class="aifp__hist-time">{{ when(c.updatedAt) }}</span> - <span class="aifp__hist-del" :title="t('Delete')" @click.stop="conv.remove(c.id)"><Icon name="trash" :size="12" /></span> - </button> + <div class="aifp__hist-list"> + <div v-if="ordered.length === 0" class="aifp__hist-empty">{{ t('No conversations yet.') }}</div> + <button + v-for="c in ordered" + :key="c.id" + type="button" + class="aifp__hist-row" + :class="{ active: c.id === conv.currentId.value }" + @click="conv.select(c.id)" + > + <span class="aifp__hist-title">{{ c.title || t('New chat') }}</span> + <span class="aifp__hist-time">{{ when(c.updatedAt) }}</span> + <span class="aifp__hist-del" :title="t('Delete')" @click.stop="conv.remove(c.id)"><Icon name="trash" :size="12" /></span> + </button> + </div> + + <div class="aifp__hist-foot"> + <label class="aifp__save"> + <input type="checkbox" :checked="savingHistory" @change="toggleSaving" /> + <span>{{ t('Save history') }}</span> + </label> + <p v-if="showUnencryptedWarning" class="aifp__warn"> + {{ t('History is stored unencrypted in this browser.') }} + </p> + <div v-if="savingHistory && maxBytes > 0" class="aifp__usage"> + <div class="aifp__usage-bar"> + <div class="aifp__usage-fill" :style="{ width: Math.min(100, (usedBytes / maxBytes) * 100) + '%' }" /> + </div> + <span class="aifp__usage-txt">{{ fmtMb(usedBytes) }} / {{ fmtMb(maxBytes) }} MB</span> + </div> + <div v-if="ordered.length > 0" class="aifp__clear"> + <button v-if="!confirmingClear" type="button" class="aifp__clear-btn" @click="clickClear">{{ t('Clear all') }}</button> + <template v-else> + <button type="button" class="aifp__clear-btn danger" @click="clickClear">{{ t('Confirm clear all') }}</button> + <button type="button" class="aifp__clear-btn" @click="confirmingClear = false">{{ t('Cancel') }}</button> + </template> + </div> + </div> </aside> <section class="aifp__conv"> @@ -195,9 +261,16 @@ function when(ts: number): string { .aifp__hist { border-right: 1px solid var(--sw-line); background: var(--sw-bg-1); - overflow-y: auto; + display: flex; + flex-direction: column; + min-height: 0; padding: 8px; } +.aifp__hist-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} .aifp__hist-head { font-size: var(--sw-fs-xs); font-weight: var(--sw-fw-bold); @@ -206,6 +279,79 @@ function when(ts: number): string { color: var(--sw-fg-3); padding: 8px 8px 6px; } +.aifp__hist-foot { + flex: 0 0 auto; + border-top: 1px solid var(--sw-line); + margin-top: 8px; + padding: 10px 8px 4px; + display: flex; + flex-direction: column; + gap: 8px; +} +.aifp__save { + display: flex; + align-items: center; + gap: 7px; + font-size: var(--sw-fs-sm); + color: var(--sw-fg-1); + cursor: pointer; +} +.aifp__save input { + accent-color: var(--sw-accent); + cursor: pointer; +} +.aifp__warn { + margin: 0; + font-size: var(--sw-fs-xs); + line-height: var(--sw-lh-normal); + color: var(--sw-warn); +} +.aifp__usage { + display: flex; + flex-direction: column; + gap: 4px; +} +.aifp__usage-bar { + height: 4px; + border-radius: 2px; + background: var(--sw-bg-3); + overflow: hidden; +} +.aifp__usage-fill { + height: 100%; + background: var(--sw-accent); +} +.aifp__usage-txt { + font-size: var(--sw-fs-xs); + color: var(--sw-fg-3); + font-variant-numeric: tabular-nums; +} +.aifp__clear { + display: flex; + gap: 6px; +} +.aifp__clear-btn { + height: 26px; + padding: 0 10px; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 6px; + color: var(--sw-fg-2); + font: inherit; + font-size: var(--sw-fs-sm); + cursor: pointer; +} +.aifp__clear-btn:hover { + background: var(--sw-bg-3); + color: var(--sw-fg-0); +} +.aifp__clear-btn.danger { + border-color: var(--sw-err); + color: var(--sw-err); +} +.aifp__clear-btn.danger:hover { + background: var(--sw-err-soft); +} .aifp__hist-empty { font-size: var(--sw-fs-sm); color: var(--sw-fg-3); diff --git a/apps/ui/src/ai/historyStore.ts b/apps/ui/src/ai/historyStore.ts index 7370b0e..06c8ccc 100644 --- a/apps/ui/src/ai/historyStore.ts +++ b/apps/ui/src/ai/historyStore.ts @@ -15,48 +15,201 @@ * limitations under the License. */ -// Conversation history in localStorage — swap the HistoryStore impl for a DB later. -// Prune to newest MAX_CONVERSATIONS, then drop oldest until under the ~5MB budget. +// Conversation history persistence. Records are owner-scoped (per username). +// Each mode selects its own impl via createHistoryStore; only `client` +// (browser IndexedDB, unencrypted) is implemented today. All ops degrade +// silently (resolve, never reject) when IndexedDB is unavailable or a write +// fails — the same posture as the localStorage impl it replaced. import type { Conversation } from './types'; +export type HistoryMode = 'client' | 'server'; + export interface HistoryStore { - load(): Conversation[]; - save(conversations: Conversation[]): void; + load(owner: string): Promise<Conversation[]>; + /** Persists the set (evicting oldest past the cap) and returns what was kept, + * so the caller can reflect any eviction in its in-memory list. */ + save(owner: string, conversations: Conversation[]): Promise<Conversation[]>; + remove(owner: string, id: string): Promise<void>; + clear(owner: string): Promise<void>; + usageBytes(owner: string): Promise<number>; } -const KEY = 'sw.ai.history.v1'; -const MAX_CONVERSATIONS = 30; -const MAX_BYTES = 4 * 1024 * 1024; +const DB_NAME = 'sw.ai.history'; +const STORE = 'conversations'; +const ENABLED_PREFIX = 'sw.ai.history.enabled:'; -function prune(conversations: Conversation[]): Conversation[] { - // newest first, cap count, then cap total size - const sorted = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_CONVERSATIONS); - let json = JSON.stringify(sorted); - while (sorted.length > 1 && json.length > MAX_BYTES) { - sorted.pop(); - json = JSON.stringify(sorted); - } - return sorted; +type StoredConversation = Conversation & { owner: string }; + +function sizeOf(v: unknown): number { + return new Blob([JSON.stringify(v)]).size; } -export const localStorageHistory: HistoryStore = { - load(): Conversation[] { +let dbPromise: Promise<IDBDatabase | null> | null = null; +// Resolves null (never rejects) when IndexedDB is unavailable (private mode, old +// browser) so callers degrade to no persistence instead of throwing. +function openDb(): Promise<IDBDatabase | null> { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve) => { + if (typeof indexedDB === 'undefined') return resolve(null); + let req: IDBOpenDBRequest; try { - const raw = localStorage.getItem(KEY); - if (!raw) return []; - const parsed: unknown = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as Conversation[]) : []; + req = indexedDB.open(DB_NAME, 1); } catch { - return []; + return resolve(null); } + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: 'id' }).createIndex('owner', 'owner', { unique: false }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + req.onblocked = () => resolve(null); + }); + return dbPromise; +} + +function reqToPromise<T>(r: IDBRequest<T>): Promise<T> { + return new Promise((resolve, reject) => { + r.onsuccess = () => resolve(r.result); + r.onerror = () => reject(r.error); + }); +} +function txDone(tx: IDBTransaction): Promise<void> { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +// Newest-first, dropping oldest past the byte cap (always keeps at least one). +function capToBytes(conversations: Conversation[], maxBytes: number): Conversation[] { + const sorted = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt); + if (maxBytes <= 0) return sorted; + const kept: Conversation[] = []; + let total = 0; + for (const c of sorted) { + const sz = sizeOf(c); + if (kept.length > 0 && total + sz > maxBytes) break; + kept.push(c); + total += sz; + } + return kept; +} + +function stripOwner(rec: StoredConversation): Conversation { + const { owner: _owner, ...conv } = rec; + return conv; +} + +export function indexedDbHistory(maxBytes: number): HistoryStore { + return { + async load(owner) { + const db = await openDb(); + if (!db) return []; + try { + const tx = db.transaction(STORE, 'readonly'); + const rows = await reqToPromise<StoredConversation[]>(tx.objectStore(STORE).index('owner').getAll(owner)); + return rows.map(stripOwner).sort((a, b) => b.updatedAt - a.updatedAt); + } catch { + return []; + } + }, + async save(owner, conversations) { + const kept = capToBytes(conversations, maxBytes); + const db = await openDb(); + if (!db) return kept; + try { + const keepIds = new Set(kept.map((c) => c.id)); + 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 }); + await txDone(tx); + } catch { + /* degrade: this write just won't persist */ + } + return kept; + }, + async remove(owner, id) { + const db = await openDb(); + if (!db) return; + try { + 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); + await txDone(tx); + } catch { + /* ignore */ + } + }, + async clear(owner) { + const db = await openDb(); + if (!db) return; + try { + 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 { + /* ignore */ + } + }, + async usageBytes(owner) { + const db = await openDb(); + if (!db) return 0; + try { + const tx = db.transaction(STORE, 'readonly'); + const rows = await reqToPromise<StoredConversation[]>(tx.objectStore(STORE).index('owner').getAll(owner)); + return rows.reduce((n, r) => n + sizeOf(stripOwner(r)), 0); + } catch { + return 0; + } + }, + }; +} + +export const noopHistory: HistoryStore = { + async load() { + return []; }, - save(conversations: Conversation[]): void { - try { - localStorage.setItem(KEY, JSON.stringify(prune(conversations))); - } catch { - /* quota exceeded / storage disabled: history just won't persist this session */ - } + async save(_owner, conversations) { + return conversations; + }, + async remove() {}, + async clear() {}, + async usageBytes() { + return 0; }, }; -export const HISTORY_KEY = KEY; +export function createHistoryStore(mode: HistoryMode, opts: { maxBytes: number }): HistoryStore { + switch (mode) { + case 'client': + return indexedDbHistory(opts.maxBytes); + default: + return noopHistory; // server store not built yet + } +} + +// Whether client persistence is on, per user (its own key — it can't live in the +// store it gates, and must not leak across users on a shared browser). Default ON. +export function historyEnabledPref(owner: string): boolean { + try { + return localStorage.getItem(ENABLED_PREFIX + owner) !== '0'; + } catch { + return true; + } +} +export function setHistoryEnabledPref(owner: string, on: boolean): void { + try { + localStorage.setItem(ENABLED_PREFIX + owner, on ? '1' : '0'); + } catch { + /* ignore */ + } +} diff --git a/apps/ui/src/ai/useAiChat.ts b/apps/ui/src/ai/useAiChat.ts index 99e0497..feeab74 100644 --- a/apps/ui/src/ai/useAiChat.ts +++ b/apps/ui/src/ai/useAiChat.ts @@ -22,6 +22,7 @@ import { computed, ref, type ComputedRef, type Ref } from 'vue'; import { useAuthStore } from '@/state/auth'; import { bff, type AiConfigResponse } from '@/api/client'; +import type { HistoryMode } from './historyStore'; const HINT_SEEN_KEY = 'sw.ai.hintSeen'; @@ -54,6 +55,12 @@ async function ensureConfig(): Promise<void> { } } +// History persistence settings from the once-per-session config probe, null until it loads. +export function aiHistorySettings(): { mode: HistoryMode; maxBytes: number } | null { + const h = aiConfig.value?.history; + return h ? { mode: h.mode, maxBytes: h.clientMaxBytes } : null; +} + export interface AiChatController { /** Whether the slide-over panel is open. */ open: Ref<boolean>; @@ -68,6 +75,8 @@ export interface AiChatController { showHint: ComputedRef<boolean>; /** Server-supplied starter prompts (empty until config loads). */ starters: ComputedRef<string[]>; + /** Client history settings (mode + cap), null until config loads. */ + history: ComputedRef<AiConfigResponse['history'] | null>; /** Fetch the AI config once (call from the launcher on mount / auth change). */ ensureConfig: () => Promise<void>; openPanel: () => void; @@ -92,6 +101,7 @@ export function useAiChat(): AiChatController { // don't draw attention to a read-only, not-yet-configured panel. const showHint = computed<boolean>(() => ready.value && !hintSeen.value); const starters = computed<string[]>(() => aiConfig.value?.starters ?? []); + const history = computed<AiConfigResponse['history'] | null>(() => aiConfig.value?.history ?? null); function dismissHint(): void { if (hintSeen.value) return; @@ -115,5 +125,5 @@ export function useAiChat(): AiChatController { else openPanel(); } - return { open: openState, available, ready, enabled, showHint, starters, ensureConfig, openPanel, closePanel, toggle, dismissHint }; + return { open: openState, available, ready, enabled, showHint, starters, history, ensureConfig, openPanel, closePanel, toggle, dismissHint }; } diff --git a/apps/ui/src/ai/useAiConversations.ts b/apps/ui/src/ai/useAiConversations.ts index 9ca50e8..09b42e1 100644 --- a/apps/ui/src/ai/useAiConversations.ts +++ b/apps/ui/src/ai/useAiConversations.ts @@ -19,7 +19,9 @@ // BFF chat stream (streamAnswer), and appends each SSE event into an ordered Block[]. // Persists to localStorage, live-syncs across tabs. import { computed, ref, type ComputedRef, type Ref } from 'vue'; -import { localStorageHistory, HISTORY_KEY } from './historyStore'; +import { useAuthStore } from '@/state/auth'; +import { createHistoryStore, noopHistory, historyEnabledPref, setHistoryEnabledPref, type HistoryStore } from './historyStore'; +import { aiHistorySettings } from './useAiChat'; import { streamAnswer, type AiTurn, type AiStreamRange } from './aiStream'; import { AI_TIME_PRESETS, aiTimePresetId } from './scope'; import type { Block, Conversation, ChatMessage, ProposalBlock, ProposalStatus } from './types'; @@ -73,19 +75,61 @@ function rangeForCurrentPreset(): AiStreamRange { return { startMs, endMs, step }; } -const conversations = ref<Conversation[]>(localStorageHistory.load()); -const currentId = ref<string | null>(conversations.value[0]?.id ?? null); +const conversations = ref<Conversation[]>([]); +const currentId = ref<string | null>(null); const streaming = ref(false); // Aborts the in-flight answer (stop button / panel close), so a closed panel // stops consuming the model instead of streaming to nowhere. let streamController: AbortController | null = null; +let store: HistoryStore = noopHistory; +let owner = ''; +let storeIsReal = false; +let loadedOwner: string | null = null; +let loadedReal = false; +const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('sw.ai.history') : null; + function uid(): string { return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `id-${Date.now()}-${Math.round(Math.random() * 1e9)}`; } +function applyLoaded(loaded: Conversation[]): void { + conversations.value = loaded; + if (!loaded.some((c) => c.id === currentId.value)) currentId.value = loaded[0]?.id ?? null; +} + +function rebuildStore(): boolean { + const settings = aiHistorySettings(); + storeIsReal = historyEnabledPref(owner) && !!settings && !!owner; + store = storeIsReal && settings ? createHistoryStore(settings.mode, { maxBytes: settings.maxBytes }) : noopHistory; + return storeIsReal; +} + +// Build the store for the current user + config and load their history. Loads on +// first run, owner change, or when a real store first becomes usable (config +// arrived after an initial no-op hydrate). Call after ensureConfig. +async function hydrate(): Promise<void> { + const nextOwner = useAuthStore().user?.username ?? ''; + owner = nextOwner; + const real = rebuildStore(); + if (nextOwner === loadedOwner && !(real && !loadedReal)) return; + const loaded = await store.load(nextOwner); + if (owner !== nextOwner) return; // user switched during the load — discard the stale result + loadedOwner = nextOwner; + loadedReal = real; + applyLoaded(loaded); +} + +// Persist immediately (send() persists once per finished turn, not per token), +// notifying other tabs only when the write was real (a no-op store must not make +// a peer reload to empty). function persist(): void { - localStorageHistory.save(conversations.value); + const s = store; + const own = owner; + const wasReal = storeIsReal; + void s.save(own, conversations.value).then(() => { + if (wasReal) channel?.postMessage({ owner: own }); + }); } const current = computed<Conversation | null>(() => conversations.value.find((c) => c.id === currentId.value) ?? null); @@ -104,10 +148,37 @@ function select(id: string): void { currentId.value = id; } -function remove(id: string): void { +async function remove(id: string): Promise<void> { conversations.value = conversations.value.filter((c) => c.id !== id); if (currentId.value === id) currentId.value = conversations.value[0]?.id ?? null; - persist(); + await store.remove(owner, id); + if (storeIsReal) channel?.postMessage({ owner }); +} + +async function clearAll(): Promise<void> { + conversations.value = []; + currentId.value = null; + await store.clear(owner); + if (storeIsReal) channel?.postMessage({ owner }); +} + +// On: merge stored history with the session's in-memory conversations, then +// persist — never overwrite the store with a possibly-empty in-memory list. Off: +// switch to no-op; existing records stay until Clear all. +async function setHistoryEnabled(on: boolean): Promise<void> { + setHistoryEnabledPref(owner, on); + rebuildStore(); + if (on && storeIsReal) { + const stored = await store.load(owner); + const haveIds = new Set(conversations.value.map((c) => c.id)); + const merged = [...conversations.value, ...stored.filter((c) => !haveIds.has(c.id))].sort((a, b) => b.updatedAt - a.updatedAt); + applyLoaded(merged); + void store.save(owner, merged); + } +} + +function usageBytes(): Promise<number> { + return store.usageBytes(owner); } function ensureCurrent(): Conversation { @@ -205,15 +276,14 @@ function resolveProposal( persist(); } -// Cross-tab sync: reload when another tab writes history (skip mid-stream). -if (typeof window !== 'undefined') { - window.addEventListener('storage', (e) => { - if (e.key !== HISTORY_KEY || streaming.value) return; - conversations.value = localStorageHistory.load(); - if (!conversations.value.some((c) => c.id === currentId.value)) { - currentId.value = conversations.value[0]?.id ?? null; - } - }); +// Cross-tab sync: reload when another tab writes this owner's history. Skip when +// mid-stream, or when history is off here (store is no-op → a reload would wipe +// the visible list to []), or when the write was for a different user. +if (channel) { + channel.onmessage = (e: MessageEvent<{ owner?: string }>) => { + if (streaming.value || !storeIsReal || e.data?.owner !== owner) return; + void store.load(owner).then(applyLoaded); + }; } export interface AiConversations { @@ -226,9 +296,30 @@ export interface AiConversations { resolveProposal: (block: ProposalBlock, status: ProposalStatus, patch?: { taskId?: string; error?: string }) => void; newChat: () => Conversation; select: (id: string) => void; - remove: (id: string) => void; + remove: (id: string) => Promise<void>; + clearAll: () => Promise<void>; + hydrate: () => Promise<void>; + setHistoryEnabled: (on: boolean) => Promise<void>; + historyEnabled: () => boolean; + usageBytes: () => Promise<number>; } export function useAiConversations(): AiConversations { - return { conversations, current, currentId, streaming, send, stop, resolveProposal, newChat, select, remove }; + return { + conversations, + current, + currentId, + streaming, + send, + stop, + resolveProposal, + newChat, + select, + remove, + clearAll, + hydrate, + setHistoryEnabled, + historyEnabled: () => historyEnabledPref(owner), + usageBytes, + }; } diff --git a/apps/ui/src/api/scopes/ai.ts b/apps/ui/src/api/scopes/ai.ts index a14d3a3..4f9fae1 100644 --- a/apps/ui/src/api/scopes/ai.ts +++ b/apps/ui/src/api/scopes/ai.ts @@ -28,6 +28,11 @@ export interface AiConfigResponse { provider: string; /** Starter prompts (bundled defaults or the operator's `ai.starters`). */ starters: string[]; + /** Client history: `mode` selects the store impl (only `client` today); `clientMaxBytes` caps IndexedDB. */ + history: { + mode: 'client' | 'server'; + clientMaxBytes: number; + }; } export class AiApi { diff --git a/horizon.yaml b/horizon.yaml index af5bf02..8a4695b 100644 --- a/horizon.yaml +++ b/horizon.yaml @@ -152,6 +152,9 @@ ai: # prompt, replace the value with a YAML block scalar ( |- ). starters: JSON array. systemPrompt: "${HORIZON_AI_SYSTEM_PROMPT:}" starters: ${HORIZON_AI_STARTERS:null} + # Client-side chat-history cap (MB); browser IndexedDB, per user. + history: + maxMb: ${HORIZON_AI_HISTORY_MAX_MB:500} # Performance / behavior tuning — BFF→OAP fan-out + storage-protective caps. # Operational, hot-reloaded. `:null` keeps the built-in defaults (shown in
