feat(webui): show the actual fallback model (#5017)
This commit is contained in:
@@ -81,6 +81,13 @@ class RuntimeModelUpdatedEvent(OutboundEvent):
|
|||||||
model_preset: str | None = None
|
model_preset: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TurnModelUpdatedEvent(OutboundEvent):
|
||||||
|
"""The fallback model currently handling one chat turn."""
|
||||||
|
|
||||||
|
model: str
|
||||||
|
|
||||||
|
|
||||||
def outbound_message_for_event(
|
def outbound_message_for_event(
|
||||||
*,
|
*,
|
||||||
channel: str,
|
channel: str,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
RuntimeModelUpdatedEvent,
|
RuntimeModelUpdatedEvent,
|
||||||
SessionUpdatedEvent,
|
SessionUpdatedEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
|
TurnModelUpdatedEvent,
|
||||||
outbound_event_from_message,
|
outbound_event_from_message,
|
||||||
outbound_message_for_event,
|
outbound_message_for_event,
|
||||||
)
|
)
|
||||||
@@ -319,7 +320,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
await self.send_goal_status(chat_id, "running", started_at=t0)
|
await self.send_goal_status(chat_id, "running", started_at=t0)
|
||||||
|
|
||||||
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
|
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
|
||||||
"""Replay goal/run strip state after subscribe (same-process refresh)."""
|
"""Replay persisted or actively running per-chat state after subscribe."""
|
||||||
await self._maybe_push_active_goal_state(chat_id)
|
await self._maybe_push_active_goal_state(chat_id)
|
||||||
await self._maybe_push_turn_run_wall_clock(chat_id)
|
await self._maybe_push_turn_run_wall_clock(chat_id)
|
||||||
|
|
||||||
@@ -805,6 +806,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
|
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
|
||||||
else:
|
else:
|
||||||
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
||||||
|
if isinstance(event, TurnModelUpdatedEvent):
|
||||||
|
if conns:
|
||||||
|
await self.send_turn_model_updated(
|
||||||
|
msg.chat_id,
|
||||||
|
model_name=event.model,
|
||||||
|
)
|
||||||
|
return
|
||||||
if isinstance(event, GoalStateSyncEvent):
|
if isinstance(event, GoalStateSyncEvent):
|
||||||
if conns:
|
if conns:
|
||||||
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
||||||
@@ -1113,3 +1121,26 @@ class WebSocketChannel(BaseChannel):
|
|||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" runtime_model_updated ")
|
await self._safe_send_to(connection, raw, label=" runtime_model_updated ")
|
||||||
|
|
||||||
|
async def send_turn_model_updated(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
*,
|
||||||
|
model_name: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Notify one chat's subscribers which model is handling its current request."""
|
||||||
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
|
if (
|
||||||
|
not conns
|
||||||
|
or not isinstance(model_name, str)
|
||||||
|
or not model_name.strip()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"event": "turn_model_updated",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"model_name": model_name.strip(),
|
||||||
|
}
|
||||||
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
|
for connection in conns:
|
||||||
|
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
RuntimeModelUpdatedEvent,
|
RuntimeModelUpdatedEvent,
|
||||||
SessionUpdatedEvent,
|
SessionUpdatedEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
|
TurnModelUpdatedEvent,
|
||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.websocket.runtime import (
|
from nanobot.channels.websocket.runtime import (
|
||||||
@@ -1061,6 +1062,33 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
|
|||||||
assert payload["model_preset"] == "fast"
|
assert payload["model_preset"] == "fast"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||||
|
chat_one = AsyncMock()
|
||||||
|
chat_two = AsyncMock()
|
||||||
|
channel._attach(chat_one, "chat-1")
|
||||||
|
channel._attach(chat_two, "chat-2")
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
content="",
|
||||||
|
event=TurnModelUpdatedEvent(model="deepseek/deepseek-chat"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(chat_one.send.call_args.args[0])
|
||||||
|
assert payload == {
|
||||||
|
"event": "turn_model_updated",
|
||||||
|
"chat_id": "chat-1",
|
||||||
|
"model_name": "deepseek/deepseek-chat",
|
||||||
|
}
|
||||||
|
chat_two.send.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
|
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ async def test_hydrate_after_subscribe_is_quiet_when_no_turn_active():
|
|||||||
channel.gateway = MagicMock()
|
channel.gateway = MagicMock()
|
||||||
channel.gateway.session_manager = MagicMock()
|
channel.gateway.session_manager = MagicMock()
|
||||||
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
||||||
|
channel._turn_models = {}
|
||||||
|
|
||||||
sent_events = []
|
sent_events = []
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ async def test_hydrate_after_subscribe_pushes_running_when_turn_active():
|
|||||||
channel.gateway = MagicMock()
|
channel.gateway = MagicMock()
|
||||||
channel.gateway.session_manager = MagicMock()
|
channel.gateway.session_manager = MagicMock()
|
||||||
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
||||||
|
channel._turn_models = {}
|
||||||
|
|
||||||
sent_events = []
|
sent_events = []
|
||||||
|
|
||||||
|
|||||||
+18
-3
@@ -1617,9 +1617,14 @@ def _run_gateway(
|
|||||||
from nanobot.cron.session_turns import is_bound_cron_job
|
from nanobot.cron.session_turns import is_bound_cron_job
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
||||||
|
from nanobot.providers.fallback_provider import FallbackProvider
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.webui_turns import WebuiTurnCoordinator, WebuiTurnRoutePolicy
|
from nanobot.session.webui_turns import (
|
||||||
|
WebuiTurnCoordinator,
|
||||||
|
WebuiTurnRoutePolicy,
|
||||||
|
build_webui_fallback_model_observer,
|
||||||
|
)
|
||||||
from nanobot.triggers.local_runner import run_local_trigger_queue
|
from nanobot.triggers.local_runner import run_local_trigger_queue
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
from nanobot.webui.token_usage import TokenUsageHook
|
from nanobot.webui.token_usage import TokenUsageHook
|
||||||
@@ -1651,8 +1656,18 @@ def _run_gateway(
|
|||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
runtime_events = RuntimeEventBus()
|
runtime_events = RuntimeEventBus()
|
||||||
|
fallback_model_observer = build_webui_fallback_model_observer(bus)
|
||||||
|
|
||||||
|
def _observe_fallback_models(snapshot):
|
||||||
|
if isinstance(snapshot.provider, FallbackProvider):
|
||||||
|
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def _load_gateway_provider_snapshot(*args: Any, **kwargs: Any):
|
||||||
|
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
provider_snapshot = build_provider_snapshot(config)
|
provider_snapshot = _observe_fallback_models(build_provider_snapshot(config))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
@@ -1696,7 +1711,7 @@ def _run_gateway(
|
|||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
provider_snapshot_loader=load_provider_snapshot,
|
provider_snapshot_loader=_load_gateway_provider_snapshot,
|
||||||
preset_catalog_loader=load_model_preset_catalog,
|
preset_catalog_loader=load_model_preset_catalog,
|
||||||
runtime_events=runtime_events,
|
runtime_events=runtime_events,
|
||||||
turn_delivery_factory=turn_delivery_factory,
|
turn_delivery_factory=turn_delivery_factory,
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ _FALLBACK_ERROR_TOKENS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FallbackModelObserver = Callable[[str], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
class FallbackProvider(LLMProvider):
|
class FallbackProvider(LLMProvider):
|
||||||
"""Wrap a primary provider and transparently failover to fallback models.
|
"""Wrap a primary provider and transparently failover to fallback models.
|
||||||
|
|
||||||
@@ -108,10 +111,12 @@ class FallbackProvider(LLMProvider):
|
|||||||
primary: LLMProvider,
|
primary: LLMProvider,
|
||||||
fallback_presets: list[Any],
|
fallback_presets: list[Any],
|
||||||
provider_factory: Callable[[Any], LLMProvider],
|
provider_factory: Callable[[Any], LLMProvider],
|
||||||
|
fallback_model_observer: FallbackModelObserver | None = None,
|
||||||
):
|
):
|
||||||
self._primary = primary
|
self._primary = primary
|
||||||
self._fallback_presets = list(fallback_presets)
|
self._fallback_presets = list(fallback_presets)
|
||||||
self._provider_factory = provider_factory
|
self._provider_factory = provider_factory
|
||||||
|
self._fallback_model_observer = fallback_model_observer
|
||||||
self._has_fallbacks = bool(fallback_presets)
|
self._has_fallbacks = bool(fallback_presets)
|
||||||
self._primary_failures = 0
|
self._primary_failures = 0
|
||||||
self._primary_tripped_at: float | None = None
|
self._primary_tripped_at: float | None = None
|
||||||
@@ -127,6 +132,10 @@ class FallbackProvider(LLMProvider):
|
|||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
return self._primary.get_default_model()
|
return self._primary.get_default_model()
|
||||||
|
|
||||||
|
def set_fallback_model_observer(self, observer: FallbackModelObserver | None) -> None:
|
||||||
|
"""Attach a process-level observer without changing request call signatures."""
|
||||||
|
self._fallback_model_observer = observer
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_progress_deltas(self) -> bool:
|
def supports_progress_deltas(self) -> bool:
|
||||||
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
||||||
@@ -268,6 +277,8 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
await self._notify_fallback_model(fallback_model)
|
||||||
|
|
||||||
original_values = {
|
original_values = {
|
||||||
name: kwargs.get(name, _MISSING)
|
name: kwargs.get(name, _MISSING)
|
||||||
for name in ("model", "max_tokens", "temperature", "reasoning_effort")
|
for name in ("model", "max_tokens", "temperature", "reasoning_effort")
|
||||||
@@ -315,6 +326,14 @@ class FallbackProvider(LLMProvider):
|
|||||||
finish_reason="error",
|
finish_reason="error",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _notify_fallback_model(self, model: str) -> None:
|
||||||
|
if self._fallback_model_observer is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self._fallback_model_observer(model)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("fallback model observer failed for '{}'", model)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _should_fallback(response: LLMResponse) -> bool:
|
def _should_fallback(response: LLMResponse) -> bool:
|
||||||
if LLMProvider.is_arrearage_response(response):
|
if LLMProvider.is_arrearage_response(response):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent.tools.context import current_request_context
|
||||||
from nanobot.agent.turn_delivery import TurnRoute
|
from nanobot.agent.turn_delivery import TurnRoute
|
||||||
from nanobot.bus import progress as bus_progress
|
from nanobot.bus import progress as bus_progress
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
@@ -20,6 +21,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
RuntimeModelUpdatedEvent,
|
RuntimeModelUpdatedEvent,
|
||||||
SessionUpdatedEvent,
|
SessionUpdatedEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
|
TurnModelUpdatedEvent,
|
||||||
outbound_message_for_event,
|
outbound_message_for_event,
|
||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -33,6 +35,7 @@ from nanobot.bus.runtime_events import (
|
|||||||
TurnRunStatusChanged,
|
TurnRunStatusChanged,
|
||||||
)
|
)
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
|
from nanobot.providers.fallback_provider import FallbackModelObserver
|
||||||
from nanobot.runtime_context import public_history_message
|
from nanobot.runtime_context import public_history_message
|
||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
@@ -272,6 +275,28 @@ class WebuiTurnRoutePolicy:
|
|||||||
return replace(route, metadata=metadata, publish_lifecycle=True)
|
return replace(route, metadata=metadata, publish_lifecycle=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver:
|
||||||
|
"""Translate provider fallback choices into chat-scoped WebUI events."""
|
||||||
|
|
||||||
|
async def _publish(model: str) -> None:
|
||||||
|
context = current_request_context()
|
||||||
|
if context is None or context.channel != "websocket":
|
||||||
|
return
|
||||||
|
chat_id = str(context.chat_id or "").strip()
|
||||||
|
if not chat_id:
|
||||||
|
return
|
||||||
|
await bus.publish_outbound(
|
||||||
|
outbound_message_for_event(
|
||||||
|
channel=context.channel,
|
||||||
|
chat_id=chat_id,
|
||||||
|
event=TurnModelUpdatedEvent(model=model),
|
||||||
|
metadata=context.metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _publish
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WebuiTurnCoordinator:
|
class WebuiTurnCoordinator:
|
||||||
"""Translate generic runtime events into WebUI/WebSocket wire messages."""
|
"""Translate generic runtime events into WebUI/WebSocket wire messages."""
|
||||||
|
|||||||
@@ -285,6 +285,30 @@ class TestFallbackOnPrimaryError:
|
|||||||
assert primary.chat_calls[0]["model"] == "primary-model"
|
assert primary.chat_calls[0]["model"] == "primary-model"
|
||||||
assert fallback.chat_calls[0]["model"] == "fallback-a"
|
assert fallback.chat_calls[0]["model"] == "fallback-a"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reports_the_fallback_model_before_its_request(self) -> None:
|
||||||
|
primary = _FakeProvider("primary", _error_response())
|
||||||
|
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||||
|
fallback_models: list[str] = []
|
||||||
|
|
||||||
|
async def _observe(model: str) -> None:
|
||||||
|
fallback_models.append(model)
|
||||||
|
|
||||||
|
fb = FallbackProvider(
|
||||||
|
primary=primary,
|
||||||
|
fallback_presets=[_fallback("fallback-a", provider="backup")],
|
||||||
|
provider_factory=MagicMock(return_value=fallback),
|
||||||
|
fallback_model_observer=_observe,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fb.chat_with_retry(
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
model="primary-model",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.content == "fallback ok"
|
||||||
|
assert fallback_models == ["fallback-a"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_logs_primary_error_before_fallback(self) -> None:
|
async def test_logs_primary_error_before_fallback(self) -> None:
|
||||||
primary = _FakeProvider("primary", _error_response("primary overloaded"))
|
primary = _FakeProvider("primary", _error_response("primary overloaded"))
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools.context import RequestContext, request_context
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.outbound_events import GoalStatusEvent
|
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent
|
||||||
from nanobot.session import webui_turns as wth
|
from nanobot.session import webui_turns as wth
|
||||||
|
|
||||||
|
|
||||||
@@ -69,3 +70,38 @@ async def test_publish_turn_run_status_non_websocket_noop_registry() -> None:
|
|||||||
await wth.publish_turn_run_status(bus, msg, "running")
|
await wth.publish_turn_run_status(bus, msg, "running")
|
||||||
|
|
||||||
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
|
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fallback_model_is_scoped_to_its_websocket_chat() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_outbound = AsyncMock()
|
||||||
|
observer = wth.build_webui_fallback_model_observer(bus)
|
||||||
|
|
||||||
|
with request_context(
|
||||||
|
RequestContext(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-model",
|
||||||
|
metadata={"webui": True},
|
||||||
|
)
|
||||||
|
):
|
||||||
|
await observer("deepseek/deepseek-chat")
|
||||||
|
|
||||||
|
outbound = bus.publish_outbound.await_args.args[0]
|
||||||
|
assert outbound.channel == "websocket"
|
||||||
|
assert outbound.chat_id == "chat-model"
|
||||||
|
assert outbound.metadata == {"webui": True}
|
||||||
|
assert isinstance(outbound.event, TurnModelUpdatedEvent)
|
||||||
|
assert outbound.event.model == "deepseek/deepseek-chat"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fallback_model_ignores_non_websocket_requests() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_outbound = AsyncMock()
|
||||||
|
observer = wth.build_webui_fallback_model_observer(bus)
|
||||||
|
|
||||||
|
with request_context(RequestContext(channel="telegram", chat_id="chat-model")):
|
||||||
|
await observer("fallback")
|
||||||
|
|
||||||
|
bus.publish_outbound.assert_not_awaited()
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ interface ThreadComposerProps {
|
|||||||
modelProvider?: string | null;
|
modelProvider?: string | null;
|
||||||
modelProviderLabel?: string | null;
|
modelProviderLabel?: string | null;
|
||||||
modelNeedsSetup?: boolean;
|
modelNeedsSetup?: boolean;
|
||||||
|
fallbackModelName?: string | null;
|
||||||
onModelBadgeClick?: () => void;
|
onModelBadgeClick?: () => void;
|
||||||
variant?: "thread" | "hero";
|
variant?: "thread" | "hero";
|
||||||
slashCommands?: SlashCommand[];
|
slashCommands?: SlashCommand[];
|
||||||
@@ -815,6 +816,7 @@ export function ThreadComposer({
|
|||||||
modelProvider = null,
|
modelProvider = null,
|
||||||
modelProviderLabel = null,
|
modelProviderLabel = null,
|
||||||
modelNeedsSetup = false,
|
modelNeedsSetup = false,
|
||||||
|
fallbackModelName = null,
|
||||||
onModelBadgeClick,
|
onModelBadgeClick,
|
||||||
variant = "thread",
|
variant = "thread",
|
||||||
slashCommands = [],
|
slashCommands = [],
|
||||||
@@ -2073,6 +2075,7 @@ export function ThreadComposer({
|
|||||||
provider={modelProvider}
|
provider={modelProvider}
|
||||||
providerLabel={modelProviderLabel}
|
providerLabel={modelProviderLabel}
|
||||||
needsSetup={modelNeedsSetup}
|
needsSetup={modelNeedsSetup}
|
||||||
|
fallbackModelName={fallbackModelName}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -2361,6 +2364,7 @@ function ComposerModelBadge({
|
|||||||
provider,
|
provider,
|
||||||
providerLabel,
|
providerLabel,
|
||||||
needsSetup,
|
needsSetup,
|
||||||
|
fallbackModelName,
|
||||||
isHero,
|
isHero,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
@@ -2368,6 +2372,7 @@ function ComposerModelBadge({
|
|||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
providerLabel?: string | null;
|
providerLabel?: string | null;
|
||||||
needsSetup?: boolean;
|
needsSetup?: boolean;
|
||||||
|
fallbackModelName?: string | null;
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -2381,11 +2386,12 @@ function ComposerModelBadge({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
title={title}
|
data-fallback={fallbackModelName ? "true" : undefined}
|
||||||
|
title={fallbackModelName || title}
|
||||||
type={interactive ? "button" : undefined}
|
type={interactive ? "button" : undefined}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
|
"composer-model-badge inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
|
||||||
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||||
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
|
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
|
||||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ export function ThreadShell({
|
|||||||
forkBoundaryMessageCount,
|
forkBoundaryMessageCount,
|
||||||
} = useSessionHistory(historyKey);
|
} = useSessionHistory(historyKey);
|
||||||
const { client, ingressLimits, modelName, token } = useClient();
|
const { client, ingressLimits, modelName, token } = useClient();
|
||||||
|
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
const cliApps = useInstalledSettingItems({
|
const cliApps = useInstalledSettingItems({
|
||||||
@@ -379,6 +380,7 @@ export function ThreadShell({
|
|||||||
return messageCacheRef.current.get(chatId) ?? historical;
|
return messageCacheRef.current.get(chatId) ?? historical;
|
||||||
}, [chatId, historical]);
|
}, [chatId, historical]);
|
||||||
const handleTurnEnd = useCallback(() => {
|
const handleTurnEnd = useCallback(() => {
|
||||||
|
setFallbackModelName(null);
|
||||||
onTurnEnd?.();
|
onTurnEnd?.();
|
||||||
}, [onTurnEnd]);
|
}, [onTurnEnd]);
|
||||||
const {
|
const {
|
||||||
@@ -519,6 +521,18 @@ export function ThreadShell({
|
|||||||
});
|
});
|
||||||
}, [client, refreshModelSettings]);
|
}, [client, refreshModelSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatId) {
|
||||||
|
setFallbackModelName(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFallbackModelName(null);
|
||||||
|
return client.onChat(chatId, (event) => {
|
||||||
|
if (event.event !== "turn_model_updated") return;
|
||||||
|
setFallbackModelName(event.model_name);
|
||||||
|
});
|
||||||
|
}, [chatId, client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId || loading) return;
|
if (!chatId || loading) return;
|
||||||
const cached = messageCacheRef.current.get(chatId);
|
const cached = messageCacheRef.current.get(chatId);
|
||||||
@@ -680,6 +694,7 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const handleThreadSend = useCallback(
|
const handleThreadSend = useCallback(
|
||||||
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||||
|
setFallbackModelName(null);
|
||||||
setScrollToLatestUserPromptSignal((value) => value + 1);
|
setScrollToLatestUserPromptSignal((value) => value + 1);
|
||||||
send(content, images, withWorkspaceScope(options));
|
send(content, images, withWorkspaceScope(options));
|
||||||
},
|
},
|
||||||
@@ -808,6 +823,7 @@ export function ThreadShell({
|
|||||||
modelProvider={modelBadge.provider}
|
modelProvider={modelBadge.provider}
|
||||||
modelProviderLabel={modelBadge.providerLabel}
|
modelProviderLabel={modelBadge.providerLabel}
|
||||||
modelNeedsSetup={modelBadge.needsSetup}
|
modelNeedsSetup={modelBadge.needsSetup}
|
||||||
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant={showHeroComposer ? "hero" : "thread"}
|
variant={showHeroComposer ? "hero" : "thread"}
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
@@ -845,6 +861,7 @@ export function ThreadShell({
|
|||||||
modelProvider={modelBadge.provider}
|
modelProvider={modelBadge.provider}
|
||||||
modelProviderLabel={modelBadge.providerLabel}
|
modelProviderLabel={modelBadge.providerLabel}
|
||||||
modelNeedsSetup={modelBadge.needsSetup}
|
modelNeedsSetup={modelBadge.needsSetup}
|
||||||
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant="hero"
|
variant="hero"
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
|
|||||||
@@ -124,6 +124,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.composer-model-badge {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-model-badge::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: rgb(236 141 49);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 600ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-model-badge[data-fallback="true"]::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-model-badge > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.composer-model-badge::before {
|
||||||
|
transition-duration: 150ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.host-drag-region {
|
.host-drag-region {
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1087,6 +1087,11 @@ export type InboundEvent =
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
model_preset?: string | null;
|
model_preset?: string | null;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
event: "turn_model_updated";
|
||||||
|
chat_id: string;
|
||||||
|
model_name: string;
|
||||||
|
}
|
||||||
| ({
|
| ({
|
||||||
event: "turn_end";
|
event: "turn_end";
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
|
|||||||
@@ -357,6 +357,30 @@ describe("NanobotClient", () => {
|
|||||||
expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast");
|
expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches turn model updates to the active chat", () => {
|
||||||
|
const client = new NanobotClient({
|
||||||
|
url: "ws://test",
|
||||||
|
reconnect: false,
|
||||||
|
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||||
|
});
|
||||||
|
const chatHandler = vi.fn();
|
||||||
|
client.onChat("chat-a", chatHandler);
|
||||||
|
client.connect();
|
||||||
|
lastSocket().fakeOpen();
|
||||||
|
|
||||||
|
lastSocket().fakeMessage({
|
||||||
|
event: "turn_model_updated",
|
||||||
|
chat_id: "chat-a",
|
||||||
|
model_name: "deepseek/deepseek-chat",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chatHandler).toHaveBeenCalledWith({
|
||||||
|
event: "turn_model_updated",
|
||||||
|
chat_id: "chat-a",
|
||||||
|
model_name: "deepseek/deepseek-chat",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("dispatches session updates globally", () => {
|
it("dispatches session updates globally", () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
|
|||||||
@@ -14,13 +14,23 @@ const HERO_GREETING_PATTERN =
|
|||||||
function makeClient() {
|
function makeClient() {
|
||||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||||
|
const runtimeModelHandlers = new Set<
|
||||||
|
(modelName: string | null, modelPreset?: string | null) => void
|
||||||
|
>();
|
||||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||||
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
defaultChatId: null as string | null,
|
defaultChatId: null as string | null,
|
||||||
onStatus: () => () => {},
|
onStatus: () => () => {},
|
||||||
onRuntimeModelUpdate: () => () => {},
|
onRuntimeModelUpdate: (
|
||||||
|
handler: (modelName: string | null, modelPreset?: string | null) => void,
|
||||||
|
) => {
|
||||||
|
runtimeModelHandlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
runtimeModelHandlers.delete(handler);
|
||||||
|
};
|
||||||
|
},
|
||||||
getRunStartedAt: () => null,
|
getRunStartedAt: () => null,
|
||||||
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
||||||
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
||||||
@@ -55,6 +65,9 @@ function makeClient() {
|
|||||||
}
|
}
|
||||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||||
},
|
},
|
||||||
|
_emitRuntimeModelUpdate(modelName: string | null, modelPreset?: string | null) {
|
||||||
|
for (const h of runtimeModelHandlers) h(modelName, modelPreset);
|
||||||
|
},
|
||||||
_emitSessionUpdate(chatId: string, scope?: string) {
|
_emitSessionUpdate(chatId: string, scope?: string) {
|
||||||
for (const h of sessionUpdateHandlers) h(chatId, scope);
|
for (const h of sessionUpdateHandlers) h(chatId, scope);
|
||||||
},
|
},
|
||||||
@@ -411,6 +424,66 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("highlights the configured model badge without replacing the preset label", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
render(wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("fallback-model")}
|
||||||
|
title="Fallback model"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
settingsSnapshot={modelSettings("openai-codex/gpt-5.5", "openai_codex")}
|
||||||
|
/>,
|
||||||
|
"openai-codex/gpt-5.5",
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(await screen.findByText("gpt-5.5")).toBeInTheDocument();
|
||||||
|
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
|
||||||
|
expect(configuredBadge).not.toBeNull();
|
||||||
|
expect(configuredBadge).toHaveClass("composer-model-badge");
|
||||||
|
expect(configuredBadge).not.toHaveAttribute("data-fallback");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
client._emitChat("fallback-model", {
|
||||||
|
event: "turn_model_updated",
|
||||||
|
chat_id: "fallback-model",
|
||||||
|
model_name: "deepseek/deepseek-chat",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const logo = screen.getByTestId("composer-model-logo-openai_codex");
|
||||||
|
const badge = logo.parentElement;
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge).toBe(configuredBadge);
|
||||||
|
expect(screen.getByText("gpt-5.5")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
|
||||||
|
expect(badge).toHaveAttribute("data-fallback", "true");
|
||||||
|
expect(badge).toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
"deepseek/deepseek-chat",
|
||||||
|
);
|
||||||
|
expect(logo).not.toHaveAttribute("data-fallback");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
client._emitChat("fallback-model", {
|
||||||
|
event: "turn_end",
|
||||||
|
chat_id: "fallback-model",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||||
|
).not.toHaveAttribute("data-fallback");
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||||
|
).toHaveAttribute("title", "gpt-5.5 · OpenAI Codex");
|
||||||
|
expect(
|
||||||
|
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||||
|
).toBe(badge);
|
||||||
|
});
|
||||||
|
|
||||||
it("opens model settings from the unconfigured model badge", async () => {
|
it("opens model settings from the unconfigured model badge", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
|
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
|
||||||
|
|||||||
Reference in New Issue
Block a user