feat(webui): add lightweight session messaging via mentions

This commit is contained in:
chengyongru
2026-08-19 01:15:56 +08:00
committed by chengyongru
parent 2bdb11eeba
commit 0e184965e8
76 changed files with 8297 additions and 658 deletions
+112 -14
View File
@@ -4,9 +4,9 @@ from __future__ import annotations
import re
import time
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, replace
from typing import Any
from typing import Any, cast
from uuid import uuid4
from loguru import logger
@@ -19,6 +19,7 @@ from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
RuntimeModelUpdatedEvent,
SessionMessageInputEvent,
SessionUpdatedEvent,
TurnEndEvent,
TurnModelUpdatedEvent,
@@ -41,12 +42,20 @@ from nanobot.runtime_context import public_history_message
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import Session, SessionManager
from nanobot.session.session_messages import (
SESSION_MESSAGE_METADATA_KEY,
session_message_inbound,
session_message_public_metadata,
session_reply_timeout_inbound,
)
from nanobot.utils.helpers import strip_think, truncate_text
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.webui.metadata import (
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
)
from nanobot.webui.transcript import append_session_message_input
WEBUI_SESSION_METADATA_KEY = "webui"
WEBUI_TITLE_METADATA_KEY = "title"
@@ -106,6 +115,20 @@ def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
return True
def _session_for_webui_lifecycle(
sessions: SessionManager,
msg: InboundMessage,
session_key: str,
) -> Session | None:
"""Resolve lifecycle state without reviving deleted internal-message targets."""
if (
session_message_inbound(msg) is not None
or session_reply_timeout_inbound(msg) is not None
):
return sessions.get_existing(session_key)
return sessions.get_or_create(session_key)
def clean_generated_title(raw: str | None) -> str:
text = (raw or "").strip()
if not text:
@@ -153,7 +176,9 @@ async def maybe_generate_webui_title(
model: str,
) -> bool:
"""Generate and persist a short title for WebUI-owned sessions only."""
session = sessions.get_or_create(session_key)
session = sessions.get_existing(session_key)
if session is None:
return False
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:
@@ -389,7 +414,7 @@ async def publish_turn_run_status(
@dataclass(frozen=True)
class WebuiTurnRoutePolicy:
"""Expose independently dispatched late subagent turns to WebUI sessions."""
"""Expose independently dispatched agent turns to WebUI sessions."""
sessions: SessionManager
@@ -399,22 +424,52 @@ class WebuiTurnRoutePolicy:
session_key: str,
route: TurnRoute,
) -> TurnRoute:
"""Make an independently dispatched late subagent result visible in WebUI."""
"""Make an independently dispatched agent turn visible in WebUI."""
routed = route
session_message = session_message_inbound(msg)
reply_timeout = session_reply_timeout_inbound(msg)
if (
msg.channel == "system"
and msg.sender_id == "subagent"
and msg.metadata.get("injected_event") == "subagent_result"
(
(
msg.channel == "system"
and msg.sender_id == "subagent"
and msg.metadata.get("injected_event") == "subagent_result"
)
or session_message is not None
or reply_timeout is not None
)
and route.channel == "websocket"
):
session = self.sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is True:
if session_message is not None or reply_timeout is not None:
persisted = self.sessions.read_session_metadata(session_key)
raw_session_metadata = (
persisted.get("metadata") if persisted is not None else None
)
session_metadata: Mapping[str, Any] = (
cast(Mapping[str, Any], raw_session_metadata)
if isinstance(raw_session_metadata, Mapping)
else {}
)
else:
session_metadata = self.sessions.get_or_create(session_key).metadata
if session_metadata.get(WEBUI_SESSION_METADATA_KEY) is True:
metadata = dict(route.metadata)
turn_prefix = "subagent"
if session_message is not None:
turn_prefix = "session-message"
elif reply_timeout is not None:
turn_prefix = "session-reply-timeout"
metadata.update({
WEBUI_SESSION_METADATA_KEY: True,
"_wants_stream": True,
WEBUI_TURN_METADATA_KEY: f"subagent:{uuid4().hex}",
WEBUI_TURN_METADATA_KEY: f"{turn_prefix}:{uuid4().hex}",
})
if session_message is not None:
metadata[SESSION_MESSAGE_METADATA_KEY] = session_message
metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = {
"kind": "session",
"label": f"@{session_message['source']['name']}",
}
routed = replace(route, metadata=metadata, publish_lifecycle=True)
if routed.channel == "websocket" and routed.publish_lifecycle:
@@ -446,6 +501,40 @@ class WebuiTurnRoutePolicy:
return routed
async def project_session_message_input(
bus: MessageBus,
msg: InboundMessage,
session_key: str,
) -> None:
"""Persist and publish an incoming session message for WebUI clients."""
envelope = session_message_inbound(msg)
if envelope is None or msg.channel != "websocket":
return
public_metadata = session_message_public_metadata(envelope)
try:
append_session_message_input(
session_key,
content=msg.content,
created_at_ms=envelope["created_at_ms"],
session_message=public_metadata,
)
except (OSError, TypeError, ValueError):
logger.warning(
"Failed to persist session input {}",
envelope["message_id"],
exc_info=True,
)
await bus.publish_outbound(outbound_message_for_event(
channel="websocket",
chat_id=str(msg.chat_id),
event=SessionMessageInputEvent(
content=msg.content,
created_at_ms=envelope["created_at_ms"],
session_message=public_metadata,
),
))
def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver:
"""Translate provider fallback choices into chat-scoped WebUI events."""
@@ -533,10 +622,17 @@ class WebuiTurnCoordinator:
def _is_websocket_event(ctx: RuntimeEventContext) -> bool:
return ctx.channel == "websocket"
def _handle_session_turn_started(self, event: SessionTurnStarted) -> None:
async def _handle_session_turn_started(self, event: SessionTurnStarted) -> None:
if not self._is_websocket_event(event.context):
return
session = self.sessions.get_or_create(event.context.session_key)
msg = self._ctx_msg(event.context)
session = _session_for_webui_lifecycle(
self.sessions,
msg,
event.context.session_key,
)
if session is None:
return
mark_webui_session(session, event.context.metadata)
async def _handle_run_status_changed(self, event: TurnRunStatusChanged) -> None:
@@ -630,7 +726,9 @@ class WebuiTurnCoordinator:
if msg.channel != "websocket":
return
session = self.sessions.get_or_create(session_key)
session = _session_for_webui_lifecycle(self.sessions, msg, session_key)
if session is None:
return
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,