mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
fix(webui): derive temporary chats from session policy
This commit is contained in:
@@ -21,10 +21,7 @@ from websockets.exceptions import ConnectionClosed
|
|||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
|
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import (
|
||||||
INBOUND_META_RUNTIME_CONTROL,
|
|
||||||
OUTBOUND_META_AGENT_UI,
|
OUTBOUND_META_AGENT_UI,
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
|
||||||
InboundMessage,
|
|
||||||
OutboundMessage,
|
OutboundMessage,
|
||||||
)
|
)
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
@@ -89,6 +86,7 @@ from nanobot.webui.session_access import (
|
|||||||
session_mentions_runtime_context,
|
session_mentions_runtime_context,
|
||||||
)
|
)
|
||||||
from nanobot.webui.sidebar_state import write_webui_sidebar_state
|
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.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
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
|
# Accept UUIDs and short scoped keys like "unified:default". Keeps the capability
|
||||||
# namespace small enough to rule out path traversal / quote injection tricks.
|
# namespace small enough to rule out path traversal / quote injection tricks.
|
||||||
_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
|
_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]:
|
def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
|
||||||
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
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:
|
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.
|
"""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._ingress = gateway.ingress
|
||||||
self._transcripts = gateway.transcripts
|
self._transcripts = gateway.transcripts
|
||||||
self._workspaces = gateway.workspaces
|
self._workspaces = gateway.workspaces
|
||||||
|
self._temporary_chats = gateway.temporary_chats
|
||||||
self._session_access = (
|
self._session_access = (
|
||||||
WebuiSessionAccess(gateway.session_manager)
|
WebuiSessionAccess(gateway.session_manager)
|
||||||
if gateway.session_manager is not None
|
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._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||||
self._temporary_media_paths: dict[str, set[str]] = {}
|
|
||||||
|
|
||||||
# -- Subscription bookkeeping -------------------------------------------
|
# -- Subscription bookkeeping -------------------------------------------
|
||||||
|
|
||||||
@@ -448,38 +435,15 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if key[0] == chat_id:
|
if key[0] == chat_id:
|
||||||
self._stream_text_buffers.pop(key, None)
|
self._stream_text_buffers.pop(key, None)
|
||||||
|
|
||||||
def _discard_temporary_media(self, chat_id: str) -> None:
|
async def _discard_connection_owned_chat(
|
||||||
"""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,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
session_key = f"{self.name}:{chat_id}"
|
await self._temporary_chats.discard(connection, chat_id)
|
||||||
self._detach(connection, chat_id)
|
self._detach(connection, chat_id)
|
||||||
clear_websocket_turns(chat_id)
|
clear_websocket_turns(chat_id)
|
||||||
self._clear_stream_buffers(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(
|
async def send_webui_protocol_error(
|
||||||
self,
|
self,
|
||||||
@@ -513,10 +477,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||||
chat_ids = tuple(self._conn_chats.get(connection, ()))
|
chat_ids = tuple(self._conn_chats.get(connection, ()))
|
||||||
for cid in chat_ids:
|
for cid in chat_ids:
|
||||||
if _is_temporary_chat_id(cid):
|
if self._temporary_chats.owns(connection, cid):
|
||||||
await self._discard_temporary_chat(connection, cid)
|
await self._discard_connection_owned_chat(connection, cid)
|
||||||
else:
|
else:
|
||||||
self._detach(connection, cid)
|
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._conn_default.pop(connection, None)
|
||||||
self._webui_connections.discard(connection)
|
self._webui_connections.discard(connection)
|
||||||
|
|
||||||
@@ -831,21 +797,46 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
await self._hydrate_after_subscribe(new_id)
|
await self._hydrate_after_subscribe(new_id)
|
||||||
return
|
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":
|
if t == "fork_chat":
|
||||||
await handle_webui_fork_chat(self, connection, envelope)
|
await handle_webui_fork_chat(self, connection, envelope)
|
||||||
return
|
return
|
||||||
if t == "discard_temporary_chat":
|
if t == "discard_temporary_chat":
|
||||||
cid = envelope.get("chat_id")
|
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")
|
await self._send_event(connection, "error", detail="invalid temporary chat_id")
|
||||||
return
|
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
|
return
|
||||||
if t == "attach":
|
if t == "attach":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
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)
|
self._attach(connection, cid)
|
||||||
await self._send_event(connection, "attached", chat_id=cid)
|
await self._send_event(connection, "attached", chat_id=cid)
|
||||||
await self._hydrate_after_subscribe(cid)
|
await self._hydrate_after_subscribe(cid)
|
||||||
@@ -879,6 +870,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
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(
|
scope = await self._workspace_scope_or_error(
|
||||||
connection,
|
connection,
|
||||||
lambda: self._workspaces.scope_for_set_request(
|
lambda: self._workspaces.scope_for_set_request(
|
||||||
@@ -910,7 +906,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
temporary = _is_temporary_chat_id(cid)
|
|
||||||
raw_turn_id = envelope.get("turn_id")
|
raw_turn_id = envelope.get("turn_id")
|
||||||
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
|
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
|
||||||
rejection_fields = {
|
rejection_fields = {
|
||||||
@@ -948,28 +943,20 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if temporary:
|
try:
|
||||||
command = content.strip().partition(" ")[0].lower()
|
temporary_policy = self._temporary_chats.message_policy(
|
||||||
if command.startswith("/") and command not in {"/model", "/stop"}:
|
connection,
|
||||||
|
cid,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
except TemporaryChatError as exc:
|
||||||
await self._send_event(
|
await self._send_event(
|
||||||
connection,
|
connection,
|
||||||
"error",
|
"error",
|
||||||
detail="temporary_chat_command_rejected",
|
detail=exc.detail,
|
||||||
**rejection_fields,
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_media = envelope.get("media")
|
raw_media = envelope.get("media")
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
@@ -993,8 +980,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
**rejection_fields,
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if temporary and media_paths:
|
if temporary_policy is not None:
|
||||||
self._temporary_media_paths.setdefault(cid, set()).update(media_paths)
|
self._temporary_chats.register_media(connection, cid, media_paths)
|
||||||
|
|
||||||
# Allow media-only turns (content may be empty when attachments are present).
|
# Allow media-only turns (content may be empty when attachments are present).
|
||||||
if not content.strip() and not media_paths:
|
if not content.strip() and not media_paths:
|
||||||
@@ -1007,15 +994,15 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||||
self._attach(connection, cid)
|
self._attach(connection, cid)
|
||||||
if not temporary:
|
if temporary_policy is None or temporary_policy.hydrate_transcript:
|
||||||
await self._hydrate_after_subscribe(cid)
|
await self._hydrate_after_subscribe(cid)
|
||||||
|
|
||||||
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
|
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
connection,
|
connection,
|
||||||
lambda: (
|
lambda: (
|
||||||
self._workspaces.restricted_default_scope()
|
temporary_policy.workspace_scope
|
||||||
if temporary
|
if temporary_policy is not None
|
||||||
else self._workspaces.scope_for_message(
|
else self._workspaces.scope_for_message(
|
||||||
envelope,
|
envelope,
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
@@ -1074,7 +1061,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||||
accepted = False
|
accepted = False
|
||||||
try:
|
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(
|
self._transcripts.append_user_message(
|
||||||
cid,
|
cid,
|
||||||
content,
|
content,
|
||||||
@@ -1103,8 +1096,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
media=media_paths or None,
|
media=media_paths or None,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
is_dm=False,
|
is_dm=False,
|
||||||
session_key=f"{self.name}:{cid}" if temporary else None,
|
session_key=(
|
||||||
require_existing_session=temporary,
|
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
|
accepted = True
|
||||||
finally:
|
finally:
|
||||||
@@ -1165,8 +1166,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
self._webui_connections.clear()
|
self._webui_connections.clear()
|
||||||
self._tokens.clear()
|
self._tokens.clear()
|
||||||
for chat_id in tuple(self._temporary_media_paths):
|
self._temporary_chats.close()
|
||||||
self._discard_temporary_media(chat_id)
|
|
||||||
|
|
||||||
async def _safe_send_to(
|
async def _safe_send_to(
|
||||||
self,
|
self,
|
||||||
@@ -1196,7 +1196,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
transcript_overrides: dict[str, Any] | None = None,
|
transcript_overrides: dict[str, Any] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
"""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
|
return True
|
||||||
persisted = self._transcripts.prepare_and_append(
|
persisted = self._transcripts.prepare_and_append(
|
||||||
chat_id,
|
chat_id,
|
||||||
|
|||||||
@@ -196,6 +196,23 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
|||||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
|
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
|
||||||
sessions = SessionManager(tmp_path)
|
sessions = SessionManager(tmp_path)
|
||||||
@@ -212,8 +229,7 @@ async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
connection = AsyncMock()
|
connection = AsyncMock()
|
||||||
connection.remote_address = ("127.0.0.1", 5000)
|
connection.remote_address = ("127.0.0.1", 5000)
|
||||||
channel._webui_connections.add(connection)
|
chat_id = await _new_temporary_chat(channel, connection)
|
||||||
chat_id = "temporary-test"
|
|
||||||
upload = tmp_path / "temporary-upload.txt"
|
upload = tmp_path / "temporary-upload.txt"
|
||||||
upload.write_text("private attachment", encoding="utf-8")
|
upload.write_text("private attachment", encoding="utf-8")
|
||||||
channel.gateway.media.store_inbound_attachments = MagicMock(
|
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 = AsyncMock()
|
||||||
connection.remote_address = ("127.0.0.1", 5000)
|
connection.remote_address = ("127.0.0.1", 5000)
|
||||||
|
chat_id = await _new_temporary_chat(channel, connection)
|
||||||
|
|
||||||
await channel._dispatch_envelope(connection, "webui-client", {
|
await channel._dispatch_envelope(connection, "webui-client", {
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"chat_id": "temporary-command",
|
"chat_id": chat_id,
|
||||||
"content": content,
|
"content": content,
|
||||||
"webui": True,
|
"webui": True,
|
||||||
})
|
})
|
||||||
|
|
||||||
assert bus.publish_inbound.await_count == 0
|
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"] == (
|
assert json.loads(connection.send.await_args.args[0])["detail"] == (
|
||||||
"temporary_chat_command_rejected"
|
"temporary_chat_command_rejected"
|
||||||
)
|
)
|
||||||
@@ -314,6 +331,122 @@ async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
connection = AsyncMock()
|
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)
|
channel._webui_connections.add(connection)
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
await channel._dispatch_envelope(
|
||||||
@@ -321,21 +454,42 @@ async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
|
|||||||
"webui-client",
|
"webui-client",
|
||||||
{
|
{
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"chat_id": "temporary-disconnect",
|
"chat_id": "temporary-looking-but-persistent",
|
||||||
"content": "hello",
|
"content": "/goal ordinary chat",
|
||||||
"webui": True,
|
"webui": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await channel._cleanup_connection(connection)
|
|
||||||
|
|
||||||
session_key = "websocket:temporary-disconnect"
|
inbound = bus.publish_inbound.await_args.args[0]
|
||||||
control = bus.publish_inbound.await_args_list[-1].args[0]
|
assert inbound.require_existing_session is False
|
||||||
assert control.session_key == session_key
|
assert inbound.session_key_override is None
|
||||||
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
|
session = sessions.get_cached("websocket:temporary-looking-but-persistent")
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD
|
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
|
connection = AsyncMock()
|
||||||
assert "temporary-disconnect" not in channel._subs
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from loguru import logger as default_logger
|
|||||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
|
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
from nanobot.webui.ws_http import GatewayHTTPHandler
|
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||||
@@ -33,6 +34,7 @@ class GatewayServices:
|
|||||||
ingress: WebUIIngressPolicy
|
ingress: WebUIIngressPolicy
|
||||||
transcripts: WebUITranscriptRecorder
|
transcripts: WebUITranscriptRecorder
|
||||||
workspaces: WebUIWorkspaceController
|
workspaces: WebUIWorkspaceController
|
||||||
|
temporary_chats: WebUITemporaryChats
|
||||||
session_manager: SessionManager | None
|
session_manager: SessionManager | None
|
||||||
cron_service: CronService | None
|
cron_service: CronService | None
|
||||||
local_trigger_store: LocalTriggerStore | None
|
local_trigger_store: LocalTriggerStore | None
|
||||||
@@ -82,6 +84,12 @@ def build_gateway_services(
|
|||||||
default_workspace=workspace_path,
|
default_workspace=workspace_path,
|
||||||
default_restrict_to_workspace=default_restrict_to_workspace,
|
default_restrict_to_workspace=default_restrict_to_workspace,
|
||||||
)
|
)
|
||||||
|
temporary_chats = WebUITemporaryChats(
|
||||||
|
bus=bus,
|
||||||
|
session_manager=session_manager,
|
||||||
|
workspaces=workspaces,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
http = GatewayHTTPHandler(
|
http = GatewayHTTPHandler(
|
||||||
config=config,
|
config=config,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
@@ -112,6 +120,7 @@ def build_gateway_services(
|
|||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
transcripts=transcripts,
|
transcripts=transcripts,
|
||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
|
temporary_chats=temporary_chats,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
local_trigger_store=local_trigger_store,
|
local_trigger_store=local_trigger_store,
|
||||||
|
|||||||
@@ -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()
|
||||||
+36
-17
@@ -64,8 +64,6 @@ import {
|
|||||||
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
|
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
|
||||||
import {
|
import {
|
||||||
createTemporaryChatSession,
|
createTemporaryChatSession,
|
||||||
isTemporaryChatId,
|
|
||||||
temporaryChatIdFromSessionKey,
|
|
||||||
} from "@/lib/temporary-chat";
|
} from "@/lib/temporary-chat";
|
||||||
|
|
||||||
type BootState =
|
type BootState =
|
||||||
@@ -101,6 +99,7 @@ type ShellRoute = {
|
|||||||
view: ShellView;
|
view: ShellView;
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
settingsSection: SettingsSectionKey;
|
settingsSection: SettingsSectionKey;
|
||||||
|
temporary?: boolean;
|
||||||
};
|
};
|
||||||
const loadSettingsView = () => import("@/components/settings/SettingsView");
|
const loadSettingsView = () => import("@/components/settings/SettingsView");
|
||||||
const SettingsView = lazy(async () => {
|
const SettingsView = lazy(async () => {
|
||||||
@@ -235,11 +234,12 @@ function readShellRoute(): ShellRoute {
|
|||||||
const encoded = path.slice("/temporary/".length);
|
const encoded = path.slice("/temporary/".length);
|
||||||
try {
|
try {
|
||||||
const chatId = decodeURIComponent(encoded).trim();
|
const chatId = decodeURIComponent(encoded).trim();
|
||||||
return isTemporaryChatId(chatId)
|
return chatId
|
||||||
? {
|
? {
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: `websocket:${chatId}`,
|
activeKey: `websocket:${chatId}`,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
|
temporary: true,
|
||||||
}
|
}
|
||||||
: defaultShellRoute();
|
: defaultShellRoute();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -262,8 +262,10 @@ function readShellRoute(): ShellRoute {
|
|||||||
|
|
||||||
function shellRouteHash(route: ShellRoute): string {
|
function shellRouteHash(route: ShellRoute): string {
|
||||||
if (route.view === "chat") {
|
if (route.view === "chat") {
|
||||||
const temporaryChatId = temporaryChatIdFromSessionKey(route.activeKey);
|
if (route.temporary && route.activeKey?.startsWith("websocket:")) {
|
||||||
if (temporaryChatId) return `#/temporary/${encodeURIComponent(temporaryChatId)}`;
|
const chatId = route.activeKey.slice("websocket:".length);
|
||||||
|
return `#/temporary/${encodeURIComponent(chatId)}`;
|
||||||
|
}
|
||||||
return route.activeKey
|
return route.activeKey
|
||||||
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
||||||
: "#/new";
|
: "#/new";
|
||||||
@@ -1034,7 +1036,8 @@ function Shell({
|
|||||||
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
||||||
const showHostChrome = effectiveRuntimeSurface === "native";
|
const showHostChrome = effectiveRuntimeSurface === "native";
|
||||||
const showMainSidebar = view !== "settings";
|
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 temporaryChatActive = view === "chat" && temporaryChatId !== null;
|
||||||
const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled;
|
const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled;
|
||||||
const temporarySessionList = useMemo(
|
const temporarySessionList = useMemo(
|
||||||
@@ -1173,9 +1176,7 @@ function Shell({
|
|||||||
|
|
||||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (temporaryChatIdFromSessionKey(activeKey)) {
|
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||||
return temporarySessions[activeKey] ?? null;
|
|
||||||
}
|
|
||||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||||
}, [sessions, activeKey, temporarySessions]);
|
}, [sessions, activeKey, temporarySessions]);
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
@@ -1249,7 +1250,8 @@ function Shell({
|
|||||||
pendingCreatedSessionKeyRef.current = null;
|
pendingCreatedSessionKeyRef.current = null;
|
||||||
}
|
}
|
||||||
if (!activeKey) return;
|
if (!activeKey) return;
|
||||||
if (temporaryChatIdFromSessionKey(activeKey)) {
|
const currentRoute = readShellRoute();
|
||||||
|
if (currentRoute.temporary) {
|
||||||
if (temporarySessions[activeKey]) return;
|
if (temporarySessions[activeKey]) return;
|
||||||
navigate(defaultShellRoute(), { replace: true });
|
navigate(defaultShellRoute(), { replace: true });
|
||||||
return;
|
return;
|
||||||
@@ -1258,7 +1260,6 @@ function Shell({
|
|||||||
// WebKit can commit the route before useSessions' optimistic insert.
|
// WebKit can commit the route before useSessions' optimistic insert.
|
||||||
// Keep that just-created destination valid until the session list catches up.
|
// Keep that just-created destination valid until the session list catches up.
|
||||||
if (pendingCreatedKey === activeKey) return;
|
if (pendingCreatedKey === activeKey) return;
|
||||||
const currentRoute = readShellRoute();
|
|
||||||
navigate(
|
navigate(
|
||||||
currentRoute.view === "chat"
|
currentRoute.view === "chat"
|
||||||
? defaultShellRoute()
|
? defaultShellRoute()
|
||||||
@@ -1478,7 +1479,9 @@ function Shell({
|
|||||||
workspaceScope?: WorkspaceScopePayload | null,
|
workspaceScope?: WorkspaceScopePayload | null,
|
||||||
initialMessage?: string,
|
initialMessage?: string,
|
||||||
) => {
|
) => {
|
||||||
const session = createTemporaryChatSession();
|
try {
|
||||||
|
const chatId = await client.newTemporaryChat();
|
||||||
|
const session = createTemporaryChatSession(chatId);
|
||||||
const restrictedScope = workspaceScope
|
const restrictedScope = workspaceScope
|
||||||
? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted"))
|
? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted"))
|
||||||
: null;
|
: null;
|
||||||
@@ -1498,11 +1501,16 @@ function Shell({
|
|||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: nextSession.key,
|
activeKey: nextSession.key,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
|
temporary: true,
|
||||||
});
|
});
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
return nextSession.chatId;
|
return nextSession.chatId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create temporary chat", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[navigate],
|
[client, navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onForkChat = useCallback(async (
|
const onForkChat = useCallback(async (
|
||||||
@@ -1572,7 +1580,8 @@ function Shell({
|
|||||||
|
|
||||||
const onSelectChat = useCallback(
|
const onSelectChat = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
const selected = temporarySessionsRef.current[key]
|
const selectedTemporary = temporarySessionsRef.current[key];
|
||||||
|
const selected = selectedTemporary
|
||||||
?? sessions.find((session) => session.key === key);
|
?? sessions.find((session) => session.key === key);
|
||||||
const selectedChatId = selected?.chatId;
|
const selectedChatId = selected?.chatId;
|
||||||
if (selectedChatId) {
|
if (selectedChatId) {
|
||||||
@@ -1589,7 +1598,12 @@ function Shell({
|
|||||||
setDraftWorkspaceScope(null);
|
setDraftWorkspaceScope(null);
|
||||||
}
|
}
|
||||||
setWorkspaceError(null);
|
setWorkspaceError(null);
|
||||||
navigate({ view: "chat", activeKey: key, settingsSection: "overview" });
|
navigate({
|
||||||
|
view: "chat",
|
||||||
|
activeKey: key,
|
||||||
|
settingsSection: "overview",
|
||||||
|
...(selectedTemporary ? { temporary: true } : {}),
|
||||||
|
});
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
},
|
},
|
||||||
[navigate, sessions],
|
[navigate, sessions],
|
||||||
@@ -1610,6 +1624,7 @@ function Shell({
|
|||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: remaining[0]?.key ?? null,
|
activeKey: remaining[0]?.key ?? null,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
|
...(remaining[0] ? { temporary: true } : {}),
|
||||||
}, { replace: true });
|
}, { replace: true });
|
||||||
}
|
}
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
@@ -1897,7 +1912,11 @@ function Shell({
|
|||||||
nextRunning.delete(chatId);
|
nextRunning.delete(chatId);
|
||||||
runningChatIdsRef.current = nextRunning;
|
runningChatIdsRef.current = nextRunning;
|
||||||
setRunningChatIds(nextRunning);
|
setRunningChatIds(nextRunning);
|
||||||
if (isTemporaryChatId(chatId)) return;
|
if (
|
||||||
|
Object.values(temporarySessionsRef.current).some(
|
||||||
|
(session) => session.chatId === chatId,
|
||||||
|
)
|
||||||
|
) return;
|
||||||
setUpdatedChatIds((current) => {
|
setUpdatedChatIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (activeChatIdRef.current === chatId) {
|
if (activeChatIdRef.current === chatId) {
|
||||||
@@ -1922,7 +1941,7 @@ function Shell({
|
|||||||
if (Object.keys(temporarySessionsRef.current).length === 0) return;
|
if (Object.keys(temporarySessionsRef.current).length === 0) return;
|
||||||
temporarySessionsRef.current = {};
|
temporarySessionsRef.current = {};
|
||||||
setTemporarySessions({});
|
setTemporarySessions({});
|
||||||
if (temporaryChatIdFromSessionKey(readShellRoute().activeKey)) {
|
if (readShellRoute().temporary) {
|
||||||
navigate(defaultShellRoute(), { replace: true });
|
navigate(defaultShellRoute(), { replace: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|||||||
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
||||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||||
import { isTemporaryChatId } from "@/lib/temporary-chat";
|
|
||||||
import type {
|
import type {
|
||||||
CliAppInfo,
|
CliAppInfo,
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
@@ -444,9 +443,7 @@ function storeSlashRecents(commands: string[]): void {
|
|||||||
|
|
||||||
function queuedPromptsStorageKey(key?: string | null): string | null {
|
function queuedPromptsStorageKey(key?: string | null): string | null {
|
||||||
const clean = key?.trim();
|
const clean = key?.trim();
|
||||||
return clean && !isTemporaryChatId(clean)
|
return clean ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` : null;
|
||||||
? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}`
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeQueuedSessionMentions(value: unknown): SessionMention[] {
|
function normalizeQueuedSessionMentions(value: unknown): SessionMention[] {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
} from "@/lib/mcp-preset-events";
|
} from "@/lib/mcp-preset-events";
|
||||||
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
||||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||||
import { isTemporaryChatId } from "@/lib/temporary-chat";
|
|
||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
@@ -677,6 +676,7 @@ export function ThreadShell({
|
|||||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||||
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(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). */
|
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||||
@@ -753,10 +753,12 @@ export function ThreadShell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const retained = new Set(temporaryChatIds);
|
const retained = new Set(temporaryChatIds);
|
||||||
for (const cachedChatId of messageCacheRef.current.keys()) {
|
for (const chatId of retained) knownTemporaryChatIdsRef.current.add(chatId);
|
||||||
if (isTemporaryChatId(cachedChatId) && !retained.has(cachedChatId)) {
|
for (const cachedChatId of knownTemporaryChatIdsRef.current) {
|
||||||
|
if (!retained.has(cachedChatId)) {
|
||||||
messageCacheRef.current.delete(cachedChatId);
|
messageCacheRef.current.delete(cachedChatId);
|
||||||
activeViewportTurnByChatIdRef.current.delete(cachedChatId);
|
activeViewportTurnByChatIdRef.current.delete(cachedChatId);
|
||||||
|
knownTemporaryChatIdsRef.current.delete(cachedChatId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [temporaryChatIds]);
|
}, [temporaryChatIds]);
|
||||||
@@ -1435,7 +1437,7 @@ export function ThreadShell({
|
|||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
pendingQueueKey={chatId}
|
pendingQueueKey={temporary ? null : chatId}
|
||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
ingressLimits={ingressLimits}
|
ingressLimits={ingressLimits}
|
||||||
quotedContext={quotedContext}
|
quotedContext={quotedContext}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { createHostWebSocket } from "./runtime";
|
import { createHostWebSocket } from "./runtime";
|
||||||
import { isTemporaryChatId } from "./temporary-chat";
|
|
||||||
|
|
||||||
/** WebSocket readyState constants, referenced by value to stay portable
|
/** WebSocket readyState constants, referenced by value to stay portable
|
||||||
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
|
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
|
||||||
@@ -109,6 +108,10 @@ interface PendingRequest<T> {
|
|||||||
timer: ReturnType<typeof setTimeout>;
|
timer: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingChatRequest extends PendingRequest<string> {
|
||||||
|
temporary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
||||||
const TURN_REJECTION_DETAILS = new Set([
|
const TURN_REJECTION_DETAILS = new Set([
|
||||||
"access_denied",
|
"access_denied",
|
||||||
@@ -197,7 +200,7 @@ export class NanobotClient {
|
|||||||
private static readonly COMPLETED_TURN_FENCE_MAX = 256;
|
private static readonly COMPLETED_TURN_FENCE_MAX = 256;
|
||||||
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
||||||
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
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 pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||||
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
||||||
// Frames queued while the socket is not yet OPEN
|
// Frames queued while the socket is not yet OPEN
|
||||||
@@ -743,7 +746,7 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
discardTemporaryChat(chatId: string): void {
|
discardTemporaryChat(chatId: string): void {
|
||||||
if (!isTemporaryChatId(chatId)) return;
|
if (!this.temporaryChatIds.has(chatId)) return;
|
||||||
if (this.socket?.readyState === WS_OPEN) {
|
if (this.socket?.readyState === WS_OPEN) {
|
||||||
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
|
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
|
||||||
}
|
}
|
||||||
@@ -760,7 +763,7 @@ export class NanobotClient {
|
|||||||
this.pendingNewChat = null;
|
this.pendingNewChat = null;
|
||||||
reject(new Error("newChat timed out"));
|
reject(new Error("newChat timed out"));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
this.pendingNewChat = { resolve, reject, timer };
|
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
||||||
this.queueSend({
|
this.queueSend({
|
||||||
type: "new_chat",
|
type: "new_chat",
|
||||||
...(workspaceScope ? { workspace_scope: workspaceScope } : {}),
|
...(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(
|
transcribeAudio(
|
||||||
dataUrl: string,
|
dataUrl: string,
|
||||||
options?: { durationMs?: number; timeoutMs?: number },
|
options?: { durationMs?: number; timeoutMs?: number },
|
||||||
@@ -804,7 +822,7 @@ export class NanobotClient {
|
|||||||
this.pendingNewChat = null;
|
this.pendingNewChat = null;
|
||||||
reject(new Error("forkChat timed out"));
|
reject(new Error("forkChat timed out"));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
this.pendingNewChat = { resolve, reject, timer };
|
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
||||||
this.queueSend({
|
this.queueSend({
|
||||||
type: "fork_chat",
|
type: "fork_chat",
|
||||||
source_chat_id: sourceChatId,
|
source_chat_id: sourceChatId,
|
||||||
@@ -815,10 +833,7 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
attach(chatId: string): void {
|
attach(chatId: string): void {
|
||||||
if (isTemporaryChatId(chatId)) {
|
if (this.temporaryChatIds.has(chatId)) return;
|
||||||
this.temporaryChatIds.add(chatId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.knownChats.add(chatId);
|
this.knownChats.add(chatId);
|
||||||
if (this.socket?.readyState === WS_OPEN) {
|
if (this.socket?.readyState === WS_OPEN) {
|
||||||
this.queueSend({ type: "attach", chat_id: chatId });
|
this.queueSend({ type: "attach", chat_id: chatId });
|
||||||
@@ -840,8 +855,7 @@ export class NanobotClient {
|
|||||||
startsNewRun?: boolean;
|
startsNewRun?: boolean;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const temporary = isTemporaryChatId(chatId);
|
const temporary = this.temporaryChatIds.has(chatId);
|
||||||
if (temporary) this.temporaryChatIds.add(chatId);
|
|
||||||
if (!temporary) this.knownChats.add(chatId);
|
if (!temporary) this.knownChats.add(chatId);
|
||||||
const frame: Outbound = {
|
const frame: Outbound = {
|
||||||
type: "message",
|
type: "message",
|
||||||
@@ -891,7 +905,7 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
||||||
if (isTemporaryChatId(chatId)) return;
|
if (this.temporaryChatIds.has(chatId)) return;
|
||||||
this.knownChats.add(chatId);
|
this.knownChats.add(chatId);
|
||||||
this.queueSend({
|
this.queueSend({
|
||||||
type: "set_workspace_scope",
|
type: "set_workspace_scope",
|
||||||
@@ -1016,8 +1030,15 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.event === "attached") {
|
if (parsed.event === "attached") {
|
||||||
|
if (parsed.temporary === true) {
|
||||||
|
this.temporaryChatIds.add(parsed.chat_id);
|
||||||
|
} else {
|
||||||
this.knownChats.add(parsed.chat_id);
|
this.knownChats.add(parsed.chat_id);
|
||||||
if (this.pendingNewChat) {
|
}
|
||||||
|
if (
|
||||||
|
this.pendingNewChat
|
||||||
|
&& this.pendingNewChat.temporary === (parsed.temporary === true)
|
||||||
|
) {
|
||||||
clearTimeout(this.pendingNewChat.timer);
|
clearTimeout(this.pendingNewChat.timer);
|
||||||
this.pendingNewChat.resolve(parsed.chat_id);
|
this.pendingNewChat.resolve(parsed.chat_id);
|
||||||
this.pendingNewChat = null;
|
this.pendingNewChat = null;
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
import type { ChatSummary } from "./types";
|
import type { ChatSummary } from "./types";
|
||||||
|
|
||||||
export const TEMPORARY_CHAT_ID_PREFIX = "temporary-";
|
|
||||||
const WEBSOCKET_SESSION_KEY_PREFIX = "websocket:";
|
const WEBSOCKET_SESSION_KEY_PREFIX = "websocket:";
|
||||||
|
|
||||||
export function isTemporaryChatId(value: string): boolean {
|
export function createTemporaryChatSession(chatId: string): ChatSummary {
|
||||||
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()}`;
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
key: `${WEBSOCKET_SESSION_KEY_PREFIX}${chatId}`,
|
key: `${WEBSOCKET_SESSION_KEY_PREFIX}${chatId}`,
|
||||||
|
|||||||
@@ -1162,7 +1162,7 @@ export interface InboundTurnMetadata {
|
|||||||
|
|
||||||
export type InboundEvent =
|
export type InboundEvent =
|
||||||
| { event: "ready"; chat_id: string; client_id: string }
|
| { 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_accepted"; chat_id: string; turn_id: string }
|
||||||
| ({
|
| ({
|
||||||
event: "message";
|
event: "message";
|
||||||
@@ -1338,6 +1338,7 @@ export interface FilePreviewPayload {
|
|||||||
|
|
||||||
export type Outbound =
|
export type Outbound =
|
||||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||||
|
| { type: "new_temporary_chat" }
|
||||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||||
| { type: "attach"; chat_id: string }
|
| { type: "attach"; chat_id: string }
|
||||||
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const updateUrlSpy = vi.fn();
|
|||||||
const attachSpy = vi.fn();
|
const attachSpy = vi.fn();
|
||||||
const setSidebarStateSpy = vi.fn();
|
const setSidebarStateSpy = vi.fn();
|
||||||
const discardTemporaryChatSpy = vi.fn();
|
const discardTemporaryChatSpy = vi.fn();
|
||||||
|
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
|
||||||
const sendMessageSpy = vi.fn();
|
const sendMessageSpy = vi.fn();
|
||||||
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
|
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
|
||||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||||
@@ -238,6 +239,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
|||||||
getGoalState = () => undefined;
|
getGoalState = () => undefined;
|
||||||
sendMessage = sendMessageSpy;
|
sendMessage = sendMessageSpy;
|
||||||
newChat = vi.fn();
|
newChat = vi.fn();
|
||||||
|
newTemporaryChat = newTemporaryChatSpy;
|
||||||
attach = attachSpy;
|
attach = attachSpy;
|
||||||
setSidebarState = setSidebarStateSpy;
|
setSidebarState = setSidebarStateSpy;
|
||||||
discardTemporaryChat = discardTemporaryChatSpy;
|
discardTemporaryChat = discardTemporaryChatSpy;
|
||||||
@@ -270,6 +272,10 @@ describe("App layout", () => {
|
|||||||
attachSpy.mockReset();
|
attachSpy.mockReset();
|
||||||
setSidebarStateSpy.mockReset();
|
setSidebarStateSpy.mockReset();
|
||||||
discardTemporaryChatSpy.mockReset();
|
discardTemporaryChatSpy.mockReset();
|
||||||
|
let temporaryChatCounter = 0;
|
||||||
|
newTemporaryChatSpy.mockImplementation(async () => (
|
||||||
|
`00000000-0000-4000-8000-${String(++temporaryChatCounter).padStart(12, "0")}`
|
||||||
|
));
|
||||||
sendMessageSpy.mockReset();
|
sendMessageSpy.mockReset();
|
||||||
statusHandlers.clear();
|
statusHandlers.clear();
|
||||||
runStatusHandlers.clear();
|
runStatusHandlers.clear();
|
||||||
@@ -425,9 +431,9 @@ describe("App layout", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send 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 firstHash = window.location.hash;
|
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(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||||
expect(createChatSpy).not.toHaveBeenCalled();
|
expect(createChatSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -441,9 +447,9 @@ describe("App layout", () => {
|
|||||||
target: { value: "second private message" },
|
target: { value: "second private message" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send 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;
|
const secondHash = window.location.hash;
|
||||||
expect(secondHash).toMatch(/^#\/temporary\/temporary-/);
|
expect(secondHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
|
||||||
expect(secondHash).not.toBe(firstHash);
|
expect(secondHash).not.toBe(firstHash);
|
||||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||||
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
|
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
|
||||||
@@ -481,8 +487,8 @@ describe("App layout", () => {
|
|||||||
const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId);
|
const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId);
|
||||||
expect(new Set(discardedChatIds).size).toBe(2);
|
expect(new Set(discardedChatIds).size).toBe(2);
|
||||||
expect(discardedChatIds).toEqual([
|
expect(discardedChatIds).toEqual([
|
||||||
expect.stringMatching(/^temporary-/),
|
"00000000-0000-4000-8000-000000000001",
|
||||||
expect.stringMatching(/^temporary-/),
|
"00000000-0000-4000-8000-000000000002",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -544,7 +550,7 @@ describe("App layout", () => {
|
|||||||
target: { value: "start temporary chat" },
|
target: { value: "start temporary chat" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send 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-]+$/));
|
||||||
|
|
||||||
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
|
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Clear temporary chat" })).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" },
|
target: { value: "do not lose this" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send 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 beforeUnload = new Event("beforeunload", { cancelable: true });
|
const beforeUnload = new Event("beforeunload", { cancelable: true });
|
||||||
act(() => window.dispatchEvent(beforeUnload));
|
act(() => window.dispatchEvent(beforeUnload));
|
||||||
@@ -579,7 +585,7 @@ describe("App layout", () => {
|
|||||||
target: { value: "connection-sensitive message" },
|
target: { value: "connection-sensitive message" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send 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(() => {
|
act(() => {
|
||||||
statusHandlers.forEach((handler) => handler("reconnecting"));
|
statusHandlers.forEach((handler) => handler("reconnecting"));
|
||||||
|
|||||||
@@ -71,19 +71,25 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("NanobotClient", () => {
|
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({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
reconnect: false,
|
reconnect: false,
|
||||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||||
});
|
});
|
||||||
const chatId = "temporary-test";
|
const chatId = "temp-server-id";
|
||||||
client.connect();
|
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.onChat(chatId, vi.fn());
|
||||||
client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });
|
client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });
|
||||||
|
|
||||||
lastSocket().fakeOpen();
|
|
||||||
|
|
||||||
expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
|
expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
|
||||||
{
|
{
|
||||||
type: "message",
|
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 () => {
|
it("forgets every temporary chat when the socket drops", async () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
@@ -112,20 +142,37 @@ describe("NanobotClient", () => {
|
|||||||
const secondHandler = vi.fn();
|
const secondHandler = vi.fn();
|
||||||
client.connect();
|
client.connect();
|
||||||
lastSocket().fakeOpen();
|
lastSocket().fakeOpen();
|
||||||
client.onChat("temporary-drop-a", firstHandler);
|
const firstCreation = client.newTemporaryChat();
|
||||||
client.onChat("temporary-drop-b", secondHandler);
|
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();
|
lastSocket().close();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
lastSocket().fakeOpen();
|
lastSocket().fakeOpen();
|
||||||
lastSocket().fakeMessage({
|
lastSocket().fakeMessage({
|
||||||
event: "message",
|
event: "message",
|
||||||
chat_id: "temporary-drop-a",
|
chat_id: "temp-drop-a",
|
||||||
text: "stale first chat",
|
text: "stale first chat",
|
||||||
});
|
});
|
||||||
lastSocket().fakeMessage({
|
lastSocket().fakeMessage({
|
||||||
event: "message",
|
event: "message",
|
||||||
chat_id: "temporary-drop-b",
|
chat_id: "temp-drop-b",
|
||||||
text: "stale second chat",
|
text: "stale second chat",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2944,7 +2944,7 @@ describe("ThreadComposer", () => {
|
|||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onStop={vi.fn()}
|
onStop={vi.fn()}
|
||||||
isStreaming
|
isStreaming
|
||||||
pendingQueueKey="temporary-private"
|
pendingQueueKey={null}
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -2966,7 +2966,7 @@ describe("ThreadComposer", () => {
|
|||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onStop={vi.fn()}
|
onStop={vi.fn()}
|
||||||
isStreaming
|
isStreaming
|
||||||
pendingQueueKey="temporary-private"
|
pendingQueueKey={null}
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user