You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/10/19 11:47:27 UTC

(rocketmq-dashboard) branch rocketmq-studio updated: fix(ai): scope prompt templates to the operator, keep replayed durations, report failed overview reads (#6707)

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 fbdc5f22b fix(ai): scope prompt templates to the operator, keep replayed durations, report failed overview reads (#6707)
fbdc5f22b is described below

commit fbdc5f22b8626ebf5dbdb8fc18d52a820a1e2a0b
Author: Loyal-Young <hj...@gmail.com>
AuthorDate: Mon Oct 19 19:47:22 2026 +0800

    fix(ai): scope prompt templates to the operator, keep replayed durations, report failed overview reads (#6707)
    
    Batched integration of @Loyal-Young's AI, a11y, user-management and docs fixes. This branch carries
    five PRs' worth of changes; the individual PRs are closed as folded into this one.
    
    - #6707 — scope custom prompt templates to the operator, so two accounts sharing a browser no longer
      see each other's templates.
    - #6469 — retain the recorded run duration when a terminal event is replayed.
    - #6908 — expose collapsed tag labels on focus.
    - #6902 — report failed session-overview reads instead of rendering them as empty.
    - #6911 — document how the demo clients reach the NameServer.
    
    Maintainer fixes on top: `promptTemplates` lines rewrapped to the configured print width and the new
    modal case moved out of the "denied storage" describe; the forwarding-only one-arg `replay` overload
    dropped (zero call sites, and keeping it would keep producing `durationMs=null` frames); two
    session-overview retry lines rewrapped.
    
    The `AiRunService` cherry-pick conflicted with #5753 / #5757 / #5758 / #5727, which landed on the
    trunk today; resolved as a union — the observer-before-replay ordering, the attach-session close on a
    failed replay and the buffered-frame drain under the send lock are all intact, and this branch only
    changes the single `projector.replay(...)` call site plus the projector signature.
    
    Verified locally against `rocketmq-studio`: `AiRunServiceTest` 32/32 and `AgentEventProjectorTest`
    40/40 green, the four affected web suites 43/43 green, `npm run build` and the web license check pass,
    eslint clean.
---
 deploy/rocketmq/README.md                          |  8 +++-
 .../studio/ops/ai/conversation/AiRunService.java   |  2 +-
 .../ai/conversation/event/AgentEventProjector.java |  7 ++-
 .../ops/ai/conversation/AiRunServiceTest.java      | 25 ++++++++++
 web/src/components/TagList.tsx                     |  9 +++-
 web/src/components/__tests__/TagList.test.tsx      | 12 +++++
 web/src/i18n/translations.ts                       |  4 ++
 .../pages/ai/components/PromptTemplateModal.tsx    | 41 ++++++++++------
 .../__tests__/PromptTemplateModal.test.tsx         | 54 +++++++++++++++++++++-
 web/src/pages/ai/promptTemplates.test.ts           | 18 ++++++++
 web/src/pages/ai/promptTemplates.ts                | 39 +++++++++++-----
 web/src/pages/studio/UserManagement.tsx            | 27 ++++++++++-
 .../pages/studio/__tests__/UserManagement.test.tsx | 18 ++++++++
 13 files changed, 230 insertions(+), 34 deletions(-)

diff --git a/deploy/rocketmq/README.md b/deploy/rocketmq/README.md
index ff77e352c..769aafb8d 100644
--- a/deploy/rocketmq/README.md
+++ b/deploy/rocketmq/README.md
@@ -17,10 +17,14 @@ deploy/rocketmq/
 │   ├── broker-1.conf       # rocketmq-studio-1,同上
 │   └── rmq-proxy.json      # Proxy 集群模式,指向 nameserver:9876,关闭 topic 类型校验
 └── clients/
-    ├── TraceProducer.java  # 1 TPS 发送(带 Key),enableMsgTrace=true,经 proxy:8080 接入
-    └── TraceConsumer.java  # Push 消费,enableMsgTrace=true,经 proxy:8080 接入
+    ├── TraceProducer.java  # 1 TPS 发送(带 Key),enableMsgTrace=true,连接 NameServer
+    └── TraceConsumer.java  # Push 消费,enableMsgTrace=true,连接 NameServer
 ```
 
+这两个示例客户端读取 `NAMESRV_ADDR`;Compose 为它们设置
+`rmq-nameserver:9876`。内置收发流量不会经过 Proxy,不能用来验证 Proxy
+端点是否有流量。
+
 ## 快速开始
 
 ```bash
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
index 1565c2fd2..b43d8e176 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
@@ -307,7 +307,7 @@ public class AiRunService {
                     continue;
                 }
                 terminalReplayed = terminalReplayed || event.get() instanceof TimelineEvent.RunStatus;
-                projector.replay(event.get()).ifPresent(session::sendReplayed);
+                projector.replay(event.get(), run.getDurationMs()).ifPresent(session::sendReplayed);
             }
         } catch (RuntimeException exception) {
             // The session this method created never reaches a response now, so no transport
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/event/AgentEventProjector.java b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/event/AgentEventProjector.java
index 4b4908cd1..9fe619a65 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/event/AgentEventProjector.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/event/AgentEventProjector.java
@@ -251,8 +251,11 @@ public final class AgentEventProjector {
      * <p>Exhaustive over the sealed hierarchy with no default branch, like {@link #project}: a new
      * persisted event type that nobody thought about is a compile error here rather than a silently
      * missing block in every replayed conversation.
+     *
+     * <p>{@code runDurationMs} is the run row's duration, which is not stored on a timeline terminal
+     * event. A missing duration stays unknown; other event types retain their own timing fields.
      */
-    public Optional<LiveEvent> replay(TimelineEvent event) {
+    public Optional<LiveEvent> replay(TimelineEvent event, Long runDurationMs) {
         if (event == null) {
             return Optional.empty();
         }
@@ -269,7 +272,7 @@ public final class AgentEventProjector {
             case TimelineEvent.Notice notice -> new LiveEvent.Notice(notice.level(), notice.message());
             case TimelineEvent.Error error -> new LiveEvent.Error(error.code(), error.message(), error.hint());
             case TimelineEvent.RunStatus terminal ->
-                    new LiveEvent.RunFinished(runId, terminal.status(), null);
+                    new LiveEvent.RunFinished(runId, terminal.status(), runDurationMs);
         };
         return Optional.ofNullable(live);
     }
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
index 0da06e1a4..00a1a38f7 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
@@ -36,6 +36,8 @@ import org.apache.rocketmq.studio.persistence.entity.RmqAiRun;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 import java.time.Clock;
@@ -573,6 +575,7 @@ class AiRunServiceTest {
         // reduce to the same transcript.
         assertThat(text).contains("\"type\":\"text_delta\"").contains("group A is 4000 behind");
         assertThat(text).contains("\"type\":\"run_finished\"").contains("event:done");
+        assertThat(text).doesNotContain("\"durationMs\"");
         // The user's own turn has no live counterpart: the client renders it from what it sent.
         assertThat(text).doesNotContain("what is lagging");
         assertThat(emitters.get(0).completed()).isTrue();
@@ -612,6 +615,28 @@ class AiRunServiceTest {
         verifyCursor(2);
         String text = emitters.get(0).eventText();
         assertThat(text).contains("tail").contains("\"type\":\"run_finished\"").contains("FAILED");
+        assertThat(text).contains("\"durationMs\":1234");
+        assertThat(emitters.get(0).completed()).isTrue();
+    }
+
+    @ParameterizedTest
+    @CsvSource({"COMPLETED,8123", "STOPPED,0", "FAILED,1234"})
+    void attachShouldReplayTheRecordedRunDurationTest(RunStatus status, long durationMs) {
+        RmqAiRun finished = AiRunTestSupport.run(RUN_ID, CONVERSATION_ID, 1, status);
+        finished.setDurationMs(durationMs);
+        when(runRepository.findById(RUN_ID)).thenReturn(Optional.of(finished));
+        when(eventRepository.findByConversationIdAfterSeq(CONVERSATION_ID, 2, 200))
+                .thenReturn(List.of(event(3, "run_status",
+                        "{\"type\":\"run_status\",\"status\":\"" + status.name() + "\"}")));
+
+        service.attach(RUN_ID, 2);
+
+        verifyCursor(2);
+        String text = emitters.get(0).eventText();
+        assertThat(text).containsOnlyOnce("\"type\":\"run_finished\"")
+                .contains("\"runId\":" + RUN_ID)
+                .contains("\"status\":\"" + status.name() + "\"")
+                .contains("\"durationMs\":" + durationMs);
         assertThat(emitters.get(0).completed()).isTrue();
     }
 
diff --git a/web/src/components/TagList.tsx b/web/src/components/TagList.tsx
index b1f2ea182..9cc4a619f 100644
--- a/web/src/components/TagList.tsx
+++ b/web/src/components/TagList.tsx
@@ -69,11 +69,18 @@ const TagList = ({ items, max, tagMaxWidth, gap = 4, style }: TagListProps) => {
       ))}
       {hidden.length > 0 && (
         <Tooltip
+          trigger={['hover', 'focus']}
           title={hidden.map((item) => (
             <div key={item.key}>{item.label}</div>
           ))}
         >
-          <Tag style={TAG_STYLE}>+{hidden.length}</Tag>
+          <Tag
+            style={TAG_STYLE}
+            tabIndex={0}
+            aria-label={`+${hidden.length}: ${hidden.map((item) => item.label).join(', ')}`}
+          >
+            +{hidden.length}
+          </Tag>
         </Tooltip>
       )}
     </Flex>
diff --git a/web/src/components/__tests__/TagList.test.tsx b/web/src/components/__tests__/TagList.test.tsx
index 8ef360681..061665387 100644
--- a/web/src/components/__tests__/TagList.test.tsx
+++ b/web/src/components/__tests__/TagList.test.tsx
@@ -63,6 +63,18 @@ describe('TagList', () => {
     expect(screen.getByText('SMS')).toBeInTheDocument();
   });
 
+  it('exposes hidden labels on keyboard focus', async () => {
+    render(<TagList items={items} max={1} />);
+    const overflow = screen.getByText('+2');
+
+    expect(overflow).toHaveAttribute('tabindex', '0');
+    expect(overflow).toHaveAttribute('aria-label', '+2: Email, SMS');
+    fireEvent.focus(overflow);
+
+    expect(await screen.findByText('Email')).toBeInTheDocument();
+    expect(screen.getByText('SMS')).toBeInTheDocument();
+  });
+
   it('keeps every item when max is above the item countTest', () => {
     render(<TagList items={items} max={5} />);
     expect(screen.getByText('SMS')).toBeInTheDocument();
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 042ebf0f8..d4ef5d1d1 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -2989,6 +2989,10 @@ const translations: Record<string, Record<Lang, string>> = {
   'userMgmt.myAccount': { zh: '我的账号', en: 'My Account' },
   'userMgmt.changeMyPassword': { zh: '修改我的密码', en: 'Change My Password' },
   'userMgmt.sessionOverview': { zh: '会话概览', en: 'Session Overview' },
+  'userMgmt.sessionOverviewLoadFailed': {
+    zh: '加载会话概览失败',
+    en: 'Failed to load session overview',
+  },
   'userMgmt.activeSessions': { zh: '活跃会话', en: 'Active Sessions' },
   'userMgmt.activeUsers': { zh: '活跃用户', en: 'Active Users' },
   'userMgmt.expiringWithin': {
diff --git a/web/src/pages/ai/components/PromptTemplateModal.tsx b/web/src/pages/ai/components/PromptTemplateModal.tsx
index fe236cec0..4b8307a1c 100644
--- a/web/src/pages/ai/components/PromptTemplateModal.tsx
+++ b/web/src/pages/ai/components/PromptTemplateModal.tsx
@@ -35,6 +35,7 @@ import {
 } from 'antd';
 import { Plus, Trash } from '@phosphor-icons/react';
 import { useLang } from '../../../i18n/LangContext';
+import useAuthStore from '../../../stores/authStore';
 import type { ChatMode } from '../chatDraft';
 import {
   deleteCustomPromptTemplate,
@@ -54,7 +55,8 @@ import {
  *
  * Storage stays where it was (`promptTemplates.ts`, `localStorage`): a template is a personal
  * snippet, not conversation history, and it is the one AI artefact that legitimately outlives a
- * session. Custom templates therefore survive the move of history to the server untouched.
+ * session. Signed-in operators use separate local storage keys; an unowned legacy key is only
+ * read when authentication is disabled because its original owner cannot be established.
  *
  * ─── Expand / collapse ─────────────────────────────────────────
  * A template body is clamped to two lines by default and expands on demand. Long runbook prompts
@@ -90,7 +92,11 @@ const PromptTemplateModal = ({
   onApply,
 }: PromptTemplateModalProps) => {
   const { t } = useLang();
+  const userId = useAuthStore((state) => state.userId);
+  const username = useAuthStore((state) => state.user);
+  const templateOwner = userId != null ? `id:${userId}` : username ? `name:${username}` : undefined;
   const [templates, setTemplates] = useState<PromptTemplate[]>([]);
+  const [loadedOwner, setLoadedOwner] = useState<string | undefined>();
   const [storageAvailable, setStorageAvailable] = useState(true);
   const [search, setSearch] = useState('');
   const [modeFilter, setModeFilter] = useState<ChatMode | 'all'>('all');
@@ -119,15 +125,16 @@ const PromptTemplateModal = ({
   );
 
   const visibleTemplates = useMemo(
-    () => filterPromptTemplates(templates, search, modeFilter),
-    [modeFilter, search, templates],
+    () => filterPromptTemplates(loadedOwner === templateOwner ? templates : [], search, modeFilter),
+    [loadedOwner, modeFilter, search, templateOwner, templates],
   );
 
   const refreshPromptTemplates = useCallback(() => {
-    const catalog = loadPromptTemplateCatalog(undefined, t);
+    const catalog = loadPromptTemplateCatalog(undefined, t, templateOwner);
     setTemplates(catalog.templates);
+    setLoadedOwner(templateOwner);
     setStorageAvailable(catalog.storageAvailable);
-  }, [t]);
+  }, [t, templateOwner]);
 
   // The catalog is read from localStorage, so it is refreshed on every open: a template saved in
   // another tab (or by the previous open of this modal) has to show up.
@@ -141,13 +148,18 @@ const PromptTemplateModal = ({
   }, [open, refreshPromptTemplates]);
 
   const handleSaveCurrentPromptTemplate = useCallback(() => {
-    const result = saveCustomPromptTemplate({
-      title: customTemplateTitle,
-      tags: customTemplateTags,
-      mode,
-      enhance,
-      body: inputValue,
-    });
+    const result = saveCustomPromptTemplate(
+      {
+        title: customTemplateTitle,
+        tags: customTemplateTags,
+        mode,
+        enhance,
+        body: inputValue,
+      },
+      undefined,
+      undefined,
+      templateOwner,
+    );
     if (!result.ok) {
       const errorMessage =
         result.reason === 'empty_title'
@@ -170,19 +182,20 @@ const PromptTemplateModal = ({
     mode,
     refreshPromptTemplates,
     t,
+    templateOwner,
   ]);
 
   const handleDeletePromptTemplate = useCallback(
     (template: PromptTemplate) => {
       if (template.scope !== 'custom') return;
-      if (!deleteCustomPromptTemplate(template.id)) {
+      if (!deleteCustomPromptTemplate(template.id, undefined, templateOwner)) {
         message.error(t('ai.promptTemplates.storageDeleteFailed'));
         return;
       }
       message.success(t('ai.promptTemplates.deleted'));
       refreshPromptTemplates();
     },
-    [refreshPromptTemplates, t],
+    [refreshPromptTemplates, t, templateOwner],
   );
 
   return (
diff --git a/web/src/pages/ai/components/__tests__/PromptTemplateModal.test.tsx b/web/src/pages/ai/components/__tests__/PromptTemplateModal.test.tsx
index c4b124f98..51aa35f5d 100644
--- a/web/src/pages/ai/components/__tests__/PromptTemplateModal.test.tsx
+++ b/web/src/pages/ai/components/__tests__/PromptTemplateModal.test.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { fireEvent, render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
 import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
 import PromptTemplateModal from '../PromptTemplateModal';
 import {
@@ -24,6 +24,7 @@ import {
   saveCustomPromptTemplate,
   deleteCustomPromptTemplate,
 } from '../../promptTemplates';
+import useAuthStore from '../../../../stores/authStore';
 
 describe('prompt templates with denied storage access', () => {
   beforeAll(() => {
@@ -112,3 +113,54 @@ describe('prompt templates with denied storage access', () => {
     }
   });
 });
+
+describe('prompt templates scoped to the signed-in operator', () => {
+  beforeAll(() => {
+    Object.defineProperty(window, 'matchMedia', {
+      writable: true,
+      value: vi.fn().mockImplementation((query: string) => ({
+        matches: false,
+        media: query,
+        onchange: null,
+        addListener: vi.fn(),
+        removeListener: vi.fn(),
+        addEventListener: vi.fn(),
+        removeEventListener: vi.fn(),
+        dispatchEvent: vi.fn(),
+      })),
+    });
+  });
+  afterEach(() => vi.restoreAllMocks());
+
+  it("does not show another operator's custom template after an account switch", async () => {
+    localStorage.clear();
+    useAuthStore.getState().login('alice', 1, false);
+    saveCustomPromptTemplate(
+      { title: 'Alice private template', body: 'Inspect a private topic' },
+      undefined,
+      100,
+      'id:1',
+    );
+    const view = render(
+      <PromptTemplateModal
+        open
+        onClose={vi.fn()}
+        inputValue=""
+        mode="chat"
+        enhance={false}
+        onApply={vi.fn()}
+      />,
+    );
+    try {
+      expect(await screen.findByText('Alice private template')).toBeInTheDocument();
+      act(() => useAuthStore.getState().login('bob', 2, false));
+      await waitFor(() =>
+        expect(screen.queryByText('Alice private template')).not.toBeInTheDocument(),
+      );
+    } finally {
+      view.unmount();
+      useAuthStore.getState().logout();
+      localStorage.clear();
+    }
+  });
+});
diff --git a/web/src/pages/ai/promptTemplates.test.ts b/web/src/pages/ai/promptTemplates.test.ts
index 8b2a2723e..c78dcddf6 100644
--- a/web/src/pages/ai/promptTemplates.test.ts
+++ b/web/src/pages/ai/promptTemplates.test.ts
@@ -83,6 +83,24 @@ describe('AI prompt templates', () => {
     expect(catalog.templates[1].scope).toBe('builtin');
   });
 
+  it('keeps signed-in operators separate from each other and from unowned legacy templates', () => {
+    saveCustomPromptTemplate({ title: 'Legacy', body: 'unowned notes' });
+    saveCustomPromptTemplate(
+      { title: 'Alice', body: 'private topic details' },
+      undefined,
+      100,
+      'id:1',
+    );
+    saveCustomPromptTemplate({ title: 'Bob', body: 'other notes' }, undefined, 200, 'id:2');
+
+    expect(loadPromptTemplateCatalog(undefined, undefined, 'id:1').templates[0].title).toBe(
+      'Alice',
+    );
+    expect(loadPromptTemplateCatalog(undefined, undefined, 'id:2').templates[0].title).toBe('Bob');
+    expect(loadPromptTemplateCatalog().templates[0].title).toBe('Legacy');
+    expect(localStorage.getItem(PROMPT_TEMPLATE_STORAGE_KEY)).toContain('Legacy');
+  });
+
   it('rejects empty titles or bodies without mutating storage', () => {
     expect(saveCustomPromptTemplate({ title: ' ', body: 'Inspect lag' })).toEqual({
       ok: false,
diff --git a/web/src/pages/ai/promptTemplates.ts b/web/src/pages/ai/promptTemplates.ts
index 4e79c2038..31935d50d 100644
--- a/web/src/pages/ai/promptTemplates.ts
+++ b/web/src/pages/ai/promptTemplates.ts
@@ -59,6 +59,10 @@ export interface SavePromptTemplateResult {
 type Translate = (key: string) => string;
 
 export const PROMPT_TEMPLATE_STORAGE_KEY = 'rocketmq-studio-ai-prompt-templates';
+const storageKeyFor = (owner?: string): string =>
+  owner
+    ? `${PROMPT_TEMPLATE_STORAGE_KEY}:user:${encodeURIComponent(owner)}`
+    : PROMPT_TEMPLATE_STORAGE_KEY;
 export const MAX_CUSTOM_PROMPT_TEMPLATES = 20;
 export const MAX_PROMPT_TEMPLATE_BODY_LENGTH = 6000;
 export const MAX_PROMPT_TEMPLATE_TITLE_LENGTH = 80;
@@ -272,7 +276,10 @@ function getBuiltinPromptTemplates(translate?: Translate): PromptTemplate[] {
   }));
 }
 
-function readCustomPromptTemplates(storage = getStorage()): {
+function readCustomPromptTemplates(
+  storage = getStorage(),
+  owner?: string,
+): {
   templates: PromptTemplate[];
   storageAvailable: boolean;
   invalidCustomCount: number;
@@ -280,7 +287,7 @@ function readCustomPromptTemplates(storage = getStorage()): {
   if (!storage) return { templates: [], storageAvailable: false, invalidCustomCount: 0 };
   let raw: string | null;
   try {
-    raw = storage.getItem(PROMPT_TEMPLATE_STORAGE_KEY);
+    raw = storage.getItem(storageKeyFor(owner));
   } catch {
     return { templates: [], storageAvailable: false, invalidCustomCount: 0 };
   }
@@ -307,11 +314,15 @@ function readCustomPromptTemplates(storage = getStorage()): {
   };
 }
 
-function writeCustomPromptTemplates(templates: PromptTemplate[], storage = getStorage()): boolean {
+function writeCustomPromptTemplates(
+  templates: PromptTemplate[],
+  storage = getStorage(),
+  owner?: string,
+): boolean {
   if (!storage) return false;
   try {
     storage.setItem(
-      PROMPT_TEMPLATE_STORAGE_KEY,
+      storageKeyFor(owner),
       JSON.stringify(templates.slice(0, MAX_CUSTOM_PROMPT_TEMPLATES)),
     );
     return true;
@@ -323,8 +334,9 @@ function writeCustomPromptTemplates(templates: PromptTemplate[], storage = getSt
 export function loadPromptTemplateCatalog(
   storage = getStorage(),
   translate?: Translate,
+  owner?: string,
 ): PromptTemplateCatalog {
-  const custom = readCustomPromptTemplates(storage);
+  const custom = readCustomPromptTemplates(storage, owner);
   return {
     templates: [...custom.templates, ...getBuiltinPromptTemplates(translate)],
     customCount: custom.templates.length,
@@ -337,13 +349,14 @@ export function saveCustomPromptTemplate(
   draft: PromptTemplateDraft,
   storage = getStorage(),
   timestamp = nowMs(),
+  owner?: string,
 ): SavePromptTemplateResult {
   const title = normalizeText(draft.title, MAX_PROMPT_TEMPLATE_TITLE_LENGTH);
   const body = normalizeText(draft.body, MAX_PROMPT_TEMPLATE_BODY_LENGTH);
   if (!title) return { ok: false, reason: 'empty_title' };
   if (!body) return { ok: false, reason: 'empty_body' };
 
-  const current = readCustomPromptTemplates(storage);
+  const current = readCustomPromptTemplates(storage, owner);
   if (!current.storageAvailable) return { ok: false, reason: 'storage_unavailable' };
 
   const template: PromptTemplate = {
@@ -359,26 +372,30 @@ export function saveCustomPromptTemplate(
     updatedAt: timestamp,
   };
   const next = [template, ...current.templates].slice(0, MAX_CUSTOM_PROMPT_TEMPLATES);
-  if (!writeCustomPromptTemplates(next, storage)) {
+  if (!writeCustomPromptTemplates(next, storage, owner)) {
     return { ok: false, reason: 'storage_unavailable' };
   }
   return { ok: true, template };
 }
 
-export function deleteCustomPromptTemplate(id: string, storage = getStorage()): boolean {
-  const current = readCustomPromptTemplates(storage);
+export function deleteCustomPromptTemplate(
+  id: string,
+  storage = getStorage(),
+  owner?: string,
+): boolean {
+  const current = readCustomPromptTemplates(storage, owner);
   if (!current.storageAvailable) return false;
   const next = current.templates.filter((template) => template.id !== id);
   if (next.length === current.templates.length) return true;
   if (next.length === 0) {
     try {
-      storage?.removeItem(PROMPT_TEMPLATE_STORAGE_KEY);
+      storage?.removeItem(storageKeyFor(owner));
       return true;
     } catch {
       return false;
     }
   }
-  return writeCustomPromptTemplates(next, storage);
+  return writeCustomPromptTemplates(next, storage, owner);
 }
 
 export function applyPromptTemplate(
diff --git a/web/src/pages/studio/UserManagement.tsx b/web/src/pages/studio/UserManagement.tsx
index e33972a0a..8b5897c5b 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -131,6 +131,7 @@ const UserManagementPage = () => {
   const [statusFilter, setStatusFilter] = useState<StatusFilter>();
   const [loading, setLoading] = useState(false);
   const [sessionOverview, setSessionOverview] = useState<StudioUserSessionOverview | null>(null);
+  const [sessionOverviewFailed, setSessionOverviewFailed] = useState(false);
   const [sessionModalUser, setSessionModalUser] = useState<StudioUser | null>(null);
   const [sessionDetails, setSessionDetails] = useState<StudioUserSessionDetail[]>([]);
   const [sessionDetailsLoading, setSessionDetailsLoading] = useState(false);
@@ -158,6 +159,7 @@ const UserManagementPage = () => {
       setUsers([]);
       setTotal(0);
       setSessionOverview(null);
+      setSessionOverviewFailed(false);
       sessionModalUserIdRef.current = null;
       setSessionModalUser(null);
       setSessionDetails([]);
@@ -176,10 +178,14 @@ const UserManagementPage = () => {
           page,
           pageSize,
         }),
-        getStudioUserSessionOverview().catch(() => null),
+        getStudioUserSessionOverview().then(
+          (value) => ({ value, failed: false }),
+          () => ({ value: null, failed: true }),
+        ),
       ]);
       if (requestId !== requestSeqRef.current) return;
-      setSessionOverview(overview);
+      setSessionOverview(overview.value);
+      setSessionOverviewFailed(overview.failed);
       if (result.items.length === 0 && result.total > 0 && page > 1) {
         const lastPage = Math.max(1, Math.ceil(result.total / result.size));
         if (page > lastPage) {
@@ -590,6 +596,23 @@ const UserManagementPage = () => {
           {t('userMgmt.changeMyPassword')}
         </Button>
       </Card>
+      {admin && sessionOverviewFailed && (
+        <Alert
+          type="error"
+          showIcon
+          message={t('userMgmt.sessionOverviewLoadFailed')}
+          action={
+            <Button
+              size="small"
+              loading={loading}
+              onClick={() => setRefreshVersion((version) => version + 1)}
+            >
+              {t('common.retry')}
+            </Button>
+          }
+          style={{ marginBottom: 16 }}
+        />
+      )}
       {admin && sessionOverview && (
         <Card title={t('userMgmt.sessionOverview')} style={{ marginBottom: 16 }}>
           <Flex gap={32} wrap>
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 6b3459987..1d2fc8a54 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -197,6 +197,24 @@ describe('UserManagementPage', () => {
     expect(screen.getByText('未来 5 分钟过期')).toBeInTheDocument();
   });
 
+  it('reports an independent session overview failure and recovers on retry', async () => {
+    vi.mocked(getStudioUserSessionOverview).mockRejectedValueOnce(
+      new Error('overview unavailable'),
+    );
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    // The user inventory succeeded, so it stays usable while the missing overview is explained.
+    await screen.findByText('operator');
+    expect(screen.getByText('加载会话概览失败')).toBeInTheDocument();
+    expect(screen.queryByText('未来 5 分钟过期')).not.toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: /重\s*试/ }));
+    await screen.findByText('未来 5 分钟过期');
+    expect(screen.queryByText('加载会话概览失败')).not.toBeInTheDocument();
+    expect(getStudioUserSessionOverview).toHaveBeenCalledTimes(2);
+  });
+
   it('debounces username search and sends role and status filters', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();