diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 784c4da13..d1952312b 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -55,6 +55,7 @@ from nanobot.utils.progress_events import ( on_progress_accepts_tool_events, ) from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE +from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn if TYPE_CHECKING: from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig @@ -814,6 +815,25 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content="", metadata={**msg.metadata, "_turn_end": True}, )) + if msg.metadata.get("webui") is True: + async def _generate_title_and_notify() -> None: + generated = await maybe_generate_webui_title_after_turn( + channel=msg.channel, + metadata=msg.metadata, + sessions=self.sessions, + session_key=session_key, + provider=self.provider, + model=self.model, + ) + if generated: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="", + metadata={**msg.metadata, "_session_updated": True}, + )) + + self._schedule_background(_generate_title_and_notify()) except asyncio.CancelledError: logger.info("Task cancelled for session {}", session_key) # Preserve partial context from the interrupted turn so @@ -1003,6 +1023,7 @@ class AgentLoop: key = session_key or msg.session_key session = self.sessions.get_or_create(key) + mark_webui_session(session, msg.metadata) if self._restore_runtime_checkpoint(session): self.sessions.save(session) if self._restore_pending_user_turn(session): diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 0f60c63a8..62e67a5b7 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -1184,12 +1184,15 @@ class WebSocketChannel(BaseChannel): # Auto-attach on first use so clients can one-shot without a separate attach. self._attach(connection, cid) + metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)} + if envelope.get("webui") is True: + metadata["webui"] = True await self._handle_message( sender_id=client_id, chat_id=cid, content=content, media=media_paths or None, - metadata={"remote": getattr(connection, "remote_address", None)}, + metadata=metadata, ) return await self._send_event(connection, "error", detail=f"unknown type: {t!r}") @@ -1233,6 +1236,9 @@ class WebSocketChannel(BaseChannel): if msg.metadata.get("_turn_end"): await self.send_turn_end(msg.chat_id) return + if msg.metadata.get("_session_updated"): + await self.send_session_updated(msg.chat_id) + return text = msg.content if msg.buttons: text = _append_buttons_as_text(text, msg.buttons) @@ -1299,3 +1305,13 @@ class WebSocketChannel(BaseChannel): raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" turn_end ") + + async def send_session_updated(self, chat_id: str) -> None: + """Notify clients that session metadata changed outside the main turn.""" + conns = list(self._subs.get(chat_id, ())) + if not conns: + return + body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id} + raw = json.dumps(body, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" session_updated ") diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 06c7317d0..859d2cca8 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -547,10 +547,13 @@ class SessionManager: data = json.loads(first_line) if data.get("_type") == "metadata": key = data.get("key") or path.stem.replace("_", ":", 1) + metadata = data.get("metadata", {}) + title = metadata.get("title") if isinstance(metadata, dict) else None sessions.append({ "key": key, "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), + "title": title if isinstance(title, str) else "", "path": str(path) }) except Exception: @@ -560,6 +563,11 @@ class SessionManager: "key": repaired.key, "created_at": repaired.created_at.isoformat(), "updated_at": repaired.updated_at.isoformat(), + "title": ( + repaired.metadata.get("title") + if isinstance(repaired.metadata.get("title"), str) + else "" + ), "path": str(path) }) continue diff --git a/nanobot/utils/webui_titles.py b/nanobot/utils/webui_titles.py new file mode 100644 index 000000000..2d363f926 --- /dev/null +++ b/nanobot/utils/webui_titles.py @@ -0,0 +1,138 @@ +"""Helpers for WebUI chat title generation.""" + +from __future__ import annotations + +import re +from typing import Any + +from loguru import logger + +from nanobot.providers.base import LLMProvider +from nanobot.session.manager import Session, SessionManager +from nanobot.utils.helpers import truncate_text + +WEBUI_SESSION_METADATA_KEY = "webui" +WEBUI_TITLE_METADATA_KEY = "title" +WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited" +TITLE_MAX_CHARS = 60 + + +def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool: + """Persist a WebUI marker only when the inbound websocket frame opted in.""" + if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + return True + + +def clean_generated_title(raw: str | None) -> str: + text = (raw or "").strip() + if not text: + return "" + text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE) + text = text.strip().strip("\"'`“”‘’") + text = re.sub(r"\s+", " ", text).strip() + text = text.rstrip("。.!!??,,;;:") + if len(text) > TITLE_MAX_CHARS: + text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…" + return text + + +def _title_inputs(session: Session) -> tuple[str, str]: + user_text = "" + assistant_text = "" + for message in session.messages: + role = message.get("role") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + continue + if role == "user" and not user_text: + user_text = content.strip() + elif role == "assistant" and not assistant_text: + assistant_text = content.strip() + if user_text and assistant_text: + break + return user_text, assistant_text + + +async def maybe_generate_webui_title( + *, + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + """Generate and persist a short title for WebUI-owned sessions only.""" + session = sessions.get_or_create(session_key) + if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True: + return False + current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY) + if isinstance(current_title, str) and current_title.strip(): + return False + + user_text, assistant_text = _title_inputs(session) + if not user_text: + return False + + prompt = ( + "Generate a concise title for this chat.\n" + "Rules:\n" + "- Use the same language as the user when practical.\n" + "- 3 to 8 words.\n" + "- No quotes.\n" + "- No punctuation at the end.\n" + "- Return only the title.\n\n" + f"User: {truncate_text(user_text, 1_000)}" + ) + if assistant_text: + prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}" + + try: + response = await provider.chat_with_retry( + [ + { + "role": "system", + "content": ( + "You write short, neutral chat titles. " + "Return only the title text." + ), + }, + {"role": "user", "content": prompt}, + ], + tools=None, + model=model, + max_tokens=32, + temperature=0.2, + retry_mode="standard", + ) + except Exception: + logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True) + return False + + title = clean_generated_title(response.content) + if not title or title.lower().startswith("error"): + return False + session.metadata[WEBUI_TITLE_METADATA_KEY] = title + sessions.save(session) + return True + + +async def maybe_generate_webui_title_after_turn( + *, + channel: str, + metadata: dict[str, Any], + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + return await maybe_generate_webui_title( + sessions=sessions, + session_key=session_key, + provider=provider, + model=model, + ) diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 47a63ba02..ee3f1e3db 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -1,5 +1,6 @@ """Tests for structured tool-event progress metadata emitted by AgentLoop.""" +import asyncio from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -291,6 +292,48 @@ class TestToolEventProgress: assert (outbound[-1].metadata or {}).get("_turn_end") is True assert outbound[-1].chat_id == "chat1" + @pytest.mark.asyncio + async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + title_started = asyncio.Event() + release_title = asyncio.Event() + calls = 0 + + async def chat_with_retry(*_args: object, **_kwargs: object) -> LLMResponse: + nonlocal calls + calls += 1 + if calls == 1: + return LLMResponse(content="Done", tool_calls=[]) + title_started.set() + await release_title.wait() + return LLMResponse(content="Generated title", tool_calls=[]) + + provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry) + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + await asyncio.wait_for(loop._dispatch(InboundMessage( + channel="websocket", + sender_id="u1", + chat_id="chat1", + content="say hello", + metadata={"webui": True}, + )), timeout=0.5) + + outbound = [await bus.consume_outbound(), await bus.consume_outbound()] + assert outbound[0].content == "Done" + assert (outbound[1].metadata or {}).get("_turn_end") is True + + await asyncio.wait_for(title_started.wait(), timeout=0.5) + release_title.set() + session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5) + + assert (session_updated.metadata or {}).get("_session_updated") is True + assert provider.chat_with_retry.await_count == 2 + @pytest.mark.asyncio async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None: bus = MessageBus() diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index c3dd90af2..36b133999 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -8,7 +8,13 @@ from nanobot.agent.context import ContextBuilder from nanobot.agent.loop import AgentLoop from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMResponse from nanobot.session.manager import Session +from nanobot.utils.webui_titles import ( + WEBUI_SESSION_METADATA_KEY, + WEBUI_TITLE_METADATA_KEY, + maybe_generate_webui_title, +) def _mk_loop() -> AgentLoop: @@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop: def _make_full_loop(tmp_path: Path) -> AgentLoop: provider = MagicMock() provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title")) return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model") +@pytest.mark.asyncio +async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content='"优化 WebUI 侧边栏。"', finish_reason="stop") + ) + session = loop.sessions.get_or_create("websocket:chat-title") + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + session.add_message("user", "帮我优化一下 webui 的 sidebar") + session.add_message("assistant", "可以,我会先调整布局和视觉层级。") + loop.sessions.save(session) + + generated = await maybe_generate_webui_title( + sessions=loop.sessions, + session_key="websocket:chat-title", + provider=loop.provider, + model=loop.model, + ) + + assert generated is True + assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏" + loop.provider.chat_with_retry.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content="Plain websocket title", finish_reason="stop") + ) + session = loop.sessions.get_or_create("websocket:custom-client") + session.add_message("user", "hello from a custom websocket client") + loop.sessions.save(session) + + generated = await maybe_generate_webui_title( + sessions=loop.sessions, + session_key="websocket:custom-client", + provider=loop.provider, + model=loop.model, + ) + + assert generated is False + assert WEBUI_TITLE_METADATA_KEY not in session.metadata + loop.provider.chat_with_retry.assert_not_awaited() + + def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: loop = _mk_loop() session = Session(key="test:runtime-only") diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index b80c774a1..75bc7713d 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -1,4 +1,4 @@ -from nanobot.session.manager import Session +from nanobot.session.manager import Session, SessionManager def _assert_no_orphans(history: list[dict]) -> None: @@ -31,6 +31,18 @@ def _tool_turn(prefix: str, idx: int) -> list[dict]: ] +def test_list_sessions_includes_metadata_title(tmp_path): + manager = SessionManager(tmp_path) + session = manager.get_or_create("websocket:chat-title") + session.metadata["title"] = "自动生成标题" + manager.save(session) + + rows = manager.list_sessions() + + assert rows[0]["key"] == "websocket:chat-title" + assert rows[0]["title"] == "自动生成标题" + + # --- Original regression test (from PR 2075) --- def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls(): diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index db61fc285..f20095388 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -167,6 +167,40 @@ def test_issue_route_secret_matches_empty_secret() -> None: assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True +@pytest.mark.asyncio +async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None: + channel = _ch(bus) + conn = MagicMock() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "webui-client", + {"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True}, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert msg.channel == "websocket" + assert msg.chat_id == "chat-1" + assert msg.metadata["webui"] is True + assert msg.metadata["_wants_stream"] is True + + +@pytest.mark.asyncio +async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> None: + channel = _ch(bus) + conn = MagicMock() + + await channel._dispatch_envelope( + conn, + "custom-client", + {"type": "message", "chat_id": "chat-1", "content": "hello"}, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert "webui" not in msg.metadata + + @pytest.mark.asyncio async def test_send_delivers_json_message_with_media_and_reply() -> None: bus = MagicMock() @@ -306,6 +340,25 @@ async def test_send_turn_end_emits_turn_end_event() -> None: assert body == {"event": "turn_end", "chat_id": "chat-1"} +@pytest.mark.asyncio +async def test_send_session_updated_emits_session_updated_event() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_session_updated": True}, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "session_updated", "chat_id": "chat-1"} + + @pytest.mark.asyncio async def test_send_non_connection_closed_exception_is_raised() -> None: bus = MagicMock() diff --git a/webui/src/App.tsx b/webui/src/App.tsx index c6ad6f067..0fbb3f54f 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -25,7 +25,7 @@ type BootState = }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; -const SIDEBAR_WIDTH = 279; +const SIDEBAR_WIDTH = 272; type ShellView = "chat" | "settings"; function readSidebarOpen(): boolean { @@ -99,13 +99,6 @@ export default function App() { return (
{t("app.error.title")}
{state.message}
@@ -213,7 +199,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | } }, []); - const onNewChat = useCallback(async () => { + const onCreateChat = useCallback(async () => { try { const chatId = await createChat(); setActiveKey(`websocket:${chatId}`); @@ -226,6 +212,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | } }, [createChat]); + const onNewChat = useCallback(() => { + setActiveKey(null); + setView("chat"); + setMobileSidebarOpen(false); + }, []); + const onSelectChat = useCallback( (key: string) => { setActiveKey(key); @@ -235,6 +227,15 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | [], ); + const onOpenSettings = useCallback(() => { + setView("settings"); + setMobileSidebarOpen(false); + }, []); + + const onTurnEnd = useCallback(() => { + void refresh(); + }, [refresh]); + const onConfirmDelete = useCallback(async () => { if (!pendingDelete) return; const key = pendingDelete.key; @@ -254,7 +255,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | }, [pendingDelete, deleteChat, activeKey, sessions]); const headerTitle = activeSession - ? activeSession.preview || + ? activeSession.title || + activeSession.preview || t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) }) : t("app.brand"); @@ -268,20 +270,10 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | sessions, activeKey, loading, - theme, - onToggleTheme: toggle, - onNewChat: () => { - void onNewChat(); - }, + onNewChat, onSelect: onSelectChat, - onRefresh: () => void refresh(), onRequestDelete: (key: string, label: string) => setPendingDelete({ key, label }), - activeView: view, - onOpenSettings: () => { - setView("settings" as const); - setMobileSidebarOpen(false); - }, }; return ( @@ -296,10 +288,11 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | >
- Your conversations are persisted locally under the nanobot @@ -105,7 +93,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) { disabled={booting} onSend={handleWelcomeSend} placeholder={ - booting ? "Opening a new chat…" : "Type your message…" + booting ? "Opening a new chat…" : "Ask anything..." } />