mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
feat(webui): refresh session titles from live updates
This commit is contained in:
parent
79e528119c
commit
fb508a302a
@ -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
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -294,11 +294,6 @@ export function useNanobotStream(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "session_updated") {
|
||||
onTurnEnd?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "message") {
|
||||
if (
|
||||
suppressStreamUntilTurnEndRef.current &&
|
||||
|
||||
@ -91,6 +91,12 @@ export function useSessions(): {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onSessionUpdate(() => {
|
||||
void refresh();
|
||||
});
|
||||
}, [client, refresh]);
|
||||
|
||||
const createChat = useCallback(async (): Promise<string> => {
|
||||
const chatId = await client.newChat();
|
||||
const key = `websocket:${chatId}`;
|
||||
|
||||
@ -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<StatusHandler>();
|
||||
private runtimeModelHandlers = new Set<RuntimeModelHandler>();
|
||||
private sessionUpdateHandlers = new Set<SessionUpdateHandler>();
|
||||
private errorHandlers = new Set<ErrorHandler>();
|
||||
// chat_id -> handlers listening on it
|
||||
private chatHandlers = new Map<string, Set<EventHandler>>();
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user