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: {