From 3dcf511c84a146feb3c713f42e7e2279e5d9d063 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sat, 30 May 2026 23:45:26 +0800 Subject: [PATCH] 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 --- nanobot/channels/websocket.py | 302 +------ nanobot/providers/anthropic_provider.py | 10 +- nanobot/session/manager.py | 22 +- nanobot/session/webui_turns.py | 14 +- nanobot/webui/media_api.py | 9 + nanobot/webui/settings_api.py | 248 ++++++ nanobot/webui/settings_routes.py | 329 ++++++++ nanobot/webui/transcript.py | 8 +- tests/agent/test_loop_save_turn.py | 6 + tests/agent/test_session_manager_history.py | 26 + tests/channels/test_websocket_http_routes.py | 6 +- tests/channels/test_websocket_media_route.py | 29 + tests/cli/test_commands.py | 22 + .../test_provider_sdk_retry_defaults.py | 12 + tests/utils/test_webui_transcript.py | 36 + tests/webui/test_settings_api.py | 97 +++ webui/src/App.tsx | 17 +- webui/src/components/AttachmentTile.tsx | 173 ++++ webui/src/components/ChatList.tsx | 2 +- webui/src/components/CodeBlock.tsx | 5 +- webui/src/components/MarkdownText.tsx | 5 +- webui/src/components/MarkdownTextRenderer.tsx | 305 +++++-- webui/src/components/MessageBubble.tsx | 74 +- .../src/components/settings/SettingsView.tsx | 290 ++++++- .../thread/AgentActivityCluster.tsx | 616 +++++--------- .../src/components/thread/ThreadComposer.tsx | 778 +++++++++++++----- .../src/components/thread/ThreadMessages.tsx | 245 +----- webui/src/components/thread/ThreadShell.tsx | 10 +- .../activity/ActivityEvidencePreview.tsx | 35 + .../thread/activity/ActivityGroup.tsx | 28 + .../thread/activity/ActivityStep.tsx | 95 +++ .../components/thread/activity/DiffPair.tsx | 114 +++ .../thread/activity/FileEditRow.tsx | 114 +++ .../thread/activity/ReasoningRow.tsx | 96 +++ webui/src/components/ui/dropdown-menu.tsx | 2 +- webui/src/globals.css | 72 ++ webui/src/hooks/useAttachedImages.ts | 60 +- webui/src/i18n/locales/en/common.json | 25 +- webui/src/i18n/locales/es/common.json | 25 +- webui/src/i18n/locales/fr/common.json | 25 +- webui/src/i18n/locales/id/common.json | 25 +- webui/src/i18n/locales/ja/common.json | 25 +- webui/src/i18n/locales/ko/common.json | 25 +- webui/src/i18n/locales/vi/common.json | 25 +- webui/src/i18n/locales/zh-CN/common.json | 25 +- webui/src/i18n/locales/zh-TW/common.json | 25 +- webui/src/lib/activity-timeline.ts | 297 +++++++ webui/src/lib/api.ts | 14 + webui/src/lib/media.ts | 11 +- webui/src/lib/provider-brand.ts | 4 +- webui/src/lib/types.ts | 23 + .../src/tests/agent-activity-cluster.test.tsx | 65 +- webui/src/tests/api.test.ts | 12 + webui/src/tests/app-layout.test.tsx | 105 ++- webui/src/tests/chat-list.test.tsx | 4 +- webui/src/tests/code-block.test.tsx | 1 + .../src/tests/markdown-text-renderer.test.tsx | 122 ++- webui/src/tests/markdown-text.test.tsx | 28 +- webui/src/tests/message-bubble.test.tsx | 46 ++ webui/src/tests/provider-brand.test.ts | 3 +- webui/src/tests/settings-view.test.tsx | 98 +++ webui/src/tests/thread-composer.test.tsx | 465 +++++++++-- webui/src/tests/thread-messages.test.tsx | 53 +- webui/src/tests/thread-shell.test.tsx | 6 +- webui/src/tests/useNanobotStream.test.tsx | 60 ++ 65 files changed, 4526 insertions(+), 1428 deletions(-) create mode 100644 nanobot/webui/settings_routes.py create mode 100644 webui/src/components/AttachmentTile.tsx create mode 100644 webui/src/components/thread/activity/ActivityEvidencePreview.tsx create mode 100644 webui/src/components/thread/activity/ActivityGroup.tsx create mode 100644 webui/src/components/thread/activity/ActivityStep.tsx create mode 100644 webui/src/components/thread/activity/DiffPair.tsx create mode 100644 webui/src/components/thread/activity/FileEditRow.tsx create mode 100644 webui/src/components/thread/activity/ReasoningRow.tsx create mode 100644 webui/src/lib/activity-timeline.ts diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 478d20b3f..b392a071e 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -27,7 +27,6 @@ from websockets.exceptions import ConnectionClosed from websockets.http11 import Request as WsRequest from websockets.http11 import Response -from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.security.workspace_access import ( WORKSPACE_SCOPE_METADATA_KEY, WorkspaceScopeError, @@ -45,35 +44,15 @@ from nanobot.utils.media_decode import ( save_base64_data_url, ) from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel -from nanobot.webui.settings_api import ( - WebUISettingsError, - 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.settings_api import runtime_capabilities +from nanobot.webui.cli_apps_api import normalize_cli_app_mentions from nanobot.webui.media_api import ( serve_signed_media, sign_media_path, sign_or_stage_media_path, ) -from nanobot.webui.mcp_presets_api import ( - mcp_presets_settings_action, - normalize_mcp_preset_mentions, -) +from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions +from nanobot.webui.settings_routes import WebUISettingsRouter from nanobot.webui.sidebar_state import ( read_webui_sidebar_state, write_webui_sidebar_state, @@ -88,18 +67,6 @@ from nanobot.webui.workspaces import ( 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: 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] -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: """Return the first value for *key*, or None.""" values = query.get(key) @@ -586,7 +525,16 @@ class WebSocketChannel(BaseChannel): self._runtime_surface, 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]] = {} # 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 @@ -808,59 +756,7 @@ class WebSocketChannel(BaseChannel): request: WsRequest, got: str, ) -> Response | None: - if got == "/api/settings": - 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 + return await self._settings_routes.dispatch(request, got) def _dispatch_session_api_route( self, @@ -1019,38 +915,6 @@ class WebSocketChannel(BaseChannel): 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: if not self._check_api_token(request): return _http_error(401, "Unauthorized") @@ -1083,142 +947,6 @@ class WebSocketChannel(BaseChannel): return _http_error(500, "failed to write sidebar 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 ---------------------- @staticmethod diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 504c35ad0..8a59d5c42 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -45,13 +45,21 @@ class AnthropicProvider(LLMProvider): if api_key: client_kw["api_key"] = api_key if api_base: - client_kw["base_url"] = api_base + client_kw["base_url"] = self._normalize_base_url(api_base) if extra_headers: client_kw["default_headers"] = extra_headers # Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification. client_kw["max_retries"] = 0 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 def _handle_error(cls, e: Exception) -> LLMResponse: response = getattr(e, "response", None) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 2b41537a9..efd83ef8b 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -19,6 +19,7 @@ from nanobot.utils.helpers import ( find_legal_message_start, image_placeholder_text, safe_filename, + strip_think, ) 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) +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 class Session: """A conversation session.""" @@ -642,7 +654,7 @@ class SessionManager: if data.get("_type") == "metadata": key = data.get("key") or path.stem.replace("_", ":", 1) metadata = data.get("metadata", {}) - title = metadata.get("title") if isinstance(metadata, dict) else None + title = _metadata_title(metadata) preview = "" fallback_preview = "" scanned_records = 0 @@ -673,7 +685,7 @@ class SessionManager: "key": key, "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), - "title": title if isinstance(title, str) else "", + "title": title, "preview": preview, "path": str(path) }) @@ -684,11 +696,7 @@ class SessionManager: "key": repaired.key, "created_at": repaired.created_at.isoformat(), "updated_at": repaired.updated_at.isoformat(), - "title": ( - repaired.metadata.get("title") - if isinstance(repaired.metadata.get("title"), str) - else "" - ), + "title": _metadata_title(repaired.metadata), "preview": next( ( text diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index de1083b0e..864ef6a35 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -19,7 +19,7 @@ from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMProvider from nanobot.session.goal_state import goal_state_ws_blob 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 WEBUI_SESSION_METADATA_KEY = "webui" @@ -48,6 +48,7 @@ def clean_generated_title(raw: str | None) -> str: return "" text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE) text = text.strip().strip("\"'`“”‘’") + text = strip_think(text) text = re.sub(r"\s+", " ", text).strip() text = text.rstrip("。.!!??,,;;:") if len(text) > TITLE_MAX_CHARS: @@ -65,6 +66,9 @@ def _title_inputs(session: Session) -> tuple[str, str]: content = message.get("content") if not isinstance(content, str) or not content.strip(): continue + content = strip_think(content) + if not content: + continue if role == "user" and not user_text: user_text = content.strip() elif role == "assistant" and not assistant_text: @@ -89,7 +93,13 @@ async def maybe_generate_webui_title( return False current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY) 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) if not user_text: diff --git a/nanobot/webui/media_api.py b/nanobot/webui/media_api.py index 451252116..a4f6a7770 100644 --- a/nanobot/webui/media_api.py +++ b/nanobot/webui/media_api.py @@ -50,10 +50,17 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({ "image/jpeg", "image/webp", "image/gif", + "image/svg+xml", "video/mp4", "video/webm", "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*)$") @@ -203,6 +210,8 @@ def serve_signed_media( ("Cache-Control", "private, max-age=31536000, immutable"), ("X-Content-Type-Options", "nosniff"), ] + if mime == "image/svg+xml": + common_headers.extend(_SVG_MEDIA_HEADERS) try: size = candidate.stat().st_size except OSError: diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 255dccc5e..8a9df6624 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -6,12 +6,15 @@ settings payload shape and the allowlisted config mutations exposed to WebUI. from __future__ import annotations +import os import re import time from contextlib import suppress from typing import Any, Literal from zoneinfo import ZoneInfo +import httpx + from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.schema import ModelPresetConfig from nanobot.providers.image_generation import ( @@ -87,6 +90,47 @@ _IMAGE_GENERATION_ASPECT_RATIOS = { } _CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144} _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): @@ -180,6 +224,25 @@ def _mask_secret_hint(secret: str | None) -> str | None: 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: if spec.backend == "azure_openai": 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: normalized = value.strip().lower() if normalized not in {"1", "0", "true", "false", "yes", "no"}: diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py new file mode 100644 index 000000000..9e0caab57 --- /dev/null +++ b/nanobot/webui/settings_routes.py @@ -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")) diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 059c5f6f2..dc888277d 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -27,6 +27,7 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({ ".jpeg", ".webp", ".gif", + ".svg", }) _INLINE_MARKDOWN_VIDEO_EXTS: frozenset[str] = frozenset({ ".mp4", @@ -87,7 +88,12 @@ def rewrite_local_markdown_images( 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: diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index cc8bba804..290cda0a6 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -17,6 +17,7 @@ from nanobot.session.webui_turns import ( WEBUI_SESSION_METADATA_KEY, WEBUI_TITLE_METADATA_KEY, WebuiTurnCoordinator, + clean_generated_title, maybe_generate_webui_title, ) 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" +def test_clean_generated_title_strips_reasoning_tags() -> None: + assert clean_generated_title("reasoning WebUI polish") == "WebUI polish" + assert clean_generated_title("Title: The user said hello") == "" + + @pytest.mark.asyncio async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 8ea2f24a4..ac4609120 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -43,6 +43,32 @@ def test_list_sessions_includes_metadata_title(tmp_path): 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"] = " 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"] = " literally discussed" + session.metadata["title_user_edited"] = True + manager.save(session) + + rows = manager.list_sessions() + + assert rows[0]["title"] == " literally discussed" + + def test_list_sessions_includes_user_preview(tmp_path): manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:chat-preview") diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index ffb6d2c01..980004953 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -148,7 +148,7 @@ async def test_cli_apps_routes_require_token_and_return_payload( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( - "nanobot.channels.websocket.cli_apps_payload", + "nanobot.webui.settings_routes.cli_apps_payload", lambda: { "apps": [ { @@ -173,7 +173,7 @@ async def test_cli_apps_routes_require_token_and_return_payload( }, ) monkeypatch.setattr( - "nanobot.channels.websocket.cli_apps_action", + "nanobot.webui.settings_routes.cli_apps_action", lambda action, query: { "apps": [], "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} monkeypatch.setattr( - "nanobot.channels.websocket.request_mcp_reload", + "nanobot.webui.settings_routes.request_mcp_reload", _hot_reload, ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913) diff --git a/tests/channels/test_websocket_media_route.py b/tests/channels/test_websocket_media_route.py index 4c3f9161d..f70826f8e 100644 --- a/tests/channels/test_websocket_media_route.py +++ b/tests/channels/test_websocket_media_route.py @@ -453,6 +453,35 @@ async def test_media_route_degrades_non_image_to_octet_stream( 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("") + + 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//messages: media_urls hydration on session read # --------------------------------------------------------------------------- diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 5060a3a76..4a384ab1d 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -469,6 +469,28 @@ def test_config_auto_detects_xiaomi_mimo_from_model_keyword(): 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(): config = Config.model_validate( { diff --git a/tests/providers/test_provider_sdk_retry_defaults.py b/tests/providers/test_provider_sdk_retry_defaults.py index cbd8ba837..bf4f10bda 100644 --- a/tests/providers/test_provider_sdk_retry_defaults.py +++ b/tests/providers/test_provider_sdk_retry_defaults.py @@ -22,6 +22,18 @@ def test_anthropic_disables_sdk_retries_by_default() -> None: 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: with patch("nanobot.providers.azure_openai_provider.AsyncOpenAI") as mock_client: AzureOpenAIProvider( diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 22ce9893d..2381c6c3a 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -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: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) key = "websocket:t-file" diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 6cd8da493..470b23bba 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import httpx import pytest from nanobot.config.loader import load_config, save_config @@ -10,6 +11,7 @@ from nanobot.webui.settings_api import ( WebUISettingsError, _oauth_provider_status, create_model_configuration, + provider_models_payload, settings_payload, update_agent_settings, update_model_configuration, @@ -336,6 +338,101 @@ def test_openai_codex_oauth_status_rejects_unavailable_token( 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( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 9ae092053..1dec4a6f9 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -450,6 +450,7 @@ function Shell({ const [workspaceOverrides, setWorkspaceOverrides] = useState>({}); const runningChatIdsRef = useRef>(new Set()); + const activeChatIdRef = useRef(null); useEffect(() => { let cancelled = false; @@ -487,6 +488,16 @@ function Shell({ const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]); 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(() => { if (activeChatId && workspaceOverrides[activeChatId]) { return workspaceOverrides[activeChatId]; @@ -929,7 +940,11 @@ function Shell({ setRunningChatIds(nextRunning); setCompletedChatIds((current) => { const next = new Set(current); - next.add(chatId); + if (activeChatIdRef.current === chatId) { + next.delete(chatId); + } else { + next.add(chatId); + } return next; }); }); diff --git a/webui/src/components/AttachmentTile.tsx b/webui/src/components/AttachmentTile.tsx new file mode 100644 index 000000000..6eed1c266 --- /dev/null +++ b/webui/src/components/AttachmentTile.tsx @@ -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 ( + + + {attachment.name setFailed(true)} + className={cn( + "block h-auto max-w-full bg-background object-contain", + variant === "compact" ? "max-h-40" : "max-h-[34rem]", + )} + /> + + + ); + } + + if (attachment.kind === "video" && hasUrl) { + return ( + + + ); + } + + const Icon = attachment.kind === "video" + ? PlaySquare + : attachment.kind === "image" + ? ImageIcon + : FileIcon; + const body = ( + <> + + {attachment.name ?? label} + + ); + + if (hasUrl && !failed) { + return ( + + {body} + + ); + } + + return ( +
+ {body} + + {t("message.attachmentUnavailable", { defaultValue: "Attachment unavailable" })} + +
+ ); +} + +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 ? ( + {children} + ) : ( +
{children}
+ ); + return inline ? ( + + {body} + + ) : ( +
+ {body} +
+ ); +} + +function attachmentLabel(attachment: UIMediaAttachment, t: ReturnType["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" }); +} diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index e5a6260f5..cea2ae5f4 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -540,7 +540,7 @@ function SessionActivityIndicator({ title={label} className="grid h-4 w-4 shrink-0 place-items-center" > - + ); } diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx index 4e3b8b736..fc2e97bc6 100644 --- a/webui/src/components/CodeBlock.tsx +++ b/webui/src/components/CodeBlock.tsx @@ -54,9 +54,10 @@ const LazyHighlightedCode = lazy(async () => { function PlainCodeFallback({ code }: { code: string }) { return (
-      {code}
+      {code}
     
); } diff --git a/webui/src/components/MarkdownText.tsx b/webui/src/components/MarkdownText.tsx index 076ad55d0..bad9801ca 100644 --- a/webui/src/components/MarkdownText.tsx +++ b/webui/src/components/MarkdownText.tsx @@ -40,6 +40,7 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({ const SHORT_STREAM_COMMIT_MS = 80; const MEDIUM_STREAM_COMMIT_MS = 140; const LONG_STREAM_COMMIT_MS = 220; +const STREAMING_HIGHLIGHT_CHAR_LIMIT = 16_000; export function preloadMarkdownText(): void { void loadMarkdownRenderer(); @@ -56,7 +57,9 @@ export function MarkdownText({ streaming = false, }: MarkdownTextProps) { const renderedSource = useStreamingMarkdownSource(children, streaming); - const highlightCode = !streaming && renderedSource === children; + const highlightCode = streaming + ? renderedSource.length <= STREAMING_HIGHLIGHT_CHAR_LIMIT + : renderedSource === children; useEffect(() => { if (streaming) preloadMarkdownText(); diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index a47786d17..4b43e778d 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -1,11 +1,13 @@ -import { Children, isValidElement, useMemo } from "react"; -import type { Components } from "react-markdown"; +import { Children, isValidElement, useMemo, type ReactNode } from "react"; +import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; +import { Check } from "lucide-react"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; +import { AttachmentTile } from "@/components/AttachmentTile"; import { CodeBlock } from "@/components/CodeBlock"; import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip"; import { inferMediaKind } from "@/lib/media"; @@ -19,8 +21,181 @@ interface MarkdownTextRendererProps { highlightCode?: boolean; } -const remarkPlugins = [remarkBreaks, remarkGfm, remarkMath]; -const rehypePlugins = [rehypeKatex]; +type MarkdownAstNode = { + 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 = [ + remarkBreaks, + remarkGfm, + [remarkMath, { singleDollarTextMath: false }], + remarkSafeHtmlSubset, +]; +const rehypePlugins: NonNullable = [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 @@ -82,9 +257,20 @@ export default function MarkdownTextRenderer({ const kids = Children.toArray(markdownChildren); const lone = kids.length === 1 ? kids[0] : null; /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
``. */ - if (lone != null && isValidElement(lone) && lone.type === CodeBlock) { + if (isRenderedCodeBlock(lone)) { return <>{markdownChildren}; } + const fence = codeFenceFromPreChild(lone); + if (fence) { + return ( + + ); + } return (
         );
       },
+      input({ type, checked }) {
+        if (type !== "checkbox") return null;
+        return (
+          
+            {checked ?  : null}
+          
+        );
+      },
+      mark({ children: markdownChildren }) {
+        return (
+          
+            {markdownChildren}
+          
+        );
+      },
+      sub({ children: markdownChildren }) {
+        return {markdownChildren};
+      },
+      sup({ children: markdownChildren }) {
+        return {markdownChildren};
+      },
+      details({ children: markdownChildren }) {
+        return (
+          
+ {markdownChildren} +
+ ); + }, + summary({ children: markdownChildren }) { + return ( + + {markdownChildren} + + ); + }, img({ src, alt, node: _node, className: imgClassName, ...props }) { void _node; + void imgClassName; + void props; const source = typeof src === "string" ? src : ""; if (!source) return null; const label = typeof alt === "string" ? alt : ""; - if (inferMediaKind({ url: source, name: label }) === "video") { - return ( - - - ); - } + const kind = markdownAttachmentKind(source, label); return ( - - - {label} - - {label ? ( - - {label} - - ) : null} - + ); }, }), diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 157d4e291..6fbd29a4d 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -6,14 +6,16 @@ import { useState, type ReactNode, } 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 { AttachmentTile } from "@/components/AttachmentTile"; import { CliAppMentionText } from "@/components/CliAppMentionText"; import { ImageLightbox } from "@/components/ImageLightbox"; import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; import { cn } from "@/lib/utils"; import { formatTurnLatency } from "@/lib/format"; +import { toMediaAttachment } from "@/lib/media"; import type { CliAppInfo, McpPresetInfo, @@ -258,10 +260,11 @@ function MessageMedia({ const images: UIImage[] = []; const nonImages: UIMediaAttachment[] = []; for (const item of media) { - if (item.kind === "image") { - images.push({ url: item.url, name: item.name }); + const normalized = toMediaAttachment(item); + if (normalized.kind === "image") { + images.push({ url: normalized.url, name: normalized.name }); } else { - nonImages.push(item); + nonImages.push(normalized); } } @@ -276,73 +279,12 @@ function MessageMedia({ ) : null} {nonImages.map((item, i) => ( - + ))}
); } -function MediaCell({ media }: { media: UIMediaAttachment }) { - const { t } = useTranslation(); - const hasUrl = typeof media.url === "string" && media.url.length > 0; - - if (media.kind === "video" && hasUrl) { - return ( -
-
- ); - } - - 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 = ( - <> - - {media.name ?? label} - - ); - - if (hasUrl) { - return ( - - {inner} - - ); - } - - return ( -
- {inner} -
- ); -} - /** * Right-aligned preview row for images attached to a user turn. * diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 811af69c0..0fc92ae18 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -57,6 +57,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -74,6 +75,7 @@ import { fetchSettings, fetchCliApps, fetchMcpPresets, + fetchProviderModels, importMcpConfig, loginProviderOAuth, logoutProviderOAuth, @@ -105,6 +107,7 @@ import type { McpPresetInfo, McpPresetsPayload, NetworkSafetySettingsUpdate, + ProviderModelsPayload, SettingsPayload, WebSearchSettingsUpdate, WebuiDefaultAccessMode, @@ -166,6 +169,23 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png"; 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 = [ "UTC", @@ -1124,6 +1144,7 @@ export function SettingsView({ return (
-
+
{filteredOptions.length ? ( filteredOptions.map((option) => { const selected = option.name === value; @@ -4268,7 +4303,7 @@ function ProviderPicker({ {providers.map((provider) => { 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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 } = {}, + ) => ( + 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", + )} + > + + + + {model.label ?? model.id} + + + + {model.context_window ? {formatContextWindow(model.context_window)} : null} + {options.selected ? : null} + + + ); + + return ( + + + + + +
+
+ + 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]" + /> +
+
+ + {!canFetchModels ? ( +
+ {tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")} +
+ ) : waitingForModelSearch ? ( +
+ {tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")} +
+ ) : loading ? ( +
+ + {tx("settings.models.loadingModels", "Loading models...")} +
+ ) : error || payload?.status === "error" ? ( +
+ {payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")} +
+ ) : payload?.status === "not_configured" ? ( +
+ {tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")} +
+ ) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? ( +
+ {payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")} +
+ ) : isCatalog && !normalizedQuery ? ( +
+ {tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")} + {providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""} +
+ ) : null} + + {showModels && visibleModels.length ? ( +
+ {visibleModels.map((model) => + renderModelRow(model, { selected: model.id === value }), + )} +
+ ) : showModels ? ( +
+ {tx("settings.models.noModelResults", "No matching models.")} +
+ ) : null} + + {customCandidate && !exactQueryMatch && customCandidate !== value ? ( + <> + {showModels ? : null} + selectModel(customCandidate)} + className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85" + > + + + + + {tx("settings.models.useCustomModel", "Use")}{" "} + “{customCandidate}” + + + + ) : null} +
+
+ ); +} + +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({ provider, showBrandLogos, @@ -4860,7 +5128,7 @@ function ModelPresetPicker({ {presets.map((preset) => { const selected = preset.name === value; diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index c3eaa479b..aaf5a7be0 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -1,10 +1,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AlertCircle, - Check, CheckCircle2, ChevronRight, - CircleDashed, + FileImage, Layers, Search, Server, @@ -16,8 +15,20 @@ import { useTranslation } from "react-i18next"; import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText"; import { FileReferenceChip } from "@/components/FileReferenceChip"; -import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; 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 { formatToolCallTrace } from "@/lib/tool-traces"; 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 ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24; -export function isReasoningOnlyAssistant(m: UIMessage): boolean { - 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"; -} +export { isAgentActivityMember, isReasoningOnlyAssistant }; interface ActivityCounts { reasoningSteps: number; @@ -58,20 +61,6 @@ interface ActivityCounts { 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 { key: string; name: string; @@ -485,7 +474,7 @@ export function AgentActivityCluster({ {outerExpanded && (
-
+
{messages.map((m) => { if (isReasoningOnlyAssistant(m)) { return ( - { - if (text.length > 0) preloadMarkdownText(); - }, [text.length]); - return ( -
-
- - - {streaming - ? t("message.reasoningStreaming", { defaultValue: "Thinking…" }) - : t("message.reasoning", { defaultValue: "Thinking" })} - -
- {text.trim() ? ( - - {text} - - ) : null} -
- ); -} - -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 ( - - ); - } - return ( - - - - ); -} - function ActivityTraceList({ lines, active, + evidenceByLine, }: { lines: string[]; active: boolean; + evidenceByLine?: Map; }) { return (
    @@ -741,6 +643,7 @@ function ActivityTraceList({ key={`${line}-${index}`} line={line} active={active && index === lines.length - 1} + evidence={evidenceByLine?.get(line) ?? []} /> ))}
@@ -761,6 +664,8 @@ function ActivityTraceTimeline({ const lines = traceLines(message); const cliRunsByLine = cliRunMapByTraceLine(message); const mcpRunsByLine = mcpRunMapByTraceLine(message); + const evidenceByLine = toolEvidenceByTraceLine(message); + const trailingEvidence = activityEvidenceFromMessageMedia(message); const renderedRunKeys = new Set(); const items: ReactNode[] = []; let normalLines: string[] = []; @@ -772,6 +677,7 @@ function ActivityTraceTimeline({ key={`${message.id}:trace:${suffix}`} lines={normalLines} active={active} + evidenceByLine={evidenceByLine} />, ); normalLines = []; @@ -790,6 +696,15 @@ function ActivityTraceTimeline({ cliAppsByName={cliAppsByName} />, ); + const evidence = evidenceByLine.get(line) ?? []; + if (evidence.length) { + items.push( + , + ); + } return; } @@ -805,6 +720,15 @@ function ActivityTraceTimeline({ mcpPresetsByName={mcpPresetsByName} />, ); + const evidence = evidenceByLine.get(line) ?? []; + if (evidence.length) { + items.push( + , + ); + } return; } @@ -836,10 +760,25 @@ function ActivityTraceTimeline({ ); } - return items.length ? <>{items} : null; + if (trailingEvidence.length) { + items.push( + , + ); + } + + if (!items.length) return null; + const group = describeActivityGroup(message, evidenceByLine, trailingEvidence); + return ( + + {items} + + ); } -function ActivityTraceRow({ line, active }: { line: string; active: boolean }) { +function ActivityTraceRow({ line, active, evidence = [] }: { line: string; active: boolean; evidence?: ActivityEvidence[] }) { const trace = describeTraceLine(line); const Icon = trace.kind === "search" ? Search @@ -849,21 +788,90 @@ function ActivityTraceRow({ line, active }: { line: string; active: boolean }) { ? Wrench : Layers; return ( -
  • - - - {trace.label} - {trace.detail ? ( - <> - - {trace.detail} - - ) : null} - -
  • + } + active={active && trace.kind !== "done"} + tone={trace.kind === "done" ? "success" : active ? "active" : "neutral"} + label={trace.label} + detail={trace.detail} + title={`${trace.label}${trace.detail ? ` ${trace.detail}` : ""}`} + > + + ); } +function ActivityEvidenceList({ evidence }: { evidence: ActivityEvidence[] }) { + return ( +
      + + + +
    + ); +} + +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 { + const map = new Map(); + 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): ActivityEvidence[] { + return [...evidenceByLine.values()].flat(); +} + +function describeActivityGroup( + message: UIMessage, + evidenceByLine: Map, + 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 { kind: "search" | "tool" | "done" | "trace"; label: string; @@ -891,7 +899,7 @@ function TraceIconMark({ ): 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({ runs, active, @@ -1694,40 +1681,42 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; useEffect(() => setLogoIndex(0), [app?.logo_url]); return ( -
  • + {logoUrl ? ( + setLogoIndex((index) => index + 1)} + /> + ) : app ? ( + cliAppInitials(app).slice(0, 2) + ) : ( + + )} + + )} > - - {logoUrl ? ( - setLogoIndex((index) => index + 1)} - /> - ) : app ? ( - cliAppInitials(app).slice(0, 2) - ) : ( - - )} - - - - {label} - +
    @{run.name} @@ -1758,8 +1747,8 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; ) : null} - -
  • +
    + ); } @@ -1803,40 +1792,42 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea useEffect(() => setLogoIndex(0), [preset?.logo_url]); return ( -
  • + {logoUrl ? ( + setLogoIndex((index) => index + 1)} + /> + ) : preset ? ( + mcpPresetInitials(preset).slice(0, 2) + ) : ( + + )} + + )} > - - {logoUrl ? ( - setLogoIndex((index) => index + 1)} - /> - ) : preset ? ( - mcpPresetInitials(preset).slice(0, 2) - ) : ( - - )} - - - - {label} - +
    {displayName} @@ -1856,8 +1847,8 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea ) : null} - -
  • +
    + ); } @@ -1870,180 +1861,3 @@ function alphaColor(color: string, percent: number): string { } return `color-mix(in srgb, ${color} ${percent}%, transparent)`; } - -function FileEditGroup({ edits }: { edits: FileEditSummary[] }) { - if (edits.length === 0) return null; - return ( -
      - {edits.map((edit) => ( - - ))} -
    - ); -} - -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 ( -
  • -
    - - {failed ? ( - - ) : editing ? ( - - ) : ( - - )} - - {edit.pending && !edit.path ? ( - - {t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })} - - ) : ( - - )} - {failed ? ( - - {failureDetail} - - ) : null} -
    - {hasCountedDiff ? ( - - ) : null} -
  • - ); -} - -function DiffPair({ added, deleted }: { added: number; deleted: number }) { - return ( - - - - - ); -} - -function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) { - const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; - return ( - - - {sign} - - - {sign}{safeValue} - - ); -} - -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 ; -} - -function RollingNumber({ value }: { value: number }) { - const digits = String(value).split(""); - return ( - - {digits.map((digit, index) => ( - - ))} - - ); -} - -function RollingDigit({ digit }: { digit: number }) { - const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0; - return ( - - 0 - - {Array.from({ length: 10 }, (_, n) => ( - - {n} - - ))} - - - ); -} diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 6fa66406b..32becaec5 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -5,6 +5,7 @@ import { useMemo, useRef, useState, + type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, } from "react"; @@ -22,10 +23,11 @@ import { ArrowUp, BookOpen, Brain, - Check, ChevronDown, ChevronUp, CircleHelp, + CornerDownRight, + GripVertical, History, ImageIcon, Loader2, @@ -36,6 +38,7 @@ import { Square, SquarePen, Target, + Trash2, Undo2, X, type LucideIcon, @@ -52,6 +55,7 @@ import { type AttachedImage, type AttachmentError, MAX_IMAGES_PER_MESSAGE, + type RestoredReadyImage, } from "@/hooks/useAttachedImages"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import type { SendImage, SendOptions } from "@/hooks/useNanobotStream"; @@ -94,9 +98,6 @@ interface ThreadComposerProps { slashCommands?: SlashCommand[]; cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; - imageGenerationEnabled?: boolean; - imageMode?: boolean; - onImageModeChange?: (enabled: boolean) => void; onStop?: () => void; /** Unix seconds from server; turn elapsed timer above input while set. */ runStartedAt?: number | null; @@ -108,6 +109,7 @@ interface ThreadComposerProps { workspaceScopeDisabled?: boolean; workspaceError?: string | null; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; + pendingQueueKey?: string | null; } const COMMAND_ICONS: Record = { @@ -124,15 +126,15 @@ const COMMAND_ICONS: Record = { "undo-2": Undo2, }; -type ImageAspectRatio = "auto" | "1:1" | "3:4" | "9:16" | "4:3" | "16:9"; - -const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "4:3", "16:9"]; const SLASH_PALETTE_GAP_PX = 8; const SLASH_PALETTE_MAX_HEIGHT_PX = 288; const SLASH_PALETTE_MIN_HEIGHT_PX = 144; const SLASH_PALETTE_CHROME_PX = 12; const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents"; const SLASH_RECENTS_LIMIT = 5; +const QUEUED_PROMPTS_STORAGE_PREFIX = "nanobot.webui.composerQueuedGuidance.v1:"; +const QUEUED_PROMPTS_LIMIT = 20; +const QUEUED_PROMPT_MAX_CHARS = 4000; type SlashPalettePlacement = "above" | "below"; @@ -141,6 +143,17 @@ interface SlashPaletteLayout { maxHeight: number; } +interface QueuedPrompt { + id: string; + text: string; + images?: QueuedPromptImage[]; +} + +interface QueuedPromptImage { + dataUrl: string; + name?: string; +} + interface CliAppMentionQuery { query: string; start: number; @@ -186,20 +199,125 @@ function storeSlashRecents(commands: string[]): void { } } -function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) { - if (!(target instanceof Element) || deltaY === 0) return; - let el: HTMLElement | null = target.parentElement; - while (el) { - const style = window.getComputedStyle(el); - const canScroll = /(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight; - if (canScroll) { - el.scrollTop += deltaY; +function queuedPromptsStorageKey(key?: string | null): string | null { + const clean = key?.trim(); + return clean ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` : null; +} + +function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | null { + if (!item || typeof item !== "object") return null; + const record = item as Partial; + if (typeof record.text !== "string") return null; + const text = record.text.trim().slice(0, QUEUED_PROMPT_MAX_CHARS); + const images = Array.isArray(record.images) + ? record.images.flatMap((image) => { + if (!image || typeof image !== "object") return []; + const candidate = image as Partial; + if (typeof candidate.dataUrl !== "string" || !candidate.dataUrl.startsWith("data:image/")) { + return []; + } + return [{ + dataUrl: candidate.dataUrl, + ...(typeof candidate.name === "string" && candidate.name.trim() + ? { name: candidate.name.trim() } + : {}), + }]; + }).slice(0, MAX_IMAGES_PER_MESSAGE) + : []; + if (!text && images.length === 0) return null; + const id = typeof record.id === "string" && record.id.trim() + ? record.id + : `queued-prompt-restored-${index}`; + return { id, text, ...(images.length > 0 ? { images } : {}) }; +} + +function readQueuedPrompts(storageKey: string): QueuedPrompt[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(storageKey); + const parsed = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return []; + return parsed + .map((item, index) => normalizeQueuedPrompt(item, index)) + .filter((item): item is QueuedPrompt => item != null) + .slice(0, QUEUED_PROMPTS_LIMIT); + } catch { + return []; + } +} + +function storeQueuedPrompts(storageKey: string, prompts: QueuedPrompt[]): void { + if (typeof window === "undefined") return; + try { + if (prompts.length === 0) { + window.localStorage.removeItem(storageKey); return; } - el = el.parentElement; + window.localStorage.setItem( + storageKey, + JSON.stringify( + prompts.slice(0, QUEUED_PROMPTS_LIMIT).map((prompt) => ({ + id: prompt.id, + text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS), + ...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_IMAGES_PER_MESSAGE) } : {}), + })), + ), + ); + } catch { + // localStorage persistence is a convenience; the in-memory queue still works. } } +function readyImagesToQueuedImages( + images: Array, +): QueuedPromptImage[] { + return images.map((img) => ({ + dataUrl: img.dataUrl, + name: img.file.name, + })); +} + +function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | undefined { + if (!images?.length) return undefined; + return images.map((img) => ({ + media: { + data_url: img.dataUrl, + ...(img.name ? { name: img.name } : {}), + }, + preview: { + url: img.dataUrl, + ...(img.name ? { name: img.name } : {}), + }, + })); +} + +function queuedPromptLabel(prompt: QueuedPrompt): string { + const text = prompt.text.trim(); + if (text) return text; + return prompt.images?.map((img) => img.name).filter(Boolean).join(", ") || "Image attachment"; +} + +function suppressNativeDragPreview(dataTransfer: DataTransfer): void { + if (typeof document === "undefined" || typeof dataTransfer.setDragImage !== "function") { + return; + } + const ghost = document.createElement("div"); + ghost.style.position = "fixed"; + ghost.style.left = "-9999px"; + ghost.style.top = "-9999px"; + ghost.style.width = "1px"; + ghost.style.height = "1px"; + ghost.style.opacity = "0"; + document.body.appendChild(ghost); + try { + dataTransfer.setDragImage(ghost, 0, 0); + } catch { + ghost.remove(); + return; + } + window.setTimeout(() => ghost.remove(), 0); +} + function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } { let top = 0; let bottom = window.innerHeight; @@ -284,25 +402,61 @@ function RunElapsedStrip({ }) { const { t } = useTranslation(); const [goalPanelOpen, setGoalPanelOpen] = useState(false); + const showTimer = startedAt != null; + const stripLabel = goalStateStripPreview(goalState, t); + const showGoal = !!stripLabel?.trim(); + const active = showTimer || showGoal; + const [renderStrip, setRenderStrip] = useState(active); + const [leaving, setLeaving] = useState(false); const [, setTick] = useState(0); const stripWrapperRef = useRef(null); const panelRef = useRef(null); const expandToggleRef = useRef(null); + const stripSnapshotRef = useRef<{ + startedAt: number | null; + goalState?: GoalStateWsPayload; + stripLabel: string | null; + } | null>(null); const [panelMaxPx, setPanelMaxPx] = useState(280); + if (active) { + stripSnapshotRef.current = { startedAt, goalState, stripLabel }; + } + + useEffect(() => { + if (active) { + setRenderStrip(true); + setLeaving(false); + return; + } + setGoalPanelOpen(false); + if (!renderStrip) return; + setLeaving(true); + const id = window.setTimeout(() => { + setRenderStrip(false); + setLeaving(false); + }, 180); + return () => window.clearTimeout(id); + }, [active, renderStrip]); + useEffect(() => { if (startedAt == null) return; const id = window.setInterval(() => setTick((n) => n + 1), 1000); return () => window.clearInterval(id); }, [startedAt]); - const showTimer = startedAt != null; - const stripLabel = goalStateStripPreview(goalState, t); - const showGoal = !!stripLabel?.trim(); + const display = active + ? { startedAt, goalState, stripLabel } + : stripSnapshotRef.current; + const displayStartedAt = display?.startedAt ?? null; + const displayGoalState = display?.goalState; + const displayStripLabel = display?.stripLabel ?? null; + const displayShowTimer = displayStartedAt != null; + const displayShowGoal = !!displayStripLabel?.trim(); - const objectiveFull = goalState?.objective?.trim() ?? ""; - const summaryFull = goalState?.ui_summary?.trim() ?? ""; - const canExpandGoal = !!(goalState?.active && (objectiveFull || summaryFull)); + const objectiveFull = displayGoalState?.objective?.trim() ?? ""; + const summaryFull = displayGoalState?.ui_summary?.trim() ?? ""; + const canExpandGoal = !!(active && displayGoalState?.active && (objectiveFull || summaryFull)); const markdownBody = objectiveFull || summaryFull @@ -361,22 +515,26 @@ function RunElapsedStrip({ }; }, [goalPanelOpen]); - if (!showTimer && !showGoal) return null; + if (!renderStrip || !display) return null; const elapsed = - startedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - startedAt)) : 0; + displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0; const m = Math.floor(elapsed / 60); const sec = elapsed % 60; const shortElapsed = m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`; - const timerTitle = showTimer + const timerTitle = displayShowTimer ? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed }) : null; - const ariaParts = [timerTitle, showGoal ? stripLabel : null].filter(Boolean); + const ariaParts = [timerTitle, displayShowGoal ? displayStripLabel : null].filter(Boolean); const ariaLabel = ariaParts.join(" · "); return ( -
    +
    {goalPanelOpen && canExpandGoal && markdownBody ? (
    - {showTimer ? ( + {displayShowTimer ? ( ) : ( )} {timerTitle ? {timerTitle} : null} - {timerTitle && showGoal ? ( + {timerTitle && displayShowGoal ? ( · ) : null} - {showGoal ? ( + {displayShowGoal ? ( - {t("thread.composer.goalStateStrip", { label: stripLabel })} + {t("thread.composer.goalStateStrip", { label: displayStripLabel })} ) : null} @@ -484,9 +642,6 @@ export function ThreadComposer({ slashCommands = [], cliApps = [], mcpPresets = [], - imageGenerationEnabled = true, - imageMode: controlledImageMode, - onImageModeChange, onStop, runStartedAt = null, goalState, @@ -496,6 +651,7 @@ export function ThreadComposer({ workspaceScopeDisabled = false, workspaceError = null, onWorkspaceScopeChange, + pendingQueueKey = null, }: ThreadComposerProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); @@ -505,46 +661,47 @@ export function ThreadComposer({ const [cliAppMenuDismissed, setCliAppMenuDismissed] = useState(false); const [selectedCliAppIndex, setSelectedCliAppIndex] = useState(0); const [cursorPosition, setCursorPosition] = useState(0); - const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false); - const [imageAspectRatio, setImageAspectRatio] = useState("auto"); - const [aspectMenuOpen, setAspectMenuOpen] = useState(false); const [recentSlashCommands, setRecentSlashCommands] = useState(() => readSlashRecents()); + const [queuedPrompts, setQueuedPrompts] = useState([]); const textareaRef = useRef(null); const formRef = useRef(null); const fileInputRef = useRef(null); - const aspectControlRef = useRef(null); const chipRefs = useRef(new Map()); + const queuedPromptCounterRef = useRef(0); + const draggedQueuedPromptIdRef = useRef(null); + const wasStreamingRef = useRef(isStreaming); + const skipNextQueuedFlushRef = useRef(false); + const skipQueuedPromptPersistRef = useRef(false); const isHero = variant === "hero"; + const queuedPromptStorageKey = useMemo( + () => queuedPromptsStorageKey(pendingQueueKey), + [pendingQueueKey], + ); const showProjectPicker = isHero && !!workspaceDefaultScope && !!onWorkspaceScopeChange && workspaceControls?.can_change_project !== false; - const requestedImageMode = controlledImageMode ?? uncontrolledImageMode; - const imageMode = imageGenerationEnabled && requestedImageMode; - const setImageMode = useCallback( - (enabled: boolean) => { - if (controlledImageMode === undefined) { - setUncontrolledImageMode(enabled); - } - onImageModeChange?.(enabled); - }, - [controlledImageMode, onImageModeChange], - ); useEffect(() => { - if (imageGenerationEnabled || !requestedImageMode) return; - setImageMode(false); - setAspectMenuOpen(false); - }, [imageGenerationEnabled, requestedImageMode, setImageMode]); + skipQueuedPromptPersistRef.current = true; + setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []); + }, [queuedPromptStorageKey]); + + useEffect(() => { + if (!queuedPromptStorageKey) return; + if (skipQueuedPromptPersistRef.current) { + skipQueuedPromptPersistRef.current = false; + return; + } + storeQueuedPrompts(queuedPromptStorageKey, queuedPrompts); + }, [queuedPromptStorageKey, queuedPrompts]); const resolvedPlaceholder = isStreaming ? t("thread.composer.placeholderStreaming") - : imageMode - ? t("thread.composer.imageMode.placeholder") - : placeholder ?? t("thread.composer.placeholderThread"); + : placeholder ?? t("thread.composer.placeholderThread"); - const { images, enqueue, remove, clear, encoding, full } = + const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } = useAttachedImages(); const formatRejection = useCallback( @@ -598,6 +755,13 @@ export function ThreadComposer({ && !encoding && !hasErrors && (value.trim().length > 0 || readyImages.length > 0); + const canQueueGuidance = + isStreaming + && !disabled + && !encoding + && !hasErrors + && (value.trim().length > 0 || readyImages.length > 0) + && !value.trimStart().startsWith("/"); const slashQuery = useMemo(() => { if (disabled || slashMenuDismissed || !value.startsWith("/")) return null; @@ -835,38 +999,6 @@ export function ThreadComposer({ }; }, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]); - useEffect(() => { - if (!aspectMenuOpen) return; - - const closeOnPointerDown = (event: PointerEvent) => { - const target = event.target; - if (target instanceof Node && aspectControlRef.current?.contains(target)) return; - setAspectMenuOpen(false); - }; - const closeOnKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setAspectMenuOpen(false); - textareaRef.current?.focus(); - } - }; - const closeOnScroll = () => setAspectMenuOpen(false); - const closeOnWheel = (event: WheelEvent) => { - setAspectMenuOpen(false); - scrollNearestOverflowParent(event.target, event.deltaY); - }; - - document.addEventListener("pointerdown", closeOnPointerDown, true); - document.addEventListener("keydown", closeOnKeyDown); - document.addEventListener("scroll", closeOnScroll, true); - document.addEventListener("wheel", closeOnWheel, { capture: true, passive: true }); - return () => { - document.removeEventListener("pointerdown", closeOnPointerDown, true); - document.removeEventListener("keydown", closeOnKeyDown); - document.removeEventListener("scroll", closeOnScroll, true); - document.removeEventListener("wheel", closeOnWheel, true); - }; - }, [aspectMenuOpen]); - const resizeTextarea = useCallback(() => { requestAnimationFrame(() => { const el = textareaRef.current; @@ -928,9 +1060,121 @@ export function ThreadComposer({ [cliAppMention, resizeTextarea, value], ); + const clearComposerText = useCallback(() => { + setValue(""); + setInlineError(null); + setSlashMenuDismissed(false); + setCliAppMenuDismissed(false); + setCursorPosition(0); + resizeTextarea(); + }, [resizeTextarea]); + + const queueGuidancePrompt = useCallback(() => { + const text = value.trim(); + if (!canQueueGuidance || (!text && readyImages.length === 0)) return; + const queuedImages = readyImagesToQueuedImages(readyImages); + queuedPromptCounterRef.current += 1; + setQueuedPrompts((items) => [ + ...items, + { + id: `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`, + text, + ...(queuedImages.length > 0 ? { images: queuedImages } : {}), + }, + ]); + clear(); + clearComposerText(); + }, [canQueueGuidance, clear, clearComposerText, readyImages, value]); + + const removeQueuedPrompt = useCallback((id: string) => { + setQueuedPrompts((items) => items.filter((item) => item.id !== id)); + requestAnimationFrame(() => textareaRef.current?.focus()); + }, []); + + const editQueuedPrompt = useCallback((prompt: QueuedPrompt) => { + setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id)); + setValue(prompt.text); + setInlineError(null); + setSlashMenuDismissed(false); + setCliAppMenuDismissed(false); + setCursorPosition(prompt.text.length); + if (prompt.images?.length) { + restoreReadyImages(prompt.images as RestoredReadyImage[]); + } else { + clear(); + } + resizeTextarea(); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(prompt.text.length, prompt.text.length); + }); + }, [clear, resizeTextarea, restoreReadyImages]); + + const moveQueuedPrompt = useCallback((dragId: string, targetId: string) => { + if (dragId === targetId) return; + setQueuedPrompts((items) => { + const from = items.findIndex((item) => item.id === dragId); + const to = items.findIndex((item) => item.id === targetId); + if (from === -1 || to === -1) return items; + const next = [...items]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; + }); + }, []); + + const sendQueuedPrompt = useCallback( + (prompt: QueuedPrompt) => { + const text = prompt.text.trim(); + const queuedImages = queuedImagesToSendImages(prompt.images); + setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id)); + if (text || queuedImages?.length) { + if (queuedImages?.length) onSend(text, queuedImages); + else onSend(text); + } + requestAnimationFrame(() => textareaRef.current?.focus()); + }, + [onSend], + ); + + const sendNextQueuedPrompt = useCallback(() => { + if (queuedPrompts.length === 0) return; + const nextPrompt = queuedPrompts.find((prompt) => prompt.text.trim()); + if (!nextPrompt) { + setQueuedPrompts([]); + return; + } + setQueuedPrompts((items) => items.filter((item) => item.id !== nextPrompt.id)); + const queuedImages = queuedImagesToSendImages(nextPrompt.images); + if (queuedImages?.length) onSend(nextPrompt.text.trim(), queuedImages); + else onSend(nextPrompt.text.trim()); + requestAnimationFrame(() => textareaRef.current?.focus()); + }, [onSend, queuedPrompts]); + + useEffect(() => { + const wasStreaming = wasStreamingRef.current; + wasStreamingRef.current = isStreaming; + if (!wasStreaming || isStreaming || queuedPrompts.length === 0) return; + if (skipNextQueuedFlushRef.current) { + skipNextQueuedFlushRef.current = false; + return; + } + sendNextQueuedPrompt(); + }, [sendNextQueuedPrompt, isStreaming, queuedPrompts.length]); + + const handleStop = useCallback(() => { + if (queuedPrompts.length > 0) { + skipNextQueuedFlushRef.current = true; + } + onStop?.(); + }, [onStop, queuedPrompts.length]); + const submit = useCallback(() => { if (!canSend) return; const trimmed = value.trim(); + const content = trimmed; // Share the same normalized ``data:`` URL with both the wire payload and // the optimistic bubble preview: data URLs are self-contained (no blob // lifetime, safe under React StrictMode double-mount) and keep the @@ -948,40 +1192,26 @@ export function ThreadComposer({ const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload); const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload); const options: SendOptions | undefined = - imageMode || attachedCliApps.length > 0 || attachedMcpPresets.length > 0 + attachedCliApps.length > 0 || attachedMcpPresets.length > 0 ? { - ...(imageMode - ? { - imageGeneration: { - enabled: true, - aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio, - }, - } - : {}), ...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}), ...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}), } : undefined; - onSend(trimmed, payload, options); - setValue(""); - setInlineError(null); + onSend(content, payload, options); + setQueuedPrompts([]); // Bubble owns the data URL copy; safe to revoke every staged blob // preview here without affecting the rendered message. clear(); - setSlashMenuDismissed(false); - setCliAppMenuDismissed(false); - setCursorPosition(0); - resizeTextarea(); + clearComposerText(); }, [ activeCliMentionApps, activeMcpPresetMentions, canSend, clear, - imageAspectRatio, - imageMode, + clearComposerText, onSend, readyImages, - resizeTextarea, value, ]); @@ -1036,6 +1266,10 @@ export function ThreadComposer({ } if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); + if (canQueueGuidance) { + queueGuidancePrompt(); + return; + } submit(); } }; @@ -1085,14 +1319,13 @@ export function ThreadComposer({ const attachButtonDisabled = disabled || full; const showStopButton = isStreaming && !!onStop; - const centerHeroPlaceholder = - isHero && value.length === 0 && images.length === 0 && !isStreaming; + const relaxedHeroInput = isHero && images.length === 0 && !isStreaming; const inputTextClasses = cn( "w-full resize-none bg-transparent", isHero ? cn( "min-h-[78px] px-5 text-[15px] leading-6", - centerHeroPlaceholder ? "pb-2 pt-[27px]" : "pb-1.5 pt-4", + relaxedHeroInput ? "pb-2 pt-[27px]" : "pb-1.5 pt-4", ) : "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5", ); @@ -1144,6 +1377,30 @@ export function ThreadComposer({ "goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45", )} > + {queuedPrompts.length > 0 ? ( + { + draggedQueuedPromptIdRef.current = id; + }} + onDragEnd={() => { + draggedQueuedPromptIdRef.current = null; + }} + onDrop={(targetId) => { + const dragId = draggedQueuedPromptIdRef.current; + if (dragId) moveQueuedPrompt(dragId, targetId); + }} + /> + ) : null} {images.length > 0 ? (
    ) : null} - {runStartedAt != null || goalState?.active ? ( - - ) : null} +
    {hasMentionDecorations ? ( ) : null} - {imageGenerationEnabled ? ( -
    - - {imageMode ? ( - - ) : null} - {imageMode && aspectMenuOpen ? ( - { - setImageAspectRatio(ratio); - setAspectMenuOpen(false); - textareaRef.current?.focus(); - }} - /> - ) : null} -
    - ) : null}
    {modelLabel ? ( @@ -1332,7 +1532,7 @@ export function ThreadComposer({ size="icon" disabled={showStopButton ? disabled : !canSend} aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")} - onClick={showStopButton ? onStop : undefined} + onClick={showStopButton ? handleStop : undefined} className={cn( "rounded-full transition-transform", showStopButton @@ -1368,6 +1568,191 @@ export function ThreadComposer({ ); } +function QueuedPromptStack({ + prompts, + isHero, + label, + guideLabel, + deleteLabel, + dragLabel, + editLabel, + onGuide, + onDelete, + onEdit, + onDragStart, + onDragEnd, + onDrop, +}: { + prompts: QueuedPrompt[]; + isHero: boolean; + label: string; + guideLabel: string; + deleteLabel: string; + dragLabel: string; + editLabel: string; + onGuide: (prompt: QueuedPrompt) => void; + onDelete: (id: string) => void; + onEdit: (prompt: QueuedPrompt) => void; + onDragStart: (id: string) => void; + onDragEnd: () => void; + onDrop: (targetId: string) => void; +}) { + const stripMaxHeight = Math.min(240, 14 + prompts.length * 34 + Math.max(0, prompts.length - 1) * 4); + + return ( +
    +
    + {prompts.map((prompt) => ( + + ))} +
    +
    + ); +} + +function QueuedPromptRow({ + prompt, + isHero, + guideLabel, + deleteLabel, + dragLabel, + editLabel, + onGuide, + onDelete, + onEdit, + onDragStart, + onDragEnd, + onDrop, +}: { + prompt: QueuedPrompt; + isHero: boolean; + guideLabel: string; + deleteLabel: string; + dragLabel: string; + editLabel: string; + onGuide: (prompt: QueuedPrompt) => void; + onDelete: (id: string) => void; + onEdit: (prompt: QueuedPrompt) => void; + onDragStart: (id: string) => void; + onDragEnd: () => void; + onDrop: (targetId: string) => void; +}) { + const displayLabel = queuedPromptLabel(prompt); + + return ( +
    { + event.preventDefault(); + onDrop(prompt.id); + }} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => { + event.preventDefault(); + onDrop(prompt.id); + }} + onDragEnd={onDragEnd} + className={cn( + "queued-prompt-row group/queued flex min-h-8 items-center gap-1.5 rounded-[12px] px-2 py-0.5", + "text-[13px] transition-colors hover:bg-muted/55 dark:hover:bg-white/[0.055]", + isHero && "text-[13.5px]", + )} + > + { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", prompt.id); + suppressNativeDragPreview(event.dataTransfer); + onDragStart(prompt.id); + }} + onDragEnd={onDragEnd} + className={cn( + "inline-flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-lg", + "text-muted-foreground/45 transition-colors hover:bg-background/80 hover:text-muted-foreground", + "active:cursor-grabbing dark:hover:bg-white/[0.06]", + )} + > + + +
    +

    + {displayLabel} +

    +
    + + + +
    + ); +} + function ComposerModelBadge({ label, provider, @@ -1513,59 +1898,6 @@ function useSelectedOptionScroll(selectedIndex: number) { return containerRef; } -function ImageAspectMenu({ - selected, - isHero, - onSelect, -}: { - selected: ImageAspectRatio; - isHero: boolean; - onSelect: (ratio: ImageAspectRatio) => void; -}) { - const { t } = useTranslation(); - return ( -
    -
    - {t("thread.composer.imageMode.aspectLabel")} -
    - {IMAGE_ASPECT_RATIOS.map((ratio) => { - const label = t(`thread.composer.imageMode.aspect.${ratio.replace(":", "_")}`); - return ( - - ); - })} -
    - ); -} - function CliAppMentionPalette({ candidates, selectedIndex, diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index c9770be5b..9fd01e9c6 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -2,15 +2,13 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { MessageBubble } from "@/components/MessageBubble"; -import { - AgentActivityCluster, - isAgentActivityMember, -} from "@/components/thread/AgentActivityCluster"; +import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; +import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; interface ThreadMessagesProps { 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; hiddenMessageCount?: number; onLoadEarlier?: () => void; @@ -18,9 +16,7 @@ interface ThreadMessagesProps { mcpPresets?: McpPresetInfo[]; } -export type DisplayUnit = - | { type: "cluster"; messages: UIMessage[] } - | { type: "single"; message: UIMessage }; +export type DisplayUnit = TurnUnit; /** True when this unit index is the last assistant text slice before the next user message (or end of thread). */ export function isFinalAssistantSliceBeforeNextUser( @@ -28,170 +24,17 @@ export function isFinalAssistantSliceBeforeNextUser( index: number, ): boolean { 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++) { 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 true; } export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] { - const out: DisplayUnit[] = []; - 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; + return normalizeActivityTimeline(messages); } export function assistantCopyFlags(units: DisplayUnit[]): boolean[] { @@ -199,11 +42,11 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] { let hasLaterUnitBeforeUser = false; for (let i = units.length - 1; i >= 0; i -= 1) { const unit = units[i]; - if (unit.type === "single" && unit.message.role === "user") { + if (unit.type === "message" && unit.message.role === "user") { hasLaterUnitBeforeUser = false; continue; } - if (unit.type === "single" && unit.message.role === "assistant") { + if (unit.type === "message" && unit.message.role === "assistant") { flags[i] = !hasLaterUnitBeforeUser; } hasLaterUnitBeforeUser = true; @@ -222,8 +65,8 @@ export function ThreadMessages({ const { t } = useTranslation(); const units = useMemo(() => buildDisplayUnits(messages), [messages]); const copyFlags = useMemo(() => assistantCopyFlags(units), [units]); - const liveActivityClusterIndex = useMemo( - () => isStreaming ? currentActivityClusterIndex(units) : -1, + const liveActivityClusterIndices = useMemo( + () => isStreaming ? currentActivityClusterIndices(units) : new Set(), [isStreaming, units], ); @@ -251,20 +94,18 @@ export function ThreadMessages({ : ""; const next = units[index + 1]; const hasBodyBelow = - unit.type === "cluster" - && next?.type === "single" + unit.type === "activity" + && next?.type === "message" && next.message.role === "assistant"; - const turnLatencyMs = - unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined; return (
    - {unit.type === "cluster" ? ( + {unit.type === "activity" ? ( @@ -287,49 +128,45 @@ export function ThreadMessages({ ); } -function activityClusterTurnLatencyMs( - messages: UIMessage[], - next: DisplayUnit | undefined, -): 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 { +function currentActivityClusterIndices(units: DisplayUnit[]): Set { + const indices = new Set(); + let markedCurrentActivity = false; for (let i = units.length - 1; i >= 0; i -= 1) { 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 === "user") break; - return -1; } - return -1; + return indices; +} + +function activityHasLiveFileEdit(unit: Extract): 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 { - if (unit.type === "cluster") { + if (unit.type === "activity") { 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; } function marginAfterPrevUnit(prev: DisplayUnit): string { - if (prev.type === "cluster") { + if (prev.type === "activity") { return "mt-4"; } const p = prev.message; diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index c2a501207..689a93d3f 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -167,7 +167,6 @@ export function ThreadShell({ const [cliApps, setCliApps] = useState([]); const [mcpPresets, setMcpPresets] = useState([]); const [settings, setSettings] = useState(settingsSnapshot); - const [heroImageMode, setHeroImageMode] = useState(false); const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey); const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0); const pendingFirstRef = useRef(null); @@ -211,8 +210,6 @@ export function ThreadShell({ () => toModelBadgeInfo(modelName, settings), [modelName, settings], ); - const imageGenerationEnabled = settings?.image_generation.enabled === true; - useEffect(() => { if (showHeroComposer && !wasShowingHeroComposerRef.current) { setHeroGreetingKey(randomHeroGreetingKey()); @@ -508,9 +505,6 @@ export function ThreadShell({ slashCommands={slashCommands} cliApps={cliApps} mcpPresets={mcpPresets} - imageGenerationEnabled={imageGenerationEnabled} - imageMode={showHeroComposer ? heroImageMode : undefined} - onImageModeChange={showHeroComposer ? setHeroImageMode : undefined} onStop={stop} runStartedAt={runStartedAt} goalState={goalState} @@ -520,6 +514,7 @@ export function ThreadShell({ workspaceScopeDisabled={workspaceScopeDisabled} workspaceError={workspaceError} onWorkspaceScopeChange={onWorkspaceScopeChange} + pendingQueueKey={chatId} /> ) : ( + {evidence.slice(0, 4).map((item) => ( + + ))} +
    + ); +} diff --git a/webui/src/components/thread/activity/ActivityGroup.tsx b/webui/src/components/thread/activity/ActivityGroup.tsx new file mode 100644 index 000000000..99fbc9a99 --- /dev/null +++ b/webui/src/components/thread/activity/ActivityGroup.tsx @@ -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 ( +
    +
    + {Icon ? : null} + {title} +
    +
    {children}
    +
    + ); +} diff --git a/webui/src/components/thread/activity/ActivityStep.tsx b/webui/src/components/thread/activity/ActivityStep.tsx new file mode 100644 index 000000000..bd69d0f9d --- /dev/null +++ b/webui/src/components/thread/activity/ActivityStep.tsx @@ -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 ( + + + {marker ?? ( + + {Icon ? : null} + + )} + +
    +
    + + {label} + + {detail ? ( + + {detail} + + ) : null} + {aside ? {aside} : null} +
    + {children ?
    {children}
    : null} +
    +
    + ); +} diff --git a/webui/src/components/thread/activity/DiffPair.tsx b/webui/src/components/thread/activity/DiffPair.tsx new file mode 100644 index 000000000..9ed58b5f4 --- /dev/null +++ b/webui/src/components/thread/activity/DiffPair.tsx @@ -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 ( + + + + + ); +} + +function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) { + const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; + return ( + + + {sign} + + + {sign}{safeValue} + + ); +} + +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 ; +} + +function RollingNumber({ value }: { value: number }) { + const digits = String(value).split(""); + return ( + + {digits.map((digit, index) => ( + + ))} + + ); +} + +function RollingDigit({ digit }: { digit: number }) { + const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0; + return ( + + 0 + + {Array.from({ length: 10 }, (_, n) => ( + + {n} + + ))} + + + ); +} diff --git a/webui/src/components/thread/activity/FileEditRow.tsx b/webui/src/components/thread/activity/FileEditRow.tsx new file mode 100644 index 000000000..defc18acb --- /dev/null +++ b/webui/src/components/thread/activity/FileEditRow.tsx @@ -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 ( +
      + {edits.map((edit) => ( + + ))} +
    + ); +} + +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 ? ( + + ) : editing ? ( + + ) : ( + + ); + return ( + + {statusIcon} + + )} + 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…" }) + : ( + + )} + detail={failed ? ( + + {failureDetail} + + ) : null} + aside={hasCountedDiff ? : null} + /> + ); +} + +export function hasVisibleDiffStats(edit: Pick): 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); +} diff --git a/webui/src/components/thread/activity/ReasoningRow.tsx b/webui/src/components/thread/activity/ReasoningRow.tsx new file mode 100644 index 000000000..388375000 --- /dev/null +++ b/webui/src/components/thread/activity/ReasoningRow.tsx @@ -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 ( + } + active={streaming} + tone={streaming ? "active" : "success"} + label={streaming + ? t("message.reasoningStreaming", { defaultValue: "Thinking…" }) + : t("message.reasoning", { defaultValue: "Thinking" })} + > + {text.trim() ? ( + + {text} + + ) : null} + + ); +} + +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 ( + + ); + } + return ( + + + + ); +} diff --git a/webui/src/components/ui/dropdown-menu.tsx b/webui/src/components/ui/dropdown-menu.tsx index f9623c88c..7bc738a15 100644 --- a/webui/src/components/ui/dropdown-menu.tsx +++ b/webui/src/components/ui/dropdown-menu.tsx @@ -12,7 +12,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 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 = "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]"; diff --git a/webui/src/globals.css b/webui/src/globals.css index 800eb479a..27d4b3e38 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -110,6 +110,14 @@ --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 tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better 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). */ @keyframes goal-shell-glow-breathe { 0%, diff --git a/webui/src/hooks/useAttachedImages.ts b/webui/src/hooks/useAttachedImages.ts index 6aa679f74..51fc84b1b 100644 --- a/webui/src/hooks/useAttachedImages.ts +++ b/webui/src/hooks/useAttachedImages.ts @@ -27,6 +27,11 @@ export interface AttachedImage { error?: AttachmentError; } +export interface RestoredReadyImage { + dataUrl: string; + name?: string; +} + /** Machine-readable rejection reasons surfaced as inline chip errors. * * Callers localize these via the ``composer.imageRejected.*`` i18n table. */ @@ -48,6 +53,27 @@ const ACCEPTED_MIMES: ReadonlySet = new Set([ "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 { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return (crypto as Crypto).randomUUID(); @@ -84,6 +110,10 @@ export interface UseAttachedImagesApi { * successful submit — the optimistic bubble holds onto an independent * ``data:`` URL so tearing down blob previews here is safe. */ 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. */ encoding: boolean; /** ``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 // under StrictMode double-invoke because revoked blob URLs are only // 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 full = images.length >= MAX_IMAGES_PER_MESSAGE; - return { images, enqueue, remove, clear, encoding, full }; + return { images, enqueue, remove, clear, restoreReadyImages, encoding, full }; } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 0a6a37d98..bb0820dbf 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -102,9 +102,19 @@ "addConfiguration": "Add configuration", "newConfiguration": "New model configuration", "newConfigurationHelp": "Save a provider and model as a one-click option.", - "configurationName": "Name", + "configurationName": "Configuration name", "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": { "theme": "Theme", @@ -117,7 +127,7 @@ "gateway": "Gateway", "restartState": "Restart state", "pendingChanges": "Pending changes", - "currentModel": "Current model", + "currentModel": "Current configuration", "selectedPreset": "Selected preset", "presetModel": "Preset model", "density": "Density", @@ -155,7 +165,7 @@ "provider": "Select the provider that should serve new model requests.", "model": "Set the default model name used by nanobot.", "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.", "selectedModelValue": "Set by the selected model.", "selectedPreset": "Named presets are read-only here; edit them in config.json.", @@ -555,6 +565,13 @@ "goalStateCloseAria": "Close goal", "send": "Send message", "stop": "Stop response", + "queued": { + "label": "Queued guidance", + "guide": "Guide", + "delete": "Delete guidance", + "edit": "Edit guidance", + "drag": "Drag to reorder" + }, "attachImage": "Attach image", "imageMode": { "label": "Image Generation", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 361d7d719..4f742daa2 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -131,7 +131,7 @@ "workspacePath": "Workspace predeterminado", "localServiceAccess": "Local services", "webuiDefaultAccess": "Default access", - "currentModel": "Modelo actual", + "currentModel": "Configuración actual", "brandLogos": "Logotipos de marca", "cliAppsCatalog": "Catálogo de apps CLI", "cliAppsFilter": "Filtro de apps CLI", @@ -167,7 +167,7 @@ "localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "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.", - "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.", "selectedModelValue": "Lo define el modelo seleccionado.", "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", "newConfiguration": "Nueva configuración de modelo", "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.", - "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": { "select": "Seleccionar zona horaria", @@ -555,6 +565,13 @@ "goalStateCloseAria": "Cerrar objetivo", "send": "Enviar mensaje", "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", "imageMode": { "label": "Generar imagen", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 029cf0d2b..ce211b254 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -131,7 +131,7 @@ "workspacePath": "Espace de travail par défaut", "localServiceAccess": "Local services", "webuiDefaultAccess": "Default access", - "currentModel": "Modèle actuel", + "currentModel": "Configuration actuelle", "brandLogos": "Logos de marque", "cliAppsCatalog": "Catalogue d'apps CLI", "cliAppsFilter": "Filtre des apps CLI", @@ -167,7 +167,7 @@ "localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "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.", - "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é.", "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.", @@ -295,9 +295,19 @@ "addConfiguration": "Ajouter une configuration", "newConfiguration": "Nouvelle configuration de modèle", "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.", - "configurationNamePlaceholder": "Rédaction rapide" + "configurationNamePlaceholder": "Rédaction rapide", + "searchModels": "Rechercher ou saisir l’ID 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": { "select": "Sélectionner un fuseau horaire", @@ -555,6 +565,13 @@ "goalStateCloseAria": "Fermer l’objectif", "send": "Envoyer le message", "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", "imageMode": { "label": "Génération d’image", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 18ee9059e..132f99436 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -131,7 +131,7 @@ "workspacePath": "Workspace default", "localServiceAccess": "Local services", "webuiDefaultAccess": "Default access", - "currentModel": "Model saat ini", + "currentModel": "Konfigurasi saat ini", "brandLogos": "Logo merek", "cliAppsCatalog": "Katalog aplikasi CLI", "cliAppsFilter": "Filter aplikasi CLI", @@ -167,7 +167,7 @@ "localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "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.", - "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.", "selectedModelValue": "Ditentukan oleh model yang dipilih.", "brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.", @@ -295,9 +295,19 @@ "addConfiguration": "Tambah konfigurasi", "newConfiguration": "Konfigurasi model baru", "newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.", - "configurationName": "Nama", + "configurationName": "Nama konfigurasi", "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": { "select": "Pilih zona waktu", @@ -555,6 +565,13 @@ "goalStateCloseAria": "Tutup tujuan", "send": "Kirim pesan", "stop": "Hentikan respons", + "queued": { + "label": "Panduan antrean", + "guide": "Pandu", + "delete": "Hapus panduan", + "edit": "Edit panduan", + "drag": "Seret untuk mengurutkan" + }, "attachImage": "Lampirkan gambar", "imageMode": { "label": "Buat gambar", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 1cb307da2..d029ca7c9 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -131,7 +131,7 @@ "workspacePath": "デフォルトワークスペース", "localServiceAccess": "Local services", "webuiDefaultAccess": "Default access", - "currentModel": "現在のモデル", + "currentModel": "現在の設定", "brandLogos": "ブランドロゴ", "cliAppsCatalog": "CLI アプリカタログ", "cliAppsFilter": "CLI アプリフィルター", @@ -167,7 +167,7 @@ "localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "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.", - "currentModel": "今後の返信で nanobot が使用するモデルを選択します。", + "currentModel": "今後の返信で nanobot が使用するモデル設定を選択します。", "selectedModelProvider": "選択したモデルによって設定されます。", "selectedModelValue": "選択したモデルによって設定されます。", "brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。", @@ -295,9 +295,19 @@ "addConfiguration": "設定を追加", "newConfiguration": "新しいモデル設定", "newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。", - "configurationName": "名前", + "configurationName": "設定名", "configurationNameHelp": "保存済みのモデル設定の名前を変更します。", - "configurationNamePlaceholder": "高速ライティング" + "configurationNamePlaceholder": "高速ライティング", + "searchModels": "検索またはモデル ID を入力", + "useCustomModel": "使用", + "loadingModels": "モデルを読み込み中...", + "searchCatalog": "プロバイダーのカタログを検索してモデルを選択します。", + "modelsAvailable": "件利用可能", + "noModelResults": "一致するモデルはありません。", + "loadFailed": "モデル一覧を利用できません。", + "unsupportedModelList": "モデル ID を手動で入力してください。", + "providerNotConfigured": "モデルを読み込む前にこのプロバイダーを設定してください。", + "autoProviderCustomOnly": "自動プロバイダーモードではカスタムモデル ID を使用します。" }, "timezone": { "select": "タイムゾーンを選択", @@ -555,6 +565,13 @@ "goalStateCloseAria": "目標を閉じる", "send": "メッセージを送信", "stop": "応答を停止", + "queued": { + "label": "保留中のガイド", + "guide": "ガイド", + "delete": "ガイドを削除", + "edit": "ガイドを編集", + "drag": "ドラッグして並べ替え" + }, "attachImage": "画像を添付", "imageMode": { "label": "画像生成", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index e3be15b0c..5b1569a29 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -131,7 +131,7 @@ "workspacePath": "기본 작업공간", "localServiceAccess": "Local services", "webuiDefaultAccess": "Default access", - "currentModel": "현재 모델", + "currentModel": "현재 구성", "brandLogos": "브랜드 로고", "cliAppsCatalog": "CLI 앱 카탈로그", "cliAppsFilter": "CLI 앱 필터", @@ -167,7 +167,7 @@ "localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "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.", - "currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.", + "currentModel": "nanobot이 새 답변에 사용할 모델 구성을 선택합니다.", "selectedModelProvider": "선택한 모델에서 설정됩니다.", "selectedModelValue": "선택한 모델에서 설정됩니다.", "brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.", @@ -295,9 +295,19 @@ "addConfiguration": "구성 추가", "newConfiguration": "새 모델 구성", "newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.", - "configurationName": "이름", + "configurationName": "구성 이름", "configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.", - "configurationNamePlaceholder": "빠른 글쓰기" + "configurationNamePlaceholder": "빠른 글쓰기", + "searchModels": "검색하거나 모델 ID 입력", + "useCustomModel": "사용", + "loadingModels": "모델을 불러오는 중...", + "searchCatalog": "제공자 카탈로그를 검색해 모델을 선택하세요.", + "modelsAvailable": "개 사용 가능", + "noModelResults": "일치하는 모델이 없습니다.", + "loadFailed": "모델 목록을 사용할 수 없습니다.", + "unsupportedModelList": "모델 ID를 직접 입력하세요.", + "providerNotConfigured": "모델을 불러오기 전에 이 제공자를 설정하세요.", + "autoProviderCustomOnly": "자동 제공자 모드는 사용자 지정 모델 ID를 사용합니다." }, "timezone": { "select": "시간대 선택", @@ -555,6 +565,13 @@ "goalStateCloseAria": "목표 닫기", "send": "메시지 보내기", "stop": "응답 중지", + "queued": { + "label": "대기 중인 안내", + "guide": "안내", + "delete": "안내 삭제", + "edit": "안내 수정", + "drag": "드래그하여 순서 변경" + }, "attachImage": "이미지 첨부", "imageMode": { "label": "이미지 생성", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index ac65b7d51..f4892278f 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -131,7 +131,7 @@ "workspacePath": "Workspace mặc định", "localServiceAccess": "Local services", "webuiDefaultAccess": "Default access", - "currentModel": "Mô hình hiện tại", + "currentModel": "Cấu hình hiện tại", "brandLogos": "Logo thương hiệu", "cliAppsCatalog": "Danh mụ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.", "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.", - "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.", "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.", @@ -295,9 +295,19 @@ "addConfiguration": "Thêm cấu hình", "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.", - "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.", - "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": { "select": "Chọn múi giờ", @@ -555,6 +565,13 @@ "goalStateCloseAria": "Đóng mục tiêu", "send": "Gửi tin nhắn", "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", "imageMode": { "label": "Tạo ảnh", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 2f9bf06cb..b16ea423f 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -102,9 +102,19 @@ "addConfiguration": "添加配置", "newConfiguration": "新建模型配置", "newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。", - "configurationName": "名称", + "configurationName": "配置名称", "configurationNameHelp": "重命名这个已保存的模型配置。", - "configurationNamePlaceholder": "快速写作" + "configurationNamePlaceholder": "快速写作", + "searchModels": "搜索或输入模型 ID", + "useCustomModel": "使用", + "loadingModels": "正在加载模型...", + "searchCatalog": "搜索服务商目录来选择模型。", + "modelsAvailable": "个可用", + "noModelResults": "没有匹配的模型。", + "loadFailed": "模型列表暂不可用。", + "unsupportedModelList": "手动输入模型 ID。", + "providerNotConfigured": "先配置这个服务商再加载模型。", + "autoProviderCustomOnly": "自动服务商模式使用自定义模型 ID。" }, "rows": { "theme": "主题", @@ -117,7 +127,7 @@ "gateway": "网关", "restartState": "重启状态", "pendingChanges": "待处理更改", - "currentModel": "当前模型", + "currentModel": "当前配置", "selectedPreset": "选中的预设", "presetModel": "预设模型", "density": "密度", @@ -155,7 +165,7 @@ "provider": "选择新模型请求使用的服务商。", "model": "设置 nanobot 默认使用的模型名称。", "configPath": "当前网关正在使用的配置文件。", - "currentModel": "选择 nanobot 接下来回复时使用的模型。", + "currentModel": "选择 nanobot 接下来回复时使用的模型配置。", "selectedModelProvider": "由当前模型决定。", "selectedModelValue": "由当前模型决定。", "selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。", @@ -554,6 +564,13 @@ "goalStateSheetTitle": "目标", "send": "发送消息", "stop": "停止响应", + "queued": { + "label": "待引导提示", + "guide": "引导", + "delete": "删除引导", + "edit": "编辑引导", + "drag": "拖动排序" + }, "attachImage": "添加图片", "imageMode": { "label": "图片生成", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 2e90fc108..e567363a6 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -131,7 +131,7 @@ "workspacePath": "預設工作區", "localServiceAccess": "Local services", "webuiDefaultAccess": "Default access", - "currentModel": "目前模型", + "currentModel": "目前設定", "brandLogos": "品牌標誌", "cliAppsCatalog": "CLI 應用目錄", "cliAppsFilter": "CLI 應用篩選", @@ -167,7 +167,7 @@ "localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。", "webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", - "currentModel": "選擇 nanobot 接下來回覆時使用的模型。", + "currentModel": "選擇 nanobot 接下來回覆時使用的模型設定。", "selectedModelProvider": "由目前模型決定。", "selectedModelValue": "由目前模型決定。", "brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。", @@ -295,9 +295,19 @@ "addConfiguration": "新增設定", "newConfiguration": "新增模型設定", "newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。", - "configurationName": "名稱", + "configurationName": "設定名稱", "configurationNameHelp": "重新命名這個已儲存的模型配置。", - "configurationNamePlaceholder": "快速寫作" + "configurationNamePlaceholder": "快速寫作", + "searchModels": "搜尋或輸入模型 ID", + "useCustomModel": "使用", + "loadingModels": "正在載入模型...", + "searchCatalog": "搜尋服務商目錄來選擇模型。", + "modelsAvailable": "個可用", + "noModelResults": "沒有符合的模型。", + "loadFailed": "模型列表暫不可用。", + "unsupportedModelList": "手動輸入模型 ID。", + "providerNotConfigured": "先設定這個服務商再載入模型。", + "autoProviderCustomOnly": "自動服務商模式使用自訂模型 ID。" }, "timezone": { "select": "選擇時區", @@ -555,6 +565,13 @@ "goalStateCloseAria": "關閉目標", "send": "送出訊息", "stop": "停止回覆", + "queued": { + "label": "待引導提示", + "guide": "引導", + "delete": "刪除引導", + "edit": "編輯引導", + "drag": "拖曳排序" + }, "attachImage": "附加圖片", "imageMode": { "label": "圖片生成", diff --git a/webui/src/lib/activity-timeline.ts b/webui/src/lib/activity-timeline.ts new file mode 100644 index 000000000..b01b2578a --- /dev/null +++ b/webui/src/lib/activity-timeline.ts @@ -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; + 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, 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, 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; +} diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 14d59d4aa..cfea95dc0 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -6,6 +6,7 @@ import type { ModelConfigurationCreate, ModelConfigurationUpdate, NetworkSafetySettingsUpdate, + ProviderModelsPayload, ProviderSettingsUpdate, SettingsPayload, SettingsUpdate, @@ -174,6 +175,19 @@ export async function fetchMcpPresets( return request(`${base}/api/settings/mcp-presets`, token); } +export async function fetchProviderModels( + token: string, + provider: string, + base: string = "", +): Promise { + const query = new URLSearchParams(); + query.set("provider", provider); + return request( + `${base}/api/settings/provider-models?${query}`, + token, + ); +} + export async function runMcpPresetAction( token: string, action: "enable" | "remove" | "test", diff --git a/webui/src/lib/media.ts b/webui/src/lib/media.ts index 399bc33a5..a329e30cf 100644 --- a/webui/src/lib/media.ts +++ b/webui/src/lib/media.ts @@ -8,6 +8,7 @@ const IMAGE_EXTENSIONS = new Set([ ".webp", ".bmp", ".ico", + ".svg", ".tif", ".tiff", ]); @@ -34,26 +35,30 @@ function extensionOf(value?: string): string { 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 ?? ""; if (url.startsWith("data:image/")) return "image"; if (url.startsWith("data:video/")) return "video"; const ext = extensionOf(media.name) || extensionOf(url); + if (!ext) return null; if (IMAGE_EXTENSIONS.has(ext)) return "image"; if (VIDEO_EXTENSIONS.has(ext)) return "video"; return "file"; } +export function inferMediaKind(media: { url?: string; name?: string }): UIMediaKind { + return explicitMediaKind(media) ?? "file"; +} + export function toMediaAttachment(media: { url?: string; name?: string; kind?: UIMediaKind; }): UIMediaAttachment { return { - kind: media.kind ?? inferMediaKind(media), + kind: explicitMediaKind(media) ?? media.kind ?? "file", url: media.url, name: media.name, }; } - diff --git a/webui/src/lib/provider-brand.ts b/webui/src/lib/provider-brand.ts index 749dcc62e..93571238b 100644 --- a/webui/src/lib/provider-brand.ts +++ b/webui/src/lib/provider-brand.ts @@ -146,7 +146,9 @@ const PROVIDER_BRANDS: Record = { searxng: brand("searxng.org", "#3050FF", "SX"), siliconflow: brand("siliconflow.cn", "#111827", "SF"), 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"), volcengine: brand("volcengine.com", "#1664FF", "VE"), vllm: brand("vllm.ai", "#2563EB", "VL"), diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 23462b043..970808eac 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -221,6 +221,29 @@ export interface RuntimeCapabilities { 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 { surface?: RuntimeSurface; runtime_surface?: RuntimeSurface; diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index e34c99f4d..8de2e260e 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -736,7 +736,7 @@ describe("AgentActivityCluster", () => { 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.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument(); expect(screen.queryByText(/for id in/)).not.toBeInTheDocument(); @@ -936,4 +936,67 @@ describe("AgentActivityCluster", () => { restoreMotion(); } }); + + it("renders tool event embeds as inline activity evidence", () => { + render( + , + ); + + 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( + , + ); + + expect(screen.getByText("Vision")).toBeInTheDocument(); + expect(screen.getByTestId("activity-evidence-preview")).toBeInTheDocument(); + expect(screen.getByText("missing.png")).toBeInTheDocument(); + }); }); diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index e22e672ef..0ab16274a 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -5,6 +5,7 @@ import { deleteSession, fetchCliApps, fetchMcpPresets, + fetchProviderModels, fetchSidebarState, fetchWebuiThread, 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 () => { await loginProviderOAuth("tok", "openai_codex"); expect(fetch).toHaveBeenCalledWith( diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 21671c7c4..4e378fc44 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -540,6 +540,58 @@ describe("App layout", () => { 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(); + + 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 () => { mockSessions = [ { @@ -590,7 +642,22 @@ describe("App layout", () => { vi.stubGlobal( "fetch", 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 { ok: true, status: 200, @@ -822,9 +889,9 @@ describe("App layout", () => { expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument(); fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" })); expect(screen.queryByText("AI")).not.toBeInTheDocument(); - expect(screen.getByText("Current model")).toBeInTheDocument(); + expect(screen.getByText("Current configuration")).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" })); const modelDialog = screen.getByRole("dialog", { name: "New model configuration" }); 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: "Save" })).toBeEnabled(); 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/ })); expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0); 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( "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("Ant Ling")).toBeInTheDocument(); expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument(); expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument(); 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.change(screen.getByPlaceholderText("Leave blank to keep the current key"), { target: { value: "unsaved-openai-key" }, }); - fireEvent.click(screen.getByText("OpenRouter")); - fireEvent.click(screen.getByText("OpenAI")); + clickProviderRow("OpenRouter"); + clickProviderRow("OpenAI"); expect(screen.getByText("open••••-key")).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(); - fireEvent.click(screen.getByText("Atomic Chat")); + clickProviderRow("Atomic Chat"); expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled(); diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index 96153de30..eb3ae5c8b 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -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", () => { diff --git a/webui/src/tests/code-block.test.tsx b/webui/src/tests/code-block.test.tsx index b76aeb0d8..7503960ee 100644 --- a/webui/src/tests/code-block.test.tsx +++ b/webui/src/tests/code-block.test.tsx @@ -45,6 +45,7 @@ describe("CodeBlock", () => { expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument(); expect(screen.getByText("const value = 1;")).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 () => { diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx index b3f7b8f28..e3ff59727 100644 --- a/webui/src/tests/markdown-text-renderer.test.tsx +++ b/webui/src/tests/markdown-text-renderer.test.tsx @@ -4,6 +4,30 @@ import { describe, expect, it } from "vitest"; import MarkdownTextRenderer from "@/components/MarkdownTextRenderer"; describe("MarkdownTextRenderer", () => { + it("does not wrap complete fenced code blocks in an extra pre", () => { + const { container } = render( + + {"当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace\n```"} + , + ); + + 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( + + {"当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace"} + , + ); + + 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", () => { render(![Diagram](/api/media/sig/payload)); @@ -13,7 +37,6 @@ describe("MarkdownTextRenderer", () => { "href", "/api/media/sig/payload", ); - expect(screen.getByText("Diagram")).toBeInTheDocument(); }); it("renders markdown videos as inline players", () => { @@ -25,4 +48,101 @@ describe("MarkdownTextRenderer", () => { expect(video).toHaveAttribute("controls"); expect(screen.queryByRole("img", { name: "nanobot-intro.mp4" })).not.toBeInTheDocument(); }); + + it("renders markdown links with file-looking names as file attachments", () => { + render(![index.html](/api/media/sig/html)); + + 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(![Diagram](/api/media/sig/payload)); + + 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( + + {"高亮文本\n\n上标:x2\n下标:H2O"} + , + ); + + 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( + + {"\n\nbad"} + , + ); + + expect(container.querySelector("script")).toBeNull(); + expect(container.querySelector("mark")).toBeNull(); + expect(container).toHaveTextContent(""); + expect(container).toHaveTextContent("bad"); + }); + + it("renders safe details blocks", () => { + const { container } = render( + + { + "
    点击展开更多内容\n\n这里是被折叠的内容。\n\n- 可以放列表\n\n
    " + } +
    , + ); + + expect(container.querySelector("details")).toBeInTheDocument(); + expect(container.querySelector("summary")).toHaveTextContent("点击展开更多内容"); + expect(screen.getByText("这里是被折叠的内容。")).toBeInTheDocument(); + expect(screen.getByText("可以放列表")).toBeInTheDocument(); + expect(container).not.toHaveTextContent(""); + }); + + it("renders task list checkboxes as quiet status marks", () => { + const { container } = render( + + {"- [x] 写 Markdown 示例\n- [x] 加点 emoji\n- [ ] 测试渲染效果"} + , + ); + + 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( + + { + "VBeats mentions $24 million, while Globe states a total of $130.6 million since founding." + } + , + ); + + 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( + {"$$x^2 + y^2 = z^2$$"}, + ); + + expect(container.querySelector(".katex")).toBeInTheDocument(); + }); }); diff --git a/webui/src/tests/markdown-text.test.tsx b/webui/src/tests/markdown-text.test.tsx index c818f2f5a..fb1c1851f 100644 --- a/webui/src/tests/markdown-text.test.tsx +++ b/webui/src/tests/markdown-text.test.tsx @@ -42,7 +42,7 @@ describe("MarkdownText", () => { expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello"); expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( "data-highlight-code", - "false", + "true", ); expect(rendererSpy).toHaveBeenCalledTimes(1); @@ -79,4 +79,30 @@ describe("MarkdownText", () => { 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( + {largeCode}, + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( + "data-highlight-code", + "false", + ); + + rerender({largeCode}); + + expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( + "data-highlight-code", + "true", + ); + }); }); diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index 079f48b10..1caaf0194 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -236,7 +236,10 @@ describe("MessageBubble", () => { const video = screen.getByLabelText(/video attachment/i); expect(video.tagName).toBe("VIDEO"); expect(video).toHaveAttribute("src", "/api/media/sig/payload"); + expect(video).toHaveAttribute("preload", "auto"); 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", () => { @@ -366,4 +369,47 @@ describe("MessageBubble", () => { expect(imageButton).not.toHaveAttribute("title"); 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(); + + 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(); + + 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(); + }); }); diff --git a/webui/src/tests/provider-brand.test.ts b/webui/src/tests/provider-brand.test.ts index 872c3bf96..67b4c20d0 100644 --- a/webui/src/tests/provider-brand.test.ts +++ b/webui/src/tests/provider-brand.test.ts @@ -35,8 +35,9 @@ describe("provider brand logos", () => { 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("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("mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg"); }); diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index 6b0dfd37a..c149235aa 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -245,6 +245,104 @@ describe("SettingsView Apps catalog", () => { 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 () => { const payload = settingsPayload(); const fetchMock = vi.fn(async (input: RequestInfo | URL) => { diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index c177ff864..45f70387e 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -4,6 +4,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; 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[] = [ { command: "/stop", @@ -113,6 +122,17 @@ const MCP_PRESETS: McpPresetInfo[] = [ ]; 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(() => { vi.restoreAllMocks(); Reflect.deleteProperty(window, "nanobotHost"); @@ -160,6 +180,9 @@ describe("ThreadComposer", () => { const input = screen.getByPlaceholderText("Ask anything..."); expect(input).toBeInTheDocument(); 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]"); }); @@ -361,6 +384,8 @@ describe("ThreadComposer", () => { const status = screen.getByRole("status"); expect(status).toHaveTextContent(/Running/); expect(status).toHaveTextContent(/2:05/); + expect(status.parentElement).toHaveClass("composer-status-strip"); + expect(status.parentElement).toHaveAttribute("data-state", "enter"); vi.useRealTimers(); }); @@ -802,7 +827,7 @@ describe("ThreadComposer", () => { 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(); render( { />, ); - fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" })); - expect(screen.getByPlaceholderText("Describe or edit an image…")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Image aspect ratio" })).not.toBeInTheDocument(); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "Draw a friendly robot" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith( - "Draw a friendly robot", - undefined, - { imageGeneration: { enabled: true, aspect_ratio: null } }, - ); + expect(onSend).toHaveBeenCalledWith("Draw a friendly robot", undefined, undefined); }); it("shows a stop button while streaming", () => { @@ -842,75 +863,407 @@ describe("ThreadComposer", () => { 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(); render( , ); - 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"); - fireEvent.change(input, { target: { value: "Draw a banner" } }); - fireEvent.click(screen.getByRole("button", { name: "Send message" })); + fireEvent.change(input, { target: { value: "keep the UI minimal" } }); + fireEvent.keyDown(input, { key: "Enter" }); - expect(onSend).toHaveBeenCalledWith( - "Draw a banner", - undefined, - { imageGeneration: { enabled: true, aspect_ratio: "16:9" } }, - ); + expect(onSend).not.toHaveBeenCalled(); + expect(input).toHaveValue(""); + expect(screen.getByText("keep the UI minimal")).toBeInTheDocument(); + + 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", () => { - render( + it("keeps queued guidance attached to the composer and sends it one item at a time", async () => { + const onSend = vi.fn(); + const { rerender } = render( , ); - 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( - "top-full", + const queue = screen.getByRole("group", { name: "Queued guidance" }); + 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( + , ); + + 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( + , + ); + rerender( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + await waitFor(() => { + expect(onSend).toHaveBeenCalledWith("second follow-up"); + }); + expect(onSend).toHaveBeenCalledTimes(1); + + rerender( + , + ); + rerender( + , + ); + + 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( + , + ); + + const input = screen.getByLabelText("Message input"); + const fileInput = container.querySelector('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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + await waitFor(() => { + expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument(); + }); + + unmount(); + const remount = render( + , + ); + + 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( -
    - - -
    , + , ); - - const aspectButton = screen.getByRole("button", { name: "Image aspect ratio" }); - 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(); + await waitFor(() => { + expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument(); + }); }); + }); diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index beb3abec3..9319a0951 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -9,7 +9,7 @@ import { import type { UIMessage } from "@/lib/types"; 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[] = [ { id: "r1", @@ -55,7 +55,7 @@ describe("ThreadMessages", () => { 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[] = [ { id: "r1", @@ -95,14 +95,11 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); - expect(units).toHaveLength(2); - expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ - "r1", - "t1", - ]); - expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([ - "r2", - ]); + expect(units).toHaveLength(3); + expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]); + expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); + 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"]); }); it("does not split ordinary tool activity just because segment ids changed", () => { @@ -146,7 +143,7 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); 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", "t1", "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[] = [ { id: "r1", @@ -197,12 +194,10 @@ describe("ThreadMessages", () => { render(); - expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/editing foo\.txt/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[] = [ { id: "r1", @@ -234,21 +229,21 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); expect(units).toHaveLength(2); - expect(units[0]).toMatchObject({ type: "cluster" }); - expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ + expect(units[0]).toMatchObject({ type: "activity" }); + expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ "r1", "t1", "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({ - type: "single", + type: "message", message: { id: "a1", content: "final answer", }, }); - if (units[1].type === "single") { + if (units[1].type === "message") { expect(units[1].message).not.toHaveProperty("reasoning"); } @@ -290,12 +285,12 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); 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", "t1", ]); expect(units[1]).toMatchObject({ - type: "single", + type: "message", message: { id: "a1", content: "partial answer", @@ -340,12 +335,12 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); 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", "t1", ]); expect(units[1]).toMatchObject({ - type: "single", + type: "message", message: { id: "a1", content: "Hong Kong is hot today.", @@ -391,12 +386,12 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); 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", "tool", ]); expect(units[1]).toMatchObject({ - type: "single", + type: "message", message: { id: "final", 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[] = [ { id: "r1", @@ -496,7 +491,7 @@ describe("ThreadMessages", () => { const flags = assistantCopyFlags(units); const assistantFlags = units .map((unit, index) => - unit.type === "single" && unit.message.role === "assistant" + unit.type === "message" && unit.message.role === "assistant" ? [unit.message.id, flags[index]] : null, ) diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 04793d607..5db59d9c6 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -270,7 +270,7 @@ describe("ThreadShell", () => { 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 disabledSettings = modelSettings("deepseek-v4-pro", "deepseek"); 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 () => { @@ -1092,8 +1092,6 @@ describe("ThreadShell", () => { expect(screen.queryByText("Design an app icon")).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("Write code")).not.toBeInTheDocument(); }); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 58800f579..e880bf90f 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -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", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), {