2026-06-01 14:16:38 +08:00
|
|
|
|
"""Session turn helpers for WebUI-capable WebSocket sessions."""
|
2026-05-16 01:14:11 +08:00
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-05-17 23:51:52 +08:00
|
|
|
|
import re
|
2026-05-16 01:14:11 +08:00
|
|
|
|
import time
|
2026-08-18 18:28:19 +08:00
|
|
|
|
from collections.abc import Awaitable, Callable
|
2026-07-28 11:07:58 +08:00
|
|
|
|
from dataclasses import dataclass, replace
|
2026-08-18 09:53:55 +08:00
|
|
|
|
from typing import Any, cast
|
2026-07-22 23:04:36 +08:00
|
|
|
|
from uuid import uuid4
|
2026-05-16 01:14:11 +08:00
|
|
|
|
|
2026-05-17 23:51:52 +08:00
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
2026-07-23 15:57:13 +08:00
|
|
|
|
from nanobot.agent.tools.context import current_request_context
|
2026-07-22 23:04:36 +08:00
|
|
|
|
from nanobot.agent.turn_delivery import TurnRoute
|
2026-06-01 14:16:38 +08:00
|
|
|
|
from nanobot.bus import progress as bus_progress
|
2026-06-30 00:03:07 +08:00
|
|
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
|
|
from nanobot.bus.outbound_events import (
|
|
|
|
|
|
GoalStateSyncEvent,
|
|
|
|
|
|
GoalStatusEvent,
|
|
|
|
|
|
RuntimeModelUpdatedEvent,
|
|
|
|
|
|
SessionUpdatedEvent,
|
|
|
|
|
|
TurnEndEvent,
|
2026-07-23 15:57:13 +08:00
|
|
|
|
TurnModelUpdatedEvent,
|
2026-08-18 18:28:19 +08:00
|
|
|
|
UserInputEvent,
|
2026-06-30 00:03:07 +08:00
|
|
|
|
outbound_message_for_event,
|
|
|
|
|
|
)
|
2026-05-16 01:14:11 +08:00
|
|
|
|
from nanobot.bus.queue import MessageBus
|
2026-06-01 14:16:38 +08:00
|
|
|
|
from nanobot.bus.runtime_events import (
|
|
|
|
|
|
GoalStateChanged,
|
|
|
|
|
|
RuntimeEventBus,
|
|
|
|
|
|
RuntimeEventContext,
|
|
|
|
|
|
RuntimeModelChanged,
|
|
|
|
|
|
SessionTurnStarted,
|
|
|
|
|
|
TurnCompleted,
|
|
|
|
|
|
TurnRunStatusChanged,
|
2026-08-16 02:50:30 +08:00
|
|
|
|
TurnRuntimeAdmitted,
|
2026-08-18 18:28:19 +08:00
|
|
|
|
UserInputAccepted,
|
2026-06-01 14:16:38 +08:00
|
|
|
|
)
|
2026-05-17 23:51:52 +08:00
|
|
|
|
from nanobot.providers.base import LLMProvider
|
2026-07-23 15:57:13 +08:00
|
|
|
|
from nanobot.providers.fallback_provider import FallbackModelObserver
|
2026-07-11 18:52:56 +08:00
|
|
|
|
from nanobot.runtime_context import public_history_message
|
2026-05-17 23:51:52 +08:00
|
|
|
|
from nanobot.session.goal_state import goal_state_ws_blob
|
2026-07-02 13:31:58 +08:00
|
|
|
|
from nanobot.session.history_visibility import is_hidden_history_message
|
2026-05-17 23:51:52 +08:00
|
|
|
|
from nanobot.session.manager import Session, SessionManager
|
2026-08-19 00:28:23 +08:00
|
|
|
|
from nanobot.session.session_handles import session_handle_for_name
|
2026-08-18 09:53:55 +08:00
|
|
|
|
from nanobot.session.session_messages import (
|
2026-08-18 18:28:19 +08:00
|
|
|
|
SessionMessageEnvelope,
|
|
|
|
|
|
session_message_envelope,
|
2026-08-18 09:53:55 +08:00
|
|
|
|
)
|
2026-05-30 23:45:26 +08:00
|
|
|
|
from nanobot.utils.helpers import strip_think, truncate_text
|
2026-05-18 00:35:12 +08:00
|
|
|
|
from nanobot.utils.llm_runtime import LLMRuntime
|
2026-07-28 11:40:01 +08:00
|
|
|
|
from nanobot.webui.metadata import (
|
|
|
|
|
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
|
|
|
|
|
WEBUI_TURN_METADATA_KEY,
|
|
|
|
|
|
)
|
2026-08-18 09:53:55 +08:00
|
|
|
|
from nanobot.webui.transcript import append_session_message_input
|
2026-05-17 23:51:52 +08:00
|
|
|
|
|
|
|
|
|
|
WEBUI_SESSION_METADATA_KEY = "webui"
|
|
|
|
|
|
WEBUI_TITLE_METADATA_KEY = "title"
|
|
|
|
|
|
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
|
|
|
|
|
|
TITLE_MAX_CHARS = 60
|
|
|
|
|
|
TITLE_GENERATION_MAX_TOKENS = 96
|
|
|
|
|
|
TITLE_GENERATION_REASONING_EFFORT = "none"
|
2026-05-16 01:14:11 +08:00
|
|
|
|
|
2026-07-28 11:40:01 +08:00
|
|
|
|
# Latest active turn projection per ``chat_id`` (websocket only). It survives browser refresh
|
|
|
|
|
|
# while the gateway process stays up and is implicitly dropped on restart.
|
2026-05-16 01:14:11 +08:00
|
|
|
|
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
|
2026-07-28 11:40:01 +08:00
|
|
|
|
_WEBSOCKET_TURN_IDS: dict[str, str] = {}
|
|
|
|
|
|
_WEBSOCKET_TURN_OWNERS: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class _WebsocketTurn:
|
|
|
|
|
|
started_at: float
|
|
|
|
|
|
turn_id: str | None
|
|
|
|
|
|
transcript_persistence_failed: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# All in-flight lifecycle owners per chat, in admission order. The three maps
|
|
|
|
|
|
# above remain the latest-owner projection consumed by the HTTP API.
|
|
|
|
|
|
_WEBSOCKET_ACTIVE_TURNS: dict[str, dict[str, _WebsocketTurn]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 18:28:19 +08:00
|
|
|
|
def _session_message_public_metadata(
|
|
|
|
|
|
envelope: SessionMessageEnvelope,
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-08-19 00:28:23 +08:00
|
|
|
|
source = session_handle_for_name(
|
|
|
|
|
|
envelope["source_session_key"],
|
|
|
|
|
|
envelope["source_handle"],
|
|
|
|
|
|
)
|
2026-08-18 18:28:19 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"message_id": envelope["message_id"],
|
|
|
|
|
|
"session": source.public_payload(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _validated_llm_runtime(value: object) -> LLMRuntime | None:
|
|
|
|
|
|
"""Keep runtime-event consumers defensive if an external publisher violates the contract."""
|
|
|
|
|
|
return value if isinstance(value, LLMRuntime) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 11:40:01 +08:00
|
|
|
|
def _sync_websocket_turn_projection(chat_id: str) -> None:
|
|
|
|
|
|
turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id)
|
|
|
|
|
|
if not turns:
|
|
|
|
|
|
_WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
|
|
|
|
|
|
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None)
|
|
|
|
|
|
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
|
|
|
|
|
_WEBSOCKET_TURN_OWNERS.pop(chat_id, None)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
owner = next(reversed(turns))
|
|
|
|
|
|
turn = turns[owner]
|
|
|
|
|
|
_WEBSOCKET_TURN_WALL_STARTED_AT[chat_id] = turn.started_at
|
|
|
|
|
|
_WEBSOCKET_TURN_OWNERS[chat_id] = owner
|
|
|
|
|
|
if turn.turn_id is None:
|
|
|
|
|
|
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
_WEBSOCKET_TURN_IDS[chat_id] = turn.turn_id
|
2026-05-17 23:51:52 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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("\"'`“”‘’")
|
2026-05-30 23:45:26 +08:00
|
|
|
|
text = strip_think(text)
|
2026-05-17 23:51:52 +08:00
|
|
|
|
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:
|
|
|
|
|
|
if message.get("_command") is True:
|
|
|
|
|
|
continue
|
2026-07-02 13:31:58 +08:00
|
|
|
|
if is_hidden_history_message(message):
|
2026-06-12 18:17:28 +08:00
|
|
|
|
continue
|
2026-07-11 18:52:56 +08:00
|
|
|
|
message = public_history_message(message)
|
2026-05-17 23:51:52 +08:00
|
|
|
|
role = message.get("role")
|
|
|
|
|
|
content = message.get("content")
|
|
|
|
|
|
if not isinstance(content, str) or not content.strip():
|
|
|
|
|
|
continue
|
2026-05-30 23:45:26 +08:00
|
|
|
|
content = strip_think(content)
|
|
|
|
|
|
if not content:
|
|
|
|
|
|
continue
|
2026-05-17 23:51:52 +08:00
|
|
|
|
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."""
|
2026-08-18 18:28:19 +08:00
|
|
|
|
session = sessions.get_or_create(session_key)
|
2026-05-17 23:51:52 +08:00
|
|
|
|
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():
|
2026-05-30 23:45:26 +08:00
|
|
|
|
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)
|
|
|
|
|
|
return False
|
|
|
|
|
|
session.metadata.pop(WEBUI_TITLE_METADATA_KEY, None)
|
2026-05-17 23:51:52 +08:00
|
|
|
|
|
|
|
|
|
|
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=TITLE_GENERATION_MAX_TOKENS,
|
|
|
|
|
|
temperature=0.2,
|
|
|
|
|
|
reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
|
|
|
|
|
|
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"):
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
"WebUI title generation returned no usable title for {} (finish_reason={})",
|
|
|
|
|
|
session_key,
|
|
|
|
|
|
response.finish_reason,
|
|
|
|
|
|
)
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-05-16 01:14:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
|
|
|
|
|
|
"""Return ``time.time()`` when the active user turn began, if still running."""
|
|
|
|
|
|
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 11:40:01 +08:00
|
|
|
|
def websocket_turn_id(chat_id: str) -> str | None:
|
|
|
|
|
|
"""Return the WebUI identity of the active turn, when one was provided."""
|
|
|
|
|
|
return _WEBSOCKET_TURN_IDS.get(chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_queued_websocket_turn_if_idle(
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
turn_id: str | None,
|
|
|
|
|
|
) -> str | None:
|
|
|
|
|
|
"""Track an accepted WebUI turn while it waits for AgentLoop admission."""
|
|
|
|
|
|
if websocket_turn_wall_started_at(chat_id) is not None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
owner = uuid4().hex
|
|
|
|
|
|
_WEBSOCKET_ACTIVE_TURNS.setdefault(chat_id, {})[owner] = _WebsocketTurn(
|
|
|
|
|
|
started_at=time.time(),
|
|
|
|
|
|
turn_id=turn_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
_sync_websocket_turn_projection(chat_id)
|
|
|
|
|
|
return owner
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def websocket_turn_owner_is_registered(
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
owner: str,
|
|
|
|
|
|
turn_id: str | None,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Return whether websocket ingress registered this owner for the turn."""
|
|
|
|
|
|
turn = _WEBSOCKET_ACTIVE_TURNS.get(chat_id, {}).get(owner)
|
|
|
|
|
|
return turn is not None and turn.turn_id == turn_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def websocket_turn_transcript_persistence_failed(
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
owner: str | None = None,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Return whether one active owner has an incomplete canonical transcript."""
|
|
|
|
|
|
turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id)
|
|
|
|
|
|
if not turns:
|
|
|
|
|
|
return False
|
|
|
|
|
|
selected_owner = owner or next(reversed(turns))
|
|
|
|
|
|
turn = turns.get(selected_owner)
|
|
|
|
|
|
return turn.transcript_persistence_failed if turn is not None else False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mark_websocket_turn_transcript_persistence_failed(
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
owner: str | None,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Keep a turn active when any canonical display event could not be written."""
|
|
|
|
|
|
if not owner:
|
|
|
|
|
|
return False
|
|
|
|
|
|
turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id)
|
|
|
|
|
|
if turns is None or owner not in turns:
|
|
|
|
|
|
return False
|
|
|
|
|
|
turns[owner] = replace(turns[owner], transcript_persistence_failed=True)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clear_websocket_turn_if_current(
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
owner: str | None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
preserve_persistence_failure: bool = False,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Clear one lifecycle owner without disturbing concurrent turns for the chat."""
|
|
|
|
|
|
if not owner:
|
|
|
|
|
|
return False
|
|
|
|
|
|
turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id)
|
|
|
|
|
|
if turns is not None:
|
|
|
|
|
|
if owner not in turns:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if preserve_persistence_failure and turns[owner].transcript_persistence_failed:
|
|
|
|
|
|
return False
|
|
|
|
|
|
turns.pop(owner)
|
|
|
|
|
|
_sync_websocket_turn_projection(chat_id)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# Compatibility for callers/tests that populated the legacy projection
|
|
|
|
|
|
# directly before the multi-owner registry existed.
|
|
|
|
|
|
if (
|
|
|
|
|
|
chat_id in _WEBSOCKET_TURN_WALL_STARTED_AT
|
|
|
|
|
|
and _WEBSOCKET_TURN_OWNERS.get(chat_id) == owner
|
|
|
|
|
|
):
|
|
|
|
|
|
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None)
|
|
|
|
|
|
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
|
|
|
|
|
_WEBSOCKET_TURN_OWNERS.pop(chat_id, None)
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 19:24:41 +08:00
|
|
|
|
def clear_websocket_turns(chat_id: str) -> None:
|
|
|
|
|
|
"""Forget every in-process turn projection for a discarded chat."""
|
|
|
|
|
|
_WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
|
|
|
|
|
|
_sync_websocket_turn_projection(chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 14:16:38 +08:00
|
|
|
|
def build_bus_progress_callback(
|
|
|
|
|
|
bus: MessageBus,
|
|
|
|
|
|
msg: InboundMessage,
|
|
|
|
|
|
) -> Callable[..., Awaitable[None]]:
|
|
|
|
|
|
"""Compatibility wrapper for the generic bus progress callback."""
|
|
|
|
|
|
return bus_progress.build_bus_progress_callback(bus, msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 00:56:39 +08:00
|
|
|
|
async def publish_turn_run_status(
|
|
|
|
|
|
bus: MessageBus,
|
|
|
|
|
|
msg: InboundMessage,
|
|
|
|
|
|
status: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
started_at: float | None = None,
|
|
|
|
|
|
) -> None:
|
2026-05-16 01:14:11 +08:00
|
|
|
|
"""Notify WebSocket clients while a user turn is executing (timing strip)."""
|
|
|
|
|
|
if msg.channel != "websocket":
|
|
|
|
|
|
return
|
|
|
|
|
|
cid = str(msg.chat_id)
|
2026-06-30 00:03:07 +08:00
|
|
|
|
started_at_event: float | None = None
|
2026-05-16 01:14:11 +08:00
|
|
|
|
if status == "running":
|
2026-06-01 00:56:39 +08:00
|
|
|
|
if isinstance(started_at, int | float) and started_at > 0:
|
|
|
|
|
|
t0 = float(started_at)
|
|
|
|
|
|
else:
|
|
|
|
|
|
t0 = time.time()
|
2026-06-30 00:03:07 +08:00
|
|
|
|
started_at_event = t0
|
2026-07-28 11:40:01 +08:00
|
|
|
|
owner = msg.metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
|
|
|
|
|
if not isinstance(owner, str) or not owner:
|
|
|
|
|
|
owner = uuid4().hex
|
|
|
|
|
|
msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = owner
|
|
|
|
|
|
turn_id = msg.metadata.get(WEBUI_TURN_METADATA_KEY)
|
|
|
|
|
|
current_turn_id = turn_id if isinstance(turn_id, str) and turn_id else None
|
|
|
|
|
|
turns = _WEBSOCKET_ACTIVE_TURNS.setdefault(cid, {})
|
|
|
|
|
|
# Re-registration makes this owner the latest projection.
|
|
|
|
|
|
turns.pop(owner, None)
|
|
|
|
|
|
turns[owner] = _WebsocketTurn(started_at=t0, turn_id=current_turn_id)
|
|
|
|
|
|
_sync_websocket_turn_projection(cid)
|
2026-05-16 01:14:11 +08:00
|
|
|
|
await bus.publish_outbound(
|
2026-06-30 00:03:07 +08:00
|
|
|
|
outbound_message_for_event(
|
2026-05-16 01:14:11 +08:00
|
|
|
|
channel=msg.channel,
|
|
|
|
|
|
chat_id=cid,
|
2026-06-30 00:03:07 +08:00
|
|
|
|
event=GoalStatusEvent(status=status, started_at=started_at_event),
|
|
|
|
|
|
metadata=msg.metadata,
|
2026-05-16 01:14:11 +08:00
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-05-17 23:51:52 +08:00
|
|
|
|
|
2026-07-22 23:04:36 +08:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class WebuiTurnRoutePolicy:
|
2026-08-18 09:53:55 +08:00
|
|
|
|
"""Expose independently dispatched agent turns to WebUI sessions."""
|
2026-07-22 23:04:36 +08:00
|
|
|
|
|
|
|
|
|
|
sessions: SessionManager
|
|
|
|
|
|
|
|
|
|
|
|
def __call__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
msg: InboundMessage,
|
|
|
|
|
|
session_key: str,
|
|
|
|
|
|
route: TurnRoute,
|
|
|
|
|
|
) -> TurnRoute:
|
2026-08-18 09:53:55 +08:00
|
|
|
|
"""Make an independently dispatched agent turn visible in WebUI."""
|
2026-07-28 11:40:01 +08:00
|
|
|
|
routed = route
|
2026-08-18 18:28:19 +08:00
|
|
|
|
internal_user_input = msg.channel == "system" and msg.is_user_input
|
2026-07-22 23:04:36 +08:00
|
|
|
|
if (
|
2026-08-18 09:53:55 +08:00
|
|
|
|
(
|
|
|
|
|
|
(
|
|
|
|
|
|
msg.channel == "system"
|
|
|
|
|
|
and msg.sender_id == "subagent"
|
|
|
|
|
|
and msg.metadata.get("injected_event") == "subagent_result"
|
|
|
|
|
|
)
|
2026-08-18 18:28:19 +08:00
|
|
|
|
or internal_user_input
|
2026-08-18 09:53:55 +08:00
|
|
|
|
)
|
2026-07-28 11:40:01 +08:00
|
|
|
|
and route.channel == "websocket"
|
2026-07-22 23:04:36 +08:00
|
|
|
|
):
|
2026-08-18 18:28:19 +08:00
|
|
|
|
session = self.sessions.get_or_create(session_key)
|
|
|
|
|
|
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is True:
|
2026-07-28 11:40:01 +08:00
|
|
|
|
metadata = dict(route.metadata)
|
2026-08-18 18:28:19 +08:00
|
|
|
|
turn_prefix = "session-input" if internal_user_input else "subagent"
|
2026-07-28 11:40:01 +08:00
|
|
|
|
metadata.update({
|
|
|
|
|
|
WEBUI_SESSION_METADATA_KEY: True,
|
|
|
|
|
|
"_wants_stream": True,
|
2026-08-18 09:53:55 +08:00
|
|
|
|
WEBUI_TURN_METADATA_KEY: f"{turn_prefix}:{uuid4().hex}",
|
2026-07-28 11:40:01 +08:00
|
|
|
|
})
|
|
|
|
|
|
routed = replace(route, metadata=metadata, publish_lifecycle=True)
|
2026-07-22 23:04:36 +08:00
|
|
|
|
|
2026-07-28 11:40:01 +08:00
|
|
|
|
if routed.channel == "websocket" and routed.publish_lifecycle:
|
|
|
|
|
|
metadata = dict(routed.metadata)
|
|
|
|
|
|
turn_id = metadata.get(WEBUI_TURN_METADATA_KEY)
|
|
|
|
|
|
current_turn_id = turn_id if isinstance(turn_id, str) and turn_id else None
|
|
|
|
|
|
queued_owner = metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
|
|
|
|
|
owner = (
|
|
|
|
|
|
queued_owner
|
|
|
|
|
|
if (
|
|
|
|
|
|
msg.channel == "websocket"
|
|
|
|
|
|
and isinstance(queued_owner, str)
|
|
|
|
|
|
and websocket_turn_owner_is_registered(
|
|
|
|
|
|
str(msg.chat_id),
|
|
|
|
|
|
queued_owner,
|
|
|
|
|
|
current_turn_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
else uuid4().hex
|
|
|
|
|
|
)
|
|
|
|
|
|
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = owner
|
|
|
|
|
|
routed = replace(routed, metadata=metadata)
|
|
|
|
|
|
# Direct websocket turns publish their final idle transition from
|
|
|
|
|
|
# the original input message. Carry the same server-owned identity
|
|
|
|
|
|
# there, overwriting any untrusted client-supplied value.
|
|
|
|
|
|
if msg.channel == "websocket":
|
|
|
|
|
|
msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = owner
|
2026-07-22 23:04:36 +08:00
|
|
|
|
|
2026-07-28 11:40:01 +08:00
|
|
|
|
return routed
|
2026-07-22 23:04:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:57:13 +08:00
|
|
|
|
def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver:
|
|
|
|
|
|
"""Translate provider fallback choices into chat-scoped WebUI events."""
|
|
|
|
|
|
|
|
|
|
|
|
async def _publish(model: str) -> None:
|
|
|
|
|
|
context = current_request_context()
|
|
|
|
|
|
if context is None or context.channel != "websocket":
|
|
|
|
|
|
return
|
|
|
|
|
|
chat_id = str(context.chat_id or "").strip()
|
|
|
|
|
|
if not chat_id:
|
|
|
|
|
|
return
|
|
|
|
|
|
await bus.publish_outbound(
|
|
|
|
|
|
outbound_message_for_event(
|
|
|
|
|
|
channel=context.channel,
|
|
|
|
|
|
chat_id=chat_id,
|
2026-08-16 02:50:30 +08:00
|
|
|
|
event=TurnModelUpdatedEvent(
|
|
|
|
|
|
model=model,
|
|
|
|
|
|
model_preset=(
|
|
|
|
|
|
context.runtime.model_preset
|
|
|
|
|
|
if context.runtime is not None
|
|
|
|
|
|
else None
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-07-23 15:57:13 +08:00
|
|
|
|
metadata=context.metadata,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return _publish
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 23:51:52 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class WebuiTurnCoordinator:
|
2026-06-01 14:16:38 +08:00
|
|
|
|
"""Translate generic runtime events into WebUI/WebSocket wire messages."""
|
2026-05-17 23:51:52 +08:00
|
|
|
|
|
|
|
|
|
|
bus: MessageBus
|
|
|
|
|
|
sessions: SessionManager
|
|
|
|
|
|
schedule_background: Callable[[Awaitable[None]], None]
|
|
|
|
|
|
|
2026-06-01 14:16:38 +08:00
|
|
|
|
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
|
|
|
|
|
|
"""Subscribe this coordinator to runtime events."""
|
2026-06-01 15:07:25 +08:00
|
|
|
|
unsubscribe = [
|
2026-08-18 18:28:19 +08:00
|
|
|
|
runtime_events.subscribe(
|
|
|
|
|
|
self._handle_user_input_accepted,
|
|
|
|
|
|
UserInputAccepted,
|
|
|
|
|
|
),
|
2026-06-01 15:07:25 +08:00
|
|
|
|
runtime_events.subscribe(
|
|
|
|
|
|
self._handle_session_turn_started,
|
|
|
|
|
|
SessionTurnStarted,
|
|
|
|
|
|
),
|
|
|
|
|
|
runtime_events.subscribe(
|
|
|
|
|
|
self._handle_run_status_changed,
|
|
|
|
|
|
TurnRunStatusChanged,
|
|
|
|
|
|
),
|
2026-08-16 02:50:30 +08:00
|
|
|
|
runtime_events.subscribe(
|
|
|
|
|
|
self._handle_turn_runtime_admitted,
|
|
|
|
|
|
TurnRuntimeAdmitted,
|
|
|
|
|
|
),
|
2026-06-01 15:07:25 +08:00
|
|
|
|
runtime_events.subscribe(
|
|
|
|
|
|
self._handle_turn_completed_event,
|
|
|
|
|
|
TurnCompleted,
|
|
|
|
|
|
),
|
|
|
|
|
|
runtime_events.subscribe(
|
|
|
|
|
|
self._handle_goal_state_changed,
|
|
|
|
|
|
GoalStateChanged,
|
|
|
|
|
|
),
|
|
|
|
|
|
runtime_events.subscribe(
|
|
|
|
|
|
self._handle_runtime_model_changed,
|
|
|
|
|
|
RuntimeModelChanged,
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
2026-06-01 14:16:38 +08:00
|
|
|
|
|
2026-06-01 15:07:25 +08:00
|
|
|
|
def _unsubscribe() -> None:
|
|
|
|
|
|
for fn in reversed(unsubscribe):
|
|
|
|
|
|
fn()
|
|
|
|
|
|
|
|
|
|
|
|
return _unsubscribe
|
2026-06-01 14:16:38 +08:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _ctx_msg(ctx: RuntimeEventContext) -> InboundMessage:
|
|
|
|
|
|
return InboundMessage(
|
|
|
|
|
|
channel=ctx.channel,
|
|
|
|
|
|
sender_id="runtime",
|
|
|
|
|
|
chat_id=ctx.chat_id,
|
|
|
|
|
|
content="",
|
|
|
|
|
|
metadata=dict(ctx.metadata or {}),
|
|
|
|
|
|
session_key_override=ctx.session_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _is_websocket_event(ctx: RuntimeEventContext) -> bool:
|
|
|
|
|
|
return ctx.channel == "websocket"
|
|
|
|
|
|
|
2026-08-18 18:28:19 +08:00
|
|
|
|
async def _handle_user_input_accepted(self, event: UserInputAccepted) -> None:
|
|
|
|
|
|
envelope = session_message_envelope(event.context.metadata)
|
|
|
|
|
|
session_key = event.context.session_key
|
|
|
|
|
|
if (
|
|
|
|
|
|
event.context.channel != "system"
|
|
|
|
|
|
or envelope is None
|
|
|
|
|
|
or envelope["target_session_key"] != session_key
|
|
|
|
|
|
or not session_key.startswith("websocket:")
|
|
|
|
|
|
):
|
|
|
|
|
|
return
|
|
|
|
|
|
persisted = self.sessions.read_session_metadata(session_key)
|
|
|
|
|
|
metadata_value: object = persisted.get("metadata") if persisted is not None else None
|
|
|
|
|
|
metadata = (
|
|
|
|
|
|
cast(dict[str, Any], metadata_value)
|
|
|
|
|
|
if isinstance(metadata_value, dict)
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
|
|
|
|
|
if metadata is None or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
|
|
|
|
|
return
|
|
|
|
|
|
public_metadata = _session_message_public_metadata(envelope)
|
|
|
|
|
|
try:
|
|
|
|
|
|
append_session_message_input(
|
|
|
|
|
|
session_key,
|
|
|
|
|
|
content=event.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 self.bus.publish_outbound(outbound_message_for_event(
|
|
|
|
|
|
channel="websocket",
|
|
|
|
|
|
chat_id=session_key.split(":", 1)[1],
|
|
|
|
|
|
event=UserInputEvent(
|
|
|
|
|
|
content=event.content,
|
|
|
|
|
|
created_at_ms=envelope["created_at_ms"],
|
|
|
|
|
|
provenance={"session_message": public_metadata},
|
|
|
|
|
|
),
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_session_turn_started(self, event: SessionTurnStarted) -> None:
|
2026-06-01 14:16:38 +08:00
|
|
|
|
if not self._is_websocket_event(event.context):
|
|
|
|
|
|
return
|
2026-08-18 18:28:19 +08:00
|
|
|
|
session = self.sessions.get_or_create(event.context.session_key)
|
2026-06-01 14:16:38 +08:00
|
|
|
|
mark_webui_session(session, event.context.metadata)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_run_status_changed(self, event: TurnRunStatusChanged) -> None:
|
|
|
|
|
|
if not self._is_websocket_event(event.context):
|
|
|
|
|
|
return
|
|
|
|
|
|
await publish_turn_run_status(
|
|
|
|
|
|
self.bus,
|
|
|
|
|
|
self._ctx_msg(event.context),
|
|
|
|
|
|
event.status,
|
|
|
|
|
|
started_at=event.started_at,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-16 02:50:30 +08:00
|
|
|
|
async def _handle_turn_runtime_admitted(self, event: TurnRuntimeAdmitted) -> None:
|
|
|
|
|
|
if not self._is_websocket_event(event.context):
|
|
|
|
|
|
return
|
|
|
|
|
|
await self.bus.publish_outbound(
|
|
|
|
|
|
outbound_message_for_event(
|
|
|
|
|
|
channel=event.context.channel,
|
|
|
|
|
|
chat_id=event.context.chat_id,
|
|
|
|
|
|
event=TurnModelUpdatedEvent(
|
|
|
|
|
|
model=event.runtime.model,
|
|
|
|
|
|
model_preset=event.runtime.model_preset,
|
2026-08-16 16:34:11 +08:00
|
|
|
|
context_window_tokens=event.runtime.context_window_tokens,
|
2026-08-16 02:50:30 +08:00
|
|
|
|
),
|
|
|
|
|
|
metadata=event.context.metadata,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-01 14:16:38 +08:00
|
|
|
|
async def _handle_turn_completed_event(self, event: TurnCompleted) -> None:
|
|
|
|
|
|
if not self._is_websocket_event(event.context):
|
|
|
|
|
|
return
|
|
|
|
|
|
msg = self._ctx_msg(event.context)
|
|
|
|
|
|
await self.handle_turn_end(
|
|
|
|
|
|
msg,
|
|
|
|
|
|
session_key=event.context.session_key,
|
|
|
|
|
|
latency_ms=event.latency_ms,
|
2026-08-16 16:34:11 +08:00
|
|
|
|
usage=event.usage,
|
|
|
|
|
|
context_window_tokens=(
|
|
|
|
|
|
event.runtime.context_window_tokens if event.runtime is not None else None
|
|
|
|
|
|
),
|
2026-06-01 14:16:38 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._schedule_title_update_from_event(event)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_goal_state_changed(self, event: GoalStateChanged) -> None:
|
|
|
|
|
|
if not self._is_websocket_event(event.context):
|
|
|
|
|
|
return
|
|
|
|
|
|
cid = str(event.context.chat_id or "").strip()
|
|
|
|
|
|
if not cid:
|
|
|
|
|
|
return
|
|
|
|
|
|
await self.bus.publish_outbound(
|
2026-06-30 00:03:07 +08:00
|
|
|
|
outbound_message_for_event(
|
2026-06-01 14:16:38 +08:00
|
|
|
|
channel=event.context.channel,
|
|
|
|
|
|
chat_id=cid,
|
2026-06-30 00:03:07 +08:00
|
|
|
|
event=GoalStateSyncEvent(
|
|
|
|
|
|
goal_state=goal_state_ws_blob(event.session_metadata),
|
|
|
|
|
|
),
|
|
|
|
|
|
metadata=event.context.metadata,
|
2026-06-01 14:16:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None:
|
|
|
|
|
|
await self.bus.publish_outbound(
|
2026-06-30 00:03:07 +08:00
|
|
|
|
outbound_message_for_event(
|
2026-06-01 14:16:38 +08:00
|
|
|
|
channel="websocket",
|
|
|
|
|
|
chat_id="*",
|
2026-06-30 00:03:07 +08:00
|
|
|
|
event=RuntimeModelUpdatedEvent(
|
|
|
|
|
|
model=event.model,
|
|
|
|
|
|
model_preset=event.model_preset,
|
|
|
|
|
|
),
|
2026-06-01 14:16:38 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-01 00:56:39 +08:00
|
|
|
|
async def publish_run_status(
|
|
|
|
|
|
self,
|
|
|
|
|
|
msg: InboundMessage,
|
|
|
|
|
|
status: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
started_at: float | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
await publish_turn_run_status(self.bus, msg, status, started_at=started_at)
|
2026-05-17 23:51:52 +08:00
|
|
|
|
|
|
|
|
|
|
async def handle_turn_end(
|
|
|
|
|
|
self,
|
|
|
|
|
|
msg: InboundMessage,
|
|
|
|
|
|
*,
|
|
|
|
|
|
session_key: str,
|
|
|
|
|
|
latency_ms: int | None,
|
2026-08-16 16:34:11 +08:00
|
|
|
|
usage: dict[str, int] | None = None,
|
|
|
|
|
|
context_window_tokens: int | None = None,
|
2026-05-17 23:51:52 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
if msg.channel != "websocket":
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-08-18 18:28:19 +08:00
|
|
|
|
session = self.sessions.get_or_create(session_key)
|
2026-06-30 00:03:07 +08:00
|
|
|
|
await self.bus.publish_outbound(
|
|
|
|
|
|
outbound_message_for_event(
|
|
|
|
|
|
channel=msg.channel,
|
|
|
|
|
|
chat_id=msg.chat_id,
|
|
|
|
|
|
event=TurnEndEvent(
|
|
|
|
|
|
latency_ms=latency_ms,
|
|
|
|
|
|
goal_state=goal_state_ws_blob(session.metadata),
|
2026-08-16 16:34:11 +08:00
|
|
|
|
usage=usage or None,
|
|
|
|
|
|
context_window_tokens=context_window_tokens,
|
2026-06-30 00:03:07 +08:00
|
|
|
|
),
|
|
|
|
|
|
metadata=msg.metadata,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-06-01 14:16:38 +08:00
|
|
|
|
|
|
|
|
|
|
def _schedule_title_update_from_event(self, event: TurnCompleted) -> None:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
title_context = _validated_llm_runtime(event.runtime)
|
2026-06-01 14:16:38 +08:00
|
|
|
|
if (
|
|
|
|
|
|
event.context.metadata.get("webui") is not True
|
|
|
|
|
|
or title_context is None
|
|
|
|
|
|
):
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
async def _generate_title_and_notify(
|
|
|
|
|
|
title_llm: LLMRuntime = title_context,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
generated = await maybe_generate_webui_title_after_turn(
|
|
|
|
|
|
channel=event.context.channel,
|
|
|
|
|
|
metadata=event.context.metadata,
|
|
|
|
|
|
sessions=self.sessions,
|
|
|
|
|
|
session_key=event.context.session_key,
|
|
|
|
|
|
provider=title_llm.provider,
|
|
|
|
|
|
model=title_llm.model,
|
|
|
|
|
|
)
|
|
|
|
|
|
if generated:
|
2026-06-30 00:03:07 +08:00
|
|
|
|
await self._publish_session_metadata_updated(
|
2026-06-01 14:16:38 +08:00
|
|
|
|
channel=event.context.channel,
|
|
|
|
|
|
chat_id=event.context.chat_id,
|
2026-06-30 00:03:07 +08:00
|
|
|
|
metadata=event.context.metadata,
|
|
|
|
|
|
)
|
2026-06-01 14:16:38 +08:00
|
|
|
|
|
|
|
|
|
|
self.schedule_background(_generate_title_and_notify())
|
2026-06-30 00:03:07 +08:00
|
|
|
|
|
|
|
|
|
|
async def _publish_session_metadata_updated(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
channel: str,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
metadata: dict[str, Any],
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
await self.bus.publish_outbound(
|
|
|
|
|
|
outbound_message_for_event(
|
|
|
|
|
|
channel=channel,
|
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
|
event=SessionUpdatedEvent(scope="metadata"),
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|