mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 09:58:34 +00:00
fix(webui): derive temporary chats from session policy
This commit is contained in:
parent
36253685bd
commit
f971d7e895
@ -21,10 +21,7 @@ from websockets.exceptions import ConnectionClosed
|
||||
from websockets.http11 import Request as WsRequest
|
||||
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
OUTBOUND_META_AGENT_UI,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
)
|
||||
from nanobot.bus.outbound_events import (
|
||||
@ -89,6 +86,7 @@ from nanobot.webui.session_access import (
|
||||
session_mentions_runtime_context,
|
||||
)
|
||||
from nanobot.webui.sidebar_state import write_webui_sidebar_state
|
||||
from nanobot.webui.temporary_chats import TemporaryChatError
|
||||
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
||||
@ -326,23 +324,12 @@ 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]:
|
||||
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
||||
|
||||
|
||||
def _is_temporary_chat_id(value: Any) -> TypeGuard[str]:
|
||||
return _is_valid_chat_id(value) and value.startswith(_TEMPORARY_CHAT_PREFIX)
|
||||
|
||||
|
||||
def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
||||
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
|
||||
|
||||
@ -412,6 +399,7 @@ class WebSocketChannel(BaseChannel):
|
||||
self._ingress = gateway.ingress
|
||||
self._transcripts = gateway.transcripts
|
||||
self._workspaces = gateway.workspaces
|
||||
self._temporary_chats = gateway.temporary_chats
|
||||
self._session_access = (
|
||||
WebuiSessionAccess(gateway.session_manager)
|
||||
if gateway.session_manager is not None
|
||||
@ -419,7 +407,6 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
|
||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
self._temporary_media_paths: dict[str, set[str]] = {}
|
||||
|
||||
# -- Subscription bookkeeping -------------------------------------------
|
||||
|
||||
@ -448,38 +435,15 @@ class WebSocketChannel(BaseChannel):
|
||||
if key[0] == chat_id:
|
||||
self._stream_text_buffers.pop(key, 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(
|
||||
async def _discard_connection_owned_chat(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
chat_id: str,
|
||||
) -> None:
|
||||
session_key = f"{self.name}:{chat_id}"
|
||||
await self._temporary_chats.discard(connection, chat_id)
|
||||
self._detach(connection, chat_id)
|
||||
clear_websocket_turns(chat_id)
|
||||
self._clear_stream_buffers(chat_id)
|
||||
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,
|
||||
@ -513,10 +477,12 @@ class WebSocketChannel(BaseChannel):
|
||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||
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)
|
||||
if self._temporary_chats.owns(connection, cid):
|
||||
await self._discard_connection_owned_chat(connection, cid)
|
||||
else:
|
||||
self._detach(connection, cid)
|
||||
for cid in self._temporary_chats.chat_ids_for_owner(connection):
|
||||
await self._discard_connection_owned_chat(connection, cid)
|
||||
self._conn_default.pop(connection, None)
|
||||
self._webui_connections.discard(connection)
|
||||
|
||||
@ -831,21 +797,46 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
await self._hydrate_after_subscribe(new_id)
|
||||
return
|
||||
if t == "new_temporary_chat":
|
||||
try:
|
||||
new_id = self._temporary_chats.create(
|
||||
connection,
|
||||
trusted_webui=connection in self._webui_connections,
|
||||
)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail)
|
||||
return
|
||||
self._attach(connection, new_id)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"attached",
|
||||
chat_id=new_id,
|
||||
temporary=True,
|
||||
)
|
||||
return
|
||||
if t == "fork_chat":
|
||||
await handle_webui_fork_chat(self, connection, envelope)
|
||||
return
|
||||
if t == "discard_temporary_chat":
|
||||
cid = envelope.get("chat_id")
|
||||
if not _is_temporary_chat_id(cid):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid temporary chat_id")
|
||||
return
|
||||
await self._discard_temporary_chat(connection, cid)
|
||||
try:
|
||||
await self._discard_connection_owned_chat(connection, cid)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=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
|
||||
try:
|
||||
self._temporary_chats.validate_attach(cid)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
||||
return
|
||||
self._attach(connection, cid)
|
||||
await self._send_event(connection, "attached", chat_id=cid)
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
@ -879,6 +870,11 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
try:
|
||||
self._temporary_chats.validate_workspace_update(cid)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
||||
return
|
||||
scope = await self._workspace_scope_or_error(
|
||||
connection,
|
||||
lambda: self._workspaces.scope_for_set_request(
|
||||
@ -910,7 +906,6 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
temporary = _is_temporary_chat_id(cid)
|
||||
raw_turn_id = envelope.get("turn_id")
|
||||
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
|
||||
rejection_fields = {
|
||||
@ -948,28 +943,20 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
return
|
||||
|
||||
if temporary:
|
||||
command = content.strip().partition(" ")[0].lower()
|
||||
if command.startswith("/") and command not in {"/model", "/stop"}:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="temporary_chat_command_rejected",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
if self.gateway.session_manager is None:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
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,
|
||||
try:
|
||||
temporary_policy = self._temporary_chats.message_policy(
|
||||
connection,
|
||||
cid,
|
||||
content,
|
||||
)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail=exc.detail,
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
|
||||
raw_media = envelope.get("media")
|
||||
media_paths: list[str] = []
|
||||
@ -993,8 +980,8 @@ class WebSocketChannel(BaseChannel):
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
if temporary and media_paths:
|
||||
self._temporary_media_paths.setdefault(cid, set()).update(media_paths)
|
||||
if temporary_policy is not None:
|
||||
self._temporary_chats.register_media(connection, cid, media_paths)
|
||||
|
||||
# Allow media-only turns (content may be empty when attachments are present).
|
||||
if not content.strip() and not media_paths:
|
||||
@ -1007,15 +994,15 @@ class WebSocketChannel(BaseChannel):
|
||||
return
|
||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||
self._attach(connection, cid)
|
||||
if not temporary:
|
||||
if temporary_policy is None or temporary_policy.hydrate_transcript:
|
||||
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.restricted_default_scope()
|
||||
if temporary
|
||||
temporary_policy.workspace_scope
|
||||
if temporary_policy is not None
|
||||
else self._workspaces.scope_for_message(
|
||||
envelope,
|
||||
chat_id=cid,
|
||||
@ -1074,7 +1061,13 @@ class WebSocketChannel(BaseChannel):
|
||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||
accepted = False
|
||||
try:
|
||||
if is_webui and not temporary:
|
||||
if (
|
||||
is_webui
|
||||
and (
|
||||
temporary_policy is None
|
||||
or temporary_policy.persist_transcript
|
||||
)
|
||||
):
|
||||
self._transcripts.append_user_message(
|
||||
cid,
|
||||
content,
|
||||
@ -1103,8 +1096,16 @@ 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,
|
||||
session_key=(
|
||||
temporary_policy.session_key
|
||||
if temporary_policy is not None
|
||||
else None
|
||||
),
|
||||
require_existing_session=(
|
||||
temporary_policy.require_existing_session
|
||||
if temporary_policy is not None
|
||||
else False
|
||||
),
|
||||
)
|
||||
accepted = True
|
||||
finally:
|
||||
@ -1165,8 +1166,7 @@ class WebSocketChannel(BaseChannel):
|
||||
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)
|
||||
self._temporary_chats.close()
|
||||
|
||||
async def _safe_send_to(
|
||||
self,
|
||||
@ -1196,7 +1196,7 @@ class WebSocketChannel(BaseChannel):
|
||||
transcript_overrides: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
||||
if _is_temporary_chat_id(chat_id):
|
||||
if not self._temporary_chats.should_persist_transcript(chat_id):
|
||||
return True
|
||||
persisted = self._transcripts.prepare_and_append(
|
||||
chat_id,
|
||||
|
||||
@ -196,6 +196,23 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
async def _new_temporary_chat(
|
||||
channel: WebSocketChannel,
|
||||
connection: AsyncMock,
|
||||
) -> str:
|
||||
channel._webui_connections.add(connection)
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{"type": "new_temporary_chat"},
|
||||
)
|
||||
payload = json.loads(connection.send.await_args.args[0])
|
||||
assert payload["event"] == "attached"
|
||||
assert payload["temporary"] is True
|
||||
connection.send.reset_mock()
|
||||
return payload["chat_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
@ -212,8 +229,7 @@ async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
|
||||
)
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = ("127.0.0.1", 5000)
|
||||
channel._webui_connections.add(connection)
|
||||
chat_id = "temporary-test"
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
upload = tmp_path / "temporary-upload.txt"
|
||||
upload.write_text("private attachment", encoding="utf-8")
|
||||
channel.gateway.media.store_inbound_attachments = MagicMock(
|
||||
@ -286,16 +302,17 @@ async def test_temporary_chat_rejects_persistent_commands(bus, tmp_path, content
|
||||
)
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = ("127.0.0.1", 5000)
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
|
||||
await channel._dispatch_envelope(connection, "webui-client", {
|
||||
"type": "message",
|
||||
"chat_id": "temporary-command",
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"webui": True,
|
||||
})
|
||||
|
||||
assert bus.publish_inbound.await_count == 0
|
||||
assert sessions.get_cached("websocket:temporary-command") is None
|
||||
assert sessions.get_cached(f"websocket:{chat_id}") is not None
|
||||
assert json.loads(connection.send.await_args.args[0])["detail"] == (
|
||||
"temporary_chat_command_rejected"
|
||||
)
|
||||
@ -314,6 +331,122 @@ async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "hello",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
await channel._cleanup_connection(connection)
|
||||
|
||||
session_key = f"websocket:{chat_id}"
|
||||
control = bus.publish_inbound.await_args_list[-1].args[0]
|
||||
assert control.session_key == session_key
|
||||
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
|
||||
RUNTIME_CONTROL_SESSION_DISCARD
|
||||
)
|
||||
assert sessions.get_cached(session_key) is None
|
||||
assert chat_id not in channel._subs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_creation_requires_authenticated_webui_connection(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"generic-websocket-client",
|
||||
{"type": "new_temporary_chat"},
|
||||
)
|
||||
|
||||
assert json.loads(connection.send.await_args.args[0])["detail"] == "access_denied"
|
||||
assert sessions.list_sessions() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_cannot_be_claimed_by_another_connection(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
owner = AsyncMock()
|
||||
other = AsyncMock()
|
||||
channel._webui_connections.add(other)
|
||||
chat_id = await _new_temporary_chat(channel, owner)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
other,
|
||||
"other-webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "claim it",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert json.loads(other.send.await_args.args[0])["detail"] == (
|
||||
"temporary_chat_unavailable"
|
||||
)
|
||||
assert bus.publish_inbound.await_count == 0
|
||||
assert sessions.get_cached(f"websocket:{chat_id}") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_cannot_persist_workspace_scope(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": chat_id,
|
||||
"workspace_scope": {
|
||||
"project_path": str(tmp_path),
|
||||
"access_mode": "full",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
payload = json.loads(connection.send.await_args.args[0])
|
||||
assert payload["detail"] == "temporary_chat_workspace_rejected"
|
||||
session = sessions.get_cached(f"websocket:{chat_id}")
|
||||
assert session is not None
|
||||
assert WORKSPACE_SCOPE_METADATA_KEY not in session.metadata
|
||||
assert sessions.list_sessions() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_looking_id_does_not_define_session_policy(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
channel._webui_connections.add(connection)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
@ -321,21 +454,42 @@ async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "temporary-disconnect",
|
||||
"content": "hello",
|
||||
"chat_id": "temporary-looking-but-persistent",
|
||||
"content": "/goal ordinary chat",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
await channel._cleanup_connection(connection)
|
||||
|
||||
session_key = "websocket:temporary-disconnect"
|
||||
control = bus.publish_inbound.await_args_list[-1].args[0]
|
||||
assert control.session_key == session_key
|
||||
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
|
||||
RUNTIME_CONTROL_SESSION_DISCARD
|
||||
inbound = bus.publish_inbound.await_args.args[0]
|
||||
assert inbound.require_existing_session is False
|
||||
assert inbound.session_key_override is None
|
||||
session = sessions.get_cached("websocket:temporary-looking-but-persistent")
|
||||
assert session is not None
|
||||
assert session.policy.persist is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_temporary_chat_does_not_detach_persistent_chat(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
assert sessions.get_cached(session_key) is None
|
||||
assert "temporary-disconnect" not in channel._subs
|
||||
connection = AsyncMock()
|
||||
channel._attach(connection, "ordinary-chat")
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{"type": "discard_temporary_chat", "chat_id": "ordinary-chat"},
|
||||
)
|
||||
|
||||
assert json.loads(connection.send.await_args.args[0])["detail"] == (
|
||||
"temporary_chat_unavailable"
|
||||
)
|
||||
assert connection in channel._subs["ordinary-chat"]
|
||||
assert "ordinary-chat" in channel._conn_chats[connection]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -11,6 +11,7 @@ from loguru import logger as default_logger
|
||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||
@ -33,6 +34,7 @@ class GatewayServices:
|
||||
ingress: WebUIIngressPolicy
|
||||
transcripts: WebUITranscriptRecorder
|
||||
workspaces: WebUIWorkspaceController
|
||||
temporary_chats: WebUITemporaryChats
|
||||
session_manager: SessionManager | None
|
||||
cron_service: CronService | None
|
||||
local_trigger_store: LocalTriggerStore | None
|
||||
@ -82,6 +84,12 @@ def build_gateway_services(
|
||||
default_workspace=workspace_path,
|
||||
default_restrict_to_workspace=default_restrict_to_workspace,
|
||||
)
|
||||
temporary_chats = WebUITemporaryChats(
|
||||
bus=bus,
|
||||
session_manager=session_manager,
|
||||
workspaces=workspaces,
|
||||
logger=logger,
|
||||
)
|
||||
http = GatewayHTTPHandler(
|
||||
config=config,
|
||||
session_manager=session_manager,
|
||||
@ -112,6 +120,7 @@ def build_gateway_services(
|
||||
ingress=ingress,
|
||||
transcripts=transcripts,
|
||||
workspaces=workspaces,
|
||||
temporary_chats=temporary_chats,
|
||||
session_manager=session_manager,
|
||||
cron_service=cron_service,
|
||||
local_trigger_store=local_trigger_store,
|
||||
|
||||
218
nanobot/webui/temporary_chats.py
Normal file
218
nanobot/webui/temporary_chats.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""Connection-owned Temporary Chat behavior for the WebUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
InboundMessage,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.security.workspace_access import WorkspaceScope
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
|
||||
_TEMPORARY_CHAT_DISABLED_TOOLS = frozenset({
|
||||
"create_goal",
|
||||
"update_goal",
|
||||
"spawn",
|
||||
"cron",
|
||||
})
|
||||
_TEMPORARY_CHAT_COMMANDS = frozenset({"/model", "/stop"})
|
||||
|
||||
|
||||
class TemporaryChatError(ValueError):
|
||||
"""A stable WebUI protocol error for a Temporary Chat operation."""
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemporaryChatMessagePolicy:
|
||||
"""Server-owned message rules for one active Temporary Chat."""
|
||||
|
||||
session_key: str
|
||||
workspace_scope: WorkspaceScope
|
||||
require_existing_session: bool = True
|
||||
hydrate_transcript: bool = False
|
||||
persist_transcript: bool = False
|
||||
|
||||
|
||||
class WebUITemporaryChats:
|
||||
"""Own Temporary Chat creation, policy, attachments, and disposal."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bus: MessageBus,
|
||||
session_manager: SessionManager | None,
|
||||
workspaces: WebUIWorkspaceController,
|
||||
logger: Any,
|
||||
channel_name: str = "websocket",
|
||||
) -> None:
|
||||
self._bus = bus
|
||||
self._sessions = session_manager
|
||||
self._workspaces = workspaces
|
||||
self._logger = logger
|
||||
self._channel_name = channel_name
|
||||
self._owners: dict[str, object] = {}
|
||||
self._owner_chat_ids: dict[object, set[str]] = {}
|
||||
# Keep active sessions alive if the bounded manager cache evicts them
|
||||
# between WebUI turns. SessionPolicy remains the authority below.
|
||||
self._active_sessions: dict[str, Session] = {}
|
||||
# Retain policy-derived tombstones until shutdown so late outbound
|
||||
# events cannot create a durable transcript after a chat is discarded.
|
||||
self._known_transient_chat_ids: set[str] = set()
|
||||
self._media_paths: dict[str, set[str]] = {}
|
||||
|
||||
def _session_key(self, chat_id: str) -> str:
|
||||
return f"{self._channel_name}:{chat_id}"
|
||||
|
||||
def _cached_session_is_transient(self, chat_id: str) -> bool:
|
||||
if self._sessions is None:
|
||||
return False
|
||||
session = self._sessions.get_cached(self._session_key(chat_id))
|
||||
return session is not None and not session.policy.persist
|
||||
|
||||
def create(self, owner: object, *, trusted_webui: bool) -> str:
|
||||
"""Create a server-identified chat owned by one authenticated WebUI connection."""
|
||||
if not trusted_webui:
|
||||
raise TemporaryChatError("access_denied")
|
||||
if self._sessions is None:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
chat_id = str(uuid.uuid4())
|
||||
session = self._sessions.get_or_create_transient(
|
||||
self._session_key(chat_id),
|
||||
disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS,
|
||||
)
|
||||
if session.policy.persist:
|
||||
raise RuntimeError("Temporary Chat must use a non-persistent session policy")
|
||||
self._owners[chat_id] = owner
|
||||
self._owner_chat_ids.setdefault(owner, set()).add(chat_id)
|
||||
self._active_sessions[chat_id] = session
|
||||
self._known_transient_chat_ids.add(chat_id)
|
||||
return chat_id
|
||||
|
||||
def message_policy(
|
||||
self,
|
||||
owner: object,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
) -> TemporaryChatMessagePolicy | None:
|
||||
"""Return Temporary Chat rules, or ``None`` for an ordinary chat."""
|
||||
if not self._cached_session_is_transient(chat_id):
|
||||
if chat_id in self._known_transient_chat_ids:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
return None
|
||||
if self._owners.get(chat_id) is not owner or self._sessions is None:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
session = self._sessions.get_cached(self._session_key(chat_id))
|
||||
if session is None:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
command = content.strip().split(maxsplit=1)[0].lower() if content.strip() else ""
|
||||
if command.startswith("/") and command not in _TEMPORARY_CHAT_COMMANDS:
|
||||
raise TemporaryChatError("temporary_chat_command_rejected")
|
||||
|
||||
return TemporaryChatMessagePolicy(
|
||||
session_key=self._session_key(chat_id),
|
||||
workspace_scope=self._workspaces.restricted_default_scope(),
|
||||
)
|
||||
|
||||
def validate_attach(self, chat_id: str) -> None:
|
||||
"""Reject attempts to recover a non-persistent session."""
|
||||
if not self._cached_session_is_transient(chat_id):
|
||||
if chat_id in self._known_transient_chat_ids:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
return
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
def validate_workspace_update(self, chat_id: str) -> None:
|
||||
"""Prevent non-persistent sessions from acquiring durable workspace state."""
|
||||
if self._cached_session_is_transient(chat_id):
|
||||
raise TemporaryChatError("temporary_chat_workspace_rejected")
|
||||
if chat_id in self._known_transient_chat_ids:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
def register_media(self, owner: object, chat_id: str, paths: list[str]) -> None:
|
||||
if not paths:
|
||||
return
|
||||
if self._owners.get(chat_id) is not owner:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
self._media_paths.setdefault(chat_id, set()).update(paths)
|
||||
|
||||
def chat_ids_for_owner(self, owner: object) -> tuple[str, ...]:
|
||||
return tuple(self._owner_chat_ids.get(owner, ()))
|
||||
|
||||
def owns(self, owner: object, chat_id: str) -> bool:
|
||||
return self._owners.get(chat_id) is owner
|
||||
|
||||
def should_persist_transcript(self, chat_id: str) -> bool:
|
||||
"""Apply the session policy and retain it for late events after disposal."""
|
||||
return (
|
||||
not self._cached_session_is_transient(chat_id)
|
||||
and chat_id not in self._known_transient_chat_ids
|
||||
)
|
||||
|
||||
def _discard_media(self, chat_id: str) -> None:
|
||||
for raw_path in self._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")
|
||||
|
||||
def _forget_owner(self, owner: object, chat_id: str) -> None:
|
||||
self._owners.pop(chat_id, None)
|
||||
chat_ids = self._owner_chat_ids.get(owner)
|
||||
if chat_ids is None:
|
||||
return
|
||||
chat_ids.discard(chat_id)
|
||||
if not chat_ids:
|
||||
self._owner_chat_ids.pop(owner, None)
|
||||
|
||||
async def discard(self, owner: object, chat_id: str) -> None:
|
||||
"""Forget one owned chat and cancel any active work through the message bus."""
|
||||
if (
|
||||
not self._cached_session_is_transient(chat_id)
|
||||
or self._owners.get(chat_id) is not owner
|
||||
):
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
session_key = self._session_key(chat_id)
|
||||
self._forget_owner(owner, chat_id)
|
||||
self._active_sessions.pop(chat_id, None)
|
||||
self._discard_media(chat_id)
|
||||
if self._sessions is not None:
|
||||
self._sessions.invalidate(session_key)
|
||||
await self._bus.publish_inbound(
|
||||
InboundMessage(
|
||||
channel=self._channel_name,
|
||||
sender_id="webui",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata={
|
||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
},
|
||||
session_key_override=session_key,
|
||||
)
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release process-local resources during gateway shutdown."""
|
||||
for chat_id in tuple(self._owners):
|
||||
self._discard_media(chat_id)
|
||||
if self._sessions is not None:
|
||||
self._sessions.invalidate(self._session_key(chat_id))
|
||||
self._owners.clear()
|
||||
self._owner_chat_ids.clear()
|
||||
self._active_sessions.clear()
|
||||
self._known_transient_chat_ids.clear()
|
||||
@ -64,8 +64,6 @@ import {
|
||||
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
|
||||
import {
|
||||
createTemporaryChatSession,
|
||||
isTemporaryChatId,
|
||||
temporaryChatIdFromSessionKey,
|
||||
} from "@/lib/temporary-chat";
|
||||
|
||||
type BootState =
|
||||
@ -101,6 +99,7 @@ type ShellRoute = {
|
||||
view: ShellView;
|
||||
activeKey: string | null;
|
||||
settingsSection: SettingsSectionKey;
|
||||
temporary?: boolean;
|
||||
};
|
||||
const loadSettingsView = () => import("@/components/settings/SettingsView");
|
||||
const SettingsView = lazy(async () => {
|
||||
@ -235,11 +234,12 @@ function readShellRoute(): ShellRoute {
|
||||
const encoded = path.slice("/temporary/".length);
|
||||
try {
|
||||
const chatId = decodeURIComponent(encoded).trim();
|
||||
return isTemporaryChatId(chatId)
|
||||
return chatId
|
||||
? {
|
||||
view: "chat",
|
||||
activeKey: `websocket:${chatId}`,
|
||||
settingsSection: "overview",
|
||||
temporary: true,
|
||||
}
|
||||
: defaultShellRoute();
|
||||
} catch {
|
||||
@ -262,8 +262,10 @@ function readShellRoute(): ShellRoute {
|
||||
|
||||
function shellRouteHash(route: ShellRoute): string {
|
||||
if (route.view === "chat") {
|
||||
const temporaryChatId = temporaryChatIdFromSessionKey(route.activeKey);
|
||||
if (temporaryChatId) return `#/temporary/${encodeURIComponent(temporaryChatId)}`;
|
||||
if (route.temporary && route.activeKey?.startsWith("websocket:")) {
|
||||
const chatId = route.activeKey.slice("websocket:".length);
|
||||
return `#/temporary/${encodeURIComponent(chatId)}`;
|
||||
}
|
||||
return route.activeKey
|
||||
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
||||
: "#/new";
|
||||
@ -1034,7 +1036,8 @@ function Shell({
|
||||
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
||||
const showHostChrome = effectiveRuntimeSurface === "native";
|
||||
const showMainSidebar = view !== "settings";
|
||||
const temporaryChatId = temporaryChatIdFromSessionKey(activeKey);
|
||||
const activeTemporarySession = activeKey ? temporarySessions[activeKey] ?? null : null;
|
||||
const temporaryChatId = activeTemporarySession?.chatId ?? null;
|
||||
const temporaryChatActive = view === "chat" && temporaryChatId !== null;
|
||||
const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled;
|
||||
const temporarySessionList = useMemo(
|
||||
@ -1173,9 +1176,7 @@ function Shell({
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
if (temporaryChatIdFromSessionKey(activeKey)) {
|
||||
return temporarySessions[activeKey] ?? null;
|
||||
}
|
||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey, temporarySessions]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
@ -1249,7 +1250,8 @@ function Shell({
|
||||
pendingCreatedSessionKeyRef.current = null;
|
||||
}
|
||||
if (!activeKey) return;
|
||||
if (temporaryChatIdFromSessionKey(activeKey)) {
|
||||
const currentRoute = readShellRoute();
|
||||
if (currentRoute.temporary) {
|
||||
if (temporarySessions[activeKey]) return;
|
||||
navigate(defaultShellRoute(), { replace: true });
|
||||
return;
|
||||
@ -1258,7 +1260,6 @@ function Shell({
|
||||
// WebKit can commit the route before useSessions' optimistic insert.
|
||||
// Keep that just-created destination valid until the session list catches up.
|
||||
if (pendingCreatedKey === activeKey) return;
|
||||
const currentRoute = readShellRoute();
|
||||
navigate(
|
||||
currentRoute.view === "chat"
|
||||
? defaultShellRoute()
|
||||
@ -1478,31 +1479,38 @@ function Shell({
|
||||
workspaceScope?: WorkspaceScopePayload | null,
|
||||
initialMessage?: string,
|
||||
) => {
|
||||
const session = createTemporaryChatSession();
|
||||
const restrictedScope = workspaceScope
|
||||
? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted"))
|
||||
: null;
|
||||
const nextSession: ChatSummary = {
|
||||
...session,
|
||||
preview: initialMessage ?? "",
|
||||
...(restrictedScope ? { workspaceScope: restrictedScope } : {}),
|
||||
};
|
||||
setTemporarySessions((current) => ({
|
||||
...current,
|
||||
[nextSession.key]: nextSession,
|
||||
}));
|
||||
setTemporaryChatEnabled(false);
|
||||
setWorkspaceError(null);
|
||||
setSessionSearchOpen(false);
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: nextSession.key,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
setMobileSidebarOpen(false);
|
||||
return nextSession.chatId;
|
||||
try {
|
||||
const chatId = await client.newTemporaryChat();
|
||||
const session = createTemporaryChatSession(chatId);
|
||||
const restrictedScope = workspaceScope
|
||||
? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted"))
|
||||
: null;
|
||||
const nextSession: ChatSummary = {
|
||||
...session,
|
||||
preview: initialMessage ?? "",
|
||||
...(restrictedScope ? { workspaceScope: restrictedScope } : {}),
|
||||
};
|
||||
setTemporarySessions((current) => ({
|
||||
...current,
|
||||
[nextSession.key]: nextSession,
|
||||
}));
|
||||
setTemporaryChatEnabled(false);
|
||||
setWorkspaceError(null);
|
||||
setSessionSearchOpen(false);
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: nextSession.key,
|
||||
settingsSection: "overview",
|
||||
temporary: true,
|
||||
});
|
||||
setMobileSidebarOpen(false);
|
||||
return nextSession.chatId;
|
||||
} catch (error) {
|
||||
console.error("Failed to create temporary chat", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
[client, navigate],
|
||||
);
|
||||
|
||||
const onForkChat = useCallback(async (
|
||||
@ -1572,7 +1580,8 @@ function Shell({
|
||||
|
||||
const onSelectChat = useCallback(
|
||||
(key: string) => {
|
||||
const selected = temporarySessionsRef.current[key]
|
||||
const selectedTemporary = temporarySessionsRef.current[key];
|
||||
const selected = selectedTemporary
|
||||
?? sessions.find((session) => session.key === key);
|
||||
const selectedChatId = selected?.chatId;
|
||||
if (selectedChatId) {
|
||||
@ -1589,7 +1598,12 @@ function Shell({
|
||||
setDraftWorkspaceScope(null);
|
||||
}
|
||||
setWorkspaceError(null);
|
||||
navigate({ view: "chat", activeKey: key, settingsSection: "overview" });
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: key,
|
||||
settingsSection: "overview",
|
||||
...(selectedTemporary ? { temporary: true } : {}),
|
||||
});
|
||||
setMobileSidebarOpen(false);
|
||||
},
|
||||
[navigate, sessions],
|
||||
@ -1610,6 +1624,7 @@ function Shell({
|
||||
view: "chat",
|
||||
activeKey: remaining[0]?.key ?? null,
|
||||
settingsSection: "overview",
|
||||
...(remaining[0] ? { temporary: true } : {}),
|
||||
}, { replace: true });
|
||||
}
|
||||
setMobileSidebarOpen(false);
|
||||
@ -1897,7 +1912,11 @@ function Shell({
|
||||
nextRunning.delete(chatId);
|
||||
runningChatIdsRef.current = nextRunning;
|
||||
setRunningChatIds(nextRunning);
|
||||
if (isTemporaryChatId(chatId)) return;
|
||||
if (
|
||||
Object.values(temporarySessionsRef.current).some(
|
||||
(session) => session.chatId === chatId,
|
||||
)
|
||||
) return;
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (activeChatIdRef.current === chatId) {
|
||||
@ -1922,7 +1941,7 @@ function Shell({
|
||||
if (Object.keys(temporarySessionsRef.current).length === 0) return;
|
||||
temporarySessionsRef.current = {};
|
||||
setTemporarySessions({});
|
||||
if (temporaryChatIdFromSessionKey(readShellRoute().activeKey)) {
|
||||
if (readShellRoute().temporary) {
|
||||
navigate(defaultShellRoute(), { replace: true });
|
||||
}
|
||||
});
|
||||
|
||||
@ -85,7 +85,6 @@ import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||
import { isTemporaryChatId } from "@/lib/temporary-chat";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
ChatSummary,
|
||||
@ -444,9 +443,7 @@ function storeSlashRecents(commands: string[]): void {
|
||||
|
||||
function queuedPromptsStorageKey(key?: string | null): string | null {
|
||||
const clean = key?.trim();
|
||||
return clean && !isTemporaryChatId(clean)
|
||||
? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}`
|
||||
: null;
|
||||
return clean ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` : null;
|
||||
}
|
||||
|
||||
function normalizeQueuedSessionMentions(value: unknown): SessionMention[] {
|
||||
|
||||
@ -33,7 +33,6 @@ import {
|
||||
} from "@/lib/mcp-preset-events";
|
||||
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import { isTemporaryChatId } from "@/lib/temporary-chat";
|
||||
import type {
|
||||
ChatSummary,
|
||||
SettingsPayload,
|
||||
@ -677,6 +676,7 @@ export function ThreadShell({
|
||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
const knownTemporaryChatIdsRef = useRef(new Set<string>());
|
||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||
@ -753,10 +753,12 @@ export function ThreadShell({
|
||||
|
||||
useEffect(() => {
|
||||
const retained = new Set(temporaryChatIds);
|
||||
for (const cachedChatId of messageCacheRef.current.keys()) {
|
||||
if (isTemporaryChatId(cachedChatId) && !retained.has(cachedChatId)) {
|
||||
for (const chatId of retained) knownTemporaryChatIdsRef.current.add(chatId);
|
||||
for (const cachedChatId of knownTemporaryChatIdsRef.current) {
|
||||
if (!retained.has(cachedChatId)) {
|
||||
messageCacheRef.current.delete(cachedChatId);
|
||||
activeViewportTurnByChatIdRef.current.delete(cachedChatId);
|
||||
knownTemporaryChatIdsRef.current.delete(cachedChatId);
|
||||
}
|
||||
}
|
||||
}, [temporaryChatIds]);
|
||||
@ -1435,7 +1437,7 @@ export function ThreadShell({
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
workspaceError={workspaceError}
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
pendingQueueKey={chatId}
|
||||
pendingQueueKey={temporary ? null : chatId}
|
||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||
ingressLimits={ingressLimits}
|
||||
quotedContext={quotedContext}
|
||||
|
||||
@ -11,7 +11,6 @@ import type {
|
||||
WorkspaceScopePayload,
|
||||
} from "./types";
|
||||
import { createHostWebSocket } from "./runtime";
|
||||
import { isTemporaryChatId } from "./temporary-chat";
|
||||
|
||||
/** WebSocket readyState constants, referenced by value to stay portable
|
||||
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
|
||||
@ -109,6 +108,10 @@ interface PendingRequest<T> {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface PendingChatRequest extends PendingRequest<string> {
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
||||
const TURN_REJECTION_DETAILS = new Set([
|
||||
"access_denied",
|
||||
@ -197,7 +200,7 @@ export class NanobotClient {
|
||||
private static readonly COMPLETED_TURN_FENCE_MAX = 256;
|
||||
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
||||
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
private pendingNewChat: PendingRequest<string> | null = null;
|
||||
private pendingNewChat: PendingChatRequest | null = null;
|
||||
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
||||
// Frames queued while the socket is not yet OPEN
|
||||
@ -743,7 +746,7 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
discardTemporaryChat(chatId: string): void {
|
||||
if (!isTemporaryChatId(chatId)) return;
|
||||
if (!this.temporaryChatIds.has(chatId)) return;
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
|
||||
}
|
||||
@ -760,7 +763,7 @@ export class NanobotClient {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("newChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer };
|
||||
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
||||
this.queueSend({
|
||||
type: "new_chat",
|
||||
...(workspaceScope ? { workspace_scope: workspaceScope } : {}),
|
||||
@ -768,6 +771,21 @@ export class NanobotClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ask the WebUI gateway to create a connection-owned non-persistent chat. */
|
||||
newTemporaryChat(timeoutMs: number = 5_000): Promise<string> {
|
||||
if (this.pendingNewChat) {
|
||||
return Promise.reject(new Error("newChat already in flight"));
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("newTemporaryChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer, temporary: true };
|
||||
this.queueSend({ type: "new_temporary_chat" });
|
||||
});
|
||||
}
|
||||
|
||||
transcribeAudio(
|
||||
dataUrl: string,
|
||||
options?: { durationMs?: number; timeoutMs?: number },
|
||||
@ -804,7 +822,7 @@ export class NanobotClient {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("forkChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer };
|
||||
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
||||
this.queueSend({
|
||||
type: "fork_chat",
|
||||
source_chat_id: sourceChatId,
|
||||
@ -815,10 +833,7 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
attach(chatId: string): void {
|
||||
if (isTemporaryChatId(chatId)) {
|
||||
this.temporaryChatIds.add(chatId);
|
||||
return;
|
||||
}
|
||||
if (this.temporaryChatIds.has(chatId)) return;
|
||||
this.knownChats.add(chatId);
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.queueSend({ type: "attach", chat_id: chatId });
|
||||
@ -840,8 +855,7 @@ export class NanobotClient {
|
||||
startsNewRun?: boolean;
|
||||
},
|
||||
): void {
|
||||
const temporary = isTemporaryChatId(chatId);
|
||||
if (temporary) this.temporaryChatIds.add(chatId);
|
||||
const temporary = this.temporaryChatIds.has(chatId);
|
||||
if (!temporary) this.knownChats.add(chatId);
|
||||
const frame: Outbound = {
|
||||
type: "message",
|
||||
@ -891,7 +905,7 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
||||
if (isTemporaryChatId(chatId)) return;
|
||||
if (this.temporaryChatIds.has(chatId)) return;
|
||||
this.knownChats.add(chatId);
|
||||
this.queueSend({
|
||||
type: "set_workspace_scope",
|
||||
@ -1016,8 +1030,15 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
if (parsed.event === "attached") {
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
if (this.pendingNewChat) {
|
||||
if (parsed.temporary === true) {
|
||||
this.temporaryChatIds.add(parsed.chat_id);
|
||||
} else {
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
}
|
||||
if (
|
||||
this.pendingNewChat
|
||||
&& this.pendingNewChat.temporary === (parsed.temporary === true)
|
||||
) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.resolve(parsed.chat_id);
|
||||
this.pendingNewChat = null;
|
||||
|
||||
@ -1,20 +1,8 @@
|
||||
import type { ChatSummary } from "./types";
|
||||
|
||||
export const TEMPORARY_CHAT_ID_PREFIX = "temporary-";
|
||||
const WEBSOCKET_SESSION_KEY_PREFIX = "websocket:";
|
||||
|
||||
export function isTemporaryChatId(value: string): boolean {
|
||||
return value.startsWith(TEMPORARY_CHAT_ID_PREFIX);
|
||||
}
|
||||
|
||||
export function temporaryChatIdFromSessionKey(value: string | null): string | null {
|
||||
if (!value?.startsWith(WEBSOCKET_SESSION_KEY_PREFIX)) return null;
|
||||
const chatId = value.slice(WEBSOCKET_SESSION_KEY_PREFIX.length);
|
||||
return isTemporaryChatId(chatId) ? chatId : null;
|
||||
}
|
||||
|
||||
export function createTemporaryChatSession(): ChatSummary {
|
||||
const chatId = `${TEMPORARY_CHAT_ID_PREFIX}${crypto.randomUUID()}`;
|
||||
export function createTemporaryChatSession(chatId: string): ChatSummary {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
key: `${WEBSOCKET_SESSION_KEY_PREFIX}${chatId}`,
|
||||
|
||||
@ -1162,7 +1162,7 @@ export interface InboundTurnMetadata {
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string }
|
||||
| { event: "attached"; chat_id: string; temporary?: boolean }
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| ({
|
||||
event: "message";
|
||||
@ -1338,6 +1338,7 @@ export interface FilePreviewPayload {
|
||||
|
||||
export type Outbound =
|
||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||
| { type: "new_temporary_chat" }
|
||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||
|
||||
@ -20,6 +20,7 @@ const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const setSidebarStateSpy = vi.fn();
|
||||
const discardTemporaryChatSpy = vi.fn();
|
||||
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
|
||||
const sendMessageSpy = vi.fn();
|
||||
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
@ -238,6 +239,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
||||
getGoalState = () => undefined;
|
||||
sendMessage = sendMessageSpy;
|
||||
newChat = vi.fn();
|
||||
newTemporaryChat = newTemporaryChatSpy;
|
||||
attach = attachSpy;
|
||||
setSidebarState = setSidebarStateSpy;
|
||||
discardTemporaryChat = discardTemporaryChatSpy;
|
||||
@ -270,6 +272,10 @@ describe("App layout", () => {
|
||||
attachSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset();
|
||||
discardTemporaryChatSpy.mockReset();
|
||||
let temporaryChatCounter = 0;
|
||||
newTemporaryChatSpy.mockImplementation(async () => (
|
||||
`00000000-0000-4000-8000-${String(++temporaryChatCounter).padStart(12, "0")}`
|
||||
));
|
||||
sendMessageSpy.mockReset();
|
||||
statusHandlers.clear();
|
||||
runStatusHandlers.clear();
|
||||
@ -425,9 +431,9 @@ describe("App layout", () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
const firstHash = window.location.hash;
|
||||
expect(firstHash).toMatch(/^#\/temporary\/temporary-/);
|
||||
expect(firstHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
expect(createChatSpy).not.toHaveBeenCalled();
|
||||
|
||||
@ -441,9 +447,9 @@ describe("App layout", () => {
|
||||
target: { value: "second private message" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
const secondHash = window.location.hash;
|
||||
expect(secondHash).toMatch(/^#\/temporary\/temporary-/);
|
||||
expect(secondHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
|
||||
expect(secondHash).not.toBe(firstHash);
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
|
||||
@ -481,8 +487,8 @@ describe("App layout", () => {
|
||||
const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId);
|
||||
expect(new Set(discardedChatIds).size).toBe(2);
|
||||
expect(discardedChatIds).toEqual([
|
||||
expect.stringMatching(/^temporary-/),
|
||||
expect.stringMatching(/^temporary-/),
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"00000000-0000-4000-8000-000000000002",
|
||||
]);
|
||||
});
|
||||
|
||||
@ -544,7 +550,7 @@ describe("App layout", () => {
|
||||
target: { value: "start temporary chat" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
|
||||
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Clear temporary chat" })).not.toBeInTheDocument();
|
||||
@ -560,7 +566,7 @@ describe("App layout", () => {
|
||||
target: { value: "do not lose this" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
|
||||
const beforeUnload = new Event("beforeunload", { cancelable: true });
|
||||
act(() => window.dispatchEvent(beforeUnload));
|
||||
@ -579,7 +585,7 @@ describe("App layout", () => {
|
||||
target: { value: "connection-sensitive message" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
|
||||
act(() => {
|
||||
statusHandlers.forEach((handler) => handler("reconnecting"));
|
||||
|
||||
@ -71,19 +71,25 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("keeps temporary chats out of attachment and reconnect state", () => {
|
||||
it("keeps temporary chats out of attachment and reconnect state", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatId = "temporary-test";
|
||||
const chatId = "temp-server-id";
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const creation = client.newTemporaryChat();
|
||||
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
|
||||
type: "new_temporary_chat",
|
||||
});
|
||||
lastSocket().fakeMessage({ event: "attached", chat_id: chatId, temporary: true });
|
||||
await expect(creation).resolves.toBe(chatId);
|
||||
lastSocket().sent = [];
|
||||
client.onChat(chatId, vi.fn());
|
||||
client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });
|
||||
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
@ -101,6 +107,30 @@ describe("NanobotClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the temporary attachment when creating a temporary chat", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
const creation = client.newTemporaryChat();
|
||||
let resolved = false;
|
||||
void creation.then(() => { resolved = true; });
|
||||
lastSocket().fakeMessage({ event: "attached", chat_id: "ordinary-chat" });
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "attached",
|
||||
chat_id: "server-temporary-chat",
|
||||
temporary: true,
|
||||
});
|
||||
await expect(creation).resolves.toBe("server-temporary-chat");
|
||||
});
|
||||
|
||||
it("forgets every temporary chat when the socket drops", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
@ -112,20 +142,37 @@ describe("NanobotClient", () => {
|
||||
const secondHandler = vi.fn();
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.onChat("temporary-drop-a", firstHandler);
|
||||
client.onChat("temporary-drop-b", secondHandler);
|
||||
const firstCreation = client.newTemporaryChat();
|
||||
lastSocket().fakeMessage({
|
||||
event: "attached",
|
||||
chat_id: "temp-drop-a",
|
||||
temporary: true,
|
||||
});
|
||||
await firstCreation;
|
||||
const secondCreation = client.newTemporaryChat();
|
||||
lastSocket().fakeMessage({
|
||||
event: "attached",
|
||||
chat_id: "temp-drop-b",
|
||||
temporary: true,
|
||||
});
|
||||
await secondCreation;
|
||||
lastSocket().sent = [];
|
||||
client.onChat("temp-drop-a", firstHandler);
|
||||
client.onChat("temp-drop-b", secondHandler);
|
||||
firstHandler.mockClear();
|
||||
secondHandler.mockClear();
|
||||
lastSocket().close();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "message",
|
||||
chat_id: "temporary-drop-a",
|
||||
chat_id: "temp-drop-a",
|
||||
text: "stale first chat",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message",
|
||||
chat_id: "temporary-drop-b",
|
||||
chat_id: "temp-drop-b",
|
||||
text: "stale second chat",
|
||||
});
|
||||
|
||||
|
||||
@ -2944,7 +2944,7 @@ describe("ThreadComposer", () => {
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
pendingQueueKey="temporary-private"
|
||||
pendingQueueKey={null}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
@ -2966,7 +2966,7 @@ describe("ThreadComposer", () => {
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
pendingQueueKey="temporary-private"
|
||||
pendingQueueKey={null}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user