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();