fix(webui): complete temporary chat mode

This commit is contained in:
chengyongru
2026-08-08 23:20:59 +08:00
committed by Xubin Ren
parent c9a6145878
commit a5bc3bfbb9
51 changed files with 1285 additions and 945 deletions
+71 -74
View File
@@ -21,8 +21,10 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from nanobot.bus.events import (
INBOUND_META_TRANSIENT_SESSION,
INBOUND_META_RUNTIME_CONTROL,
OUTBOUND_META_AGENT_UI,
RUNTIME_CONTROL_SESSION_DISCARD,
InboundMessage,
OutboundMessage,
)
from nanobot.bus.outbound_events import (
@@ -37,11 +39,6 @@ from nanobot.bus.outbound_events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.websocket.temporary_chat import (
TEMPORARY_COMMANDS,
TemporaryChats,
has_temporary_chat_prefix,
)
from nanobot.command.builtin import builtin_command_starts_agent_turn
from nanobot.config.schema import Base
from nanobot.runtime_context import (
@@ -313,6 +310,13 @@ def _parse_inbound_payload(raw: str) -> str | None:
# Accept UUIDs and short scoped keys like "unified:default". Keeps the capability
# namespace small enough to rule out path traversal / quote injection tricks.
_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
_TEMPORARY_CHAT_PREFIX = "temporary-"
_TEMPORARY_CHAT_DISABLED_TOOLS = frozenset({
"create_goal",
"update_goal",
"spawn",
"cron",
})
def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
@@ -320,7 +324,7 @@ def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
def _is_temporary_chat_id(value: Any) -> TypeGuard[str]:
return _is_valid_chat_id(value) and has_temporary_chat_prefix(value)
return _is_valid_chat_id(value) and value.startswith(_TEMPORARY_CHAT_PREFIX)
def _parse_envelope(raw: str) -> dict[str, Any] | None:
@@ -397,9 +401,9 @@ class WebSocketChannel(BaseChannel):
if gateway.session_manager is not None
else None
)
self._temporary_chats = TemporaryChats(gateway.session_manager, self._media, bus)
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
self._temporary_media_paths: dict[str, set[str]] = {}
# -- Subscription bookkeeping -------------------------------------------
@@ -428,31 +432,38 @@ class WebSocketChannel(BaseChannel):
if key[0] == chat_id:
self._stream_text_buffers.pop(key, None)
def _claim_temporary_chat(
self,
connection: ServerConnection,
chat_id: str,
) -> str | None:
"""Create the connection's single in-memory chat on first use."""
if connection not in self._webui_connections:
return "temporary_chat_unavailable"
if detail := self._temporary_chats.claim(connection, chat_id):
return detail
self._attach(connection, chat_id)
return None
def _discard_temporary_media(self, chat_id: str) -> None:
"""Remove uploads owned by one connection-scoped temporary chat."""
for raw_path in self._temporary_media_paths.pop(chat_id, set()):
try:
Path(raw_path).unlink(missing_ok=True)
except OSError:
self.logger.warning("failed to remove a temporary WebUI attachment")
async def _discard_temporary_chat(
self,
connection: ServerConnection,
chat_id: str,
) -> str | None:
detail = await self._temporary_chats.discard(connection, chat_id)
if detail is not None:
return detail
) -> None:
session_key = f"{self.name}:{chat_id}"
self._detach(connection, chat_id)
clear_websocket_turns(chat_id)
self._clear_stream_buffers(chat_id)
return None
self._discard_temporary_media(chat_id)
if self.gateway.session_manager is not None:
self.gateway.session_manager.invalidate(session_key)
await self.bus.publish_inbound(
InboundMessage(
channel=self.name,
sender_id="webui",
chat_id=chat_id,
content="",
metadata={
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
},
session_key_override=session_key,
)
)
async def send_webui_protocol_error(
self,
@@ -484,15 +495,14 @@ class WebSocketChannel(BaseChannel):
async def _cleanup_connection(self, connection: ServerConnection) -> None:
"""Remove *connection* from every subscription set; safe to call multiple times."""
try:
temporary_chat_id = self._temporary_chats.chat_id_for(connection)
if temporary_chat_id is not None:
await self._discard_temporary_chat(connection, temporary_chat_id)
finally:
for chat_id in tuple(self._conn_chats.get(connection, ())):
self._detach(connection, chat_id)
self._conn_default.pop(connection, None)
self._webui_connections.discard(connection)
chat_ids = tuple(self._conn_chats.get(connection, ()))
for cid in chat_ids:
if _is_temporary_chat_id(cid):
await self._discard_temporary_chat(connection, cid)
else:
self._detach(connection, cid)
self._conn_default.pop(connection, None)
self._webui_connections.discard(connection)
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
@@ -813,22 +823,13 @@ class WebSocketChannel(BaseChannel):
if not _is_temporary_chat_id(cid):
await self._send_event(connection, "error", detail="invalid temporary chat_id")
return
if detail := await self._discard_temporary_chat(connection, cid):
await self._send_event(connection, "error", detail=detail, chat_id=cid)
await self._discard_temporary_chat(connection, cid)
return
if t == "attach":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
if _is_temporary_chat_id(cid):
await self._send_event(
connection,
"error",
detail="temporary_chat_cannot_attach",
chat_id=cid,
)
return
self._attach(connection, cid)
await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid)
@@ -862,14 +863,6 @@ class WebSocketChannel(BaseChannel):
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
if _is_temporary_chat_id(cid):
await self._send_event(
connection,
"error",
detail="temporary_chat_scope_is_per_message",
chat_id=cid,
)
return
scope = await self._workspace_scope_or_error(
connection,
lambda: self._workspaces.scope_for_set_request(
@@ -941,7 +934,7 @@ class WebSocketChannel(BaseChannel):
if temporary:
command = content.strip().partition(" ")[0].lower()
if command.startswith("/") and command not in TEMPORARY_COMMANDS:
if command.startswith("/") and command not in {"/model", "/stop"}:
await self._send_event(
connection,
"error",
@@ -949,14 +942,18 @@ class WebSocketChannel(BaseChannel):
**rejection_fields,
)
return
if detail := self._claim_temporary_chat(connection, cid):
if self.gateway.session_manager is None:
await self._send_event(
connection,
"error",
detail=detail,
detail="temporary_chat_unavailable",
**rejection_fields,
)
return
self.gateway.session_manager.get_or_create_transient(
f"{self.name}:{cid}",
disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS,
)
raw_media = envelope.get("media")
media_paths: list[str] = []
@@ -970,12 +967,7 @@ class WebSocketChannel(BaseChannel):
**rejection_fields,
)
return
store_attachments = (
self._media.store_temporary_attachments
if temporary
else self._media.store_inbound_attachments
)
media_paths, reason = store_attachments(cast(list[Any], raw_media))
media_paths, reason = self._media.store_inbound_attachments(cast(list[Any], raw_media))
if reason is not None:
await self._send_event(
connection,
@@ -985,8 +977,9 @@ class WebSocketChannel(BaseChannel):
**rejection_fields,
)
return
if temporary:
self._temporary_chats.remember_attachments(cid, media_paths)
if temporary and media_paths:
self._temporary_media_paths.setdefault(cid, set()).update(media_paths)
# Allow media-only turns (content may be empty when attachments are present).
if not content.strip() and not media_paths:
await self._send_event(
@@ -996,19 +989,23 @@ class WebSocketChannel(BaseChannel):
**rejection_fields,
)
return
# Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid)
if not temporary:
# Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid)
await self._hydrate_after_subscribe(cid)
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
scope = await self._workspace_scope_or_error(
connection,
lambda: self._workspaces.scope_for_message(
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
controls_available=self._workspace_controls_available(connection),
lambda: (
self._workspaces.restricted_default_scope()
if temporary
else self._workspaces.scope_for_message(
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
controls_available=self._workspace_controls_available(connection),
)
),
chat_id=cid,
turn_id=turn_id,
@@ -1029,8 +1026,6 @@ class WebSocketChannel(BaseChannel):
return
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
if temporary:
metadata[INBOUND_META_TRANSIENT_SESSION] = True
if envelope.get("webui") is True:
metadata["webui"] = True
metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id")))
@@ -1092,6 +1087,8 @@ class WebSocketChannel(BaseChannel):
media=media_paths or None,
metadata=metadata,
is_dm=False,
session_key=f"{self.name}:{cid}" if temporary else None,
require_existing_session=temporary,
)
accepted = True
finally:
@@ -1147,13 +1144,13 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None
for connection in tuple(self._conn_chats):
await self._cleanup_connection(connection)
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
self._webui_connections.clear()
self._tokens.clear()
for chat_id in tuple(self._temporary_media_paths):
self._discard_temporary_media(chat_id)
async def _safe_send_to(
self,