feat(webui): refine output timeline and model controls (#4108)

* feat(webui): refine output timeline and composer queue

* feat(webui): add provider model picker

* fix(webui): polish model settings and heartbeat checks

* chore: keep heartbeat changes out of webui pr

* refactor(webui): isolate settings routes

* fix(providers): align minimax anthropic test

* fix(providers): keep minimax anthropic base sdk-compatible

* fix(providers): normalize anthropic base urls
This commit is contained in:
Xubin Ren 2026-05-30 23:45:26 +08:00 committed by GitHub
parent b2e43955e3
commit 3dcf511c84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 4526 additions and 1428 deletions

View File

@ -27,7 +27,6 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from websockets.http11 import Response from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.security.workspace_access import ( from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY, WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScopeError, WorkspaceScopeError,
@ -45,35 +44,15 @@ from nanobot.utils.media_decode import (
save_base64_data_url, save_base64_data_url,
) )
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.settings_api import ( from nanobot.webui.settings_api import runtime_capabilities
WebUISettingsError, from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
create_model_configuration,
decorate_settings_payload,
login_oauth_provider,
logout_oauth_provider,
runtime_capabilities,
settings_payload,
update_agent_settings,
update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
update_web_search_settings,
)
from nanobot.webui.cli_apps_api import (
cli_apps_action,
cli_apps_payload,
normalize_cli_app_mentions,
)
from nanobot.webui.media_api import ( from nanobot.webui.media_api import (
serve_signed_media, serve_signed_media,
sign_media_path, sign_media_path,
sign_or_stage_media_path, sign_or_stage_media_path,
) )
from nanobot.webui.mcp_presets_api import ( from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
mcp_presets_settings_action, from nanobot.webui.settings_routes import WebUISettingsRouter
normalize_mcp_preset_mentions,
)
from nanobot.webui.sidebar_state import ( from nanobot.webui.sidebar_state import (
read_webui_sidebar_state, read_webui_sidebar_state,
write_webui_sidebar_state, write_webui_sidebar_state,
@ -88,18 +67,6 @@ from nanobot.webui.workspaces import (
WebUIWorkspaceController, WebUIWorkspaceController,
) )
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom",
"/api/settings/mcp-presets/import": "import",
"/api/settings/mcp-presets/import-cursor": "import-cursor",
"/api/settings/mcp-presets/tools": "tools",
}
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
@ -318,34 +285,6 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
return _parse_request_path(path_with_query)[1] return _parse_request_path(path_with_query)[1]
def _parse_mcp_settings_query(request: WsRequest) -> dict[str, list[str]]:
query = _parse_query(request.path)
raw = request.headers.get(_MCP_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("MCP settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid MCP settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("MCP settings payload must be a JSON object")
merged = {key: list(values) for key, values in query.items()}
for key, value in payload.items():
if not isinstance(key, str) or not key:
raise WebUISettingsError("MCP settings payload contains an invalid key")
if value is None:
continue
if isinstance(value, str):
text = value.strip()
else:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if text:
merged[key] = [text]
return merged
def _query_first(query: dict[str, list[str]], key: str) -> str | None: def _query_first(query: dict[str, list[str]], key: str) -> str | None:
"""Return the first value for *key*, or None.""" """Return the first value for *key*, or None."""
values = query.get(key) values = query.get(key)
@ -586,7 +525,16 @@ class WebSocketChannel(BaseChannel):
self._runtime_surface, self._runtime_surface,
runtime_capabilities_overrides, runtime_capabilities_overrides,
) )
self._settings_restart_sections: set[str] = set() self._settings_routes = WebUISettingsRouter(
bus=self.bus,
logger=self.logger,
check_api_token=self._check_api_token,
parse_query=_parse_query,
json_response=_http_json_response,
error_response=_http_error,
runtime_surface=self._runtime_surface,
runtime_capabilities=self._runtime_capabilities,
)
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {} self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
# Process-local secret used to HMAC-sign media URLs. The signed URL is # Process-local secret used to HMAC-sign media URLs. The signed URL is
# the capability — anyone who holds a valid URL can fetch that one # the capability — anyone who holds a valid URL can fetch that one
@ -808,59 +756,7 @@ class WebSocketChannel(BaseChannel):
request: WsRequest, request: WsRequest,
got: str, got: str,
) -> Response | None: ) -> Response | None:
if got == "/api/settings": return await self._settings_routes.dispatch(request, got)
return self._handle_settings(request)
if got == "/api/settings/update":
return self._handle_settings_update(request)
if got == "/api/settings/model-configurations/create":
return self._handle_settings_model_configuration_create(request)
if got == "/api/settings/model-configurations/update":
return self._handle_settings_model_configuration_update(request)
if got == "/api/settings/provider/update":
return self._handle_settings_provider_update(request)
if got == "/api/settings/provider/oauth-login":
return await self._handle_settings_provider_oauth(request, "login")
if got == "/api/settings/provider/oauth-logout":
return await self._handle_settings_provider_oauth(request, "logout")
if got == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if got == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if got == "/api/settings/network-safety/update":
return self._handle_settings_network_safety_update(request)
if got == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request)
if got == "/api/settings/cli-apps/install":
return await self._handle_settings_cli_apps_action(request, "install")
if got == "/api/settings/cli-apps/update":
return await self._handle_settings_cli_apps_action(request, "update")
if got == "/api/settings/cli-apps/uninstall":
return await self._handle_settings_cli_apps_action(request, "uninstall")
if got == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
if got == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(got)
if mcp_action is not None:
return await self._handle_settings_mcp_presets(request, mcp_action)
return None
def _dispatch_session_api_route( def _dispatch_session_api_route(
self, self,
@ -1019,38 +915,6 @@ class WebSocketChannel(BaseChannel):
self._webui_workspaces.payload(controls_available=_is_localhost(connection)) self._webui_workspaces.payload(controls_available=_is_localhost(connection))
) )
def _handle_settings(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
self._with_settings_restart_state(
settings_payload(
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
)
)
)
def _with_settings_restart_state(
self,
payload: dict[str, Any],
*,
section: str | None = None,
) -> dict[str, Any]:
"""Keep restart-required state alive for this gateway process."""
if section and payload.get("requires_restart"):
self._settings_restart_sections.add(section)
sections = sorted(self._settings_restart_sections)
payload = dict(payload)
if sections:
payload["requires_restart"] = True
return decorate_settings_payload(
payload,
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
restart_required_sections=sections,
)
def _handle_commands(self, request: WsRequest) -> Response: def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
@ -1083,142 +947,6 @@ class WebSocketChannel(BaseChannel):
return _http_error(500, "failed to write sidebar state") return _http_error(500, "failed to write sidebar state")
return _http_json_response(state) return _http_json_response(state)
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_agent_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(
self._with_settings_restart_state(payload, section="runtime")
)
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = create_model_configuration(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_model_configuration(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_provider_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
async def _handle_settings_provider_oauth(self, request: WsRequest, action: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
if action == "login":
payload = await asyncio.to_thread(login_oauth_provider, query)
else:
payload = await asyncio.to_thread(logout_oauth_provider, query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_web_search_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="browser"))
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_image_generation_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_network_safety_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="runtime"))
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
try:
payload = cli_apps_payload()
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return _http_error(500, "failed to load CLI Apps")
return _http_json_response(payload)
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = await asyncio.to_thread(cli_apps_action, action, query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return _http_error(status, message)
return _http_json_response(payload)
async def _handle_settings_mcp_presets(
self,
request: WsRequest,
action: str | None = None,
) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
try:
payload = await mcp_presets_settings_action(
action,
_parse_mcp_settings_query(request),
reload_mcp=lambda: request_mcp_reload(self.bus),
)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("MCP preset action '{}' failed", action or "list")
return _http_error(status, message)
if action is None:
return _http_json_response(payload)
return _http_json_response(
self._with_settings_restart_state(payload, section="runtime")
)
# -- Session replay, transcript, and signed media ---------------------- # -- Session replay, transcript, and signed media ----------------------
@staticmethod @staticmethod

View File

@ -45,13 +45,21 @@ class AnthropicProvider(LLMProvider):
if api_key: if api_key:
client_kw["api_key"] = api_key client_kw["api_key"] = api_key
if api_base: if api_base:
client_kw["base_url"] = api_base client_kw["base_url"] = self._normalize_base_url(api_base)
if extra_headers: if extra_headers:
client_kw["default_headers"] = extra_headers client_kw["default_headers"] = extra_headers
# Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification. # Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification.
client_kw["max_retries"] = 0 client_kw["max_retries"] = 0
self._client = AsyncAnthropic(**client_kw) self._client = AsyncAnthropic(**client_kw)
@staticmethod
def _normalize_base_url(api_base: str) -> str:
"""Anthropic SDK appends /v1 to request paths internally."""
normalized = api_base.rstrip("/")
if normalized.endswith("/v1"):
return normalized[: -len("/v1")]
return normalized
@classmethod @classmethod
def _handle_error(cls, e: Exception) -> LLMResponse: def _handle_error(cls, e: Exception) -> LLMResponse:
response = getattr(e, "response", None) response = getattr(e, "response", None)

View File

@ -19,6 +19,7 @@ from nanobot.utils.helpers import (
find_legal_message_start, find_legal_message_start,
image_placeholder_text, image_placeholder_text,
safe_filename, safe_filename,
strip_think,
) )
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
@ -76,6 +77,17 @@ def _message_preview_text(message: dict[str, Any]) -> str:
return _text_preview(content) return _text_preview(content)
def _metadata_title(metadata: Any) -> str:
if not isinstance(metadata, dict):
return ""
title = metadata.get("title")
if not isinstance(title, str):
return ""
if metadata.get("title_user_edited") is True:
return title
return strip_think(title)
@dataclass @dataclass
class Session: class Session:
"""A conversation session.""" """A conversation session."""
@ -642,7 +654,7 @@ class SessionManager:
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1) key = data.get("key") or path.stem.replace("_", ":", 1)
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
title = metadata.get("title") if isinstance(metadata, dict) else None title = _metadata_title(metadata)
preview = "" preview = ""
fallback_preview = "" fallback_preview = ""
scanned_records = 0 scanned_records = 0
@ -673,7 +685,7 @@ class SessionManager:
"key": key, "key": key,
"created_at": data.get("created_at"), "created_at": data.get("created_at"),
"updated_at": data.get("updated_at"), "updated_at": data.get("updated_at"),
"title": title if isinstance(title, str) else "", "title": title,
"preview": preview, "preview": preview,
"path": str(path) "path": str(path)
}) })
@ -684,11 +696,7 @@ class SessionManager:
"key": repaired.key, "key": repaired.key,
"created_at": repaired.created_at.isoformat(), "created_at": repaired.created_at.isoformat(),
"updated_at": repaired.updated_at.isoformat(), "updated_at": repaired.updated_at.isoformat(),
"title": ( "title": _metadata_title(repaired.metadata),
repaired.metadata.get("title")
if isinstance(repaired.metadata.get("title"), str)
else ""
),
"preview": next( "preview": next(
( (
text text

View File

@ -19,7 +19,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.session.goal_state import goal_state_ws_blob from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import truncate_text from nanobot.utils.helpers import strip_think, truncate_text
from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.llm_runtime import LLMRuntime
WEBUI_SESSION_METADATA_KEY = "webui" WEBUI_SESSION_METADATA_KEY = "webui"
@ -48,6 +48,7 @@ def clean_generated_title(raw: str | None) -> str:
return "" return ""
text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE) text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE)
text = text.strip().strip("\"'`“”‘’") text = text.strip().strip("\"'`“”‘’")
text = strip_think(text)
text = re.sub(r"\s+", " ", text).strip() text = re.sub(r"\s+", " ", text).strip()
text = text.rstrip("。.!?,;:") text = text.rstrip("。.!?,;:")
if len(text) > TITLE_MAX_CHARS: if len(text) > TITLE_MAX_CHARS:
@ -65,6 +66,9 @@ def _title_inputs(session: Session) -> tuple[str, str]:
content = message.get("content") content = message.get("content")
if not isinstance(content, str) or not content.strip(): if not isinstance(content, str) or not content.strip():
continue continue
content = strip_think(content)
if not content:
continue
if role == "user" and not user_text: if role == "user" and not user_text:
user_text = content.strip() user_text = content.strip()
elif role == "assistant" and not assistant_text: elif role == "assistant" and not assistant_text:
@ -89,7 +93,13 @@ async def maybe_generate_webui_title(
return False return False
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY) current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
if isinstance(current_title, str) and current_title.strip(): if isinstance(current_title, str) and current_title.strip():
return False cleaned_current_title = clean_generated_title(current_title)
if cleaned_current_title:
if cleaned_current_title != current_title:
session.metadata[WEBUI_TITLE_METADATA_KEY] = cleaned_current_title
sessions.save(session)
return False
session.metadata.pop(WEBUI_TITLE_METADATA_KEY, None)
user_text, assistant_text = _title_inputs(session) user_text, assistant_text = _title_inputs(session)
if not user_text: if not user_text:

View File

@ -50,10 +50,17 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
"image/jpeg", "image/jpeg",
"image/webp", "image/webp",
"image/gif", "image/gif",
"image/svg+xml",
"video/mp4", "video/mp4",
"video/webm", "video/webm",
"video/quicktime", "video/quicktime",
}) })
_SVG_MEDIA_HEADERS: tuple[tuple[str, str], ...] = (
(
"Content-Security-Policy",
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
),
)
_BYTE_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$") _BYTE_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
@ -203,6 +210,8 @@ def serve_signed_media(
("Cache-Control", "private, max-age=31536000, immutable"), ("Cache-Control", "private, max-age=31536000, immutable"),
("X-Content-Type-Options", "nosniff"), ("X-Content-Type-Options", "nosniff"),
] ]
if mime == "image/svg+xml":
common_headers.extend(_SVG_MEDIA_HEADERS)
try: try:
size = candidate.stat().st_size size = candidate.stat().st_size
except OSError: except OSError:

View File

@ -6,12 +6,15 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
from __future__ import annotations from __future__ import annotations
import os
import re import re
import time import time
from contextlib import suppress from contextlib import suppress
from typing import Any, Literal from typing import Any, Literal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import httpx
from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import ModelPresetConfig from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.image_generation import ( from nanobot.providers.image_generation import (
@ -87,6 +90,47 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
} }
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144} _CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144}
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+") _MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
_MODEL_LIST_UNSUPPORTED_BACKENDS = {
"anthropic",
"azure_openai",
"bedrock",
"github_copilot",
"openai_codex",
}
_MODEL_LIST_CATALOG_PROVIDERS = {
"aihubmix",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"novita",
"openrouter",
"siliconflow",
"volcengine",
"volcengine_coding_plan",
}
_MODEL_LIST_OFFICIAL_PROVIDERS = {
"ant_ling",
"dashscope",
"deepseek",
"gemini",
"groq",
"longcat",
"minimax",
"minimax_anthropic",
"mistral",
"moonshot",
"nvidia",
"openai",
"qianfan",
"skywork",
"stepfun",
"xiaomi_mimo",
"zhipu",
}
class WebUISettingsError(ValueError): class WebUISettingsError(ValueError):
@ -180,6 +224,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
return f"{secret[:4]}••••{secret[-4:]}" return f"{secret[:4]}••••{secret[-4:]}"
def _resolve_env_placeholders(value: str | None) -> str | None:
if not value:
return None
missing = False
def replace(match: re.Match[str]) -> str:
nonlocal missing
env_value = os.environ.get(match.group(1))
if env_value is None:
missing = True
return ""
return env_value
resolved = _ENV_REF_RE.sub(replace, value).strip()
if missing and not resolved:
return None
return resolved or None
def _provider_requires_api_key(spec: Any) -> bool: def _provider_requires_api_key(spec: Any) -> bool:
if spec.backend == "azure_openai": if spec.backend == "azure_openai":
return True return True
@ -251,6 +314,191 @@ def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
) )
def _model_catalog_kind(spec: Any) -> str:
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
return "catalog"
if spec.name in _MODEL_LIST_OFFICIAL_PROVIDERS:
return "official"
if spec.is_local:
return "local"
if spec.is_direct:
return "custom"
if spec.is_gateway:
return "catalog"
return "official"
def _model_id_from_row(row: Any) -> str | None:
if isinstance(row, str):
return row.strip() or None
if not isinstance(row, dict):
return None
for key in ("id", "name", "model"):
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _model_context_window(row: Any) -> int | None:
if not isinstance(row, dict):
return None
for key in (
"context_window",
"context_length",
"max_context_length",
"max_model_len",
"max_input_tokens",
):
value = row.get(key)
if isinstance(value, int) and value > 0:
return value
if isinstance(value, float) and value > 0:
return int(value)
return None
def _model_row_payload(row: Any) -> dict[str, Any] | None:
model_id = _model_id_from_row(row)
if not model_id:
return None
label: str | None = None
owned_by: str | None = None
if isinstance(row, dict):
raw_label = row.get("display_name") or row.get("label") or row.get("name")
if isinstance(raw_label, str) and raw_label.strip() and raw_label.strip() != model_id:
label = raw_label.strip()
raw_owner = row.get("owned_by") or row.get("owner") or row.get("organization")
if isinstance(raw_owner, str) and raw_owner.strip():
owned_by = raw_owner.strip()
return {
"id": model_id,
"label": label,
"owned_by": owned_by,
"context_window": _model_context_window(row),
}
def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
raw_rows = body.get("data") if isinstance(body, dict) else body
if not isinstance(raw_rows, list):
return []
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for raw_row in raw_rows:
row = _model_row_payload(raw_row)
if row is None or row["id"] in seen:
continue
seen.add(row["id"])
rows.append(row)
return rows
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
"""Fetch an OpenAI-compatible provider's model list for Settings.
The result is advisory only: users can always type a custom model id. This
helper deliberately avoids mutating config so probing model lists never
changes runtime behavior.
"""
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
raise WebUISettingsError("provider is required")
spec = find_by_name(provider_name)
if spec is None:
raise WebUISettingsError("unknown provider")
base_payload: dict[str, Any] = {
"provider": spec.name,
"label": spec.label,
"catalog_kind": _model_catalog_kind(spec),
"models": [],
"model_count": 0,
"message": None,
"fetched_at": time.time(),
}
if (
spec.backend in _MODEL_LIST_UNSUPPORTED_BACKENDS
and spec.name != "minimax_anthropic"
) or spec.is_oauth:
return {
**base_payload,
"status": "unsupported",
"catalog_kind": "unsupported",
"message": "Model list is not available for this provider. Type a model ID manually.",
}
config = load_config()
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None:
raise WebUISettingsError("unknown provider")
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
if spec.name == "openai" and not api_base:
api_base = "https://api.openai.com/v1"
if not api_base:
return {
**base_payload,
"status": "missing_api_base",
"message": "Configure an API base URL to load models.",
}
api_key = _resolve_env_placeholders(provider_config.api_key)
if _provider_requires_api_key(spec) and not api_key:
return {
**base_payload,
"status": "not_configured",
"message": "Configure this provider before loading models.",
}
headers = {"Accept": "application/json"}
if api_key:
if spec.name == "minimax_anthropic":
headers["X-Api-Key"] = api_key
else:
headers["Authorization"] = f"Bearer {api_key}"
models_url = f"{api_base.rstrip('/')}/models"
if spec.name == "minimax_anthropic" and not api_base.rstrip("/").endswith("/v1"):
models_url = f"{api_base.rstrip('/')}/v1/models"
try:
response = httpx.get(
models_url,
headers=headers,
timeout=10.0,
follow_redirects=False,
)
response.raise_for_status()
rows = _extract_model_rows(response.json())
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
if status in {401, 403}:
return {
**base_payload,
"status": "not_configured",
"message": "The provider rejected the configured credential.",
}
return {
**base_payload,
"status": "error",
"message": f"Model list request failed with HTTP {status}.",
}
except (httpx.HTTPError, ValueError) as exc:
return {
**base_payload,
"status": "error",
"message": f"Could not load models: {exc}",
}
return {
**base_payload,
"status": "available",
"models": rows,
"model_count": len(rows),
}
def _parse_bool(value: str, field: str) -> bool: def _parse_bool(value: str, field: str) -> bool:
normalized = value.strip().lower() normalized = value.strip().lower()
if normalized not in {"1", "0", "true", "false", "yes", "no"}: if normalized not in {"1", "0", "true", "false", "yes", "no"}:

View File

@ -0,0 +1,329 @@
"""HTTP route adapter for WebUI Settings APIs.
Keep WebUI Settings route handlers here, not in ``channels/websocket.py``.
The websocket channel owns transport concerns; this module owns WebUI Settings
request mapping and response shaping.
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Callable
from typing import Any
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.bus.queue import MessageBus
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
from nanobot.webui.settings_api import (
WebUISettingsError,
create_model_configuration,
decorate_settings_payload,
login_oauth_provider,
logout_oauth_provider,
provider_models_payload,
settings_payload,
update_agent_settings,
update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
update_web_search_settings,
)
QueryParams = dict[str, list[str]]
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom",
"/api/settings/mcp-presets/import": "import",
"/api/settings/mcp-presets/import-cursor": "import-cursor",
"/api/settings/mcp-presets/tools": "tools",
}
class WebUISettingsRouter:
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
def __init__(
self,
*,
bus: MessageBus,
logger: Any,
check_api_token: Callable[[WsRequest], bool],
parse_query: Callable[[str], QueryParams],
json_response: Callable[[dict[str, Any]], Response],
error_response: Callable[[int, str | None], Response],
runtime_surface: str,
runtime_capabilities: dict[str, Any],
) -> None:
self.bus = bus
self.logger = logger
self._check_api_token = check_api_token
self._parse_query = parse_query
self._json_response = json_response
self._error_response = error_response
self._runtime_surface = runtime_surface
self._runtime_capabilities = runtime_capabilities
self._restart_sections: set[str] = set()
async def dispatch(self, request: WsRequest, path: str) -> Response | None:
if path == "/api/settings":
return self._handle_settings(request)
if path == "/api/settings/update":
return self._handle_settings_update(request)
if path == "/api/settings/model-configurations/create":
return self._handle_settings_model_configuration_create(request)
if path == "/api/settings/model-configurations/update":
return self._handle_settings_model_configuration_update(request)
if path == "/api/settings/provider/update":
return self._handle_settings_provider_update(request)
if path == "/api/settings/provider-models":
return await self._handle_settings_provider_models(request)
if path == "/api/settings/provider/oauth-login":
return await self._handle_settings_provider_oauth(request, "login")
if path == "/api/settings/provider/oauth-logout":
return await self._handle_settings_provider_oauth(request, "logout")
if path == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if path == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if path == "/api/settings/network-safety/update":
return self._handle_settings_network_safety_update(request)
if path == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request)
if path == "/api/settings/cli-apps/install":
return await self._handle_settings_cli_apps_action(request, "install")
if path == "/api/settings/cli-apps/update":
return await self._handle_settings_cli_apps_action(request, "update")
if path == "/api/settings/cli-apps/uninstall":
return await self._handle_settings_cli_apps_action(request, "uninstall")
if path == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
if mcp_action is not None:
return await self._handle_settings_mcp_presets(request, mcp_action)
return None
def _query(self, request: WsRequest) -> QueryParams:
return self._parse_query(request.path)
def _authorized(self, request: WsRequest) -> bool:
return self._check_api_token(request)
def _unauthorized(self) -> Response:
return self._error_response(401, "Unauthorized")
def _with_restart_state(
self,
payload: dict[str, Any],
*,
section: str | None = None,
) -> dict[str, Any]:
"""Keep restart-required state alive for this gateway process."""
if section and payload.get("requires_restart"):
self._restart_sections.add(section)
sections = sorted(self._restart_sections)
payload = dict(payload)
if sections:
payload["requires_restart"] = True
return decorate_settings_payload(
payload,
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
restart_required_sections=sections,
)
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
query = self._query(request)
raw = request.headers.get(_MCP_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("MCP settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid MCP settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("MCP settings payload must be a JSON object")
merged = {key: list(values) for key, values in query.items()}
for key, value in payload.items():
if not isinstance(key, str) or not key:
raise WebUISettingsError("MCP settings payload contains an invalid key")
if value is None:
continue
if isinstance(value, str):
text = value.strip()
else:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if text:
merged[key] = [text]
return merged
def _handle_settings(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(
self._with_restart_state(
settings_payload(
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
)
)
)
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_agent_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = create_model_configuration(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_model_configuration(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_provider_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="image"))
async def _handle_settings_provider_models(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to load provider model list")
return self._error_response(500, "failed to load provider model list")
return self._json_response(payload)
async def _handle_settings_provider_oauth(
self,
request: WsRequest,
action: str,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
try:
if action == "login":
payload = await asyncio.to_thread(login_oauth_provider, query)
else:
payload = await asyncio.to_thread(logout_oauth_provider, query)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_web_search_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="browser"))
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_image_generation_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="image"))
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_network_safety_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = cli_apps_payload()
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return self._error_response(500, "failed to load CLI Apps")
return self._json_response(payload)
async def _handle_settings_cli_apps_action(
self,
request: WsRequest,
action: str,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return self._error_response(status, message)
return self._json_response(payload)
async def _handle_settings_mcp_presets(
self,
request: WsRequest,
action: str | None = None,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await mcp_presets_settings_action(
action,
self._parse_mcp_settings_query(request),
reload_mcp=lambda: request_mcp_reload(self.bus),
)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("MCP preset action '{}' failed", action or "list")
return self._error_response(status, message)
if action is None:
return self._json_response(payload)
return self._json_response(self._with_restart_state(payload, section="runtime"))

View File

@ -27,6 +27,7 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
".jpeg", ".jpeg",
".webp", ".webp",
".gif", ".gif",
".svg",
}) })
_INLINE_MARKDOWN_VIDEO_EXTS: frozenset[str] = frozenset({ _INLINE_MARKDOWN_VIDEO_EXTS: frozenset[str] = frozenset({
".mp4", ".mp4",
@ -87,7 +88,12 @@ def rewrite_local_markdown_images(
def _media_kind_from_name(name: str) -> str: def _media_kind_from_name(name: str) -> str:
return "video" if Path(name).suffix.lower() in _INLINE_MARKDOWN_VIDEO_EXTS else "image" ext = Path(name).suffix.lower()
if ext in _INLINE_MARKDOWN_IMAGE_EXTS:
return "image"
if ext in _INLINE_MARKDOWN_VIDEO_EXTS:
return "video"
return "file"
def webui_transcript_path(session_key: str) -> Path: def webui_transcript_path(session_key: str) -> Path:

View File

@ -17,6 +17,7 @@ from nanobot.session.webui_turns import (
WEBUI_SESSION_METADATA_KEY, WEBUI_SESSION_METADATA_KEY,
WEBUI_TITLE_METADATA_KEY, WEBUI_TITLE_METADATA_KEY,
WebuiTurnCoordinator, WebuiTurnCoordinator,
clean_generated_title,
maybe_generate_webui_title, maybe_generate_webui_title,
) )
from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.llm_runtime import LLMRuntime
@ -53,6 +54,11 @@ def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Pa
assert runtime.model == "next-model" assert runtime.model == "next-model"
def test_clean_generated_title_strips_reasoning_tags() -> None:
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: <think> The user said hello") == ""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None: async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path) loop = _make_full_loop(tmp_path)

View File

@ -43,6 +43,32 @@ def test_list_sessions_includes_metadata_title(tmp_path):
assert rows[0]["title"] == "自动生成标题" assert rows[0]["title"] == "自动生成标题"
def test_list_sessions_hides_generated_think_title(tmp_path):
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:chat-think-title")
session.metadata["title"] = "<think> The user said hello and assistant replied"
session.add_message("user", "hello")
manager.save(session)
rows = manager.list_sessions()
assert rows[0]["key"] == "websocket:chat-think-title"
assert rows[0]["title"] == ""
assert rows[0]["preview"] == "hello"
def test_list_sessions_keeps_user_edited_think_title(tmp_path):
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:chat-user-title")
session.metadata["title"] = "<think> literally discussed"
session.metadata["title_user_edited"] = True
manager.save(session)
rows = manager.list_sessions()
assert rows[0]["title"] == "<think> literally discussed"
def test_list_sessions_includes_user_preview(tmp_path): def test_list_sessions_includes_user_preview(tmp_path):
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:chat-preview") session = manager.get_or_create("websocket:chat-preview")

View File

@ -148,7 +148,7 @@ async def test_cli_apps_routes_require_token_and_return_payload(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.channels.websocket.cli_apps_payload", "nanobot.webui.settings_routes.cli_apps_payload",
lambda: { lambda: {
"apps": [ "apps": [
{ {
@ -173,7 +173,7 @@ async def test_cli_apps_routes_require_token_and_return_payload(
}, },
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.channels.websocket.cli_apps_action", "nanobot.webui.settings_routes.cli_apps_action",
lambda action, query: { lambda action, query: {
"apps": [], "apps": [],
"installed_count": 1, "installed_count": 1,
@ -280,7 +280,7 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False} return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.channels.websocket.request_mcp_reload", "nanobot.webui.settings_routes.request_mcp_reload",
_hot_reload, _hot_reload,
) )
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)

View File

@ -453,6 +453,35 @@ async def test_media_route_degrades_non_image_to_octet_stream(
assert resp.headers.get("x-content-type-options") == "nosniff" assert resp.headers.get("x-content-type-options") == "nosniff"
@pytest.mark.asyncio
async def test_media_route_serves_svg_with_strict_csp(
bus: MagicMock, tmp_path: Path
) -> None:
"""Generated SVG can preview as an image without becoming executable HTML."""
media = tmp_path / "media"
media.mkdir()
target = media / "chart.svg"
target.write_text("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>")
channel = _ch(bus, port=29928)
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(f"http://127.0.0.1:29928{url_path}")
finally:
await channel.stop()
await server_task
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/svg+xml")
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 # /api/sessions/<key>/messages: media_urls hydration on session read
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -469,6 +469,28 @@ def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
assert config.get_api_base() == "https://api.xiaomimimo.com/v1" assert config.get_api_base() == "https://api.xiaomimimo.com/v1"
def test_config_explicit_minimax_anthropic_provider_uses_default_api_base():
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "minimax_anthropic",
"model": "MiniMax-M2.7-highspeed",
}
},
"providers": {
"minimaxAnthropic": {
"apiKey": "test-key",
}
},
}
)
assert config.get_provider_name() == "minimax_anthropic"
assert config.get_api_key() == "test-key"
assert config.get_api_base() == "https://api.minimax.io/anthropic"
def test_config_auto_detects_ollama_from_local_api_base(): def test_config_auto_detects_ollama_from_local_api_base():
config = Config.model_validate( config = Config.model_validate(
{ {

View File

@ -22,6 +22,18 @@ def test_anthropic_disables_sdk_retries_by_default() -> None:
assert kwargs["max_retries"] == 0 assert kwargs["max_retries"] == 0
def test_anthropic_normalizes_versioned_base_url() -> None:
with patch("anthropic.AsyncAnthropic") as mock_client:
AnthropicProvider(
api_key="sk-test",
api_base="https://api.minimax.io/anthropic/v1",
default_model="MiniMax-M2.7-highspeed",
)
kwargs = mock_client.call_args.kwargs
assert kwargs["base_url"] == "https://api.minimax.io/anthropic"
def test_azure_openai_disables_sdk_retries_by_default() -> None: def test_azure_openai_disables_sdk_retries_by_default() -> None:
with patch("nanobot.providers.azure_openai_provider.AsyncOpenAI") as mock_client: with patch("nanobot.providers.azure_openai_provider.AsyncOpenAI") as mock_client:
AzureOpenAIProvider( AzureOpenAIProvider(

View File

@ -84,6 +84,42 @@ def test_replay_infers_video_media_from_attachment_name() -> None:
] ]
def test_replay_infers_svg_media_from_attachment_name() -> None:
msgs = replay_transcript_to_ui_messages(
[
{"event": "user", "chat_id": "t-svg", "text": "send svg"},
{
"event": "message",
"chat_id": "t-svg",
"text": "chart ready",
"media_urls": [{"url": "/api/media/sig/payload", "name": "chart.svg"}],
},
],
)
assert msgs[1]["media"] == [
{"kind": "image", "url": "/api/media/sig/payload", "name": "chart.svg"},
]
def test_replay_infers_file_media_from_attachment_name() -> None:
msgs = replay_transcript_to_ui_messages(
[
{"event": "user", "chat_id": "t-file-media", "text": "send html"},
{
"event": "message",
"chat_id": "t-file-media",
"text": "file ready",
"media_urls": [{"url": "/api/media/sig/payload", "name": "index.html"}],
},
],
)
assert msgs[1]["media"] == [
{"kind": "file", "url": "/api/media/sig/payload", "name": "index.html"},
]
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None: def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-file" key = "websocket:t-file"

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import httpx
import pytest import pytest
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_config, save_config
@ -10,6 +11,7 @@ from nanobot.webui.settings_api import (
WebUISettingsError, WebUISettingsError,
_oauth_provider_status, _oauth_provider_status,
create_model_configuration, create_model_configuration,
provider_models_payload,
settings_payload, settings_payload,
update_agent_settings, update_agent_settings,
update_model_configuration, update_model_configuration,
@ -336,6 +338,101 @@ def test_openai_codex_oauth_status_rejects_unavailable_token(
assert status["account"] is None assert status["account"] is None
def test_provider_models_payload_fetches_openai_compatible_models(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.deepseek.api_key = "sk-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def fake_get(url: str, **kwargs):
assert url == "https://api.deepseek.com/models"
assert kwargs["headers"]["Authorization"] == "Bearer sk-test"
return httpx.Response(
200,
json={
"data": [
{"id": "deepseek-chat", "owned_by": "deepseek"},
{"id": "deepseek-reasoner", "context_window": 65536},
]
},
request=httpx.Request("GET", url),
)
monkeypatch.setattr("nanobot.webui.settings_api.httpx.get", fake_get)
payload = provider_models_payload({"provider": ["deepseek"]})
assert payload["status"] == "available"
assert payload["catalog_kind"] == "official"
assert payload["model_count"] == 2
assert payload["models"][0]["id"] == "deepseek-chat"
assert payload["models"][1]["context_window"] == 65536
@pytest.mark.parametrize(
("api_base", "expected_url"),
[
("https://api.minimaxi.com/anthropic", "https://api.minimaxi.com/anthropic/v1/models"),
("https://api.minimaxi.com/anthropic/v1", "https://api.minimaxi.com/anthropic/v1/models"),
],
)
def test_provider_models_payload_fetches_minimax_anthropic_models(
api_base: str,
expected_url: str,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.minimax_anthropic.api_key = "sk-test"
config.providers.minimax_anthropic.api_base = api_base
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def fake_get(url: str, **kwargs):
assert url == expected_url
assert kwargs["headers"]["X-Api-Key"] == "sk-test"
assert "Authorization" not in kwargs["headers"]
return httpx.Response(
200,
json={"data": [{"id": "MiniMax-M2.7-highspeed"}]},
request=httpx.Request("GET", url),
)
monkeypatch.setattr("nanobot.webui.settings_api.httpx.get", fake_get)
payload = provider_models_payload({"provider": ["minimax_anthropic"]})
assert payload["status"] == "available"
assert payload["catalog_kind"] == "official"
assert payload["models"] == [
{
"id": "MiniMax-M2.7-highspeed",
"label": None,
"owned_by": None,
"context_window": None,
}
]
def test_provider_models_payload_requires_gateway_key(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = provider_models_payload({"provider": ["openrouter"]})
assert payload["status"] == "not_configured"
assert payload["models"] == []
def test_create_model_configuration_accepts_configured_oauth_provider( def test_create_model_configuration_accepts_configured_oauth_provider(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,

View File

@ -450,6 +450,7 @@ function Shell({
const [workspaceOverrides, setWorkspaceOverrides] = const [workspaceOverrides, setWorkspaceOverrides] =
useState<Record<string, WorkspaceScopePayload>>({}); useState<Record<string, WorkspaceScopePayload>>({});
const runningChatIdsRef = useRef<Set<string>>(new Set()); const runningChatIdsRef = useRef<Set<string>>(new Set());
const activeChatIdRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -487,6 +488,16 @@ function Shell({
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]); const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
const activeChatId = activeSession?.chatId ?? null; const activeChatId = activeSession?.chatId ?? null;
useEffect(() => {
activeChatIdRef.current = activeChatId;
if (!activeChatId) return;
setCompletedChatIds((current) => {
if (!current.has(activeChatId)) return current;
const next = new Set(current);
next.delete(activeChatId);
return next;
});
}, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => { const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (activeChatId && workspaceOverrides[activeChatId]) { if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId]; return workspaceOverrides[activeChatId];
@ -929,7 +940,11 @@ function Shell({
setRunningChatIds(nextRunning); setRunningChatIds(nextRunning);
setCompletedChatIds((current) => { setCompletedChatIds((current) => {
const next = new Set(current); const next = new Set(current);
next.add(chatId); if (activeChatIdRef.current === chatId) {
next.delete(chatId);
} else {
next.add(chatId);
}
return next; return next;
}); });
}); });

View File

@ -0,0 +1,173 @@
import { useState, type ReactNode } from "react";
import { FileIcon, ImageIcon, PlaySquare } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { UIMediaAttachment } from "@/lib/types";
interface AttachmentTileProps {
attachment: UIMediaAttachment;
className?: string;
inline?: boolean;
variant?: "default" | "compact";
}
export function AttachmentTile({ attachment, className, inline = false, variant = "default" }: AttachmentTileProps) {
const { t } = useTranslation();
const [failed, setFailed] = useState(false);
const hasUrl = typeof attachment.url === "string" && attachment.url.length > 0;
const label = attachmentLabel(attachment, t);
if (attachment.kind === "image" && hasUrl && !failed) {
return (
<AttachmentFrame
attachment={attachment}
className={className}
inline={inline}
variant={variant}
>
<a
href={attachment.url}
target="_blank"
rel="noreferrer noopener"
className="block bg-muted/20"
aria-label={attachment.name ? `Open ${attachment.name}` : t("lightbox.open", { defaultValue: "Open image" })}
>
<img
src={attachment.url}
alt={attachment.name ?? ""}
loading="lazy"
decoding="async"
draggable={false}
onError={() => setFailed(true)}
className={cn(
"block h-auto max-w-full bg-background object-contain",
variant === "compact" ? "max-h-40" : "max-h-[34rem]",
)}
/>
</a>
</AttachmentFrame>
);
}
if (attachment.kind === "video" && hasUrl) {
return (
<AttachmentFrame
attachment={attachment}
className={className}
inline={inline}
variant={variant}
>
<video
src={attachment.url}
controls
preload="auto"
className={cn(
"block w-full bg-black",
variant === "compact" ? "max-h-40" : "max-h-[26rem]",
)}
aria-label={attachment.name ? `${t("message.videoAttachment", { defaultValue: "Video attachment" })}: ${attachment.name}` : t("message.videoAttachment", { defaultValue: "Video attachment" })}
/>
</AttachmentFrame>
);
}
const Icon = attachment.kind === "video"
? PlaySquare
: attachment.kind === "image"
? ImageIcon
: FileIcon;
const body = (
<>
<Icon className="h-4 w-4 flex-none" aria-hidden />
<span className="min-w-0 truncate">{attachment.name ?? label}</span>
</>
);
if (hasUrl && !failed) {
return (
<a
href={attachment.url}
download={attachment.name ?? label}
title={attachment.name ?? undefined}
aria-label={label}
className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
"border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
"transition-colors hover:bg-muted/55 hover:text-foreground",
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
className,
)}
>
{body}
</a>
);
}
return (
<div
className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
"border border-border/60 bg-muted/35 px-3 py-2 text-xs text-muted-foreground",
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
className,
)}
title={attachment.name ?? undefined}
aria-label={label}
>
{body}
<span className="sr-only">
{t("message.attachmentUnavailable", { defaultValue: "Attachment unavailable" })}
</span>
</div>
);
}
function AttachmentFrame({
attachment,
children,
className,
inline = false,
variant = "default",
}: {
attachment: UIMediaAttachment;
children: ReactNode;
className?: string;
inline?: boolean;
variant?: "default" | "compact";
}) {
const frameClassName = cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/60 bg-muted/40",
attachment.kind === "image" && "bg-background/85",
attachment.kind === "video" ? "w-[min(100%,32rem)]" : "",
variant === "compact" && "my-1 rounded-xl shadow-none",
variant === "compact" && attachment.kind === "video" && "w-[min(100%,20rem)]",
className,
);
const bodyClassName = "block max-w-full";
const body = inline ? (
<span className={bodyClassName}>{children}</span>
) : (
<div className={bodyClassName}>{children}</div>
);
return inline ? (
<span className={frameClassName}>
{body}
</span>
) : (
<figure className={frameClassName}>
{body}
</figure>
);
}
function attachmentLabel(attachment: UIMediaAttachment, t: ReturnType<typeof useTranslation>["t"]): string {
if (attachment.kind === "video") {
return t("message.videoAttachment", { defaultValue: "Video attachment" });
}
if (attachment.kind === "image") {
return t("message.imageAttachment", { defaultValue: "Image attachment" });
}
return t("message.fileAttachment", { defaultValue: "File attachment" });
}

View File

@ -540,7 +540,7 @@ function SessionActivityIndicator({
title={label} title={label}
className="grid h-4 w-4 shrink-0 place-items-center" className="grid h-4 w-4 shrink-0 place-items-center"
> >
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" /> <span className="h-2 w-2 rounded-full bg-blue-500 dark:bg-blue-400" />
</span> </span>
); );
} }

View File

@ -54,9 +54,10 @@ const LazyHighlightedCode = lazy(async () => {
function PlainCodeFallback({ code }: { code: string }) { function PlainCodeFallback({ code }: { code: string }) {
return ( return (
<pre <pre
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]" className="m-0 overflow-x-auto whitespace-pre-wrap bg-background p-4 font-mono text-sm leading-[1.6] text-foreground/90"
data-testid="plain-code-fallback"
> >
<code>{code}</code> <code className="text-inherit">{code}</code>
</pre> </pre>
); );
} }

View File

@ -40,6 +40,7 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
const SHORT_STREAM_COMMIT_MS = 80; const SHORT_STREAM_COMMIT_MS = 80;
const MEDIUM_STREAM_COMMIT_MS = 140; const MEDIUM_STREAM_COMMIT_MS = 140;
const LONG_STREAM_COMMIT_MS = 220; const LONG_STREAM_COMMIT_MS = 220;
const STREAMING_HIGHLIGHT_CHAR_LIMIT = 16_000;
export function preloadMarkdownText(): void { export function preloadMarkdownText(): void {
void loadMarkdownRenderer(); void loadMarkdownRenderer();
@ -56,7 +57,9 @@ export function MarkdownText({
streaming = false, streaming = false,
}: MarkdownTextProps) { }: MarkdownTextProps) {
const renderedSource = useStreamingMarkdownSource(children, streaming); const renderedSource = useStreamingMarkdownSource(children, streaming);
const highlightCode = !streaming && renderedSource === children; const highlightCode = streaming
? renderedSource.length <= STREAMING_HIGHLIGHT_CHAR_LIMIT
: renderedSource === children;
useEffect(() => { useEffect(() => {
if (streaming) preloadMarkdownText(); if (streaming) preloadMarkdownText();

View File

@ -1,11 +1,13 @@
import { Children, isValidElement, useMemo } from "react"; import { Children, isValidElement, useMemo, type ReactNode } from "react";
import type { Components } from "react-markdown"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
import { Check } from "lucide-react";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CodeBlock } from "@/components/CodeBlock"; import { CodeBlock } from "@/components/CodeBlock";
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip"; import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
import { inferMediaKind } from "@/lib/media"; import { inferMediaKind } from "@/lib/media";
@ -19,8 +21,181 @@ interface MarkdownTextRendererProps {
highlightCode?: boolean; highlightCode?: boolean;
} }
const remarkPlugins = [remarkBreaks, remarkGfm, remarkMath]; type MarkdownAstNode = {
const rehypePlugins = [rehypeKatex]; type: string;
value?: string;
children?: MarkdownAstNode[];
data?: {
hName?: string;
};
};
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
function extensionOf(value: string): string {
const clean = value.split(/[?#]/, 1)[0]?.trim() ?? "";
const slash = clean.lastIndexOf("/");
const name = slash >= 0 ? clean.slice(slash + 1) : clean;
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot).toLowerCase() : "";
}
function markdownAttachmentKind(source: string, label: string): "image" | "video" | "file" {
const inferredKind = inferMediaKind({ url: source, name: label });
if (inferredKind !== "file") return inferredKind;
return extensionOf(label) || extensionOf(source) ? "file" : "image";
}
function safeHtmlNode(tagName: string, children: MarkdownAstNode[]): MarkdownAstNode {
return {
type: `nanobotSafeHtml${tagName}`,
data: { hName: tagName },
children,
};
}
function safeText(value: string): MarkdownAstNode {
return { type: "text", value };
}
function htmlTag(node: MarkdownAstNode): { tag: string; closing: boolean } | null {
if (node.type !== "html" || typeof node.value !== "string") return null;
const match = /^<\s*(\/?)\s*(mark|sub|sup)\s*>$/i.exec(node.value.trim());
if (!match) return null;
return { tag: match[2].toLowerCase(), closing: match[1] === "/" };
}
function normalizeSafeInlineHtml(children: MarkdownAstNode[]): MarkdownAstNode[] {
const next: MarkdownAstNode[] = [];
for (let index = 0; index < children.length; index += 1) {
const node = children[index];
if (node.children) {
node.children = normalizeSafeInlineHtml(node.children);
}
const tag = htmlTag(node);
if (!tag || tag.closing || !SAFE_INLINE_HTML_TAGS.has(tag.tag)) {
next.push(node);
continue;
}
let closeIndex = -1;
for (let cursor = index + 1; cursor < children.length; cursor += 1) {
const closeTag = htmlTag(children[cursor]);
if (closeTag?.closing && closeTag.tag === tag.tag) {
closeIndex = cursor;
break;
}
}
if (closeIndex === -1) {
next.push(node);
continue;
}
next.push(
safeHtmlNode(
tag.tag,
normalizeSafeInlineHtml(children.slice(index + 1, closeIndex)),
),
);
index = closeIndex;
}
return next;
}
function detailsOpen(node: MarkdownAstNode): { summary: string } | null {
if (node.type !== "html" || typeof node.value !== "string") return null;
const value = node.value.trim();
const match = /^<\s*details\s*>\s*<\s*summary\s*>([\s\S]*?)<\s*\/\s*summary\s*>$/i.exec(value);
if (match) return { summary: match[1].trim() };
if (/^<\s*details\s*>$/i.test(value)) return { summary: "Details" };
return null;
}
function isDetailsClose(node: MarkdownAstNode): boolean {
return node.type === "html"
&& typeof node.value === "string"
&& /^<\s*\/\s*details\s*>$/i.test(node.value.trim());
}
function normalizeSafeDetails(children: MarkdownAstNode[]): MarkdownAstNode[] {
const next: MarkdownAstNode[] = [];
for (let index = 0; index < children.length; index += 1) {
const node = children[index];
const open = detailsOpen(node);
if (!open) {
next.push(node);
continue;
}
const closeIndex = children.findIndex(
(candidate, candidateIndex) => candidateIndex > index && isDetailsClose(candidate),
);
if (closeIndex === -1) {
next.push(node);
continue;
}
const body = normalizeSafeInlineHtml(
normalizeSafeDetails(children.slice(index + 1, closeIndex)),
);
next.push({
type: "nanobotSafeHtmlDetails",
data: { hName: "details" },
children: [
{
type: "nanobotSafeHtmlSummary",
data: { hName: "summary" },
children: [safeText(open.summary)],
},
...body,
],
});
index = closeIndex;
}
return next;
}
function remarkSafeHtmlSubset() {
return (tree: MarkdownAstNode) => {
if (tree.children) {
tree.children = normalizeSafeInlineHtml(normalizeSafeDetails(tree.children));
}
};
}
const remarkPlugins: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [
remarkBreaks,
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
remarkSafeHtmlSubset,
];
const rehypePlugins: NonNullable<ReactMarkdownOptions["rehypePlugins"]> = [rehypeKatex];
function nodeText(value: ReactNode): string {
return Children.toArray(value)
.map((child) => (typeof child === "string" || typeof child === "number" ? String(child) : ""))
.join("");
}
function isRenderedCodeBlock(value: ReactNode): boolean {
if (!isValidElement(value)) return false;
const props = value.props as { code?: unknown };
return value.type === CodeBlock || typeof props.code === "string";
}
function codeFenceFromPreChild(value: ReactNode): { code: string; language?: string } | null {
if (!isValidElement(value)) return null;
const props = value.props as { className?: unknown; children?: ReactNode };
if (!("children" in props)) return null;
const className = typeof props.className === "string" ? props.className : "";
const language = /language-([^\s]+)/.exec(className)?.[1];
return {
code: nodeText(props.children).replace(/\n$/, ""),
language,
};
}
/** /**
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a * Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
@ -82,9 +257,20 @@ export default function MarkdownTextRenderer({
const kids = Children.toArray(markdownChildren); const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null; const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */ /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) { if (isRenderedCodeBlock(lone)) {
return <>{markdownChildren}</>; return <>{markdownChildren}</>;
} }
const fence = codeFenceFromPreChild(lone);
if (fence) {
return (
<CodeBlock
language={fence.language}
code={fence.code}
className="my-3"
highlight={highlightCode}
/>
);
}
return ( return (
<pre <pre
className={cn( className={cn(
@ -110,67 +296,66 @@ export default function MarkdownTextRenderer({
</a> </a>
); );
}, },
input({ type, checked }) {
if (type !== "checkbox") return null;
return (
<span
aria-hidden
data-testid="markdown-task-checkbox"
className={cn(
"mr-2 inline-grid h-4 w-4 translate-y-[2px] place-items-center rounded-[4px]",
"border border-border/70 bg-muted/55 text-background",
checked && "border-foreground/55 bg-foreground/65",
)}
>
{checked ? <Check className="h-3 w-3 stroke-[3]" /> : null}
</span>
);
},
mark({ children: markdownChildren }) {
return (
<mark className="rounded-[5px] bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
{markdownChildren}
</mark>
);
},
sub({ children: markdownChildren }) {
return <sub className="text-[0.72em] leading-none">{markdownChildren}</sub>;
},
sup({ children: markdownChildren }) {
return <sup className="text-[0.72em] leading-none">{markdownChildren}</sup>;
},
details({ children: markdownChildren }) {
return (
<details className="my-3 rounded-xl border border-border/65 bg-muted/25 px-4 py-3 open:pb-4">
{markdownChildren}
</details>
);
},
summary({ children: markdownChildren }) {
return (
<summary className="cursor-pointer select-none text-sm font-medium text-foreground/88 marker:text-muted-foreground">
{markdownChildren}
</summary>
);
},
img({ src, alt, node: _node, className: imgClassName, ...props }) { img({ src, alt, node: _node, className: imgClassName, ...props }) {
void _node; void _node;
void imgClassName;
void props;
const source = typeof src === "string" ? src : ""; const source = typeof src === "string" ? src : "";
if (!source) return null; if (!source) return null;
const label = typeof alt === "string" ? alt : ""; const label = typeof alt === "string" ? alt : "";
if (inferMediaKind({ url: source, name: label }) === "video") { const kind = markdownAttachmentKind(source, label);
return (
<span
className={cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/70 bg-background shadow-sm",
)}
>
<video
src={source}
controls
preload="metadata"
className="block max-h-[26rem] max-w-full bg-black"
aria-label={label ? `Video attachment: ${label}` : "Video attachment"}
/>
{label ? (
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
{label}
</span>
) : null}
</span>
);
}
return ( return (
<span <AttachmentTile
className={cn( attachment={{
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]", kind,
"border border-border/70 bg-background shadow-sm", url: source,
)} name: label,
> }}
<a inline
href={source} />
target="_blank"
rel="noreferrer noopener"
className="block bg-muted/20"
aria-label={label ? `Open ${label}` : "Open image"}
>
<img
src={source}
alt={label}
loading="lazy"
decoding="async"
draggable={false}
className={cn(
"block h-auto max-h-[34rem] max-w-full bg-background object-contain",
imgClassName,
)}
{...props}
/>
</a>
{label ? (
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
{label}
</span>
) : null}
</span>
); );
}, },
}), }),

View File

@ -6,14 +6,16 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react"; import { Check, ChevronRight, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CliAppMentionText } from "@/components/CliAppMentionText"; import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox"; import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatTurnLatency } from "@/lib/format"; import { formatTurnLatency } from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import type { import type {
CliAppInfo, CliAppInfo,
McpPresetInfo, McpPresetInfo,
@ -258,10 +260,11 @@ function MessageMedia({
const images: UIImage[] = []; const images: UIImage[] = [];
const nonImages: UIMediaAttachment[] = []; const nonImages: UIMediaAttachment[] = [];
for (const item of media) { for (const item of media) {
if (item.kind === "image") { const normalized = toMediaAttachment(item);
images.push({ url: item.url, name: item.name }); if (normalized.kind === "image") {
images.push({ url: normalized.url, name: normalized.name });
} else { } else {
nonImages.push(item); nonImages.push(normalized);
} }
} }
@ -276,73 +279,12 @@ function MessageMedia({
<UserImages images={images} align={align} size={align === "left" ? "large" : "compact"} /> <UserImages images={images} align={align} size={align === "left" ? "large" : "compact"} />
) : null} ) : null}
{nonImages.map((item, i) => ( {nonImages.map((item, i) => (
<MediaCell key={`${item.url ?? item.name ?? item.kind}-${i}`} media={item} /> <AttachmentTile key={`${item.url ?? item.name ?? item.kind}-${i}`} attachment={item} />
))} ))}
</div> </div>
); );
} }
function MediaCell({ media }: { media: UIMediaAttachment }) {
const { t } = useTranslation();
const hasUrl = typeof media.url === "string" && media.url.length > 0;
if (media.kind === "video" && hasUrl) {
return (
<figure className="max-w-[min(100%,32rem)] overflow-hidden rounded-[14px] border border-border/60 bg-muted/40">
<video
src={media.url}
controls
preload="metadata"
className="block max-h-[26rem] w-full bg-black"
aria-label={media.name ? `${t("message.videoAttachment", { defaultValue: "Video attachment" })}: ${media.name}` : t("message.videoAttachment", { defaultValue: "Video attachment" })}
/>
{media.name ? (
<figcaption className="truncate px-3 py-1.5 text-[11.5px] text-muted-foreground">
{media.name}
</figcaption>
) : null}
</figure>
);
}
const label =
media.kind === "video"
? t("message.videoAttachment", { defaultValue: "Video attachment" })
: t("message.fileAttachment", { defaultValue: "File attachment" });
const Icon = media.kind === "video" ? PlaySquare : FileIcon;
const inner = (
<>
<Icon className="h-4 w-4 flex-none" aria-hidden />
<span className="truncate">{media.name ?? label}</span>
</>
);
if (hasUrl) {
return (
<a
href={media.url}
download={media.name ?? label}
title={media.name ?? undefined}
aria-label={label}
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground hover:underline"
>
{inner}
</a>
);
}
return (
<div
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
title={media.name ?? undefined}
aria-label={label}
>
{inner}
</div>
);
}
/** /**
* Right-aligned preview row for images attached to a user turn. * Right-aligned preview row for images attached to a user turn.
* *

View File

@ -57,6 +57,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
@ -74,6 +75,7 @@ import {
fetchSettings, fetchSettings,
fetchCliApps, fetchCliApps,
fetchMcpPresets, fetchMcpPresets,
fetchProviderModels,
importMcpConfig, importMcpConfig,
loginProviderOAuth, loginProviderOAuth,
logoutProviderOAuth, logoutProviderOAuth,
@ -105,6 +107,7 @@ import type {
McpPresetInfo, McpPresetInfo,
McpPresetsPayload, McpPresetsPayload,
NetworkSafetySettingsUpdate, NetworkSafetySettingsUpdate,
ProviderModelsPayload,
SettingsPayload, SettingsPayload,
WebSearchSettingsUpdate, WebSearchSettingsUpdate,
WebuiDefaultAccessMode, WebuiDefaultAccessMode,
@ -166,6 +169,23 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png"; const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const; const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
"atomic_chat",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"lm_studio",
"novita",
"ollama",
"openrouter",
"ovms",
"siliconflow",
"vllm",
"volcengine",
"volcengine_coding_plan",
]);
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
const FALLBACK_TIMEZONES = [ const FALLBACK_TIMEZONES = [
"UTC", "UTC",
@ -1124,6 +1144,7 @@ export function SettingsView({
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<ModelsSettings <ModelsSettings
token={token}
form={form} form={form}
setForm={setForm} setForm={setForm}
settings={settings} settings={settings}
@ -1754,7 +1775,7 @@ function NewModelConfigurationDialog({
<div className="space-y-4 px-5 py-5"> <div className="space-y-4 px-5 py-5">
<label className="block"> <label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground"> <span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.configurationName", "Name")} {tx("settings.models.configurationName", "Configuration name")}
</span> </span>
<Input <Input
autoFocus autoFocus
@ -1827,6 +1848,7 @@ function NewModelConfigurationDialog({
} }
function ModelsSettings({ function ModelsSettings({
token,
form, form,
setForm, setForm,
settings, settings,
@ -1838,6 +1860,7 @@ function ModelsSettings({
onSave, onSave,
onCreateConfiguration, onCreateConfiguration,
}: { }: {
token: string;
form: AgentSettingsDraft; form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>; setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
settings: SettingsPayload; settings: SettingsPayload;
@ -1876,8 +1899,8 @@ function ModelsSettings({
<section> <section>
<SettingsGroup> <SettingsGroup>
<SettingsRow <SettingsRow
title={tx("settings.rows.currentModel", "Current model")} title={tx("settings.rows.currentModel", "Current configuration")}
description={tx("settings.help.currentModel", "Choose the model nanobot uses for new replies.")} description={tx("settings.help.currentModel", "Used for new replies.")}
> >
<ModelPresetPicker <ModelPresetPicker
presets={settings.model_presets} presets={settings.model_presets}
@ -1906,7 +1929,7 @@ function ModelsSettings({
</SettingsRow> </SettingsRow>
{selectedPreset && !selectedPreset.is_default ? ( {selectedPreset && !selectedPreset.is_default ? (
<SettingsRow <SettingsRow
title={tx("settings.models.configurationName", "Name")} title={tx("settings.models.configurationName", "Configuration name")}
description={tx("settings.models.configurationNameHelp", "Rename this saved model configuration.")} description={tx("settings.models.configurationNameHelp", "Rename this saved model configuration.")}
> >
<Input <Input
@ -1927,7 +1950,13 @@ function ModelsSettings({
value={providerValue} value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")} emptyLabel={t("settings.byok.noConfiguredProviders")}
showProviderLogos={showBrandLogos} showProviderLogos={showBrandLogos}
onChange={(provider) => setForm((prev) => ({ ...prev, provider }))} onChange={(provider) =>
setForm((prev) => ({
...prev,
provider,
model: provider === prev.provider ? prev.model : "",
}))
}
/> />
</SettingsRow> </SettingsRow>
{selectedProviderNeedsSignIn ? ( {selectedProviderNeedsSignIn ? (
@ -1958,10 +1987,13 @@ function ModelsSettings({
title={t("settings.rows.model")} title={t("settings.rows.model")}
description={t("settings.help.model")} description={t("settings.help.model")}
> >
<Input <ModelIdPicker
token={token}
settings={settings}
provider={form.provider}
value={form.model} value={form.model}
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))} showProviderLogos={showBrandLogos}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" onChange={(model) => setForm((prev) => ({ ...prev, model }))}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow <SettingsRow
@ -4190,7 +4222,10 @@ function TimezonePicker({
/> />
</div> </div>
</div> </div>
<div className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5" data-testid="timezone-picker-list"> <div
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
data-testid="timezone-picker-list"
>
{filteredOptions.length ? ( {filteredOptions.length ? (
filteredOptions.map((option) => { filteredOptions.map((option) => {
const selected = option.name === value; const selected = option.name === value;
@ -4268,7 +4303,7 @@ function ProviderPicker({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className="max-h-[18rem] w-[240px] overflow-y-auto" className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
> >
{providers.map((provider) => { {providers.map((provider) => {
const selected = provider.name === value; const selected = provider.name === value;
@ -4300,6 +4335,239 @@ function ProviderPicker({
); );
} }
function ModelIdPicker({
token,
settings,
provider,
value,
showProviderLogos,
onChange,
}: {
token: string;
settings: SettingsPayload;
provider: string;
value: string;
showProviderLogos: boolean;
onChange: (model: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const canFetchModels = Boolean(effectiveProvider && effectiveProvider !== "auto");
const normalizedQuery = query.trim().toLowerCase();
const providerModels = payload?.models ?? [];
const visibleModels = providerModels
.filter((model) => {
if (!normalizedQuery) return true;
return [model.id, model.label ?? "", model.owned_by ?? ""]
.some((field) => field.toLowerCase().includes(normalizedQuery));
})
.slice(0, 80);
const isCatalog = payload?.catalog_kind === "catalog";
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
const hasDeferredSearchQuery =
normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
const shouldFetchModels =
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
const waitingForModelSearch =
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
const hasModelList = payload?.status === "available";
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
const customCandidate = query.trim();
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const providerModelCount = payload?.model_count ?? providerModels.length;
useEffect(() => {
if (!open) return;
setQuery("");
}, [open, effectiveProvider]);
useEffect(() => {
if (!open || !shouldFetchModels) {
setPayload(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setPayload(null);
setError(null);
setLoading(true);
fetchProviderModels(token, effectiveProvider)
.then((nextPayload) => {
if (!cancelled) setPayload(nextPayload);
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [effectiveProvider, open, shouldFetchModels, token]);
const selectModel = (model: string) => {
onChange(model);
setOpen(false);
};
const renderModelRow = (
model: ProviderModelsPayload["models"][number],
options: { selected?: boolean } = {},
) => (
<DropdownMenuItem
key={model.id}
onSelect={() => selectModel(model.id)}
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
"focus:bg-muted/85 focus:text-foreground",
options.selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<span className="min-w-0 truncate font-medium text-foreground">
{model.label ?? model.id}
</span>
</span>
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
</span>
</DropdownMenuItem>
);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<span
className={cn(
"min-w-0 truncate font-medium",
value ? "text-foreground" : "text-muted-foreground",
)}
>
{value || tx("settings.models.selectModel", "Select model")}
</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
>
<div className="p-1 pb-1.5">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder={tx("settings.models.searchModels", "Search or type model ID")}
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
/>
</div>
</div>
{!canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div>
) : waitingForModelSearch ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
</div>
) : loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.models.loadingModels", "Loading models...")}
</div>
) : error || payload?.status === "error" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
</div>
) : payload?.status === "not_configured" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : isCatalog && !normalizedQuery ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
</div>
) : null}
{showModels && visibleModels.length ? (
<div className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent">
{visibleModels.map((model) =>
renderModelRow(model, { selected: model.id === value }),
)}
</div>
) : showModels ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
{customCandidate && !exactQueryMatch && customCandidate !== value ? (
<>
{showModels ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
onSelect={() => selectModel(customCandidate)}
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85"
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
<Pencil className="h-3 w-3" aria-hidden />
</span>
<span className="min-w-0 truncate">
{tx("settings.models.useCustomModel", "Use")}{" "}
<span className="font-medium text-foreground">{customCandidate}</span>
</span>
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
function formatContextWindow(tokens: number): string {
if (tokens >= 1_000_000) {
const value = tokens / 1_000_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
}
if (tokens >= 1_000) {
const value = tokens / 1_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
}
return String(tokens);
}
function ProviderPickerIcon({ function ProviderPickerIcon({
provider, provider,
showBrandLogos, showBrandLogos,
@ -4860,7 +5128,7 @@ function ModelPresetPicker({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className="max-h-[20rem] w-[430px] max-w-[calc(100vw-2rem)] overflow-y-auto" className="max-h-[20rem] w-[430px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
> >
{presets.map((preset) => { {presets.map((preset) => {
const selected = preset.name === value; const selected = preset.name === value;

View File

@ -1,10 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { import {
AlertCircle, AlertCircle,
Check,
CheckCircle2, CheckCircle2,
ChevronRight, ChevronRight,
CircleDashed, FileImage,
Layers, Layers,
Search, Search,
Server, Server,
@ -16,8 +15,20 @@ import { useTranslation } from "react-i18next";
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText"; import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
import { FileReferenceChip } from "@/components/FileReferenceChip"; import { FileReferenceChip } from "@/components/FileReferenceChip";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { StreamingLabelSheen } from "@/components/MessageBubble"; import { StreamingLabelSheen } from "@/components/MessageBubble";
import { ActivityEvidencePreview } from "@/components/thread/activity/ActivityEvidencePreview";
import { ActivityGroup } from "@/components/thread/activity/ActivityGroup";
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
import { DiffPair } from "@/components/thread/activity/DiffPair";
import { FileEditGroup, hasVisibleDiffStats, type FileEditSummary } from "@/components/thread/activity/FileEditRow";
import { ReasoningRow } from "@/components/thread/activity/ReasoningRow";
import {
activityEvidenceFromMessageMedia,
activityEvidenceFromToolEvent,
isAgentActivityMember,
isReasoningOnlyAssistant,
type ActivityEvidence,
} from "@/lib/activity-timeline";
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand"; import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
import { formatToolCallTrace } from "@/lib/tool-traces"; import { formatToolCallTrace } from "@/lib/tool-traces";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -27,15 +38,7 @@ import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessag
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52"; const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24; const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
export function isReasoningOnlyAssistant(m: UIMessage): boolean { export { isAgentActivityMember, isReasoningOnlyAssistant };
if (m.role !== "assistant" || m.kind === "trace") return false;
if (m.content.trim().length > 0) return false;
return !!(m.reasoning?.length || m.reasoningStreaming || m.isStreaming);
}
export function isAgentActivityMember(m: UIMessage): boolean {
return isReasoningOnlyAssistant(m) || m.kind === "trace";
}
interface ActivityCounts { interface ActivityCounts {
reasoningSteps: number; reasoningSteps: number;
@ -58,20 +61,6 @@ interface ActivityCounts {
primaryMcpStatus?: McpRunStatus; primaryMcpStatus?: McpRunStatus;
} }
interface FileEditSummary {
key: string;
path: string;
absolute_path?: string;
added: number;
deleted: number;
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
}
interface CliRunSummary { interface CliRunSummary {
key: string; key: string;
name: string; name: string;
@ -485,7 +474,7 @@ export function AgentActivityCluster({
{outerExpanded && ( {outerExpanded && (
<div <div
className={cn( className={cn(
"ml-2 mt-1 overflow-hidden border-l border-muted-foreground/14 pl-4", "ml-1 mt-1 overflow-hidden pl-1",
)} )}
> >
<div <div
@ -497,11 +486,11 @@ export function AgentActivityCluster({
"overflow-y-auto py-1 pr-1 scrollbar-thin scrollbar-track-transparent", "overflow-y-auto py-1 pr-1 scrollbar-thin scrollbar-track-transparent",
)} )}
> >
<div ref={activityContentRef} className="flex flex-col gap-1.5"> <div ref={activityContentRef} className="flex flex-col gap-0.5">
{messages.map((m) => { {messages.map((m) => {
if (isReasoningOnlyAssistant(m)) { if (isReasoningOnlyAssistant(m)) {
return ( return (
<ActivityReasoningRow <ReasoningRow
key={m.id} key={m.id}
text={m.reasoning ?? ""} text={m.reasoning ?? ""}
streaming={isTurnStreaming && !!m.reasoningStreaming} streaming={isTurnStreaming && !!m.reasoningStreaming}
@ -638,101 +627,14 @@ function traceLines(message: UIMessage): string[] {
return message.content.trim() ? [message.content] : []; return message.content.trim() ? [message.content] : [];
} }
function ActivityReasoningRow({
text,
streaming,
}: {
text: string;
streaming: boolean;
}) {
const { t } = useTranslation();
useEffect(() => {
if (text.length > 0) preloadMarkdownText();
}, [text.length]);
return (
<div className="min-w-0 py-0.5">
<div className="flex min-w-0 items-center gap-2 text-[13px] leading-5 text-muted-foreground/78">
<ReasoningMarker streaming={streaming} />
<StreamingLabelSheen active={streaming} className="min-w-0 font-medium">
{streaming
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
: t("message.reasoning", { defaultValue: "Thinking" })}
</StreamingLabelSheen>
</div>
{text.trim() ? (
<MarkdownText
streaming={streaming}
className={cn(
"mt-1 min-w-0 pl-5 text-[12.5px] italic text-muted-foreground/78",
"prose-p:my-1 prose-li:my-0.5",
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90",
"prose-code:text-[0.92em]",
)}
>
{text}
</MarkdownText>
) : null}
</div>
);
}
function ReasoningMarker({ streaming }: { streaming: boolean }) {
const wasStreamingRef = useRef(streaming);
const [justCompleted, setJustCompleted] = useState(false);
useEffect(() => {
if (wasStreamingRef.current && !streaming) {
setJustCompleted(true);
const timeout = window.setTimeout(() => setJustCompleted(false), 650);
wasStreamingRef.current = streaming;
return () => window.clearTimeout(timeout);
}
wasStreamingRef.current = streaming;
return undefined;
}, [streaming]);
if (streaming) {
return (
<CircleDashed
data-testid="activity-reasoning-marker"
data-state="thinking"
className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/55"
strokeWidth={1.8}
aria-hidden
/>
);
}
return (
<span
data-testid="activity-reasoning-marker"
data-state="done"
className={cn(
"grid h-3.5 w-3.5 shrink-0 place-items-center rounded-full border border-emerald-500/28 text-emerald-500/78",
"bg-emerald-500/[0.035] transition-[border-color,background-color,box-shadow,transform] duration-300 ease-out",
justCompleted
&& "animate-in fade-in-0 zoom-in-75 shadow-[0_0_0_3px_rgba(16,185,129,0.10)] motion-reduce:animate-none",
)}
aria-hidden
>
<Check
className={cn(
"h-2.5 w-2.5 stroke-[2.4]",
justCompleted && "animate-in fade-in-0 zoom-in-50 duration-300 motion-reduce:animate-none",
)}
/>
</span>
);
}
function ActivityTraceList({ function ActivityTraceList({
lines, lines,
active, active,
evidenceByLine,
}: { }: {
lines: string[]; lines: string[];
active: boolean; active: boolean;
evidenceByLine?: Map<string, ActivityEvidence[]>;
}) { }) {
return ( return (
<ul className="space-y-1"> <ul className="space-y-1">
@ -741,6 +643,7 @@ function ActivityTraceList({
key={`${line}-${index}`} key={`${line}-${index}`}
line={line} line={line}
active={active && index === lines.length - 1} active={active && index === lines.length - 1}
evidence={evidenceByLine?.get(line) ?? []}
/> />
))} ))}
</ul> </ul>
@ -761,6 +664,8 @@ function ActivityTraceTimeline({
const lines = traceLines(message); const lines = traceLines(message);
const cliRunsByLine = cliRunMapByTraceLine(message); const cliRunsByLine = cliRunMapByTraceLine(message);
const mcpRunsByLine = mcpRunMapByTraceLine(message); const mcpRunsByLine = mcpRunMapByTraceLine(message);
const evidenceByLine = toolEvidenceByTraceLine(message);
const trailingEvidence = activityEvidenceFromMessageMedia(message);
const renderedRunKeys = new Set<string>(); const renderedRunKeys = new Set<string>();
const items: ReactNode[] = []; const items: ReactNode[] = [];
let normalLines: string[] = []; let normalLines: string[] = [];
@ -772,6 +677,7 @@ function ActivityTraceTimeline({
key={`${message.id}:trace:${suffix}`} key={`${message.id}:trace:${suffix}`}
lines={normalLines} lines={normalLines}
active={active} active={active}
evidenceByLine={evidenceByLine}
/>, />,
); );
normalLines = []; normalLines = [];
@ -790,6 +696,15 @@ function ActivityTraceTimeline({
cliAppsByName={cliAppsByName} cliAppsByName={cliAppsByName}
/>, />,
); );
const evidence = evidenceByLine.get(line) ?? [];
if (evidence.length) {
items.push(
<ActivityEvidenceList
key={`${message.id}:cli-evidence:${cliRun.key}:${index}`}
evidence={evidence}
/>,
);
}
return; return;
} }
@ -805,6 +720,15 @@ function ActivityTraceTimeline({
mcpPresetsByName={mcpPresetsByName} mcpPresetsByName={mcpPresetsByName}
/>, />,
); );
const evidence = evidenceByLine.get(line) ?? [];
if (evidence.length) {
items.push(
<ActivityEvidenceList
key={`${message.id}:mcp-evidence:${mcpRun.key}:${index}`}
evidence={evidence}
/>,
);
}
return; return;
} }
@ -836,10 +760,25 @@ function ActivityTraceTimeline({
); );
} }
return items.length ? <>{items}</> : null; if (trailingEvidence.length) {
items.push(
<ActivityEvidenceList
key={`${message.id}:media-evidence`}
evidence={trailingEvidence}
/>,
);
}
if (!items.length) return null;
const group = describeActivityGroup(message, evidenceByLine, trailingEvidence);
return (
<ActivityGroup title={group.title} icon={group.icon}>
{items}
</ActivityGroup>
);
} }
function ActivityTraceRow({ line, active }: { line: string; active: boolean }) { function ActivityTraceRow({ line, active, evidence = [] }: { line: string; active: boolean; evidence?: ActivityEvidence[] }) {
const trace = describeTraceLine(line); const trace = describeTraceLine(line);
const Icon = trace.kind === "search" const Icon = trace.kind === "search"
? Search ? Search
@ -849,21 +788,90 @@ function ActivityTraceRow({ line, active }: { line: string; active: boolean }) {
? Wrench ? Wrench
: Layers; : Layers;
return ( return (
<li className="flex min-w-0 items-start gap-2 py-0.5 text-[13px] leading-5"> <ActivityStep
<TraceIconMark trace={trace} fallbackIcon={Icon} active={active} /> as="li"
<span className="min-w-0 flex-1"> marker={<TraceIconMark trace={trace} fallbackIcon={Icon} active={active} />}
<span className="font-medium text-muted-foreground/85">{trace.label}</span> active={active && trace.kind !== "done"}
{trace.detail ? ( tone={trace.kind === "done" ? "success" : active ? "active" : "neutral"}
<> label={trace.label}
<span className="text-muted-foreground/55"> </span> detail={trace.detail}
<span className="break-words text-foreground/82">{trace.detail}</span> title={`${trace.label}${trace.detail ? ` ${trace.detail}` : ""}`}
</> >
) : null} <ActivityEvidencePreview evidence={evidence} />
</span> </ActivityStep>
</li>
); );
} }
function ActivityEvidenceList({ evidence }: { evidence: ActivityEvidence[] }) {
return (
<ul className="space-y-1">
<ActivityStep
as="li"
icon={FileImage}
tone="success"
label={evidenceLabel(evidence)}
>
<ActivityEvidencePreview evidence={evidence} />
</ActivityStep>
</ul>
);
}
function evidenceLabel(evidence: ActivityEvidence[]): string {
const first = evidence[0]?.attachment.kind;
if (first === "image") return evidence.length > 1 ? "Found images" : "Found image";
if (first === "video") return evidence.length > 1 ? "Found videos" : "Found video";
return evidence.length > 1 ? "Found files" : "Found file";
}
function toolEvidenceByTraceLine(message: UIMessage): Map<string, ActivityEvidence[]> {
const map = new Map<string, ActivityEvidence[]>();
for (const event of message.toolEvents ?? []) {
const evidence = activityEvidenceFromToolEvent(event);
if (!evidence.length) continue;
const line = formatToolCallTrace(event);
if (!line) continue;
const existing = map.get(line) ?? [];
map.set(line, [...existing, ...evidence]);
}
return map;
}
function allToolEvidence(evidenceByLine: Map<string, ActivityEvidence[]>): ActivityEvidence[] {
return [...evidenceByLine.values()].flat();
}
function describeActivityGroup(
message: UIMessage,
evidenceByLine: Map<string, ActivityEvidence[]>,
mediaEvidence: ActivityEvidence[],
): { title: string; icon: LucideIcon } {
const names = [
...traceLines(message).map((line) => /^([a-zA-Z0-9_.-]+)\(/.exec(line.trim())?.[1] ?? line),
...(message.toolEvents ?? []).map(toolEventDisplayName),
].map((name) => name.toLowerCase());
const evidence = [...allToolEvidence(evidenceByLine), ...mediaEvidence];
const hasVisualEvidence = evidence.some((item) => item.attachment.kind === "image" || item.attachment.kind === "video");
if (hasVisualEvidence && names.some((name) => /browser|screenshot|vision|image|video/.test(name))) {
return { title: "Vision", icon: FileImage };
}
if (names.some((name) => /browser|screenshot/.test(name))) return { title: "Browser", icon: FileImage };
if (names.some((name) => /web|search|fetch|read|open/.test(name))) return { title: "Web", icon: Search };
if (names.some((name) => /exec|shell|terminal|bash|run_cli_app|cli_anything/.test(name))) return { title: "Shell", icon: Terminal };
if (names.some((name) => /^mcp_|mcp/.test(name))) return { title: "MCP", icon: Server };
if (message.fileEdits?.length) return { title: "Files", icon: Layers };
if (evidence.length) return { title: "Media", icon: FileImage };
return { title: "Working", icon: Layers };
}
function toolEventDisplayName(event: ToolProgressEvent): string {
return typeof (event as { function?: { name?: unknown } }).function?.name === "string"
? String((event as { function?: { name?: unknown } }).function?.name)
: typeof event.name === "string"
? event.name
: "";
}
interface TraceDescription { interface TraceDescription {
kind: "search" | "tool" | "done" | "trace"; kind: "search" | "tool" | "done" | "trace";
label: string; label: string;
@ -891,7 +899,7 @@ function TraceIconMark({
<span <span
data-testid={`activity-web-favicon-${trace.host}`} data-testid={`activity-web-favicon-${trace.host}`}
className={cn( className={cn(
"mt-0.5 grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]", "grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]",
active && "animate-pulse", active && "animate-pulse",
)} )}
aria-hidden aria-hidden
@ -909,7 +917,7 @@ function TraceIconMark({
return ( return (
<FallbackIcon <FallbackIcon
className={cn( className={cn(
"mt-0.5 h-3.5 w-3.5 shrink-0", "h-3.5 w-3.5 shrink-0",
trace.kind === "done" trace.kind === "done"
? "text-emerald-500/75" ? "text-emerald-500/75"
: active : active
@ -945,7 +953,7 @@ function describeTraceLine(line: string): TraceDescription {
if (isShellTraceName(name)) { if (isShellTraceName(name)) {
return { return {
kind: "tool", kind: "tool",
label: "Shell", label: "Command",
detail: previewShellTraceDetail(args, trimmed), detail: previewShellTraceDetail(args, trimmed),
}; };
} }
@ -1633,27 +1641,6 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
}); });
} }
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
return edit.added > 0 || edit.deleted > 0;
}
function formatFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
const cleaned = firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}
function CliRunGroup({ function CliRunGroup({
runs, runs,
active, active,
@ -1694,40 +1681,42 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
useEffect(() => setLogoIndex(0), [app?.logo_url]); useEffect(() => setLogoIndex(0), [app?.logo_url]);
return ( return (
<li <ActivityStep
className="flex min-w-0 items-center gap-2 py-0.5 text-[13px] leading-5" as="li"
active={rowActive}
tone={failed ? "error" : rowActive ? "active" : run.status === "done" ? "success" : "neutral"}
title={`${label} @${run.name}${args ? ` ${args}` : ""}${run.error ? ` ${run.error}` : ""}`} title={`${label} @${run.name}${args ? ` ${args}` : ""}${run.error ? ` ${run.error}` : ""}`}
label={label}
marker={(
<span
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 22),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: rowActive ? `0 0 0 3px ${alphaColor(color, 9)}` : undefined,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : app ? (
cliAppInitials(app).slice(0, 2)
) : (
<Terminal className="h-3 w-3" aria-hidden />
)}
</span>
)}
> >
<span <div className="-mt-0.5 flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 22),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: rowActive ? `0 0 0 3px ${alphaColor(color, 9)}` : undefined,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : app ? (
cliAppInitials(app).slice(0, 2)
) : (
<Terminal className="h-3 w-3" aria-hidden />
)}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<StreamingLabelSheen active={rowActive} className="shrink-0 font-medium text-muted-foreground/85">
{label}
</StreamingLabelSheen>
<span className="max-w-[11rem] shrink-0 truncate font-mono text-[12.5px] font-semibold text-foreground/90"> <span className="max-w-[11rem] shrink-0 truncate font-mono text-[12.5px] font-semibold text-foreground/90">
@{run.name} @{run.name}
</span> </span>
@ -1758,8 +1747,8 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
</span> </span>
</> </>
) : null} ) : null}
</span> </div>
</li> </ActivityStep>
); );
} }
@ -1803,40 +1792,42 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
useEffect(() => setLogoIndex(0), [preset?.logo_url]); useEffect(() => setLogoIndex(0), [preset?.logo_url]);
return ( return (
<li <ActivityStep
className="flex min-w-0 items-center gap-2 py-0.5 text-[13px] leading-5" as="li"
active={rowActive}
tone={failed ? "error" : rowActive ? "active" : run.status === "done" ? "success" : "neutral"}
title={`${label} ${displayName} ${run.toolName}${run.argsPreview ? ` ${run.argsPreview}` : ""}${run.error ? ` ${run.error}` : ""}`} title={`${label} ${displayName} ${run.toolName}${run.argsPreview ? ` ${run.argsPreview}` : ""}${run.error ? ` ${run.error}` : ""}`}
label={label}
marker={(
<span
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 22),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: rowActive ? `0 0 0 3px ${alphaColor(color, 9)}` : undefined,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : preset ? (
mcpPresetInitials(preset).slice(0, 2)
) : (
<Server className="h-3 w-3" aria-hidden />
)}
</span>
)}
> >
<span <div className="-mt-0.5 flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 22),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: rowActive ? `0 0 0 3px ${alphaColor(color, 9)}` : undefined,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : preset ? (
mcpPresetInitials(preset).slice(0, 2)
) : (
<Server className="h-3 w-3" aria-hidden />
)}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<StreamingLabelSheen active={rowActive} className="shrink-0 font-medium text-muted-foreground/85">
{label}
</StreamingLabelSheen>
<span className="max-w-[12rem] shrink-0 truncate text-[12.5px] font-semibold text-foreground/90"> <span className="max-w-[12rem] shrink-0 truncate text-[12.5px] font-semibold text-foreground/90">
{displayName} {displayName}
</span> </span>
@ -1856,8 +1847,8 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
</span> </span>
</> </>
) : null} ) : null}
</span> </div>
</li> </ActivityStep>
); );
} }
@ -1870,180 +1861,3 @@ function alphaColor(color: string, percent: number): string {
} }
return `color-mix(in srgb, ${color} ${percent}%, transparent)`; return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
} }
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} />
))}
</ul>
);
}
function FileEditRow({ edit }: { edit: FileEditSummary }) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
: "";
return (
<li
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs"
title={failureDetail || edit.absolute_path || edit.path}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-5 w-5 shrink-0 place-items-center text-muted-foreground/50">
{failed ? (
<AlertCircle className="h-3.5 w-3.5 text-destructive/75" aria-hidden />
) : editing ? (
<CircleDashed className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500/75" aria-hidden />
)}
</span>
{edit.pending && !edit.path ? (
<StreamingLabelSheen
active={editing}
className="min-w-0 text-[12px] font-medium text-muted-foreground"
>
{t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })}
</StreamingLabelSheen>
) : (
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
)}
{failed ? (
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
{failureDetail}
</span>
) : null}
</div>
{hasCountedDiff ? (
<DiffPair added={edit.added} deleted={edit.deleted} />
) : null}
</li>
);
}
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return (
<span
className="inline-flex shrink-0 items-baseline gap-1.5 leading-[inherit] tabular-nums"
data-testid="activity-diff-pair"
>
<DiffValue
sign="+"
value={added}
className="text-emerald-600/75 dark:text-emerald-300/75"
/>
<DiffValue
sign="-"
value={deleted}
className="text-rose-600/70 dark:text-rose-300/75"
/>
</span>
);
}
function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return (
<span
className={cn("inline-flex items-baseline leading-[inherit]", className)}
aria-label={`${sign}${safeValue}`}
>
<span className="inline-flex items-baseline leading-none" aria-hidden>
{sign}
<AnimatedNumber value={safeValue} />
</span>
<span className="sr-only">{sign}{safeValue}</span>
</span>
);
}
function AnimatedNumber({ value }: { value: number }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
const [display, setDisplay] = useState(0);
const displayRef = useRef(0);
const setAnimatedDisplay = useCallback((next: number) => {
displayRef.current = next;
setDisplay(next);
}, []);
useEffect(() => {
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setAnimatedDisplay(safeValue);
return;
}
const start = displayRef.current;
const delta = safeValue - start;
if (delta === 0) {
setAnimatedDisplay(safeValue);
return;
}
const duration = 260;
const startedAt = performance.now();
let frame = 0;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedDisplay(Math.round(start + delta * eased));
if (progress < 1) {
frame = window.requestAnimationFrame(tick);
return;
}
displayRef.current = safeValue;
};
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]);
return <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex items-baseline leading-none" aria-hidden>
{digits.map((digit, index) => (
<RollingDigit
key={`${digits.length}-${index}`}
digit={Number(digit)}
/>
))}
</span>
);
}
function RollingDigit({ digit }: { digit: number }) {
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
<span className="invisible block h-[1em] leading-none">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
style={{ transform: `translateY(-${safeDigit}em)` }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n}
</span>
))}
</span>
</span>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -2,15 +2,13 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
import { import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
AgentActivityCluster, import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
isAgentActivityMember,
} from "@/components/thread/AgentActivityCluster";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps { interface ThreadMessagesProps {
messages: UIMessage[]; messages: UIMessage[];
/** When true, agent turn still in flight — keeps activity cluster expanded. */ /** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean; isStreaming?: boolean;
hiddenMessageCount?: number; hiddenMessageCount?: number;
onLoadEarlier?: () => void; onLoadEarlier?: () => void;
@ -18,9 +16,7 @@ interface ThreadMessagesProps {
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
} }
export type DisplayUnit = export type DisplayUnit = TurnUnit;
| { type: "cluster"; messages: UIMessage[] }
| { type: "single"; message: UIMessage };
/** True when this unit index is the last assistant text slice before the next user message (or end of thread). */ /** True when this unit index is the last assistant text slice before the next user message (or end of thread). */
export function isFinalAssistantSliceBeforeNextUser( export function isFinalAssistantSliceBeforeNextUser(
@ -28,170 +24,17 @@ export function isFinalAssistantSliceBeforeNextUser(
index: number, index: number,
): boolean { ): boolean {
const u = units[index]; const u = units[index];
if (u.type !== "single" || u.message.role !== "assistant") return true; if (u.type !== "message" || u.message.role !== "assistant") return true;
for (let j = index + 1; j < units.length; j++) { for (let j = index + 1; j < units.length; j++) {
const v = units[j]; const v = units[j];
if (v.type === "single" && v.message.role === "user") break; if (v.type === "message" && v.message.role === "user") break;
return false; return false;
} }
return true; return true;
} }
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] { export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
const out: DisplayUnit[] = []; return normalizeActivityTimeline(messages);
let i = 0;
while (i < messages.length) {
const m = messages[i];
if (isAgentActivityMember(m)) {
const cluster: UIMessage[] = [];
let segmentId: string | undefined = m.activitySegmentId;
let clusterHasFileEdits = hasFileEdits(m);
while (
i < messages.length
&& isAgentActivityMember(messages[i])
&& canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i])
) {
const current = messages[i];
if (!segmentId && current.activitySegmentId) {
segmentId = current.activitySegmentId;
}
clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current);
cluster.push(current);
i += 1;
}
pushActivityCluster(out, cluster);
continue;
}
const previous = out[out.length - 1];
if (
previous?.type === "cluster"
&& assistantHasInlineReasoning(m)
&& canFoldInlineReasoning(previous.messages, m)
) {
previous.messages.push(reasoningOnlyMessageFromAnswer(m));
out.push({ type: "single", message: stripInlineReasoning(m) });
i += 1;
continue;
}
if (assistantHasInlineReasoning(m)) {
out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] });
out.push({ type: "single", message: stripInlineReasoning(m) });
i += 1;
continue;
}
out.push({ type: "single", message: m });
i += 1;
}
return out;
}
function pushActivityCluster(out: DisplayUnit[], cluster: UIMessage[]) {
const previous = out[out.length - 1];
if (
previous?.type !== "single"
|| !shouldPlaceLateActivityBeforeAssistant(out, previous.message)
) {
out.push({ type: "cluster", messages: cluster });
return;
}
const beforeAssistant = out[out.length - 2];
if (beforeAssistant?.type === "cluster" && canMergeActivityClusters(beforeAssistant.messages, cluster)) {
beforeAssistant.messages.push(...cluster);
return;
}
out.splice(out.length - 1, 0, { type: "cluster", messages: cluster });
}
function shouldPlaceLateActivityBeforeAssistant(out: DisplayUnit[], message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.isStreaming) return true;
if (hasTurnLatency(message)) return true;
const beforeAssistant = out[out.length - 2];
return beforeAssistant?.type === "cluster";
}
function hasTurnLatency(message: UIMessage): boolean {
return (
typeof message.latencyMs === "number"
&& Number.isFinite(message.latencyMs)
&& message.latencyMs >= 0
);
}
function clusterSegmentId(messages: UIMessage[]): string | undefined {
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
}
function hasFileEdits(message: UIMessage): boolean {
return !!message.fileEdits?.length;
}
function clusterHasFileEdits(messages: UIMessage[]): boolean {
return messages.some(hasFileEdits);
}
function canJoinActivityCluster(
clusterSegmentId: string | undefined,
clusterIncludesFileEdits: boolean,
message: UIMessage,
): boolean {
const messageHasFileEdits = hasFileEdits(message);
if (!clusterIncludesFileEdits && !messageHasFileEdits) return true;
if (!clusterSegmentId || !message.activitySegmentId) return true;
return clusterSegmentId === message.activitySegmentId;
}
function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean {
if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true;
const segmentId = clusterSegmentId(cluster);
if (!segmentId || !message.activitySegmentId) return true;
return segmentId === message.activitySegmentId;
}
function canMergeActivityClusters(target: UIMessage[], incoming: UIMessage[]): boolean {
let segmentId = clusterSegmentId(target);
let includesFileEdits = clusterHasFileEdits(target);
for (const message of incoming) {
if (!canJoinActivityCluster(segmentId, includesFileEdits, message)) return false;
if (!segmentId && message.activitySegmentId) {
segmentId = message.activitySegmentId;
}
includesFileEdits = includesFileEdits || hasFileEdits(message);
}
return true;
}
function assistantHasInlineReasoning(message: UIMessage): boolean {
return (
message.role === "assistant"
&& message.kind !== "trace"
&& message.content.trim().length > 0
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
);
}
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
return {
id: `${message.id}-reasoning`,
role: "assistant",
content: "",
createdAt: message.createdAt,
reasoning: message.reasoning,
reasoningStreaming: message.reasoningStreaming,
isStreaming: message.reasoningStreaming,
activitySegmentId: message.activitySegmentId,
latencyMs: message.latencyMs,
};
}
function stripInlineReasoning(message: UIMessage): UIMessage {
const next = { ...message };
delete next.reasoning;
delete next.reasoningStreaming;
return next;
} }
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] { export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
@ -199,11 +42,11 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
let hasLaterUnitBeforeUser = false; let hasLaterUnitBeforeUser = false;
for (let i = units.length - 1; i >= 0; i -= 1) { for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i]; const unit = units[i];
if (unit.type === "single" && unit.message.role === "user") { if (unit.type === "message" && unit.message.role === "user") {
hasLaterUnitBeforeUser = false; hasLaterUnitBeforeUser = false;
continue; continue;
} }
if (unit.type === "single" && unit.message.role === "assistant") { if (unit.type === "message" && unit.message.role === "assistant") {
flags[i] = !hasLaterUnitBeforeUser; flags[i] = !hasLaterUnitBeforeUser;
} }
hasLaterUnitBeforeUser = true; hasLaterUnitBeforeUser = true;
@ -222,8 +65,8 @@ export function ThreadMessages({
const { t } = useTranslation(); const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]); const units = useMemo(() => buildDisplayUnits(messages), [messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]); const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndex = useMemo( const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndex(units) : -1, () => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units], [isStreaming, units],
); );
@ -251,20 +94,18 @@ export function ThreadMessages({
: ""; : "";
const next = units[index + 1]; const next = units[index + 1];
const hasBodyBelow = const hasBodyBelow =
unit.type === "cluster" unit.type === "activity"
&& next?.type === "single" && next?.type === "message"
&& next.message.role === "assistant"; && next.message.role === "assistant";
const turnLatencyMs =
unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined;
return ( return (
<div key={unitKey(unit, index)} className={marginTop}> <div key={unitKey(unit, index)} className={marginTop}>
{unit.type === "cluster" ? ( {unit.type === "activity" ? (
<AgentActivityCluster <AgentActivityCluster
messages={unit.messages} messages={unit.messages}
isTurnStreaming={index === liveActivityClusterIndex} isTurnStreaming={liveActivityClusterIndices.has(index)}
hasBodyBelow={hasBodyBelow} hasBodyBelow={hasBodyBelow}
turnLatencyMs={turnLatencyMs} turnLatencyMs={unit.turnLatencyMs}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
/> />
@ -287,49 +128,45 @@ export function ThreadMessages({
); );
} }
function activityClusterTurnLatencyMs( function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
messages: UIMessage[], const indices = new Set<number>();
next: DisplayUnit | undefined, let markedCurrentActivity = false;
): number | undefined {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const latency = messages[i].latencyMs;
if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) {
return latency;
}
}
if (
next?.type === "single"
&& next.message.role === "assistant"
&& typeof next.message.latencyMs === "number"
&& Number.isFinite(next.message.latencyMs)
&& next.message.latencyMs >= 0
) {
return next.message.latencyMs;
}
return undefined;
}
function currentActivityClusterIndex(units: DisplayUnit[]): number {
for (let i = units.length - 1; i >= 0; i -= 1) { for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i]; const unit = units[i];
if (unit.type === "cluster") return i; if (unit.type === "activity") {
if (!markedCurrentActivity) {
indices.add(i);
markedCurrentActivity = true;
continue;
}
if (activityHasLiveFileEdit(unit)) {
indices.add(i);
}
continue;
}
if (unit.message.role === "assistant" && unit.message.isStreaming) continue; if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
if (unit.message.role === "user") break; if (unit.message.role === "user") break;
return -1;
} }
return -1; return indices;
}
function activityHasLiveFileEdit(unit: Extract<DisplayUnit, { type: "activity" }>): boolean {
return unit.messages.some((message) => (
message.kind === "trace"
&& message.fileEdits?.some((edit) => edit.status === "editing" || edit.pending || !edit.path)
));
} }
function unitKey(unit: DisplayUnit, index: number): string { function unitKey(unit: DisplayUnit, index: number): string {
if (unit.type === "cluster") { if (unit.type === "activity") {
const anchor = unit.messages[0]?.id; const anchor = unit.messages[0]?.id;
return anchor != null ? `cluster-${anchor}` : `cluster-idx-${index}`; return anchor != null ? `activity-${anchor}` : `activity-idx-${index}`;
} }
return unit.message.id; return unit.message.id;
} }
function marginAfterPrevUnit(prev: DisplayUnit): string { function marginAfterPrevUnit(prev: DisplayUnit): string {
if (prev.type === "cluster") { if (prev.type === "activity") {
return "mt-4"; return "mt-4";
} }
const p = prev.message; const p = prev.message;

View File

@ -167,7 +167,6 @@ export function ThreadShell({
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]); const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]); const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot); const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroImageMode, setHeroImageMode] = useState(false);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey); const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0); const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null); const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@ -211,8 +210,6 @@ export function ThreadShell({
() => toModelBadgeInfo(modelName, settings), () => toModelBadgeInfo(modelName, settings),
[modelName, settings], [modelName, settings],
); );
const imageGenerationEnabled = settings?.image_generation.enabled === true;
useEffect(() => { useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) { if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey()); setHeroGreetingKey(randomHeroGreetingKey());
@ -508,9 +505,6 @@ export function ThreadShell({
slashCommands={slashCommands} slashCommands={slashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop} onStop={stop}
runStartedAt={runStartedAt} runStartedAt={runStartedAt}
goalState={goalState} goalState={goalState}
@ -520,6 +514,7 @@ export function ThreadShell({
workspaceScopeDisabled={workspaceScopeDisabled} workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError} workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId}
/> />
) : ( ) : (
<ThreadComposer <ThreadComposer
@ -538,9 +533,6 @@ export function ThreadShell({
slashCommands={slashCommands} slashCommands={slashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt} runStartedAt={runStartedAt}
goalState={goalState} goalState={goalState}
workspaceScope={workspaceScope} workspaceScope={workspaceScope}

View File

@ -0,0 +1,35 @@
import { AttachmentTile } from "@/components/AttachmentTile";
import { cn } from "@/lib/utils";
import type { ActivityEvidence } from "@/lib/activity-timeline";
interface ActivityEvidencePreviewProps {
evidence: ActivityEvidence[];
className?: string;
}
export function ActivityEvidencePreview({ evidence, className }: ActivityEvidencePreviewProps) {
if (evidence.length === 0) return null;
return (
<div
data-testid="activity-evidence-preview"
className={cn(
"flex max-w-full flex-wrap items-start gap-2 pt-0.5",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200",
className,
)}
>
{evidence.slice(0, 4).map((item) => (
<AttachmentTile
key={item.id}
attachment={item.attachment}
variant="compact"
className={cn(
item.attachment.kind === "image" || item.attachment.kind === "video"
? "max-w-[min(100%,20rem)]"
: "max-w-[14rem]",
)}
/>
))}
</div>
);
}

View File

@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface ActivityGroupProps {
title: string;
icon?: LucideIcon;
children: ReactNode;
className?: string;
}
export function ActivityGroup({ title, icon: Icon, children, className }: ActivityGroupProps) {
return (
<section
className={cn(
"min-w-0 py-1 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-200",
className,
)}
>
<div className="mb-1 flex min-w-0 items-center gap-1.5 pl-0.5 text-[12px] font-medium text-muted-foreground/70">
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
<span className="min-w-0 truncate">{title}</span>
</div>
<div className="min-w-0">{children}</div>
</section>
);
}

View File

@ -0,0 +1,95 @@
import type { CSSProperties, ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { StreamingLabelSheen } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
export type ActivityStepTone = "neutral" | "active" | "success" | "error";
export interface ActivityStepProps {
as?: "div" | "li";
icon?: LucideIcon;
marker?: ReactNode;
label: ReactNode;
detail?: ReactNode;
aside?: ReactNode;
children?: ReactNode;
active?: boolean;
tone?: ActivityStepTone;
title?: string;
className?: string;
contentClassName?: string;
markerClassName?: string;
style?: CSSProperties;
}
export function ActivityStep({
as: Component = "div",
icon: Icon,
marker,
label,
detail,
aside,
children,
active = false,
tone = active ? "active" : "neutral",
title,
className,
contentClassName,
markerClassName,
style,
}: ActivityStepProps) {
return (
<Component
className={cn(
"group/activity-step relative grid min-w-0 grid-cols-[1.125rem_minmax(0,1fr)] gap-2 py-0.5 text-[13px] leading-5",
className,
)}
title={title}
style={style}
>
<span
className={cn(
"relative flex h-5 w-[1.125rem] shrink-0 items-start justify-center pt-[3px]",
"after:absolute after:left-1/2 after:top-[1.25rem] after:h-[calc(100%+0.375rem)] after:w-px after:-translate-x-1/2 after:bg-muted-foreground/14 group-last/activity-step:after:hidden",
)}
aria-hidden
>
{marker ?? (
<span
className={cn(
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
tone === "active" && "border-muted-foreground/28 text-muted-foreground/72",
tone === "success" && "border-emerald-500/28 text-emerald-500/78",
tone === "error" && "border-destructive/30 text-destructive/78",
tone === "neutral" && "border-muted-foreground/18 text-muted-foreground/50",
markerClassName,
)}
>
{Icon ? <Icon className="h-2.5 w-2.5" strokeWidth={2.15} /> : null}
</span>
)}
</span>
<div className={cn("min-w-0", contentClassName)}>
<div className="flex min-w-0 items-baseline gap-1.5">
<StreamingLabelSheen
active={active}
className={cn(
"min-w-0 shrink-0 font-medium",
tone === "error" ? "text-destructive/78" : "text-muted-foreground/85",
)}
>
{label}
</StreamingLabelSheen>
{detail ? (
<span className="min-w-0 break-words text-foreground/82">
{detail}
</span>
) : null}
{aside ? <span className="ml-auto shrink-0">{aside}</span> : null}
</div>
{children ? <div className="mt-1 min-w-0">{children}</div> : null}
</div>
</Component>
);
}

View File

@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return (
<span
className="inline-flex shrink-0 items-baseline gap-1.5 leading-[inherit] tabular-nums"
data-testid="activity-diff-pair"
>
<DiffValue
sign="+"
value={added}
className="text-emerald-600/75 dark:text-emerald-300/75"
/>
<DiffValue
sign="-"
value={deleted}
className="text-rose-600/70 dark:text-rose-300/75"
/>
</span>
);
}
function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return (
<span
className={cn("inline-flex items-baseline leading-[inherit]", className)}
aria-label={`${sign}${safeValue}`}
>
<span className="inline-flex items-baseline leading-none" aria-hidden>
{sign}
<AnimatedNumber value={safeValue} />
</span>
<span className="sr-only">{sign}{safeValue}</span>
</span>
);
}
function AnimatedNumber({ value }: { value: number }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
const [display, setDisplay] = useState(0);
const displayRef = useRef(0);
const setAnimatedDisplay = useCallback((next: number) => {
displayRef.current = next;
setDisplay(next);
}, []);
useEffect(() => {
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setAnimatedDisplay(safeValue);
return;
}
const start = displayRef.current;
const delta = safeValue - start;
if (delta === 0) {
setAnimatedDisplay(safeValue);
return;
}
const duration = 260;
const startedAt = performance.now();
let frame = 0;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedDisplay(Math.round(start + delta * eased));
if (progress < 1) {
frame = window.requestAnimationFrame(tick);
return;
}
displayRef.current = safeValue;
};
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]);
return <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex items-baseline leading-none" aria-hidden>
{digits.map((digit, index) => (
<RollingDigit
key={`${digits.length}-${index}`}
digit={Number(digit)}
/>
))}
</span>
);
}
function RollingDigit({ digit }: { digit: number }) {
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
<span className="invisible block h-[1em] leading-none">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
style={{ transform: `translateY(-${safeDigit}em)` }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n}
</span>
))}
</span>
</span>
);
}

View File

@ -0,0 +1,114 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import type { UIFileEdit } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ActivityStep } from "./ActivityStep";
import { DiffPair } from "./DiffPair";
export interface FileEditSummary {
key: string;
path: string;
absolute_path?: string;
added: number;
deleted: number;
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
}
export function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} />
))}
</ul>
);
}
function FileEditRow({ edit }: { edit: FileEditSummary }) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
: "";
const statusIcon = failed ? (
<AlertCircle className="h-3 w-3" aria-hidden />
) : editing ? (
<CircleDashed className="h-3 w-3 animate-spin" aria-hidden />
) : (
<CheckCircle2 className="h-3 w-3" aria-hidden />
);
return (
<ActivityStep
as="li"
marker={(
<span
className={cn(
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
failed && "border-destructive/30 text-destructive/78",
editing && "border-muted-foreground/24 text-muted-foreground/65",
!failed && !editing && "border-emerald-500/28 text-emerald-500/78",
)}
>
{statusIcon}
</span>
)}
active={editing}
tone={failed ? "error" : editing ? "active" : "success"}
className="text-xs"
contentClassName="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"
title={failureDetail || edit.absolute_path || edit.path}
label={edit.pending && !edit.path
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
: (
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
)}
detail={failed ? (
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
{failureDetail}
</span>
) : null}
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
/>
);
}
export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
return edit.added > 0 || edit.deleted > 0;
}
function formatFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
const cleaned = firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}

View File

@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import { Check, CircleDashed } from "lucide-react";
import { useTranslation } from "react-i18next";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { ActivityStep } from "./ActivityStep";
export function ReasoningRow({
text,
streaming,
}: {
text: string;
streaming: boolean;
}) {
const { t } = useTranslation();
useEffect(() => {
if (text.length > 0) preloadMarkdownText();
}, [text.length]);
return (
<ActivityStep
marker={<ReasoningMarker streaming={streaming} />}
active={streaming}
tone={streaming ? "active" : "success"}
label={streaming
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
: t("message.reasoning", { defaultValue: "Thinking" })}
>
{text.trim() ? (
<MarkdownText
streaming={streaming}
className={cn(
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
"prose-p:my-1 prose-li:my-0.5",
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90",
"prose-code:text-[0.92em]",
)}
>
{text}
</MarkdownText>
) : null}
</ActivityStep>
);
}
function ReasoningMarker({ streaming }: { streaming: boolean }) {
const wasStreamingRef = useRef(streaming);
const [justCompleted, setJustCompleted] = useState(false);
useEffect(() => {
if (wasStreamingRef.current && !streaming) {
setJustCompleted(true);
const timeout = window.setTimeout(() => setJustCompleted(false), 650);
wasStreamingRef.current = streaming;
return () => window.clearTimeout(timeout);
}
wasStreamingRef.current = streaming;
return undefined;
}, [streaming]);
if (streaming) {
return (
<CircleDashed
data-testid="activity-reasoning-marker"
data-state="thinking"
className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/55"
strokeWidth={1.8}
aria-hidden
/>
);
}
return (
<span
data-testid="activity-reasoning-marker"
data-state="done"
className={cn(
"grid h-3.5 w-3.5 shrink-0 place-items-center rounded-full border border-emerald-500/28 text-emerald-500/78",
"bg-emerald-500/[0.035] transition-[border-color,background-color,box-shadow,transform] duration-300 ease-out",
justCompleted
&& "animate-in fade-in-0 zoom-in-75 shadow-[0_0_0_3px_rgba(16,185,129,0.10)] motion-reduce:animate-none",
)}
aria-hidden
>
<Check
className={cn(
"h-2.5 w-2.5 stroke-[2.4]",
justCompleted && "animate-in fade-in-0 zoom-in-50 duration-300 motion-reduce:animate-none",
)}
/>
</span>
);
}

View File

@ -12,7 +12,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuContentClassName = const menuContentClassName =
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]"; "z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl scrollbar-thin scrollbar-track-transparent dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
const menuItemClassName = const menuItemClassName =
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]"; "relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";

View File

@ -110,6 +110,14 @@
--tw-prose-lead: hsl(var(--foreground)); --tw-prose-lead: hsl(var(--foreground));
} }
.markdown-content .contains-task-list {
@apply list-none pl-0;
}
.markdown-content .task-list-item {
@apply list-none pl-0;
}
/* CJK-friendly line-height: prose paragraphs default to 1.625 which is /* CJK-friendly line-height: prose paragraphs default to 1.625 which is
tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better
readability when the browser detects a CJK primary font. */ readability when the browser detects a CJK primary font. */
@ -185,6 +193,70 @@
} }
} }
@keyframes composer-status-strip-enter {
0% {
max-height: 0;
opacity: 0;
transform: translateY(10px) scale(0.985);
}
72% {
max-height: var(--composer-strip-max-height, 46px);
opacity: 1;
transform: translateY(-1px) scale(1.004);
}
100% {
max-height: var(--composer-strip-max-height, 46px);
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes composer-status-strip-exit {
0% {
max-height: var(--composer-strip-max-height, 46px);
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
max-height: 0;
opacity: 0;
transform: translateY(8px) scale(0.99);
}
}
.composer-status-strip {
transform-origin: bottom center;
will-change: max-height, opacity, transform;
}
.composer-status-strip[data-state="enter"] {
animation: composer-status-strip-enter 280ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.composer-status-strip[data-state="exit"] {
animation: composer-status-strip-exit 180ms ease-in both;
}
@keyframes queued-prompt-row-enter {
0% {
opacity: 0;
transform: translateY(6px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.queued-prompt-row {
animation: queued-prompt-row-enter 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.composer-status-strip {
will-change: auto;
}
.composer-status-strip[data-state] {
animation: none;
}
.queued-prompt-row {
animation: none;
}
}
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */ /** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
@keyframes goal-shell-glow-breathe { @keyframes goal-shell-glow-breathe {
0%, 0%,

View File

@ -27,6 +27,11 @@ export interface AttachedImage {
error?: AttachmentError; error?: AttachmentError;
} }
export interface RestoredReadyImage {
dataUrl: string;
name?: string;
}
/** Machine-readable rejection reasons surfaced as inline chip errors. /** Machine-readable rejection reasons surfaced as inline chip errors.
* *
* Callers localize these via the ``composer.imageRejected.*`` i18n table. */ * Callers localize these via the ``composer.imageRejected.*`` i18n table. */
@ -48,6 +53,27 @@ const ACCEPTED_MIMES: ReadonlySet<string> = new Set([
"image/gif", "image/gif",
]); ]);
function dataUrlMime(dataUrl: string): string {
const match = /^data:([^;,]+)[;,]/.exec(dataUrl);
return match?.[1] || "image/png";
}
function dataUrlToFile(dataUrl: string, name?: string): File {
const mime = dataUrlMime(dataUrl);
const fallbackName = `image.${mime.split("/")[1] || "png"}`;
try {
const [, base64 = ""] = dataUrl.split(",", 2);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return new File([bytes], name || fallbackName, { type: mime });
} catch {
return new File([], name || fallbackName, { type: mime });
}
}
function uuid(): string { function uuid(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) { if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return (crypto as Crypto).randomUUID(); return (crypto as Crypto).randomUUID();
@ -84,6 +110,10 @@ export interface UseAttachedImagesApi {
* successful submit the optimistic bubble holds onto an independent * successful submit the optimistic bubble holds onto an independent
* ``data:`` URL so tearing down blob previews here is safe. */ * ``data:`` URL so tearing down blob previews here is safe. */
clear: () => void; clear: () => void;
/** Restore already-encoded images, e.g. a queued composer draft moving back
* into the input. These entries are immediately sendable and use their
* ``data:`` URL as a stable preview. */
restoreReadyImages: (images: RestoredReadyImage[]) => void;
/** ``true`` when at least one image is still encoding — Send should wait. */ /** ``true`` when at least one image is still encoding — Send should wait. */
encoding: boolean; encoding: boolean;
/** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */ /** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */
@ -211,6 +241,34 @@ export function useAttachedImages(): UseAttachedImagesApi {
}); });
}, []); }, []);
const restoreReadyImages = useCallback((restored: RestoredReadyImage[]) => {
const toRestore = restored
.filter((img) => ACCEPTED_MIMES.has(dataUrlMime(img.dataUrl)))
.slice(0, MAX_IMAGES_PER_MESSAGE)
.map((img): AttachedImage => {
const file = dataUrlToFile(img.dataUrl, img.name);
return {
id: uuid(),
file,
previewUrl: img.dataUrl,
status: "ready",
dataUrl: img.dataUrl,
encodedBytes: file.size,
};
});
setImages((prev) => {
for (const img of prev) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
}
}
imagesRef.current = toRestore;
return toRestore;
});
}, []);
// Final safety net: revoke any outstanding blob URLs on unmount. Safe // Final safety net: revoke any outstanding blob URLs on unmount. Safe
// under StrictMode double-invoke because revoked blob URLs are only // under StrictMode double-invoke because revoked blob URLs are only
// referenced from in-hook chip state, which is rebuilt on remount. // referenced from in-hook chip state, which is rebuilt on remount.
@ -229,5 +287,5 @@ export function useAttachedImages(): UseAttachedImagesApi {
const encoding = images.some((img) => img.status === "encoding"); const encoding = images.some((img) => img.status === "encoding");
const full = images.length >= MAX_IMAGES_PER_MESSAGE; const full = images.length >= MAX_IMAGES_PER_MESSAGE;
return { images, enqueue, remove, clear, encoding, full }; return { images, enqueue, remove, clear, restoreReadyImages, encoding, full };
} }

View File

@ -102,9 +102,19 @@
"addConfiguration": "Add configuration", "addConfiguration": "Add configuration",
"newConfiguration": "New model configuration", "newConfiguration": "New model configuration",
"newConfigurationHelp": "Save a provider and model as a one-click option.", "newConfigurationHelp": "Save a provider and model as a one-click option.",
"configurationName": "Name", "configurationName": "Configuration name",
"configurationNameHelp": "Rename this saved model configuration.", "configurationNameHelp": "Rename this saved model configuration.",
"configurationNamePlaceholder": "Fast writing" "configurationNamePlaceholder": "Fast writing",
"searchModels": "Search or type model ID",
"useCustomModel": "Use",
"loadingModels": "Loading models...",
"searchCatalog": "Search provider catalog to choose a model.",
"modelsAvailable": "available",
"noModelResults": "No matching models.",
"loadFailed": "Model list unavailable.",
"unsupportedModelList": "Type a model ID manually.",
"providerNotConfigured": "Configure this provider before loading models.",
"autoProviderCustomOnly": "Auto provider mode uses custom model IDs."
}, },
"rows": { "rows": {
"theme": "Theme", "theme": "Theme",
@ -117,7 +127,7 @@
"gateway": "Gateway", "gateway": "Gateway",
"restartState": "Restart state", "restartState": "Restart state",
"pendingChanges": "Pending changes", "pendingChanges": "Pending changes",
"currentModel": "Current model", "currentModel": "Current configuration",
"selectedPreset": "Selected preset", "selectedPreset": "Selected preset",
"presetModel": "Preset model", "presetModel": "Preset model",
"density": "Density", "density": "Density",
@ -155,7 +165,7 @@
"provider": "Select the provider that should serve new model requests.", "provider": "Select the provider that should serve new model requests.",
"model": "Set the default model name used by nanobot.", "model": "Set the default model name used by nanobot.",
"configPath": "The gateway configuration file currently in use.", "configPath": "The gateway configuration file currently in use.",
"currentModel": "Choose the model nanobot uses for new replies.", "currentModel": "Used for new replies.",
"selectedModelProvider": "Set by the selected model.", "selectedModelProvider": "Set by the selected model.",
"selectedModelValue": "Set by the selected model.", "selectedModelValue": "Set by the selected model.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.", "selectedPreset": "Named presets are read-only here; edit them in config.json.",
@ -555,6 +565,13 @@
"goalStateCloseAria": "Close goal", "goalStateCloseAria": "Close goal",
"send": "Send message", "send": "Send message",
"stop": "Stop response", "stop": "Stop response",
"queued": {
"label": "Queued guidance",
"guide": "Guide",
"delete": "Delete guidance",
"edit": "Edit guidance",
"drag": "Drag to reorder"
},
"attachImage": "Attach image", "attachImage": "Attach image",
"imageMode": { "imageMode": {
"label": "Image Generation", "label": "Image Generation",

View File

@ -131,7 +131,7 @@
"workspacePath": "Workspace predeterminado", "workspacePath": "Workspace predeterminado",
"localServiceAccess": "Local services", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Default access",
"currentModel": "Modelo actual", "currentModel": "Configuración actual",
"brandLogos": "Logotipos de marca", "brandLogos": "Logotipos de marca",
"cliAppsCatalog": "Catálogo de apps CLI", "cliAppsCatalog": "Catálogo de apps CLI",
"cliAppsFilter": "Filtro de apps CLI", "cliAppsFilter": "Filtro de apps CLI",
@ -167,7 +167,7 @@
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Elige el modelo que nanobot usará para las próximas respuestas.", "currentModel": "Elige la configuración de modelo que nanobot usará para las próximas respuestas.",
"selectedModelProvider": "Lo define el modelo seleccionado.", "selectedModelProvider": "Lo define el modelo seleccionado.",
"selectedModelValue": "Lo define el modelo seleccionado.", "selectedModelValue": "Lo define el modelo seleccionado.",
"brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.", "brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
@ -295,9 +295,19 @@
"addConfiguration": "Añadir configuración", "addConfiguration": "Añadir configuración",
"newConfiguration": "Nueva configuración de modelo", "newConfiguration": "Nueva configuración de modelo",
"newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.", "newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
"configurationName": "Nombre", "configurationName": "Nombre de configuración",
"configurationNameHelp": "Cambia el nombre de esta configuración de modelo guardada.", "configurationNameHelp": "Cambia el nombre de esta configuración de modelo guardada.",
"configurationNamePlaceholder": "Escritura rápida" "configurationNamePlaceholder": "Escritura rápida",
"searchModels": "Buscar o escribir ID de modelo",
"useCustomModel": "Usar",
"loadingModels": "Cargando modelos...",
"searchCatalog": "Busca en el catálogo del proveedor para elegir un modelo.",
"modelsAvailable": "disponibles",
"noModelResults": "No hay modelos coincidentes.",
"loadFailed": "Lista de modelos no disponible.",
"unsupportedModelList": "Escribe manualmente un ID de modelo.",
"providerNotConfigured": "Configura este proveedor antes de cargar modelos.",
"autoProviderCustomOnly": "El modo de proveedor automático usa ID de modelo personalizados."
}, },
"timezone": { "timezone": {
"select": "Seleccionar zona horaria", "select": "Seleccionar zona horaria",
@ -555,6 +565,13 @@
"goalStateCloseAria": "Cerrar objetivo", "goalStateCloseAria": "Cerrar objetivo",
"send": "Enviar mensaje", "send": "Enviar mensaje",
"stop": "Detener respuesta", "stop": "Detener respuesta",
"queued": {
"label": "Guía en cola",
"guide": "Guiar",
"delete": "Eliminar guía",
"edit": "Editar guía",
"drag": "Arrastrar para reordenar"
},
"attachImage": "Adjuntar imagen", "attachImage": "Adjuntar imagen",
"imageMode": { "imageMode": {
"label": "Generar imagen", "label": "Generar imagen",

View File

@ -131,7 +131,7 @@
"workspacePath": "Espace de travail par défaut", "workspacePath": "Espace de travail par défaut",
"localServiceAccess": "Local services", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Default access",
"currentModel": "Modèle actuel", "currentModel": "Configuration actuelle",
"brandLogos": "Logos de marque", "brandLogos": "Logos de marque",
"cliAppsCatalog": "Catalogue d'apps CLI", "cliAppsCatalog": "Catalogue d'apps CLI",
"cliAppsFilter": "Filtre des apps CLI", "cliAppsFilter": "Filtre des apps CLI",
@ -167,7 +167,7 @@
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Choisissez le modèle que nanobot utilisera pour les prochaines réponses.", "currentModel": "Choisissez la configuration de modèle que nanobot utilisera pour les prochaines réponses.",
"selectedModelProvider": "Défini par le modèle sélectionné.", "selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "Défini par le modèle sélectionné.", "selectedModelValue": "Défini par le modèle sélectionné.",
"brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.", "brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
@ -295,9 +295,19 @@
"addConfiguration": "Ajouter une configuration", "addConfiguration": "Ajouter une configuration",
"newConfiguration": "Nouvelle configuration de modèle", "newConfiguration": "Nouvelle configuration de modèle",
"newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.", "newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
"configurationName": "Nom", "configurationName": "Nom de la configuration",
"configurationNameHelp": "Renommez cette configuration de modèle enregistrée.", "configurationNameHelp": "Renommez cette configuration de modèle enregistrée.",
"configurationNamePlaceholder": "Rédaction rapide" "configurationNamePlaceholder": "Rédaction rapide",
"searchModels": "Rechercher ou saisir lID du modèle",
"useCustomModel": "Utiliser",
"loadingModels": "Chargement des modèles...",
"searchCatalog": "Recherchez dans le catalogue du fournisseur pour choisir un modèle.",
"modelsAvailable": "disponibles",
"noModelResults": "Aucun modèle correspondant.",
"loadFailed": "Liste des modèles indisponible.",
"unsupportedModelList": "Saisissez manuellement un ID de modèle.",
"providerNotConfigured": "Configurez ce fournisseur avant de charger les modèles.",
"autoProviderCustomOnly": "Le mode fournisseur automatique utilise des ID de modèle personnalisés."
}, },
"timezone": { "timezone": {
"select": "Sélectionner un fuseau horaire", "select": "Sélectionner un fuseau horaire",
@ -555,6 +565,13 @@
"goalStateCloseAria": "Fermer lobjectif", "goalStateCloseAria": "Fermer lobjectif",
"send": "Envoyer le message", "send": "Envoyer le message",
"stop": "Arrêter la réponse", "stop": "Arrêter la réponse",
"queued": {
"label": "Guidage en attente",
"guide": "Guider",
"delete": "Supprimer le guidage",
"edit": "Modifier le guidage",
"drag": "Faire glisser pour réordonner"
},
"attachImage": "Joindre une image", "attachImage": "Joindre une image",
"imageMode": { "imageMode": {
"label": "Génération dimage", "label": "Génération dimage",

View File

@ -131,7 +131,7 @@
"workspacePath": "Workspace default", "workspacePath": "Workspace default",
"localServiceAccess": "Local services", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Default access",
"currentModel": "Model saat ini", "currentModel": "Konfigurasi saat ini",
"brandLogos": "Logo merek", "brandLogos": "Logo merek",
"cliAppsCatalog": "Katalog aplikasi CLI", "cliAppsCatalog": "Katalog aplikasi CLI",
"cliAppsFilter": "Filter aplikasi CLI", "cliAppsFilter": "Filter aplikasi CLI",
@ -167,7 +167,7 @@
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Pilih model yang digunakan nanobot untuk balasan berikutnya.", "currentModel": "Pilih konfigurasi model yang digunakan nanobot untuk balasan berikutnya.",
"selectedModelProvider": "Ditentukan oleh model yang dipilih.", "selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.", "selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.", "brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
@ -295,9 +295,19 @@
"addConfiguration": "Tambah konfigurasi", "addConfiguration": "Tambah konfigurasi",
"newConfiguration": "Konfigurasi model baru", "newConfiguration": "Konfigurasi model baru",
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.", "newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
"configurationName": "Nama", "configurationName": "Nama konfigurasi",
"configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.", "configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.",
"configurationNamePlaceholder": "Penulisan cepat" "configurationNamePlaceholder": "Penulisan cepat",
"searchModels": "Cari atau ketik ID model",
"useCustomModel": "Gunakan",
"loadingModels": "Memuat model...",
"searchCatalog": "Cari katalog penyedia untuk memilih model.",
"modelsAvailable": "tersedia",
"noModelResults": "Tidak ada model yang cocok.",
"loadFailed": "Daftar model tidak tersedia.",
"unsupportedModelList": "Ketik ID model secara manual.",
"providerNotConfigured": "Konfigurasikan penyedia ini sebelum memuat model.",
"autoProviderCustomOnly": "Mode penyedia otomatis menggunakan ID model khusus."
}, },
"timezone": { "timezone": {
"select": "Pilih zona waktu", "select": "Pilih zona waktu",
@ -555,6 +565,13 @@
"goalStateCloseAria": "Tutup tujuan", "goalStateCloseAria": "Tutup tujuan",
"send": "Kirim pesan", "send": "Kirim pesan",
"stop": "Hentikan respons", "stop": "Hentikan respons",
"queued": {
"label": "Panduan antrean",
"guide": "Pandu",
"delete": "Hapus panduan",
"edit": "Edit panduan",
"drag": "Seret untuk mengurutkan"
},
"attachImage": "Lampirkan gambar", "attachImage": "Lampirkan gambar",
"imageMode": { "imageMode": {
"label": "Buat gambar", "label": "Buat gambar",

View File

@ -131,7 +131,7 @@
"workspacePath": "デフォルトワークスペース", "workspacePath": "デフォルトワークスペース",
"localServiceAccess": "Local services", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Default access",
"currentModel": "現在のモデル", "currentModel": "現在の設定",
"brandLogos": "ブランドロゴ", "brandLogos": "ブランドロゴ",
"cliAppsCatalog": "CLI アプリカタログ", "cliAppsCatalog": "CLI アプリカタログ",
"cliAppsFilter": "CLI アプリフィルター", "cliAppsFilter": "CLI アプリフィルター",
@ -167,7 +167,7 @@
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "今後の返信で nanobot が使用するモデルを選択します。", "currentModel": "今後の返信で nanobot が使用するモデル設定を選択します。",
"selectedModelProvider": "選択したモデルによって設定されます。", "selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。", "selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。", "brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
@ -295,9 +295,19 @@
"addConfiguration": "設定を追加", "addConfiguration": "設定を追加",
"newConfiguration": "新しいモデル設定", "newConfiguration": "新しいモデル設定",
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。", "newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
"configurationName": "", "configurationName": "設定名",
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。", "configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
"configurationNamePlaceholder": "高速ライティング" "configurationNamePlaceholder": "高速ライティング",
"searchModels": "検索またはモデル ID を入力",
"useCustomModel": "使用",
"loadingModels": "モデルを読み込み中...",
"searchCatalog": "プロバイダーのカタログを検索してモデルを選択します。",
"modelsAvailable": "件利用可能",
"noModelResults": "一致するモデルはありません。",
"loadFailed": "モデル一覧を利用できません。",
"unsupportedModelList": "モデル ID を手動で入力してください。",
"providerNotConfigured": "モデルを読み込む前にこのプロバイダーを設定してください。",
"autoProviderCustomOnly": "自動プロバイダーモードではカスタムモデル ID を使用します。"
}, },
"timezone": { "timezone": {
"select": "タイムゾーンを選択", "select": "タイムゾーンを選択",
@ -555,6 +565,13 @@
"goalStateCloseAria": "目標を閉じる", "goalStateCloseAria": "目標を閉じる",
"send": "メッセージを送信", "send": "メッセージを送信",
"stop": "応答を停止", "stop": "応答を停止",
"queued": {
"label": "保留中のガイド",
"guide": "ガイド",
"delete": "ガイドを削除",
"edit": "ガイドを編集",
"drag": "ドラッグして並べ替え"
},
"attachImage": "画像を添付", "attachImage": "画像を添付",
"imageMode": { "imageMode": {
"label": "画像生成", "label": "画像生成",

View File

@ -131,7 +131,7 @@
"workspacePath": "기본 작업공간", "workspacePath": "기본 작업공간",
"localServiceAccess": "Local services", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Default access",
"currentModel": "현재 모델", "currentModel": "현재 구성",
"brandLogos": "브랜드 로고", "brandLogos": "브랜드 로고",
"cliAppsCatalog": "CLI 앱 카탈로그", "cliAppsCatalog": "CLI 앱 카탈로그",
"cliAppsFilter": "CLI 앱 필터", "cliAppsFilter": "CLI 앱 필터",
@ -167,7 +167,7 @@
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.", "currentModel": "nanobot이 새 답변에 사용할 모델 구성을 선택합니다.",
"selectedModelProvider": "선택한 모델에서 설정됩니다.", "selectedModelProvider": "선택한 모델에서 설정됩니다.",
"selectedModelValue": "선택한 모델에서 설정됩니다.", "selectedModelValue": "선택한 모델에서 설정됩니다.",
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.", "brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
@ -295,9 +295,19 @@
"addConfiguration": "구성 추가", "addConfiguration": "구성 추가",
"newConfiguration": "새 모델 구성", "newConfiguration": "새 모델 구성",
"newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.", "newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
"configurationName": "이름", "configurationName": "구성 이름",
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.", "configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
"configurationNamePlaceholder": "빠른 글쓰기" "configurationNamePlaceholder": "빠른 글쓰기",
"searchModels": "검색하거나 모델 ID 입력",
"useCustomModel": "사용",
"loadingModels": "모델을 불러오는 중...",
"searchCatalog": "제공자 카탈로그를 검색해 모델을 선택하세요.",
"modelsAvailable": "개 사용 가능",
"noModelResults": "일치하는 모델이 없습니다.",
"loadFailed": "모델 목록을 사용할 수 없습니다.",
"unsupportedModelList": "모델 ID를 직접 입력하세요.",
"providerNotConfigured": "모델을 불러오기 전에 이 제공자를 설정하세요.",
"autoProviderCustomOnly": "자동 제공자 모드는 사용자 지정 모델 ID를 사용합니다."
}, },
"timezone": { "timezone": {
"select": "시간대 선택", "select": "시간대 선택",
@ -555,6 +565,13 @@
"goalStateCloseAria": "목표 닫기", "goalStateCloseAria": "목표 닫기",
"send": "메시지 보내기", "send": "메시지 보내기",
"stop": "응답 중지", "stop": "응답 중지",
"queued": {
"label": "대기 중인 안내",
"guide": "안내",
"delete": "안내 삭제",
"edit": "안내 수정",
"drag": "드래그하여 순서 변경"
},
"attachImage": "이미지 첨부", "attachImage": "이미지 첨부",
"imageMode": { "imageMode": {
"label": "이미지 생성", "label": "이미지 생성",

View File

@ -131,7 +131,7 @@
"workspacePath": "Workspace mặc định", "workspacePath": "Workspace mặc định",
"localServiceAccess": "Local services", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Default access",
"currentModel": " hình hiện tại", "currentModel": "Cấu hình hiện tại",
"brandLogos": "Logo thương hiệu", "brandLogos": "Logo thương hiệu",
"cliAppsCatalog": "Danh mục ứng dụng CLI", "cliAppsCatalog": "Danh mục ứng dụng CLI",
"cliAppsFilter": "Bộ lọc ứng dụng CLI", "cliAppsFilter": "Bộ lọc ứng dụng CLI",
@ -167,7 +167,7 @@
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Chọn mô hình nanobot dùng cho các câu trả lời mới.", "currentModel": "Chọn cấu hình mô hình nanobot dùng cho các câu trả lời mới.",
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.", "selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Được đặt bởi mô hình đã chọn.", "selectedModelValue": "Được đặt bởi mô hình đã chọn.",
"brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.", "brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
@ -295,9 +295,19 @@
"addConfiguration": "Thêm cấu hình", "addConfiguration": "Thêm cấu hình",
"newConfiguration": "Cấu hình mô hình mới", "newConfiguration": "Cấu hình mô hình mới",
"newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.", "newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
"configurationName": "Tên", "configurationName": "Tên cấu hình",
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.", "configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
"configurationNamePlaceholder": "Viết nhanh" "configurationNamePlaceholder": "Viết nhanh",
"searchModels": "Tìm hoặc nhập ID mô hình",
"useCustomModel": "Dùng",
"loadingModels": "Đang tải mô hình...",
"searchCatalog": "Tìm trong danh mục nhà cung cấp để chọn mô hình.",
"modelsAvailable": "khả dụng",
"noModelResults": "Không có mô hình phù hợp.",
"loadFailed": "Không tải được danh sách mô hình.",
"unsupportedModelList": "Nhập ID mô hình thủ công.",
"providerNotConfigured": "Cấu hình nhà cung cấp này trước khi tải mô hình.",
"autoProviderCustomOnly": "Chế độ nhà cung cấp tự động dùng ID mô hình tùy chỉnh."
}, },
"timezone": { "timezone": {
"select": "Chọn múi giờ", "select": "Chọn múi giờ",
@ -555,6 +565,13 @@
"goalStateCloseAria": "Đóng mục tiêu", "goalStateCloseAria": "Đóng mục tiêu",
"send": "Gửi tin nhắn", "send": "Gửi tin nhắn",
"stop": "Dừng phản hồi", "stop": "Dừng phản hồi",
"queued": {
"label": "Hướng dẫn đang chờ",
"guide": "Hướng dẫn",
"delete": "Xóa hướng dẫn",
"edit": "Sửa hướng dẫn",
"drag": "Kéo để sắp xếp"
},
"attachImage": "Đính kèm ảnh", "attachImage": "Đính kèm ảnh",
"imageMode": { "imageMode": {
"label": "Tạo ảnh", "label": "Tạo ảnh",

View File

@ -102,9 +102,19 @@
"addConfiguration": "添加配置", "addConfiguration": "添加配置",
"newConfiguration": "新建模型配置", "newConfiguration": "新建模型配置",
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。", "newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
"configurationName": "名称", "configurationName": "配置名称",
"configurationNameHelp": "重命名这个已保存的模型配置。", "configurationNameHelp": "重命名这个已保存的模型配置。",
"configurationNamePlaceholder": "快速写作" "configurationNamePlaceholder": "快速写作",
"searchModels": "搜索或输入模型 ID",
"useCustomModel": "使用",
"loadingModels": "正在加载模型...",
"searchCatalog": "搜索服务商目录来选择模型。",
"modelsAvailable": "个可用",
"noModelResults": "没有匹配的模型。",
"loadFailed": "模型列表暂不可用。",
"unsupportedModelList": "手动输入模型 ID。",
"providerNotConfigured": "先配置这个服务商再加载模型。",
"autoProviderCustomOnly": "自动服务商模式使用自定义模型 ID。"
}, },
"rows": { "rows": {
"theme": "主题", "theme": "主题",
@ -117,7 +127,7 @@
"gateway": "网关", "gateway": "网关",
"restartState": "重启状态", "restartState": "重启状态",
"pendingChanges": "待处理更改", "pendingChanges": "待处理更改",
"currentModel": "当前模型", "currentModel": "当前配置",
"selectedPreset": "选中的预设", "selectedPreset": "选中的预设",
"presetModel": "预设模型", "presetModel": "预设模型",
"density": "密度", "density": "密度",
@ -155,7 +165,7 @@
"provider": "选择新模型请求使用的服务商。", "provider": "选择新模型请求使用的服务商。",
"model": "设置 nanobot 默认使用的模型名称。", "model": "设置 nanobot 默认使用的模型名称。",
"configPath": "当前网关正在使用的配置文件。", "configPath": "当前网关正在使用的配置文件。",
"currentModel": "选择 nanobot 接下来回复时使用的模型。", "currentModel": "选择 nanobot 接下来回复时使用的模型配置。",
"selectedModelProvider": "由当前模型决定。", "selectedModelProvider": "由当前模型决定。",
"selectedModelValue": "由当前模型决定。", "selectedModelValue": "由当前模型决定。",
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。", "selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
@ -554,6 +564,13 @@
"goalStateSheetTitle": "目标", "goalStateSheetTitle": "目标",
"send": "发送消息", "send": "发送消息",
"stop": "停止响应", "stop": "停止响应",
"queued": {
"label": "待引导提示",
"guide": "引导",
"delete": "删除引导",
"edit": "编辑引导",
"drag": "拖动排序"
},
"attachImage": "添加图片", "attachImage": "添加图片",
"imageMode": { "imageMode": {
"label": "图片生成", "label": "图片生成",

View File

@ -131,7 +131,7 @@
"workspacePath": "預設工作區", "workspacePath": "預設工作區",
"localServiceAccess": "Local services", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Default access",
"currentModel": "目前模型", "currentModel": "目前設定",
"brandLogos": "品牌標誌", "brandLogos": "品牌標誌",
"cliAppsCatalog": "CLI 應用目錄", "cliAppsCatalog": "CLI 應用目錄",
"cliAppsFilter": "CLI 應用篩選", "cliAppsFilter": "CLI 應用篩選",
@ -167,7 +167,7 @@
"localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。", "localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。",
"webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。", "webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "選擇 nanobot 接下來回覆時使用的模型。", "currentModel": "選擇 nanobot 接下來回覆時使用的模型設定。",
"selectedModelProvider": "由目前模型決定。", "selectedModelProvider": "由目前模型決定。",
"selectedModelValue": "由目前模型決定。", "selectedModelValue": "由目前模型決定。",
"brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。", "brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
@ -295,9 +295,19 @@
"addConfiguration": "新增設定", "addConfiguration": "新增設定",
"newConfiguration": "新增模型設定", "newConfiguration": "新增模型設定",
"newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。", "newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。",
"configurationName": "名稱", "configurationName": "設定名稱",
"configurationNameHelp": "重新命名這個已儲存的模型配置。", "configurationNameHelp": "重新命名這個已儲存的模型配置。",
"configurationNamePlaceholder": "快速寫作" "configurationNamePlaceholder": "快速寫作",
"searchModels": "搜尋或輸入模型 ID",
"useCustomModel": "使用",
"loadingModels": "正在載入模型...",
"searchCatalog": "搜尋服務商目錄來選擇模型。",
"modelsAvailable": "個可用",
"noModelResults": "沒有符合的模型。",
"loadFailed": "模型列表暫不可用。",
"unsupportedModelList": "手動輸入模型 ID。",
"providerNotConfigured": "先設定這個服務商再載入模型。",
"autoProviderCustomOnly": "自動服務商模式使用自訂模型 ID。"
}, },
"timezone": { "timezone": {
"select": "選擇時區", "select": "選擇時區",
@ -555,6 +565,13 @@
"goalStateCloseAria": "關閉目標", "goalStateCloseAria": "關閉目標",
"send": "送出訊息", "send": "送出訊息",
"stop": "停止回覆", "stop": "停止回覆",
"queued": {
"label": "待引導提示",
"guide": "引導",
"delete": "刪除引導",
"edit": "編輯引導",
"drag": "拖曳排序"
},
"attachImage": "附加圖片", "attachImage": "附加圖片",
"imageMode": { "imageMode": {
"label": "圖片生成", "label": "圖片生成",

View File

@ -0,0 +1,297 @@
import { toMediaAttachment } from "@/lib/media";
import type { ToolProgressEvent, UIMediaAttachment, UIMessage } from "@/lib/types";
export type ActivityItemType = "reasoning" | "tool" | "cli" | "mcp" | "file_edit" | "media";
export type ActivityStepStatus = "pending" | "running" | "done" | "error";
export type ActivityStepSource = "reasoning" | "tool" | "web" | "browser" | "shell" | "mcp" | "file" | "media";
export interface ActivityItem {
type: ActivityItemType;
message: UIMessage;
}
export interface ActivityEvidence {
id: string;
attachment: UIMediaAttachment;
caption?: string;
source: ActivityStepSource;
}
export interface ActivityStepItem {
id: string;
label: string;
detail?: string;
status: ActivityStepStatus;
source: ActivityStepSource;
preview?: ActivityEvidence[];
error?: string;
}
export interface ActivityGroup {
id: string;
title: string;
source: ActivityStepSource;
steps: ActivityStepItem[];
}
export type TurnUnit =
| { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number }
| { type: "message"; message: UIMessage };
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.content.trim().length > 0) return false;
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
}
export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace";
}
export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
const units: TurnUnit[] = [];
let turnMessages: UIMessage[] = [];
const flushTurn = () => {
if (turnMessages.length === 0) return;
const activityMessages: UIMessage[] = [];
const visibleMessages: UIMessage[] = [];
for (const message of turnMessages) {
if (isAgentActivityMember(message)) {
activityMessages.push(message);
continue;
}
if (assistantHasInlineReasoning(message)) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
visibleMessages.push(stripInlineReasoning(message));
continue;
}
visibleMessages.push(message);
}
pushActivityUnits(units, activityMessages, visibleMessages);
for (const message of visibleMessages) {
units.push({ type: "message", message });
}
turnMessages = [];
};
for (const message of messages) {
if (message.role === "user") {
flushTurn();
units.push({ type: "message", message });
continue;
}
turnMessages.push(message);
}
flushTurn();
return units;
}
function pushActivityUnits(units: TurnUnit[], activityMessages: UIMessage[], visibleMessages: UIMessage[]) {
let runMessages: UIMessage[] = [];
let runBucket: "file" | "other" | undefined;
const flushRun = () => {
if (!runMessages.length) return;
units.push({
type: "activity",
messages: runMessages,
items: runMessages.flatMap(activityItemsForMessage),
turnLatencyMs: activityTurnLatencyMs(runMessages, visibleMessages),
});
runMessages = [];
runBucket = undefined;
};
for (const message of activityMessages) {
const bucket = isFileEditActivityMessage(message) ? "file" : "other";
if (runBucket && bucket !== runBucket) {
flushRun();
}
runBucket = bucket;
runMessages.push(message);
}
flushRun();
}
function isFileEditActivityMessage(message: UIMessage): boolean {
return message.kind === "trace" && !!message.fileEdits?.length;
}
function assistantHasInlineReasoning(message: UIMessage): boolean {
return (
message.role === "assistant"
&& message.kind !== "trace"
&& message.content.trim().length > 0
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
);
}
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
return {
id: `${message.id}-reasoning`,
role: "assistant",
content: "",
createdAt: message.createdAt,
reasoning: message.reasoning,
reasoningStreaming: message.reasoningStreaming,
isStreaming: message.reasoningStreaming,
activitySegmentId: message.activitySegmentId,
latencyMs: message.latencyMs,
};
}
function stripInlineReasoning(message: UIMessage): UIMessage {
const next = { ...message };
delete next.reasoning;
delete next.reasoningStreaming;
return next;
}
function activityItemsForMessage(message: UIMessage): ActivityItem[] {
if (isReasoningOnlyAssistant(message)) {
return [{ type: "reasoning", message }];
}
if (message.kind !== "trace") return [];
const items: ActivityItem[] = [];
if (message.fileEdits?.length) {
items.push({ type: "file_edit", message });
}
for (const event of message.toolEvents ?? []) {
const name = String(event.name ?? "").toLowerCase();
if (name === "run_cli_app") {
items.push({ type: "cli", message });
} else if (name === "mcp") {
items.push({ type: "mcp", message });
} else {
items.push({ type: "tool", message });
}
}
if (items.length === 0 && (message.traces?.length || message.content.trim())) {
items.push({ type: "tool", message });
}
if (message.media?.length) {
items.push({ type: "media", message });
}
return items;
}
function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined {
for (let i = activityMessages.length - 1; i >= 0; i -= 1) {
const latency = activityMessages[i].latencyMs;
if (isValidLatency(latency)) return latency;
}
for (let i = visibleMessages.length - 1; i >= 0; i -= 1) {
const latency = visibleMessages[i].latencyMs;
if (isValidLatency(latency)) return latency;
}
return undefined;
}
function isValidLatency(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
export function activityEvidenceFromToolEvent(event: ToolProgressEvent): ActivityEvidence[] {
const source = activitySourceFromToolName(toolEventName(event));
const evidence: ActivityEvidence[] = [];
const extras = [
...unknownList((event as { embeds?: unknown }).embeds),
...unknownList((event as { files?: unknown }).files),
];
extras.forEach((value, index) => {
const attachment = mediaAttachmentFromUnknown(value);
if (!attachment) return;
evidence.push({
id: `${event.call_id || toolEventName(event) || "tool"}:${index}:${attachment.url || attachment.name || attachment.kind}`,
attachment,
caption: attachment.name,
source,
});
});
return evidence;
}
export function activityEvidenceFromMessageMedia(message: UIMessage): ActivityEvidence[] {
return (message.media ?? []).map((attachment, index) => ({
id: `${message.id}:media:${index}:${attachment.url || attachment.name || attachment.kind}`,
attachment,
caption: attachment.name,
source: "media",
}));
}
function unknownList(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function toolEventName(event: ToolProgressEvent): string {
return typeof (event as { function?: { name?: unknown } }).function?.name === "string"
? String((event as { function?: { name?: unknown } }).function?.name)
: typeof event.name === "string"
? event.name
: "";
}
function activitySourceFromToolName(name: string): ActivityStepSource {
const compact = name.toLowerCase();
if (compact.includes("browser") || compact.includes("screenshot")) return "browser";
if (compact.includes("web") || compact.includes("search") || compact.includes("fetch") || compact.includes("read")) return "web";
if (compact.includes("exec") || compact.includes("shell") || compact.includes("cli")) return "shell";
if (compact.startsWith("mcp_") || compact === "mcp") return "mcp";
if (compact.includes("file") || compact.includes("patch")) return "file";
if (compact.includes("image") || compact.includes("video") || compact.includes("media")) return "media";
return "tool";
}
function mediaAttachmentFromUnknown(value: unknown): UIMediaAttachment | null {
if (typeof value === "string") {
const text = value.trim();
if (!text) return null;
return toMediaAttachment({ url: looksLikeUrl(text) ? text : undefined, name: baseName(text) });
}
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const url = stringField(record, ["url", "href", "src", "uri", "signed_url", "thumbnail_url"]);
const path = stringField(record, ["path", "absolute_path", "file", "filename"]);
const name = stringField(record, ["name", "filename", "title", "label"]) ?? baseName(url ?? path ?? "");
const kind = mediaKindFromRecord(record, url, name);
return toMediaAttachment({ url, name, kind });
}
function stringField(record: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function mediaKindFromRecord(record: Record<string, unknown>, url?: string, name?: string): UIMediaAttachment["kind"] | undefined {
const raw = stringField(record, ["kind", "type", "mime", "mime_type", "content_type"])?.toLowerCase() ?? "";
if (raw.includes("image") || raw.includes("screenshot")) return "image";
if (raw.includes("video") || raw.includes("mp4") || raw.includes("quicktime")) return "video";
if (raw.includes("file") || raw.includes("document")) return "file";
return toMediaAttachment({ url, name }).kind;
}
function looksLikeUrl(value: string): boolean {
return /^(https?:|data:|\/api\/|blob:)/i.test(value);
}
function baseName(value: string): string | undefined {
const clean = value.split(/[?#]/, 1)[0] ?? "";
const last = clean.split(/[\\/]/).filter(Boolean).pop();
return last || undefined;
}

View File

@ -6,6 +6,7 @@ import type {
ModelConfigurationCreate, ModelConfigurationCreate,
ModelConfigurationUpdate, ModelConfigurationUpdate,
NetworkSafetySettingsUpdate, NetworkSafetySettingsUpdate,
ProviderModelsPayload,
ProviderSettingsUpdate, ProviderSettingsUpdate,
SettingsPayload, SettingsPayload,
SettingsUpdate, SettingsUpdate,
@ -174,6 +175,19 @@ export async function fetchMcpPresets(
return request<McpPresetsPayload>(`${base}/api/settings/mcp-presets`, token); return request<McpPresetsPayload>(`${base}/api/settings/mcp-presets`, token);
} }
export async function fetchProviderModels(
token: string,
provider: string,
base: string = "",
): Promise<ProviderModelsPayload> {
const query = new URLSearchParams();
query.set("provider", provider);
return request<ProviderModelsPayload>(
`${base}/api/settings/provider-models?${query}`,
token,
);
}
export async function runMcpPresetAction( export async function runMcpPresetAction(
token: string, token: string,
action: "enable" | "remove" | "test", action: "enable" | "remove" | "test",

View File

@ -8,6 +8,7 @@ const IMAGE_EXTENSIONS = new Set([
".webp", ".webp",
".bmp", ".bmp",
".ico", ".ico",
".svg",
".tif", ".tif",
".tiff", ".tiff",
]); ]);
@ -34,26 +35,30 @@ function extensionOf(value?: string): string {
return path.slice(dot); return path.slice(dot);
} }
export function inferMediaKind(media: { url?: string; name?: string }): UIMediaKind { function explicitMediaKind(media: { url?: string; name?: string }): UIMediaKind | null {
const url = media.url ?? ""; const url = media.url ?? "";
if (url.startsWith("data:image/")) return "image"; if (url.startsWith("data:image/")) return "image";
if (url.startsWith("data:video/")) return "video"; if (url.startsWith("data:video/")) return "video";
const ext = extensionOf(media.name) || extensionOf(url); const ext = extensionOf(media.name) || extensionOf(url);
if (!ext) return null;
if (IMAGE_EXTENSIONS.has(ext)) return "image"; if (IMAGE_EXTENSIONS.has(ext)) return "image";
if (VIDEO_EXTENSIONS.has(ext)) return "video"; if (VIDEO_EXTENSIONS.has(ext)) return "video";
return "file"; return "file";
} }
export function inferMediaKind(media: { url?: string; name?: string }): UIMediaKind {
return explicitMediaKind(media) ?? "file";
}
export function toMediaAttachment(media: { export function toMediaAttachment(media: {
url?: string; url?: string;
name?: string; name?: string;
kind?: UIMediaKind; kind?: UIMediaKind;
}): UIMediaAttachment { }): UIMediaAttachment {
return { return {
kind: media.kind ?? inferMediaKind(media), kind: explicitMediaKind(media) ?? media.kind ?? "file",
url: media.url, url: media.url,
name: media.name, name: media.name,
}; };
} }

View File

@ -146,7 +146,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
searxng: brand("searxng.org", "#3050FF", "SX"), searxng: brand("searxng.org", "#3050FF", "SX"),
siliconflow: brand("siliconflow.cn", "#111827", "SF"), siliconflow: brand("siliconflow.cn", "#111827", "SF"),
skywork: brand("skywork.ai", "#5B5BF6", "SW"), skywork: brand("skywork.ai", "#5B5BF6", "SW"),
stepfun: brand("stepfun.com", "#2F6BFF", "SF"), stepfun: brand("stepfun.com", "#2F6BFF", "SF", [
"https://www.stepfun.com/step_favicon.svg",
]),
tavily: brand("tavily.com", "#111827", "T"), tavily: brand("tavily.com", "#111827", "T"),
volcengine: brand("volcengine.com", "#1664FF", "VE"), volcengine: brand("volcengine.com", "#1664FF", "VE"),
vllm: brand("vllm.ai", "#2563EB", "VL"), vllm: brand("vllm.ai", "#2563EB", "VL"),

View File

@ -221,6 +221,29 @@ export interface RuntimeCapabilities {
can_export_diagnostics: boolean; can_export_diagnostics: boolean;
} }
export interface ProviderModelInfo {
id: string;
label?: string | null;
owned_by?: string | null;
context_window?: number | null;
}
export interface ProviderModelsPayload {
provider: string;
label: string;
status:
| "available"
| "unsupported"
| "not_configured"
| "missing_api_base"
| "error";
catalog_kind: "official" | "catalog" | "local" | "custom" | "unsupported";
models: ProviderModelInfo[];
model_count: number;
message?: string | null;
fetched_at?: number;
}
export interface SettingsPayload { export interface SettingsPayload {
surface?: RuntimeSurface; surface?: RuntimeSurface;
runtime_surface?: RuntimeSurface; runtime_surface?: RuntimeSurface;

View File

@ -736,7 +736,7 @@ describe("AgentActivityCluster", () => {
fireEvent.click(screen.getByRole("button", { name: /1 tool calls/i })); fireEvent.click(screen.getByRole("button", { name: /1 tool calls/i }));
expect(screen.getByText("Shell")).toBeInTheDocument(); expect(screen.getByText("Command")).toBeInTheDocument();
expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument(); expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument();
expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument(); expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument();
expect(screen.queryByText(/for id in/)).not.toBeInTheDocument(); expect(screen.queryByText(/for id in/)).not.toBeInTheDocument();
@ -936,4 +936,67 @@ describe("AgentActivityCluster", () => {
restoreMotion(); restoreMotion();
} }
}); });
it("renders tool event embeds as inline activity evidence", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-evidence",
role: "tool",
kind: "trace",
content: 'web_fetch({"url":"https://example.com"})',
traces: ['web_fetch({"url":"https://example.com"})'],
toolEvents: [{
phase: "end",
call_id: "call-fetch",
name: "web_fetch",
arguments: { url: "https://example.com" },
embeds: [{
url: "/api/media/signed/screenshot.png",
name: "Homepage screenshot",
type: "image/png",
}],
}],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByText("Web")).toBeInTheDocument();
expect(screen.getByTestId("activity-evidence-preview")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Homepage screenshot" })).toHaveAttribute(
"src",
"/api/media/signed/screenshot.png",
);
});
it("shows missing evidence as a file-safe placeholder", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-missing-evidence",
role: "tool",
kind: "trace",
content: 'screenshot({"path":"missing.png"})',
traces: ['screenshot({"path":"missing.png"})'],
toolEvents: [{
phase: "end",
call_id: "call-shot",
name: "screenshot",
arguments: { path: "missing.png" },
files: [{ name: "missing.png", type: "image/png" }],
}],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByText("Vision")).toBeInTheDocument();
expect(screen.getByTestId("activity-evidence-preview")).toBeInTheDocument();
expect(screen.getByText("missing.png")).toBeInTheDocument();
});
}); });

View File

@ -5,6 +5,7 @@ import {
deleteSession, deleteSession,
fetchCliApps, fetchCliApps,
fetchMcpPresets, fetchMcpPresets,
fetchProviderModels,
fetchSidebarState, fetchSidebarState,
fetchWebuiThread, fetchWebuiThread,
fetchWorkspaces, fetchWorkspaces,
@ -165,6 +166,17 @@ describe("webui API helpers", () => {
); );
}); });
it("fetches provider model lists", async () => {
await fetchProviderModels("tok", "deepseek");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=deepseek",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes provider OAuth login and logout actions", async () => { it("serializes provider OAuth login and logout actions", async () => {
await loginProviderOAuth("tok", "openai_codex"); await loginProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(

View File

@ -540,6 +540,58 @@ describe("App layout", () => {
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument(); expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
}); });
it("does not show a completed dot later when the active session finishes", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Active work",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Other chat",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(
within(sidebar).getByRole("button", { name: /^Active work$/ }),
).toBeInTheDocument(),
);
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Active work$/ }));
});
await waitFor(() => expect(document.title).toContain("Active work"));
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
});
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", null);
});
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
});
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
});
it("restores sidebar run indicators after a page reload", async () => { it("restores sidebar run indicators after a page reload", async () => {
mockSessions = [ mockSessions = [
{ {
@ -590,7 +642,22 @@ describe("App layout", () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL) => { vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("/api/settings")) { const href = String(input);
if (href === "/api/settings/provider-models?provider=openai") {
return jsonResponse({
provider: "openai",
label: "OpenAI",
status: "available",
catalog_kind: "official",
models: [
{ id: "openai/gpt-4o", owned_by: "openai", context_window: 128000 },
{ id: "openai/gpt-4o-mini", owned_by: "openai", context_window: 128000 },
],
model_count: 2,
fetched_at: 1,
});
}
if (href.includes("/api/settings")) {
return { return {
ok: true, ok: true,
status: 200, status: 200,
@ -822,9 +889,9 @@ describe("App layout", () => {
expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" })); fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
expect(screen.queryByText("AI")).not.toBeInTheDocument(); expect(screen.queryByText("AI")).not.toBeInTheDocument();
expect(screen.getByText("Current model")).toBeInTheDocument(); expect(screen.getByText("Current configuration")).toBeInTheDocument();
expect(screen.queryByText("Presets")).not.toBeInTheDocument(); expect(screen.queryByText("Presets")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: /openai\/gpt-4o/ })); fireEvent.pointerDown(screen.getAllByRole("button", { name: /openai\/gpt-4o/ })[0]);
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" })); fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
const modelDialog = screen.getByRole("dialog", { name: "New model configuration" }); const modelDialog = screen.getByRole("dialog", { name: "New model configuration" });
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument(); expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
@ -837,33 +904,47 @@ describe("App layout", () => {
expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument(); expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument();
expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled(); expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" })); fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" }));
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
expect(modelInput).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ })); fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0); expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ })); fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } }); const openModelPicker = () => {
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
};
openModelPicker();
await screen.findByText("openai/gpt-4o-mini");
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain( expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
"text-blue-600", "text-blue-600",
); );
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o" } }); const updatedModelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o-mini/ });
fireEvent.pointerDown(updatedModelButtons[updatedModelButtons.length - 1]);
await screen.findByText("openai/gpt-4o");
fireEvent.click(screen.getAllByText("openai/gpt-4o")[0]);
expect(screen.getByText("OpenRouter")).toBeInTheDocument(); expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument(); expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument(); expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument(); expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0); expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
fireEvent.click(screen.getByText("OpenAI")); const clickProviderRow = (label: string) => {
const providerLabel = screen
.getAllByText(label)
.find((element) => element.className.includes("font-semibold"));
expect(providerLabel).toBeTruthy();
fireEvent.click(providerLabel!);
};
clickProviderRow("OpenAI");
fireEvent.click(screen.getByRole("button", { name: "Edit" })); fireEvent.click(screen.getByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), { fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
target: { value: "unsaved-openai-key" }, target: { value: "unsaved-openai-key" },
}); });
fireEvent.click(screen.getByText("OpenRouter")); clickProviderRow("OpenRouter");
fireEvent.click(screen.getByText("OpenAI")); clickProviderRow("OpenAI");
expect(screen.getByText("open••••-key")).toBeInTheDocument(); expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument(); expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Ant Ling")); clickProviderRow("Ant Ling");
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument(); expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
fireEvent.click(screen.getByText("Atomic Chat")); clickProviderRow("Atomic Chat");
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument(); expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();

View File

@ -204,7 +204,9 @@ describe("ChatList", () => {
/>, />,
); );
expect(screen.getAllByLabelText("Agent finished")).toHaveLength(1); const finished = screen.getAllByLabelText("Agent finished");
expect(finished).toHaveLength(1);
expect(finished[0].firstElementChild).toHaveClass("h-2", "w-2");
}); });
it("folds long default workspace chats and can show all", () => { it("folds long default workspace chats and can show all", () => {

View File

@ -45,6 +45,7 @@ describe("CodeBlock", () => {
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument(); expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
expect(screen.getByText("const value = 1;")).toBeInTheDocument(); expect(screen.getByText("const value = 1;")).toBeInTheDocument();
expect(screen.getByText("ts")).toBeInTheDocument(); expect(screen.getByText("ts")).toBeInTheDocument();
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
}); });
it("reads theme from context without creating per-block observers", async () => { it("reads theme from context without creating per-block observers", async () => {

View File

@ -4,6 +4,30 @@ import { describe, expect, it } from "vitest";
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer"; import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
describe("MarkdownTextRenderer", () => { describe("MarkdownTextRenderer", () => {
it("does not wrap complete fenced code blocks in an extra pre", () => {
const { container } = render(
<MarkdownTextRenderer highlightCode={false}>
{"当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace\n```"}
</MarkdownTextRenderer>,
);
expect(screen.getByText("/Users/renxubin/.nanobot/workspace")).toBeInTheDocument();
expect(container.querySelectorAll("pre")).toHaveLength(1);
expect(container.querySelector("pre div")).toBeNull();
});
it("keeps streaming unfinished fenced code blocks to a single shell", () => {
const { container } = render(
<MarkdownTextRenderer highlightCode={false}>
{"当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace"}
</MarkdownTextRenderer>,
);
expect(screen.getByText("/Users/renxubin/.nanobot/workspace")).toBeInTheDocument();
expect(container.querySelectorAll("pre")).toHaveLength(1);
expect(container.querySelector("pre div")).toBeNull();
});
it("renders markdown images as inline previews", () => { it("renders markdown images as inline previews", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>); render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
@ -13,7 +37,6 @@ describe("MarkdownTextRenderer", () => {
"href", "href",
"/api/media/sig/payload", "/api/media/sig/payload",
); );
expect(screen.getByText("Diagram")).toBeInTheDocument();
}); });
it("renders markdown videos as inline players", () => { it("renders markdown videos as inline players", () => {
@ -25,4 +48,101 @@ describe("MarkdownTextRenderer", () => {
expect(video).toHaveAttribute("controls"); expect(video).toHaveAttribute("controls");
expect(screen.queryByRole("img", { name: "nanobot-intro.mp4" })).not.toBeInTheDocument(); expect(screen.queryByRole("img", { name: "nanobot-intro.mp4" })).not.toBeInTheDocument();
}); });
it("renders markdown links with file-looking names as file attachments", () => {
render(<MarkdownTextRenderer>![index.html](/api/media/sig/html)</MarkdownTextRenderer>);
expect(screen.getByLabelText("File attachment")).toHaveTextContent("index.html");
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument();
});
it("renders media attachments without an extra preview/code wrapper", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
expect(screen.getByRole("img", { name: "Diagram" })).toHaveAttribute(
"src",
"/api/media/sig/payload",
);
expect(screen.getByRole("link", { name: "Open Diagram" })).toHaveAttribute(
"href",
"/api/media/sig/payload",
);
expect(screen.queryByRole("button", { name: "Code" })).not.toBeInTheDocument();
});
it("renders a safe subset of inline HTML", () => {
const { container } = render(
<MarkdownTextRenderer>
{"<mark>高亮文本</mark>\n\n上标x<sup>2</sup>\n下标H<sub>2</sub>O"}
</MarkdownTextRenderer>,
);
expect(container.querySelector("mark")).toHaveTextContent("高亮文本");
expect(container.querySelector("sup")).toHaveTextContent("2");
expect(container.querySelector("sub")).toHaveTextContent("2");
});
it("keeps unsafe HTML as text", () => {
const { container } = render(
<MarkdownTextRenderer>
{"<script>alert(1)</script>\n\n<mark onclick=\"alert(1)\">bad</mark>"}
</MarkdownTextRenderer>,
);
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("mark")).toBeNull();
expect(container).toHaveTextContent("<script>alert(1)</script>");
expect(container).toHaveTextContent("<mark onclick=\"alert(1)\">bad</mark>");
});
it("renders safe details blocks", () => {
const { container } = render(
<MarkdownTextRenderer>
{
"<details><summary>点击展开更多内容</summary>\n\n这里是被折叠的内容。\n\n- 可以放列表\n\n</details>"
}
</MarkdownTextRenderer>,
);
expect(container.querySelector("details")).toBeInTheDocument();
expect(container.querySelector("summary")).toHaveTextContent("点击展开更多内容");
expect(screen.getByText("这里是被折叠的内容。")).toBeInTheDocument();
expect(screen.getByText("可以放列表")).toBeInTheDocument();
expect(container).not.toHaveTextContent("</details>");
});
it("renders task list checkboxes as quiet status marks", () => {
const { container } = render(
<MarkdownTextRenderer>
{"- [x] 写 Markdown 示例\n- [x] 加点 emoji\n- [ ] 测试渲染效果"}
</MarkdownTextRenderer>,
);
expect(container.querySelectorAll("input[type='checkbox']")).toHaveLength(0);
expect(screen.getAllByTestId("markdown-task-checkbox")).toHaveLength(3);
expect(container.querySelectorAll(".task-list-item")).toHaveLength(3);
});
it("keeps dollar amounts from being parsed as inline math", () => {
const { container } = render(
<MarkdownTextRenderer>
{
"VBeats mentions $24 million, while Globe states a total of $130.6 million since founding."
}
</MarkdownTextRenderer>,
);
expect(container).toHaveTextContent(
"VBeats mentions $24 million, while Globe states a total of $130.6 million since founding.",
);
expect(container.querySelector(".katex")).toBeNull();
});
it("still renders explicit math blocks", () => {
const { container } = render(
<MarkdownTextRenderer>{"$$x^2 + y^2 = z^2$$"}</MarkdownTextRenderer>,
);
expect(container.querySelector(".katex")).toBeInTheDocument();
});
}); });

View File

@ -42,7 +42,7 @@ describe("MarkdownText", () => {
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello"); expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code", "data-highlight-code",
"false", "true",
); );
expect(rendererSpy).toHaveBeenCalledTimes(1); expect(rendererSpy).toHaveBeenCalledTimes(1);
@ -79,4 +79,30 @@ describe("MarkdownText", () => {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
it("keeps very large streaming snippets plain until the final render", async () => {
rendererSpy.mockClear();
const largeCode = `\`\`\`ts\n${"const value = 1;\n".repeat(1_100)}\`\`\``;
const { rerender } = render(
<MarkdownText streaming>{largeCode}</MarkdownText>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"false",
);
rerender(<MarkdownText>{largeCode}</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"true",
);
});
}); });

View File

@ -236,7 +236,10 @@ describe("MessageBubble", () => {
const video = screen.getByLabelText(/video attachment/i); const video = screen.getByLabelText(/video attachment/i);
expect(video.tagName).toBe("VIDEO"); expect(video.tagName).toBe("VIDEO");
expect(video).toHaveAttribute("src", "/api/media/sig/payload"); expect(video).toHaveAttribute("src", "/api/media/sig/payload");
expect(video).toHaveAttribute("preload", "auto");
expect(container.querySelector("video[controls]")).toBeInTheDocument(); expect(container.querySelector("video[controls]")).toBeInTheDocument();
expect(screen.queryByText("Preview")).not.toBeInTheDocument();
expect(screen.queryByText("Code")).not.toBeInTheDocument();
}); });
it("auto-expands the reasoning trace while streaming with a shimmer header", () => { it("auto-expands the reasoning trace while streaming with a shimmer header", () => {
@ -366,4 +369,47 @@ describe("MessageBubble", () => {
expect(imageButton).not.toHaveAttribute("title"); expect(imageButton).not.toHaveAttribute("title");
expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain"); expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain");
}); });
it("renders mislabeled html assistant media as a file attachment", () => {
const message: UIMessage = {
id: "a-html",
role: "assistant",
content: "file ready",
createdAt: Date.now(),
media: [
{
kind: "image",
url: "/api/media/sig/html",
name: "index.html",
},
],
};
const { container } = render(<MessageBubble message={message} />);
expect(screen.getByLabelText("File attachment")).toHaveTextContent("index.html");
expect(container.querySelector("img")).not.toBeInTheDocument();
});
it("renders assistant svg media as an image preview", () => {
const message: UIMessage = {
id: "a-svg",
role: "assistant",
content: "chart ready",
createdAt: Date.now(),
media: [
{
kind: "file",
url: "/api/media/sig/svg",
name: "growth.svg",
},
],
};
const { container } = render(<MessageBubble message={message} />);
expect(screen.getByRole("button", { name: /view image: growth.svg/i })).toBeInTheDocument();
expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
});
}); });

View File

@ -35,8 +35,9 @@ describe("provider brand logos", () => {
expect(providerBrand("zhipu")?.initials).toBe("Z"); expect(providerBrand("zhipu")?.initials).toBe("Z");
}); });
it("uses official first-party assets for LongCat and Xiaomi MIMO", () => { it("uses official first-party assets for LongCat, Step Fun, and Xiaomi MIMO", () => {
expect(providerBrand("longcat")?.logoUrls[0]).toBe("https://www.longcatai.org/favicon.svg"); expect(providerBrand("longcat")?.logoUrls[0]).toBe("https://www.longcatai.org/favicon.svg");
expect(providerBrand("stepfun")?.logoUrls[0]).toBe("https://www.stepfun.com/step_favicon.svg");
expect(providerBrand("xiaomi_mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg"); expect(providerBrand("xiaomi_mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
expect(providerBrand("mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg"); expect(providerBrand("mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
}); });

View File

@ -245,6 +245,104 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
}); });
it("loads provider models and lets users choose one without typing the id manually", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "deepseek-chat",
provider: "deepseek",
resolved_provider: "deepseek",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "deepseek-chat",
provider: "deepseek",
},
],
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
auth_type: "api_key",
api_key_required: true,
api_key_hint: "sk-...",
api_base: "https://api.deepseek.com",
default_api_base: "https://api.deepseek.com",
},
],
};
const updatedPayload: SettingsPayload = {
...payload,
agent: {
...payload.agent,
model: "deepseek-reasoner",
},
model_presets: [
{
...payload.model_presets[0],
model: "deepseek-reasoner",
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/provider-models?provider=deepseek") {
return jsonResponse({
provider: "deepseek",
label: "DeepSeek",
status: "available",
catalog_kind: "official",
models: [
{ id: "deepseek-chat", owned_by: "deepseek", context_window: 65536 },
{ id: "deepseek-reasoner", owned_by: "deepseek", context_window: 65536 },
],
model_count: 2,
fetched_at: 1,
});
}
if (url === "/api/settings/update?model_preset=default&model=deepseek-reasoner") {
return jsonResponse(updatedPayload);
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
await screen.findByText("deepseek-reasoner");
fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=deepseek",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/update?model_preset=default&model=deepseek-reasoner",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
});
it("saves network safety without exposing technical SSRF copy", async () => { it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload(); const payload = settingsPayload();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => {

View File

@ -4,6 +4,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
vi.mock("@/lib/imageEncode", () => ({
encodeImage: vi.fn(async (file: File) => ({
ok: true,
dataUrl: `data:${file.type || "image/png"};base64,aW1hZ2U=`,
bytes: Math.max(1, file.size),
normalized: false,
})),
}));
const COMMANDS: SlashCommand[] = [ const COMMANDS: SlashCommand[] = [
{ {
command: "/stop", command: "/stop",
@ -113,6 +122,17 @@ const MCP_PRESETS: McpPresetInfo[] = [
]; ];
const ORIGINAL_INNER_HEIGHT = window.innerHeight; const ORIGINAL_INNER_HEIGHT = window.innerHeight;
function mockBlobUrls() {
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:composer-test"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
}
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
Reflect.deleteProperty(window, "nanobotHost"); Reflect.deleteProperty(window, "nanobotHost");
@ -160,6 +180,9 @@ describe("ThreadComposer", () => {
const input = screen.getByPlaceholderText("Ask anything..."); const input = screen.getByPlaceholderText("Ask anything...");
expect(input).toBeInTheDocument(); expect(input).toBeInTheDocument();
expect(input.className).toContain("min-h-[78px]"); expect(input.className).toContain("min-h-[78px]");
expect(input.className).toContain("pt-[27px]");
fireEvent.change(input, { target: { value: "1" } });
expect(input.className).toContain("pt-[27px]");
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]"); expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
}); });
@ -361,6 +384,8 @@ describe("ThreadComposer", () => {
const status = screen.getByRole("status"); const status = screen.getByRole("status");
expect(status).toHaveTextContent(/Running/); expect(status).toHaveTextContent(/Running/);
expect(status).toHaveTextContent(/2:05/); expect(status).toHaveTextContent(/2:05/);
expect(status.parentElement).toHaveClass("composer-status-strip");
expect(status.parentElement).toHaveAttribute("data-state", "enter");
vi.useRealTimers(); vi.useRealTimers();
}); });
@ -802,7 +827,7 @@ describe("ThreadComposer", () => {
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
}); });
it("sends image generation mode with automatic aspect ratio", () => { it("keeps image generation mode out of the composer chrome", () => {
const onSend = vi.fn(); const onSend = vi.fn();
render( render(
<ThreadComposer <ThreadComposer
@ -811,18 +836,14 @@ describe("ThreadComposer", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" })); expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("Describe or edit an image…")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Image aspect ratio" })).not.toBeInTheDocument();
const input = screen.getByLabelText("Message input"); const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "Draw a friendly robot" } }); fireEvent.change(input, { target: { value: "Draw a friendly robot" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" })); fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith( expect(onSend).toHaveBeenCalledWith("Draw a friendly robot", undefined, undefined);
"Draw a friendly robot",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: null } },
);
}); });
it("shows a stop button while streaming", () => { it("shows a stop button while streaming", () => {
@ -842,75 +863,407 @@ describe("ThreadComposer", () => {
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
}); });
it("lets users select a concrete image aspect ratio", () => { it("queues plain guidance while a task is running", () => {
const onSend = vi.fn(); const onSend = vi.fn();
render( render(
<ThreadComposer <ThreadComposer
onSend={onSend} onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..." placeholder="Type your message..."
/>, />,
); );
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
"bottom-full",
);
fireEvent.mouseDown(screen.getByRole("option", { name: "Wide 16:9" }));
const input = screen.getByLabelText("Message input"); const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "Draw a banner" } }); fireEvent.change(input, { target: { value: "keep the UI minimal" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" })); fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).toHaveBeenCalledWith( expect(onSend).not.toHaveBeenCalled();
"Draw a banner", expect(input).toHaveValue("");
undefined, expect(screen.getByText("keep the UI minimal")).toBeInTheDocument();
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
); fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("keep the UI minimal");
expect(screen.queryByText("keep the UI minimal")).not.toBeInTheDocument();
}); });
it("opens the hero image aspect menu downward", () => { it("keeps queued guidance attached to the composer and sends it one item at a time", async () => {
render( const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer <ThreadComposer
onSend={vi.fn()} onSend={onSend}
placeholder="Ask anything..." onStop={vi.fn()}
variant="hero" isStreaming
imageMode placeholder="Type your message..."
/>, />,
); );
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" })); const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "first follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.change(input, { target: { value: "second follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain( const queue = screen.getByRole("group", { name: "Queued guidance" });
"top-full", expect(queue).toHaveClass("composer-status-strip");
expect(queue).toHaveClass("mx-3");
expect(queue.parentElement?.className).toContain("group/composer");
expect(within(queue).getByText("first follow-up")).toBeInTheDocument();
expect(within(queue).getByText("second follow-up")).toBeInTheDocument();
expect(within(queue).getAllByRole("button", { name: "Edit guidance" })).toHaveLength(2);
expect(within(queue).getAllByRole("button", { name: "Guide" })).toHaveLength(2);
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
); );
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("first follow-up");
});
expect(onSend).toHaveBeenCalledTimes(1);
expect(screen.queryByText("first follow-up")).not.toBeInTheDocument();
expect(screen.getByText("second follow-up")).toBeInTheDocument();
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenLastCalledWith("second follow-up");
});
expect(onSend).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
}); });
it("dismisses the image aspect menu on outside click, escape, and wheel", () => { it("lets users edit queued guidance before it is sent", async () => {
const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "rough follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
const editButton = screen.getByRole("button", { name: "Edit guidance" });
fireEvent.click(editButton);
await waitFor(() => {
expect(input).toHaveFocus();
});
expect(input).toHaveValue("rough follow-up");
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: "polished follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("polished follow-up");
});
});
it("requeues edited guidance at the end of the pending list", async () => {
const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "first follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.change(input, { target: { value: "second follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.click(screen.getAllByRole("button", { name: "Edit guidance" })[0]);
await waitFor(() => {
expect(input).toHaveValue("first follow-up");
});
fireEvent.change(input, { target: { value: "first follow-up edited" } });
fireEvent.keyDown(input, { key: "Enter" });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("second follow-up");
});
expect(onSend).toHaveBeenCalledTimes(1);
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenLastCalledWith("first follow-up edited");
});
expect(onSend).toHaveBeenCalledTimes(2);
});
it("queues image guidance while running and restores it for editing", async () => {
mockBlobUrls();
const onSend = vi.fn();
const { container, rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]');
expect(fileInput).toBeTruthy();
const file = new File(["image"], "draft.png", { type: "image/png" });
fireEvent.change(fileInput!, { target: { files: [file] } });
await screen.findByText("draft.png");
fireEvent.change(input, { target: { value: "look at this" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).not.toHaveBeenCalled();
expect(screen.getByRole("group", { name: "Queued guidance" })).toBeInTheDocument();
expect(screen.getByText("look at this")).toBeInTheDocument();
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Edit guidance" }));
expect(input).toHaveValue("look at this");
expect(screen.getByTestId("composer-chip")).toHaveTextContent("draft.png");
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith(
"look at this",
[expect.objectContaining({
media: expect.objectContaining({
data_url: "data:image/png;base64,aW1hZ2U=",
name: "draft.png",
}),
})],
);
});
});
it("reorders queued guidance while dragging over another row", async () => {
const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "first follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.change(input, { target: { value: "second follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
const handles = screen.getAllByLabelText("Drag to reorder");
const secondRow = screen
.getByText("second follow-up")
.closest("[data-queued-prompt-row='true']");
expect(secondRow).toBeTruthy();
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
getData: vi.fn(),
};
fireEvent.dragStart(handles[0], { dataTransfer });
fireEvent.dragEnter(secondRow!, { dataTransfer });
fireEvent.dragEnd(handles[0], { dataTransfer });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("second follow-up");
});
});
it("moves later queued guidance before an earlier item while dragging", async () => {
const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "first follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.change(input, { target: { value: "second follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
const handles = screen.getAllByLabelText("Drag to reorder");
const firstRow = screen
.getByText("first follow-up")
.closest("[data-queued-prompt-row='true']");
expect(firstRow).toBeTruthy();
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
getData: vi.fn(),
};
fireEvent.dragStart(handles[1], { dataTransfer });
fireEvent.dragEnter(firstRow!, { dataTransfer });
fireEvent.dragEnd(handles[1], { dataTransfer });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("second follow-up");
});
});
it("persists queued guidance per chat across remounts", async () => {
const onSend = vi.fn();
const { rerender, unmount } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="chat-a"
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "remember this follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "Edit guidance" }));
fireEvent.change(input, { target: { value: "remember this edited follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("remember this edited follow-up")).toBeInTheDocument();
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="chat-b"
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument();
});
unmount();
const remount = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="chat-a"
placeholder="Type your message..."
/>,
);
expect(await screen.findByText("remember this edited follow-up")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("remember this edited follow-up");
remount.unmount();
render( render(
<div> <ThreadComposer
<button type="button">outside</button> onSend={onSend}
<ThreadComposer onStop={vi.fn()}
onSend={vi.fn()} isStreaming
placeholder="Type your message..." pendingQueueKey="chat-a"
imageMode placeholder="Type your message..."
/> />,
</div>,
); );
await waitFor(() => {
const aspectButton = screen.getByRole("button", { name: "Image aspect ratio" }); expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument();
fireEvent.click(aspectButton); });
expect(screen.getByRole("listbox", { name: "Image aspect ratio" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "outside" }));
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
fireEvent.click(aspectButton);
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
fireEvent.click(aspectButton);
fireEvent.wheel(screen.getByRole("listbox", { name: "Image aspect ratio" }), { deltaY: 120 });
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
}); });
}); });

View File

@ -9,7 +9,7 @@ import {
import type { UIMessage } from "@/lib/types"; import type { UIMessage } from "@/lib/types";
describe("ThreadMessages", () => { describe("ThreadMessages", () => {
it("groups consecutive reasoning and tool rows into one cluster before the answer", () => { it("groups consecutive reasoning and tool rows into one timeline before the answer", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@ -55,7 +55,7 @@ describe("ThreadMessages", () => {
expect(rows[1]).toHaveClass("mt-4"); expect(rows[1]).toHaveClass("mt-4");
}); });
it("starts a new activity cluster when the activity segment changes", () => { it("keeps file edits as their own activity row inside a turn", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@ -95,14 +95,11 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2); expect(units).toHaveLength(3);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]);
"r1", expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
"t1", expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
]); expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([
"r2",
]);
}); });
it("does not split ordinary tool activity just because segment ids changed", () => { it("does not split ordinary tool activity just because segment ids changed", () => {
@ -146,7 +143,7 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(1); expect(units).toHaveLength(1);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1", "r1",
"t1", "t1",
"r2", "r2",
@ -154,7 +151,7 @@ describe("ThreadMessages", () => {
]); ]);
}); });
it("only marks the current activity cluster as live while streaming", () => { it("only marks the current activity timeline as live while streaming", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@ -197,12 +194,10 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />); render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument(); expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument();
}); });
it("folds final answer reasoning into the preceding activity cluster", () => { it("folds final answer reasoning into the preceding activity timeline", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@ -234,21 +229,21 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2); expect(units).toHaveLength(2);
expect(units[0]).toMatchObject({ type: "cluster" }); expect(units[0]).toMatchObject({ type: "activity" });
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1", "r1",
"t1", "t1",
"a1-reasoning", "a1-reasoning",
]); ]);
expect(units[0].type === "cluster" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200); expect(units[0].type === "activity" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "single", type: "message",
message: { message: {
id: "a1", id: "a1",
content: "final answer", content: "final answer",
}, },
}); });
if (units[1].type === "single") { if (units[1].type === "message") {
expect(units[1].message).not.toHaveProperty("reasoning"); expect(units[1].message).not.toHaveProperty("reasoning");
} }
@ -290,12 +285,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2); expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0", "t0",
"t1", "t1",
]); ]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "single", type: "message",
message: { message: {
id: "a1", id: "a1",
content: "partial answer", content: "partial answer",
@ -340,12 +335,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2); expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1", "r1",
"t1", "t1",
]); ]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "single", type: "message",
message: { message: {
id: "a1", id: "a1",
content: "Hong Kong is hot today.", content: "Hong Kong is hot today.",
@ -391,12 +386,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2); expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"prelude", "prelude",
"tool", "tool",
]); ]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "single", type: "message",
message: { message: {
id: "final", id: "final",
content: "Done. Open index.html to play.", content: "Done. Open index.html to play.",
@ -404,7 +399,7 @@ describe("ThreadMessages", () => {
}); });
}); });
it("passes assistant turn latency to the preceding completed activity cluster", () => { it("passes assistant turn latency to the preceding completed activity timeline", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@ -496,7 +491,7 @@ describe("ThreadMessages", () => {
const flags = assistantCopyFlags(units); const flags = assistantCopyFlags(units);
const assistantFlags = units const assistantFlags = units
.map((unit, index) => .map((unit, index) =>
unit.type === "single" && unit.message.role === "assistant" unit.type === "message" && unit.message.role === "assistant"
? [unit.message.id, flags[index]] ? [unit.message.id, flags[index]]
: null, : null,
) )

View File

@ -270,7 +270,7 @@ describe("ThreadShell", () => {
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument(); expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
}); });
it("only shows image generation controls when the setting is enabled", async () => { it("keeps image generation controls out of the composer", async () => {
const client = makeClient(); const client = makeClient();
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek"); const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
const enabledSettings: SettingsPayload = { const enabledSettings: SettingsPayload = {
@ -313,7 +313,7 @@ describe("ThreadShell", () => {
); );
}); });
expect(screen.getByRole("button", { name: "Toggle image generation mode" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument();
}); });
it("restores in-memory messages when switching away and back to a session", async () => { it("restores in-memory messages when switching away and back to a session", async () => {
@ -1092,8 +1092,6 @@ describe("ThreadShell", () => {
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument(); expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
expect(screen.queryByText("Write code")).not.toBeInTheDocument(); expect(screen.queryByText("Write code")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument(); expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
expect(screen.queryByText("Write code")).not.toBeInTheDocument(); expect(screen.queryByText("Write code")).not.toBeInTheDocument();
}); });

View File

@ -1240,6 +1240,66 @@ describe("useNanobotStream", () => {
]); ]);
}); });
it("keeps assistant html media as a file attachment", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-html-media", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-html-media", {
event: "message",
chat_id: "chat-html-media",
text: "file ready",
media_urls: [{ url: "/api/media/sig/html", name: "index.html" }],
});
});
expect(result.current.messages[0].media).toEqual([
{ kind: "file", url: "/api/media/sig/html", name: "index.html" },
]);
});
it("infers assistant svg media as an image attachment", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-svg-media", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-svg-media", {
event: "message",
chat_id: "chat-svg-media",
text: "chart ready",
media_urls: [{ url: "/api/media/sig/svg", name: "growth.svg" }],
});
});
expect(result.current.messages[0].media).toEqual([
{ kind: "image", url: "/api/media/sig/svg", name: "growth.svg" },
]);
});
it("corrects explicit image media when the name is a non-image file", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-mislabelled-html", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-mislabelled-html", {
event: "message",
chat_id: "chat-mislabelled-html",
text: "file ready",
media_urls: [{ kind: "image", url: "/api/media/sig/html", name: "index.html" }],
});
});
expect(result.current.messages[0].media).toEqual([
{ kind: "file", url: "/api/media/sig/html", name: "index.html" },
]);
});
it("suppresses redundant stream confirmation after assistant media", () => { it("suppresses redundant stream confirmation after assistant media", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), {