This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 869f6d087 fix(ai): tolerate unavailable session storage (#2479)
869f6d087 is described below

commit 869f6d0878d5773b07746cf84b3f92c26256ba77
Author: shown <[email protected]>
AuthorDate: Thu Aug 27 11:51:39 2026 +0800

    fix(ai): tolerate unavailable session storage (#2479)
    
    Signed-off-by: yuluo-yx <[email protected]>
---
 web/src/stores/aiChatHistoryStore.test.ts | 72 ++++++++++++++++++++++----
 web/src/stores/aiChatHistoryStore.ts      | 85 +++++++++++++++++++++----------
 2 files changed, 121 insertions(+), 36 deletions(-)

diff --git a/web/src/stores/aiChatHistoryStore.test.ts 
b/web/src/stores/aiChatHistoryStore.test.ts
index 5c1ee3905..656f7bf58 100644
--- a/web/src/stores/aiChatHistoryStore.test.ts
+++ b/web/src/stores/aiChatHistoryStore.test.ts
@@ -19,7 +19,8 @@ const STORAGE_KEY = 'rocketmq-studio-ai-chat-history';
 
 async function loadStore(persisted?: object) {
   vi.resetModules();
-  if (persisted) sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ state: 
persisted, version: 0 }));
+  if (persisted)
+    sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ state: persisted, 
version: 0 }));
   return (await import('./aiChatHistoryStore')).useAiChatHistoryStore;
 }
 
@@ -27,9 +28,49 @@ describe('aiChatHistoryStore', () => {
   afterEach(() => {
     vi.useRealTimers();
     vi.resetModules();
+    vi.restoreAllMocks();
     sessionStorage.clear();
   });
 
+  it('keeps the in-memory store usable when session storage cannot be read', 
async () => {
+    vi.useFakeTimers();
+    const getItem = vi.spyOn(Storage.prototype, 
'getItem').mockImplementation(() => {
+      throw new DOMException('storage disabled', 'SecurityError');
+    });
+
+    const store = await loadStore();
+    store
+      .getState()
+      .setMessages('real', 'conversation-1', [
+        { id: 'message-1', role: 'user', text: 'Inspect lag' },
+      ]);
+
+    expect(store.getState().histories.real.conversations[0]).toMatchObject({
+      id: 'conversation-1',
+      messages: [{ id: 'message-1', text: 'Inspect lag' }],
+    });
+    expect(getItem).toHaveBeenCalledWith(STORAGE_KEY);
+  });
+
+  it('keeps the in-memory store usable when session storage cannot be 
removed', async () => {
+    vi.useFakeTimers();
+    const store = await loadStore();
+    store
+      .getState()
+      .setMessages('real', 'conversation-1', [
+        { id: 'message-1', role: 'user', text: 'Inspect lag' },
+      ]);
+    const removeItem = vi.spyOn(Storage.prototype, 
'removeItem').mockImplementation(() => {
+      throw new DOMException('storage disabled', 'SecurityError');
+    });
+
+    expect(() => store.persist.clearStorage()).not.toThrow();
+    expect(removeItem).toHaveBeenCalledWith(STORAGE_KEY);
+    store.getState().clearHistories();
+
+    expect(store.getState().histories.real.conversations).toEqual([]);
+  });
+
   it('keeps multiple conversations independently for each data mode', async () 
=> {
     const store = await loadStore();
     store.getState().startConversation('real', 'real-1');
@@ -51,7 +92,10 @@ describe('aiChatHistoryStore', () => {
       messages: [{ id: 'message-2', role: 'ai', summary: '', pending: true }],
     });
 
-    expect(store.getState().histories.mock).toEqual({ conversations: [], 
activeConversationId: null });
+    expect(store.getState().histories.mock).toEqual({
+      conversations: [],
+      activeConversationId: null,
+    });
     
expect(store.getState().histories.real.activeConversationId).toBe('conversation-2');
     expect(store.getState().histories.real.conversations[0]).toMatchObject({
       id: 'conversation-2',
@@ -94,17 +138,22 @@ describe('aiChatHistoryStore', () => {
       histories: { real: { conversations: { invalid: true }, 
activeConversationId: 'missing' } },
     });
 
-    expect(store.getState().histories.real).toEqual({ conversations: [], 
activeConversationId: null });
+    expect(store.getState().histories.real).toEqual({
+      conversations: [],
+      activeConversationId: null,
+    });
   });
 
   it('bounds a persisted message field before it can exhaust session storage', 
async () => {
     const { MAX_AI_CHAT_MESSAGE_FIELD_LENGTH } = await 
import('./aiChatHistoryStore');
     const store = await loadStore();
-    store.getState().setMessages('real', 'conversation-1', [{
-      id: 'answer',
-      role: 'ai',
-      summary: 'x'.repeat(MAX_AI_CHAT_MESSAGE_FIELD_LENGTH + 100),
-    }]);
+    store.getState().setMessages('real', 'conversation-1', [
+      {
+        id: 'answer',
+        role: 'ai',
+        summary: 'x'.repeat(MAX_AI_CHAT_MESSAGE_FIELD_LENGTH + 100),
+      },
+    ]);
 
     
expect(store.getState().histories.real.conversations[0]?.messages[0]?.summary).toHaveLength(
       MAX_AI_CHAT_MESSAGE_FIELD_LENGTH + '\n\n[Truncated]'.length,
@@ -114,8 +163,11 @@ describe('aiChatHistoryStore', () => {
   it('clears pending throttled persistence when histories are cleared', async 
() => {
     vi.useFakeTimers();
     const store = await loadStore();
-    const { clearAiChatHistories, flushAiChatHistoryPersistence } = await 
import('./aiChatHistoryStore');
-    store.getState().setMessages('real', 'conversation-1', [{ id: 'message', 
role: 'user', text: 'secret' }]);
+    const { clearAiChatHistories, flushAiChatHistoryPersistence } =
+      await import('./aiChatHistoryStore');
+    store
+      .getState()
+      .setMessages('real', 'conversation-1', [{ id: 'message', role: 'user', 
text: 'secret' }]);
     clearAiChatHistories();
     flushAiChatHistoryPersistence();
 
diff --git a/web/src/stores/aiChatHistoryStore.ts 
b/web/src/stores/aiChatHistoryStore.ts
index d8c7b7f37..d9a6e60be 100644
--- a/web/src/stores/aiChatHistoryStore.ts
+++ b/web/src/stores/aiChatHistoryStore.ts
@@ -87,22 +87,31 @@ const restoreMessages = (messages: unknown): 
AiChatMessage[] =>
   (Array.isArray(messages) ? messages : [])
     .filter(isRecord)
     .flatMap((message) => {
-      if (typeof message.id !== 'string' || (message.role !== 'user' && 
message.role !== 'ai')) return [];
-      return [{
-        id: message.id,
-        role: message.role as AiChatMessage['role'],
-        createdAt: typeof message.createdAt === 'number' ? message.createdAt : 
undefined,
-        text: truncateMessageField(message.text),
-        summary: truncateMessageField(message.summary),
-        thinking: truncateMessageField(message.thinking),
-        pending: false,
-      }];
+      if (typeof message.id !== 'string' || (message.role !== 'user' && 
message.role !== 'ai'))
+        return [];
+      return [
+        {
+          id: message.id,
+          role: message.role as AiChatMessage['role'],
+          createdAt: typeof message.createdAt === 'number' ? message.createdAt 
: undefined,
+          text: truncateMessageField(message.text),
+          summary: truncateMessageField(message.summary),
+          thinking: truncateMessageField(message.thinking),
+          pending: false,
+        },
+      ];
     })
     .slice(-MAX_AI_CHAT_MESSAGES);
 
 const limitHistorySize = (history: AiChatHistory): AiChatHistory => {
-  const conversations = history.conversations.map((conversation) => ({ 
...conversation, messages: [...conversation.messages] }));
-  while (conversations.length > 0 && JSON.stringify({ conversations }).length 
> MAX_AI_CHAT_HISTORY_BYTES) {
+  const conversations = history.conversations.map((conversation) => ({
+    ...conversation,
+    messages: [...conversation.messages],
+  }));
+  while (
+    conversations.length > 0 &&
+    JSON.stringify({ conversations }).length > MAX_AI_CHAT_HISTORY_BYTES
+  ) {
     const oldestIndex = conversations.length - 1;
     const oldest = conversations[oldestIndex];
     if (oldest.messages.length > 1) {
@@ -115,7 +124,7 @@ const limitHistorySize = (history: AiChatHistory): 
AiChatHistory => {
     conversations,
     activeConversationId: conversations.some((item) => item.id === 
history.activeConversationId)
       ? history.activeConversationId
-      : conversations[0]?.id ?? null,
+      : (conversations[0]?.id ?? null),
   };
 };
 
@@ -128,13 +137,20 @@ export const getRecentAiChatConversations = (
       ...conversation,
       prompt: conversation.messages.find((item) => item.role === 'user' && 
item.text?.trim())?.text,
     }))
-    .filter((conversation): conversation is RecentAiChatConversation => 
Boolean(conversation.prompt))
+    .filter((conversation): conversation is RecentAiChatConversation =>
+      Boolean(conversation.prompt),
+    )
     .slice(0, limit);
 
-const restoreHistory = (history?: Partial<AiChatHistory> & { messages?: 
AiChatMessage[]; conversationId?: string | null }): AiChatHistory => {
+const restoreHistory = (
+  history?: Partial<AiChatHistory> & { messages?: AiChatMessage[]; 
conversationId?: string | null },
+): AiChatHistory => {
   if (Array.isArray(history?.conversations)) {
     const conversations = history.conversations
-      .filter((conversation): conversation is AiChatConversation => 
isRecord(conversation) && typeof conversation.id === 'string')
+      .filter(
+        (conversation): conversation is AiChatConversation =>
+          isRecord(conversation) && typeof conversation.id === 'string',
+      )
       .slice(0, MAX_AI_CHAT_CONVERSATIONS)
       .map((conversation) => ({
         id: conversation.id,
@@ -144,8 +160,8 @@ const restoreHistory = (history?: Partial<AiChatHistory> & 
{ messages?: AiChatMe
     return limitHistorySize({
       conversations,
       activeConversationId: conversations.some((item) => item.id === 
history.activeConversationId)
-        ? history.activeConversationId ?? null
-        : conversations[0]?.id ?? null,
+        ? (history.activeConversationId ?? null)
+        : (conversations[0]?.id ?? null),
     });
   }
 
@@ -178,7 +194,13 @@ export const flushAiChatHistoryPersistence = (): void => {
 };
 
 const throttledHistoryStorage: StateStorage = {
-  getItem: (name) => sessionStorage.getItem(name),
+  getItem: (name) => {
+    try {
+      return sessionStorage.getItem(name);
+    } catch {
+      return null;
+    }
+  },
   setItem: (name, value) => {
     pendingPersist = { name, value };
     if (persistTimer) return;
@@ -190,7 +212,11 @@ const throttledHistoryStorage: StateStorage = {
       persistTimer = null;
     }
     pendingPersist = null;
-    sessionStorage.removeItem(name);
+    try {
+      sessionStorage.removeItem(name);
+    } catch {
+      // The in-memory conversation remains cleared if browser storage is 
unavailable.
+    }
   },
 };
 
@@ -232,12 +258,16 @@ export const useAiChatHistoryStore = 
create<AiChatHistoryState>()(
           const nextMessages = boundMessageFields(
             typeof messages === 'function' ? messages(conversation?.messages 
?? []) : messages,
           );
-          const updatedConversation = { id: conversationId, messages: 
nextMessages, updatedAt: Date.now() };
+          const updatedConversation = {
+            id: conversationId,
+            messages: nextMessages,
+            updatedAt: Date.now(),
+          };
           const nextHistory = limitHistorySize({
-            conversations: [updatedConversation, 
...history.conversations.filter((item) => item.id !== conversationId)].slice(
-              0,
-              MAX_AI_CHAT_CONVERSATIONS,
-            ),
+            conversations: [
+              updatedConversation,
+              ...history.conversations.filter((item) => item.id !== 
conversationId),
+            ].slice(0, MAX_AI_CHAT_CONVERSATIONS),
             activeConversationId: history.activeConversationId ?? 
conversationId,
           });
           return {
@@ -254,7 +284,10 @@ export const useAiChatHistoryStore = 
create<AiChatHistoryState>()(
       storage: createJSONStorage(() => throttledHistoryStorage),
       partialize: (state) => ({ histories: state.histories }),
       merge: (persisted, current) => {
-        const saved = persisted as Partial<AiChatHistoryState> & { messages?: 
AiChatMessage[]; conversationId?: string | null };
+        const saved = persisted as Partial<AiChatHistoryState> & {
+          messages?: AiChatMessage[];
+          conversationId?: string | null;
+        };
         return {
           ...current,
           histories: {

Reply via email to