diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index a403b69b0..c04ff52fc 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -168,30 +168,76 @@ def _title_inputs(session: Session) -> tuple[str, str]: return user_text, assistant_text +def _latest_title_inputs(session: Session) -> tuple[str, str]: + """Latest user/assistant texts, for turns executed on a shared session.""" + user_text = "" + assistant_text = "" + for message in reversed(session.messages): + if message.get("_command") is True: + continue + if is_hidden_history_message(message): + continue + message = public_history_message(message) + role = message.get("role") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + continue + content = strip_think(content) + if not content: + 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, + target_session_key: str | None = None, ) -> 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: + """Generate and persist a short title for WebUI-owned sessions. + + ``session_key`` owns the conversation content. Under unified-session + routing this is the shared session while WebUI renders per-chat sessions, + so pass ``target_session_key`` to project the title onto that per-chat + session instead of storing it on the shared one. + """ + routed_session = sessions.get_or_create(session_key) + target_is_routed = target_session_key is None or target_session_key == session_key + if target_is_routed or target_session_key is None: + target_session = routed_session + else: + target_session = sessions.get_or_create(target_session_key) + if ( + routed_session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True + and target_session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True + ): return False - if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True: + if target_session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True: return False - current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY) + current_title = target_session.metadata.get(WEBUI_TITLE_METADATA_KEY) if isinstance(current_title, str) and current_title.strip(): cleaned_current_title = clean_generated_title(current_title) if cleaned_current_title: if cleaned_current_title != current_title: - session.metadata[WEBUI_TITLE_METADATA_KEY] = cleaned_current_title - sessions.save(session) + target_session.metadata[WEBUI_TITLE_METADATA_KEY] = cleaned_current_title + sessions.save(target_session) return False - session.metadata.pop(WEBUI_TITLE_METADATA_KEY, None) + target_session.metadata.pop(WEBUI_TITLE_METADATA_KEY, None) - user_text, assistant_text = _title_inputs(session) + if target_is_routed: + user_text, assistant_text = _title_inputs(routed_session) + else: + # Shared-session content mixes every channel; generation runs right + # after this turn, so its exchange is the latest pair. + user_text, assistant_text = _latest_title_inputs(routed_session) if not user_text: return False @@ -240,14 +286,15 @@ async def maybe_generate_webui_title( response.finish_reason, ) return False - session.metadata[WEBUI_TITLE_METADATA_KEY] = title - sessions.save(session) + target_session.metadata[WEBUI_TITLE_METADATA_KEY] = title + sessions.save(target_session) return True async def maybe_generate_webui_title_after_turn( *, channel: str, + chat_id: str, metadata: dict[str, Any], sessions: SessionManager, session_key: str, @@ -256,11 +303,15 @@ async def maybe_generate_webui_title_after_turn( ) -> bool: if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: return False + origin_session_key = f"{channel}:{chat_id}" return await maybe_generate_webui_title( sessions=sessions, session_key=session_key, provider=provider, model=model, + target_session_key=( + origin_session_key if origin_session_key != session_key else None + ), ) @@ -731,6 +782,7 @@ class WebuiTurnCoordinator: ) -> None: generated = await maybe_generate_webui_title_after_turn( channel=event.context.channel, + chat_id=event.context.chat_id, metadata=event.context.metadata, sessions=self.sessions, session_key=event.context.session_key, diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 82953b893..618bbf2c2 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -49,6 +49,7 @@ from nanobot.session.webui_turns import ( WebuiTurnCoordinator, clean_generated_title, maybe_generate_webui_title, + maybe_generate_webui_title_after_turn, ) from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META @@ -395,6 +396,68 @@ async def test_generate_webui_title_ignores_cron_internal_turns(tmp_path: Path) loop.provider.chat_with_retry.assert_not_awaited() +@pytest.mark.asyncio +async def test_generate_webui_title_projects_onto_chat_session_under_unified_routing( + tmp_path: Path, +) -> None: + loop = _make_full_loop(tmp_path) + loop.provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content='"查询临期 IP"', finish_reason="stop") + ) + unified = loop.sessions.get_or_create(UNIFIED_SESSION_KEY) + unified.metadata[WEBUI_SESSION_METADATA_KEY] = True + unified.metadata[WEBUI_TITLE_METADATA_KEY] = "开启私聊Topic功能" + unified.add_message("user", "很早以前的问题") + unified.add_message("assistant", "很久以前的回答。") + unified.add_message("user", "帮我查一下临期IP有哪些") + unified.add_message("assistant", "以下是临期 IP 列表。") + loop.sessions.save(unified) + + generated = await maybe_generate_webui_title_after_turn( + channel="websocket", + chat_id="chat-projection", + metadata={WEBUI_SESSION_METADATA_KEY: True}, + sessions=loop.sessions, + session_key=UNIFIED_SESSION_KEY, + provider=loop.provider, + model=loop.model, + ) + + assert generated is True + chat = loop.sessions.get_or_create("websocket:chat-projection") + assert chat.metadata[WEBUI_TITLE_METADATA_KEY] == "查询临期 IP" + assert unified.metadata[WEBUI_TITLE_METADATA_KEY] == "开启私聊Topic功能" + prompt = loop.provider.chat_with_retry.await_args.args[0][1]["content"] + assert "帮我查一下临期IP有哪些" in prompt + assert "很早以前的问题" not in prompt + + +@pytest.mark.asyncio +async def test_projected_title_generation_skips_existing_chat_title(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + unified = loop.sessions.get_or_create(UNIFIED_SESSION_KEY) + unified.metadata[WEBUI_SESSION_METADATA_KEY] = True + unified.add_message("user", "帮我查一下临期IP有哪些") + unified.add_message("assistant", "以下是临期 IP 列表。") + chat = loop.sessions.get_or_create("websocket:chat-existing") + chat.metadata[WEBUI_TITLE_METADATA_KEY] = "Existing title" + loop.sessions.save(unified) + + generated = await maybe_generate_webui_title_after_turn( + channel="websocket", + chat_id="chat-existing", + metadata={WEBUI_SESSION_METADATA_KEY: True}, + sessions=loop.sessions, + session_key=UNIFIED_SESSION_KEY, + provider=loop.provider, + model=loop.model, + ) + + assert generated is False + assert chat.metadata[WEBUI_TITLE_METADATA_KEY] == "Existing title" + loop.provider.chat_with_retry.assert_not_awaited() + + def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None: loop = _mk_loop() session = Session(key="test:runtime-only")