mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d39d01f275 |
@@ -27,7 +27,7 @@ nanobot agent -m "Hello!"
|
||||
Install Langfuse:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable langfuse
|
||||
python -m pip install langfuse
|
||||
```
|
||||
|
||||
## Minimal working example
|
||||
|
||||
@@ -549,7 +549,7 @@ This recipe applies after the agent works and you want observability for OpenAI-
|
||||
Install the optional package in the same Python environment that runs nanobot:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable langfuse
|
||||
python -m pip install langfuse
|
||||
```
|
||||
|
||||
Set the environment variables before starting nanobot:
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.memory import Consolidator
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class AutoCompact:
|
||||
_RECENT_SUFFIX_MESSAGES = MIN_COMPACTED_REPLAY_MESSAGES
|
||||
_RECENT_SUFFIX_MESSAGES = 8
|
||||
_INTERNAL_SESSION_PREFIXES = ("dream:",)
|
||||
|
||||
def __init__(self, sessions: SessionManager, consolidator: Consolidator,
|
||||
@@ -45,9 +45,25 @@ class AutoCompact:
|
||||
return False
|
||||
return idle_seconds >= self._ttl * 60
|
||||
|
||||
def _has_unarchived_messages(self, key: str) -> bool:
|
||||
def _has_compactable_idle_tail(self, key: str) -> bool:
|
||||
session = self.sessions.get_or_create(key)
|
||||
return session.last_consolidated < len(session.messages)
|
||||
tail = list(session.messages[session.last_consolidated:])
|
||||
if not tail:
|
||||
return False
|
||||
probe = Session(
|
||||
key=session.key,
|
||||
messages=tail,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
result = probe.retain_recent_legal_suffix(
|
||||
self._RECENT_SUFFIX_MESSAGES,
|
||||
extend_to_user=True,
|
||||
)
|
||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
||||
return bool(messages_to_remove)
|
||||
|
||||
@staticmethod
|
||||
def _format_summary(text: str, last_active: datetime) -> str:
|
||||
@@ -72,7 +88,7 @@ class AutoCompact:
|
||||
if key in active_session_keys:
|
||||
continue
|
||||
updated_at = info.get("updated_at")
|
||||
if self._is_expired(updated_at, now) and self._has_unarchived_messages(key):
|
||||
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
||||
session = self.sessions.get_or_create(key)
|
||||
try:
|
||||
runtime = resolve_runtime(session)
|
||||
|
||||
+36
-36
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.runtime_context import public_history_messages
|
||||
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.gitstore import GitStore
|
||||
from nanobot.utils.helpers import (
|
||||
content_with_media_breadcrumbs,
|
||||
@@ -858,13 +858,14 @@ class Consolidator:
|
||||
return last_boundary
|
||||
|
||||
@staticmethod
|
||||
def _full_replay_history(
|
||||
def _full_unconsolidated_history(
|
||||
session: Session,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return all messages that can reach the next model prompt."""
|
||||
if not session.messages:
|
||||
"""Return the whole unconsolidated tail for consolidation decisions."""
|
||||
unconsolidated_count = len(session.messages) - session.last_consolidated
|
||||
if unconsolidated_count <= 0:
|
||||
return []
|
||||
return session.get_history(max_messages=len(session.messages))
|
||||
return session.get_history(max_messages=unconsolidated_count)
|
||||
|
||||
@staticmethod
|
||||
def _replay_overflow_boundary(
|
||||
@@ -947,8 +948,8 @@ class Consolidator:
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
) -> tuple[int, str]:
|
||||
"""Estimate prompt size from the full replayable session history."""
|
||||
history = self._full_replay_history(session)
|
||||
"""Estimate prompt size from the full unconsolidated session tail."""
|
||||
history = self._full_unconsolidated_history(session)
|
||||
channel = session.key.split(":", 1)[0] if ":" in session.key else None
|
||||
# Include archived summary in estimation so the budget accounts for it.
|
||||
meta = session.metadata.get("_last_summary")
|
||||
@@ -1159,37 +1160,42 @@ class Consolidator:
|
||||
session_key: str,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
max_suffix: int = MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
max_suffix: int = 8,
|
||||
) -> str | None:
|
||||
"""Archive the full idle tail while keeping recent messages replayable.
|
||||
|
||||
``max_suffix`` remains accepted for SDK compatibility. Replay retention
|
||||
is now derived independently from archive progress using the project-wide
|
||||
compacted-session window.
|
||||
"""
|
||||
if max_suffix != MIN_COMPACTED_REPLAY_MESSAGES:
|
||||
logger.debug(
|
||||
"Idle-session compact for {} uses the fixed replay window ({}, requested {})",
|
||||
session_key,
|
||||
MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
max_suffix,
|
||||
)
|
||||
"""Archive an idle prefix and hide it from replay without deleting it."""
|
||||
lock = self.get_lock(session_key)
|
||||
async with lock:
|
||||
self.sessions.invalidate(session_key)
|
||||
session = self.sessions.get_or_create(session_key)
|
||||
|
||||
archive_start = session.last_consolidated
|
||||
messages_to_archive = list(session.messages[archive_start:])
|
||||
if not messages_to_archive:
|
||||
messages_to_summarize = list(session.messages[session.last_consolidated:])
|
||||
if not messages_to_summarize:
|
||||
self.sessions.save(session)
|
||||
return ""
|
||||
|
||||
probe = Session(
|
||||
key=session.key,
|
||||
messages=messages_to_summarize.copy(),
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||
visible_suffix = probe.messages
|
||||
messages_to_remove = result.dropped
|
||||
|
||||
if not messages_to_remove:
|
||||
self.sessions.save(session)
|
||||
return ""
|
||||
|
||||
last_active = session.updated_at
|
||||
archive_end = archive_start + len(messages_to_archive)
|
||||
# The visible suffix informs the summary but stays out of raw fallback.
|
||||
summary = await self.archive(
|
||||
messages_to_archive,
|
||||
messages_to_remove,
|
||||
runtime=runtime,
|
||||
session_key=session_key,
|
||||
summary_messages=messages_to_summarize,
|
||||
)
|
||||
|
||||
if summary and summary != "(nothing)":
|
||||
@@ -1198,22 +1204,16 @@ class Consolidator:
|
||||
"last_active": last_active.isoformat(),
|
||||
}
|
||||
|
||||
# A turn can append while the provider call is in flight. Advance only
|
||||
# through the captured batch so new messages remain eligible next time.
|
||||
session.last_consolidated = archive_end
|
||||
# Preserve history and advance only the replay boundary.
|
||||
session.last_consolidated = len(session.messages) - len(visible_suffix)
|
||||
session.provider_state = None
|
||||
self.sessions.save(session)
|
||||
|
||||
visible = session.get_history(
|
||||
max_messages=MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
extend_to_user=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
|
||||
session_key,
|
||||
len(messages_to_archive),
|
||||
len(visible),
|
||||
len(messages_to_remove),
|
||||
len(visible_suffix),
|
||||
len(session.messages),
|
||||
bool(summary),
|
||||
)
|
||||
|
||||
@@ -458,10 +458,7 @@ class WebSearchTool(Tool):
|
||||
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
except ImportError:
|
||||
return ToolResult.error(
|
||||
"Error: Olostep support is not installed. "
|
||||
"Run `nanobot plugins enable olostep`."
|
||||
)
|
||||
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
|
||||
async_olostep = cast(Any, AsyncOlostep)
|
||||
olostep_base_error = cast(type[Exception], Olostep_BaseError)
|
||||
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
||||
|
||||
@@ -101,31 +101,6 @@ class BaseChannel(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def progress_transport_defaults(self) -> tuple[bool, bool] | None:
|
||||
"""Return channel-owned defaults for progress and tool-hint messages.
|
||||
|
||||
``None`` keeps the global channel policy. Channels should override this
|
||||
only when their transport requires different defaults.
|
||||
"""
|
||||
return None
|
||||
|
||||
def should_retry_send_error(self, error: Exception) -> bool:
|
||||
"""Return whether the channel manager may retry a failed delivery.
|
||||
|
||||
Channels with protocol-level business errors can override this hook to
|
||||
prevent retries that cannot succeed until external state changes.
|
||||
Transport and unexpected errors remain retryable by default.
|
||||
"""
|
||||
return True
|
||||
|
||||
def start_error_message(self, error: Exception) -> str | None:
|
||||
"""Return an actionable public message for a channel startup failure.
|
||||
|
||||
Channel-specific exception handling stays in the owning channel. Returning
|
||||
``None`` keeps the manager's generic fallback.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
@@ -187,15 +187,11 @@ class ChannelManager:
|
||||
channel = cls(section, self.bus, **kwargs)
|
||||
if runtime_name and runtime_name != channel.name:
|
||||
channel.name = runtime_name
|
||||
progress_default, tool_hints_default = channel.progress_transport_defaults() or (
|
||||
self.config.channels.send_progress,
|
||||
self.config.channels.send_tool_hints,
|
||||
)
|
||||
channel.send_progress = self._resolve_bool_override(
|
||||
section, "send_progress", progress_default,
|
||||
section, "send_progress", self.config.channels.send_progress,
|
||||
)
|
||||
channel.send_tool_hints = self._resolve_bool_override(
|
||||
section, "send_tool_hints", tool_hints_default,
|
||||
section, "send_tool_hints", self.config.channels.send_tool_hints,
|
||||
)
|
||||
channel.show_reasoning = self._resolve_bool_override(
|
||||
section, "show_reasoning", self.config.channels.show_reasoning,
|
||||
@@ -351,13 +347,9 @@ class ChannelManager:
|
||||
await channel.start()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
public_error = channel.start_error_message(exc)
|
||||
errors[name] = public_error or "Channel failed to start. Check gateway logs."
|
||||
if public_error:
|
||||
logger.error("Failed to start channel {}: {}", name, public_error)
|
||||
else:
|
||||
logger.exception("Failed to start channel {}", name)
|
||||
except Exception:
|
||||
errors[name] = "Channel failed to start. Check gateway logs."
|
||||
logger.exception("Failed to start channel {}", name)
|
||||
|
||||
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task[None]:
|
||||
logger.info("Starting {} channel...", name)
|
||||
@@ -920,14 +912,6 @@ class ChannelManager:
|
||||
except asyncio.CancelledError:
|
||||
raise # Propagate cancellation for graceful shutdown
|
||||
except Exception as e:
|
||||
if not channel.should_retry_send_error(e):
|
||||
logger.error(
|
||||
"Send to {} failed with a non-retryable {}: {}",
|
||||
msg.channel,
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
exhausted = (
|
||||
attempt >= max_attempts
|
||||
|
||||
@@ -24,12 +24,10 @@ try:
|
||||
import nh3
|
||||
from mistune import HTMLRenderer, create_markdown
|
||||
from nio import (
|
||||
Api,
|
||||
AsyncClient,
|
||||
AsyncClientConfig,
|
||||
InviteEvent,
|
||||
JoinError,
|
||||
JoinResponse,
|
||||
KeyVerificationCancel,
|
||||
KeyVerificationEvent,
|
||||
KeyVerificationKey,
|
||||
@@ -45,7 +43,6 @@ try:
|
||||
RoomSendResponse,
|
||||
RoomTypingError,
|
||||
SyncError,
|
||||
SyncResponse,
|
||||
ToDeviceError,
|
||||
UploadError,
|
||||
)
|
||||
@@ -704,7 +701,6 @@ class MatrixChannel(BaseChannel):
|
||||
client.add_response_callback(self._on_sync_error, SyncError)
|
||||
client.add_response_callback(self._on_join_error, JoinError)
|
||||
client.add_response_callback(self._on_send_error, RoomSendError)
|
||||
client.add_response_callback(self._on_sync_invite_fallback, SyncResponse)
|
||||
|
||||
def _is_sas_sender_allowed(self, sender: str) -> bool:
|
||||
return bool(sender and self.is_allowed(sender))
|
||||
@@ -786,49 +782,6 @@ class MatrixChannel(BaseChannel):
|
||||
with suppress(Exception):
|
||||
self.client.stop_sync_forever()
|
||||
|
||||
async def _join_room_safe(self, room_id: str) -> bool:
|
||||
"""Join a room, sending a non-empty POST body.
|
||||
|
||||
nio's ``Api.join()`` produces a POST with no body. Some homeservers
|
||||
(notably Continuwuity) reject empty bodies with ``M_BAD_JSON``.
|
||||
Sending ``"{}"`` satisfies both strict and lenient servers.
|
||||
"""
|
||||
client = self._require_client()
|
||||
method, path = Api.join(client.access_token, room_id)
|
||||
try:
|
||||
resp = cast(
|
||||
JoinResponse | JoinError,
|
||||
await client._send( # type: ignore[reportPrivateUsage, reportUnknownMemberType]
|
||||
JoinResponse, method, path, data="{}"
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
self.logger.error("Matrix join request exception for room={}", room_id, exc_info=True)
|
||||
return False
|
||||
if isinstance(resp, JoinError):
|
||||
self.logger.error("Matrix auto-join failed for room={}: {}", room_id, resp)
|
||||
return False
|
||||
self.logger.info("Matrix auto-join succeeded: {}", room_id)
|
||||
return True
|
||||
|
||||
async def _on_sync_invite_fallback(self, response: SyncResponse) -> None:
|
||||
"""Safety net: join pending invites that the event callback may have missed.
|
||||
|
||||
Some homeservers (e.g. Continuwuity) deliver each invite only once.
|
||||
If ``_on_room_invite`` fires but the join fails, the sync token
|
||||
advances and the invite is never re-delivered. This callback inspects
|
||||
the same ``SyncResponse`` for pending invites and joins them, acting
|
||||
as a fallback alongside the event-based callback.
|
||||
"""
|
||||
if not response.rooms or not response.rooms.invite:
|
||||
return
|
||||
for room_id, invite_info in response.rooms.invite.items():
|
||||
for event in cast(list[Any], invite_info.invite_state):
|
||||
sender = getattr(event, "sender", None)
|
||||
if sender and self.is_allowed(cast(str, sender)):
|
||||
await self._join_room_safe(room_id)
|
||||
break
|
||||
|
||||
async def _on_join_error(self, response: JoinError) -> None:
|
||||
self._log_response_error("join", response)
|
||||
|
||||
@@ -885,7 +838,8 @@ class MatrixChannel(BaseChannel):
|
||||
|
||||
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
||||
if self.is_allowed(event.sender):
|
||||
await self._join_room_safe(room.room_id)
|
||||
client = self._require_client()
|
||||
await client.join(room.room_id)
|
||||
|
||||
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
||||
count = getattr(room, "member_count", None)
|
||||
|
||||
@@ -4,14 +4,13 @@ import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("nio")
|
||||
pytest.importorskip("nh3")
|
||||
pytest.importorskip("mistune")
|
||||
from nio import JoinResponse, RoomSendResponse, SyncError
|
||||
from nio import RoomSendResponse, SyncError
|
||||
|
||||
import nanobot.channels.matrix.runtime as matrix_module
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@@ -105,15 +104,6 @@ class _FakeAsyncClient:
|
||||
async def join(self, room_id: str) -> None:
|
||||
self.join_calls.append(room_id)
|
||||
|
||||
async def _send(self, response_class, method, path, data=None, **kwargs):
|
||||
"""Minimal mock for nio's ``_send`` used by ``_join_room_safe``."""
|
||||
if response_class is JoinResponse and method == "POST" and "/join/" in path:
|
||||
encoded = path.split("/join/")[1].split("?")[0]
|
||||
room_id = unquote(encoded)
|
||||
self.join_calls.append(room_id)
|
||||
return JoinResponse(room_id=room_id)
|
||||
return response_class()
|
||||
|
||||
async def accept_key_verification(self, transaction_id: str):
|
||||
self.operation_calls.append(f"accept:{transaction_id}")
|
||||
self.accept_key_verification_calls.append(transaction_id)
|
||||
@@ -318,7 +308,7 @@ async def test_start_skips_load_store_when_device_id_missing(
|
||||
assert clients[0].load_store_called is False
|
||||
assert len(clients[0].callbacks) == 3
|
||||
assert clients[0].to_device_callbacks == []
|
||||
assert len(clients[0].response_callbacks) == 4
|
||||
assert len(clients[0].response_callbacks) == 3
|
||||
|
||||
await channel.stop()
|
||||
|
||||
@@ -600,7 +590,6 @@ async def test_room_invite_joins_when_sender_allowed() -> None:
|
||||
|
||||
assert client.join_calls == ["!room:matrix.org"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_invite_respects_allow_list_when_configured() -> None:
|
||||
channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
|
||||
@@ -615,61 +604,6 @@ async def test_room_invite_respects_allow_list_when_configured() -> None:
|
||||
assert client.join_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_sync_invite_fallback_joins_pending_invites() -> None:
|
||||
"""_on_sync_invite_fallback joins rooms from sync invite_state for allowed senders."""
|
||||
channel = MatrixChannel(
|
||||
_make_config(allow_from=["@alice:matrix.org"]), MessageBus()
|
||||
)
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
invite_event = SimpleNamespace(sender="@alice:matrix.org")
|
||||
invite_info = SimpleNamespace(invite_state=[invite_event])
|
||||
rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info})
|
||||
response = SimpleNamespace(rooms=rooms)
|
||||
|
||||
await channel._on_sync_invite_fallback(response)
|
||||
|
||||
assert client.join_calls == ["!room:matrix.org"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_sync_invite_fallback_skips_when_no_invites() -> None:
|
||||
"""_on_sync_invite_fallback is a no-op when sync has no invites."""
|
||||
channel = MatrixChannel(
|
||||
_make_config(allow_from=["@alice:matrix.org"]), MessageBus()
|
||||
)
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
rooms = SimpleNamespace(invite={})
|
||||
response = SimpleNamespace(rooms=rooms)
|
||||
|
||||
await channel._on_sync_invite_fallback(response)
|
||||
|
||||
assert client.join_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_sync_invite_fallback_skips_denied_sender() -> None:
|
||||
"""_on_sync_invite_fallback respects the allow list."""
|
||||
channel = MatrixChannel(
|
||||
_make_config(allow_from=["@bob:matrix.org"]), MessageBus()
|
||||
)
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
invite_event = SimpleNamespace(sender="@alice:matrix.org")
|
||||
invite_info = SimpleNamespace(invite_state=[invite_event])
|
||||
rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info})
|
||||
response = SimpleNamespace(rooms=rooms)
|
||||
|
||||
await channel._on_sync_invite_fallback(response)
|
||||
|
||||
assert client.join_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_sets_typing_for_allowed_sender() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
|
||||
@@ -81,7 +81,6 @@ from nanobot.webui.session_access import (
|
||||
WebuiSessionAccess,
|
||||
session_mentions_runtime_context,
|
||||
)
|
||||
from nanobot.webui.sidebar_state import write_webui_sidebar_state
|
||||
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
|
||||
@@ -776,30 +775,6 @@ class WebSocketChannel(BaseChannel):
|
||||
await self._send_event(connection, "attached", chat_id=cid)
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
return
|
||||
if t == "set_sidebar_state":
|
||||
if connection not in self._webui_connections:
|
||||
await self._send_event(connection, "error", detail="access_denied")
|
||||
return
|
||||
state = envelope.get("state")
|
||||
if not isinstance(state, dict):
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="invalid_sidebar_state",
|
||||
)
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
write_webui_sidebar_state,
|
||||
cast(dict[str, Any], state),
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="invalid_sidebar_state",
|
||||
)
|
||||
return
|
||||
if t == "set_workspace_scope":
|
||||
cid = envelope.get("chat_id")
|
||||
if not _is_valid_chat_id(cid):
|
||||
|
||||
@@ -559,34 +559,6 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
|
||||
assert client_connection not in channel._webui_connections
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel._webui_connections.add(conn)
|
||||
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
||||
envelope = {
|
||||
"type": "set_sidebar_state",
|
||||
"state": {
|
||||
"session_order": session_order,
|
||||
"view": {"sort": "manual"},
|
||||
},
|
||||
}
|
||||
assert len(json.dumps(envelope).encode()) > 8_192
|
||||
|
||||
await channel._dispatch_envelope(conn, "webui-client", envelope)
|
||||
|
||||
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
|
||||
assert saved["session_order"] == session_order
|
||||
assert saved["view"]["sort"] == "manual"
|
||||
conn.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
|
||||
@@ -19,6 +19,11 @@ from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
from nanobot.optional_features import InstallResult
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
RuntimeContextBlock,
|
||||
append_runtime_context,
|
||||
)
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
@@ -251,7 +256,7 @@ async def test_bootstrap_returns_token_for_localhost(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_list_requires_bearer_token(
|
||||
async def test_sessions_routes_require_bearer_token(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
sm = _seed_session(tmp_path, key="websocket:abc")
|
||||
@@ -273,26 +278,14 @@ async def test_sessions_list_requires_bearer_token(
|
||||
# Server stays an opaque source: filesystem paths must not leak to the wire.
|
||||
assert all("path" not in s for s in listing.json()["sessions"])
|
||||
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_session_messages_route_is_not_exposed(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
sm = _seed_session(tmp_path, key="websocket:legacy")
|
||||
channel = _ch(bus, session_manager=sm, port=29919)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await _http_get(
|
||||
"http://127.0.0.1:29919/api/sessions/websocket:legacy/messages",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
msgs = await _http_get(
|
||||
"http://127.0.0.1:29902/api/sessions/websocket:abc/messages",
|
||||
headers=auth,
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert msgs.status_code == 200
|
||||
body = msgs.json()
|
||||
assert body["key"] == "websocket:abc"
|
||||
assert [m["role"] for m in body["messages"]] == ["user", "assistant"]
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
@@ -2287,7 +2280,6 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
||||
payload = {
|
||||
"pinned_keys": ["websocket:sidebar"],
|
||||
"archived_keys": ["websocket:old"],
|
||||
"session_order": ["websocket:old", "websocket:sidebar"],
|
||||
"title_overrides": {"websocket:sidebar": "Pinned work"},
|
||||
"view": {"density": "compact", "show_archived": True},
|
||||
}
|
||||
@@ -2299,7 +2291,6 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
||||
assert updated.status_code == 200
|
||||
body = updated.json()
|
||||
assert body["pinned_keys"] == ["websocket:sidebar"]
|
||||
assert body["session_order"] == ["websocket:old", "websocket:sidebar"]
|
||||
assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
|
||||
assert body["view"]["density"] == "compact"
|
||||
|
||||
@@ -2854,7 +2845,7 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_accepts_percent_encoded_websocket_keys(
|
||||
async def test_session_routes_accept_percent_encoded_websocket_keys(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
sm = _seed_session(tmp_path, key="websocket:encoded-key")
|
||||
@@ -2864,6 +2855,13 @@ async def test_session_delete_accepts_percent_encoded_websocket_keys(
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
msgs = await _http_get(
|
||||
"http://127.0.0.1:29910/api/sessions/websocket%3Aencoded-key/messages",
|
||||
headers=auth,
|
||||
)
|
||||
assert msgs.status_code == 200
|
||||
assert msgs.json()["key"] == "websocket:encoded-key"
|
||||
|
||||
path = sm._get_session_path("websocket:encoded-key")
|
||||
assert path.exists()
|
||||
deleted = await _http_get(
|
||||
@@ -2878,6 +2876,41 @@ async def test_session_delete_accepts_percent_encoded_websocket_keys(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_messages_hide_persisted_runtime_context(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
sm = SessionManager(tmp_path)
|
||||
session = sm.get_or_create("websocket:runtime-context")
|
||||
content, marker = append_runtime_context(
|
||||
"visible user text",
|
||||
[RuntimeContextBlock(source="goal", content="private goal context")],
|
||||
)
|
||||
session.add_message(
|
||||
"user",
|
||||
content,
|
||||
**{RUNTIME_CONTEXT_HISTORY_META: marker},
|
||||
)
|
||||
sm.save(session)
|
||||
channel = _ch(bus, session_manager=sm, port=29919)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await _http_get(
|
||||
"http://127.0.0.1:29919/api/sessions/websocket:runtime-context/messages",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
message = response.json()["messages"][0]
|
||||
assert message["content"] == "visible user text"
|
||||
assert RUNTIME_CONTEXT_HISTORY_META not in message
|
||||
assert "private goal context" not in response.text
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_thread_resigns_assistant_media_urls(
|
||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -3081,7 +3114,7 @@ async def test_webui_thread_negotiates_gzip_for_large_payloads(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_rejects_non_websocket_keys(
|
||||
async def test_session_routes_reject_non_websocket_keys(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
sm = _seed_many(
|
||||
@@ -3098,6 +3131,14 @@ async def test_session_delete_rejects_non_websocket_keys(
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# The webui list already hides non-websocket sessions; handcrafted URLs
|
||||
# should hit the same boundary rather than exposing or deleting them.
|
||||
msgs = await _http_get(
|
||||
"http://127.0.0.1:29909/api/sessions/cli:direct/messages",
|
||||
headers=auth,
|
||||
)
|
||||
assert msgs.status_code == 404
|
||||
|
||||
doomed = sm._get_session_path("slack:C123")
|
||||
assert doomed.exists()
|
||||
deny_delete = await _http_get(
|
||||
@@ -3112,7 +3153,7 @@ async def test_session_delete_rejects_non_websocket_keys(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_rejects_invalid_key(
|
||||
async def test_session_routes_reject_invalid_key(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
sm = _seed_session(tmp_path)
|
||||
@@ -3125,7 +3166,7 @@ async def test_session_delete_rejects_invalid_key(
|
||||
# Invalid characters in the key -> regex match fails -> 404
|
||||
# (route doesn't match, falls through to channel 404).
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29904/api/sessions/bad%20key/delete",
|
||||
"http://127.0.0.1:29904/api/sessions/bad%20key/messages",
|
||||
headers=auth,
|
||||
)
|
||||
assert resp.status_code in {400, 404}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Tests for the signed ``/api/media/<sig>/<payload>`` route and WebUI replay.
|
||||
"""Tests for the signed ``/api/media/<sig>/<payload>`` route and its replay
|
||||
integration on ``/api/sessions/<key>/messages``.
|
||||
|
||||
The route is the return path for local media rendered by the WebUI. These tests
|
||||
cover URL signing and serving end-to-end plus the adversarial edges (bad
|
||||
signatures, ``..`` traversal, non-existent files, non-image types).
|
||||
The route is the return path for images attached to persisted user turns:
|
||||
:meth:`WebSocketChannel.gateway.media.sign_media_path` mints URLs during session reads,
|
||||
and :meth:`GatewayHTTPHandler._handle_media_fetch` serves the bytes back.
|
||||
These tests cover the two halves end-to-end plus the adversarial edges
|
||||
(bad signatures, ``..`` traversal, non-existent files, non-image types).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +20,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.webui.gateway_services import build_gateway_services
|
||||
from nanobot.webui.media_api import (
|
||||
b64url_decode,
|
||||
@@ -494,3 +497,91 @@ async def test_media_route_serves_svg_with_strict_csp(
|
||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
|
||||
assert "sandbox" in resp.headers.get("content-security-policy", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/sessions/<key>/messages: media_urls hydration on session read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_messages_exposes_signed_media_urls(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""The read path must map persisted ``media`` paths onto signed URLs
|
||||
and strip the raw path — the client never learns the server's layout."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
img = media / "u.png"
|
||||
img.write_bytes(_PNG_BYTES)
|
||||
|
||||
sm = SessionManager(tmp_path / "ws_state")
|
||||
sess = Session(key="websocket:media-hydrate")
|
||||
sess.add_message("user", "look at this", media=[str(img)])
|
||||
sess.add_message("assistant", "nice")
|
||||
sm.save(sess)
|
||||
|
||||
channel = _ch(bus, session_manager=sm, port=29925)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29925/api/sessions/websocket:media-hydrate/messages",
|
||||
headers=auth,
|
||||
)
|
||||
body = resp.json()
|
||||
# The signed URL round-trips end-to-end: fetching it yields the same bytes.
|
||||
user_msg = next(m for m in body["messages"] if m["role"] == "user")
|
||||
urls = user_msg["media_urls"]
|
||||
assert isinstance(urls, list) and len(urls) == 1
|
||||
assert urls[0]["name"] == "u.png"
|
||||
assert urls[0]["url"].startswith("/api/media/")
|
||||
# Raw paths must not leak to the wire.
|
||||
assert "media" not in user_msg
|
||||
|
||||
# And the URL actually works.
|
||||
fetched = await _http_get(f"http://127.0.0.1:29925{urls[0]['url']}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.content == _PNG_BYTES
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_messages_skips_vanished_media(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Paths that no longer resolve inside the media root produce no URL —
|
||||
the message is still delivered, just without the preview."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
|
||||
sm = SessionManager(tmp_path / "ws_state")
|
||||
sess = Session(key="websocket:vanished")
|
||||
sess.add_message("user", "missing pic", media=[str(media / "absent.png")])
|
||||
sm.save(sess)
|
||||
|
||||
channel = _ch(bus, session_manager=sm, port=29926)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29926/api/sessions/websocket:vanished/messages",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
user_msg = next(m for m in resp.json()["messages"] if m["role"] == "user")
|
||||
# absent.png lives inside the media root so it *does* get a signed
|
||||
# URL (we don't stat the file at signing time — that would slow
|
||||
# the listing). Fetching the URL is where the 404 surfaces.
|
||||
urls = user_msg.get("media_urls") or []
|
||||
assert len(urls) == 1
|
||||
fetched = await _http_get(f"http://127.0.0.1:29926{urls[0]['url']}")
|
||||
assert fetched.status_code == 404
|
||||
assert "media" not in user_msg
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -47,10 +47,7 @@ class WeixinConnectStore:
|
||||
if not session_id:
|
||||
raise ChannelConnectError("missing WeChat connect session")
|
||||
if action == "poll":
|
||||
return await self.poll(
|
||||
session_id,
|
||||
verify_code=(query_first(query, "verify_code") or "").strip(),
|
||||
)
|
||||
return await self.poll(session_id)
|
||||
if action == "cancel":
|
||||
return await self.cancel(session_id)
|
||||
raise ChannelConnectError(f"unsupported WeChat connect action: {action}", status=404)
|
||||
@@ -94,7 +91,7 @@ class WeixinConnectStore:
|
||||
)
|
||||
return self._start_payload(self._sessions[session_id])
|
||||
|
||||
async def poll(self, session_id: str, *, verify_code: str = "") -> dict[str, Any]:
|
||||
async def poll(self, session_id: str) -> dict[str, Any]:
|
||||
await self._cleanup()
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
@@ -108,7 +105,6 @@ class WeixinConnectStore:
|
||||
status_data = await session.channel.connect_poll_qr_code(
|
||||
base_url=session.current_poll_base_url,
|
||||
qrcode_id=session.qrcode_id,
|
||||
verify_code=verify_code,
|
||||
)
|
||||
except Exception as exc:
|
||||
if session.channel.connect_poll_error_is_retryable(exc):
|
||||
@@ -124,8 +120,6 @@ class WeixinConnectStore:
|
||||
|
||||
status_payload = status_data
|
||||
status = status_payload.get("status", "")
|
||||
from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT
|
||||
|
||||
if status == "confirmed":
|
||||
if self._sessions.get(session_id) is not session:
|
||||
return {
|
||||
@@ -163,66 +157,9 @@ class WeixinConnectStore:
|
||||
)
|
||||
return self._pending_payload(session)
|
||||
|
||||
if status == "need_verifycode":
|
||||
return self._pending_payload(
|
||||
session,
|
||||
challenge="verify_code",
|
||||
message=(
|
||||
"That verification code did not match. Enter the new number shown in WeChat."
|
||||
if verify_code
|
||||
else "Enter the number shown in WeChat to continue."
|
||||
),
|
||||
verification_failed=bool(verify_code),
|
||||
)
|
||||
|
||||
if status == "verify_code_blocked":
|
||||
session.refresh_count += 1
|
||||
if session.refresh_count > MAX_QR_REFRESH_COUNT:
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"message": "Too many incorrect verification attempts. Try again later.",
|
||||
}
|
||||
try:
|
||||
session.qrcode_id, session.qr_url = (
|
||||
await session.channel.connect_fetch_qr_code()
|
||||
)
|
||||
except Exception as exc:
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"message": f"Could not refresh WeChat QR code: {exc}",
|
||||
}
|
||||
session.current_poll_base_url = session.channel.connect_base_url
|
||||
return self._pending_payload(
|
||||
session,
|
||||
message="Verification was blocked. Scan the refreshed QR code to try again.",
|
||||
)
|
||||
|
||||
if status == "binded_redirect":
|
||||
if not session.channel.connect_load_state():
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"message": (
|
||||
"WeChat reports an existing binding, but no local credentials were found."
|
||||
),
|
||||
}
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "succeeded",
|
||||
"message": "WeChat is already connected to this nanobot instance.",
|
||||
}
|
||||
|
||||
if status == "expired":
|
||||
from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT
|
||||
|
||||
session.refresh_count += 1
|
||||
if session.refresh_count > MAX_QR_REFRESH_COUNT:
|
||||
self._sessions.pop(session_id, None)
|
||||
@@ -301,25 +238,15 @@ class WeixinConnectStore:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _pending_payload(
|
||||
session: WeixinConnectSession,
|
||||
*,
|
||||
challenge: str = "",
|
||||
message: str = "Waiting for WeChat scan.",
|
||||
verification_failed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
def _pending_payload(session: WeixinConnectSession) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"status": "pending",
|
||||
"qr_url": session.qr_url,
|
||||
"interval_ms": 2000,
|
||||
"expires_at_ms": int((session.created_wall + 600) * 1000),
|
||||
"message": message,
|
||||
"message": "Waiting for WeChat scan.",
|
||||
}
|
||||
if challenge:
|
||||
payload["challenge"] = challenge
|
||||
payload["verification_failed"] = verification_failed
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["WeixinConnectStore"]
|
||||
|
||||
@@ -10,20 +10,6 @@ SETUP_SPEC = ChannelSetupSpec(
|
||||
fields={
|
||||
"token": field("secret"),
|
||||
"allowFrom": field("list"),
|
||||
"baseUrl": field(default="https://ilinkai.weixin.qq.com"),
|
||||
"cdnBaseUrl": field(default="https://novac2c.cdn.weixin.qq.com/c2c"),
|
||||
"routeTag": field(),
|
||||
"stateDir": field(),
|
||||
"pollTimeout": field("int", default=35),
|
||||
"sendProgress": field("bool", default=False),
|
||||
"sendToolHints": field("bool", default=False),
|
||||
"replyProgressMessages": field("bool", default=False),
|
||||
"replyProgressMaxMessages": field("int", default=2),
|
||||
"contextMessageBudget": field("int", default=8),
|
||||
"streaming": field("bool", default=True),
|
||||
"blockStreaming": field("bool", default=False),
|
||||
"blockStreamingMinChars": field("int", default=1200),
|
||||
"blockStreamingMaxMessages": field("int", default=3),
|
||||
},
|
||||
required=(required("token"),),
|
||||
official_url="https://weixin.qq.com/",
|
||||
|
||||
+163
-1004
File diff suppressed because it is too large
Load Diff
@@ -147,129 +147,3 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
|
||||
assert cancelled["status"] == "cancelled"
|
||||
assert completed["status"] == "cancelled"
|
||||
assert not (state_dir / "account.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_connect_store_handles_verification_code(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-verify", "https://qr.example/verify"
|
||||
|
||||
responses = [
|
||||
{"status": "need_verifycode"},
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "verified-token",
|
||||
"ilink_user_id": "wx-user",
|
||||
},
|
||||
]
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
self: WeixinChannel,
|
||||
*,
|
||||
params: dict[str, Any],
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, str]:
|
||||
if len(responses) == 1:
|
||||
assert params == {"qrcode": "qr-verify", "verify_code": "1234"}
|
||||
return responses.pop(0)
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
started = await store.start()
|
||||
challenged = await store.poll(started["session_id"])
|
||||
completed = await store.handle(
|
||||
"poll",
|
||||
{
|
||||
"session_id": [started["session_id"]],
|
||||
"verify_code": ["1234"],
|
||||
},
|
||||
)
|
||||
|
||||
assert challenged["status"] == "pending"
|
||||
assert challenged["challenge"] == "verify_code"
|
||||
assert completed["status"] == "succeeded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_connect_store_treats_existing_binding_as_success(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
state_dir.mkdir()
|
||||
(state_dir / "account.json").write_text(
|
||||
json.dumps({"token": "working-token"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-existing", "https://qr.example/existing"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
self: WeixinChannel,
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, str]:
|
||||
return {"status": "binded_redirect"}
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
started = await store.start(force=True)
|
||||
completed = await store.poll(started["session_id"])
|
||||
|
||||
assert completed["status"] == "succeeded"
|
||||
assert "already connected" in completed["message"]
|
||||
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_connect_store_rejects_existing_binding_without_local_credentials(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-missing", "https://qr.example/missing"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
self: WeixinChannel,
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, str]:
|
||||
return {"status": "binded_redirect"}
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
started = await store.start(force=True)
|
||||
completed = await store.poll(started["session_id"])
|
||||
|
||||
assert completed["status"] == "failed"
|
||||
assert "no local credentials" in completed["message"]
|
||||
|
||||
@@ -17,7 +17,6 @@ from nanobot.channels.weixin.runtime import (
|
||||
ITEM_TEXT,
|
||||
MESSAGE_TYPE_BOT,
|
||||
WEIXIN_CHANNEL_VERSION,
|
||||
WeixinAuthError,
|
||||
WeixinChannel,
|
||||
WeixinConfig,
|
||||
_decrypt_aes_ecb,
|
||||
@@ -68,11 +67,11 @@ def test_make_headers_includes_route_tag_when_configured() -> None:
|
||||
assert headers["Authorization"] == "Bearer token"
|
||||
assert headers["SKRouteTag"] == "123"
|
||||
assert headers["iLink-App-Id"] == "bot"
|
||||
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (4 << 8) | 6)
|
||||
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 1)
|
||||
|
||||
|
||||
def test_channel_version_matches_reference_plugin_version() -> None:
|
||||
assert WEIXIN_CHANNEL_VERSION == "2.4.6"
|
||||
assert WEIXIN_CHANNEL_VERSION == "2.1.1"
|
||||
|
||||
|
||||
def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
||||
@@ -160,29 +159,6 @@ def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) ->
|
||||
assert saved["get_updates_buf"] == "current-cursor"
|
||||
|
||||
|
||||
def test_save_state_preserves_qr_replacement_of_configured_token(tmp_path) -> None:
|
||||
config = WeixinConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
token="configured-token",
|
||||
state_dir=str(tmp_path),
|
||||
)
|
||||
old_runtime = WeixinChannel(config, MessageBus())
|
||||
old_runtime._token = "configured-token"
|
||||
|
||||
replacement = WeixinChannel(config, MessageBus())
|
||||
replacement.connect_commit_account(
|
||||
token="replacement-token",
|
||||
base_url="https://new.example",
|
||||
)
|
||||
|
||||
old_runtime._save_state()
|
||||
|
||||
saved = json.loads((tmp_path / "account.json").read_text())
|
||||
assert saved["token"] == "replacement-token"
|
||||
assert saved["base_url"] == "https://new.example"
|
||||
|
||||
|
||||
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
@@ -466,15 +442,15 @@ async def test_send_without_context_token_raises() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_raises_when_authentication_is_required() -> None:
|
||||
async def test_send_raises_when_session_is_paused() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-2"
|
||||
channel._auth_required = True
|
||||
channel._pause_session(60)
|
||||
channel._send_text = AsyncMock()
|
||||
|
||||
with pytest.raises(WeixinAuthError, match="bot token is stale"):
|
||||
with pytest.raises(RuntimeError, match="session paused"):
|
||||
await channel.send(
|
||||
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
||||
)
|
||||
@@ -549,21 +525,20 @@ async def test_send_still_sends_text_when_typing_ticket_missing() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_requires_login_on_stale_token() -> None:
|
||||
async def test_poll_once_pauses_session_on_expired_errcode() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = SimpleNamespace(timeout=None)
|
||||
channel._token = "token"
|
||||
channel._api_post = AsyncMock(return_value={"ret": 0, "errcode": -14, "errmsg": "expired"})
|
||||
|
||||
with pytest.raises(WeixinAuthError, match="no replacement credentials"):
|
||||
await channel._poll_once()
|
||||
await channel._poll_once()
|
||||
|
||||
assert channel._auth_required is True
|
||||
assert channel._session_pause_remaining_s() > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_reloads_refreshed_state_after_stale_token(
|
||||
tmp_path,
|
||||
async def test_poll_once_reloads_refreshed_state_after_session_pause(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
@@ -575,13 +550,8 @@ async def test_poll_once_reloads_refreshed_state_after_stale_token(
|
||||
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._client = object()
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{"ret": 0, "errcode": -14, "errmsg": "stale"},
|
||||
{"ret": 0},
|
||||
]
|
||||
)
|
||||
channel._session_pause_until = time.time() + 10
|
||||
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
||||
|
||||
await channel._poll_once()
|
||||
|
||||
@@ -590,8 +560,8 @@ async def test_poll_once_reloads_refreshed_state_after_stale_token(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_keeps_explicit_token_and_requires_login(
|
||||
tmp_path,
|
||||
async def test_poll_once_keeps_explicit_token_after_session_pause(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(
|
||||
@@ -607,121 +577,13 @@ async def test_poll_once_keeps_explicit_token_and_requires_login(
|
||||
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._client = object()
|
||||
channel._api_post = AsyncMock(
|
||||
return_value={"ret": 0, "errcode": -14, "errmsg": "stale"}
|
||||
)
|
||||
|
||||
with pytest.raises(WeixinAuthError, match="no replacement credentials"):
|
||||
await channel._poll_once()
|
||||
|
||||
assert channel._token == "configured-token"
|
||||
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_loads_qr_replacement_for_configured_token(tmp_path) -> None:
|
||||
config = WeixinConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
token="configured-token",
|
||||
state_dir=str(tmp_path),
|
||||
)
|
||||
replacement = WeixinChannel(config, MessageBus())
|
||||
replacement.connect_commit_account(
|
||||
token="replacement-token",
|
||||
base_url="https://new.example",
|
||||
)
|
||||
|
||||
channel = WeixinChannel(config, MessageBus())
|
||||
channel._token = "configured-token"
|
||||
channel._client = object()
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{"ret": 0, "errcode": -14, "errmsg": "stale"},
|
||||
{"ret": 0},
|
||||
]
|
||||
)
|
||||
channel._session_pause_until = time.time() + 10
|
||||
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
||||
|
||||
await channel._poll_once()
|
||||
|
||||
assert channel._token == "replacement-token"
|
||||
assert channel.config.base_url == "https://new.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_uses_qr_replacement_for_configured_token(tmp_path) -> None:
|
||||
config = WeixinConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
token="configured-token",
|
||||
state_dir=str(tmp_path),
|
||||
)
|
||||
connector = WeixinChannel(config, MessageBus())
|
||||
connector.connect_commit_account(
|
||||
token="replacement-token",
|
||||
base_url="https://new.example",
|
||||
)
|
||||
|
||||
channel = WeixinChannel(config, MessageBus())
|
||||
observed_tokens: list[str] = []
|
||||
|
||||
async def stop_after_first_poll() -> None:
|
||||
observed_tokens.append(channel._token)
|
||||
channel._running = False
|
||||
|
||||
channel._notify_lifecycle = AsyncMock() # type: ignore[method-assign]
|
||||
channel._poll_once = stop_after_first_poll # type: ignore[method-assign]
|
||||
|
||||
await channel.start()
|
||||
await channel.stop()
|
||||
|
||||
assert observed_tokens == ["replacement-token"]
|
||||
assert channel.config.base_url == "https://new.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_surfaces_actionable_weixin_auth_error_without_traceback(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.channels import manager as manager_mod
|
||||
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
channel.start = AsyncMock( # type: ignore[method-assign]
|
||||
side_effect=WeixinAuthError(
|
||||
"getupdates",
|
||||
errcode=-14,
|
||||
errmsg="stale",
|
||||
)
|
||||
)
|
||||
errors: list[str] = []
|
||||
tracebacks: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
manager_mod.logger,
|
||||
"error",
|
||||
lambda message, *args: errors.append(message.format(*args)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager_mod.logger,
|
||||
"exception",
|
||||
lambda message, *args: tracebacks.append(message.format(*args)),
|
||||
)
|
||||
manager = manager_mod.ChannelManager.__new__(manager_mod.ChannelManager)
|
||||
manager._channel_errors = {}
|
||||
|
||||
await manager._start_channel("weixin", channel)
|
||||
|
||||
assert manager._channel_errors["weixin"] == (
|
||||
"WeChat login expired. Scan again to reconnect."
|
||||
)
|
||||
assert errors == [
|
||||
"Failed to start channel weixin: WeChat login expired. Scan again to reconnect."
|
||||
]
|
||||
assert tracebacks == []
|
||||
assert channel._token == "configured-token"
|
||||
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -730,9 +592,9 @@ async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda **_kwargs: None
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._api_post = AsyncMock(
|
||||
channel._api_get = AsyncMock(
|
||||
side_effect=[
|
||||
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
||||
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
||||
@@ -765,7 +627,7 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes(
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._api_post = AsyncMock(
|
||||
channel._api_get = AsyncMock(
|
||||
side_effect=[
|
||||
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
||||
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
||||
@@ -793,7 +655,7 @@ async def test_qr_login_switches_polling_base_url_on_redirect_status(
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda **_kwargs: None
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||
|
||||
@@ -827,7 +689,7 @@ async def test_qr_login_redirect_without_host_keeps_current_polling_base_url(
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda **_kwargs: None
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||
|
||||
@@ -861,7 +723,7 @@ async def test_qr_login_resets_redirect_base_url_after_qr_refresh(
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda **_kwargs: None
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(side_effect=[("qr-1", "url-1"), ("qr-2", "url-2")])
|
||||
|
||||
@@ -1153,7 +1015,7 @@ async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers(
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda **_kwargs: None
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||
|
||||
@@ -1183,7 +1045,7 @@ async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers(
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda **_kwargs: None
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||
|
||||
@@ -1218,32 +1080,6 @@ def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None:
|
||||
assert decrypted == plaintext
|
||||
|
||||
|
||||
def test_missing_aes_dependency_recommends_weixin_plugin(monkeypatch) -> None:
|
||||
real_import = __import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name.startswith(("Crypto", "cryptography")):
|
||||
raise ImportError("missing AES dependency")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
warnings: list[str] = []
|
||||
monkeypatch.setattr("builtins.__import__", fake_import)
|
||||
monkeypatch.setattr(
|
||||
weixin_mod.logger,
|
||||
"warning",
|
||||
lambda message, *args: warnings.append(message.format(*args)),
|
||||
)
|
||||
key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg=="
|
||||
data = b"unencrypted media"
|
||||
|
||||
assert _encrypt_aes_ecb(data, key_b64) == data
|
||||
assert _decrypt_aes_ecb(data, key_b64) == data
|
||||
assert warnings == [
|
||||
"Cannot encrypt media. Run `nanobot plugins enable weixin` to install WeChat support.",
|
||||
"Cannot decrypt media. Run `nanobot plugins enable weixin` to install WeChat support.",
|
||||
]
|
||||
|
||||
|
||||
class _DummyDownloadResponse:
|
||||
def __init__(self, content: bytes, status_code: int = 200) -> None:
|
||||
self.content = content
|
||||
@@ -1576,7 +1412,7 @@ async def test_send_text_raises_on_api_error() -> None:
|
||||
return_value={"errcode": -14, "errmsg": "session expired"}
|
||||
)
|
||||
|
||||
with pytest.raises(WeixinAuthError, match="WeChat sendmessage failed.*errcode=-14"):
|
||||
with pytest.raises(RuntimeError, match="WeChat send text error.*-14"):
|
||||
await channel._send_text("wx-user", "hello", "ctx-expired")
|
||||
|
||||
channel._api_post.assert_awaited_once()
|
||||
@@ -1609,7 +1445,7 @@ async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None:
|
||||
return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="WeChat sendmessage failed.*ret=-100.*errcode=0"):
|
||||
with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"):
|
||||
await channel._send_text("wx-user", "hello", "ctx-ok")
|
||||
|
||||
channel._api_post.assert_awaited_once()
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.channels.weixin.manifest import SETUP_SPEC
|
||||
from nanobot.channels.weixin.runtime import (
|
||||
ITEM_TOOL_CALL_RESULT,
|
||||
ITEM_TOOL_CALL_START,
|
||||
WEIXIN_MAX_MESSAGE_LEN,
|
||||
WeixinAPIError,
|
||||
WeixinAuthError,
|
||||
WeixinChannel,
|
||||
WeixinConfig,
|
||||
WeixinQuotaError,
|
||||
sanitize_weixin_markdown,
|
||||
split_weixin_message,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
def _channel(**config: object) -> WeixinChannel:
|
||||
return WeixinChannel(
|
||||
WeixinConfig.model_validate(
|
||||
{"enabled": True, "allowFrom": ["*"], **config}
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
|
||||
def _ready_channel(**config: object) -> WeixinChannel:
|
||||
channel = _channel(**config)
|
||||
channel._client = object()
|
||||
channel._token = "bot-token"
|
||||
channel._context_tokens["wx-user"] = "ctx-1"
|
||||
channel._context_token_at["wx-user"] = time.time()
|
||||
channel._typing_tickets["wx-user"] = {
|
||||
"ticket": "",
|
||||
"next_fetch_at": time.time() + 3600,
|
||||
}
|
||||
return channel
|
||||
|
||||
|
||||
def test_weixin_defaults_protect_context_quota() -> None:
|
||||
config = WeixinConfig()
|
||||
|
||||
assert WEIXIN_MAX_MESSAGE_LEN == 1800
|
||||
assert config.send_progress is False
|
||||
assert config.send_tool_hints is False
|
||||
assert config.reply_progress_messages is False
|
||||
assert config.context_message_budget == 8
|
||||
assert config.block_streaming is False
|
||||
|
||||
|
||||
def test_weixin_webui_manifest_covers_runtime_configuration() -> None:
|
||||
runtime_fields = set(WeixinConfig().model_dump(mode="json", by_alias=True))
|
||||
|
||||
assert set(SETUP_SPEC.fields) == runtime_fields - {"enabled"}
|
||||
|
||||
|
||||
def test_reply_progress_opt_in_enables_progress_transport() -> None:
|
||||
config = WeixinConfig(reply_progress_messages=True)
|
||||
|
||||
assert config.send_progress is True
|
||||
assert config.send_tool_hints is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("section", "send_progress", "send_tool_hints"),
|
||||
[
|
||||
({"enabled": True}, False, False),
|
||||
({"enabled": True, "replyProgressMessages": True}, True, True),
|
||||
({"enabled": True, "sendProgress": True, "sendToolHints": False}, True, False),
|
||||
],
|
||||
)
|
||||
def test_channel_manager_preserves_weixin_quota_defaults(
|
||||
section: dict[str, object],
|
||||
send_progress: bool,
|
||||
send_tool_hints: bool,
|
||||
) -> None:
|
||||
manager = ChannelManager.__new__(ChannelManager)
|
||||
manager.config = Config.model_validate({"channels": {"weixin": section}})
|
||||
manager.bus = MessageBus()
|
||||
|
||||
channel = manager._build_channel("weixin", WeixinChannel, section)
|
||||
|
||||
assert channel.send_progress is send_progress
|
||||
assert channel.send_tool_hints is send_tool_hints
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_manager_does_not_retry_permanent_weixin_error(monkeypatch) -> None:
|
||||
manager = ChannelManager.__new__(ChannelManager)
|
||||
manager.config = Config.model_validate({"channels": {"sendMaxRetries": 3}})
|
||||
manager.bus = MessageBus()
|
||||
channel = _channel()
|
||||
channel.send = AsyncMock(
|
||||
side_effect=WeixinAPIError(
|
||||
"sendmessage",
|
||||
errcode=-1,
|
||||
errmsg="business rejection",
|
||||
retryable=False,
|
||||
)
|
||||
)
|
||||
sleep = AsyncMock()
|
||||
monkeypatch.setattr("nanobot.channels.manager.asyncio.sleep", sleep)
|
||||
|
||||
await manager._send_with_retry(
|
||||
channel,
|
||||
OutboundMessage(channel="weixin", chat_id="wx-user", content="test"),
|
||||
)
|
||||
|
||||
channel.send.assert_awaited_once()
|
||||
sleep.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_http_clients_ignore_system_proxy(tmp_path, monkeypatch) -> None:
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
class FakeClient:
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
def make_client(**kwargs: object) -> FakeClient:
|
||||
captured.append(kwargs)
|
||||
return FakeClient()
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.weixin.runtime.httpx.AsyncClient", make_client)
|
||||
|
||||
connect_channel = _channel(stateDir=str(tmp_path / "connect"))
|
||||
connect_channel.connect_open_client()
|
||||
await connect_channel.connect_close_client()
|
||||
|
||||
login_channel = _channel(stateDir=str(tmp_path / "login"))
|
||||
login_channel._qr_login = AsyncMock(return_value=True)
|
||||
assert await login_channel.login() is True
|
||||
|
||||
start_channel = _channel(token="configured-token", stateDir=str(tmp_path / "start"))
|
||||
|
||||
async def stop_after_poll() -> None:
|
||||
start_channel._running = False
|
||||
|
||||
start_channel._notify_lifecycle = AsyncMock()
|
||||
start_channel._poll_once = AsyncMock(side_effect=stop_after_poll)
|
||||
await start_channel.start()
|
||||
await start_channel.stop()
|
||||
|
||||
assert len(captured) == 3
|
||||
assert all(kwargs["trust_env"] is False for kwargs in captured)
|
||||
|
||||
|
||||
def test_markdown_sanitizer_preserves_code_and_escapes_bare_angles() -> None:
|
||||
content = "before <tag> `x<y>`\n```python\na<b\n```\n"
|
||||
|
||||
sanitized = sanitize_weixin_markdown(content)
|
||||
|
||||
assert "before <tag>" in sanitized
|
||||
assert "`x<y>`" in sanitized
|
||||
assert "a<b" in sanitized
|
||||
assert "![drop]" not in sanitized
|
||||
|
||||
|
||||
def test_markdown_split_balances_fences_and_stays_within_limit() -> None:
|
||||
chunks = split_weixin_message("```python\n" + ("x" * 4000) + "\n```")
|
||||
|
||||
assert len(chunks) >= 3
|
||||
assert all(len(chunk) <= WEIXIN_MAX_MESSAGE_LEN for chunk in chunks)
|
||||
assert all(chunk.count("```") % 2 == 0 for chunk in chunks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_fetch_posts_known_local_tokens(tmp_path) -> None:
|
||||
state_dir = tmp_path / "weixin"
|
||||
state_dir.mkdir()
|
||||
(state_dir / "account.json").write_text(
|
||||
json.dumps({"token": "persisted-token"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel = _channel(stateDir=str(state_dir))
|
||||
channel._api_post = AsyncMock(
|
||||
return_value={"qrcode": "qr-1", "qrcode_img_content": "https://qr.test/1"}
|
||||
)
|
||||
|
||||
assert await channel._fetch_qr_code() == ("qr-1", "https://qr.test/1")
|
||||
channel._api_post.assert_awaited_once_with(
|
||||
"ilink/bot/get_bot_qrcode?bot_type=3",
|
||||
{"local_token_list": ["persisted-token"]},
|
||||
auth=False,
|
||||
include_base_info=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_fetch_retries_without_rejected_local_tokens(tmp_path) -> None:
|
||||
state_dir = tmp_path / "weixin"
|
||||
state_dir.mkdir()
|
||||
(state_dir / "account.json").write_text(
|
||||
json.dumps({"token": "invalid-token"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel = _channel(stateDir=str(state_dir))
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{"ret": -3},
|
||||
{"ret": 0, "qrcode": "qr-1", "qrcode_img_content": "https://qr.test/1"},
|
||||
]
|
||||
)
|
||||
|
||||
assert await channel._fetch_qr_code() == ("qr-1", "https://qr.test/1")
|
||||
assert [call.args[1] for call in channel._api_post.await_args_list] == [
|
||||
{"local_token_list": ["invalid-token"]},
|
||||
{"local_token_list": []},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_fetch_does_not_retry_invalid_request_without_local_tokens(tmp_path) -> None:
|
||||
channel = _channel(stateDir=str(tmp_path / "weixin"))
|
||||
channel._api_post = AsyncMock(return_value={"ret": -3})
|
||||
|
||||
with pytest.raises(WeixinAPIError, match="get_bot_qrcode failed.*ret=-3"):
|
||||
await channel._fetch_qr_code()
|
||||
|
||||
channel._api_post.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_notifications_are_best_effort() -> None:
|
||||
channel = _ready_channel()
|
||||
channel._api_post = AsyncMock(return_value={"ret": 0})
|
||||
|
||||
await channel._notify_lifecycle("start")
|
||||
await channel._notify_lifecycle("stop")
|
||||
|
||||
assert [call.args[0] for call in channel._api_post.await_args_list] == [
|
||||
"ilink/bot/msg/notifystart",
|
||||
"ilink/bot/msg/notifystop",
|
||||
]
|
||||
|
||||
|
||||
def test_business_errors_have_explicit_retry_contracts() -> None:
|
||||
channel = _channel()
|
||||
|
||||
with pytest.raises(WeixinQuotaError) as quota:
|
||||
channel._raise_for_api_error("sendmessage", {"ret": -2})
|
||||
with pytest.raises(WeixinAuthError) as auth:
|
||||
channel._raise_for_api_error("getupdates", {"errcode": -14})
|
||||
with pytest.raises(WeixinAPIError) as rejected:
|
||||
channel._raise_for_api_error("sendmessage", {"ret": -100})
|
||||
|
||||
assert channel.should_retry_send_error(quota.value) is False
|
||||
assert channel.should_retry_send_error(auth.value) is False
|
||||
assert channel.should_retry_send_error(rejected.value) is False
|
||||
assert channel.should_retry_send_error(httpx.ReadTimeout("slow")) is True
|
||||
|
||||
request = httpx.Request("POST", "https://ilinkai.weixin.qq.com/send")
|
||||
for status_code in (408, 425, 429, 503):
|
||||
response = httpx.Response(status_code, request=request)
|
||||
error = httpx.HTTPStatusError(
|
||||
"retryable response",
|
||||
request=request,
|
||||
response=response,
|
||||
)
|
||||
assert channel.should_retry_send_error(error) is True
|
||||
|
||||
rejected_response = httpx.Response(400, request=request)
|
||||
rejected_http = httpx.HTTPStatusError(
|
||||
"bad request",
|
||||
request=request,
|
||||
response=rejected_response,
|
||||
)
|
||||
assert channel.should_retry_send_error(rejected_http) is False
|
||||
|
||||
|
||||
def test_error_classification_checks_ret_and_errcode_independently() -> None:
|
||||
channel = _channel()
|
||||
|
||||
with pytest.raises(WeixinQuotaError):
|
||||
channel._raise_for_api_error(
|
||||
"sendmessage",
|
||||
{"ret": -2, "errcode": -100},
|
||||
)
|
||||
with pytest.raises(WeixinAuthError):
|
||||
channel._raise_for_api_error(
|
||||
"getupdates",
|
||||
{"ret": -14, "errcode": -100},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_cancels_inflight_long_poll() -> None:
|
||||
channel = _channel(token="configured-token")
|
||||
poll_started = asyncio.Event()
|
||||
poll_cancelled = asyncio.Event()
|
||||
|
||||
class FakeClient:
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
async def blocking_poll() -> None:
|
||||
poll_started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
poll_cancelled.set()
|
||||
raise
|
||||
|
||||
channel._new_http_client = lambda _timeout: FakeClient() # type: ignore[method-assign]
|
||||
channel._notify_lifecycle = AsyncMock()
|
||||
channel._poll_once = blocking_poll # type: ignore[method-assign]
|
||||
|
||||
start_task = asyncio.create_task(channel.start())
|
||||
await asyncio.wait_for(poll_started.wait(), timeout=1)
|
||||
await asyncio.wait_for(channel.stop(), timeout=1)
|
||||
await asyncio.wait_for(start_task, timeout=1)
|
||||
|
||||
assert poll_cancelled.is_set()
|
||||
assert channel._poll_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_reuses_client_id_and_skips_completed_chunks() -> None:
|
||||
channel = _ready_channel()
|
||||
request = httpx.Request("POST", "https://ilinkai.weixin.qq.com/ilink/bot/sendmessage")
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{"ret": 0},
|
||||
httpx.ReadTimeout("ambiguous timeout", request=request),
|
||||
{"ret": 0},
|
||||
]
|
||||
)
|
||||
msg = OutboundMessage(
|
||||
channel="weixin",
|
||||
chat_id="wx-user",
|
||||
content="x" * (WEIXIN_MAX_MESSAGE_LEN + 200),
|
||||
)
|
||||
|
||||
with pytest.raises(httpx.ReadTimeout):
|
||||
await channel.send(msg)
|
||||
await channel.send(msg)
|
||||
|
||||
bodies = [call.args[1] for call in channel._api_post.await_args_list]
|
||||
client_ids = [body["msg"]["client_id"] for body in bodies]
|
||||
assert client_ids[0] != client_ids[1]
|
||||
assert client_ids[1] == client_ids[2]
|
||||
assert channel._context_send_counts["ctx-1"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quota_rejection_defers_final_until_fresh_context() -> None:
|
||||
channel = _ready_channel()
|
||||
channel._api_post = AsyncMock(side_effect=[{"ret": -2}, {"ret": 0}])
|
||||
msg = OutboundMessage(
|
||||
channel="weixin",
|
||||
chat_id="wx-user",
|
||||
content="deferred answer",
|
||||
)
|
||||
|
||||
with pytest.raises(WeixinQuotaError):
|
||||
await channel.send(msg)
|
||||
first_client_id = channel._api_post.await_args_list[0].args[1]["msg"]["client_id"]
|
||||
assert "wx-user" in channel._deferred_outbound
|
||||
|
||||
channel._context_tokens["wx-user"] = "ctx-2"
|
||||
channel._context_token_at["wx-user"] = time.time()
|
||||
await channel._retry_deferred_messages("wx-user")
|
||||
|
||||
second_client_id = channel._api_post.await_args_list[1].args[1]["msg"]["client_id"]
|
||||
assert second_client_id == first_client_id
|
||||
assert "wx-user" not in channel._deferred_outbound
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_context_budget_stops_before_extra_api_call() -> None:
|
||||
channel = _ready_channel(contextMessageBudget=1)
|
||||
channel._api_post = AsyncMock(return_value={"ret": 0})
|
||||
|
||||
await channel._send_text("wx-user", "one", "ctx-1")
|
||||
with pytest.raises(WeixinQuotaError, match="local safety budget"):
|
||||
await channel._send_text("wx-user", "two", "ctx-1")
|
||||
|
||||
channel._api_post.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bounded_block_streaming_reserves_one_final_message() -> None:
|
||||
channel = _ready_channel(
|
||||
blockStreaming=True,
|
||||
blockStreamingMinChars=200,
|
||||
blockStreamingMaxMessages=3,
|
||||
)
|
||||
channel._send_text = AsyncMock()
|
||||
|
||||
await channel.send_delta("wx-user", "a" * 250, stream_id="stream-1")
|
||||
await channel.send_delta("wx-user", "b" * 250, stream_id="stream-1")
|
||||
await channel.send_delta("wx-user", "c" * 250, stream_id="stream-1")
|
||||
await channel.send_delta("wx-user", "done", stream_id="stream-1", stream_end=True)
|
||||
|
||||
assert channel._send_text.await_count == 3
|
||||
assert "stream-1" not in channel._stream_buffers
|
||||
assert "stream-1" not in channel._stream_sent_counts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_structured_progress_is_capped_and_uses_one_run_id() -> None:
|
||||
channel = _ready_channel(
|
||||
replyProgressMessages=True,
|
||||
replyProgressMaxMessages=2,
|
||||
)
|
||||
channel._send_message_item = AsyncMock()
|
||||
events = [
|
||||
{"phase": "start", "call_id": "call-1", "name": "read_file"},
|
||||
{"phase": "end", "call_id": "call-1", "name": "read_file"},
|
||||
{"phase": "start", "call_id": "call-2", "name": "exec"},
|
||||
]
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="weixin",
|
||||
chat_id="wx-user",
|
||||
content="read_file",
|
||||
event=ProgressEvent(content="read_file", tool_hint=True, tool_events=events),
|
||||
)
|
||||
)
|
||||
|
||||
assert channel._send_message_item.await_count == 2
|
||||
first = channel._send_message_item.await_args_list[0]
|
||||
second = channel._send_message_item.await_args_list[1]
|
||||
assert first.args[1]["type"] == ITEM_TOOL_CALL_START
|
||||
assert second.args[1]["type"] == ITEM_TOOL_CALL_RESULT
|
||||
assert first.kwargs["run_id"] == second.kwargs["run_id"]
|
||||
@@ -1,148 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
channelTranslator,
|
||||
type ChannelTranslator,
|
||||
} from "@/channel-plugins/i18n";
|
||||
import { channelTranslator } from "@/channel-plugins/i18n";
|
||||
import type { ChannelPluginConnectFlowProps } from "@/channel-plugins/types";
|
||||
import {
|
||||
ChannelQrConnectFlow,
|
||||
type ChannelQrConnectPendingContext,
|
||||
} from "@/components/settings/channels/ChannelQrConnectFlow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ChannelConnectPayload } from "@/lib/types";
|
||||
|
||||
type WeixinVerificationPayload = ChannelConnectPayload & {
|
||||
challenge: "verify_code";
|
||||
verification_failed?: boolean;
|
||||
};
|
||||
|
||||
export const WEIXIN_AUTH_EXPIRED_MESSAGE =
|
||||
"WeChat login expired. Scan again to reconnect.";
|
||||
|
||||
function isVerificationChallenge(
|
||||
payload: ChannelConnectPayload,
|
||||
): payload is WeixinVerificationPayload {
|
||||
return (
|
||||
"challenge" in payload
|
||||
&& payload.challenge === "verify_code"
|
||||
&& (
|
||||
!("verification_failed" in payload)
|
||||
|| typeof payload.verification_failed === "boolean"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function weixinConnectMessage(
|
||||
payload: ChannelConnectPayload,
|
||||
tx: ChannelTranslator,
|
||||
): string {
|
||||
if (payload.status === "succeeded") {
|
||||
return tx("custom.connected", "WeChat is connected.");
|
||||
}
|
||||
if (payload.status === "expired") {
|
||||
return tx("custom.expired", WEIXIN_AUTH_EXPIRED_MESSAGE);
|
||||
}
|
||||
if (payload.status === "failed") {
|
||||
return payload.message
|
||||
?? tx("custom.failed", "Unable to connect WeChat. Try again.");
|
||||
}
|
||||
if (payload.status === "cancelled") {
|
||||
return tx("custom.stopped", "WeChat login stopped.");
|
||||
}
|
||||
if (isVerificationChallenge(payload)) {
|
||||
return payload.verification_failed
|
||||
? tx(
|
||||
"custom.verifyMismatch",
|
||||
"That code did not match. Enter the new number shown in WeChat.",
|
||||
)
|
||||
: tx(
|
||||
"custom.verifyDescription",
|
||||
"Enter the number shown in WeChat to continue.",
|
||||
);
|
||||
}
|
||||
return tx("custom.waiting", "Waiting for WeChat scan...");
|
||||
}
|
||||
import { ChannelQrConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
|
||||
|
||||
export function WeixinConnectFlow({
|
||||
token,
|
||||
feature,
|
||||
idleLabel,
|
||||
connectRequestId,
|
||||
onFeaturesUpdate,
|
||||
}: ChannelPluginConnectFlowProps) {
|
||||
const { t } = useTranslation();
|
||||
const tx = channelTranslator(t, "weixin");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const authExpired = feature.runtime_error === WEIXIN_AUTH_EXPIRED_MESSAGE;
|
||||
const scanAgainLabel = t("settings.channels.scanAgain", {
|
||||
defaultValue: "Scan again",
|
||||
});
|
||||
|
||||
const renderVerification = ({
|
||||
connect,
|
||||
busy,
|
||||
poll,
|
||||
}: ChannelQrConnectPendingContext) => {
|
||||
if (!isVerificationChallenge(connect)) return null;
|
||||
return (
|
||||
<form
|
||||
className="mt-3 space-y-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const code = verificationCode.trim();
|
||||
if (!code) return;
|
||||
void poll({ verify_code: code }).then((payload) => {
|
||||
if (payload && !isVerificationChallenge(payload)) {
|
||||
setVerificationCode("");
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="text-[12px] font-semibold text-foreground">
|
||||
{tx("custom.verifyTitle", "Verification required")}
|
||||
</div>
|
||||
<p className="text-[12px] leading-5 text-muted-foreground">
|
||||
{weixinConnectMessage(connect, tx)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={verificationCode}
|
||||
onChange={(event) => setVerificationCode(event.target.value)}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder={tx("custom.verifyPlaceholder", "Code")}
|
||||
className="h-8 max-w-40"
|
||||
aria-invalid={connect.verification_failed || undefined}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
disabled={busy || !verificationCode.trim()}
|
||||
>
|
||||
{tx("custom.verifySubmit", "Verify")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ChannelQrConnectFlow
|
||||
token={token}
|
||||
channelName="weixin"
|
||||
startOptions={{ force: authExpired }}
|
||||
idleLabel={authExpired ? scanAgainLabel : idleLabel}
|
||||
idleLabel={idleLabel}
|
||||
connectRequestId={connectRequestId}
|
||||
forceOnRepeat
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
pausePolling={isVerificationChallenge}
|
||||
suppressSucceeded={feature.runtime_status === "failed"}
|
||||
renderPending={renderVerification}
|
||||
resolveMessage={(payload) => weixinConnectMessage(payload, tx)}
|
||||
labels={{
|
||||
qrAlt: tx("custom.qrAlt", "WeChat login QR code"),
|
||||
scanTitle: tx("custom.scanTitle", "Scan with WeChat"),
|
||||
@@ -154,7 +31,7 @@ export function WeixinConnectFlow({
|
||||
connected: tx("custom.connected", "WeChat is connected."),
|
||||
stopped: tx("custom.stopped", "WeChat login stopped."),
|
||||
connecting: tx("custom.connecting", "Connecting..."),
|
||||
scanAgain: scanAgainLabel,
|
||||
scanAgain: t("settings.channels.scanAgain", { defaultValue: "Scan again" }),
|
||||
connect: t("settings.channels.connect", { defaultValue: "Connect" }),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,553 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Check, ChevronDown, ExternalLink, Loader2, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { channelFieldMessageKey, channelTranslator } from "@/channel-plugins/i18n";
|
||||
import { channelLocaleMessages } from "@/channel-plugins/locale-registry";
|
||||
import type { ChannelPluginPanelProps } from "@/channel-plugins/types";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import {
|
||||
chatAppGuideUrl,
|
||||
docsUrlWithBase,
|
||||
type ChannelConfigField,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
import {
|
||||
CredentialForm,
|
||||
channelValuesForSave,
|
||||
defaultChannelFieldValues,
|
||||
} from "@/components/settings/channels/CredentialForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { normalizeLocale } from "@/i18n/config";
|
||||
import { configureChannel } from "@/lib/api";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type {
|
||||
ChannelRuntimeStatus,
|
||||
ChannelSetupContractField,
|
||||
NanobotFeatureInfo,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
||||
WeixinConnectFlow,
|
||||
} from "./WeixinConnectFlow";
|
||||
|
||||
export const WEIXIN_PRIMARY_FIELD_KEYS = [
|
||||
"channels.weixin.sendProgress",
|
||||
"channels.weixin.sendToolHints",
|
||||
"channels.weixin.streaming",
|
||||
] as const;
|
||||
|
||||
export const WEIXIN_ADVANCED_FIELD_KEYS = [
|
||||
"channels.weixin.allowFrom",
|
||||
"channels.weixin.token",
|
||||
"channels.weixin.replyProgressMessages",
|
||||
"channels.weixin.replyProgressMaxMessages",
|
||||
"channels.weixin.contextMessageBudget",
|
||||
"channels.weixin.blockStreaming",
|
||||
"channels.weixin.blockStreamingMinChars",
|
||||
"channels.weixin.blockStreamingMaxMessages",
|
||||
"channels.weixin.baseUrl",
|
||||
"channels.weixin.cdnBaseUrl",
|
||||
"channels.weixin.routeTag",
|
||||
"channels.weixin.stateDir",
|
||||
"channels.weixin.pollTimeout",
|
||||
] as const;
|
||||
|
||||
export function WeixinPanel({
|
||||
token,
|
||||
feature,
|
||||
actionKey,
|
||||
chatAppsDocsUrl,
|
||||
showBrandLogos,
|
||||
onAction,
|
||||
onFeaturesUpdate,
|
||||
}: ChannelPluginPanelProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const channelTx = channelTranslator(t, "weixin");
|
||||
const runtimeError = weixinRuntimeError(feature.runtime_error, channelTx);
|
||||
const displayName = channelTx("displayName", "WeChat");
|
||||
const enabledBusy = actionKey === `enable:${feature.name}`;
|
||||
const disabledBusy = actionKey === `disable:${feature.name}`;
|
||||
const channelBusy = enabledBusy || disabledBusy;
|
||||
const channelChecked =
|
||||
feature.runtime_status === "running" || feature.runtime_status === "starting";
|
||||
const missingSupport = feature.enabled && !feature.installed;
|
||||
const alwaysEnabled = feature.capabilities?.includes("always_enabled") ?? false;
|
||||
const toggleChecked = alwaysEnabled || channelChecked;
|
||||
const channelToggleDisabled =
|
||||
alwaysEnabled
|
||||
|| channelBusy
|
||||
|| (!feature.install_supported && !feature.installed && !feature.enabled);
|
||||
const [connectRequestId, setConnectRequestId] = useState(0);
|
||||
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
|
||||
const [touchedFields, setTouchedFields] = useState<Set<string>>(() => new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveRevision, setSaveRevision] = useState(0);
|
||||
const [attemptedRevision, setAttemptedRevision] = useState(0);
|
||||
const [saveState, setSaveState] = useState<"idle" | "saved">("idle");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const configValuesKey = JSON.stringify(feature.config_values ?? {});
|
||||
const setupFieldsKey = JSON.stringify(feature.setup?.fields ?? []);
|
||||
const configuredFields = useMemo(
|
||||
() => new Set(feature.configured_fields ?? []),
|
||||
[feature.configured_fields],
|
||||
);
|
||||
const onLabel = tx("settings.values.on", "On");
|
||||
const offLabel = tx("settings.values.off", "Off");
|
||||
const setupFields = weixinSetupFields(
|
||||
feature,
|
||||
i18n.resolvedLanguage ?? i18n.language,
|
||||
);
|
||||
const primaryFields = localizeBooleanFields(setupFields.primary, onLabel, offLabel);
|
||||
const advancedFields = localizeBooleanFields(setupFields.advanced, onLabel, offLabel);
|
||||
const editableFields = [...primaryFields, ...advancedFields];
|
||||
const docsUrl = docsUrlWithBase(chatAppGuideUrl("wechat"), chatAppsDocsUrl)
|
||||
?? chatAppGuideUrl("wechat");
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
|
||||
defaultChannelFieldValues(editableFields, feature.config_values),
|
||||
);
|
||||
const fieldValuesRef = useRef(fieldValues);
|
||||
const touchedFieldsRef = useRef(touchedFields);
|
||||
const editableFieldsRef = useRef(editableFields);
|
||||
const saveContextRef = useRef({
|
||||
token,
|
||||
enabled: feature.enabled,
|
||||
onFeaturesUpdate,
|
||||
});
|
||||
editableFieldsRef.current = editableFields;
|
||||
saveContextRef.current = {
|
||||
token,
|
||||
enabled: feature.enabled,
|
||||
onFeaturesUpdate,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const nextValues = defaultChannelFieldValues(editableFields, feature.config_values);
|
||||
for (const key of touchedFieldsRef.current) {
|
||||
nextValues[key] = fieldValuesRef.current[key] ?? "";
|
||||
}
|
||||
fieldValuesRef.current = nextValues;
|
||||
setFieldValues(nextValues);
|
||||
setVisibleSecrets({});
|
||||
}, [configValuesKey, setupFieldsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState !== "saved") return;
|
||||
const timeout = window.setTimeout(() => setSaveState("idle"), 1500);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [saveState]);
|
||||
|
||||
const saveSettings = useCallback(async (
|
||||
values: Record<string, string>,
|
||||
savedFields: Set<string>,
|
||||
) => {
|
||||
const context = saveContextRef.current;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSaveState("idle");
|
||||
try {
|
||||
const payload = await configureChannel(
|
||||
context.token,
|
||||
"weixin",
|
||||
channelValuesForSave(editableFieldsRef.current, values),
|
||||
{ enable: context.enabled },
|
||||
);
|
||||
const remainingFields = new Set(touchedFieldsRef.current);
|
||||
for (const key of savedFields) {
|
||||
if (fieldValuesRef.current[key] === values[key]) remainingFields.delete(key);
|
||||
}
|
||||
touchedFieldsRef.current = remainingFields;
|
||||
setTouchedFields(remainingFields);
|
||||
setSaveState(remainingFields.size ? "idle" : "saved");
|
||||
if (payload.nanobot_features) context.onFeaturesUpdate(payload.nanobot_features);
|
||||
} catch (err) {
|
||||
setSaveError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!editableFields.length
|
||||
|| !touchedFields.size
|
||||
|| saving
|
||||
|| saveRevision <= attemptedRevision
|
||||
) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setAttemptedRevision(saveRevision);
|
||||
void saveSettings(
|
||||
{ ...fieldValuesRef.current },
|
||||
new Set(touchedFieldsRef.current),
|
||||
);
|
||||
}, 500);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [
|
||||
attemptedRevision,
|
||||
editableFields.length,
|
||||
saveRevision,
|
||||
saveSettings,
|
||||
saving,
|
||||
touchedFields.size,
|
||||
]);
|
||||
|
||||
const setFieldValue = (key: string, value: string) => {
|
||||
if (fieldValuesRef.current[key] === value) return;
|
||||
const nextValues = { ...fieldValuesRef.current, [key]: value };
|
||||
const nextTouchedFields = new Set(touchedFieldsRef.current).add(key);
|
||||
fieldValuesRef.current = nextValues;
|
||||
touchedFieldsRef.current = nextTouchedFields;
|
||||
setFieldValues(nextValues);
|
||||
setTouchedFields(nextTouchedFields);
|
||||
setSaveError(null);
|
||||
setSaveState("idle");
|
||||
setSaveRevision((current) => current + 1);
|
||||
};
|
||||
|
||||
const toggleAriaLabel = t("settings.channels.toggleChannel", {
|
||||
name: displayName,
|
||||
defaultValue: "{{name}} channel",
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<WeixinLogo showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
|
||||
{displayName}
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
|
||||
{channelTx("description", "Use nanobot from WeChat conversations.")}
|
||||
</p>
|
||||
{missingSupport && feature.install_supported ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={enabledBusy}
|
||||
onClick={() => onAction("enable", feature.name)}
|
||||
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
>
|
||||
{enabledBusy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{tx("settings.nanobotFeatures.installSupport", "Install support")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 pt-1">
|
||||
<WeixinStatusBadge status={feature.runtime_status}>
|
||||
{weixinStatusLabel(feature, tx)}
|
||||
</WeixinStatusBadge>
|
||||
{channelBusy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
|
||||
) : null}
|
||||
<ToggleButton
|
||||
checked={toggleChecked}
|
||||
disabled={channelToggleDisabled}
|
||||
ariaLabel={toggleAriaLabel}
|
||||
label={toggleChecked ? onLabel : offLabel}
|
||||
onChange={(checked) => {
|
||||
if (checked && !channelChecked && feature.configured === false) {
|
||||
setConnectRequestId((current) => current + 1);
|
||||
return;
|
||||
}
|
||||
onAction(checked ? "enable" : "disable", feature.name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runtimeError ? (
|
||||
<div className="mt-4 rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||
{runtimeError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<WeixinConnectFlow
|
||||
token={token}
|
||||
feature={feature}
|
||||
idleLabel={channelTx("setup.primaryAction", "Connect WeChat")}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
|
||||
{primaryFields.length ? (
|
||||
<CredentialForm
|
||||
fields={primaryFields}
|
||||
values={fieldValues}
|
||||
configuredFields={configuredFields}
|
||||
visibleSecrets={visibleSecrets}
|
||||
onChange={setFieldValue}
|
||||
onToggleSecret={(key) => {
|
||||
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className={cn(
|
||||
"flex items-center justify-end gap-1.5 text-[11px] leading-4 text-muted-foreground",
|
||||
!saving && saveState !== "saved" && "sr-only",
|
||||
)}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
||||
{tx("settings.actions.saving", "Saving")}
|
||||
</>
|
||||
) : saveState === "saved" ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" aria-hidden />
|
||||
{tx("settings.channels.savedSettings", "Saved settings.")}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{saveError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
|
||||
>
|
||||
{saveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{advancedFields.length ? (
|
||||
<details className="group text-[12px] leading-5 text-muted-foreground">
|
||||
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{tx("settings.channels.advanced", "Advanced")}
|
||||
<ChevronDown
|
||||
className="h-3.5 w-3.5 transition-transform group-open:rotate-180"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
<CredentialForm
|
||||
fields={advancedFields}
|
||||
values={fieldValues}
|
||||
configuredFields={configuredFields}
|
||||
visibleSecrets={visibleSecrets}
|
||||
onChange={setFieldValue}
|
||||
onToggleSecret={(key) => {
|
||||
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<WeixinGuideLink
|
||||
url={docsUrl}
|
||||
label={channelTx("setup.docsLabel", "Open WeChat setup")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function weixinSetupFields(
|
||||
feature: NanobotFeatureInfo,
|
||||
locale: string,
|
||||
): { primary: ChannelConfigField[]; advanced: ChannelConfigField[] } {
|
||||
const fields = feature.setup?.fields ?? [];
|
||||
const fieldsByKey = new Map(fields.map((field) => [field.key, field]));
|
||||
const messages = channelLocaleMessages("weixin", normalizeLocale(locale))?.setup;
|
||||
const knownKeys = new Set<string>([
|
||||
...WEIXIN_PRIMARY_FIELD_KEYS,
|
||||
...WEIXIN_ADVANCED_FIELD_KEYS,
|
||||
]);
|
||||
const extraKeys = fields
|
||||
.map((field) => field.key)
|
||||
.filter((key) => !knownKeys.has(key));
|
||||
const hydrate = (keys: readonly string[]) => keys.flatMap((key) => {
|
||||
const field = fieldsByKey.get(key);
|
||||
if (!field) return [];
|
||||
const copy = messages?.fields?.[channelFieldMessageKey("weixin", key)];
|
||||
return [weixinConfigField(field, copy)];
|
||||
});
|
||||
|
||||
return {
|
||||
primary: hydrate(WEIXIN_PRIMARY_FIELD_KEYS),
|
||||
advanced: hydrate([...WEIXIN_ADVANCED_FIELD_KEYS, ...extraKeys]),
|
||||
};
|
||||
}
|
||||
|
||||
function weixinConfigField(
|
||||
field: ChannelSetupContractField,
|
||||
copy: { label: string; placeholder?: string; help?: string; choices?: Record<string, string> }
|
||||
| undefined,
|
||||
): ChannelConfigField {
|
||||
const choices = field.kind === "bool" ? ["true", "false"] : field.choices;
|
||||
return {
|
||||
key: field.key,
|
||||
label: copy?.label ?? fieldLabel(field.field),
|
||||
placeholder: copy?.placeholder,
|
||||
help: copy?.help,
|
||||
secret: field.kind === "secret",
|
||||
optional: !field.required,
|
||||
inputType: field.kind === "int" ? "number" : undefined,
|
||||
defaultValue: field.default_value,
|
||||
options:
|
||||
field.kind === "enum" || field.kind === "bool"
|
||||
? choices.map((choice) => ({
|
||||
value: choice,
|
||||
label: copy?.choices?.[choice] ?? fieldLabel(choice),
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function fieldLabel(value: string): string {
|
||||
const spaced = value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_-]+/g, " ")
|
||||
.trim();
|
||||
return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : value;
|
||||
}
|
||||
|
||||
function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
|
||||
const logoUrls = useMemo(() => logoFallbackUrls("https://weixin.qq.com/favicon.ico"), []);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
if (showBrandLogos && logoUrl) {
|
||||
return (
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background">
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-5.5 w-5.5 max-h-6 max-w-6 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
|
||||
style={{ color: "#07C160" }}
|
||||
aria-hidden
|
||||
>
|
||||
WX
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function WeixinGuideLink({ url, label }: { url: string; label: string }) {
|
||||
const logoUrls = useMemo(() => logoFallbackUrls("https://weixin.qq.com/favicon.ico"), []);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-full bg-background/80 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground transition-colors hover:bg-background"
|
||||
>
|
||||
<span
|
||||
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full bg-muted/70 text-[9px] font-bold"
|
||||
style={{ color: "#07C160" }}
|
||||
aria-hidden
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : (
|
||||
"WX"
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{label}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function WeixinStatusBadge({
|
||||
children,
|
||||
status,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
status?: ChannelRuntimeStatus;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-4",
|
||||
status === "failed"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: status === "running"
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-200"
|
||||
: "bg-muted/75 text-muted-foreground",
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function weixinStatusLabel(
|
||||
feature: NanobotFeatureInfo,
|
||||
tx: (key: string, fallback: string) => string,
|
||||
): string {
|
||||
if (feature.runtime_status === "failed") {
|
||||
return tx("settings.channels.runtimeFailed", "Failed");
|
||||
}
|
||||
if (feature.runtime_status === "starting") {
|
||||
return tx("settings.channels.runtimeStarting", "Starting");
|
||||
}
|
||||
if (feature.runtime_status === "running") return tx("settings.values.on", "On");
|
||||
if (feature.enabled) return tx("settings.channels.runtimeStopped", "Not running");
|
||||
return tx("settings.values.off", "Off");
|
||||
}
|
||||
|
||||
function weixinRuntimeError(
|
||||
error: string | undefined,
|
||||
tx: (key: string, fallback: string) => string,
|
||||
): string | undefined {
|
||||
if (error === WEIXIN_AUTH_EXPIRED_MESSAGE) {
|
||||
return tx("custom.expired", error);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function localizeBooleanFields(
|
||||
fields: ChannelConfigField[],
|
||||
onLabel: string,
|
||||
offLabel: string,
|
||||
): ChannelConfigField[] {
|
||||
return fields.map((field) => {
|
||||
const values = new Set(field.options?.map((option) => option.value));
|
||||
if (values.size !== 2 || !values.has("true") || !values.has("false")) return field;
|
||||
return {
|
||||
...field,
|
||||
options: field.options?.map((option) => ({
|
||||
...option,
|
||||
label: option.value === "true" ? onLabel : offLabel,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2,14 +2,8 @@ import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||
|
||||
import { WeixinConnectFlow } from "./WeixinConnectFlow";
|
||||
import {
|
||||
WEIXIN_ADVANCED_FIELD_KEYS,
|
||||
WEIXIN_PRIMARY_FIELD_KEYS,
|
||||
WeixinPanel,
|
||||
} from "./WeixinPanel";
|
||||
|
||||
export default {
|
||||
Panel: WeixinPanel,
|
||||
ConnectFlow: WeixinConnectFlow,
|
||||
canConnectBeforeConfigured: true,
|
||||
aliases: {
|
||||
@@ -24,8 +18,10 @@ export default {
|
||||
mode: "connect",
|
||||
command: "nanobot channels login weixin",
|
||||
docsUrl: chatAppGuideUrl("wechat"),
|
||||
fields: WEIXIN_PRIMARY_FIELD_KEYS.map((key) => ({ key })),
|
||||
manualFields: WEIXIN_ADVANCED_FIELD_KEYS.map((key) => ({ key })),
|
||||
manualFields: [
|
||||
{ key: "channels.weixin.allowFrom" },
|
||||
{ key: "channels.weixin.token" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ChannelUiContribution;
|
||||
|
||||
@@ -20,21 +20,7 @@
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Saved by QR login"
|
||||
},
|
||||
"sendProgress": { "label": "Send progress" },
|
||||
"sendToolHints": { "label": "Send tool hints" },
|
||||
"streaming": { "label": "Use streaming API" },
|
||||
"replyProgressMessages": { "label": "Send structured progress" },
|
||||
"replyProgressMaxMessages": { "label": "Structured progress limit" },
|
||||
"contextMessageBudget": { "label": "Context message budget" },
|
||||
"blockStreaming": { "label": "Send response blocks" },
|
||||
"blockStreamingMinChars": { "label": "Minimum block size" },
|
||||
"blockStreamingMaxMessages": { "label": "Block message limit" },
|
||||
"baseUrl": { "label": "API URL" },
|
||||
"cdnBaseUrl": { "label": "CDN URL" },
|
||||
"routeTag": { "label": "Route tag" },
|
||||
"stateDir": { "label": "State directory" },
|
||||
"pollTimeout": { "label": "Poll timeout" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -44,13 +30,6 @@
|
||||
"waiting": "Waiting for WeChat scan...",
|
||||
"connected": "WeChat is connected.",
|
||||
"stopped": "WeChat login stopped.",
|
||||
"connecting": "Connecting...",
|
||||
"verifyTitle": "Verification required",
|
||||
"verifyDescription": "Enter the number shown in WeChat to continue.",
|
||||
"verifyMismatch": "That code did not match. Enter the new number shown in WeChat.",
|
||||
"expired": "WeChat login expired. Scan again to reconnect.",
|
||||
"failed": "Unable to connect WeChat. Try again.",
|
||||
"verifyPlaceholder": "Code",
|
||||
"verifySubmit": "Verify"
|
||||
"connecting": "Connecting..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,7 @@
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Guardado al iniciar sesión por QR"
|
||||
},
|
||||
"sendProgress": { "label": "Enviar progreso" },
|
||||
"sendToolHints": { "label": "Enviar indicaciones de herramientas" },
|
||||
"streaming": { "label": "Usar API de streaming" },
|
||||
"replyProgressMessages": { "label": "Enviar progreso estructurado" },
|
||||
"replyProgressMaxMessages": { "label": "Límite de progreso estructurado" },
|
||||
"contextMessageBudget": { "label": "Presupuesto de mensajes por contexto" },
|
||||
"blockStreaming": { "label": "Enviar respuestas por bloques" },
|
||||
"blockStreamingMinChars": { "label": "Tamaño mínimo del bloque" },
|
||||
"blockStreamingMaxMessages": { "label": "Límite de mensajes por bloques" },
|
||||
"baseUrl": { "label": "URL de la API" },
|
||||
"cdnBaseUrl": { "label": "URL de la CDN" },
|
||||
"routeTag": { "label": "Etiqueta de ruta" },
|
||||
"stateDir": { "label": "Directorio de estado" },
|
||||
"pollTimeout": { "label": "Tiempo de espera de consulta" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -44,13 +30,6 @@
|
||||
"waiting": "Esperando el escaneo de WeChat...",
|
||||
"connected": "WeChat está conectado.",
|
||||
"stopped": "Inicio de WeChat detenido.",
|
||||
"connecting": "Conectando...",
|
||||
"verifyTitle": "Se requiere verificación",
|
||||
"verifyDescription": "Introduce el número que aparece en WeChat para continuar.",
|
||||
"verifyMismatch": "El código no coincide. Introduce el nuevo número que aparece en WeChat.",
|
||||
"expired": "El inicio de sesión de WeChat caducó. Escanea de nuevo para volver a conectarte.",
|
||||
"failed": "No se pudo conectar WeChat. Inténtalo de nuevo.",
|
||||
"verifyPlaceholder": "Código",
|
||||
"verifySubmit": "Verificar"
|
||||
"connecting": "Conectando..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,7 @@
|
||||
"token": {
|
||||
"label": "Jeton",
|
||||
"placeholder": "Enregistré après la connexion QR"
|
||||
},
|
||||
"sendProgress": { "label": "Envoyer la progression" },
|
||||
"sendToolHints": { "label": "Envoyer les indications d’outils" },
|
||||
"streaming": { "label": "Utiliser l’API de streaming" },
|
||||
"replyProgressMessages": { "label": "Envoyer la progression structurée" },
|
||||
"replyProgressMaxMessages": { "label": "Limite de progression structurée" },
|
||||
"contextMessageBudget": { "label": "Budget de messages du contexte" },
|
||||
"blockStreaming": { "label": "Envoyer la réponse par blocs" },
|
||||
"blockStreamingMinChars": { "label": "Taille minimale d’un bloc" },
|
||||
"blockStreamingMaxMessages": { "label": "Limite de messages par blocs" },
|
||||
"baseUrl": { "label": "URL de l’API" },
|
||||
"cdnBaseUrl": { "label": "URL du CDN" },
|
||||
"routeTag": { "label": "Étiquette de routage" },
|
||||
"stateDir": { "label": "Répertoire d’état" },
|
||||
"pollTimeout": { "label": "Délai d’interrogation" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -44,13 +30,6 @@
|
||||
"waiting": "En attente du scan WeChat...",
|
||||
"connected": "WeChat est connecté.",
|
||||
"stopped": "Connexion WeChat arrêtée.",
|
||||
"connecting": "Connexion...",
|
||||
"verifyTitle": "Vérification requise",
|
||||
"verifyDescription": "Saisissez le nombre affiché dans WeChat pour continuer.",
|
||||
"verifyMismatch": "Le code ne correspond pas. Saisissez le nouveau nombre affiché dans WeChat.",
|
||||
"expired": "La connexion WeChat a expiré. Scannez à nouveau pour vous reconnecter.",
|
||||
"failed": "Impossible de connecter WeChat. Réessayez.",
|
||||
"verifyPlaceholder": "Code",
|
||||
"verifySubmit": "Vérifier"
|
||||
"connecting": "Connexion..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,7 @@
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Disimpan saat login QR"
|
||||
},
|
||||
"sendProgress": { "label": "Kirim progres" },
|
||||
"sendToolHints": { "label": "Kirim petunjuk alat" },
|
||||
"streaming": { "label": "Gunakan API streaming" },
|
||||
"replyProgressMessages": { "label": "Kirim progres terstruktur" },
|
||||
"replyProgressMaxMessages": { "label": "Batas progres terstruktur" },
|
||||
"contextMessageBudget": { "label": "Anggaran pesan konteks" },
|
||||
"blockStreaming": { "label": "Kirim respons per blok" },
|
||||
"blockStreamingMinChars": { "label": "Ukuran blok minimum" },
|
||||
"blockStreamingMaxMessages": { "label": "Batas pesan blok" },
|
||||
"baseUrl": { "label": "URL API" },
|
||||
"cdnBaseUrl": { "label": "URL CDN" },
|
||||
"routeTag": { "label": "Tag rute" },
|
||||
"stateDir": { "label": "Direktori status" },
|
||||
"pollTimeout": { "label": "Batas waktu polling" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -44,13 +30,6 @@
|
||||
"waiting": "Menunggu pemindaian WeChat...",
|
||||
"connected": "WeChat sudah terhubung.",
|
||||
"stopped": "Login WeChat dihentikan.",
|
||||
"connecting": "Menghubungkan...",
|
||||
"verifyTitle": "Verifikasi diperlukan",
|
||||
"verifyDescription": "Masukkan angka yang ditampilkan di WeChat untuk melanjutkan.",
|
||||
"verifyMismatch": "Kode tidak cocok. Masukkan angka baru yang ditampilkan di WeChat.",
|
||||
"expired": "Login WeChat telah kedaluwarsa. Pindai lagi untuk menghubungkan kembali.",
|
||||
"failed": "Tidak dapat menghubungkan WeChat. Coba lagi.",
|
||||
"verifyPlaceholder": "Kode",
|
||||
"verifySubmit": "Verifikasi"
|
||||
"connecting": "Menghubungkan..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,7 @@
|
||||
"token": {
|
||||
"label": "トークン",
|
||||
"placeholder": "QR ログインで保存"
|
||||
},
|
||||
"sendProgress": { "label": "進捗を送信" },
|
||||
"sendToolHints": { "label": "ツールのヒントを送信" },
|
||||
"streaming": { "label": "ストリーミング API を使用" },
|
||||
"replyProgressMessages": { "label": "構造化された進捗を送信" },
|
||||
"replyProgressMaxMessages": { "label": "構造化進捗の上限" },
|
||||
"contextMessageBudget": { "label": "コンテキストのメッセージ予算" },
|
||||
"blockStreaming": { "label": "応答をブロック単位で送信" },
|
||||
"blockStreamingMinChars": { "label": "最小ブロックサイズ" },
|
||||
"blockStreamingMaxMessages": { "label": "ブロックメッセージの上限" },
|
||||
"baseUrl": { "label": "API URL" },
|
||||
"cdnBaseUrl": { "label": "CDN URL" },
|
||||
"routeTag": { "label": "ルートタグ" },
|
||||
"stateDir": { "label": "状態ディレクトリ" },
|
||||
"pollTimeout": { "label": "ポーリングタイムアウト" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -44,13 +30,6 @@
|
||||
"waiting": "WeChat のスキャンを待っています...",
|
||||
"connected": "WeChat に接続しました。",
|
||||
"stopped": "WeChat ログインを停止しました。",
|
||||
"connecting": "接続中...",
|
||||
"verifyTitle": "確認が必要です",
|
||||
"verifyDescription": "WeChat に表示された数字を入力してください。",
|
||||
"verifyMismatch": "コードが一致しません。WeChat に表示された新しい数字を入力してください。",
|
||||
"expired": "WeChat のログイン期限が切れました。再接続するにはもう一度スキャンしてください。",
|
||||
"failed": "WeChat に接続できません。もう一度お試しください。",
|
||||
"verifyPlaceholder": "コード",
|
||||
"verifySubmit": "確認"
|
||||
"connecting": "接続中..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,7 @@
|
||||
"token": {
|
||||
"label": "토큰",
|
||||
"placeholder": "QR 로그인으로 저장됨"
|
||||
},
|
||||
"sendProgress": { "label": "진행 상황 보내기" },
|
||||
"sendToolHints": { "label": "도구 힌트 보내기" },
|
||||
"streaming": { "label": "스트리밍 API 사용" },
|
||||
"replyProgressMessages": { "label": "구조화된 진행 상황 보내기" },
|
||||
"replyProgressMaxMessages": { "label": "구조화된 진행 메시지 한도" },
|
||||
"contextMessageBudget": { "label": "컨텍스트 메시지 예산" },
|
||||
"blockStreaming": { "label": "응답을 블록으로 보내기" },
|
||||
"blockStreamingMinChars": { "label": "최소 블록 크기" },
|
||||
"blockStreamingMaxMessages": { "label": "블록 메시지 한도" },
|
||||
"baseUrl": { "label": "API URL" },
|
||||
"cdnBaseUrl": { "label": "CDN URL" },
|
||||
"routeTag": { "label": "경로 태그" },
|
||||
"stateDir": { "label": "상태 디렉터리" },
|
||||
"pollTimeout": { "label": "폴링 제한 시간" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -44,13 +30,6 @@
|
||||
"waiting": "WeChat 스캔을 기다리는 중...",
|
||||
"connected": "WeChat이 연결되었습니다.",
|
||||
"stopped": "WeChat 로그인이 중지되었습니다.",
|
||||
"connecting": "연결 중...",
|
||||
"verifyTitle": "인증 필요",
|
||||
"verifyDescription": "계속하려면 WeChat에 표시된 숫자를 입력하세요.",
|
||||
"verifyMismatch": "코드가 일치하지 않습니다. WeChat에 표시된 새 숫자를 입력하세요.",
|
||||
"expired": "WeChat 로그인이 만료되었습니다. 다시 연결하려면 다시 스캔하세요.",
|
||||
"failed": "WeChat에 연결할 수 없습니다. 다시 시도하세요.",
|
||||
"verifyPlaceholder": "코드",
|
||||
"verifySubmit": "인증"
|
||||
"connecting": "연결 중..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,7 @@
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Salvo pelo login via QR"
|
||||
},
|
||||
"sendProgress": { "label": "Enviar progresso" },
|
||||
"sendToolHints": { "label": "Enviar dicas de ferramentas" },
|
||||
"streaming": { "label": "Usar API de streaming" },
|
||||
"replyProgressMessages": { "label": "Enviar progresso estruturado" },
|
||||
"replyProgressMaxMessages": { "label": "Limite de progresso estruturado" },
|
||||
"contextMessageBudget": { "label": "Orçamento de mensagens do contexto" },
|
||||
"blockStreaming": { "label": "Enviar resposta em blocos" },
|
||||
"blockStreamingMinChars": { "label": "Tamanho mínimo do bloco" },
|
||||
"blockStreamingMaxMessages": { "label": "Limite de mensagens em blocos" },
|
||||
"baseUrl": { "label": "URL da API" },
|
||||
"cdnBaseUrl": { "label": "URL da CDN" },
|
||||
"routeTag": { "label": "Etiqueta de rota" },
|
||||
"stateDir": { "label": "Diretório de estado" },
|
||||
"pollTimeout": { "label": "Tempo limite da consulta" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -44,13 +30,6 @@
|
||||
"waiting": "Aguardando leitura do WeChat...",
|
||||
"connected": "WeChat está conectado.",
|
||||
"stopped": "Login do WeChat interrompido.",
|
||||
"connecting": "Conectando...",
|
||||
"verifyTitle": "Verificação necessária",
|
||||
"verifyDescription": "Digite o número exibido no WeChat para continuar.",
|
||||
"verifyMismatch": "O código não corresponde. Digite o novo número exibido no WeChat.",
|
||||
"expired": "O login do WeChat expirou. Escaneie novamente para reconectar.",
|
||||
"failed": "Não foi possível conectar o WeChat. Tente novamente.",
|
||||
"verifyPlaceholder": "Código",
|
||||
"verifySubmit": "Verificar"
|
||||
"connecting": "Conectando..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,7 @@
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Được lưu khi đăng nhập QR"
|
||||
},
|
||||
"sendProgress": { "label": "Gửi tiến trình" },
|
||||
"sendToolHints": { "label": "Gửi gợi ý công cụ" },
|
||||
"streaming": { "label": "Sử dụng API phát trực tiếp" },
|
||||
"replyProgressMessages": { "label": "Gửi tiến trình có cấu trúc" },
|
||||
"replyProgressMaxMessages": { "label": "Giới hạn tiến trình có cấu trúc" },
|
||||
"contextMessageBudget": { "label": "Ngân sách tin nhắn ngữ cảnh" },
|
||||
"blockStreaming": { "label": "Gửi phản hồi theo khối" },
|
||||
"blockStreamingMinChars": { "label": "Kích thước khối tối thiểu" },
|
||||
"blockStreamingMaxMessages": { "label": "Giới hạn tin nhắn theo khối" },
|
||||
"baseUrl": { "label": "URL API" },
|
||||
"cdnBaseUrl": { "label": "URL CDN" },
|
||||
"routeTag": { "label": "Thẻ định tuyến" },
|
||||
"stateDir": { "label": "Thư mục trạng thái" },
|
||||
"pollTimeout": { "label": "Thời gian chờ thăm dò" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -44,13 +30,6 @@
|
||||
"waiting": "Đang chờ quét WeChat...",
|
||||
"connected": "WeChat đã kết nối.",
|
||||
"stopped": "Đăng nhập WeChat đã dừng.",
|
||||
"connecting": "Đang kết nối...",
|
||||
"verifyTitle": "Cần xác minh",
|
||||
"verifyDescription": "Nhập số hiển thị trong WeChat để tiếp tục.",
|
||||
"verifyMismatch": "Mã không khớp. Nhập số mới hiển thị trong WeChat.",
|
||||
"expired": "Đăng nhập WeChat đã hết hạn. Hãy quét lại để kết nối lại.",
|
||||
"failed": "Không thể kết nối WeChat. Hãy thử lại.",
|
||||
"verifyPlaceholder": "Mã",
|
||||
"verifySubmit": "Xác minh"
|
||||
"connecting": "Đang kết nối..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,21 +21,7 @@
|
||||
"token": {
|
||||
"label": "令牌",
|
||||
"placeholder": "二维码登录后自动保存"
|
||||
},
|
||||
"sendProgress": { "label": "发送进度消息" },
|
||||
"sendToolHints": { "label": "发送工具提示" },
|
||||
"streaming": { "label": "使用流式 API" },
|
||||
"replyProgressMessages": { "label": "发送结构化进度" },
|
||||
"replyProgressMaxMessages": { "label": "结构化进度消息上限" },
|
||||
"contextMessageBudget": { "label": "上下文消息预算" },
|
||||
"blockStreaming": { "label": "分块发送回复" },
|
||||
"blockStreamingMinChars": { "label": "最小分块字符数" },
|
||||
"blockStreamingMaxMessages": { "label": "分块消息上限" },
|
||||
"baseUrl": { "label": "API 地址" },
|
||||
"cdnBaseUrl": { "label": "CDN 地址" },
|
||||
"routeTag": { "label": "路由标签" },
|
||||
"stateDir": { "label": "状态目录" },
|
||||
"pollTimeout": { "label": "轮询超时" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -45,13 +31,6 @@
|
||||
"waiting": "正在等待微信扫码...",
|
||||
"connected": "微信已连接。",
|
||||
"stopped": "微信登录已停止。",
|
||||
"connecting": "正在连接...",
|
||||
"verifyTitle": "需要验证",
|
||||
"verifyDescription": "输入手机微信中显示的数字以继续。",
|
||||
"verifyMismatch": "验证码不匹配,请输入微信中显示的新数字。",
|
||||
"expired": "微信登录已过期,请重新扫码连接。",
|
||||
"failed": "无法连接微信,请重试。",
|
||||
"verifyPlaceholder": "验证码",
|
||||
"verifySubmit": "验证"
|
||||
"connecting": "正在连接..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,21 +21,7 @@
|
||||
"token": {
|
||||
"label": "權杖",
|
||||
"placeholder": "二維碼登入後自動儲存"
|
||||
},
|
||||
"sendProgress": { "label": "傳送進度訊息" },
|
||||
"sendToolHints": { "label": "傳送工具提示" },
|
||||
"streaming": { "label": "使用串流 API" },
|
||||
"replyProgressMessages": { "label": "傳送結構化進度" },
|
||||
"replyProgressMaxMessages": { "label": "結構化進度訊息上限" },
|
||||
"contextMessageBudget": { "label": "上下文訊息預算" },
|
||||
"blockStreaming": { "label": "分塊傳送回覆" },
|
||||
"blockStreamingMinChars": { "label": "最小分塊字元數" },
|
||||
"blockStreamingMaxMessages": { "label": "分塊訊息上限" },
|
||||
"baseUrl": { "label": "API 位址" },
|
||||
"cdnBaseUrl": { "label": "CDN 位址" },
|
||||
"routeTag": { "label": "路由標籤" },
|
||||
"stateDir": { "label": "狀態目錄" },
|
||||
"pollTimeout": { "label": "輪詢逾時" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
@@ -45,13 +31,6 @@
|
||||
"waiting": "正在等待微信掃碼...",
|
||||
"connected": "微信已連接。",
|
||||
"stopped": "微信登入已停止。",
|
||||
"connecting": "正在連接...",
|
||||
"verifyTitle": "需要驗證",
|
||||
"verifyDescription": "輸入手機微信中顯示的數字以繼續。",
|
||||
"verifyMismatch": "驗證碼不符,請輸入微信中顯示的新數字。",
|
||||
"expired": "微信登入已過期,請重新掃碼連線。",
|
||||
"failed": "無法連接微信,請重試。",
|
||||
"verifyPlaceholder": "驗證碼",
|
||||
"verifySubmit": "驗證"
|
||||
"connecting": "正在連接..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ from nanobot.cli.models import (
|
||||
)
|
||||
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -1675,7 +1674,7 @@ def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
|
||||
login_oauth_interactive,
|
||||
)
|
||||
except ImportError:
|
||||
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
|
||||
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
@@ -12,7 +12,6 @@ import typer
|
||||
from rich.console import Console
|
||||
|
||||
from nanobot import __logo__
|
||||
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.registry import ProviderSpec
|
||||
@@ -75,7 +74,7 @@ def _required_module_attribute(module_name: str, attribute: str) -> object:
|
||||
|
||||
|
||||
def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]:
|
||||
"""Load the untyped OAuth client behind a typed boundary."""
|
||||
"""Load the optional untyped OAuth client behind a typed boundary."""
|
||||
return (
|
||||
cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")),
|
||||
cast(
|
||||
@@ -86,7 +85,7 @@ def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]
|
||||
|
||||
|
||||
def _load_openai_oauth_storage() -> tuple[_OAuthProviderConfig, _FileTokenStorageFactory]:
|
||||
"""Load the untyped OAuth storage API behind a typed boundary."""
|
||||
"""Load the optional untyped OAuth storage API behind a typed boundary."""
|
||||
return (
|
||||
cast(
|
||||
_OAuthProviderConfig,
|
||||
@@ -242,7 +241,7 @@ def _login_openai_codex() -> None:
|
||||
f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]"
|
||||
)
|
||||
except ImportError:
|
||||
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
|
||||
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -251,7 +250,7 @@ def _logout_openai_codex() -> None:
|
||||
try:
|
||||
provider_config, storage_factory = _load_openai_oauth_storage()
|
||||
except ImportError:
|
||||
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
|
||||
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
storage = storage_factory(token_filename=provider_config.token_filename)
|
||||
@@ -310,7 +309,7 @@ def _logout_github_copilot() -> None:
|
||||
try:
|
||||
from nanobot.providers.github_copilot_provider import get_storage
|
||||
except ImportError:
|
||||
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
|
||||
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
storage = get_storage()
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Shared recovery guidance for OAuth dependency failures."""
|
||||
|
||||
OAUTH_CLI_KIT_MISSING_MESSAGE = (
|
||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||
"Reinstall or upgrade nanobot-ai using the same installation method."
|
||||
)
|
||||
@@ -586,7 +586,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if os.environ.get("LANGFUSE_SECRET_KEY"):
|
||||
logger.warning(
|
||||
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
|
||||
"run `nanobot plugins enable langfuse` to enable tracing"
|
||||
"install with `pip install langfuse` to enable tracing"
|
||||
)
|
||||
from openai import AsyncOpenAI as _AsyncOpenAI
|
||||
AsyncOpenAI = _AsyncOpenAI
|
||||
|
||||
+12
-38
@@ -36,7 +36,6 @@ from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||
FILE_MAX_MESSAGES = 2000
|
||||
SESSION_CACHE_MAX_SIZE = 128
|
||||
MIN_REPLAY_MAX_MESSAGES = 120
|
||||
MIN_COMPACTED_REPLAY_MESSAGES = 8
|
||||
REPLAY_TOKENS_PER_MESSAGE = 100
|
||||
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
||||
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
||||
@@ -192,37 +191,19 @@ class Session:
|
||||
extend_to_user: bool = False,
|
||||
include_runtime_context: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return recent replayable messages for LLM input.
|
||||
"""Return unconsolidated messages for LLM input.
|
||||
|
||||
History is sliced by message count first (``max_messages``), then by
|
||||
token budget from the tail (``max_tokens``) when provided.
|
||||
"""
|
||||
replay_start = self.last_consolidated
|
||||
if replay_start:
|
||||
# ``last_consolidated`` is archive progress, not a replay boundary.
|
||||
# Keep a small raw suffix for continuity, extending back to the user
|
||||
# that started an assistant/tool sequence when necessary.
|
||||
recent_start = recent_message_start_index(
|
||||
self.messages,
|
||||
MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
extend_to_user=True,
|
||||
)
|
||||
replay_start = min(replay_start, recent_start)
|
||||
|
||||
replayable = self.messages[replay_start:]
|
||||
unconsolidated = self.messages[self.last_consolidated:]
|
||||
max_messages = max_messages if max_messages > 0 else FILE_MAX_MESSAGES
|
||||
unarchived_count = len(self.messages) - self.last_consolidated
|
||||
if replay_start < self.last_consolidated and unarchived_count < max_messages:
|
||||
# The archived replay suffix can exceed the nominal count when one
|
||||
# tool-heavy turn spans the boundary. Preserve that complete turn.
|
||||
start_idx = 0
|
||||
else:
|
||||
start_idx = recent_message_start_index(
|
||||
replayable,
|
||||
max_messages,
|
||||
extend_to_user=extend_to_user,
|
||||
)
|
||||
sliced = replayable[start_idx:]
|
||||
start_idx = recent_message_start_index(
|
||||
unconsolidated,
|
||||
max_messages,
|
||||
extend_to_user=extend_to_user,
|
||||
)
|
||||
sliced = unconsolidated[start_idx:]
|
||||
|
||||
# Avoid starting mid-turn when possible, except for proactive
|
||||
# assistant deliveries that the user may be replying to.
|
||||
@@ -371,24 +352,17 @@ class Session:
|
||||
|
||||
start_idx = max(0, len(self.messages) - max_messages)
|
||||
if extend_to_user:
|
||||
recovered_user = next(
|
||||
start_idx = next(
|
||||
(i for i in range(start_idx, -1, -1) if self.messages[i].get("role") == "user"),
|
||||
None,
|
||||
start_idx,
|
||||
)
|
||||
if recovered_user is not None:
|
||||
start_idx = recovered_user
|
||||
if start_idx > 0 and self.messages[start_idx - 1].get("_channel_delivery"):
|
||||
start_idx -= 1
|
||||
|
||||
retained = self.messages[start_idx:]
|
||||
|
||||
# Prefer starting at a user turn (or its preceding _channel_delivery) when one exists within the retained window.
|
||||
# Prefer starting at a user turn when one exists within the retained window.
|
||||
first_user = next((i for i, m in enumerate(retained) if m.get("role") == "user"), None)
|
||||
if first_user is not None:
|
||||
if first_user > 0 and retained[first_user - 1].get("_channel_delivery"):
|
||||
retained = retained[first_user - 1:]
|
||||
else:
|
||||
retained = retained[first_user:]
|
||||
retained = retained[first_user:]
|
||||
elif not extend_to_user:
|
||||
# If the hard-capped tail is assistant/tool-only, anchor to the
|
||||
# latest user in the full session and take a capped forward window.
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
Persisted subagent announcements mirror ``agent/subagent_announce.md``: header,
|
||||
full ``Task:`` assignment (model context), ``Result:``, and a trailing model-only
|
||||
``Summarize…`` instruction. External channels (embedded WebUI, session previews)
|
||||
should show only the header plus a truncated result body.
|
||||
"""
|
||||
should show only the header plus a truncated result body."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Cap the Result section so session previews stay readable; full text remains on
|
||||
# disk for LLM replay.
|
||||
from typing import Any, cast
|
||||
|
||||
# Cap Result section length so WebSocket session replay stays readable; full text
|
||||
# remains on disk for LLM replay (we only mutate outgoing API copies in websocket).
|
||||
_SUBAGENT_CHANNEL_RESULT_MAX_CHARS = 800
|
||||
|
||||
|
||||
@@ -43,3 +44,16 @@ def scrub_subagent_announce_body(content: str) -> str:
|
||||
if header and body:
|
||||
return f"{header}\n\n{body}"
|
||||
return header or body or stripped
|
||||
|
||||
|
||||
def scrub_subagent_messages_for_channel(messages: list[dict[str, Any]]) -> None:
|
||||
"""Mutate message dicts in place when they carry ``subagent_result`` inject."""
|
||||
for msg in messages:
|
||||
if not isinstance(cast(object, msg), dict):
|
||||
continue
|
||||
if msg.get("injected_event") != "subagent_result":
|
||||
continue
|
||||
raw = msg.get("content")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
continue
|
||||
msg["content"] = scrub_subagent_announce_body(raw)
|
||||
|
||||
@@ -13,7 +13,7 @@ import shutil
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
@@ -32,6 +32,7 @@ from nanobot.webui.http_utils import (
|
||||
|
||||
MediaDirProvider = Callable[[str | None], Path]
|
||||
SignedMediaPath = Callable[[Path], dict[str, str] | None]
|
||||
SignedMediaUrl = Callable[[Path], str | None]
|
||||
|
||||
|
||||
def b64url_encode(data: bytes) -> str:
|
||||
@@ -189,6 +190,37 @@ def signed_media_attachments(
|
||||
return out
|
||||
|
||||
|
||||
def attach_signed_media_urls(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
sign_path: SignedMediaUrl,
|
||||
) -> None:
|
||||
"""Replace raw media path lists in a WebUI session payload with signed URLs."""
|
||||
messages = payload.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
raw_messages = cast(list[Any], messages)
|
||||
for msg in raw_messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
message = cast(dict[str, Any], msg)
|
||||
media = message.get("media")
|
||||
if not isinstance(media, list) or not media:
|
||||
continue
|
||||
media_entries = cast(list[Any], media)
|
||||
urls: list[dict[str, str]] = []
|
||||
for entry in media_entries:
|
||||
if not isinstance(entry, str) or not entry:
|
||||
continue
|
||||
signed = sign_path(Path(entry))
|
||||
if signed is None:
|
||||
continue
|
||||
urls.append({"url": signed, "name": Path(entry).name})
|
||||
if urls:
|
||||
message["media_urls"] = urls
|
||||
message.pop("media", None)
|
||||
|
||||
|
||||
def serve_signed_media(
|
||||
sig: str,
|
||||
payload: str,
|
||||
|
||||
@@ -17,6 +17,7 @@ from nanobot.webui.attachment_ingress import (
|
||||
)
|
||||
from nanobot.webui.ingress_policy import AttachmentIngressLimits
|
||||
from nanobot.webui.media_api import (
|
||||
attach_signed_media_urls,
|
||||
serve_signed_media,
|
||||
sign_media_path,
|
||||
sign_or_stage_media_path,
|
||||
@@ -98,6 +99,9 @@ class WebUIMediaGateway:
|
||||
sign_path=self.sign_or_stage_media_path,
|
||||
)
|
||||
|
||||
def augment_media_urls(self, payload: dict[str, Any]) -> None:
|
||||
attach_signed_media_urls(payload, sign_path=self.sign_media_path)
|
||||
|
||||
def augment_transcript_media(self, paths: list[str]) -> list[dict[str, Any]]:
|
||||
return signed_media_attachments(
|
||||
paths,
|
||||
|
||||
@@ -4,7 +4,7 @@ The WebSocket channel owns transport/authentication. This module owns the
|
||||
settings payload shape and the allowlisted config mutations exposed to WebUI.
|
||||
"""
|
||||
|
||||
# oauth-cli-kit does not publish type stubs.
|
||||
# oauth-cli-kit is an optional dependency and does not publish type stubs.
|
||||
# pyright: reportMissingTypeStubs=false
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,7 +36,6 @@ from nanobot.providers.image_generation import (
|
||||
get_image_gen_provider,
|
||||
image_gen_provider_names,
|
||||
)
|
||||
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
||||
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
|
||||
from nanobot.security.network import is_loopback_host
|
||||
from nanobot.security.workspace_access import workspace_sandbox_status
|
||||
@@ -1795,7 +1794,9 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
try:
|
||||
from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login
|
||||
except ImportError:
|
||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||
raise WebUISettingsError(
|
||||
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||
) from None
|
||||
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||
@@ -1833,7 +1834,9 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
login_github_copilot,
|
||||
)
|
||||
except ImportError:
|
||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||
raise WebUISettingsError(
|
||||
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||
) from None
|
||||
|
||||
token = get_github_copilot_login_status()
|
||||
if not token:
|
||||
@@ -1931,14 +1934,18 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
||||
from oauth_cli_kit.storage import FileTokenStorage
|
||||
except ImportError:
|
||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||
raise WebUISettingsError(
|
||||
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||
) from None
|
||||
_clear_webui_oauth_flows(spec.name)
|
||||
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
||||
elif spec.name == "github_copilot":
|
||||
try:
|
||||
from nanobot.providers.github_copilot_provider import get_storage
|
||||
except ImportError:
|
||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||
raise WebUISettingsError(
|
||||
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||
) from None
|
||||
token_path = get_storage().get_token_path()
|
||||
elif spec.name == "xai_grok":
|
||||
from nanobot.providers.xai_oauth import logout_xai_oauth
|
||||
|
||||
@@ -25,7 +25,7 @@ _MAX_KEY_LEN = 512
|
||||
_MAX_TITLE_LEN = 160
|
||||
_MAX_TAG_LEN = 40
|
||||
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
||||
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
|
||||
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc"}
|
||||
|
||||
|
||||
def webui_sidebar_state_path() -> Path:
|
||||
@@ -37,7 +37,6 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
||||
"schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
|
||||
"pinned_keys": [],
|
||||
"archived_keys": [],
|
||||
"session_order": [],
|
||||
"title_overrides": {},
|
||||
"project_name_overrides": {},
|
||||
"tags_by_key": {},
|
||||
@@ -139,7 +138,6 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||
state = default_webui_sidebar_state()
|
||||
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
|
||||
state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
|
||||
state["session_order"] = _clean_string_list(raw.get("session_order"))
|
||||
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
|
||||
state["project_name_overrides"] = _clean_title_overrides(
|
||||
raw.get("project_name_overrides")
|
||||
|
||||
@@ -26,8 +26,10 @@ from websockets.http11 import Response
|
||||
from nanobot.command.builtin import builtin_command_palette
|
||||
from nanobot.cron.session_turns import is_bound_cron_job
|
||||
from nanobot.cron.types import CronJob, CronSchedule
|
||||
from nanobot.runtime_context import public_history_messages
|
||||
from nanobot.security.workspace_access import WorkspaceScope
|
||||
from nanobot.triggers.local_types import LocalTrigger
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||
from nanobot.webui.file_preview import (
|
||||
WebUIFilePreviewError,
|
||||
file_preview_availability_payload,
|
||||
@@ -460,6 +462,10 @@ class GatewayHTTPHandler:
|
||||
# -- Session routes -----------------------------------------------------
|
||||
|
||||
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||
if m:
|
||||
return self._handle_session_messages(request, m.group(1))
|
||||
|
||||
m = re.match(r"^/api/sessions/([^/]+)/webui-thread$", got)
|
||||
if m:
|
||||
return self._handle_webui_thread_get(request, m.group(1))
|
||||
@@ -521,6 +527,34 @@ class GatewayHTTPHandler:
|
||||
cleaned.append(row)
|
||||
return {"sessions": cleaned}
|
||||
|
||||
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
if self.session_manager is None:
|
||||
return _http_error(503, "session manager unavailable")
|
||||
decoded_key = _decode_api_key(key)
|
||||
if decoded_key is None:
|
||||
return _http_error(400, "invalid session key")
|
||||
if not _is_websocket_channel_session_key(decoded_key):
|
||||
return _http_error(404, "session not found")
|
||||
data = self.session_manager.read_session_file(decoded_key)
|
||||
if data is None:
|
||||
return _http_error(404, "session not found")
|
||||
messages = data.get("messages")
|
||||
if isinstance(messages, list):
|
||||
session_messages = cast(list[dict[str, Any]], messages)
|
||||
scrub_subagent_messages_for_channel(session_messages)
|
||||
raw_session_messages = cast(list[Any], messages)
|
||||
data["messages"] = public_history_messages(
|
||||
[
|
||||
cast(dict[str, Any], message)
|
||||
for message in raw_session_messages
|
||||
if isinstance(message, dict)
|
||||
]
|
||||
)
|
||||
self.media.augment_media_urls(data)
|
||||
return _http_json_response(data)
|
||||
|
||||
def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
|
||||
@@ -80,6 +80,8 @@ def _make_fake_compact(
|
||||
track_archived: list | None = None,
|
||||
track_count: bool = False,
|
||||
):
|
||||
from nanobot.session.manager import Session as _Session
|
||||
|
||||
state = {"count": 0}
|
||||
|
||||
async def _fake_compact(key: str, *, runtime, max_suffix: int = 8) -> str:
|
||||
@@ -90,8 +92,25 @@ def _make_fake_compact(
|
||||
if not tail:
|
||||
loop.sessions.save(session)
|
||||
return ""
|
||||
archive_end = session.last_consolidated + len(tail)
|
||||
archive_msgs = tail
|
||||
|
||||
probe = _Session(
|
||||
key=session.key,
|
||||
messages=tail.copy(),
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
result = probe.retain_recent_legal_suffix(
|
||||
max_suffix,
|
||||
extend_to_user=True,
|
||||
)
|
||||
visible_suffix = probe.messages
|
||||
archive_msgs = result.dropped
|
||||
|
||||
if not archive_msgs:
|
||||
loop.sessions.save(session)
|
||||
return ""
|
||||
|
||||
last_active = session.updated_at
|
||||
s = summary
|
||||
@@ -107,7 +126,7 @@ def _make_fake_compact(
|
||||
"last_active": last_active.isoformat(),
|
||||
}
|
||||
|
||||
session.last_consolidated = archive_end
|
||||
session.last_consolidated = len(session.messages) - len(visible_suffix)
|
||||
loop.sessions.save(session)
|
||||
return s
|
||||
|
||||
@@ -346,7 +365,7 @@ class TestAutoCompact:
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_archives_full_tail_without_deleting_history(self, tmp_path):
|
||||
async def test_auto_compact_archives_prefix_without_deleting_history(self, tmp_path):
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
_add_turns(session, 6)
|
||||
@@ -359,7 +378,7 @@ class TestAutoCompact:
|
||||
|
||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||
|
||||
assert len(archived_messages) == 12
|
||||
assert len(archived_messages) == 4
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 12
|
||||
assert session_after.messages[0]["content"] == "msg user 0"
|
||||
@@ -454,7 +473,7 @@ class TestAutoCompact:
|
||||
|
||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||
|
||||
assert len(archived_messages) == 10
|
||||
assert len(archived_messages) == 2
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
@@ -496,7 +515,7 @@ class TestAutoCompactIdleDetection:
|
||||
await loop._process_message(msg)
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(archived_messages) == 12
|
||||
assert len(archived_messages) == 4
|
||||
assert any(m["content"] == "old user 0" for m in session_after.messages)
|
||||
assert not any(
|
||||
m["content"] == "old user 0"
|
||||
@@ -705,7 +724,7 @@ class TestAutoCompactEdgeCases:
|
||||
await loop._process_message(msg)
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert [message["content"] for message in archived_messages] == ["previous message"]
|
||||
assert archived_messages == []
|
||||
assert any(m["content"] == "previous message" for m in session_after.messages)
|
||||
assert any(m["content"] == "interrupted response" for m in session_after.messages)
|
||||
|
||||
@@ -893,7 +912,7 @@ class TestProactiveAutoCompact:
|
||||
assert len(session_after.get_history(max_messages=10)) == (
|
||||
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||
)
|
||||
assert len(archived_messages) == 10
|
||||
assert len(archived_messages) == 2
|
||||
entry = loop.auto_compact._summaries.get("cli:test")
|
||||
assert entry is not None
|
||||
assert entry[0] == "User chatted about old things."
|
||||
|
||||
@@ -405,37 +405,13 @@ class TestCheckExpired:
|
||||
scheduler.assert_not_called()
|
||||
assert "dream:20260602-155256" not in ac._archiving
|
||||
|
||||
def test_short_unarchived_session_schedules(self):
|
||||
"""A short idle session still needs an archive entry for Dream."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
last_active = datetime(2026, 1, 1, 10, 0, 0)
|
||||
session = _make_session("cli:short", updated_at=last_active)
|
||||
_add_turns(session, 2)
|
||||
mock_sm.list_sessions.return_value = [
|
||||
{"key": "cli:short", "updated_at": last_active.isoformat()},
|
||||
]
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
|
||||
scheduled = []
|
||||
|
||||
def scheduler(coro):
|
||||
scheduled.append(coro)
|
||||
coro.close()
|
||||
|
||||
ac.check_expired(scheduler, _runtime)
|
||||
|
||||
assert len(scheduled) == 1
|
||||
assert ac._archiving == {"cli:short"}
|
||||
|
||||
def test_fully_archived_session_skips(self):
|
||||
def test_already_trimmed_session_skips(self):
|
||||
"""Expired session with no removable tail should not be re-scheduled."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
last_active = datetime(2026, 1, 1, 10, 0, 0)
|
||||
session = _make_session("cli:done", updated_at=last_active)
|
||||
_add_turns(session, 2)
|
||||
session.last_consolidated = len(session.messages)
|
||||
mock_sm.list_sessions.return_value = [
|
||||
{"key": "cli:done", "updated_at": last_active.isoformat()},
|
||||
]
|
||||
|
||||
@@ -391,25 +391,6 @@ class TestConsolidatorTokenBudget:
|
||||
assert len(captured["history"]) == 160
|
||||
assert captured["history"][0]["content"].endswith("msg-0")
|
||||
|
||||
async def test_estimate_includes_recent_archived_replay(self, consolidator, runtime):
|
||||
session = Session(key="test:archived-replay")
|
||||
for i in range(10):
|
||||
session.add_message("user", f"msg-{i}")
|
||||
session.last_consolidated = len(session.messages)
|
||||
|
||||
captured: dict[str, list[dict]] = {}
|
||||
|
||||
def build_messages(**kwargs):
|
||||
captured["history"] = kwargs["history"]
|
||||
return kwargs["history"]
|
||||
|
||||
consolidator._build_messages = build_messages
|
||||
|
||||
consolidator.estimate_session_prompt_tokens(session, runtime=runtime)
|
||||
|
||||
assert len(captured["history"]) == 8
|
||||
assert captured["history"][0]["content"] == "msg-2"
|
||||
|
||||
async def test_replay_window_overflow_is_archived_even_under_token_budget(
|
||||
self,
|
||||
consolidator,
|
||||
@@ -639,7 +620,7 @@ class TestCompactIdleSession:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archives_full_tail_preserves_messages_and_replays_recent_suffix(
|
||||
async def test_archives_prefix_preserves_messages_and_hides_prefix(
|
||||
self, real_consolidator, mock_provider, runtime
|
||||
):
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
@@ -664,7 +645,7 @@ class TestCompactIdleSession:
|
||||
reloaded = sessions.get_or_create("cli:test")
|
||||
assert len(reloaded.messages) == 40
|
||||
assert reloaded.messages[0]["content"] == "user msg 0"
|
||||
assert reloaded.last_consolidated == 40
|
||||
assert reloaded.last_consolidated == 32
|
||||
assert reloaded.provider_state is None
|
||||
visible = reloaded.get_history(max_messages=40)
|
||||
assert len(visible) == 8
|
||||
@@ -676,82 +657,6 @@ class TestCompactIdleSession:
|
||||
assert "last_active" in meta
|
||||
assert reloaded.updated_at == old_ts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_idle_session_archives_once(
|
||||
self, real_consolidator, mock_provider, store, runtime
|
||||
):
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="Short summary.", finish_reason="stop"
|
||||
)
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:short")
|
||||
session.add_message("user", "hello")
|
||||
session.add_message("assistant", "hi")
|
||||
sessions.save(session)
|
||||
|
||||
first = await real_consolidator.compact_idle_session("cli:short", runtime=runtime)
|
||||
second = await real_consolidator.compact_idle_session("cli:short", runtime=runtime)
|
||||
|
||||
assert first == "Short summary."
|
||||
assert second == ""
|
||||
mock_provider.chat_with_retry.assert_awaited_once()
|
||||
assert len(store.read_unprocessed_history(since_cursor=0)) == 1
|
||||
reloaded = sessions.get_or_create("cli:short")
|
||||
assert reloaded.last_consolidated == 2
|
||||
assert [message["content"] for message in reloaded.get_history()] == ["hello", "hi"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_messages_advance_existing_archive_progress(
|
||||
self, real_consolidator, mock_provider, runtime
|
||||
):
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="Summary.", finish_reason="stop"
|
||||
)
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:incremental")
|
||||
session.add_message("user", "first user")
|
||||
session.add_message("assistant", "first assistant")
|
||||
sessions.save(session)
|
||||
|
||||
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
|
||||
current = sessions.get_or_create("cli:incremental")
|
||||
current.add_message("user", "second user")
|
||||
current.add_message("assistant", "second assistant")
|
||||
sessions.save(current)
|
||||
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
|
||||
|
||||
assert mock_provider.chat_with_retry.await_count == 2
|
||||
latest_prompt = mock_provider.chat_with_retry.await_args_list[-1].kwargs["messages"][1][
|
||||
"content"
|
||||
]
|
||||
assert "second user" in latest_prompt
|
||||
assert "first user" not in latest_prompt
|
||||
assert sessions.get_or_create("cli:incremental").last_consolidated == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_append_remains_unarchived(
|
||||
self, real_consolidator, mock_provider, runtime
|
||||
):
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:concurrent")
|
||||
session.add_message("user", "captured user")
|
||||
session.add_message("assistant", "captured assistant")
|
||||
sessions.save(session)
|
||||
|
||||
async def append_during_archive(**_kwargs):
|
||||
current = sessions.get_or_create("cli:concurrent")
|
||||
current.add_message("user", "late user")
|
||||
current.add_message("assistant", "late assistant")
|
||||
return LLMResponse(content="Summary.", finish_reason="stop")
|
||||
|
||||
mock_provider.chat_with_retry.side_effect = append_during_archive
|
||||
|
||||
await real_consolidator.compact_idle_session("cli:concurrent", runtime=runtime)
|
||||
|
||||
reloaded = sessions.get_or_create("cli:concurrent")
|
||||
assert len(reloaded.messages) == 4
|
||||
assert reloaded.last_consolidated == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
|
||||
self, real_consolidator, mock_provider, runtime
|
||||
@@ -781,10 +686,10 @@ class TestCompactIdleSession:
|
||||
assert "CORRECTED_FINAL_RESULT_alpha" in summarized
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_dumps_full_archive_batch_on_llm_failure(
|
||||
async def test_raw_dumps_only_dropped_messages_on_llm_failure(
|
||||
self, real_consolidator, mock_provider, store, runtime
|
||||
):
|
||||
"""The fallback covers the same full range as successful idle archival."""
|
||||
"""Extra summary context must not enter raw fallback. Regression for #4264."""
|
||||
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:rawdrop")
|
||||
@@ -802,7 +707,7 @@ class TestCompactIdleSession:
|
||||
raw = "\n".join(e["content"] for e in store.read_unprocessed_history(since_cursor=0))
|
||||
assert "[RAW]" in raw
|
||||
assert "user msg 0" in raw
|
||||
assert "RETAINED_SUFFIX_marker" in raw
|
||||
assert "RETAINED_SUFFIX_marker" not in raw
|
||||
reloaded = sessions.get_or_create("cli:rawdrop")
|
||||
assert len(reloaded.messages) == 38
|
||||
assert reloaded.messages[-1]["content"] == "RETAINED_SUFFIX_marker"
|
||||
@@ -900,12 +805,8 @@ class TestCompactIdleSession:
|
||||
reloaded = sessions.get_or_create("cli:fail")
|
||||
assert len(reloaded.messages) == 20
|
||||
assert reloaded.messages[0]["content"] == "u0"
|
||||
assert reloaded.last_consolidated == 20
|
||||
assert reloaded.last_consolidated == 16
|
||||
assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [
|
||||
"u6",
|
||||
"a6",
|
||||
"u7",
|
||||
"a7",
|
||||
"u8",
|
||||
"a8",
|
||||
"u9",
|
||||
@@ -934,10 +835,10 @@ class TestCompactIdleSession:
|
||||
assert result == "Tail summary."
|
||||
reloaded = sessions.get_or_create("cli:offset")
|
||||
assert len(reloaded.messages) == 60
|
||||
assert reloaded.last_consolidated == 60
|
||||
assert reloaded.last_consolidated == 56
|
||||
|
||||
# Verify only the unconsolidated tail was processed:
|
||||
# All 10 unconsolidated messages (50-59) are archived exactly once.
|
||||
# 10 unconsolidated messages (50-59), keep suffix of 4 → archive 6
|
||||
archived_call = mock_provider.chat_with_retry.call_args
|
||||
user_content = archived_call.kwargs["messages"][1]["content"]
|
||||
# Should contain only tail messages, not early ones
|
||||
@@ -945,7 +846,7 @@ class TestCompactIdleSession:
|
||||
assert "u25" in user_content or "a25" in user_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_archive_keeps_extended_legal_replay_suffix(
|
||||
async def test_extended_suffix_archives_only_hidden_prefix(
|
||||
self,
|
||||
real_consolidator,
|
||||
mock_provider,
|
||||
@@ -969,7 +870,7 @@ class TestCompactIdleSession:
|
||||
|
||||
reloaded = sessions.get_or_create("cli:noncontiguous")
|
||||
assert len(reloaded.messages) == 25
|
||||
assert reloaded.last_consolidated == 25
|
||||
assert reloaded.last_consolidated == 14
|
||||
assert [m["content"] for m in reloaded.get_history(max_messages=25)] == [
|
||||
"user-14",
|
||||
"assistant-00",
|
||||
@@ -1133,7 +1034,7 @@ class TestConsolidatorSessionRefresh:
|
||||
|
||||
session_after = sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 40
|
||||
assert session_after.last_consolidated == 40
|
||||
assert session_after.last_consolidated == 32
|
||||
assert len(session_after.get_history(max_messages=40)) == 8
|
||||
|
||||
|
||||
|
||||
@@ -1074,8 +1074,9 @@ async def test_process_message_persists_media_paths_on_user_turn(tmp_path: Path)
|
||||
"""User turns that attach images must record the media paths alongside
|
||||
the text so the webui can rehydrate previews on session replay.
|
||||
|
||||
The WebUI transcript replay can use these paths to restore attachment
|
||||
previews when it backfills from canonical session history.
|
||||
This is the producer half of the signed-media-URL round-trip: paths are
|
||||
stored here, then :meth:`WebSocketChannel._augment_media_urls` maps them
|
||||
onto signed URLs on the way out.
|
||||
"""
|
||||
img_a = tmp_path / "uuid-1.png"
|
||||
img_a.write_bytes(_PNG_1X1)
|
||||
|
||||
@@ -1092,23 +1092,6 @@ class TestMainMenuUpdate:
|
||||
assert config.providers.openai.api_key == "${UNRELATED_MISSING_KEY}"
|
||||
assert config.providers.openai_codex.proxy == "${CODEX_PROXY}"
|
||||
|
||||
def test_quick_start_openai_codex_reports_incomplete_installation(self, monkeypatch):
|
||||
import oauth_cli_kit
|
||||
|
||||
messages: list[str] = []
|
||||
monkeypatch.delattr(oauth_cli_kit, "get_token")
|
||||
monkeypatch.setattr(
|
||||
onboard_wizard.console,
|
||||
"print",
|
||||
lambda message, *args, **kwargs: messages.append(str(message)),
|
||||
)
|
||||
|
||||
assert onboard_wizard._quick_start_oauth_login(Config(), "openai_codex") is False
|
||||
assert messages == [
|
||||
"[red]This nanobot installation is missing the required oauth-cli-kit package. "
|
||||
"Reinstall or upgrade nanobot-ai using the same installation method.[/red]"
|
||||
]
|
||||
|
||||
def test_quick_start_openai_codex_runs_interactive_login_for_bad_cached_token(
|
||||
self, monkeypatch
|
||||
):
|
||||
|
||||
@@ -208,71 +208,6 @@ def test_orphan_trim_with_last_consolidated():
|
||||
assert all(m.get("role") != "tool" or m["tool_call_id"].startswith("new_") for m in history)
|
||||
|
||||
|
||||
def test_get_history_replays_recent_messages_after_full_archive():
|
||||
session = Session(key="test:fully-archived")
|
||||
for i in range(10):
|
||||
session.messages.append({"role": "user", "content": f"u{i}"})
|
||||
session.messages.append({"role": "assistant", "content": f"a{i}"})
|
||||
session.last_consolidated = len(session.messages)
|
||||
|
||||
history = session.get_history(max_messages=100)
|
||||
|
||||
assert [message["content"] for message in history] == [
|
||||
"u6",
|
||||
"a6",
|
||||
"u7",
|
||||
"a7",
|
||||
"u8",
|
||||
"a8",
|
||||
"u9",
|
||||
"a9",
|
||||
]
|
||||
|
||||
|
||||
def test_get_history_extends_compacted_replay_to_preceding_user():
|
||||
session = Session(key="test:compacted-tool-turn")
|
||||
session.messages.extend(
|
||||
[
|
||||
{"role": "user", "content": "old"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "user", "content": "run tools"},
|
||||
*_tool_turn("keep", 0),
|
||||
*_tool_turn("keep", 1),
|
||||
*_tool_turn("keep", 2),
|
||||
{"role": "assistant", "content": "done"},
|
||||
]
|
||||
)
|
||||
session.last_consolidated = len(session.messages)
|
||||
|
||||
history = session.get_history(max_messages=100)
|
||||
|
||||
assert history[0]["content"] == "run tools"
|
||||
assert history[-1]["content"] == "done"
|
||||
_assert_no_orphans(history)
|
||||
|
||||
|
||||
def test_compacted_tool_turn_can_extend_past_message_cap():
|
||||
session = Session(key="test:long-compacted-tool-turn")
|
||||
session.messages.extend(
|
||||
[
|
||||
{"role": "user", "content": "old"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "user", "content": "run many tools"},
|
||||
]
|
||||
)
|
||||
for i in range(50):
|
||||
session.messages.extend(_tool_turn("keep", i))
|
||||
session.messages.append({"role": "assistant", "content": "done"})
|
||||
session.last_consolidated = len(session.messages)
|
||||
|
||||
history = session.get_history(max_messages=120)
|
||||
|
||||
assert len(history) > 120
|
||||
assert history[0]["content"] == "run many tools"
|
||||
assert history[-1]["content"] == "done"
|
||||
_assert_no_orphans(history)
|
||||
|
||||
|
||||
# --- Edge: no tool messages at all ---
|
||||
|
||||
def test_no_tool_messages_unchanged():
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
|
||||
def _assert_no_orphans(history: list[dict]) -> None:
|
||||
declared = {
|
||||
tc["id"]
|
||||
for m in history
|
||||
if m.get("role") == "assistant"
|
||||
for tc in (m.get("tool_calls") or [])
|
||||
}
|
||||
orphans = [
|
||||
m.get("tool_call_id")
|
||||
for m in history
|
||||
if m.get("role") == "tool" and m.get("tool_call_id") not in declared
|
||||
]
|
||||
assert orphans == [], f"orphan tool_call_ids: {orphans}"
|
||||
|
||||
|
||||
def _delivery(content: str) -> dict:
|
||||
return {"role": "assistant", "content": content, "_channel_delivery": True}
|
||||
|
||||
|
||||
def _tool_turn(prefix: str, idx: int) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": f"{prefix}_{idx}_a",
|
||||
"type": "function",
|
||||
"function": {"name": "x", "arguments": "{}"},
|
||||
},
|
||||
{
|
||||
"id": f"{prefix}_{idx}_b",
|
||||
"type": "function",
|
||||
"function": {"name": "y", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": f"{prefix}_{idx}_a", "name": "x", "content": "ok"},
|
||||
{"role": "tool", "tool_call_id": f"{prefix}_{idx}_b", "name": "y", "content": "ok"},
|
||||
]
|
||||
|
||||
|
||||
def _contents(messages: list[dict]) -> list[str]:
|
||||
return [m.get("content") for m in messages]
|
||||
|
||||
|
||||
def _has_delivery(messages: list[dict]) -> bool:
|
||||
return any(m.get("_channel_delivery") for m in messages)
|
||||
|
||||
|
||||
# --- Hard-cap trimming must preserve a proactive delivery the user replied to ---
|
||||
|
||||
|
||||
def test_retain_hard_cap_keeps_delivery_before_user():
|
||||
session = Session(key="test:cap-delivery")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("Remember to drink water"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "great"})
|
||||
|
||||
session.retain_recent_legal_suffix(3)
|
||||
|
||||
assert _has_delivery(session.messages), "delivery dropped by hard-cap trim"
|
||||
assert _contents(session.messages) == [
|
||||
"Remember to drink water",
|
||||
"ok",
|
||||
"great",
|
||||
]
|
||||
|
||||
|
||||
def test_retain_hard_cap_matches_get_history_boundary():
|
||||
"""The trimmed suffix must start on the same message as get_history()."""
|
||||
session = Session(key="test:cap-boundary")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("You have 3 pending tasks"))
|
||||
session.messages.append({"role": "user", "content": "show them"})
|
||||
session.messages.append({"role": "assistant", "content": "done"})
|
||||
|
||||
expected = session.get_history(max_messages=3)
|
||||
|
||||
session.retain_recent_legal_suffix(3)
|
||||
|
||||
assert _contents(session.messages) == _contents(expected)
|
||||
|
||||
|
||||
def test_retain_extend_to_user_keeps_delivery_before_recovered_user():
|
||||
session = Session(key="test:extend-delivery")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append({"role": "assistant", "content": "work"})
|
||||
session.messages.append(_delivery("Reminder: deploy at 17:00"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "a1"})
|
||||
session.messages.append({"role": "assistant", "content": "a2"})
|
||||
session.messages.append({"role": "assistant", "content": "a3"})
|
||||
|
||||
session.retain_recent_legal_suffix(3, extend_to_user=True)
|
||||
|
||||
assert _has_delivery(session.messages), "delivery dropped by extend_to_user trim"
|
||||
assert session.messages[0]["content"] == "Reminder: deploy at 17:00"
|
||||
assert session.messages[-1]["content"] == "a3"
|
||||
|
||||
|
||||
def test_retain_extend_to_user_matches_get_history_boundary():
|
||||
session = Session(key="test:extend-boundary")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append({"role": "assistant", "content": "work"})
|
||||
session.messages.append(_delivery("Reminder: review the draft"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "a1"})
|
||||
session.messages.append({"role": "assistant", "content": "a2"})
|
||||
session.messages.append({"role": "assistant", "content": "a3"})
|
||||
|
||||
expected = session.get_history(max_messages=3, extend_to_user=True)
|
||||
|
||||
session.retain_recent_legal_suffix(3, extend_to_user=True)
|
||||
|
||||
assert _contents(session.messages) == _contents(expected)
|
||||
|
||||
|
||||
def test_retain_extend_to_user_does_not_extend_delivery_only_tail():
|
||||
session = Session(key="test:extend-no-user")
|
||||
for i in range(4):
|
||||
session.messages.append(_delivery(f"notification {i}"))
|
||||
|
||||
session.retain_recent_legal_suffix(3, extend_to_user=True)
|
||||
|
||||
assert _contents(session.messages) == [
|
||||
"notification 1",
|
||||
"notification 2",
|
||||
"notification 3",
|
||||
]
|
||||
|
||||
|
||||
# --- Only the immediately-preceding delivery is part of the anchor ---
|
||||
|
||||
|
||||
def test_retain_keeps_only_immediate_delivery():
|
||||
session = Session(key="test:multi-delivery")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("old scheduled note"))
|
||||
session.messages.append(_delivery("new scheduled note"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "great"})
|
||||
|
||||
session.retain_recent_legal_suffix(3)
|
||||
|
||||
kept = _contents(session.messages)
|
||||
assert kept == ["new scheduled note", "ok", "great"], kept
|
||||
|
||||
|
||||
def test_retain_drops_delivery_not_adjacent_to_anchor_user():
|
||||
"""A delivery that does not immediately precede the retained user turn is
|
||||
not part of the anchor and should not be force-retained."""
|
||||
session = Session(key="test:nonadjacent")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("unrelated scheduled note"))
|
||||
session.messages.append({"role": "assistant", "content": "reply"})
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "great"})
|
||||
|
||||
session.retain_recent_legal_suffix(2)
|
||||
|
||||
assert not _has_delivery(session.messages)
|
||||
assert _contents(session.messages) == ["ok", "great"]
|
||||
|
||||
|
||||
# --- Delivery preservation through the production entry points ---
|
||||
|
||||
|
||||
def test_enforce_file_cap_keeps_delivery_in_session():
|
||||
session = Session(key="test:cap-delivery")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("Remember to drink water"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "great"})
|
||||
|
||||
archived: list[list[dict]] = []
|
||||
session.enforce_file_cap(on_archive=archived.append, limit=3)
|
||||
|
||||
archived_flat = [m for chunk in archived for m in chunk]
|
||||
assert _has_delivery(session.messages)
|
||||
assert not any(m.get("_channel_delivery") for m in archived_flat)
|
||||
|
||||
|
||||
def test_enforce_file_cap_archives_only_prefix():
|
||||
session = Session(key="test:cap-prefix")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append({"role": "assistant", "content": "first reply"})
|
||||
session.messages.append(_delivery("Remember to drink water"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "great"})
|
||||
|
||||
archived: list[list[dict]] = []
|
||||
session.enforce_file_cap(on_archive=archived.append, limit=3)
|
||||
|
||||
archived_flat = [m for chunk in archived for m in chunk]
|
||||
assert _has_delivery(session.messages)
|
||||
assert _contents(archived_flat) == ["setup", "first reply"]
|
||||
|
||||
|
||||
def test_compact_probe_keeps_delivery_in_visible_suffix():
|
||||
"""compact_idle_session() trims a probe copy with extend_to_user=True; the
|
||||
visible suffix it keeps must still contain the delivery message."""
|
||||
tail = [
|
||||
{"role": "user", "content": "setup"},
|
||||
{"role": "assistant", "content": "work"},
|
||||
_delivery("Reminder: deploy at 17:00"),
|
||||
{"role": "user", "content": "ok"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
{"role": "assistant", "content": "a3"},
|
||||
]
|
||||
probe = Session(key="test:probe", messages=tail, last_consolidated=0)
|
||||
|
||||
probe.retain_recent_legal_suffix(3, extend_to_user=True)
|
||||
|
||||
assert _has_delivery(probe.messages)
|
||||
assert probe.messages[0]["content"] == "Reminder: deploy at 17:00"
|
||||
|
||||
|
||||
# --- Trimming must stay coherent with the rest of replay ---
|
||||
|
||||
|
||||
def test_retain_then_replay_keeps_delivery_and_no_orphans():
|
||||
session = Session(key="test:replay-after-trim")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("You have 3 pending tasks"))
|
||||
session.messages.append({"role": "user", "content": "show them"})
|
||||
session.messages.extend(_tool_turn("cur", 0))
|
||||
session.messages.append({"role": "assistant", "content": "done"})
|
||||
|
||||
session.retain_recent_legal_suffix(6)
|
||||
|
||||
assert _has_delivery(session.messages)
|
||||
history = session.get_history(max_messages=500)
|
||||
_assert_no_orphans(history)
|
||||
assert any(m.get("content") == "You have 3 pending tasks" for m in history)
|
||||
|
||||
|
||||
def test_retain_keeps_delivery_when_user_inside_window():
|
||||
"""When the capped window already contains a user, its immediately
|
||||
preceding delivery must stay attached to it."""
|
||||
session = Session(key="test:window-user")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append({"role": "assistant", "content": "a0"})
|
||||
session.messages.append(_delivery("Reminder"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "a1"})
|
||||
session.messages.append({"role": "assistant", "content": "a2"})
|
||||
|
||||
expected = session.get_history(max_messages=4)
|
||||
|
||||
session.retain_recent_legal_suffix(4)
|
||||
|
||||
assert _has_delivery(session.messages)
|
||||
assert _contents(session.messages) == _contents(expected)
|
||||
@@ -17,7 +17,6 @@ from nanobot.bus.outbound_events import (
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.channels.mattermost.runtime import MattermostChannel
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
@@ -312,38 +311,6 @@ class TestProgressFiltering:
|
||||
assert manager._should_send_progress("mock", tool_hint=False) is False
|
||||
assert manager._should_send_progress("mock", tool_hint=True) is False
|
||||
|
||||
def test_channel_config_defaults_do_not_override_global_policy(self, bus):
|
||||
manager = ChannelManager.__new__(ChannelManager)
|
||||
manager.config = Config.model_validate({
|
||||
"channels": {
|
||||
"sendProgress": False,
|
||||
"sendToolHints": False,
|
||||
},
|
||||
})
|
||||
manager.bus = bus
|
||||
|
||||
channel = manager._build_channel(
|
||||
"mattermost",
|
||||
MattermostChannel,
|
||||
{"enabled": True},
|
||||
)
|
||||
|
||||
assert channel.send_progress is False
|
||||
assert channel.send_tool_hints is False
|
||||
|
||||
opted_in = manager._build_channel(
|
||||
"mattermost",
|
||||
MattermostChannel,
|
||||
{
|
||||
"enabled": True,
|
||||
"sendProgress": True,
|
||||
"sendToolHints": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert opted_in.send_progress is True
|
||||
assert opted_in.send_tool_hints is True
|
||||
|
||||
def test_progress_visibility_returns_false_for_missing_channel(self, manager):
|
||||
assert manager._should_send_progress("nonexistent", tool_hint=False) is False
|
||||
assert manager._should_send_progress("nonexistent", tool_hint=True) is False
|
||||
|
||||
@@ -686,10 +686,7 @@ def test_provider_login_openai_codex_handles_missing_oauth_symbol(monkeypatch):
|
||||
result = runner.invoke(app, ["provider", "login", "openai-codex"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert (
|
||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||
"Reinstall or upgrade nanobot-ai using the same installation method."
|
||||
) in re.sub(r"\s+", " ", result.stdout)
|
||||
assert "oauth_cli_kit not installed" in result.stdout
|
||||
assert result.exception is not None
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from unittest.mock import patch, sentinel
|
||||
|
||||
from nanobot.providers import openai_compat_provider
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
from nanobot.providers.registry import ProviderSpec
|
||||
|
||||
@@ -60,22 +59,3 @@ async def test_openai_compat_provider_timeout_can_be_overridden_by_env(monkeypat
|
||||
await provider._ensure_client()
|
||||
|
||||
assert mock_async_openai.call_args.kwargs["timeout"] == 45.0
|
||||
|
||||
|
||||
async def test_missing_langfuse_warning_recommends_plugin_command(monkeypatch) -> None:
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret")
|
||||
monkeypatch.setattr(openai_compat_provider, "AsyncOpenAI", None)
|
||||
|
||||
with (
|
||||
patch("importlib.util.find_spec", return_value=None),
|
||||
patch("openai.AsyncOpenAI") as mock_async_openai,
|
||||
patch("nanobot.providers.openai_compat_provider.logger.warning") as mock_warning,
|
||||
):
|
||||
provider = OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1")
|
||||
await provider._ensure_client()
|
||||
|
||||
mock_warning.assert_called_once_with(
|
||||
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
|
||||
"run `nanobot plugins enable langfuse` to enable tracing"
|
||||
)
|
||||
mock_async_openai.assert_called_once()
|
||||
|
||||
@@ -57,7 +57,7 @@ def test_loaded_corrupt_offset_keeps_messages(tmp_path: Path):
|
||||
def test_valid_offset_is_preserved():
|
||||
session = _session(10, 4)
|
||||
assert session.last_consolidated == 4
|
||||
assert len(session.get_history()) == 8
|
||||
assert len(session.get_history()) == 6
|
||||
|
||||
|
||||
def test_loaded_null_metadata_becomes_empty_dict(tmp_path: Path):
|
||||
|
||||
@@ -820,6 +820,4 @@ async def test_olostep_package_missing_returns_install_hint(monkeypatch):
|
||||
tool = _tool(provider="olostep", api_key="olostep-key")
|
||||
result = await tool.execute(query="test query")
|
||||
|
||||
assert result == (
|
||||
"Error: Olostep support is not installed. Run `nanobot plugins enable olostep`."
|
||||
)
|
||||
assert result == "Error: olostep package not installed. Run: pip install olostep"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Tests for subagent announce text shaping on external channel surfaces."""
|
||||
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||
from nanobot.utils.subagent_channel_display import (
|
||||
scrub_subagent_announce_body,
|
||||
scrub_subagent_messages_for_channel,
|
||||
)
|
||||
|
||||
|
||||
def test_scrub_subagent_keeps_header_and_result_only() -> None:
|
||||
@@ -19,6 +22,24 @@ Summarize this naturally for the user. Keep it brief."""
|
||||
assert "Summarize" not in out
|
||||
|
||||
|
||||
def test_scrub_subagent_messages_mutates_matching_rows() -> None:
|
||||
messages: list[dict] = [
|
||||
{"role": "assistant", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"[Subagent 'x' completed successfully]\n\nTask: t\n\nResult:\nr\n\nSummarize this naturally"
|
||||
),
|
||||
"injected_event": "subagent_result",
|
||||
},
|
||||
]
|
||||
scrub_subagent_messages_for_channel(messages)
|
||||
assert messages[0]["content"] == "hi"
|
||||
assert "Task:" not in messages[1]["content"]
|
||||
assert "[Subagent 'x' completed successfully]" in messages[1]["content"]
|
||||
assert "r" in messages[1]["content"]
|
||||
|
||||
|
||||
def test_scrub_normalizes_crlf_before_result_marker() -> None:
|
||||
raw = "[Subagent 'z' failed]\r\n\r\nTask: x\r\n\r\nResult:\r\none line\r\n\r\nSummarize this naturally"
|
||||
out = scrub_subagent_announce_body(raw)
|
||||
|
||||
@@ -26,7 +26,6 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
||||
{
|
||||
"pinned_keys": ["websocket:a", "websocket:a", "", 123],
|
||||
"archived_keys": ["websocket:b"],
|
||||
"session_order": ["websocket:b", "websocket:a", "websocket:b"],
|
||||
"title_overrides": {"websocket:a": " Release notes ", "bad": ""},
|
||||
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
||||
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
||||
@@ -42,7 +41,6 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
||||
assert state["schema_version"] == 1
|
||||
assert state["pinned_keys"] == ["websocket:a"]
|
||||
assert state["archived_keys"] == ["websocket:b"]
|
||||
assert state["session_order"] == ["websocket:b", "websocket:a"]
|
||||
assert state["title_overrides"] == {"websocket:a": "Release notes"}
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
||||
@@ -63,20 +61,17 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
||||
{
|
||||
"pinned_keys": ["websocket:a"],
|
||||
"archived_keys": ["websocket:b"],
|
||||
"session_order": ["websocket:b", "websocket:a"],
|
||||
"title_overrides": {"websocket:a": "Release"},
|
||||
"project_name_overrides": {"/repo": "Core"},
|
||||
"view": {"density": "compact", "show_previews": True, "sort": "manual"},
|
||||
"view": {"density": "compact", "show_previews": True},
|
||||
}
|
||||
)
|
||||
|
||||
assert state["pinned_keys"] == ["websocket:a"]
|
||||
assert state["archived_keys"] == ["websocket:b"]
|
||||
assert state["session_order"] == ["websocket:b", "websocket:a"]
|
||||
assert state["title_overrides"] == {"websocket:a": "Release"}
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
assert state["view"]["density"] == "compact"
|
||||
assert state["view"]["show_previews"] is True
|
||||
assert state["view"]["sort"] == "manual"
|
||||
assert webui_sidebar_state_path().is_file()
|
||||
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
|
||||
|
||||
@@ -1624,10 +1624,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||
with pytest.raises(WebUISettingsError) as exc:
|
||||
login_oauth_provider({"provider": ["openai-codex"]})
|
||||
|
||||
assert str(exc.value) == (
|
||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||
"Reinstall or upgrade nanobot-ai using the same installation method."
|
||||
)
|
||||
assert "oauth_cli_kit not installed. Run: pip install oauth-cli-kit" in str(exc.value)
|
||||
|
||||
|
||||
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||
@@ -1645,10 +1642,7 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||
with pytest.raises(WebUISettingsError) as exc:
|
||||
login_oauth_provider({"provider": ["github-copilot"]})
|
||||
|
||||
assert str(exc.value) == (
|
||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||
"Reinstall or upgrade nanobot-ai using the same installation method."
|
||||
)
|
||||
assert "oauth_cli_kit not installed. Run: pip install oauth-cli-kit" in str(exc.value)
|
||||
|
||||
|
||||
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
|
||||
+3
-26
@@ -1004,7 +1004,6 @@ function Shell({
|
||||
useState<Record<string, WorkspaceScopePayload>>({});
|
||||
const runningChatIdsRef = useRef<Set<string>>(new Set());
|
||||
const activeChatIdRef = useRef<string | null>(null);
|
||||
const pendingCreatedSessionKeyRef = useRef<string | null>(null);
|
||||
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
|
||||
const effectiveRuntimeSurface =
|
||||
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
||||
@@ -1182,15 +1181,8 @@ function Shell({
|
||||
}, [loading, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
||||
if (pendingCreatedKey && sessions.some((session) => session.key === pendingCreatedKey)) {
|
||||
pendingCreatedSessionKeyRef.current = null;
|
||||
}
|
||||
if (!activeKey || sessions.some((session) => session.key === activeKey)) return;
|
||||
// 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;
|
||||
if (loading || !activeKey) return;
|
||||
if (sessions.some((session) => session.key === activeKey)) return;
|
||||
const currentRoute = readShellRoute();
|
||||
navigate(
|
||||
currentRoute.view === "chat"
|
||||
@@ -1374,11 +1366,9 @@ function Shell({
|
||||
try {
|
||||
const scope = workspaceScope ?? activeWorkspaceScope;
|
||||
const chatId = await createChat(scope);
|
||||
const key = `websocket:${chatId}`;
|
||||
pendingCreatedSessionKeyRef.current = key;
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: key,
|
||||
activeKey: `websocket:${chatId}`,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
setMobileSidebarOpen(false);
|
||||
@@ -1606,17 +1596,6 @@ function Shell({
|
||||
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
|
||||
);
|
||||
|
||||
const onReorderSessions = useCallback(
|
||||
(sessionOrder: string[]) => {
|
||||
void updateSidebarState((current) => ({
|
||||
...current,
|
||||
session_order: sessionOrder,
|
||||
view: { ...current.view, sort: "manual" },
|
||||
}));
|
||||
},
|
||||
[updateSidebarState],
|
||||
);
|
||||
|
||||
const onToggleArchived = useCallback(() => {
|
||||
void updateSidebarState((current) => ({
|
||||
...current,
|
||||
@@ -1937,7 +1916,6 @@ function Shell({
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
onReorderSessions,
|
||||
onToggleGroup,
|
||||
onRequestRenameProject,
|
||||
onNewChatInProject,
|
||||
@@ -1951,7 +1929,6 @@ function Shell({
|
||||
onToggleArchived,
|
||||
pinnedKeys: sidebarState.pinned_keys,
|
||||
archivedKeys: sidebarState.archived_keys,
|
||||
sessionOrder: sidebarState.session_order,
|
||||
titleOverrides: sidebarState.title_overrides,
|
||||
projectNameOverrides: sidebarState.project_name_overrides,
|
||||
collapsedGroups: sidebarState.collapsed_groups,
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
visibleSessionsForGroup,
|
||||
type ChatGroupLabels,
|
||||
} from "@/lib/chat-groups";
|
||||
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||
|
||||
@@ -56,13 +55,11 @@ interface ChatListProps {
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onToggleArchive: (key: string) => void;
|
||||
onReorderSessions?: (keys: string[]) => void;
|
||||
onToggleGroup?: (groupId: string) => void;
|
||||
onRequestRenameProject?: (projectKey: string, label: string) => void;
|
||||
onNewChatInProject?: (projectPath: string, projectName: string) => void;
|
||||
pinnedKeys?: string[];
|
||||
archivedKeys?: string[];
|
||||
sessionOrder?: string[];
|
||||
titleOverrides?: Record<string, string>;
|
||||
projectNameOverrides?: Record<string, string>;
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
@@ -87,13 +84,11 @@ export const ChatList = memo(function ChatList({
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
onReorderSessions,
|
||||
onToggleGroup,
|
||||
onRequestRenameProject,
|
||||
onNewChatInProject,
|
||||
pinnedKeys = [],
|
||||
archivedKeys = [],
|
||||
sessionOrder = [],
|
||||
titleOverrides = {},
|
||||
projectNameOverrides = {},
|
||||
collapsedGroups = {},
|
||||
@@ -111,11 +106,6 @@ export const ChatList = memo(function ChatList({
|
||||
}: ChatListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
|
||||
const [draggedSessionKey, setDraggedSessionKey] = useState<string | null>(null);
|
||||
const [sessionDropTarget, setSessionDropTarget] = useState<{
|
||||
edge: "before" | "after";
|
||||
key: string;
|
||||
} | null>(null);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const labels = useMemo<ChatGroupLabels>(() => ({
|
||||
pinned: t("chat.groups.pinned"),
|
||||
@@ -133,7 +123,6 @@ export const ChatList = memo(function ChatList({
|
||||
archivedKeys,
|
||||
titleOverrides,
|
||||
projectNameOverrides,
|
||||
sessionOrder,
|
||||
showArchived,
|
||||
sort,
|
||||
defaultWorkspacePath,
|
||||
@@ -147,7 +136,6 @@ export const ChatList = memo(function ChatList({
|
||||
sort,
|
||||
titleOverrides,
|
||||
projectNameOverrides,
|
||||
sessionOrder,
|
||||
defaultWorkspacePath,
|
||||
],
|
||||
);
|
||||
@@ -167,21 +155,6 @@ export const ChatList = memo(function ChatList({
|
||||
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
|
||||
[limitedGroups],
|
||||
);
|
||||
const pinned = useMemo(() => new Set(pinnedKeys), [pinnedKeys]);
|
||||
const archived = useMemo(() => new Set(archivedKeys), [archivedKeys]);
|
||||
const sessionLanes = useMemo(() => {
|
||||
const lanes = new Map<string, string>();
|
||||
for (const group of groups) {
|
||||
const scope = group.id.startsWith("date:") ? "timeline" : group.id;
|
||||
for (const session of group.sessions) {
|
||||
const status = pinned.has(session.key)
|
||||
? "pinned"
|
||||
: archived.has(session.key) ? "archived" : "normal";
|
||||
lanes.set(session.key, `${scope}:${status}`);
|
||||
}
|
||||
}
|
||||
return lanes;
|
||||
}, [archived, groups, pinned]);
|
||||
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,30 +177,13 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
}
|
||||
|
||||
const pinned = new Set(pinnedKeys);
|
||||
const archived = new Set(archivedKeys);
|
||||
const running = new Set(runningChatIds);
|
||||
const updated = new Set(updatedChatIds);
|
||||
const compact = density === "compact";
|
||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||
|
||||
const canReorderSession = (targetKey: string) => (
|
||||
!!draggedSessionKey
|
||||
&& draggedSessionKey !== targetKey
|
||||
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
|
||||
);
|
||||
const reorderSession = (targetKey: string, edge: "before" | "after") => {
|
||||
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
|
||||
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
|
||||
const reordered = keys.filter((key) => key !== draggedSessionKey);
|
||||
const targetIndex = reordered.indexOf(targetKey);
|
||||
if (targetIndex < 0) return;
|
||||
reordered.splice(targetIndex + (edge === "after" ? 1 : 0), 0, draggedSessionKey);
|
||||
const groupedKeys = new Set(keys);
|
||||
onReorderSessions([
|
||||
...reordered,
|
||||
...sessionOrder.filter((key) => !groupedKeys.has(key)),
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
||||
<SidebarSelectionHighlight
|
||||
@@ -304,41 +260,7 @@ export const ChatList = memo(function ChatList({
|
||||
? "updated"
|
||||
: null;
|
||||
return (
|
||||
<li
|
||||
key={s.key}
|
||||
className="relative min-w-0"
|
||||
onDragOver={(event) => {
|
||||
if (!canReorderSession(s.key)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setSessionDropTarget({
|
||||
key: s.key,
|
||||
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
|
||||
});
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!canReorderSession(s.key)) return;
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const edge = event.clientY < rect.top + rect.height / 2
|
||||
? "before"
|
||||
: "after";
|
||||
reorderSession(s.key, edge);
|
||||
setDraggedSessionKey(null);
|
||||
setSessionDropTarget(null);
|
||||
}}
|
||||
>
|
||||
{sessionDropTarget?.key === s.key ? (
|
||||
<span
|
||||
aria-hidden
|
||||
data-session-drop-edge={sessionDropTarget.edge}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-2 z-20 h-0.5 rounded-full bg-primary",
|
||||
sessionDropTarget.edge === "before" ? "-top-px" : "-bottom-px",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<li key={s.key} className="min-w-0">
|
||||
<div
|
||||
ref={active ? activeRowRef : undefined}
|
||||
data-chat-row={s.key}
|
||||
@@ -354,22 +276,10 @@ export const ChatList = memo(function ChatList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.key)}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
setDraggedSessionKey(s.key);
|
||||
setSessionDropTarget(null);
|
||||
writeDraggedSession(event.dataTransfer, s.key);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
clearDraggedSession();
|
||||
setDraggedSessionKey(null);
|
||||
setSessionDropTarget(null);
|
||||
}}
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={tooltipTitle}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 overflow-hidden text-left",
|
||||
"cursor-grab active:cursor-grabbing",
|
||||
compact ? "py-1" : "py-1.5",
|
||||
projectMode && "pl-7",
|
||||
)}
|
||||
|
||||
@@ -182,7 +182,6 @@ export function SessionMentionToken({
|
||||
testId={`${testIdPrefix}-session-mention-${mention.name}`}
|
||||
title={`Session: ${mention.title || mention.name}`}
|
||||
color={INLINE_TOKEN_HIGHLIGHT_COLOR}
|
||||
className={variant === "composer" ? "font-normal" : undefined}
|
||||
>
|
||||
{label}
|
||||
</InlineTokenHighlight>
|
||||
@@ -223,7 +222,6 @@ export function CliAppMentionToken({
|
||||
testId={`${testIdPrefix}-cli-mention-${app.name}`}
|
||||
title={t("thread.composer.mentions.cliTitle", { name: app.display_name || app.name })}
|
||||
color={color}
|
||||
className={variant === "composer" ? "font-normal" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn("relative inline-block", showLogo && "text-transparent")}
|
||||
@@ -280,7 +278,6 @@ export function McpPresetMentionToken({
|
||||
testId={`${testIdPrefix}-mcp-mention-${preset.name}`}
|
||||
title={t("thread.composer.mentions.mcpTitle", { name: preset.display_name || preset.name })}
|
||||
color={color}
|
||||
className={variant === "composer" ? "font-normal" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn("relative inline-block", showLogo && "text-transparent")}
|
||||
|
||||
@@ -724,7 +724,8 @@ function UserImageCell({
|
||||
aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
|
||||
className={cn(
|
||||
tileClasses,
|
||||
"block cursor-zoom-in p-0",
|
||||
"block cursor-zoom-in p-0 transition-transform duration-150 motion-reduce:transition-none",
|
||||
"hover:scale-[1.01] hover:ring-2 hover:ring-primary/25",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -40,7 +40,6 @@ interface SidebarProps {
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onToggleArchive: (key: string) => void;
|
||||
onReorderSessions: (keys: string[]) => void;
|
||||
onToggleGroup: (groupId: string) => void;
|
||||
onRequestRenameProject: (projectKey: string, label: string) => void;
|
||||
onNewChatInProject: (projectPath: string, projectName: string) => void;
|
||||
@@ -58,7 +57,6 @@ interface SidebarProps {
|
||||
collapsed?: boolean;
|
||||
pinnedKeys?: string[];
|
||||
archivedKeys?: string[];
|
||||
sessionOrder?: string[];
|
||||
titleOverrides?: Record<string, string>;
|
||||
projectNameOverrides?: Record<string, string>;
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
@@ -229,13 +227,11 @@ export function Sidebar(props: SidebarProps) {
|
||||
onTogglePin={props.onTogglePin}
|
||||
onRequestRename={props.onRequestRename}
|
||||
onToggleArchive={props.onToggleArchive}
|
||||
onReorderSessions={props.onReorderSessions}
|
||||
onToggleGroup={props.onToggleGroup}
|
||||
onRequestRenameProject={props.onRequestRenameProject}
|
||||
onNewChatInProject={props.onNewChatInProject}
|
||||
pinnedKeys={props.pinnedKeys}
|
||||
archivedKeys={props.archivedKeys}
|
||||
sessionOrder={props.sessionOrder}
|
||||
titleOverrides={props.titleOverrides}
|
||||
projectNameOverrides={props.projectNameOverrides}
|
||||
collapsedGroups={props.collapsedGroups}
|
||||
|
||||
@@ -3312,7 +3312,6 @@ function ModelsSettings({
|
||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
|
||||
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
|
||||
@@ -3339,10 +3338,6 @@ function ModelsSettings({
|
||||
})),
|
||||
];
|
||||
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
|
||||
const activeEditorRowKey =
|
||||
editorRowKey ??
|
||||
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
|
||||
null;
|
||||
useEffect(() => {
|
||||
setAdvancedOpen(false);
|
||||
}, [editorOpen, selectedPreset?.name]);
|
||||
@@ -3376,15 +3371,11 @@ function ModelsSettings({
|
||||
form.temperature < 0 ||
|
||||
form.temperature > 2;
|
||||
const selectedPresetReferenced = Boolean(
|
||||
selectedPreset && callOrder.includes(selectedPreset.name),
|
||||
selectedPreset && (settings.model_call_order ?? []).includes(selectedPreset.name),
|
||||
);
|
||||
const callOrderBusy = orderSaving || saving;
|
||||
const selectPreset = (
|
||||
preset: SettingsPayload["model_presets"][number],
|
||||
rowKey: string,
|
||||
) => {
|
||||
const toggleCurrentPreset =
|
||||
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
|
||||
const selectPreset = (preset: SettingsPayload["model_presets"][number]) => {
|
||||
const toggleCurrentPreset = !creating && selectedPreset?.name === preset.name;
|
||||
onSelectConfiguration();
|
||||
if (toggleCurrentPreset) {
|
||||
setEditorOpen((open) => !open);
|
||||
@@ -3401,7 +3392,6 @@ function ModelsSettings({
|
||||
temperature: preset.temperature,
|
||||
reasoningEffort: preset.reasoning_effort ?? "",
|
||||
}));
|
||||
setEditorRowKey(rowKey);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
@@ -3442,184 +3432,6 @@ function ModelsSettings({
|
||||
onChangeCallOrder(next);
|
||||
};
|
||||
|
||||
const renderPresetEditor = () => (
|
||||
<div
|
||||
id="model-preset-editor"
|
||||
data-testid="model-preset-editor"
|
||||
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-[18px] border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
|
||||
>
|
||||
{creating ? (
|
||||
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
|
||||
<span className="text-[13px] font-semibold text-foreground/85">
|
||||
{tx("settings.models.newPreset", "New model preset")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<SettingsRow title={tx("settings.models.presetName", "Preset name")}>
|
||||
<Input
|
||||
autoFocus={creating}
|
||||
value={form.presetLabel}
|
||||
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
|
||||
}
|
||||
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={t("settings.rows.provider")}>
|
||||
<ProviderPicker
|
||||
providers={providerOptions}
|
||||
value={providerValue}
|
||||
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(provider) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: provider === prev.provider ? prev.model : "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{selectedProviderNeedsSignIn ? (
|
||||
<SettingsRow
|
||||
title={tx("settings.oauth.signInRequired", "Sign in required")}
|
||||
description={tx(
|
||||
"settings.oauth.signInBeforeSaving",
|
||||
"Sign in before saving this provider in the preset.",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
|
||||
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
|
||||
className="rounded-full"
|
||||
>
|
||||
{selectedProviderSigningIn ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{selectedProviderSigningIn
|
||||
? tx("settings.oauth.signingIn", "Signing in...")
|
||||
: tx("settings.oauth.signIn", "Sign in")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
<SettingsRow title={t("settings.rows.model")}>
|
||||
<ModelIdPicker
|
||||
token={token}
|
||||
settings={settings}
|
||||
provider={form.provider}
|
||||
value={form.model}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={advancedOpen}
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
className="flex min-h-[62px] w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-[14px] font-medium text-foreground">
|
||||
{tx("settings.models.advancedOptions", "Advanced options")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12px] text-muted-foreground">
|
||||
{tx(
|
||||
"settings.models.advancedSummary",
|
||||
"Context {{context}} · Max {{max}} tokens",
|
||||
{
|
||||
context: formatModelContextWindow(form.contextWindowTokens),
|
||||
max: formatContextWindow(form.maxTokens),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
advancedOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="bg-muted/12 px-4 py-4 sm:px-5">
|
||||
<ModelAdvancedFields
|
||||
maxTokens={form.maxTokens}
|
||||
contextWindowTokens={form.contextWindowTokens}
|
||||
temperature={form.temperature}
|
||||
reasoningEffort={form.reasoningEffort}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
{creating ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="self-start rounded-full text-muted-foreground"
|
||||
disabled={creatingSaving}
|
||||
onClick={() => {
|
||||
setEditorOpen(false);
|
||||
onCancelCreate();
|
||||
}}
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
) : selectedPreset ? (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-full text-muted-foreground hover:text-destructive"
|
||||
disabled={selectedPresetReferenced || saving || orderSaving}
|
||||
aria-describedby={
|
||||
selectedPresetReferenced ? "model-preset-delete-hint" : undefined
|
||||
}
|
||||
onClick={() => onDeleteConfiguration(selectedPreset)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.actions.delete", "Delete")}
|
||||
</Button>
|
||||
{selectedPresetReferenced ? (
|
||||
<span
|
||||
id="model-preset-delete-hint"
|
||||
className="text-[11px] leading-4 text-muted-foreground"
|
||||
>
|
||||
{tx(
|
||||
"settings.models.removeBeforeDelete",
|
||||
"Remove this preset from the call order before deleting it.",
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
disabled={
|
||||
(!creating && !dirty) ||
|
||||
!selectedProviderConfigured ||
|
||||
modelFieldsMissing ||
|
||||
saving ||
|
||||
orderSaving
|
||||
}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving || creatingSaving
|
||||
? tx("settings.actions.saving", "Saving...")
|
||||
: tx("settings.actions.savePreset", "Save preset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
@@ -3683,13 +3495,10 @@ function ModelsSettings({
|
||||
isDropTarget &&
|
||||
draggedCallOrderIndex !== null &&
|
||||
draggedCallOrderIndex < orderIndex;
|
||||
const isSelected =
|
||||
editorOpen &&
|
||||
!creating &&
|
||||
activeEditorRowKey === key &&
|
||||
selectedPreset?.name === name;
|
||||
const presetRow = (
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="listitem"
|
||||
tabIndex={ordered ? 0 : -1}
|
||||
draggable={ordered && !callOrderBusy}
|
||||
aria-label={
|
||||
@@ -3741,7 +3550,7 @@ function ModelsSettings({
|
||||
moveCallOrderItem(orderIndex, 1);
|
||||
} else if ((event.key === "Enter" || event.key === " ") && preset) {
|
||||
event.preventDefault();
|
||||
selectPreset(preset, key);
|
||||
selectPreset(preset);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -3758,7 +3567,6 @@ function ModelsSettings({
|
||||
dropAfterTarget &&
|
||||
"after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5",
|
||||
ordered && draggedCallOrderIndex === orderIndex && "opacity-35",
|
||||
isSelected && "bg-muted/45 hover:bg-muted/45",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
@@ -3773,10 +3581,9 @@ function ModelsSettings({
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selectedPreset?.name === name}
|
||||
aria-expanded={isSelected}
|
||||
aria-controls={isSelected ? "model-preset-editor" : undefined}
|
||||
aria-expanded={selectedPreset?.name === name && editorOpen}
|
||||
disabled={!preset}
|
||||
onClick={() => preset && selectPreset(preset, key)}
|
||||
onClick={() => preset && selectPreset(preset)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{ordered ? (
|
||||
@@ -3821,7 +3628,7 @@ function ModelsSettings({
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isSelected && "rotate-90",
|
||||
selectedPreset?.name === name && editorOpen && "rotate-90",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
@@ -3858,12 +3665,6 @@ function ModelsSettings({
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={key} role="listitem">
|
||||
{presetRow}
|
||||
{isSelected ? renderPresetEditor() : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
@@ -3874,7 +3675,6 @@ function ModelsSettings({
|
||||
className="rounded-full"
|
||||
disabled={callOrderBusy}
|
||||
onClick={() => {
|
||||
setEditorRowKey(null);
|
||||
setEditorOpen(true);
|
||||
onBeginCreate();
|
||||
}}
|
||||
@@ -3894,9 +3694,174 @@ function ModelsSettings({
|
||||
</SettingsStatusMessage>
|
||||
) : null}
|
||||
</div>
|
||||
{creating && editorOpen ? renderPresetEditor() : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{editorOpen && (selectedPreset || creating) ? (
|
||||
<>
|
||||
<div className="flex min-h-[52px] items-center justify-between gap-3 bg-muted/15 px-4 py-3 sm:px-5">
|
||||
<span className="text-[13px] font-semibold text-foreground/85">
|
||||
{creating
|
||||
? tx("settings.models.newPreset", "New model preset")
|
||||
: tx("settings.models.editPreset", "Edit preset")}
|
||||
</span>
|
||||
</div>
|
||||
<SettingsRow title={tx("settings.models.presetName", "Preset name")}>
|
||||
<Input
|
||||
autoFocus={creating}
|
||||
value={form.presetLabel}
|
||||
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
|
||||
}
|
||||
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={t("settings.rows.provider")}>
|
||||
<ProviderPicker
|
||||
providers={providerOptions}
|
||||
value={providerValue}
|
||||
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(provider) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: provider === prev.provider ? prev.model : "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{selectedProviderNeedsSignIn ? (
|
||||
<SettingsRow
|
||||
title={tx("settings.oauth.signInRequired", "Sign in required")}
|
||||
description={tx(
|
||||
"settings.oauth.signInBeforeSaving",
|
||||
"Sign in before saving this provider in the preset.",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
|
||||
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
|
||||
className="rounded-full"
|
||||
>
|
||||
{selectedProviderSigningIn ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{selectedProviderSigningIn
|
||||
? tx("settings.oauth.signingIn", "Signing in...")
|
||||
: tx("settings.oauth.signIn", "Sign in")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
<SettingsRow title={t("settings.rows.model")}>
|
||||
<ModelIdPicker
|
||||
token={token}
|
||||
settings={settings}
|
||||
provider={form.provider}
|
||||
value={form.model}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={advancedOpen}
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
className="flex min-h-[62px] w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-[14px] font-medium text-foreground">
|
||||
{tx("settings.models.advancedOptions", "Advanced options")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12px] text-muted-foreground">
|
||||
{tx(
|
||||
"settings.models.advancedSummary",
|
||||
"Context {{context}} · Max {{max}} tokens",
|
||||
{
|
||||
context: formatModelContextWindow(form.contextWindowTokens),
|
||||
max: formatContextWindow(form.maxTokens),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
advancedOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="bg-muted/12 px-4 py-4 sm:px-5">
|
||||
<ModelAdvancedFields
|
||||
maxTokens={form.maxTokens}
|
||||
contextWindowTokens={form.contextWindowTokens}
|
||||
temperature={form.temperature}
|
||||
reasoningEffort={form.reasoningEffort}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
{creating ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="self-start rounded-full text-muted-foreground"
|
||||
disabled={creatingSaving}
|
||||
onClick={() => {
|
||||
setEditorOpen(false);
|
||||
onCancelCreate();
|
||||
}}
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
) : selectedPreset ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="self-start rounded-full text-muted-foreground hover:text-destructive"
|
||||
disabled={selectedPresetReferenced || saving || orderSaving}
|
||||
title={
|
||||
selectedPresetReferenced
|
||||
? tx(
|
||||
"settings.models.removeBeforeDelete",
|
||||
"Remove this preset from the call order before deleting it.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onDeleteConfiguration(selectedPreset)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.actions.delete", "Delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
disabled={
|
||||
(!creating && !dirty) ||
|
||||
!selectedProviderConfigured ||
|
||||
modelFieldsMissing ||
|
||||
saving ||
|
||||
orderSaving
|
||||
}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving || creatingSaving
|
||||
? tx("settings.actions.saving", "Saving...")
|
||||
: tx("settings.actions.savePreset", "Save preset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { Check, Loader2, Network, RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -34,14 +34,6 @@ export type ChannelConnectStartOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelQrConnectPendingContext = {
|
||||
connect: ChannelConnectPayload;
|
||||
busy: boolean;
|
||||
poll: (
|
||||
params?: Readonly<Record<string, string>>,
|
||||
) => Promise<ChannelConnectPayload | null>;
|
||||
};
|
||||
|
||||
export function ChannelQrConnectFlow({
|
||||
token,
|
||||
channelName,
|
||||
@@ -51,10 +43,6 @@ export function ChannelQrConnectFlow({
|
||||
forceOnRepeat = false,
|
||||
labels,
|
||||
onFeaturesUpdate,
|
||||
pausePolling,
|
||||
renderPending,
|
||||
resolveMessage,
|
||||
suppressSucceeded = false,
|
||||
}: {
|
||||
token: string;
|
||||
channelName: string;
|
||||
@@ -64,10 +52,6 @@ export function ChannelQrConnectFlow({
|
||||
forceOnRepeat?: boolean;
|
||||
labels: ChannelQrConnectLabels;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
pausePolling?: (payload: ChannelConnectPayload) => boolean;
|
||||
renderPending?: (context: ChannelQrConnectPendingContext) => ReactNode;
|
||||
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
|
||||
suppressSucceeded?: boolean;
|
||||
}) {
|
||||
const pageVisible = usePageVisibility();
|
||||
const { t } = useTranslation();
|
||||
@@ -88,10 +72,6 @@ export function ChannelQrConnectFlow({
|
||||
const pending = connect?.status === "pending";
|
||||
const succeeded = connect?.status === "succeeded";
|
||||
const canStart = !pending && !busy;
|
||||
const pollingPaused = Boolean(connect && pausePolling?.(connect));
|
||||
const displayMessage = connect
|
||||
? resolveMessage?.(connect) ?? connect.message
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connect?.qr_url) {
|
||||
@@ -116,14 +96,8 @@ export function ChannelQrConnectFlow({
|
||||
}, [connect?.qr_url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!connect?.session_id
|
||||
|| connect.status !== "pending"
|
||||
|| pollingPaused
|
||||
|| !pageVisible
|
||||
) return;
|
||||
if (!connect?.session_id || connect.status !== "pending" || !pageVisible) return;
|
||||
let cancelled = false;
|
||||
const sessionId = connect.session_id;
|
||||
const poll = async () => {
|
||||
if (pollInFlight.current) return;
|
||||
pollInFlight.current = true;
|
||||
@@ -131,7 +105,7 @@ export function ChannelQrConnectFlow({
|
||||
const payload = await pollChannelConnect(
|
||||
tokenRef.current,
|
||||
channelName,
|
||||
sessionId,
|
||||
connect.session_id,
|
||||
);
|
||||
if (cancelled) return;
|
||||
setConnect((current) => ({
|
||||
@@ -168,7 +142,6 @@ export function ChannelQrConnectFlow({
|
||||
connect?.status,
|
||||
onFeaturesUpdate,
|
||||
pageVisible,
|
||||
pollingPaused,
|
||||
]);
|
||||
|
||||
const start = useCallback(async (force = false) => {
|
||||
@@ -215,40 +188,6 @@ export function ChannelQrConnectFlow({
|
||||
}
|
||||
};
|
||||
|
||||
const submitPoll = async (
|
||||
params: Readonly<Record<string, string>> = {},
|
||||
): Promise<ChannelConnectPayload | null> => {
|
||||
if (!connect?.session_id) return null;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await pollChannelConnect(
|
||||
tokenRef.current,
|
||||
channelName,
|
||||
connect.session_id,
|
||||
"",
|
||||
params,
|
||||
);
|
||||
setConnect((current) => ({
|
||||
...(current ?? payload),
|
||||
...payload,
|
||||
qr_url: payload.qr_url ?? current?.qr_url,
|
||||
}));
|
||||
if (payload.nanobot_features) {
|
||||
onFeaturesUpdate(payload.nanobot_features);
|
||||
}
|
||||
if (payload.status !== "pending") {
|
||||
setError(null);
|
||||
}
|
||||
return payload;
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{pending ? (
|
||||
@@ -271,12 +210,10 @@ export function ChannelQrConnectFlow({
|
||||
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
|
||||
{labels.scanDescription}
|
||||
</p>
|
||||
{renderPending?.({ connect, busy, poll: submitPoll }) ?? (
|
||||
<div className="mt-3 flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
{labels.waiting}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
{labels.waiting}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -293,16 +230,16 @@ export function ChannelQrConnectFlow({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{succeeded && !suppressSucceeded ? (
|
||||
{succeeded ? (
|
||||
<div className="flex items-center gap-2 rounded-[12px] border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
{displayMessage ?? labels.connected}
|
||||
{connect.message ?? labels.connected}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
|
||||
<div className="rounded-[12px] border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
|
||||
{displayMessage || labels.stopped}
|
||||
{connect.message || labels.stopped}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -105,11 +105,6 @@ import {
|
||||
isSideChannelLifecycle,
|
||||
slashCommandLifecycle,
|
||||
} from "@/lib/slash-command";
|
||||
import {
|
||||
clearDraggedSession,
|
||||
hasDraggedSession,
|
||||
readDraggedSession,
|
||||
} from "@/lib/session-drag";
|
||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -322,35 +317,6 @@ type MentionCandidate = {
|
||||
}
|
||||
);
|
||||
|
||||
interface MentionInsertion {
|
||||
value: string;
|
||||
cursor: number;
|
||||
tokenStart: number;
|
||||
tokenEnd: number;
|
||||
}
|
||||
|
||||
function mentionInsertion(
|
||||
value: string,
|
||||
name: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): MentionInsertion {
|
||||
const from = Math.min(Math.max(start, 0), value.length);
|
||||
const to = Math.min(Math.max(end, from), value.length);
|
||||
const prefix = value.slice(0, from);
|
||||
const suffix = value.slice(to);
|
||||
const leadingSpace = prefix && !/\s$/.test(prefix) ? " " : "";
|
||||
const trailingSpace = /^\s/.test(suffix) ? "" : " ";
|
||||
const tokenStart = prefix.length + leadingSpace.length;
|
||||
const tokenEnd = tokenStart + name.length + 1;
|
||||
return {
|
||||
value: `${prefix}${leadingSpace}@${name}${trailingSpace}${suffix}`,
|
||||
cursor: tokenEnd + trailingSpace.length,
|
||||
tokenStart,
|
||||
tokenEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionMentionBase(session: ChatSummary): string {
|
||||
const label = session.title?.trim() || session.preview.trim() || "session";
|
||||
const slug = label
|
||||
@@ -970,11 +936,6 @@ export function ThreadComposer({
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const [selectedSessionMentions, setSelectedSessionMentions] = useState<SessionMention[]>([]);
|
||||
const [sessionDragPreview, setSessionDragPreview] = useState<{
|
||||
mention: SessionMention;
|
||||
start: number;
|
||||
end: number;
|
||||
} | null>(null);
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
@@ -1302,22 +1263,6 @@ export function ThreadComposer({
|
||||
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets, selectedSessionMentions),
|
||||
[cliApps, mcpPresets, selectedSessionMentions, value],
|
||||
);
|
||||
const sessionDragInsertion = sessionDragPreview
|
||||
? mentionInsertion(
|
||||
value,
|
||||
sessionDragPreview.mention.name,
|
||||
sessionDragPreview.start,
|
||||
sessionDragPreview.end,
|
||||
)
|
||||
: null;
|
||||
const displayMentionSegments = sessionDragInsertion && sessionDragPreview
|
||||
? splitCapabilityMentionSegments(
|
||||
sessionDragInsertion.value,
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
[...selectedSessionMentions, sessionDragPreview.mention],
|
||||
)
|
||||
: mentionSegments;
|
||||
const activeSessionMentions = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return mentionSegments.flatMap((segment) => {
|
||||
@@ -1406,7 +1351,7 @@ export function ThreadComposer({
|
||||
|
||||
const showCliAppMenu = filteredMentionCandidates.length > 0;
|
||||
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
||||
const hasMentionDecorations = displayMentionSegments.some(
|
||||
const hasMentionDecorations = mentionSegments.some(
|
||||
(segment) => segment.kind !== "text",
|
||||
);
|
||||
const activeCliMentionApps = useMemo(() => {
|
||||
@@ -1661,13 +1606,10 @@ export function ThreadComposer({
|
||||
[isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value],
|
||||
);
|
||||
|
||||
const insertMentionCandidate = useCallback(
|
||||
(candidate: MentionCandidate, start: number, end: number) => {
|
||||
const chooseMentionCandidate = useCallback(
|
||||
(candidate: MentionCandidate) => {
|
||||
if (!cliAppMention) return;
|
||||
if (candidate.kind === "session") {
|
||||
const alreadySelected = activeSessionMentions.some(
|
||||
(mention) => mention.session_key === candidate.mention.session_key,
|
||||
);
|
||||
if (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT) return;
|
||||
const name = candidate.name.toLowerCase();
|
||||
setSelectedSessionMentions([
|
||||
...activeSessionMentions.filter((mention) => (
|
||||
@@ -1677,9 +1619,12 @@ export function ThreadComposer({
|
||||
candidate.mention,
|
||||
]);
|
||||
}
|
||||
const insertion = mentionInsertion(value, candidate.name, start, end);
|
||||
setValue(insertion.value);
|
||||
setCursorPosition(insertion.cursor);
|
||||
const suffix = value.slice(cliAppMention.end);
|
||||
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
|
||||
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
||||
const nextCursor = cliAppMention.start + mention.length;
|
||||
setValue(next);
|
||||
setCursorPosition(nextCursor);
|
||||
setCliAppMenuDismissed(true);
|
||||
setSlashMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
@@ -1688,89 +1633,12 @@ export function ThreadComposer({
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(insertion.cursor, insertion.cursor);
|
||||
el.setSelectionRange(nextCursor, nextCursor);
|
||||
});
|
||||
},
|
||||
[activeSessionMentions, resizeTextarea, value],
|
||||
[activeSessionMentions, cliAppMention, resizeTextarea, value],
|
||||
);
|
||||
|
||||
const chooseMentionCandidate = useCallback(
|
||||
(candidate: MentionCandidate) => {
|
||||
if (!cliAppMention) return;
|
||||
insertMentionCandidate(candidate, cliAppMention.start, cliAppMention.end);
|
||||
},
|
||||
[cliAppMention, insertMentionCandidate],
|
||||
);
|
||||
|
||||
const handleSessionDrop = useCallback((event: React.DragEvent) => {
|
||||
if (!hasDraggedSession(event.dataTransfer)) return false;
|
||||
event.preventDefault();
|
||||
clearDraggedSession();
|
||||
const preview = sessionDragPreview;
|
||||
setSessionDragPreview(null);
|
||||
if (disabled) return true;
|
||||
const sessionKey = readDraggedSession(event.dataTransfer);
|
||||
const mention = availableSessionMentions.find(
|
||||
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
|
||||
);
|
||||
if (!mention) return true;
|
||||
const caret = preview?.start ?? textareaRef.current?.selectionStart ?? value.length;
|
||||
insertMentionCandidate(
|
||||
{
|
||||
kind: "session",
|
||||
name: mention.name,
|
||||
displayName: mention.title || mention.name,
|
||||
mention,
|
||||
},
|
||||
caret,
|
||||
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
|
||||
);
|
||||
return true;
|
||||
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
|
||||
|
||||
const previewSessionDrop = useCallback((event: React.DragEvent) => {
|
||||
if (!hasDraggedSession(event.dataTransfer)) return false;
|
||||
if (disabled) {
|
||||
event.dataTransfer.dropEffect = "none";
|
||||
setSessionDragPreview(null);
|
||||
return true;
|
||||
}
|
||||
const sessionKey = readDraggedSession(event.dataTransfer);
|
||||
const mention = availableSessionMentions.find(
|
||||
(candidate) => candidate.session_key === sessionKey,
|
||||
);
|
||||
const alreadySelected = mention && activeSessionMentions.some(
|
||||
(candidate) => candidate.session_key === mention.session_key,
|
||||
);
|
||||
if (!mention || (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT)) {
|
||||
event.dataTransfer.dropEffect = "none";
|
||||
setSessionDragPreview(null);
|
||||
return true;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
const start = textareaRef.current?.selectionStart ?? value.length;
|
||||
const end = textareaRef.current?.selectionEnd ?? start;
|
||||
setSessionDragPreview((current) => (
|
||||
current?.mention.session_key === mention.session_key
|
||||
&& current.start === start
|
||||
&& current.end === end
|
||||
? current
|
||||
: { mention, start, end }
|
||||
));
|
||||
return true;
|
||||
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionDragPreview) return;
|
||||
const clearPreview = () => {
|
||||
clearDraggedSession();
|
||||
setSessionDragPreview(null);
|
||||
};
|
||||
document.addEventListener("dragend", clearPreview);
|
||||
return () => document.removeEventListener("dragend", clearPreview);
|
||||
}, [sessionDragPreview]);
|
||||
|
||||
const clearComposerText = useCallback((restoreFocus = true) => {
|
||||
setValue("");
|
||||
setSelectedSessionMentions([]);
|
||||
@@ -2199,25 +2067,10 @@ export function ThreadComposer({
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
if (!previewSessionDrop(event)) onDragEnter(event);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!previewSessionDrop(event)) onDragOver(event);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (!hasDraggedSession(event.dataTransfer)) {
|
||||
onDragLeave(event);
|
||||
return;
|
||||
}
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
||||
setSessionDragPreview(null);
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!handleSessionDrop(event)) onDrop(event);
|
||||
}}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
||||
>
|
||||
{showSlashMenu ? (
|
||||
@@ -2247,7 +2100,6 @@ export function ThreadComposer({
|
||||
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
||||
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||
disabled && "opacity-60",
|
||||
sessionDragPreview && "ring-1 ring-primary/25",
|
||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||
goalState?.active &&
|
||||
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
|
||||
@@ -2332,12 +2184,9 @@ export function ThreadComposer({
|
||||
<div className="relative">
|
||||
{hasMentionDecorations ? (
|
||||
<ComposerCliMentionOverlay
|
||||
segments={displayMentionSegments}
|
||||
segments={mentionSegments}
|
||||
isHero={isHero}
|
||||
className={inputTextClasses}
|
||||
ghostRange={sessionDragInsertion
|
||||
? { start: sessionDragInsertion.tokenStart, end: sessionDragInsertion.tokenEnd }
|
||||
: null}
|
||||
/>
|
||||
) : null}
|
||||
<textarea
|
||||
@@ -2360,7 +2209,7 @@ export function ThreadComposer({
|
||||
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onPaste={onPaste}
|
||||
rows={1}
|
||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
@@ -2747,14 +2596,11 @@ function ComposerCliMentionOverlay({
|
||||
segments,
|
||||
isHero,
|
||||
className,
|
||||
ghostRange,
|
||||
}: {
|
||||
segments: CapabilityMentionSegment[];
|
||||
isHero: boolean;
|
||||
className: string;
|
||||
ghostRange?: { start: number; end: number } | null;
|
||||
}) {
|
||||
let offset = 0;
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -2764,24 +2610,16 @@ function ComposerCliMentionOverlay({
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, index) => {
|
||||
const start = offset;
|
||||
offset += segment.text.length;
|
||||
if (segment.kind === "text") {
|
||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||
}
|
||||
const isGhost = ghostRange?.start === start && ghostRange.end === offset;
|
||||
return (
|
||||
<span
|
||||
<CapabilityMentionToken
|
||||
key={`${segment.kind}-${index}`}
|
||||
data-testid={isGhost ? "composer-session-drag-preview" : undefined}
|
||||
className={cn(isGhost && "opacity-45 transition-opacity duration-100")}
|
||||
>
|
||||
<CapabilityMentionToken
|
||||
segment={segment}
|
||||
variant="composer"
|
||||
isHero={isHero}
|
||||
/>
|
||||
</span>
|
||||
segment={segment}
|
||||
variant="composer"
|
||||
isHero={isHero}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -76,34 +76,20 @@ export function ThinkingReasoningShell({
|
||||
: "pointer-events-none grid-rows-[0fr] opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="relative min-h-0 overflow-hidden">
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
data-testid={expanded ? "agent-activity-scroll" : undefined}
|
||||
data-fade-top={fadeTop}
|
||||
data-fade-bottom={fadeBottom}
|
||||
onScroll={onScroll}
|
||||
className="mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
className="activity-scroll-fade mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
<div ref={contentRef} className="flex flex-col gap-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{fadeTop ? (
|
||||
<span
|
||||
data-testid="activity-scroll-fade-top"
|
||||
className="pointer-events-none absolute inset-x-0 top-1.5 z-10 h-3.5 bg-gradient-to-b from-background to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{fadeBottom ? (
|
||||
<span
|
||||
data-testid="activity-scroll-fade-bottom"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-3.5 bg-gradient-to-t from-background to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -681,6 +681,34 @@
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Soften only the activity edges that have clipped content beyond them. */
|
||||
.activity-scroll-fade[data-fade-top="true"][data-fade-bottom="false"] {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 14px);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 14px);
|
||||
}
|
||||
|
||||
.activity-scroll-fade[data-fade-top="false"][data-fade-bottom="true"] {
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 14px), transparent);
|
||||
mask-image: linear-gradient(to bottom, #000 calc(100% - 14px), transparent);
|
||||
}
|
||||
|
||||
.activity-scroll-fade[data-fade-top="true"][data-fade-bottom="true"] {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
#000 14px,
|
||||
#000 calc(100% - 14px),
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
#000 14px,
|
||||
#000 calc(100% - 14px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
@supports (content-visibility: auto) {
|
||||
.thread-render-unit {
|
||||
content-visibility: auto;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import { fetchSidebarState } from "@/lib/api";
|
||||
import {
|
||||
fetchSidebarState,
|
||||
updateSidebarState as persistSidebarState,
|
||||
} from "@/lib/api";
|
||||
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
||||
|
||||
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
||||
schema_version: 1,
|
||||
pinned_keys: [],
|
||||
archived_keys: [],
|
||||
session_order: [],
|
||||
title_overrides: {},
|
||||
project_name_overrides: {},
|
||||
tags_by_key: {},
|
||||
@@ -81,14 +83,13 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
|
||||
? value.view
|
||||
: DEFAULT_SIDEBAR_STATE.view;
|
||||
const density = view.density === "compact" ? "compact" : "comfortable";
|
||||
const sort = ["updated_desc", "created_desc", "title_asc", "manual"].includes(view.sort)
|
||||
const sort = ["updated_desc", "created_desc", "title_asc"].includes(view.sort)
|
||||
? view.sort
|
||||
: "updated_desc";
|
||||
return {
|
||||
schema_version: 1,
|
||||
pinned_keys: uniqueStrings(value.pinned_keys),
|
||||
archived_keys: uniqueStrings(value.archived_keys),
|
||||
session_order: uniqueStrings(value.session_order),
|
||||
title_overrides: stringMap(value.title_overrides),
|
||||
project_name_overrides: stringMap(value.project_name_overrides),
|
||||
tags_by_key: tagsMap(value.tags_by_key),
|
||||
@@ -121,7 +122,6 @@ function pruneMissingSessions(
|
||||
...state,
|
||||
pinned_keys: filterKeys(state.pinned_keys),
|
||||
archived_keys: filterKeys(state.archived_keys),
|
||||
session_order: filterKeys(state.session_order),
|
||||
title_overrides: filterMap(state.title_overrides),
|
||||
tags_by_key: filterMap(state.tags_by_key),
|
||||
};
|
||||
@@ -141,9 +141,10 @@ export function useSidebarState(
|
||||
updater: (state: SidebarStatePayload) => SidebarStatePayload,
|
||||
) => Promise<void>;
|
||||
} {
|
||||
const { client, token } = useClient();
|
||||
const { token } = useClient();
|
||||
const tokenRef = useRef(token);
|
||||
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
||||
const persistVersionRef = useRef(0);
|
||||
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
||||
const [loading, setLoading] = useState(true);
|
||||
tokenRef.current = token;
|
||||
@@ -174,11 +175,23 @@ export function useSidebarState(
|
||||
const update = useCallback(
|
||||
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
||||
const next = normalizeSidebarState(updater(stateRef.current));
|
||||
const version = persistVersionRef.current + 1;
|
||||
persistVersionRef.current = version;
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
client.setSidebarState(next);
|
||||
try {
|
||||
const persisted = normalizeSidebarState(
|
||||
await persistSidebarState(tokenRef.current, next),
|
||||
);
|
||||
if (persistVersionRef.current !== version) return;
|
||||
stateRef.current = persisted;
|
||||
setState(persisted);
|
||||
} catch {
|
||||
// Keep the optimistic UI state. Older gateways or transient auth expiry
|
||||
// should not break the chat list; the next refresh can try again.
|
||||
}
|
||||
},
|
||||
[client],
|
||||
[],
|
||||
);
|
||||
|
||||
const pruned = useMemo(() => {
|
||||
|
||||
@@ -627,13 +627,9 @@ export async function pollChannelConnect(
|
||||
channel: string,
|
||||
sessionId: string,
|
||||
base: string = "",
|
||||
params: Readonly<Record<string, string>> = {},
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("session_id", sessionId);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (key !== "session_id") query.set(key, value);
|
||||
});
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
|
||||
token,
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface ChatGroupingOptions {
|
||||
archivedKeys: string[];
|
||||
titleOverrides: Record<string, string>;
|
||||
projectNameOverrides: Record<string, string>;
|
||||
sessionOrder: string[];
|
||||
showArchived: boolean;
|
||||
sort: SidebarSortMode;
|
||||
defaultWorkspacePath?: string | null;
|
||||
@@ -65,7 +64,7 @@ export function groupSessions(
|
||||
pinnedSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
if (options.sort === "title_asc" || options.sort === "manual") {
|
||||
if (options.sort === "title_asc") {
|
||||
normalSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
@@ -88,12 +87,11 @@ export function groupSessions(
|
||||
buckets.get(label) ?? [],
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
options.sessionOrder,
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.sessions.length > 0);
|
||||
|
||||
if ((options.sort === "title_asc" || options.sort === "manual") && normalSessions.length) {
|
||||
if (options.sort === "title_asc" && normalSessions.length) {
|
||||
groups.push({
|
||||
id: "date:all",
|
||||
label: labels.all,
|
||||
@@ -101,7 +99,6 @@ export function groupSessions(
|
||||
normalSessions,
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
options.sessionOrder,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -113,7 +110,6 @@ export function groupSessions(
|
||||
pinnedSessions,
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
options.sessionOrder,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -125,7 +121,6 @@ export function groupSessions(
|
||||
archivedSessions,
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
options.sessionOrder,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -281,7 +276,6 @@ function groupSessionsByProject(
|
||||
bucket.sessions,
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
options.sessionOrder,
|
||||
pinned,
|
||||
archived,
|
||||
),
|
||||
@@ -303,7 +297,6 @@ function groupSessionsByProject(
|
||||
conversations,
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
options.sessionOrder,
|
||||
pinned,
|
||||
archived,
|
||||
),
|
||||
@@ -326,11 +319,10 @@ function sortProjectSessions(
|
||||
sessions: ChatSummary[],
|
||||
sort: SidebarSortMode,
|
||||
titleOverrides: Record<string, string>,
|
||||
sessionOrder: string[],
|
||||
pinned: Set<string>,
|
||||
archived: Set<string>,
|
||||
): ChatSummary[] {
|
||||
return sortSessions(sessions, sort, titleOverrides, sessionOrder).sort((a, b) => {
|
||||
return sortSessions(sessions, sort, titleOverrides).sort((a, b) => {
|
||||
const pinOrder = Number(pinned.has(b.key)) - Number(pinned.has(a.key));
|
||||
if (pinOrder !== 0) return pinOrder;
|
||||
const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key));
|
||||
@@ -343,19 +335,9 @@ function sortSessions(
|
||||
sessions: ChatSummary[],
|
||||
sort: SidebarSortMode,
|
||||
titleOverrides: Record<string, string>,
|
||||
sessionOrder: string[],
|
||||
): ChatSummary[] {
|
||||
const copy = [...sessions];
|
||||
const order = new Map(sessionOrder.map((key, index) => [key, index]));
|
||||
copy.sort((a, b) => {
|
||||
if (sort === "manual") {
|
||||
const aIndex = order.get(a.key);
|
||||
const bIndex = order.get(b.key);
|
||||
if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex;
|
||||
if (aIndex === undefined && bIndex !== undefined) return -1;
|
||||
if (aIndex !== undefined && bIndex === undefined) return 1;
|
||||
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
|
||||
}
|
||||
if (sort === "title_asc") {
|
||||
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
|
||||
titleForSort(b, titleOverrides),
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
OutboundMcpPresetMention,
|
||||
OutboundMedia,
|
||||
SessionMention,
|
||||
SidebarStatePayload,
|
||||
GoalStateWsPayload,
|
||||
WorkspaceScopePayload,
|
||||
} from "./types";
|
||||
@@ -871,10 +870,6 @@ export class NanobotClient {
|
||||
});
|
||||
}
|
||||
|
||||
setSidebarState(state: SidebarStatePayload): void {
|
||||
this.queueSend({ type: "set_sidebar_state", state });
|
||||
}
|
||||
|
||||
// -- internals ---------------------------------------------------------
|
||||
|
||||
private setStatus(status: ConnectionStatus): void {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
|
||||
|
||||
let activeSessionKey: string | null = null;
|
||||
|
||||
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
|
||||
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
|
||||
}
|
||||
|
||||
export function readDraggedSession(dataTransfer: DataTransfer): string | null {
|
||||
const sessionKey = dataTransfer.getData(SESSION_DRAG_TYPE).trim();
|
||||
return sessionKey || activeSessionKey;
|
||||
}
|
||||
|
||||
export function clearDraggedSession(): void {
|
||||
activeSessionKey = null;
|
||||
}
|
||||
|
||||
export function writeDraggedSession(
|
||||
dataTransfer: DataTransfer,
|
||||
sessionKey: string,
|
||||
): void {
|
||||
activeSessionKey = sessionKey;
|
||||
dataTransfer.effectAllowed = "copyMove";
|
||||
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
|
||||
}
|
||||
@@ -367,7 +367,7 @@ export interface WorkspacesPayload {
|
||||
}
|
||||
|
||||
export type SidebarDensity = "comfortable" | "compact";
|
||||
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc" | "manual";
|
||||
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc";
|
||||
|
||||
export interface SidebarViewState {
|
||||
density: SidebarDensity;
|
||||
@@ -381,7 +381,6 @@ export interface SidebarStatePayload {
|
||||
schema_version: number;
|
||||
pinned_keys: string[];
|
||||
archived_keys: string[];
|
||||
session_order: string[];
|
||||
title_overrides: Record<string, string>;
|
||||
project_name_overrides: Record<string, string>;
|
||||
tags_by_key: Record<string, string[]>;
|
||||
@@ -1340,7 +1339,6 @@ export type Outbound =
|
||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
||||
| {
|
||||
|
||||
@@ -288,24 +288,16 @@ describe("AgentActivityCluster", () => {
|
||||
});
|
||||
expect(scrollport).toHaveAttribute("data-fade-top", "true");
|
||||
expect(scrollport).toHaveAttribute("data-fade-bottom", "false");
|
||||
const topFade = screen.getByTestId("activity-scroll-fade-top");
|
||||
expect(scrollport).not.toContainElement(topFade);
|
||||
expect(scrollport).not.toHaveClass("activity-scroll-fade");
|
||||
expect(screen.queryByTestId("activity-scroll-fade-bottom")).not.toBeInTheDocument();
|
||||
|
||||
scrollport.scrollTop = 440;
|
||||
fireEvent.scroll(scrollport);
|
||||
expect(scrollport).toHaveAttribute("data-fade-top", "true");
|
||||
expect(scrollport).toHaveAttribute("data-fade-bottom", "true");
|
||||
expect(screen.getByTestId("activity-scroll-fade-top")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("activity-scroll-fade-bottom")).toBeInTheDocument();
|
||||
|
||||
scrollport.scrollTop = 0;
|
||||
fireEvent.scroll(scrollport);
|
||||
expect(scrollport).toHaveAttribute("data-fade-top", "false");
|
||||
expect(scrollport).toHaveAttribute("data-fade-bottom", "true");
|
||||
expect(screen.queryByTestId("activity-scroll-fade-top")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("activity-scroll-fade-bottom")).toBeInTheDocument();
|
||||
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 100,
|
||||
@@ -315,8 +307,6 @@ describe("AgentActivityCluster", () => {
|
||||
fireEvent.scroll(scrollport);
|
||||
expect(scrollport).toHaveAttribute("data-fade-top", "false");
|
||||
expect(scrollport).toHaveAttribute("data-fade-bottom", "false");
|
||||
expect(screen.queryByTestId("activity-scroll-fade-top")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("activity-scroll-fade-bottom")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@ describe("webui API helpers", () => {
|
||||
schema_version: 1,
|
||||
pinned_keys: ["websocket:chat-1"],
|
||||
archived_keys: ["websocket:old"],
|
||||
session_order: ["websocket:chat-1", "websocket:old"],
|
||||
title_overrides: { "websocket:chat-1": "Release" },
|
||||
project_name_overrides: { "/Users/me/nanobot": "Core" },
|
||||
tags_by_key: {},
|
||||
|
||||
@@ -13,7 +13,6 @@ const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomatio
|
||||
const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const setSidebarStateSpy = vi.fn();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
@@ -219,7 +218,6 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
sendMessage = vi.fn();
|
||||
newChat = vi.fn();
|
||||
attach = attachSpy;
|
||||
setSidebarState = setSidebarStateSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
updateMaxFrameBytes = vi.fn();
|
||||
@@ -247,7 +245,6 @@ describe("App layout", () => {
|
||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset();
|
||||
runStatusHandlers.clear();
|
||||
sessionUpdateHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
@@ -367,23 +364,6 @@ describe("App layout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a just-created topic route while the session list catches up", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
target: { value: "/model" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(window.location.hash).toBe(
|
||||
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the Settings route after a restart fallback hash", async () => {
|
||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||
@@ -1423,6 +1403,13 @@ describe("App layout", () => {
|
||||
if (href === "/api/webui/sidebar-state") {
|
||||
return { ok: true, json: async () => initialState };
|
||||
}
|
||||
if (href.startsWith("/api/webui/sidebar-state/update?")) {
|
||||
const encoded = new URLSearchParams(href.split("?", 2)[1]).get("state");
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => JSON.parse(encoded ?? "{}"),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
}),
|
||||
);
|
||||
@@ -1442,11 +1429,12 @@ describe("App layout", () => {
|
||||
expect(within(sidebar).getByText("Archived")).toBeInTheDocument(),
|
||||
);
|
||||
expect(within(sidebar).getByRole("button", { name: /^First chat$/ })).toBeInTheDocument();
|
||||
expect(setSidebarStateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
view: expect.objectContaining({ show_archived: true }),
|
||||
}),
|
||||
);
|
||||
const updateUrl = vi.mocked(fetch).mock.calls
|
||||
.map(([url]) => String(url))
|
||||
.find((url) => url.startsWith("/api/webui/sidebar-state/update?"));
|
||||
expect(updateUrl).toBeTruthy();
|
||||
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
|
||||
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
|
||||
|
||||
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||
@@ -48,110 +47,6 @@ describe("ChatList", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("exposes chats as drag sources", () => {
|
||||
const dataTransfer = {
|
||||
effectAllowed: "",
|
||||
setData: vi.fn(),
|
||||
};
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "active", title: "Active chat" }),
|
||||
session({ chatId: "reference", title: "Reference chat" }),
|
||||
]}
|
||||
activeKey="websocket:active"
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Active chat" }))
|
||||
.toHaveAttribute("draggable", "true");
|
||||
const reference = screen.getByRole("button", { name: "Reference chat" });
|
||||
expect(reference).toHaveAttribute("draggable", "true");
|
||||
|
||||
fireEvent.dragStart(reference, { dataTransfer });
|
||||
|
||||
expect(dataTransfer.setData).toHaveBeenCalledWith(
|
||||
SESSION_DRAG_TYPE,
|
||||
"websocket:reference",
|
||||
);
|
||||
fireEvent.dragEnd(reference, { dataTransfer });
|
||||
});
|
||||
|
||||
it("reorders chats around a Codex-style insertion line", () => {
|
||||
const onReorderSessions = vi.fn();
|
||||
const sessions = [
|
||||
session({ chatId: "alpha", title: "Alpha" }),
|
||||
session({ chatId: "bravo", title: "Bravo" }),
|
||||
session({ chatId: "charlie", title: "Charlie" }),
|
||||
session({ chatId: "old-a", title: "Old A" }),
|
||||
session({ chatId: "old-b", title: "Old B" }),
|
||||
];
|
||||
const { rerender } = render(
|
||||
<ChatList
|
||||
sessions={sessions}
|
||||
activeKey={null}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
onReorderSessions={onReorderSessions}
|
||||
archivedKeys={["websocket:old-a", "websocket:old-b"]}
|
||||
sessionOrder={sessions.map((item) => item.key)}
|
||||
/>,
|
||||
);
|
||||
const dataTransfer = {
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
setData: vi.fn(),
|
||||
};
|
||||
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
|
||||
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
|
||||
fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer });
|
||||
expect(charlieRow.querySelector("[data-session-drop-edge='after']"))
|
||||
.toBeInTheDocument();
|
||||
fireEvent.drop(charlieRow, { clientY: 1, dataTransfer });
|
||||
|
||||
expect(onReorderSessions).toHaveBeenCalledWith([
|
||||
"websocket:bravo",
|
||||
"websocket:charlie",
|
||||
"websocket:alpha",
|
||||
"websocket:old-a",
|
||||
"websocket:old-b",
|
||||
]);
|
||||
|
||||
rerender(
|
||||
<ChatList
|
||||
sessions={sessions}
|
||||
activeKey={null}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
onReorderSessions={onReorderSessions}
|
||||
archivedKeys={["websocket:old-a", "websocket:old-b"]}
|
||||
sessionOrder={[
|
||||
"websocket:bravo",
|
||||
"websocket:charlie",
|
||||
"websocket:alpha",
|
||||
"websocket:old-a",
|
||||
"websocket:old-b",
|
||||
]}
|
||||
sort="manual"
|
||||
/>,
|
||||
);
|
||||
const section = screen.getByRole("region", { name: "Topics" });
|
||||
const text = section.textContent ?? "";
|
||||
expect(text.indexOf("Bravo")).toBeLessThan(text.indexOf("Charlie"));
|
||||
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
||||
});
|
||||
|
||||
it("orders chats by latest session activity by default", () => {
|
||||
const sessions = [
|
||||
session({
|
||||
|
||||
@@ -916,14 +916,6 @@ describe("MessageBubble", () => {
|
||||
|
||||
const imageButton = screen.getByRole("button", { name: /view image/i });
|
||||
expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-[20px]");
|
||||
expect(imageButton).toHaveClass(
|
||||
"border",
|
||||
"border-border/60",
|
||||
"focus-visible:ring-2",
|
||||
);
|
||||
expect(imageButton).not.toHaveClass("hover:scale-[1.01]");
|
||||
expect(imageButton).not.toHaveClass("hover:ring-2");
|
||||
expect(imageButton).not.toHaveClass("hover:ring-primary/25");
|
||||
expect(imageButton).not.toHaveAttribute("title");
|
||||
expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
import type { SidebarStatePayload } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Minimal fake WebSocket implementing the subset NanobotClient touches.
|
||||
@@ -936,44 +935,6 @@ describe("NanobotClient", () => {
|
||||
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
|
||||
});
|
||||
|
||||
it("sends large sidebar ordering state outside the HTTP request line", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const sessionOrder = Array.from(
|
||||
{ length: 160 },
|
||||
(_, index) => `websocket:${index.toString().padStart(4, "0")}-${"x".repeat(48)}`,
|
||||
);
|
||||
const state: SidebarStatePayload = {
|
||||
schema_version: 1,
|
||||
pinned_keys: [],
|
||||
archived_keys: [],
|
||||
session_order: sessionOrder,
|
||||
title_overrides: {},
|
||||
project_name_overrides: {},
|
||||
tags_by_key: {},
|
||||
collapsed_groups: {},
|
||||
view: {
|
||||
density: "comfortable",
|
||||
show_previews: false,
|
||||
show_timestamps: false,
|
||||
show_archived: false,
|
||||
sort: "manual",
|
||||
},
|
||||
updated_at: null,
|
||||
};
|
||||
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.setSidebarState(state);
|
||||
|
||||
const [serialized] = lastSocket().sent;
|
||||
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
|
||||
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
|
||||
});
|
||||
|
||||
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -2137,57 +2137,6 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(reasoningEffort).toHaveValue("provider-native-mode");
|
||||
});
|
||||
|
||||
it("expands the model preset editor directly below the selected row", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
const row = await screen.findByTestId("model-call-order-row-primary");
|
||||
const trigger = within(row).getAllByRole("button")[0];
|
||||
expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const editor = screen.getByTestId("model-preset-editor");
|
||||
expect(trigger).toHaveAttribute("aria-pressed", "true");
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
expect(trigger).toHaveAttribute("aria-controls", "model-preset-editor");
|
||||
expect(row.parentElement).toHaveAttribute("role", "listitem");
|
||||
expect(row.parentElement?.parentElement).toHaveAttribute("role", "list");
|
||||
expect(row.nextElementSibling).toBe(editor);
|
||||
expect(editor).toHaveClass(
|
||||
"slide-in-from-top-1",
|
||||
"lg:max-w-6xl",
|
||||
"rounded-[18px]",
|
||||
);
|
||||
expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument();
|
||||
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
|
||||
expect(deleteButton).toBeDisabled();
|
||||
expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint");
|
||||
expect(
|
||||
within(editor).getByText("Remove this preset from the call order before deleting it."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drags model presets to reorder and saves the model call order immediately", async () => {
|
||||
const { payload, backupPreset } = settingsPayloadWithBackup();
|
||||
const updatedPayload: SettingsPayload = {
|
||||
@@ -2319,17 +2268,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
renderSettingsView({ initialSection: "models", initialSettings: initialPayload });
|
||||
|
||||
const primaryRow = screen.getByTestId("model-call-order-row-primary");
|
||||
const backupRows = screen.getAllByTestId("model-call-order-row-backup");
|
||||
const firstBackupRow = backupRows[0];
|
||||
const secondBackupRow = backupRows[1];
|
||||
const secondBackupTrigger = within(secondBackupRow).getAllByRole("button")[0];
|
||||
fireEvent.click(secondBackupTrigger);
|
||||
expect(screen.getAllByTestId("model-preset-editor")).toHaveLength(1);
|
||||
expect(secondBackupRow.nextElementSibling).toBe(
|
||||
screen.getByTestId("model-preset-editor"),
|
||||
);
|
||||
fireEvent.click(secondBackupTrigger);
|
||||
|
||||
const firstBackupRow = screen.getAllByTestId("model-call-order-row-backup")[0];
|
||||
const dataTransfer = {
|
||||
dropEffect: "move",
|
||||
effectAllowed: "move",
|
||||
|
||||
@@ -3,7 +3,6 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||
import type { ChatSummary, CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
|
||||
|
||||
vi.mock("@/lib/imageEncode", () => ({
|
||||
@@ -1534,10 +1533,7 @@ describe("ThreadComposer", () => {
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(input).toHaveValue("use @browserbase ");
|
||||
const mention = screen.getByTestId("composer-mcp-mention-browserbase");
|
||||
expect(mention).toHaveTextContent("@browserbase");
|
||||
expect(mention).toHaveClass("font-normal");
|
||||
expect(mention).not.toHaveClass("font-[550]");
|
||||
expect(screen.getByTestId("composer-mcp-mention-browserbase")).toHaveTextContent("@browserbase");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
@@ -1584,8 +1580,6 @@ describe("ThreadComposer", () => {
|
||||
expect(input).toHaveValue("参考 @收费设计 ");
|
||||
const mention = screen.getByTestId("composer-session-mention-收费设计");
|
||||
expect(mention).toHaveTextContent("@收费设计");
|
||||
expect(mention).toHaveClass("font-normal");
|
||||
expect(mention).not.toHaveClass("font-[550]");
|
||||
expect(mention.closest("a")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
@@ -1599,81 +1593,6 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a dropped sidebar session into the shared structured mention", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[session("pricing", "收费设计", "讨论云存储")]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: "Compare notes" } });
|
||||
input.setSelectionRange(7, 7);
|
||||
const dataTransfer = {
|
||||
types: [SESSION_DRAG_TYPE],
|
||||
effectAllowed: "copy",
|
||||
dropEffect: "none",
|
||||
files: [],
|
||||
getData: (type: string) => (
|
||||
type === SESSION_DRAG_TYPE ? "websocket:pricing" : ""
|
||||
),
|
||||
};
|
||||
|
||||
fireEvent.dragEnter(input, { dataTransfer });
|
||||
fireEvent.dragOver(input, { dataTransfer });
|
||||
|
||||
expect(input).toHaveValue("Compare notes");
|
||||
expect(screen.getByTestId("composer-session-drag-preview"))
|
||||
.toHaveTextContent("@收费设计");
|
||||
|
||||
fireEvent.dragEnd(document);
|
||||
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.dragEnter(input, { dataTransfer });
|
||||
fireEvent.dragOver(input, { dataTransfer });
|
||||
|
||||
fireEvent.drop(input, { dataTransfer });
|
||||
|
||||
expect(input).toHaveValue("Compare @收费设计 notes");
|
||||
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("composer-session-mention-收费设计"))
|
||||
.toHaveTextContent("@收费设计");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
expect(onSend).toHaveBeenCalledWith("Compare @收费设计 notes", undefined, {
|
||||
sessionMentions: [{
|
||||
name: "收费设计",
|
||||
session_key: "websocket:pricing",
|
||||
title: "收费设计",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects session drops that are unavailable to the composer", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
sessions={[]}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText("Message input");
|
||||
const dataTransfer = {
|
||||
types: [SESSION_DRAG_TYPE],
|
||||
effectAllowed: "copyMove",
|
||||
dropEffect: "copy",
|
||||
files: [],
|
||||
getData: () => "websocket:current",
|
||||
};
|
||||
|
||||
expect(fireEvent.dragEnter(input, { dataTransfer })).toBe(true);
|
||||
expect(fireEvent.dragOver(input, { dataTransfer })).toBe(true);
|
||||
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disambiguates duplicate and capability-colliding session names", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
@@ -1971,8 +1890,7 @@ describe("ThreadComposer", () => {
|
||||
expect(input).toHaveValue("meeting in @gimp");
|
||||
const token = screen.getByTestId("composer-cli-mention-gimp");
|
||||
expect(token).toHaveTextContent("@gimp");
|
||||
expect(token).toHaveClass("font-normal");
|
||||
expect(token).not.toHaveClass("font-[550]");
|
||||
expect(token).toHaveClass("font-[550]");
|
||||
expect(token.className).not.toContain("zoom-in");
|
||||
expect(token.className).not.toContain("px-");
|
||||
expect(token.className).not.toContain("mx-");
|
||||
|
||||
Reference in New Issue
Block a user