fix(webui): project generated titles onto per-chat sessions under unifiedSession

With unifiedSession enabled, all turns are routed to the shared
unified:default session, so title generation, persistence, and change
notifications all happen on that shared session. The WebUI sidebar,
however, renders per-chat websocket:<id> sessions, so generated titles
never reach the session the user sees and every chat stays "Untitled".

Pass the origin chat_id through maybe_generate_webui_title_after_turn and
add a target_session_key to maybe_generate_webui_title so the title is
written to the per-chat session while conversation content still comes
from the shared (routed) session. When the routed session differs from
the per-chat session, the latest user/assistant exchange is used as the
title prompt since the shared session mixes content from every channel.
The shared session's title metadata is no longer read or written, so
stale shared titles can no longer leak into per-chat sessions.
This commit is contained in:
zpljd258
2026-08-26 10:52:56 +08:00
committed by chengyongru
parent 42f37dc4c0
commit a8ffe0f136
2 changed files with 126 additions and 11 deletions
+63 -11
View File
@@ -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,
+63
View File
@@ -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")