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 &lt;service&gt;") 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

Reply via email to