From fb508a302a86f68855e739c35d0cd3ceace8c4e9 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 13 May 2026 13:10:21 +0000 Subject: [PATCH] feat(webui): refresh session titles from live updates --- nanobot/session/manager.py | 50 ++++++++++++++++++++- tests/agent/test_session_manager_history.py | 13 ++++++ webui/src/hooks/useNanobotStream.ts | 5 --- webui/src/hooks/useSessions.ts | 6 +++ webui/src/lib/nanobot-client.ts | 20 +++++++++ webui/src/tests/nanobot-client.test.ts | 19 ++++++++ webui/src/tests/useNanobotStream.test.tsx | 16 ------- webui/src/tests/useSessions.test.tsx | 47 +++++++++++++++++++ 8 files changed, 154 insertions(+), 22 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 47d98976b..188911435 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -25,6 +25,7 @@ FILE_MAX_MESSAGES = 2000 _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?") _LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$") _TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$') +_SESSION_PREVIEW_MAX_CHARS = 120 def _sanitize_assistant_replay_text(content: str) -> str: @@ -43,6 +44,27 @@ def _sanitize_assistant_replay_text(content: str) -> str: return "\n".join(lines).strip() +def _text_preview(content: Any) -> str: + """Return compact display text for session lists.""" + if isinstance(content, str): + text = content + elif isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + value = block.get("text") + if isinstance(value, str): + parts.append(value) + text = " ".join(parts) + else: + return "" + text = _sanitize_assistant_replay_text(text) + text = re.sub(r"\s+", " ", text).strip() + if len(text) > _SESSION_PREVIEW_MAX_CHARS: + text = text[: _SESSION_PREVIEW_MAX_CHARS - 1].rstrip() + "…" + return text + + @dataclass class Session: """A conversation session.""" @@ -560,7 +582,7 @@ class SessionManager: for path in self.sessions_dir.glob("*.jsonl"): fallback_key = path.stem.replace("_", ":", 1) try: - # Read just the metadata line + # Read the metadata line and a small preview for WebUI/session lists. with open(path, encoding="utf-8") as f: first_line = f.readline().strip() if first_line: @@ -569,11 +591,29 @@ class SessionManager: key = data.get("key") or path.stem.replace("_", ":", 1) metadata = data.get("metadata", {}) title = metadata.get("title") if isinstance(metadata, dict) else None + preview = "" + fallback_preview = "" + for line in f: + if not line.strip(): + continue + item = json.loads(line) + if item.get("_type") == "metadata": + continue + text = _text_preview(item.get("content")) + if not text: + continue + if item.get("role") == "user": + preview = text + break + if not fallback_preview and item.get("role") == "assistant": + fallback_preview = text + preview = preview or fallback_preview sessions.append({ "key": key, "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), "title": title if isinstance(title, str) else "", + "preview": preview, "path": str(path) }) except Exception: @@ -588,6 +628,14 @@ class SessionManager: if isinstance(repaired.metadata.get("title"), str) else "" ), + "preview": next( + ( + text + for msg in repaired.messages + if (text := _text_preview(msg.get("content"))) + ), + "", + ), "path": str(path) }) continue diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 9fb77fafd..ffc41583d 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -43,6 +43,19 @@ def test_list_sessions_includes_metadata_title(tmp_path): assert rows[0]["title"] == "自动生成标题" +def test_list_sessions_includes_user_preview(tmp_path): + manager = SessionManager(tmp_path) + session = manager.get_or_create("websocket:chat-preview") + session.add_message("user", "帮我总结一下 OpenAI 的最新硬件计划") + session.add_message("assistant", "可以,我会先查最新消息。") + manager.save(session) + + rows = manager.list_sessions() + + assert rows[0]["key"] == "websocket:chat-preview" + assert rows[0]["preview"] == "帮我总结一下 OpenAI 的最新硬件计划" + + # --- Original regression test (from PR 2075) --- def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls(): diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 10f1e2400..c399856db 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -294,11 +294,6 @@ export function useNanobotStream( return; } - if (ev.event === "session_updated") { - onTurnEnd?.(); - return; - } - if (ev.event === "message") { if ( suppressStreamUntilTurnEndRef.current && diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index d1be437b7..89bf436cc 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -91,6 +91,12 @@ export function useSessions(): { void refresh(); }, [refresh]); + useEffect(() => { + return client.onSessionUpdate(() => { + void refresh(); + }); + }, [client, refresh]); + const createChat = useCallback(async (): Promise => { const chatId = await client.newChat(); const key = `websocket:${chatId}`; diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index f8243cfae..98f1796e2 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -15,6 +15,7 @@ type Unsubscribe = () => void; type EventHandler = (ev: InboundEvent) => void; type StatusHandler = (status: ConnectionStatus) => void; type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void; +type SessionUpdateHandler = (chatId: string) => void; /** Structured connection-level errors surfaced to the UI. * @@ -60,6 +61,7 @@ export class NanobotClient { private socket: WebSocket | null = null; private statusHandlers = new Set(); private runtimeModelHandlers = new Set(); + private sessionUpdateHandlers = new Set(); private errorHandlers = new Set(); // chat_id -> handlers listening on it private chatHandlers = new Map>(); @@ -116,6 +118,13 @@ export class NanobotClient { }; } + onSessionUpdate(handler: SessionUpdateHandler): Unsubscribe { + this.sessionUpdateHandlers.add(handler); + return () => { + this.sessionUpdateHandlers.delete(handler); + }; + } + /** Subscribe to transport-level faults (see :type:`StreamError`). */ onError(handler: ErrorHandler): Unsubscribe { this.errorHandlers.add(handler); @@ -259,6 +268,11 @@ export class NanobotClient { return; } + if (parsed.event === "session_updated") { + this.emitSessionUpdate(parsed.chat_id); + return; + } + const chatId = (parsed as { chat_id?: string }).chat_id; if (chatId) this.dispatch(chatId, parsed); } @@ -269,6 +283,12 @@ export class NanobotClient { } } + private emitSessionUpdate(chatId: string): void { + for (const handler of this.sessionUpdateHandlers) { + handler(chatId); + } + } + private dispatch(chatId: string, ev: InboundEvent): void { const handlers = this.chatHandlers.get(chatId); if (!handlers) return; diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 899d10c58..084b015b7 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -109,6 +109,25 @@ describe("NanobotClient", () => { expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast"); }); + it("dispatches session updates globally", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const globalHandler = vi.fn(); + const chatHandler = vi.fn(); + client.onSessionUpdate(globalHandler); + client.onChat("chat-title", chatHandler); + client.connect(); + lastSocket().fakeOpen(); + + lastSocket().fakeMessage({ event: "session_updated", chat_id: "chat-title" }); + + expect(globalHandler).toHaveBeenCalledWith("chat-title"); + expect(chatHandler).not.toHaveBeenCalled(); + }); + it("resolves newChat() via the server-assigned chat_id", async () => { const client = new NanobotClient({ url: "ws://test", diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 0aa069cfb..311e7545f 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -477,20 +477,4 @@ describe("useNanobotStream", () => { expect(onTurnEnd).toHaveBeenCalledTimes(1); }); - it("refreshes session metadata when the server reports a session update", () => { - const fake = fakeClient(); - const onTurnEnd = vi.fn(); - renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), { - wrapper: wrap(fake.client), - }); - - act(() => { - fake.emit("chat-title", { - event: "session_updated", - chat_id: "chat-title", - }); - }); - - expect(onTurnEnd).toHaveBeenCalledTimes(1); - }); }); diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index 988b97252..ecb1df681 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -17,12 +17,20 @@ vi.mock("@/lib/api", async (importOriginal) => { }); function fakeClient() { + const sessionUpdateHandlers = new Set<(chatId: string) => void>(); return { status: "open" as const, defaultChatId: null as string | null, onStatus: () => () => {}, onError: () => () => {}, onChat: () => () => {}, + onSessionUpdate: (handler: (chatId: string) => void) => { + sessionUpdateHandlers.add(handler); + return () => sessionUpdateHandlers.delete(handler); + }, + emitSessionUpdate: (chatId: string) => { + for (const handler of sessionUpdateHandlers) handler(chatId); + }, sendMessage: vi.fn(), newChat: vi.fn(), attach: vi.fn(), @@ -87,6 +95,45 @@ describe("useSessions", () => { expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]); }); + it("refreshes sessions when the websocket reports a session update", async () => { + vi.mocked(api.listSessions) + .mockResolvedValueOnce([ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "", + }, + ]) + .mockResolvedValueOnce([ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:01:00Z", + title: "生成的小标题", + preview: "用户第一句话", + }, + ]); + const client = fakeClient(); + + const { result } = renderHook(() => useSessions(), { + wrapper: wrap(client), + }); + + await waitFor(() => expect(result.current.sessions[0]?.title).toBeUndefined()); + + act(() => { + client.emitSessionUpdate("chat-a"); + }); + + await waitFor(() => expect(result.current.sessions[0]?.title).toBe("生成的小标题")); + expect(api.listSessions).toHaveBeenCalledTimes(2); + }); + it("hydrates media_urls from historical user turns into UIMessage.images", async () => { // Round-trip check for the signed-media replay: the backend emits // ``media_urls`` on a historical user row and the hook must surface them