From 57d5276da1879be0988431a89c525d4c781c64ee Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Tue, 19 May 2026 22:42:38 +0800 Subject: [PATCH] feat(webui): upgrade settings and sidebar controls (#3906) * feat(settings): expand settings api payload * feat(webui): build app-style settings center * feat(webui): add centered chat search dialog * fix(webui): shorten chat search label * fix(webui): center dialog entrance animation * fix(webui): simplify chat search results * fix(webui): tighten mobile settings navigation * feat(webui): persist sidebar state * feat(webui): add sidebar organization controls * refactor(webui): organize backend helpers * refactor(webui): remove utils compatibility shims * refactor(session): move shared webui helpers out of webui package * feat(webui): add image generation settings * style(webui): refine settings overview layout * fix(webui): localize settings zh-CN copy * style(webui): add settings status indicators * feat(webui): show sidebar run indicators * fix(webui): persist sidebar run indicators * fix(webui): highlight settings pending status * fix(webui): align settings test with provider update * fix(utils): preserve legacy webui helper imports --- nanobot/agent/loop.py | 10 +- nanobot/channels/websocket.py | 341 +-- nanobot/providers/image_generation.py | 5 + .../webui_turns.py} | 2 +- nanobot/utils/__init__.py | 36 + nanobot/utils/artifacts.py | 2 - nanobot/webui/__init__.py | 2 + nanobot/webui/settings_api.py | 609 +++++ nanobot/webui/sidebar_state.py | 193 ++ .../thread_disk.py} | 4 +- .../transcript.py} | 0 tests/agent/test_loop_progress.py | 4 +- tests/agent/test_loop_save_turn.py | 4 +- tests/channels/test_websocket_channel.py | 184 +- tests/channels/test_websocket_http_routes.py | 52 +- tests/utils/test_webui_compat_imports.py | 14 + tests/utils/test_webui_sidebar_state.py | 73 + tests/utils/test_webui_thread_disk.py | 4 +- tests/utils/test_webui_transcript.py | 4 +- tests/utils/test_webui_turn_helpers.py | 2 +- webui/src/App.tsx | 276 +- webui/src/components/ChatList.tsx | 291 ++- webui/src/components/RenameChatDialog.tsx | 75 + webui/src/components/SessionSearchDialog.tsx | 213 ++ webui/src/components/Sidebar.tsx | 205 +- .../src/components/settings/SettingsView.tsx | 2295 +++++++++++++---- webui/src/components/ui/dialog.tsx | 41 +- webui/src/components/ui/dropdown-menu.tsx | 11 +- webui/src/hooks/useSidebarState.ts | 206 ++ webui/src/i18n/locales/en/common.json | 158 +- webui/src/i18n/locales/es/common.json | 131 +- webui/src/i18n/locales/fr/common.json | 131 +- webui/src/i18n/locales/id/common.json | 131 +- webui/src/i18n/locales/ja/common.json | 131 +- webui/src/i18n/locales/ko/common.json | 131 +- webui/src/i18n/locales/vi/common.json | 131 +- webui/src/i18n/locales/zh-CN/common.json | 184 +- webui/src/i18n/locales/zh-TW/common.json | 131 +- webui/src/lib/api.ts | 56 + webui/src/lib/nanobot-client.ts | 23 +- webui/src/lib/types.ts | 118 + webui/src/tests/api.test.ts | 79 +- webui/src/tests/app-layout.test.tsx | 573 +++- webui/src/tests/i18n.test.tsx | 21 +- webui/src/tests/nanobot-client.test.ts | 31 + 45 files changed, 6306 insertions(+), 1012 deletions(-) rename nanobot/{utils/webui_turn_helpers.py => session/webui_turns.py} (99%) create mode 100644 nanobot/webui/__init__.py create mode 100644 nanobot/webui/settings_api.py create mode 100644 nanobot/webui/sidebar_state.py rename nanobot/{utils/webui_thread_disk.py => webui/thread_disk.py} (90%) rename nanobot/{utils/webui_transcript.py => webui/transcript.py} (100%) create mode 100644 tests/utils/test_webui_compat_imports.py create mode 100644 tests/utils/test_webui_sidebar_state.py create mode 100644 webui/src/components/RenameChatDialog.tsx create mode 100644 webui/src/components/SessionSearchDialog.tsx create mode 100644 webui/src/hooks/useSidebarState.ts diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6f3926120..abc6449de 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -36,17 +36,17 @@ from nanobot.session.goal_state import ( runner_wall_llm_timeout_s, ) from nanobot.session.manager import Session, SessionManager +from nanobot.session.webui_turns import ( + WebuiTurnCoordinator, + build_bus_progress_callback, + mark_webui_session, +) from nanobot.utils.document import extract_documents from nanobot.utils.helpers import image_placeholder_text from nanobot.utils.helpers import truncate_text as truncate_text_fn from nanobot.utils.image_generation_intent import image_generation_prompt from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE -from nanobot.utils.webui_turn_helpers import ( - WebuiTurnCoordinator, - build_bus_progress_callback, - mark_webui_session, -) if TYPE_CHECKING: from nanobot.config.schema import ( diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 0202bd33d..11818ea90 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -37,15 +37,27 @@ from nanobot.command.builtin import builtin_command_palette from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base from nanobot.session.goal_state import goal_state_ws_blob +from nanobot.session.webui_turns import websocket_turn_wall_started_at from nanobot.utils.helpers import safe_filename from nanobot.utils.media_decode import ( FileSizeExceeded, save_base64_data_url, ) from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel -from nanobot.utils.webui_thread_disk import delete_webui_thread -from nanobot.utils.webui_transcript import append_transcript_object, build_webui_thread_response -from nanobot.utils.webui_turn_helpers import websocket_turn_wall_started_at +from nanobot.webui.settings_api import ( + WebUISettingsError, + settings_payload, + update_agent_settings, + update_image_generation_settings, + update_provider_settings, + update_web_search_settings, +) +from nanobot.webui.sidebar_state import ( + read_webui_sidebar_state, + write_webui_sidebar_state, +) +from nanobot.webui.thread_disk import delete_webui_thread +from nanobot.webui.transcript import append_transcript_object, build_webui_thread_response if TYPE_CHECKING: from nanobot.session.manager import SessionManager @@ -222,47 +234,6 @@ def _query_first(query: dict[str, list[str]], key: str) -> str | None: return values[0] if values else None -def _mask_secret_hint(secret: str | None) -> str | None: - if not secret: - return None - if len(secret) <= 8: - return "••••" - return f"{secret[:4]}••••{secret[-4:]}" - - -def _provider_requires_api_key(spec: Any) -> bool: - if spec.backend == "azure_openai": - return True - if spec.is_local or spec.is_direct: - return False - return True - - -def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool: - if _provider_requires_api_key(spec): - return bool(provider_config.api_key) - return bool( - provider_config.api_key - or provider_config.api_base - or getattr(provider_config, "region", None) - or getattr(provider_config, "profile", None) - ) - - -_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( - {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"}, - {"name": "brave", "label": "Brave Search", "credential": "api_key"}, - {"name": "tavily", "label": "Tavily", "credential": "api_key"}, - {"name": "searxng", "label": "SearXNG", "credential": "base_url"}, - {"name": "jina", "label": "Jina", "credential": "api_key"}, - {"name": "kagi", "label": "Kagi", "credential": "api_key"}, - {"name": "olostep", "label": "Olostep", "credential": "api_key"}, -) -_WEB_SEARCH_PROVIDER_BY_NAME = { - provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS -} - - def _parse_inbound_payload(raw: str) -> str | None: """Parse a client frame into text; return None for empty or unrecognized content.""" text = raw.strip() @@ -501,6 +472,7 @@ class WebSocketChannel(BaseChannel): static_dist_path.resolve() if static_dist_path is not None else None ) self._runtime_model_name = runtime_model_name + self._settings_restart_sections: set[str] = set() # 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 # file, nothing else. The secret regenerates on restart so links @@ -663,6 +635,12 @@ class WebSocketChannel(BaseChannel): if got == "/api/commands": return self._handle_commands(request) + if got == "/api/webui/sidebar-state": + return self._handle_webui_sidebar_state(request) + + if got == "/api/webui/sidebar-state/update": + return self._handle_webui_sidebar_state_update(request) + if got == "/api/settings/update": return self._handle_settings_update(request) @@ -672,6 +650,9 @@ class WebSocketChannel(BaseChannel): 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) + m = re.match(r"^/api/sessions/([^/]+)/messages$", got) if m: return self._handle_session_messages(request, m.group(1)) @@ -783,221 +764,115 @@ class WebSocketChannel(BaseChannel): sessions = self._session_manager.list_sessions() # Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc. # keys are not intended for resume over this HTTP surface. - cleaned = [ - {k: v for k, v in s.items() if k != "path"} - for s in sessions - if isinstance(s.get("key"), str) and s["key"].startswith("websocket:") - ] - return _http_json_response({"sessions": cleaned}) - - def _settings_payload(self, *, requires_restart: bool = False) -> dict[str, Any]: - from nanobot.config.loader import get_config_path, load_config - from nanobot.providers.registry import PROVIDERS, find_by_name - - config = load_config() - defaults = config.agents.defaults - provider_name = config.get_provider_name(defaults.model) or defaults.provider - provider = config.get_provider(defaults.model) - selected_provider = provider_name - if defaults.provider != "auto": - spec = find_by_name(defaults.provider) - selected_provider = spec.name if spec else provider_name - providers = [] - for spec in PROVIDERS: - provider_config = getattr(config.providers, spec.name, None) - if provider_config is None or spec.is_oauth: + cleaned = [] + for s in sessions: + key = s.get("key") + if not (isinstance(key, str) and key.startswith("websocket:")): continue - providers.append( - { - "name": spec.name, - "label": spec.label, - "configured": _provider_configured_for_settings(spec, provider_config), - "api_key_required": _provider_requires_api_key(spec), - "api_key_hint": _mask_secret_hint(provider_config.api_key), - "api_base": provider_config.api_base, - "default_api_base": spec.default_api_base or None, - } - ) - search_config = config.tools.web.search - search_provider = ( - search_config.provider - if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME - else "duckduckgo" - ) - return { - "agent": { - "model": defaults.model, - "provider": selected_provider, - "resolved_provider": provider_name, - "has_api_key": bool(provider and provider.api_key), - }, - "providers": providers, - "web_search": { - "provider": search_provider, - "api_key_hint": _mask_secret_hint(search_config.api_key), - "base_url": search_config.base_url or None, - "providers": list(_WEB_SEARCH_PROVIDER_OPTIONS), - }, - "runtime": { - "config_path": str(get_config_path().expanduser()), - }, - "requires_restart": requires_restart, - } + row = {k: v for k, v in s.items() if k != "path"} + chat_id = key.split(":", 1)[1] + started_at = websocket_turn_wall_started_at(chat_id) + if started_at is not None: + row["run_started_at"] = started_at + cleaned.append(row) + return _http_json_response({"sessions": cleaned}) def _handle_settings(self, request: WsRequest) -> Response: if not self._check_api_token(request): return _http_error(401, "Unauthorized") - return _http_json_response(self._settings_payload()) + return _http_json_response(self._with_settings_restart_state(settings_payload())) + + 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) + if self._settings_restart_sections: + payload = dict(payload) + payload["requires_restart"] = True + payload["restart_required_sections"] = sorted(self._settings_restart_sections) + else: + payload = dict(payload) + payload["restart_required_sections"] = [] + return payload def _handle_commands(self, request: WsRequest) -> Response: if not self._check_api_token(request): return _http_error(401, "Unauthorized") return _http_json_response({"commands": builtin_command_palette()}) + def _handle_webui_sidebar_state(self, request: WsRequest) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + return _http_json_response(read_webui_sidebar_state()) + + def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + query = _parse_query(request.path) + raw_state = _query_first(query, "state") + if raw_state is None: + return _http_error(400, "missing state") + try: + decoded = json.loads(raw_state) + except json.JSONDecodeError: + return _http_error(400, "state must be JSON") + if not isinstance(decoded, dict): + return _http_error(400, "state must be an object") + try: + state = write_webui_sidebar_state(decoded) + except ValueError as e: + return _http_error(400, str(e)) + except OSError: + self.logger.exception("failed to write webui sidebar state") + 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") - from nanobot.config.loader import load_config, save_config - from nanobot.providers.registry import find_by_name - query = _parse_query(request.path) - config = load_config() - defaults = config.agents.defaults - changed = False - - model = _query_first(query, "model") - if model is not None: - model = model.strip() - if not model: - return _http_error(400, "model is required") - if defaults.model != model: - defaults.model = model - changed = True - - provider = _query_first(query, "provider") - if provider is not None: - provider = provider.strip() - if not provider: - return _http_error(400, "provider is required") - if find_by_name(provider) is None: - return _http_error(400, "unknown provider") - provider_config = getattr(config.providers, provider, None) - spec = find_by_name(provider) - if ( - provider_config is None - or spec is None - or not _provider_configured_for_settings(spec, provider_config) - ): - return _http_error(400, "provider is not configured") - if defaults.provider != provider: - defaults.provider = provider - changed = True - - if changed: - save_config(config) - # LLM provider/model changes are hot-reloaded by AgentLoop before each - # new turn via the provider snapshot loader, so a restart is unnecessary. - return _http_json_response(self._settings_payload(requires_restart=False)) + 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_provider_update(self, request: WsRequest) -> Response: if not self._check_api_token(request): return _http_error(401, "Unauthorized") - from nanobot.config.loader import load_config, save_config - from nanobot.providers.registry import find_by_name - query = _parse_query(request.path) - provider_name = (_query_first(query, "provider") or "").strip() - if not provider_name: - return _http_error(400, "provider is required") - spec = find_by_name(provider_name) - if spec is None or spec.is_oauth: - return _http_error(400, "unknown provider") - - config = load_config() - provider_config = getattr(config.providers, spec.name, None) - if provider_config is None: - return _http_error(400, "unknown provider") - - changed = False - if "api_key" in query or "apiKey" in query: - api_key = _query_first(query, "api_key") - if api_key is None: - api_key = _query_first(query, "apiKey") - api_key = (api_key or "").strip() or None - if provider_config.api_key != api_key: - provider_config.api_key = api_key - changed = True - - if "api_base" in query or "apiBase" in query: - api_base = _query_first(query, "api_base") - if api_base is None: - api_base = _query_first(query, "apiBase") - api_base = (api_base or "").strip() or None - if provider_config.api_base != api_base: - provider_config.api_base = api_base - changed = True - - if changed: - save_config(config) - # API key/base changes are picked up by the next provider snapshot refresh. - return _http_json_response(self._settings_payload(requires_restart=False)) + 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")) def _handle_settings_web_search_update(self, request: WsRequest) -> Response: if not self._check_api_token(request): return _http_error(401, "Unauthorized") - from nanobot.config.loader import load_config, save_config - query = _parse_query(request.path) - provider_name = (_query_first(query, "provider") or "").strip().lower() - provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name) - if provider_option is None: - return _http_error(400, "unknown web search provider") + 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="web")) - config = load_config() - search_config = config.tools.web.search - previous_provider = search_config.provider - changed = False - - def set_value(attr: str, value: str | None) -> None: - nonlocal changed - if getattr(search_config, attr) != value: - setattr(search_config, attr, value) - changed = True - - if search_config.provider != provider_name: - search_config.provider = provider_name - changed = True - - credential = provider_option["credential"] - if credential == "none": - set_value("api_key", "") - set_value("base_url", "") - elif credential == "base_url": - base_url = _query_first(query, "base_url") - if base_url is None: - base_url = _query_first(query, "baseUrl") - base_url = base_url.strip() if base_url is not None else None - if not base_url and previous_provider == provider_name and search_config.base_url: - base_url = search_config.base_url - if not base_url: - return _http_error(400, "base_url is required") - set_value("base_url", base_url) - set_value("api_key", "") - else: - api_key = _query_first(query, "api_key") - if api_key is None: - api_key = _query_first(query, "apiKey") - api_key = api_key.strip() if api_key is not None else None - if not api_key and previous_provider == provider_name and search_config.api_key: - api_key = search_config.api_key - if not api_key: - return _http_error(400, "api_key is required") - set_value("api_key", api_key) - set_value("base_url", "") - - if changed: - save_config(config) - return _http_json_response(self._settings_payload(requires_restart=False)) + 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")) @staticmethod def _is_websocket_channel_session_key(key: str) -> bool: diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py index 09db0ef83..08e57cbad 100644 --- a/nanobot/providers/image_generation.py +++ b/nanobot/providers/image_generation.py @@ -139,6 +139,11 @@ def get_image_gen_provider(name: str) -> type[ImageGenerationProvider] | None: return _IMAGE_GEN_PROVIDERS.get(name) +def image_gen_provider_names() -> tuple[str, ...]: + """Return registered image generation provider names in registry order.""" + return tuple(_IMAGE_GEN_PROVIDERS) + + def image_gen_provider_configs(config: Any) -> dict[str, Any]: providers_cfg = config.providers return { diff --git a/nanobot/utils/webui_turn_helpers.py b/nanobot/session/webui_turns.py similarity index 99% rename from nanobot/utils/webui_turn_helpers.py rename to nanobot/session/webui_turns.py index 6a3ac2ba0..de1083b0e 100644 --- a/nanobot/utils/webui_turn_helpers.py +++ b/nanobot/session/webui_turns.py @@ -1,4 +1,4 @@ -"""Outbound helpers for the WebSocket/WebUI wire contract. +"""Session turn helpers for WebUI-capable WebSocket sessions. AgentLoop uses these without importing a concrete channel plugin; only ``channel == "websocket"`` messages are affected. diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py index 9ad157c2e..15dbe2e98 100644 --- a/nanobot/utils/__init__.py +++ b/nanobot/utils/__init__.py @@ -1,6 +1,42 @@ """Utility functions for nanobot.""" +from __future__ import annotations + +import sys +from importlib import import_module +from types import ModuleType + from nanobot.utils.helpers import ensure_dir from nanobot.utils.path import abbreviate_path __all__ = ["ensure_dir", "abbreviate_path"] + + +class _LazyModuleAlias(ModuleType): + def __init__(self, name: str, target: str) -> None: + super().__init__(name) + self.__dict__["_target"] = target + + def _load(self) -> ModuleType: + module = import_module(self.__dict__["_target"]) + sys.modules[self.__name__] = module + return module + + def __getattr__(self, name: str) -> object: + return getattr(self._load(), name) + + def __dir__(self) -> list[str]: + return sorted(set(super().__dir__()) | set(dir(self._load()))) + + +_LEGACY_MODULE_ALIASES = { + "webui_thread_disk": "nanobot.webui.thread_disk", + "webui_transcript": "nanobot.webui.transcript", + "webui_turn_helpers": "nanobot.session.webui_turns", +} + +for _legacy_name, _target_name in _LEGACY_MODULE_ALIASES.items(): + sys.modules.setdefault( + f"{__name__}.{_legacy_name}", + _LazyModuleAlias(f"{__name__}.{_legacy_name}", _target_name), + ) diff --git a/nanobot/utils/artifacts.py b/nanobot/utils/artifacts.py index 5f127f44c..6366c18cf 100644 --- a/nanobot/utils/artifacts.py +++ b/nanobot/utils/artifacts.py @@ -120,5 +120,3 @@ def generated_image_tool_result(artifacts: list[dict[str, Any]]) -> str: }, ensure_ascii=False, ) - - diff --git a/nanobot/webui/__init__.py b/nanobot/webui/__init__.py new file mode 100644 index 000000000..1ee95c7b6 --- /dev/null +++ b/nanobot/webui/__init__.py @@ -0,0 +1,2 @@ +"""Backend helpers for the bundled WebUI surface.""" + diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py new file mode 100644 index 000000000..a5ab13c5a --- /dev/null +++ b/nanobot/webui/settings_api.py @@ -0,0 +1,609 @@ +"""Settings REST helpers for the WebUI HTTP surface. + +The WebSocket channel owns transport/authentication. This module owns the +settings payload shape and the allowlisted config mutations exposed to WebUI. +""" + +from __future__ import annotations + +from typing import Any +from zoneinfo import ZoneInfo + +from nanobot.config.loader import get_config_path, load_config, save_config +from nanobot.providers.image_generation import ( + get_image_gen_provider, + image_gen_provider_names, +) +from nanobot.providers.registry import PROVIDERS, find_by_name + +QueryParams = dict[str, list[str]] + +_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( + {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"}, + {"name": "brave", "label": "Brave Search", "credential": "api_key"}, + {"name": "tavily", "label": "Tavily", "credential": "api_key"}, + {"name": "searxng", "label": "SearXNG", "credential": "base_url"}, + {"name": "jina", "label": "Jina", "credential": "api_key"}, + {"name": "kagi", "label": "Kagi", "credential": "api_key"}, + {"name": "olostep", "label": "Olostep", "credential": "api_key"}, +) +_WEB_SEARCH_PROVIDER_BY_NAME = { + provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS +} + +_IMAGE_GENERATION_ASPECT_RATIOS = { + "1:1", + "3:4", + "9:16", + "4:3", + "16:9", + "3:2", + "2:3", + "21:9", +} + + +class WebUISettingsError(ValueError): + """User-facing settings validation failure.""" + + def __init__(self, message: str, *, status: int = 400) -> None: + super().__init__(message) + self.message = message + self.status = status + + +def _query_first(query: QueryParams, key: str) -> str | None: + values = query.get(key) + return values[0] if values else None + + +def _query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None: + value = _query_first(query, snake) + return _query_first(query, camel) if value is None else value + + +def _mask_secret_hint(secret: str | None) -> str | None: + if not secret: + return None + if len(secret) <= 8: + return "••••" + return f"{secret[:4]}••••{secret[-4:]}" + + +def _provider_requires_api_key(spec: Any) -> bool: + if spec.backend == "azure_openai": + return True + if spec.is_local or spec.is_direct: + return False + return True + + +def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool: + if _provider_requires_api_key(spec): + return bool(provider_config.api_key) + return bool( + provider_config.api_key + or provider_config.api_base + or getattr(provider_config, "region", None) + or getattr(provider_config, "profile", None) + ) + + +def _parse_bool(value: str, field: str) -> bool: + normalized = value.strip().lower() + if normalized not in {"1", "0", "true", "false", "yes", "no"}: + raise WebUISettingsError(f"{field} must be boolean") + return normalized in {"1", "true", "yes"} + + +def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for name in image_gen_provider_names(): + spec = find_by_name(name) + provider_config = getattr(config.providers, name, None) + configured = ( + _provider_configured_for_settings(spec, provider_config) + if spec is not None and provider_config is not None + else bool(getattr(provider_config, "api_key", None)) + ) + rows.append( + { + "name": name, + "label": spec.label if spec is not None else name, + "configured": configured, + "api_key_hint": _mask_secret_hint( + getattr(provider_config, "api_key", None) + ), + "api_base": getattr(provider_config, "api_base", None), + "default_api_base": ( + spec.default_api_base if spec and spec.default_api_base else None + ), + } + ) + return rows + + +def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]: + config = load_config() + defaults = config.agents.defaults + active_preset_name = defaults.model_preset or "default" + try: + effective_preset = config.resolve_preset() + except Exception: + effective_preset = config.resolve_default_preset() + active_preset_name = "default" + + provider_name = ( + config.get_provider_name(effective_preset.model, preset=effective_preset) + or effective_preset.provider + ) + provider = config.get_provider(effective_preset.model, preset=effective_preset) + selected_provider = provider_name + if effective_preset.provider != "auto": + spec = find_by_name(effective_preset.provider) + selected_provider = spec.name if spec else provider_name + + providers = [] + for spec in PROVIDERS: + provider_config = getattr(config.providers, spec.name, None) + if provider_config is None or spec.is_oauth: + continue + providers.append( + { + "name": spec.name, + "label": spec.label, + "configured": _provider_configured_for_settings(spec, provider_config), + "api_key_required": _provider_requires_api_key(spec), + "api_key_hint": _mask_secret_hint(provider_config.api_key), + "api_base": provider_config.api_base, + "default_api_base": spec.default_api_base or None, + } + ) + + search_config = config.tools.web.search + image_config = config.tools.image_generation + search_provider = ( + search_config.provider + if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME + else "duckduckgo" + ) + image_providers = _image_generation_provider_rows(config) + selected_image_provider = next( + ( + provider + for provider in image_providers + if provider["name"] == image_config.provider + ), + None, + ) + model_presets = [ + { + "name": "default", + "label": "Default", + "active": active_preset_name == "default", + "is_default": True, + "model": defaults.model, + "provider": defaults.provider, + "max_tokens": defaults.max_tokens, + "context_window_tokens": defaults.context_window_tokens, + "temperature": defaults.temperature, + "reasoning_effort": defaults.reasoning_effort, + } + ] + for name, preset in config.model_presets.items(): + model_presets.append( + { + "name": name, + "label": name, + "active": active_preset_name == name, + "is_default": False, + "model": preset.model, + "provider": preset.provider, + "max_tokens": preset.max_tokens, + "context_window_tokens": preset.context_window_tokens, + "temperature": preset.temperature, + "reasoning_effort": preset.reasoning_effort, + } + ) + + exec_config = config.tools.exec + return { + "agent": { + "model": effective_preset.model, + "provider": selected_provider, + "resolved_provider": provider_name, + "has_api_key": bool(provider and provider.api_key), + "model_preset": active_preset_name, + "max_tokens": effective_preset.max_tokens, + "context_window_tokens": effective_preset.context_window_tokens, + "temperature": effective_preset.temperature, + "reasoning_effort": effective_preset.reasoning_effort, + "timezone": defaults.timezone, + "bot_name": defaults.bot_name, + "bot_icon": defaults.bot_icon, + "tool_hint_max_length": defaults.tool_hint_max_length, + }, + "model_presets": model_presets, + "providers": providers, + "web_search": { + "provider": search_provider, + "api_key_hint": _mask_secret_hint(search_config.api_key), + "base_url": search_config.base_url or None, + "max_results": search_config.max_results, + "timeout": search_config.timeout, + "providers": list(_WEB_SEARCH_PROVIDER_OPTIONS), + }, + "web": { + "enable": config.tools.web.enable, + "proxy": config.tools.web.proxy, + "user_agent": config.tools.web.user_agent, + "search": { + "max_results": search_config.max_results, + "timeout": search_config.timeout, + }, + "fetch": { + "use_jina_reader": config.tools.web.fetch.use_jina_reader, + }, + }, + "image_generation": { + "enabled": image_config.enabled, + "provider": image_config.provider, + "provider_configured": bool( + selected_image_provider and selected_image_provider["configured"] + ), + "model": image_config.model, + "default_aspect_ratio": image_config.default_aspect_ratio, + "default_image_size": image_config.default_image_size, + "max_images_per_turn": image_config.max_images_per_turn, + "save_dir": image_config.save_dir, + "providers": image_providers, + }, + "runtime": { + "config_path": str(get_config_path().expanduser()), + "workspace_path": str(config.workspace_path), + "gateway_host": config.gateway.host, + "gateway_port": config.gateway.port, + "heartbeat": { + "enabled": config.gateway.heartbeat.enabled, + "interval_s": config.gateway.heartbeat.interval_s, + "keep_recent_messages": config.gateway.heartbeat.keep_recent_messages, + }, + "dream": { + "schedule": defaults.dream.describe_schedule(), + "max_batch_size": defaults.dream.max_batch_size, + "max_iterations": defaults.dream.max_iterations, + "annotate_line_ages": defaults.dream.annotate_line_ages, + }, + "unified_session": defaults.unified_session, + }, + "advanced": { + "restrict_to_workspace": config.tools.restrict_to_workspace, + "ssrf_whitelist_count": len(config.tools.ssrf_whitelist), + "mcp_server_count": len(config.tools.mcp_servers), + "exec_enabled": exec_config.enable, + "exec_sandbox": exec_config.sandbox or None, + "exec_path_append_set": bool(exec_config.path_append), + }, + "requires_restart": requires_restart, + } + + +def update_agent_settings(query: QueryParams) -> dict[str, Any]: + config = load_config() + defaults = config.agents.defaults + changed = False + restart_required = False + + if "model_preset" in query or "modelPreset" in query: + preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip() + preset_value = None if not preset or preset == "default" else preset + if preset_value is not None and preset_value not in config.model_presets: + raise WebUISettingsError("unknown model preset") + if defaults.model_preset != preset_value: + defaults.model_preset = preset_value + changed = True + + model = _query_first(query, "model") + if model is not None: + model = model.strip() + if not model: + raise WebUISettingsError("model is required") + if defaults.model != model: + defaults.model = model + changed = True + + provider = _query_first(query, "provider") + if provider is not None: + provider = provider.strip() + if not provider: + raise WebUISettingsError("provider is required") + spec = find_by_name(provider) + if spec is None: + raise WebUISettingsError("unknown provider") + provider_config = getattr(config.providers, provider, None) + if ( + provider_config is None + or not _provider_configured_for_settings(spec, provider_config) + ): + raise WebUISettingsError("provider is not configured") + if defaults.provider != provider: + defaults.provider = provider + changed = True + + timezone = _query_first(query, "timezone") + if timezone is not None: + timezone = timezone.strip() + if not timezone: + raise WebUISettingsError("timezone is required") + try: + ZoneInfo(timezone) + except Exception: + raise WebUISettingsError("invalid timezone") from None + if defaults.timezone != timezone: + defaults.timezone = timezone + changed = True + restart_required = True + + bot_name = _query_first_alias(query, "bot_name", "botName") + if bot_name is not None: + bot_name = bot_name.strip() + if not bot_name: + raise WebUISettingsError("bot_name is required") + if defaults.bot_name != bot_name: + defaults.bot_name = bot_name + changed = True + restart_required = True + + bot_icon = _query_first_alias(query, "bot_icon", "botIcon") + if bot_icon is not None: + bot_icon = bot_icon.strip() + if defaults.bot_icon != bot_icon: + defaults.bot_icon = bot_icon + changed = True + restart_required = True + + tool_hint_max_length = _query_first_alias( + query, + "tool_hint_max_length", + "toolHintMaxLength", + ) + if tool_hint_max_length is not None: + try: + parsed = int(tool_hint_max_length) + except ValueError: + raise WebUISettingsError("tool_hint_max_length must be an integer") from None + if parsed < 20 or parsed > 500: + raise WebUISettingsError("tool_hint_max_length must be between 20 and 500") + if defaults.tool_hint_max_length != parsed: + defaults.tool_hint_max_length = parsed + changed = True + restart_required = True + + if changed: + save_config(config) + return settings_payload(requires_restart=restart_required) + + +def update_provider_settings(query: QueryParams) -> dict[str, Any]: + 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 or spec.is_oauth: + raise WebUISettingsError("unknown provider") + + config = load_config() + provider_config = getattr(config.providers, spec.name, None) + if provider_config is None: + raise WebUISettingsError("unknown provider") + + changed = False + if "api_key" in query or "apiKey" in query: + api_key = _query_first_alias(query, "api_key", "apiKey") + api_key = (api_key or "").strip() or None + if provider_config.api_key != api_key: + provider_config.api_key = api_key + changed = True + + if "api_base" in query or "apiBase" in query: + api_base = _query_first_alias(query, "api_base", "apiBase") + api_base = (api_base or "").strip() or None + if provider_config.api_base != api_base: + provider_config.api_base = api_base + changed = True + + if changed: + save_config(config) + image_config = config.tools.image_generation + restart_required = ( + changed + and image_config.enabled + and image_config.provider == spec.name + and get_image_gen_provider(spec.name) is not None + ) + return settings_payload(requires_restart=restart_required) + + +def update_web_search_settings(query: QueryParams) -> dict[str, Any]: + provider_name = (_query_first(query, "provider") or "").strip().lower() + provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name) + if provider_option is None: + raise WebUISettingsError("unknown web search provider") + + config = load_config() + search_config = config.tools.web.search + web_config = config.tools.web + previous_provider = search_config.provider + changed = False + restart_required = False + + def set_search_value(attr: str, value: object) -> None: + nonlocal changed + if getattr(search_config, attr) != value: + setattr(search_config, attr, value) + changed = True + + def set_fetch_value(attr: str, value: object) -> None: + nonlocal changed + if getattr(web_config.fetch, attr) != value: + setattr(web_config.fetch, attr, value) + changed = True + + if search_config.provider != provider_name: + search_config.provider = provider_name + changed = True + + credential = provider_option["credential"] + if credential == "none": + set_search_value("api_key", "") + set_search_value("base_url", "") + elif credential == "base_url": + base_url = _query_first_alias(query, "base_url", "baseUrl") + base_url = base_url.strip() if base_url is not None else None + if not base_url and previous_provider == provider_name and search_config.base_url: + base_url = search_config.base_url + if not base_url: + raise WebUISettingsError("base_url is required") + set_search_value("base_url", base_url) + set_search_value("api_key", "") + else: + api_key = _query_first_alias(query, "api_key", "apiKey") + api_key = api_key.strip() if api_key is not None else None + if not api_key and previous_provider == provider_name and search_config.api_key: + api_key = search_config.api_key + if not api_key: + raise WebUISettingsError("api_key is required") + set_search_value("api_key", api_key) + set_search_value("base_url", "") + + max_results = _query_first_alias(query, "max_results", "maxResults") + if max_results is not None: + try: + parsed = int(max_results) + except ValueError: + raise WebUISettingsError("max_results must be an integer") from None + if parsed < 1 or parsed > 10: + raise WebUISettingsError("max_results must be between 1 and 10") + set_search_value("max_results", parsed) + + timeout = _query_first(query, "timeout") + if timeout is not None: + try: + parsed_timeout = int(timeout) + except ValueError: + raise WebUISettingsError("timeout must be an integer") from None + if parsed_timeout < 1 or parsed_timeout > 120: + raise WebUISettingsError("timeout must be between 1 and 120") + set_search_value("timeout", parsed_timeout) + + use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader") + if use_jina_reader is not None: + normalized = use_jina_reader.strip().lower() + if normalized not in {"1", "0", "true", "false", "yes", "no"}: + raise WebUISettingsError("use_jina_reader must be boolean") + previous_jina_reader = web_config.fetch.use_jina_reader + set_fetch_value("use_jina_reader", normalized in {"1", "true", "yes"}) + if web_config.fetch.use_jina_reader != previous_jina_reader: + restart_required = True + + if changed: + save_config(config) + return settings_payload(requires_restart=restart_required) + + +def update_image_generation_settings(query: QueryParams) -> dict[str, Any]: + config = load_config() + image_config = config.tools.image_generation + changed = False + + provider_name = _query_first(query, "provider") + if provider_name is not None: + provider_name = provider_name.strip().lower() + if not provider_name: + raise WebUISettingsError("image generation provider is required") + if get_image_gen_provider(provider_name) is None: + raise WebUISettingsError("unknown image generation provider") + if image_config.provider != provider_name: + image_config.provider = provider_name + changed = True + + enabled = _query_first(query, "enabled") + if enabled is not None: + parsed_enabled = _parse_bool(enabled, "enabled") + if image_config.enabled != parsed_enabled: + image_config.enabled = parsed_enabled + changed = True + + model = _query_first(query, "model") + if model is not None: + model = model.strip() + if not model: + raise WebUISettingsError("image generation model is required") + if len(model) > 200: + raise WebUISettingsError("image generation model is too long") + if image_config.model != model: + image_config.model = model + changed = True + + default_aspect_ratio = _query_first_alias( + query, + "default_aspect_ratio", + "defaultAspectRatio", + ) + if default_aspect_ratio is not None: + default_aspect_ratio = default_aspect_ratio.strip() + if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS: + raise WebUISettingsError("unsupported image generation aspect ratio") + if image_config.default_aspect_ratio != default_aspect_ratio: + image_config.default_aspect_ratio = default_aspect_ratio + changed = True + + default_image_size = _query_first_alias( + query, + "default_image_size", + "defaultImageSize", + ) + if default_image_size is not None: + default_image_size = default_image_size.strip() + if not default_image_size: + raise WebUISettingsError("default image size is required") + if len(default_image_size) > 32 or not all( + char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"}) + for char in default_image_size + ): + raise WebUISettingsError("unsupported image generation size") + if image_config.default_image_size != default_image_size: + image_config.default_image_size = default_image_size + changed = True + + max_images_per_turn = _query_first_alias( + query, + "max_images_per_turn", + "maxImagesPerTurn", + ) + if max_images_per_turn is not None: + try: + parsed_max = int(max_images_per_turn) + except ValueError: + raise WebUISettingsError("max_images_per_turn must be an integer") from None + if parsed_max < 1 or parsed_max > 8: + raise WebUISettingsError("max_images_per_turn must be between 1 and 8") + if image_config.max_images_per_turn != parsed_max: + image_config.max_images_per_turn = parsed_max + changed = True + + if image_config.enabled: + selected_provider = next( + ( + provider + for provider in _image_generation_provider_rows(config) + if provider["name"] == image_config.provider + ), + None, + ) + if not selected_provider or not selected_provider["configured"]: + raise WebUISettingsError("image generation provider is not configured") + + if changed: + save_config(config) + return settings_payload(requires_restart=changed) diff --git a/nanobot/webui/sidebar_state.py b/nanobot/webui/sidebar_state.py new file mode 100644 index 000000000..12d26c106 --- /dev/null +++ b/nanobot/webui/sidebar_state.py @@ -0,0 +1,193 @@ +"""Persisted WebUI sidebar workspace state. + +This state is UI-only metadata, scoped to the active nanobot instance data +directory (the directory containing the current config.json). It deliberately +does not modify agent sessions. +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any + +from loguru import logger + +from nanobot.config.paths import get_webui_dir + +WEBUI_SIDEBAR_STATE_SCHEMA_VERSION = 1 +_MAX_STATE_FILE_BYTES = 256 * 1024 +_MAX_LIST_ITEMS = 2_000 +_MAX_MAP_ITEMS = 2_000 +_MAX_KEY_LEN = 512 +_MAX_TITLE_LEN = 160 +_MAX_TAG_LEN = 40 +_ALLOWED_DENSITIES = {"comfortable", "compact"} +_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc"} + + +def webui_sidebar_state_path() -> Path: + return get_webui_dir() / "sidebar-state.json" + + +def default_webui_sidebar_state() -> dict[str, Any]: + return { + "schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION, + "pinned_keys": [], + "archived_keys": [], + "title_overrides": {}, + "tags_by_key": {}, + "collapsed_groups": {}, + "view": { + "density": "comfortable", + "show_previews": False, + "show_timestamps": False, + "show_archived": False, + "sort": "updated_desc", + }, + "updated_at": None, + } + + +def _clean_string(value: Any, *, max_len: int = _MAX_KEY_LEN) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.strip() + if not cleaned: + return None + return cleaned[:max_len] + + +def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]: + if not isinstance(value, list): + return [] + out: list[str] = [] + seen: set[str] = set() + for item in value[:_MAX_LIST_ITEMS]: + cleaned = _clean_string(item, max_len=max_len) + if cleaned is None or cleaned in seen: + continue + seen.add(cleaned) + out.append(cleaned) + return out + + +def _clean_bool_map(value: Any) -> dict[str, bool]: + if not isinstance(value, dict): + return {} + out: dict[str, bool] = {} + for key, raw in list(value.items())[:_MAX_MAP_ITEMS]: + cleaned_key = _clean_string(key) + if cleaned_key is None: + continue + out[cleaned_key] = bool(raw) + return out + + +def _clean_title_overrides(value: Any) -> dict[str, str]: + if not isinstance(value, dict): + return {} + out: dict[str, str] = {} + for key, raw_title in list(value.items())[:_MAX_MAP_ITEMS]: + cleaned_key = _clean_string(key) + cleaned_title = _clean_string(raw_title, max_len=_MAX_TITLE_LEN) + if cleaned_key is None or cleaned_title is None: + continue + out[cleaned_key] = cleaned_title + return out + + +def _clean_tags_by_key(value: Any) -> dict[str, list[str]]: + if not isinstance(value, dict): + return {} + out: dict[str, list[str]] = {} + for key, raw_tags in list(value.items())[:_MAX_MAP_ITEMS]: + cleaned_key = _clean_string(key) + if cleaned_key is None: + continue + tags = _clean_string_list(raw_tags, max_len=_MAX_TAG_LEN)[:12] + if tags: + out[cleaned_key] = tags + return out + + +def _clean_view(value: Any) -> dict[str, Any]: + default = default_webui_sidebar_state()["view"] + if not isinstance(value, dict): + return dict(default) + density = value.get("density") + sort = value.get("sort") + return { + "density": density if density in _ALLOWED_DENSITIES else default["density"], + "show_previews": bool(value.get("show_previews", default["show_previews"])), + "show_timestamps": bool(value.get("show_timestamps", default["show_timestamps"])), + "show_archived": bool(value.get("show_archived", default["show_archived"])), + "sort": sort if sort in _ALLOWED_SORTS else default["sort"], + } + + +def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]: + """Return a schema-v1 sidebar state from any older/partial input.""" + if not isinstance(raw, dict): + raw = {} + state = default_webui_sidebar_state() + state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys")) + state["archived_keys"] = _clean_string_list(raw.get("archived_keys")) + state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides")) + state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key")) + state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups")) + state["view"] = _clean_view(raw.get("view")) + updated_at = raw.get("updated_at") + state["updated_at"] = updated_at if isinstance(updated_at, str) else None + return state + + +def read_webui_sidebar_state() -> dict[str, Any]: + path = webui_sidebar_state_path() + if not path.is_file(): + return default_webui_sidebar_state() + try: + if path.stat().st_size > _MAX_STATE_FILE_BYTES: + logger.warning("webui sidebar state too large, ignoring: {}", path) + return default_webui_sidebar_state() + with open(path, encoding="utf-8") as f: + raw = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.warning("read webui sidebar state failed {}: {}", path, e) + return default_webui_sidebar_state() + return normalize_webui_sidebar_state(raw) + + +def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]: + state = normalize_webui_sidebar_state(raw) + state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + encoded = json.dumps( + state, + ensure_ascii=False, + indent=2, + sort_keys=True, + ).encode("utf-8") + if len(encoded) > _MAX_STATE_FILE_BYTES: + raise ValueError("sidebar state is too large") + + path = webui_sidebar_state_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + with open(tmp, "wb") as f: + f.write(encoded) + f.write(b"\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + try: + dir_fd = os.open(path.parent, os.O_RDONLY) + except OSError: + return state + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + return state + diff --git a/nanobot/utils/webui_thread_disk.py b/nanobot/webui/thread_disk.py similarity index 90% rename from nanobot/utils/webui_thread_disk.py rename to nanobot/webui/thread_disk.py index 65f12825d..03438f8af 100644 --- a/nanobot/utils/webui_thread_disk.py +++ b/nanobot/webui/thread_disk.py @@ -1,4 +1,4 @@ -"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use webui_transcript.""" +"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use transcript.""" from __future__ import annotations @@ -8,7 +8,7 @@ from loguru import logger from nanobot.config.paths import get_webui_dir from nanobot.session.manager import SessionManager -from nanobot.utils.webui_transcript import delete_webui_transcript +from nanobot.webui.transcript import delete_webui_transcript def webui_thread_file_path(session_key: str) -> Path: diff --git a/nanobot/utils/webui_transcript.py b/nanobot/webui/transcript.py similarity index 100% rename from nanobot/utils/webui_transcript.py rename to nanobot/webui/transcript.py diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 974377472..f7bd038ba 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -651,7 +651,7 @@ class TestToolEventProgress: return False monkeypatch.setattr( - "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn", + "nanobot.session.webui_turns.maybe_generate_webui_title_after_turn", fake_title_after_turn, ) scheduled_title: list[object] = [] @@ -698,7 +698,7 @@ class TestToolEventProgress: raise AssertionError("command-only turns should not generate titles") monkeypatch.setattr( - "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn", + "nanobot.session.webui_turns.maybe_generate_webui_title_after_turn", fake_title_after_turn, ) scheduled: list[object] = [] diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 9814c386d..06ed8a0c4 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -11,7 +11,7 @@ from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMResponse from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.manager import Session, SessionManager -from nanobot.utils.webui_turn_helpers import ( +from nanobot.session.webui_turns import ( TITLE_GENERATION_MAX_TOKENS, TITLE_GENERATION_REASONING_EFFORT, WEBUI_SESSION_METADATA_KEY, @@ -143,7 +143,7 @@ def test_webui_title_update_uses_captured_llm_runtime( return False monkeypatch.setattr( - "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn", + "nanobot.session.webui_turns.maybe_generate_webui_title_after_turn", fake_title_after_turn, ) coordinator = WebuiTurnCoordinator( diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 0c55a229c..78953864e 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -29,7 +29,8 @@ from nanobot.channels.websocket import ( publish_runtime_model_update, ) from nanobot.config.loader import load_config, save_config -from nanobot.config.schema import Config +from nanobot.config.schema import Config, ModelPresetConfig +from nanobot.webui.settings_api import settings_payload # -- Shared helpers (aligned with test_websocket_integration.py) --------------- @@ -756,7 +757,7 @@ async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> Non channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) mock_ws = AsyncMock() channel._attach(mock_ws, "chat-1") - from nanobot.utils import webui_turn_helpers as wth + from nanobot.session import webui_turns as wth wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() await channel._maybe_push_turn_run_wall_clock("chat-1") @@ -769,7 +770,7 @@ async def test_maybe_push_turn_run_wall_clock_replays_running() -> None: channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) mock_ws = AsyncMock() channel._attach(mock_ws, "chat-1") - from nanobot.utils import webui_turn_helpers as wth + from nanobot.session import webui_turns as wth wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() try: @@ -991,6 +992,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( config = Config() config.agents.defaults.model = "openai/gpt-4o" config.providers.openai.api_key = "secret-key" + config.model_presets["deep"] = ModelPresetConfig( + model="anthropic/claude-opus-4-5", + provider="anthropic", + reasoning_effort="high", + ) config.tools.web.search.provider = "brave" config.tools.web.search.api_key = "brave-secret" save_config(config, config_path) @@ -1011,6 +1017,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( body = settings.json() assert body["agent"]["model"] == "openai/gpt-4o" assert body["agent"]["provider"] == "openai" + assert body["agent"]["model_preset"] == "default" + assert body["agent"]["max_tokens"] == 8192 + assert body["agent"]["timezone"] == "UTC" + assert body["agent"]["tool_hint_max_length"] == 40 + presets = {preset["name"]: preset for preset in body["model_presets"]} + assert presets["default"]["active"] is True + assert presets["deep"]["reasoning_effort"] == "high" providers = {provider["name"]: provider for provider in body["providers"]} assert providers["openai"]["configured"] is True assert providers["openai"]["api_key_hint"] == "secr••••-key" @@ -1025,9 +1038,28 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert body["agent"]["has_api_key"] is True assert body["web_search"]["provider"] == "brave" assert body["web_search"]["api_key_hint"] == "brav••••cret" + assert body["web_search"]["max_results"] == 5 + assert body["web"]["fetch"]["use_jina_reader"] is True search_providers = {provider["name"]: provider for provider in body["web_search"]["providers"]} assert search_providers["duckduckgo"]["credential"] == "none" assert search_providers["searxng"]["credential"] == "base_url" + assert body["image_generation"]["enabled"] is False + assert body["image_generation"]["provider"] == "openrouter" + assert body["image_generation"]["provider_configured"] is False + assert body["image_generation"]["default_aspect_ratio"] == "1:1" + image_providers = { + provider["name"]: provider + for provider in body["image_generation"]["providers"] + } + assert image_providers["openrouter"]["label"] == "OpenRouter" + assert image_providers["openrouter"]["configured"] is False + assert image_providers["gemini"]["label"] == "Gemini" + assert body["runtime"]["config_path"] == str(config_path) + assert body["runtime"]["workspace_path"].endswith(".nanobot/workspace") + assert body["runtime"]["gateway_port"] == 18790 + assert body["advanced"]["exec_enabled"] is True + assert body["advanced"]["mcp_server_count"] == 0 + assert body["restart_required_sections"] == [] assert "secret-key" not in settings.text assert "brave-secret" not in settings.text @@ -1042,6 +1074,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert provider_body["requires_restart"] is False provider_rows = {provider["name"]: provider for provider in provider_body["providers"]} assert provider_rows["openrouter"]["configured"] is True + assert provider_body["image_generation"]["provider_configured"] is True assert "sk-or-test" not in provider_updated.text local_provider_updated = await _http_get( @@ -1061,34 +1094,117 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( updated = await _http_get( "http://127.0.0.1:" f"{port}/api/settings/update?model=atomic_chat/test" - "&provider=atomic_chat", + "&provider=atomic_chat&timezone=Asia%2FShanghai" + "&bot_name=Nano&bot_icon=N&tool_hint_max_length=120", headers={"Authorization": "Bearer tok"}, ) assert updated.status_code == 200 - assert updated.json()["requires_restart"] is False + updated_body = updated.json() + assert updated_body["requires_restart"] is True + assert updated_body["restart_required_sections"] == ["runtime"] + + preset_updated = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/update?model_preset=deep", + headers={"Authorization": "Bearer tok"}, + ) + assert preset_updated.status_code == 200 + assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5" + + bad_preset = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/update?model_preset=missing", + headers={"Authorization": "Bearer tok"}, + ) + assert bad_preset.status_code == 400 search_updated = await _http_get( "http://127.0.0.1:" f"{port}/api/settings/web-search/update?provider=searxng" - "&base_url=https%3A%2F%2Fsearch.example.com", + "&base_url=https%3A%2F%2Fsearch.example.com" + "&max_results=8&timeout=45&use_jina_reader=false", headers={"Authorization": "Bearer tok"}, ) assert search_updated.status_code == 200 search_body = search_updated.json() - assert search_body["requires_restart"] is False + assert search_body["requires_restart"] is True + assert search_body["restart_required_sections"] == ["runtime", "web"] assert search_body["web_search"]["provider"] == "searxng" assert search_body["web_search"]["api_key_hint"] is None assert search_body["web_search"]["base_url"] == "https://search.example.com" + assert search_body["web_search"]["max_results"] == 8 + assert search_body["web"]["fetch"]["use_jina_reader"] is False + + image_updated = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/image-generation/update?enabled=true" + "&provider=openrouter&model=openai%2Fgpt-image-1" + "&default_aspect_ratio=16%3A9&default_image_size=2K" + "&max_images_per_turn=3", + headers={"Authorization": "Bearer tok"}, + ) + assert image_updated.status_code == 200 + image_body = image_updated.json() + assert image_body["requires_restart"] is True + assert image_body["restart_required_sections"] == ["image", "runtime", "web"] + assert image_body["image_generation"]["enabled"] is True + assert image_body["image_generation"]["model"] == "openai/gpt-image-1" + assert image_body["image_generation"]["default_aspect_ratio"] == "16:9" + assert image_body["image_generation"]["default_image_size"] == "2K" + assert image_body["image_generation"]["max_images_per_turn"] == 3 + + image_provider_updated = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/provider/update?provider=openrouter" + "&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1", + headers={"Authorization": "Bearer tok"}, + ) + assert image_provider_updated.status_code == 200 + assert image_provider_updated.json()["requires_restart"] is True + assert image_provider_updated.json()["restart_required_sections"] == [ + "image", + "runtime", + "web", + ] + assert "sk-or-next" not in image_provider_updated.text + + bad_web = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99", + headers={"Authorization": "Bearer tok"}, + ) + assert bad_web.status_code == 400 + + bad_image = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/image-generation/update?provider=missing", + headers={"Authorization": "Bearer tok"}, + ) + assert bad_image.status_code == 400 saved = load_config(config_path) assert saved.agents.defaults.model == "atomic_chat/test" assert saved.agents.defaults.provider == "atomic_chat" - assert saved.providers.openrouter.api_key == "sk-or-test" + assert saved.agents.defaults.model_preset == "deep" + assert saved.agents.defaults.timezone == "Asia/Shanghai" + assert saved.agents.defaults.bot_name == "Nano" + assert saved.agents.defaults.bot_icon == "N" + assert saved.agents.defaults.tool_hint_max_length == 120 + assert saved.providers.openrouter.api_key == "sk-or-next" assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1" assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1" assert saved.tools.web.search.provider == "searxng" assert saved.tools.web.search.api_key == "" assert saved.tools.web.search.base_url == "https://search.example.com" + assert saved.tools.web.search.max_results == 8 + assert saved.tools.web.search.timeout == 45 + assert saved.tools.web.fetch.use_jina_reader is False + assert saved.tools.image_generation.enabled is True + assert saved.tools.image_generation.provider == "openrouter" + assert saved.tools.image_generation.model == "openai/gpt-image-1" + assert saved.tools.image_generation.default_aspect_ratio == "16:9" + assert saved.tools.image_generation.default_image_size == "2K" + assert saved.tools.image_generation.max_images_per_turn == 3 finally: await channel.stop() await server_task @@ -1133,7 +1249,7 @@ def test_settings_payload_normalizes_camel_case_provider( save_config(config, config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - body = _ch(bus)._settings_payload() + body = settings_payload() assert body["agent"]["provider"] == "minimax_anthropic" @@ -1550,6 +1666,54 @@ def test_parse_envelope_rejects_legacy_and_garbage() -> None: assert _parse_envelope('{"type":123}') is None +def test_sessions_list_includes_active_run_started_at() -> None: + from websockets.datastructures import Headers + from websockets.http11 import Request + + from nanobot.session import webui_turns as wth + + bus = MagicMock() + channel = _ch(bus) + channel._api_tokens["tok"] = time.monotonic() + 300.0 + channel._session_manager = MagicMock() + channel._session_manager.list_sessions.return_value = [ + { + "key": "websocket:chat-1", + "created_at": "2026-05-19T10:00:00Z", + "updated_at": "2026-05-19T10:01:00Z", + "title": "Running", + "preview": "work", + "path": "/private/path", + }, + { + "key": "cli:chat-2", + "created_at": "2026-05-19T10:00:00Z", + "updated_at": "2026-05-19T10:01:00Z", + }, + ] + + wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() + try: + wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0 + req = Request("/api/sessions", Headers([("Authorization", "Bearer tok")])) + resp = channel._handle_sessions_list(req) + finally: + wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() + + assert resp.status_code == 200 + body = json.loads(resp.body.decode()) + assert body["sessions"] == [ + { + "key": "websocket:chat-1", + "created_at": "2026-05-19T10:00:00Z", + "updated_at": "2026-05-19T10:01:00Z", + "title": "Running", + "preview": "work", + "run_started_at": 1_700_000_000.0, + } + ] + + @pytest.mark.parametrize( ("value", "expected"), [ @@ -1576,7 +1740,7 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None: from websockets.datastructures import Headers from websockets.http11 import Request - from nanobot.utils.webui_transcript import append_transcript_object + from nanobot.webui.transcript import append_transcript_object monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) key = "websocket:c1" diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 9286670da..ddb45dfbf 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -6,6 +6,7 @@ import json from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock +from urllib.parse import urlencode import httpx import pytest @@ -176,13 +177,62 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default( await server_task +@pytest.mark.asyncio +async def test_webui_sidebar_state_routes_are_config_dir_scoped( + bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + sm = _seed_session(tmp_path, key="websocket:sidebar") + channel = _ch(bus, session_manager=sm, port=29911) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + boot = await _http_get("http://127.0.0.1:29911/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + + initial = await _http_get( + "http://127.0.0.1:29911/api/webui/sidebar-state", + headers=auth, + ) + assert initial.status_code == 200 + assert initial.json()["schema_version"] == 1 + assert initial.json()["pinned_keys"] == [] + + payload = { + "pinned_keys": ["websocket:sidebar"], + "archived_keys": ["websocket:old"], + "title_overrides": {"websocket:sidebar": "Pinned work"}, + "view": {"density": "compact", "show_archived": True}, + } + query = urlencode({"state": json.dumps(payload)}) + updated = await _http_get( + f"http://127.0.0.1:29911/api/webui/sidebar-state/update?{query}", + headers=auth, + ) + assert updated.status_code == 200 + body = updated.json() + assert body["pinned_keys"] == ["websocket:sidebar"] + assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"} + assert body["view"]["density"] == "compact" + + state_path = tmp_path / "webui" / "sidebar-state.json" + assert state_path.is_file() + assert json.loads(state_path.read_text(encoding="utf-8"))["pinned_keys"] == [ + "websocket:sidebar" + ] + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_session_delete_removes_file( bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) sm = _seed_session(tmp_path, key="websocket:doomed") - from nanobot.utils.webui_transcript import append_transcript_object + from nanobot.webui.transcript import append_transcript_object append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"}) channel = _ch(bus, session_manager=sm, port=29903) diff --git a/tests/utils/test_webui_compat_imports.py b/tests/utils/test_webui_compat_imports.py new file mode 100644 index 000000000..ccb97e288 --- /dev/null +++ b/tests/utils/test_webui_compat_imports.py @@ -0,0 +1,14 @@ +import importlib + +from nanobot.session import webui_turns +from nanobot.webui import thread_disk, transcript + + +def test_legacy_webui_utils_imports_resolve_to_new_modules() -> None: + legacy_thread_disk = importlib.import_module("nanobot.utils.webui_thread_disk") + legacy_transcript = importlib.import_module("nanobot.utils.webui_transcript") + legacy_turn_helpers = importlib.import_module("nanobot.utils.webui_turn_helpers") + + assert legacy_thread_disk.delete_webui_thread is thread_disk.delete_webui_thread + assert legacy_transcript.append_transcript_object is transcript.append_transcript_object + assert legacy_turn_helpers.mark_webui_session is webui_turns.mark_webui_session diff --git a/tests/utils/test_webui_sidebar_state.py b/tests/utils/test_webui_sidebar_state.py new file mode 100644 index 000000000..0244a304a --- /dev/null +++ b/tests/utils/test_webui_sidebar_state.py @@ -0,0 +1,73 @@ +import json + +from nanobot.webui.sidebar_state import ( + default_webui_sidebar_state, + read_webui_sidebar_state, + webui_sidebar_state_path, + write_webui_sidebar_state, +) + + +def test_sidebar_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + + state = read_webui_sidebar_state() + + assert state == default_webui_sidebar_state() + assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json" + + +def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + path = webui_sidebar_state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "pinned_keys": ["websocket:a", "websocket:a", "", 123], + "archived_keys": ["websocket:b"], + "title_overrides": {"websocket:a": " Release notes ", "bad": ""}, + "tags_by_key": {"websocket:a": ["work", "work", ""]}, + "collapsed_groups": {"Earlier": 1}, + "view": {"density": "tiny", "show_archived": True, "sort": "nope"}, + } + ), + encoding="utf-8", + ) + + state = read_webui_sidebar_state() + + assert state["schema_version"] == 1 + assert state["pinned_keys"] == ["websocket:a"] + assert state["archived_keys"] == ["websocket:b"] + assert state["title_overrides"] == {"websocket:a": "Release notes"} + assert state["tags_by_key"] == {"websocket:a": ["work"]} + assert state["collapsed_groups"] == {"Earlier": True} + assert state["view"] == { + "density": "comfortable", + "show_previews": False, + "show_timestamps": False, + "show_archived": True, + "sort": "updated_desc", + } + + +def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + + state = write_webui_sidebar_state( + { + "pinned_keys": ["websocket:a"], + "archived_keys": ["websocket:b"], + "title_overrides": {"websocket:a": "Release"}, + "view": {"density": "compact", "show_previews": True}, + } + ) + + assert state["pinned_keys"] == ["websocket:a"] + assert state["archived_keys"] == ["websocket:b"] + assert state["title_overrides"] == {"websocket:a": "Release"} + assert state["view"]["density"] == "compact" + assert state["view"]["show_previews"] is True + assert webui_sidebar_state_path().is_file() + assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"] diff --git a/tests/utils/test_webui_thread_disk.py b/tests/utils/test_webui_thread_disk.py index 36680b458..53094d65b 100644 --- a/tests/utils/test_webui_thread_disk.py +++ b/tests/utils/test_webui_thread_disk.py @@ -2,8 +2,8 @@ from __future__ import annotations -from nanobot.utils.webui_thread_disk import delete_webui_thread, webui_thread_file_path -from nanobot.utils.webui_transcript import append_transcript_object, webui_transcript_path +from nanobot.webui.thread_disk import delete_webui_thread, webui_thread_file_path +from nanobot.webui.transcript import append_transcript_object, webui_transcript_path def test_delete_webui_thread_removes_legacy_json_and_transcript(tmp_path, monkeypatch) -> None: diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 42736c9b1..e910ca4a0 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -2,7 +2,7 @@ from __future__ import annotations -from nanobot.utils.webui_transcript import ( +from nanobot.webui.transcript import ( WEBUI_TRANSCRIPT_SCHEMA_VERSION, append_transcript_object, read_transcript_lines, @@ -294,7 +294,7 @@ def test_replay_keeps_new_file_edit_after_reasoning_in_order(tmp_path, monkeypat def test_build_response_schema(monkeypatch, tmp_path) -> None: - from nanobot.utils.webui_transcript import build_webui_thread_response + from nanobot.webui.transcript import build_webui_thread_response monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) key = "websocket:t3" diff --git a/tests/utils/test_webui_turn_helpers.py b/tests/utils/test_webui_turn_helpers.py index f3c0b174b..be01c5f45 100644 --- a/tests/utils/test_webui_turn_helpers.py +++ b/tests/utils/test_webui_turn_helpers.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from nanobot.bus.events import InboundMessage -from nanobot.utils import webui_turn_helpers as wth +from nanobot.session import webui_turns as wth @pytest.fixture(autouse=True) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 7ff9bae20..c303446e2 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,13 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { DeleteConfirm } from "@/components/DeleteConfirm"; +import { RenameChatDialog } from "@/components/RenameChatDialog"; import { Sidebar } from "@/components/Sidebar"; +import { SessionSearchDialog } from "@/components/SessionSearchDialog"; import { SettingsView } from "@/components/settings/SettingsView"; import { ThreadShell } from "@/components/thread/ThreadShell"; -import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { useSessions } from "@/hooks/useSessions"; import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh"; +import { useSidebarState } from "@/hooks/useSidebarState"; import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; import { @@ -37,6 +40,7 @@ type BootState = }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; +const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1"; const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; const SIDEBAR_WIDTH = 272; const TOKEN_REFRESH_MARGIN_MS = 30_000; @@ -121,6 +125,29 @@ function readSidebarOpen(): boolean { } } +function readCompletedRunChatIds(): Set { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(COMPLETED_RUNS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((item): item is string => typeof item === "string")); + } catch { + return new Set(); + } +} + +function writeCompletedRunChatIds(chatIds: Set): void { + try { + window.localStorage.setItem( + COMPLETED_RUNS_STORAGE_KEY, + JSON.stringify(Array.from(chatIds)), + ); + } catch { + // ignore storage errors (private mode, etc.) + } +} + export default function App() { const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); @@ -293,18 +320,28 @@ function Shell({ const { client } = useClient(); const { theme, toggle } = useTheme(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); + const { state: sidebarState, update: updateSidebarState } = + useSidebarState(sessions, !loading); const [activeKey, setActiveKey] = useState(null); const [view, setView] = useState("chat"); const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(readSidebarOpen); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [pendingDelete, setPendingDelete] = useState<{ key: string; label: string; } | null>(null); + const [pendingRename, setPendingRename] = useState<{ + key: string; + label: string; + } | null>(null); const restartSawDisconnectRef = useRef(false); const [restartToast, setRestartToast] = useState(null); const [isRestarting, setIsRestarting] = useState(false); + const [runningChatIds, setRunningChatIds] = useState>(() => new Set()); + const [completedChatIds, setCompletedChatIds] = useState>(readCompletedRunChatIds); + const runningChatIdsRef = useRef>(new Set()); useEffect(() => { try { @@ -317,12 +354,58 @@ function Shell({ } }, [desktopSidebarOpen]); - + useEffect(() => { + writeCompletedRunChatIds(completedChatIds); + }, [completedChatIds]); const activeSession = useMemo(() => { if (!activeKey) return null; return sessions.find((s) => s.key === activeKey) ?? null; }, [sessions, activeKey]); + const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); + const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]); + + useEffect(() => { + if (loading) return; + const knownChatIds = new Set(sessions.map((session) => session.chatId)); + setCompletedChatIds((current) => { + const next = new Set( + Array.from(current).filter((chatId) => knownChatIds.has(chatId)), + ); + return next.size === current.size ? current : next; + }); + }, [loading, sessions]); + + useEffect(() => { + if (loading) return; + const activeRunIds = sessions + .filter((session) => typeof session.runStartedAt === "number") + .map((session) => session.chatId); + if (activeRunIds.length === 0) return; + + for (const chatId of activeRunIds) { + client.attach(chatId); + } + setRunningChatIds((current) => { + let changed = false; + const next = new Set(current); + for (const chatId of activeRunIds) { + if (!next.has(chatId)) changed = true; + next.add(chatId); + } + if (!changed) return current; + runningChatIdsRef.current = next; + return next; + }); + setCompletedChatIds((current) => { + let changed = false; + const next = new Set(current); + for (const chatId of activeRunIds) { + if (next.delete(chatId)) changed = true; + } + return changed ? next : current; + }); + }, [client, loading, sessions]); const closeDesktopSidebar = useCallback(() => { setDesktopSidebarOpen(false); @@ -364,14 +447,129 @@ function Shell({ const onSelectChat = useCallback( (key: string) => { + const selectedChatId = sessions.find((session) => session.key === key)?.chatId; + if (selectedChatId) { + setCompletedChatIds((current) => { + if (!current.has(selectedChatId)) return current; + const next = new Set(current); + next.delete(selectedChatId); + return next; + }); + } setActiveKey(key); setView("chat"); setMobileSidebarOpen(false); }, - [], + [sessions], + ); + + const onTogglePin = useCallback( + (key: string) => { + void updateSidebarState((current) => { + const pinned = new Set(current.pinned_keys); + if (pinned.has(key)) { + pinned.delete(key); + } else { + pinned.add(key); + } + return { + ...current, + pinned_keys: Array.from(pinned), + }; + }); + }, + [updateSidebarState], + ); + + const onRequestRename = useCallback((key: string, label: string) => { + setPendingRename({ key, label }); + }, []); + + const onConfirmRename = useCallback( + (title: string) => { + if (!pendingRename) return; + const key = pendingRename.key; + setPendingRename(null); + void updateSidebarState((current) => { + const titleOverrides = { ...current.title_overrides }; + const cleaned = title.trim(); + if (cleaned) { + titleOverrides[key] = cleaned; + } else { + delete titleOverrides[key]; + } + return { + ...current, + title_overrides: titleOverrides, + }; + }); + }, + [pendingRename, updateSidebarState], + ); + + const onToggleArchive = useCallback( + (key: string) => { + void updateSidebarState((current) => { + const archived = new Set(current.archived_keys); + const pinned = current.pinned_keys.filter((item) => item !== key); + if (archived.has(key)) { + archived.delete(key); + } else { + archived.add(key); + } + return { + ...current, + pinned_keys: pinned, + archived_keys: Array.from(archived), + }; + }); + if (activeKey === key && !sidebarState.archived_keys.includes(key)) { + const archived = new Set([...sidebarState.archived_keys, key]); + const next = sessions.find((session) => !archived.has(session.key)); + setActiveKey(next?.key ?? null); + } + }, + [activeKey, sessions, sidebarState.archived_keys, updateSidebarState], + ); + + const onToggleArchived = useCallback(() => { + void updateSidebarState((current) => ({ + ...current, + view: { + ...current.view, + show_archived: !current.view.show_archived, + }, + })); + }, [updateSidebarState]); + + const onUpdateSidebarView = useCallback( + (viewUpdate: Partial) => { + void updateSidebarState((current) => ({ + ...current, + view: { + ...current.view, + ...viewUpdate, + }, + })); + }, + [updateSidebarState], + ); + + const onOpenSessionSearch = useCallback(() => { + setMobileSidebarOpen(false); + setSessionSearchOpen(true); + }, []); + + const onSelectSearchResult = useCallback( + (key: string) => { + setSessionSearchOpen(false); + onSelectChat(key); + }, + [onSelectChat], ); const onOpenSettings = useCallback(() => { + setSessionSearchOpen(false); setView("settings"); setMobileSidebarOpen(false); }, []); @@ -405,6 +603,35 @@ function Shell({ }); }, [client, onModelNameChange]); + useEffect(() => { + return client.onRunStatus((chatId, startedAt) => { + if (startedAt != null) { + const nextRunning = new Set(runningChatIdsRef.current); + nextRunning.add(chatId); + runningChatIdsRef.current = nextRunning; + setRunningChatIds(nextRunning); + setCompletedChatIds((current) => { + if (!current.has(chatId)) return current; + const next = new Set(current); + next.delete(chatId); + return next; + }); + return; + } + + if (!runningChatIdsRef.current.has(chatId)) return; + const nextRunning = new Set(runningChatIdsRef.current); + nextRunning.delete(chatId); + runningChatIdsRef.current = nextRunning; + setRunningChatIds(nextRunning); + setCompletedChatIds((current) => { + const next = new Set(current); + next.add(chatId); + return next; + }); + }); + }, [client]); + useEffect(() => { return client.onStatus((status) => { let startedAt = 0; @@ -452,7 +679,8 @@ function Shell({ }, [pendingDelete, deleteChat, activeKey, sessions]); const headerTitle = activeSession - ? activeSession.title || + ? sidebarState.title_overrides[activeSession.key] || + activeSession.title || deriveTitle(activeSession.preview, t("chat.newChat")) : t("app.brand"); @@ -476,7 +704,21 @@ function Shell({ onSelect: onSelectChat, onRequestDelete: (key: string, label: string) => setPendingDelete({ key, label }), + onTogglePin, + onRequestRename, + onToggleArchive, onOpenSettings, + onOpenSearch: onOpenSessionSearch, + onToggleArchived, + onUpdateView: onUpdateSidebarView, + pinnedKeys: sidebarState.pinned_keys, + archivedKeys: sidebarState.archived_keys, + titleOverrides: sidebarState.title_overrides, + runningChatIds: runningChatIdList, + completedChatIds: completedChatIdList, + viewState: sidebarState.view, + showArchived: sidebarState.view.show_archived, + archivedCount: sidebarState.archived_keys.length, }; const showMainSidebar = view !== "settings"; @@ -513,14 +755,32 @@ function Shell({ - + {t("sidebar.navigation")} + ) : null} + {showMainSidebar ? ( + + ) : null} +
setPendingDelete(null)} onConfirm={onConfirmDelete} /> + setPendingRename(null)} + onConfirm={onConfirmRename} + /> {restartToast ? (
void; onRequestDelete: (key: string, label: string) => void; + onTogglePin: (key: string) => void; + onRequestRename: (key: string, label: string) => void; + onToggleArchive: (key: string) => void; + pinnedKeys?: string[]; + archivedKeys?: string[]; + titleOverrides?: Record; + runningChatIds?: string[]; + completedChatIds?: string[]; + density?: SidebarDensity; + showPreviews?: boolean; + showTimestamps?: boolean; + sort?: SidebarSortMode; + showArchived?: boolean; + actionMenuPortalContainer?: HTMLElement | null; loading?: boolean; emptyLabel?: string; } @@ -25,6 +47,20 @@ export function ChatList({ activeKey, onSelect, onRequestDelete, + onTogglePin, + onRequestRename, + onToggleArchive, + pinnedKeys = [], + archivedKeys = [], + titleOverrides = {}, + runningChatIds = [], + completedChatIds = [], + density = "comfortable", + showPreviews = false, + showTimestamps = false, + sort = "updated_desc", + showArchived = false, + actionMenuPortalContainer, loading, emptyLabel, }: ChatListProps) { @@ -46,10 +82,25 @@ export function ChatList({ } const groups = groupSessions(sessions, { + pinned: t("chat.groups.pinned"), + all: t("chat.groups.all"), today: t("chat.groups.today"), yesterday: t("chat.groups.yesterday"), earlier: t("chat.groups.earlier"), + archived: t("chat.groups.archived"), + fallbackTitle: t("chat.newChat"), + }, { + pinnedKeys, + archivedKeys, + titleOverrides, + showArchived, + sort, }); + const pinned = new Set(pinnedKeys); + const archived = new Set(archivedKeys); + const running = new Set(runningChatIds); + const completed = new Set(completedChatIds); + const compact = density === "compact"; return (
@@ -66,15 +117,29 @@ export function ChatList({ id: s.chatId.slice(0, 6), }); const generatedTitle = s.title?.trim() || ""; - const title = - generatedTitle || deriveTitle(s.preview, t("chat.newChat")); + const title = displayTitle(s, titleOverrides, t("chat.newChat")); const tooltipTitle = - generatedTitle || deriveTitle(s.preview, fallbackTitle); + titleOverrides[s.key]?.trim() || + generatedTitle || + deriveTitle(s.preview, fallbackTitle); + const isPinned = pinned.has(s.key); + const isArchived = archived.has(s.key); + const preview = s.preview.trim(); + const showPreview = showPreviews && preview && preview !== title; + const timestamp = showTimestamps + ? relativeTime(s.updatedAt ?? s.createdAt) + : ""; + const activityState = running.has(s.chatId) + ? "running" + : completed.has(s.chatId) + ? "complete" + : null; return (
  • onSelect(s.key)} title={tooltipTitle} - className="min-w-0 flex-1 overflow-hidden py-1.5 text-left" + className={cn( + "min-w-0 flex-1 overflow-hidden text-left", + compact ? "py-1" : "py-1.5", + )} > {title} + {showPreview ? ( + + {preview} + + ) : null} + {timestamp ? ( + + {timestamp} + + ) : null} + event.preventDefault()} > + onTogglePin(s.key)} + > + {isPinned ? ( + + ) : ( + + )} + {isPinned ? t("chat.unpin") : t("chat.pin")} + + onRequestRename(s.key, title)} + > + + {t("chat.rename")} + + onToggleArchive(s.key)} + > + {isArchived ? ( + + ) : ( + + )} + {isArchived ? t("chat.unarchive") : t("chat.archive")} + { window.setTimeout(() => onRequestDelete(s.key, title), 0); @@ -127,16 +233,85 @@ export function ChatList({ ); } +function SessionActivityIndicator({ + state, +}: { + state: "running" | "complete" | null; +}) { + const { t } = useTranslation(); + + if (state === "running") { + const label = t("chat.activity.running"); + return ( + + + + ); + } + + if (state === "complete") { + const label = t("chat.activity.complete"); + return ( + + + + ); + } + + return
    - + + + {props.archivedCount ? ( + + ) : null}
    @@ -132,3 +163,83 @@ export function Sidebar(props: SidebarProps) { ); } + +function SidebarViewMenu({ + view, + onUpdateView, +}: { + view?: SidebarViewState; + onUpdateView: (view: Partial) => void; +}) { + const { t } = useTranslation(); + const sort = view?.sort ?? "updated_desc"; + const setSort = (value: string) => { + if (isSidebarSortMode(value)) onUpdateView({ sort: value }); + }; + + return ( + + + + + + + {t("sidebar.viewOptions")} + + + onUpdateView({ density: checked ? "compact" : "comfortable" }) + } + onSelect={(event) => event.preventDefault()} + > + {t("sidebar.compactList")} + + + onUpdateView({ show_previews: Boolean(checked) }) + } + onSelect={(event) => event.preventDefault()} + > + {t("sidebar.showPreviews")} + + + onUpdateView({ show_timestamps: Boolean(checked) }) + } + onSelect={(event) => event.preventDefault()} + > + {t("sidebar.showTimestamps")} + + + + {t("sidebar.sortLabel")} + + + + {t("sidebar.sortUpdated")} + + + {t("sidebar.sortCreated")} + + + {t("sidebar.sortTitle")} + + + + + ); +} + +function isSidebarSortMode(value: string): value is SidebarSortMode { + return value === "updated_desc" || value === "created_desc" || value === "title_asc"; +} diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 96cd2b54c..d4a47c1f0 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1,27 +1,45 @@ -import { useCallback, useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; +import { + Activity, Bot, Brain, - ChevronLeft, - ChevronDown, Check, + ChevronDown, + ChevronLeft, + ChevronRight, Cloud, Cpu, Database, Eye, EyeOff, - Pencil, Gem, + Globe2, Grid3X3, + HardDrive, Hexagon, - Loader2, - LogOut, + ImageIcon, + Info, KeyRound, Layers, + Loader2, + LogOut, Moon, Orbit, + Palette, + Pencil, RotateCcw, - Settings, + Search, + Server, + ShieldCheck, + SlidersHorizontal, Sparkles, Triangle, Waves, @@ -41,16 +59,58 @@ import { import { Input } from "@/components/ui/input"; import { fetchSettings, + updateImageGenerationSettings, updateProviderSettings, updateSettings, updateWebSearchSettings, } from "@/lib/api"; import { cn } from "@/lib/utils"; import { useClient } from "@/providers/ClientProvider"; -import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types"; +import type { + ImageGenerationSettingsUpdate, + SettingsPayload, + WebSearchSettingsUpdate, +} from "@/lib/types"; -type SettingsSectionKey = "general" | "byok"; -type ByokPaneKey = "llm" | "web-search"; +type SettingsSectionKey = + | "overview" + | "appearance" + | "models" + | "providers" + | "image" + | "web" + | "runtime" + | "advanced"; + +type LocalDensity = "comfortable" | "compact"; +type LocalActivityMode = "auto" | "expanded"; + +interface LocalPreferences { + density: LocalDensity; + activityMode: LocalActivityMode; + codeWrap: boolean; +} + +interface AgentSettingsDraft { + model: string; + provider: string; + modelPreset: string; + timezone: string; + botName: string; + botIcon: string; + toolHintMaxLength: number; +} + +type PendingRestartSection = "runtime" | "web" | "image"; +type PendingRestartSections = Record; + +const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; + +const DEFAULT_LOCAL_PREFS: LocalPreferences = { + density: "comfortable", + activityMode: "auto", + codeWrap: true, +}; const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map( ["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [ @@ -59,6 +119,14 @@ const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map( ]), ); +const IMAGE_ASPECT_RATIO_OPTIONS = ["1:1", "3:4", "9:16", "4:3", "16:9", "3:2", "2:3", "21:9"]; +const IMAGE_SIZE_OPTIONS = ["1K", "2K", "4K", "1024x1024", "1536x1024", "1024x1536"]; +const EMPTY_PENDING_RESTART_SECTIONS: PendingRestartSections = { + runtime: false, + web: false, + image: false, +}; + interface SettingsViewProps { theme: "light" | "dark"; onToggleTheme: () => void; @@ -69,6 +137,34 @@ interface SettingsViewProps { isRestarting?: boolean; } +function readLocalPreferences(): LocalPreferences { + try { + const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY); + if (!raw) return DEFAULT_LOCAL_PREFS; + const parsed = JSON.parse(raw) as Partial; + return { + density: parsed.density === "compact" ? "compact" : "comfortable", + activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto", + codeWrap: parsed.codeWrap !== false, + }; + } catch { + return DEFAULT_LOCAL_PREFS; + } +} + +function modelPresetValue(payload: SettingsPayload): string { + return payload.agent.model_preset || "default"; +} + +function defaultPreset(payload: SettingsPayload): SettingsPayload["model_presets"][number] | null { + return payload.model_presets.find((preset) => preset.is_default) ?? null; +} + +function editableDefaultProvider(payload: SettingsPayload): string { + const base = defaultPreset(payload); + return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? ""; +} + export function SettingsView({ theme, onToggleTheme, @@ -85,35 +181,87 @@ export function SettingsView({ const [saving, setSaving] = useState(false); const [providerSaving, setProviderSaving] = useState(null); const [webSearchSaving, setWebSearchSaving] = useState(false); + const [imageGenerationSaving, setImageGenerationSaving] = useState(false); const [error, setError] = useState(null); - const [activeSection, setActiveSection] = useState("general"); + const [activeSection, setActiveSection] = useState("overview"); const [expandedProvider, setExpandedProvider] = useState(null); + const [providerQuery, setProviderQuery] = useState(""); const [providerForms, setProviderForms] = useState>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState>({}); const [editingProviderKeys, setEditingProviderKeys] = useState>({}); + const [pendingRestartSections, setPendingRestartSections] = useState( + EMPTY_PENDING_RESTART_SECTIONS, + ); + const [localPrefs, setLocalPrefs] = useState(() => readLocalPreferences()); const [webSearchForm, setWebSearchForm] = useState({ provider: "duckduckgo", apiKey: "", baseUrl: "", + maxResults: 5, + timeout: 30, + useJinaReader: true, + }); + const [imageGenerationForm, setImageGenerationForm] = useState({ + enabled: false, + provider: "openrouter", + model: "openai/gpt-5.4-image-2", + defaultAspectRatio: "1:1", + defaultImageSize: "1K", + maxImagesPerTurn: 4, }); const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false); const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false); - const [form, setForm] = useState({ + const [form, setForm] = useState({ model: "", provider: "", + modelPreset: "default", + timezone: "UTC", + botName: "nanobot", + botIcon: "", + toolHintMaxLength: 40, }); + const text = useCallback( + (key: string, fallback: string, options?: Record) => + t(key, { defaultValue: fallback, ...(options ?? {}) }), + [t], + ); + const applyPayload = useCallback((payload: SettingsPayload) => { + const fallbackDefault = defaultPreset(payload); setSettings(payload); setForm({ - model: payload.agent.model, - provider: payload.agent.provider, + model: fallbackDefault?.model ?? payload.agent.model, + provider: editableDefaultProvider(payload), + modelPreset: modelPresetValue(payload), + timezone: payload.agent.timezone, + botName: payload.agent.bot_name, + botIcon: payload.agent.bot_icon, + toolHintMaxLength: payload.agent.tool_hint_max_length, }); setWebSearchForm((prev) => ({ provider: payload.web_search.provider, apiKey: prev.provider === payload.web_search.provider ? prev.apiKey ?? "" : "", baseUrl: payload.web_search.base_url ?? "", + maxResults: payload.web_search.max_results, + timeout: payload.web_search.timeout, + useJinaReader: payload.web.fetch.use_jina_reader, })); + setImageGenerationForm({ + enabled: payload.image_generation.enabled, + provider: payload.image_generation.provider, + model: payload.image_generation.model, + defaultAspectRatio: payload.image_generation.default_aspect_ratio, + defaultImageSize: payload.image_generation.default_image_size, + maxImagesPerTurn: payload.image_generation.max_images_per_turn, + }); + if (payload.restart_required_sections) { + setPendingRestartSections({ + runtime: payload.restart_required_sections.includes("runtime"), + web: payload.restart_required_sections.includes("web"), + image: payload.restart_required_sections.includes("image"), + }); + } }, []); useEffect(() => { @@ -137,6 +285,14 @@ export function SettingsView({ }; }, [applyPayload, token]); + useEffect(() => { + try { + window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs)); + } catch { + // Browser-only preferences should never block settings. + } + }, [localPrefs]); + useEffect(() => { if (!settings) return; setProviderForms((prev) => { @@ -151,21 +307,59 @@ export function SettingsView({ }); }, [settings]); - const dirty = useMemo(() => { + const modelDirty = useMemo(() => { if (!settings) return false; + const preset = modelPresetValue(settings); + const base = defaultPreset(settings); return ( - form.model !== settings.agent.model || - form.provider !== settings.agent.provider + form.modelPreset !== preset || + (form.modelPreset === "default" && + (form.model !== (base?.model ?? settings.agent.model) || + form.provider !== editableDefaultProvider(settings))) ); }, [form, settings]); - const save = async () => { - if (!dirty || saving) return; + const runtimeDirty = useMemo(() => { + if (!settings) return false; + return ( + form.timezone !== settings.agent.timezone || + form.botName !== settings.agent.bot_name || + form.botIcon !== settings.agent.bot_icon || + form.toolHintMaxLength !== settings.agent.tool_hint_max_length + ); + }, [form, settings]); + + const imageGenerationDirty = useMemo(() => { + if (!settings) return false; + return ( + imageGenerationForm.enabled !== settings.image_generation.enabled || + imageGenerationForm.provider !== settings.image_generation.provider || + imageGenerationForm.model !== settings.image_generation.model || + imageGenerationForm.defaultAspectRatio !== settings.image_generation.default_aspect_ratio || + imageGenerationForm.defaultImageSize !== settings.image_generation.default_image_size || + imageGenerationForm.maxImagesPerTurn !== settings.image_generation.max_images_per_turn + ); + }, [imageGenerationForm, settings]); + + const hasPendingRestart = useMemo( + () => + !!settings?.requires_restart || + pendingRestartSections.runtime || + pendingRestartSections.web || + pendingRestartSections.image, + [pendingRestartSections, settings?.requires_restart], + ); + + const saveModelSettings = async () => { + if (!settings || !modelDirty || saving) return; setSaving(true); try { + const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model; + const defaultProvider = editableDefaultProvider(settings); const payload = await updateSettings(token, { - model: form.model, - ...(form.provider ? { provider: form.provider } : {}), + modelPreset: form.modelPreset, + ...(form.modelPreset === "default" && form.model !== defaultModel ? { model: form.model } : {}), + ...(form.modelPreset === "default" && form.provider !== defaultProvider ? { provider: form.provider } : {}), }); applyPayload(payload); onModelNameChange(payload.agent.model || null); @@ -177,6 +371,45 @@ export function SettingsView({ } }; + const saveRuntimeSettings = async () => { + if (!settings || !runtimeDirty || saving) return; + setSaving(true); + try { + const payload = await updateSettings(token, { + timezone: form.timezone, + botName: form.botName, + botIcon: form.botIcon, + toolHintMaxLength: form.toolHintMaxLength, + }); + applyPayload(payload); + if (payload.requires_restart) { + setPendingRestartSections((prev) => ({ ...prev, runtime: true })); + } + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + const saveImageGenerationSettings = async () => { + if (!settings || !imageGenerationDirty || imageGenerationSaving) return; + setImageGenerationSaving(true); + try { + const payload = await updateImageGenerationSettings(token, imageGenerationForm); + applyPayload(payload); + if (payload.requires_restart) { + setPendingRestartSections((prev) => ({ ...prev, image: true })); + } + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setImageGenerationSaving(false); + } + }; + const saveProvider = async (providerName: string) => { if (providerSaving) return; const provider = settings?.providers.find((item) => item.name === providerName); @@ -196,6 +429,9 @@ export function SettingsView({ apiBase: providerForm.apiBase.trim(), }); applyPayload(payload); + if (payload.requires_restart) { + setPendingRestartSections((prev) => ({ ...prev, image: true })); + } setProviderForms((prev) => ({ ...prev, [providerName]: { @@ -235,15 +471,29 @@ export function SettingsView({ setWebSearchSaving(true); try { - const update: WebSearchSettingsUpdate = { provider: webSearchForm.provider }; + const webFetchRestartRequired = + (webSearchForm.useJinaReader ?? settings.web.fetch.use_jina_reader) !== + settings.web.fetch.use_jina_reader; + const update: WebSearchSettingsUpdate = { + provider: webSearchForm.provider, + maxResults: webSearchForm.maxResults, + timeout: webSearchForm.timeout, + useJinaReader: webSearchForm.useJinaReader, + }; if (provider.credential === "api_key" && apiKey) update.apiKey = apiKey; if (provider.credential === "base_url") update.baseUrl = baseUrl; const payload = await updateWebSearchSettings(token, update); applyPayload(payload); + if (payload.requires_restart || webFetchRestartRequired) { + setPendingRestartSections((prev) => ({ ...prev, web: true })); + } setWebSearchForm((prev) => ({ provider: payload.web_search.provider, apiKey: "", baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "", + maxResults: payload.web_search.max_results, + timeout: payload.web_search.timeout, + useJinaReader: payload.web.fetch.use_jina_reader, })); setWebSearchKeyVisible(false); setWebSearchKeyEditing(false); @@ -280,6 +530,9 @@ export function SettingsView({ provider: settings.web_search.provider, apiKey: "", baseUrl: settings.web_search.base_url ?? "", + maxResults: settings.web_search.max_results, + timeout: settings.web_search.timeout, + useJinaReader: settings.web.fetch.use_jina_reader, }); setWebSearchKeyVisible(false); setWebSearchKeyEditing(false); @@ -287,11 +540,14 @@ export function SettingsView({ const handleWebSearchProviderChange = useCallback((provider: string) => { if (!settings) return; - setWebSearchForm({ + setWebSearchForm((prev) => ({ provider, apiKey: "", baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "", - }); + maxResults: prev.maxResults ?? settings.web_search.max_results, + timeout: prev.timeout ?? settings.web_search.timeout, + useJinaReader: prev.useJinaReader ?? settings.web.fetch.use_jina_reader, + })); setWebSearchKeyVisible(false); setWebSearchKeyEditing(false); }, [settings]); @@ -318,8 +574,132 @@ export function SettingsView({ }); }; + const renderSection = () => { + if (!settings) return null; + switch (activeSection) { + case "overview": + return ( + + ); + case "appearance": + return ( + + ); + case "models": + return ( + setActiveSection("providers")} + /> + ); + case "providers": + return ( + + setProviderForms((prev) => ({ + ...prev, + [provider]: { + apiKey: prev[provider]?.apiKey ?? "", + apiBase: prev[provider]?.apiBase ?? "", + ...value, + }, + })) + } + onSaveProvider={saveProvider} + onResetProviderDraft={resetProviderDraft} + imageProviderRestartPending={pendingRestartSections.image} + onRestart={onRestart} + isRestarting={isRestarting} + /> + ); + case "image": + return ( + setActiveSection("providers")} + onRestart={onRestart} + isRestarting={isRestarting} + requiresRestartPending={pendingRestartSections.image} + /> + ); + case "web": + return ( + setWebSearchKeyVisible((visible) => !visible)} + onToggleKeyEditing={() => { + setWebSearchKeyEditing((editing) => !editing); + setWebSearchKeyVisible(false); + setWebSearchForm((prev) => ({ ...prev, apiKey: "" })); + }} + onReset={resetWebSearchDraft} + onSave={saveWebSearch} + onRestart={onRestart} + isRestarting={isRestarting} + requiresRestartPending={pendingRestartSections.web} + /> + ); + case "runtime": + return ( + + ); + case "advanced": + return ; + default: + return null; + } + }; + return ( -
    +
    -
    -
    +
    +

    {t("settings.sidebar.title")}

    -

    - {t(`settings.nav.${activeSection}`)} +

    + {text(`settings.nav.${activeSection}`, titleForSection(activeSection))}

    @@ -356,59 +736,7 @@ export function SettingsView({ {error}
    ) : null} - {activeSection === "general" ? ( - setActiveSection("byok")} - /> - ) : ( - - setProviderForms((prev) => ({ - ...prev, - [provider]: { - apiKey: prev[provider]?.apiKey ?? "", - apiBase: prev[provider]?.apiBase ?? "", - ...value, - }, - })) - } - onSaveProvider={saveProvider} - onChangeWebSearchForm={setWebSearchForm} - onChangeWebSearchProvider={handleWebSearchProviderChange} - onToggleWebSearchKey={() => setWebSearchKeyVisible((visible) => !visible)} - onToggleWebSearchKeyEditing={() => { - setWebSearchKeyEditing((editing) => !editing); - setWebSearchKeyVisible(false); - setWebSearchForm((prev) => ({ ...prev, apiKey: "" })); - }} - onResetProviderDraft={resetProviderDraft} - onResetWebSearchDraft={resetWebSearchDraft} - onSaveWebSearch={saveWebSearch} - /> - )} + {renderSection()}
    ) : null}
    @@ -417,10 +745,20 @@ export function SettingsView({ ); } -const SETTINGS_NAV_ITEMS = [ - { key: "general", icon: Settings }, - { key: "byok", icon: KeyRound }, -] as const; +const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fallback: string }> = [ + { key: "overview", icon: Activity, fallback: "Overview" }, + { key: "appearance", icon: Palette, fallback: "Appearance" }, + { key: "models", icon: SlidersHorizontal, fallback: "Models" }, + { key: "providers", icon: KeyRound, fallback: "Providers" }, + { key: "image", icon: ImageIcon, fallback: "Image" }, + { key: "web", icon: Globe2, fallback: "Web" }, + { key: "runtime", icon: Server, fallback: "Runtime" }, + { key: "advanced", icon: ShieldCheck, fallback: "Advanced" }, +]; + +function titleForSection(section: SettingsSectionKey): string { + return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings"; +} function SettingsSidebar({ activeSection, @@ -435,23 +773,26 @@ function SettingsSidebar({ }) { const { t } = useTranslation(); return ( -
    + ); +} + +function AppearanceSettings({ + theme, + onToggleTheme, + localPrefs, + onChangeLocalPrefs, }: { theme: "light" | "dark"; onToggleTheme: () => void; - form: { - model: string; - provider: string; - }; - setForm: Dispatch>; - settings: SettingsPayload; - dirty: boolean; - saving: boolean; - onSave: () => void; - onRestart?: () => void; - isRestarting?: boolean; - onOpenByok: () => void; + localPrefs: LocalPreferences; + onChangeLocalPrefs: Dispatch>; }) { const { t } = useTranslation(); - const configuredProviders = settings.providers.filter((provider) => provider.configured); - const providerValue = configuredProviders.some((provider) => provider.name === form.provider) - ? form.provider - : ""; + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); return ( -
    +
    {t("settings.sections.interface")} @@ -568,335 +1034,193 @@ function GeneralSettings({
    +
    + {tx("settings.sections.localPreferences", "Local preferences")} + + + + onChangeLocalPrefs((prev) => ({ ...prev, density: density as LocalDensity })) + } + /> + + + + onChangeLocalPrefs((prev) => ({ ...prev, activityMode: activityMode as LocalActivityMode })) + } + /> + + + onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))} + label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} + /> + + +
    +
    + ); +} + +function ModelsSettings({ + form, + setForm, + settings, + dirty, + saving, + onSave, + onOpenProviders, +}: { + form: AgentSettingsDraft; + setForm: Dispatch>; + settings: SettingsPayload; + dirty: boolean; + saving: boolean; + onSave: () => void; + onOpenProviders: () => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const configuredProviders = settings.providers.filter((provider) => provider.configured); + const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto"; + const providerOptions = showAutoProvider + ? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...configuredProviders] + : configuredProviders; + const providerValue = providerOptions.some((provider) => provider.name === form.provider) + ? form.provider + : ""; + const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset); + return ( +
    +
    + {tx("settings.sections.presets", "Presets")} +
    + {settings.model_presets.map((preset) => ( + + ))} +
    +
    +
    {t("settings.sections.ai")} - setForm((prev) => ({ ...prev, provider }))} - /> + {selectedPreset?.label ?? form.modelPreset} - - - setForm((prev) => ({ ...prev, model: event.target.value }))} - className="h-8 w-[280px] rounded-full text-[13px]" - /> - - - {(dirty || saving || settings.requires_restart) ? ( - - ) : null} + {form.modelPreset === "default" ? ( + <> + + setForm((prev) => ({ ...prev, provider }))} + /> + + + setForm((prev) => ({ ...prev, model: event.target.value }))} + className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" + /> + + + ) : ( + + + {selectedPreset?.model ?? settings.agent.model} + + + )} + {configuredProviders.length === 0 ? ( - ) : null}
    - - {onRestart && ( -
    - {t("settings.sections.system")} - - - - - - - {settings.runtime.config_path || t("settings.values.notAvailable")} - - - -
    - )}
    ); } -function ProviderPicker({ - providers, - value, - emptyLabel, - onChange, -}: { - providers: Array<{ name: string; label: string }>; - value: string; - emptyLabel: string; - onChange: (provider: string) => void; -}) { - const selectedProvider = providers.find((provider) => provider.name === value) ?? null; - const disabled = providers.length === 0; - - return ( - - - - - - {providers.map((provider) => { - const selected = provider.name === value; - return ( - onChange(provider.name)} - className={cn( - "flex cursor-default items-center justify-between gap-2 rounded-[12px] px-3 py-2 text-[13px]", - "focus:bg-muted focus:text-foreground", - selected && "bg-primary/10 text-primary focus:bg-primary/12 focus:text-primary", - )} - > - {provider.label} - {selected ? : null} - - ); - })} - - - ); -} - -function WebSearchByokSettings({ - settings, - form, - keyVisible, - keyEditing, - saving, - onChangeForm, - onChangeProvider, - onToggleKey, - onToggleKeyEditing, - onSave, -}: { - settings: SettingsPayload; - form: WebSearchSettingsUpdate; - keyVisible: boolean; - keyEditing: boolean; - saving: boolean; - onChangeForm: Dispatch>; - onChangeProvider: (provider: string) => void; - onToggleKey: () => void; - onToggleKeyEditing: () => void; - onSave: () => void; -}) { - const { t } = useTranslation(); - const selectedProvider = - settings.web_search.providers.find((provider) => provider.name === form.provider) ?? - settings.web_search.providers[0]; - const hasExistingSecret = - selectedProvider?.credential === "api_key" && - form.provider === settings.web_search.provider && - !!settings.web_search.api_key_hint; - const showKeyInput = selectedProvider?.credential === "api_key" && (!hasExistingSecret || keyEditing); - const apiKey = form.apiKey?.trim() ?? ""; - const baseUrl = form.baseUrl?.trim() ?? ""; - const dirty = - form.provider !== settings.web_search.provider || - apiKey.length > 0 || - baseUrl !== (settings.web_search.base_url ?? ""); - const missingCredential = - selectedProvider?.credential === "api_key" - ? !apiKey && !hasExistingSecret - : selectedProvider?.credential === "base_url" - ? !baseUrl - : false; - - return ( -
    - - - - - - {selectedProvider?.credential === "none" ? ( - - - {t("settings.byok.webSearch.noCredentialRequired")} - - - ) : null} - - {selectedProvider?.credential === "api_key" ? ( - -
    - {showKeyInput ? ( - <> - - onChangeForm((prev) => ({ ...prev, apiKey: event.target.value })) - } - placeholder={ - hasExistingSecret - ? t("settings.byok.apiKeyConfiguredPlaceholder") - : t("settings.byok.apiKeyPlaceholder") - } - className="h-9 rounded-full pr-11 text-[13px]" - /> - - - ) : ( - <> -
    - {settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")} -
    - - - )} -
    -
    - ) : null} - - {selectedProvider?.credential === "base_url" ? ( - - - onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value })) - } - placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")} - className="h-9 w-[280px] rounded-full text-[13px]" - /> - - ) : null} - -
    -
    - {missingCredential - ? t("settings.byok.webSearch.missingCredential") - : t("settings.byok.webSearch.saveHint")} -
    - -
    -
    -
    - ); -} - -function ByokSettings({ +function ProvidersSettings({ settings, expandedProvider, providerForms, visibleProviderKeys, editingProviderKeys, providerSaving, - webSearchForm, - webSearchKeyVisible, - webSearchKeyEditing, - webSearchSaving, + query, + onQueryChange, onToggleProvider, onToggleProviderKey, onToggleProviderKeyEditing, onChangeProviderForm, onSaveProvider, - onChangeWebSearchForm, - onChangeWebSearchProvider, - onToggleWebSearchKey, - onToggleWebSearchKeyEditing, onResetProviderDraft, - onResetWebSearchDraft, - onSaveWebSearch, + imageProviderRestartPending, + onRestart, + isRestarting, }: { settings: SettingsPayload; expandedProvider: string | null; @@ -904,39 +1228,27 @@ function ByokSettings({ visibleProviderKeys: Record; editingProviderKeys: Record; providerSaving: string | null; - webSearchForm: WebSearchSettingsUpdate; - webSearchKeyVisible: boolean; - webSearchKeyEditing: boolean; - webSearchSaving: boolean; + query: string; + onQueryChange: (query: string) => void; onToggleProvider: (provider: string) => void; onToggleProviderKey: (provider: string) => void; onToggleProviderKeyEditing: (provider: string) => void; onChangeProviderForm: (provider: string, value: Partial<{ apiKey: string; apiBase: string }>) => void; onSaveProvider: (provider: string) => void; - onChangeWebSearchForm: Dispatch>; - onChangeWebSearchProvider: (provider: string) => void; - onToggleWebSearchKey: () => void; - onToggleWebSearchKeyEditing: () => void; onResetProviderDraft: (provider: string) => void; - onResetWebSearchDraft: () => void; - onSaveWebSearch: () => void; + imageProviderRestartPending: boolean; + onRestart?: () => void; + isRestarting?: boolean; }) { const { t } = useTranslation(); - const [activePane, setActivePane] = useState("llm"); - const [showAllUnconfigured, setShowAllUnconfigured] = useState(false); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const configuredProviders = settings.providers.filter((provider) => provider.configured); const unconfiguredProviders = useMemo( () => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)), [settings.providers], ); - const initialUnconfiguredCount = 6; - const visibleUnconfiguredProviders = showAllUnconfigured - ? unconfiguredProviders - : unconfiguredProviders.slice(0, initialUnconfiguredCount); - const hiddenUnconfiguredCount = Math.max( - 0, - unconfiguredProviders.length - visibleUnconfiguredProviders.length, - ); + const filteredConfigured = filterProviders(configuredProviders, query); + const filteredUnconfigured = filterProviders(unconfiguredProviders, query); const renderProviderRow = (provider: SettingsPayload["providers"][number]) => { const expanded = expandedProvider === provider.name; const form = providerForms[provider.name] ?? { @@ -953,10 +1265,7 @@ function ByokSettings({ const missingOptionalCredential = !apiKeyRequired && !provider.configured && !apiKey && !apiBase; return ( -
    +
    {expanded ? ( @@ -1057,7 +1362,15 @@ function ByokSettings({ className="h-9 rounded-full text-[13px]" /> -
    +
    + +
    +
    + ) : null} +
    + + onQueryChange(event.target.value)} + placeholder={tx("settings.providers.searchPlaceholder", "Search providers")} + className="h-10 rounded-full pl-9 text-[13px]" + /> +
    + - {panes.map((pane) => { - const selected = activePane === pane.key; + {filteredConfigured.map(renderProviderRow)} + + + {filteredUnconfigured.map(renderProviderRow)} + +
    + ); +} + +function ImageGenerationSettings({ + settings, + form, + dirty, + saving, + onChangeForm, + onSave, + onOpenProviders, + onRestart, + isRestarting, + requiresRestartPending, +}: { + settings: SettingsPayload; + form: ImageGenerationSettingsUpdate; + dirty: boolean; + saving: boolean; + onChangeForm: Dispatch>; + onSave: () => void; + onOpenProviders: () => void; + onRestart?: () => void; + isRestarting?: boolean; + requiresRestartPending: boolean; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const selectedProvider = + settings.image_generation.providers.find((provider) => provider.name === form.provider) ?? + settings.image_generation.providers[0]; + const providerConfigured = !!selectedProvider?.configured; + const missingCredential = form.enabled && !providerConfigured; + const aspectOptions = optionRowsWithCurrent( + IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })), + form.defaultAspectRatio, + ); + const sizeOptions = optionRowsWithCurrent( + IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })), + form.defaultImageSize, + ); + + return ( +
    +
    + {tx("settings.sections.imageGeneration", "Image generation")} + + + onChangeForm((prev) => ({ ...prev, enabled }))} + label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} + /> + + + onChangeForm((prev) => ({ ...prev, provider }))} + /> + + +
    + + {providerConfigured + ? tx("settings.values.configured", "Configured") + : tx("settings.values.notConfigured", "Not configured")} + + {!providerConfigured ? ( + + ) : null} +
    +
    + + + {selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")} + + +
    +
    + +
    + {tx("settings.sections.imageDefaults", "Defaults")} + + + onChangeForm((prev) => ({ ...prev, model: event.target.value }))} + className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]" + /> + + + + onChangeForm((prev) => ({ ...prev, defaultAspectRatio })) + } + /> + + + + onChangeForm((prev) => ({ ...prev, defaultImageSize })) + } + /> + + + + onChangeForm((prev) => ({ ...prev, maxImagesPerTurn })) + } + /> + + + + +
    +
    + ); +} + +function WebSettings({ + settings, + form, + keyVisible, + keyEditing, + saving, + onChangeForm, + onChangeProvider, + onToggleKey, + onToggleKeyEditing, + onReset, + onSave, + onRestart, + isRestarting, + requiresRestartPending, +}: { + settings: SettingsPayload; + form: WebSearchSettingsUpdate; + keyVisible: boolean; + keyEditing: boolean; + saving: boolean; + onChangeForm: Dispatch>; + onChangeProvider: (provider: string) => void; + onToggleKey: () => void; + onToggleKeyEditing: () => void; + onReset: () => void; + onSave: () => void; + onRestart?: () => void; + isRestarting?: boolean; + requiresRestartPending: boolean; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const selectedProvider = + settings.web_search.providers.find((provider) => provider.name === form.provider) ?? + settings.web_search.providers[0]; + const hasExistingSecret = + selectedProvider?.credential === "api_key" && + form.provider === settings.web_search.provider && + !!settings.web_search.api_key_hint; + const showKeyInput = selectedProvider?.credential === "api_key" && (!hasExistingSecret || keyEditing); + const apiKey = form.apiKey?.trim() ?? ""; + const baseUrl = form.baseUrl?.trim() ?? ""; + const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader; + const dirty = + form.provider !== settings.web_search.provider || + apiKey.length > 0 || + baseUrl !== (settings.web_search.base_url ?? "") || + form.maxResults !== settings.web_search.max_results || + form.timeout !== settings.web_search.timeout || + effectiveJinaReader !== settings.web.fetch.use_jina_reader; + const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader; + const missingCredential = + selectedProvider?.credential === "api_key" + ? !apiKey && !hasExistingSecret + : selectedProvider?.credential === "base_url" + ? !baseUrl + : false; + + return ( +
    +
    + {tx("settings.sections.webSearch", "Web search")} + + + + + + {selectedProvider?.credential === "none" ? ( + + {t("settings.byok.webSearch.noCredentialRequired")} + + ) : null} + + {selectedProvider?.credential === "api_key" ? ( + +
    + {showKeyInput ? ( + <> + + onChangeForm((prev) => ({ ...prev, apiKey: event.target.value })) + } + placeholder={ + hasExistingSecret + ? t("settings.byok.apiKeyConfiguredPlaceholder") + : t("settings.byok.apiKeyPlaceholder") + } + className="h-9 rounded-full pr-11 text-[13px]" + /> + + + ) : ( + <> +
    + {settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")} +
    + + + )} +
    +
    + ) : null} + + {selectedProvider?.credential === "base_url" ? ( + + + onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value })) + } + placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")} + className="h-9 w-[280px] rounded-full text-[13px]" + /> + + ) : null} +
    +
    + +
    + {tx("settings.sections.webBehavior", "Behavior")} + + + onChangeForm((prev) => ({ ...prev, maxResults }))} + /> + + + onChangeForm((prev) => ({ ...prev, timeout }))} + suffix="s" + /> + + + onChangeForm((prev) => ({ ...prev, useJinaReader }))} + label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} + /> + + + +
    +
    + ); +} + +function RuntimeSettings({ + form, + setForm, + settings, + dirty, + saving, + onSave, + onRestart, + isRestarting, + requiresRestartPending, +}: { + form: AgentSettingsDraft; + setForm: Dispatch>; + settings: SettingsPayload; + dirty: boolean; + saving: boolean; + onSave: () => void; + onRestart?: () => void; + isRestarting?: boolean; + requiresRestartPending: boolean; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + return ( +
    +
    + {tx("settings.sections.identity", "Identity")} + + + setForm((prev) => ({ ...prev, botName: event.target.value }))} + className="h-8 w-[220px] rounded-full text-[13px]" + /> + + + setForm((prev) => ({ ...prev, botIcon: event.target.value }))} + className="h-8 w-[120px] rounded-full text-center text-[13px]" + /> + + + setForm((prev) => ({ ...prev, timezone: event.target.value }))} + className="h-8 w-[220px] rounded-full text-[13px]" + /> + + + setForm((prev) => ({ ...prev, toolHintMaxLength }))} + /> + + + +
    + +
    + {t("settings.sections.system")} + + {onRestart && !requiresRestartPending ? ( + + + + ) : null} + + + + + + +
    +
    + ); +} + +function AdvancedSettings({ settings }: { settings: SettingsPayload }) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + return ( +
    +
    + {tx("settings.sections.safety", "Safety")} + + + + + + +
    + +
    + {tx("settings.sections.integrations", "Integrations")} + + + + + + + {tx("settings.actions.openDocs", "Open docs")} + + + +
    +
    + ); +} + +function ProviderPicker({ + providers, + value, + emptyLabel, + onChange, +}: { + providers: Array<{ name: string; label: string }>; + value: string; + emptyLabel: string; + onChange: (provider: string) => void; +}) { + const selectedProvider = providers.find((provider) => provider.name === value) ?? null; + const disabled = providers.length === 0; + + return ( + + + + + + {providers.map((provider) => { + const selected = provider.name === value; return ( - + {provider.label} + {selected ? : null} + ); })} -
    - {activePane === "llm" ? ( -
    -
    - -
    - {configuredProviders.length > 0 ? ( -
    - {configuredProviders.map(renderProviderRow)} -
    - ) : ( - {t("settings.byok.noConfiguredProviders")} - )} -
    -
    + + + ); +} -
    - -
    -
    - {visibleUnconfiguredProviders.map(renderProviderRow)} -
    -
    - {hiddenUnconfiguredCount > 0 ? ( - - ) : showAllUnconfigured && unconfiguredProviders.length > initialUnconfiguredCount ? ( - - ) : null} -
    -
    - ) : ( - - )} -
    +function ProviderSection({ + title, + count, + empty, + children, +}: { + title: string; + count: number; + empty: string; + children: ReactNode; +}) { + return ( +
    + +
    + {count > 0 ? ( +
    {children}
    + ) : ( + {empty} + )} +
    +
    ); } @@ -1224,6 +2098,34 @@ function providerVisibilityRank(provider: SettingsPayload["providers"][number]): return 200; } +function filterProviders( + providers: SettingsPayload["providers"], + query: string, +): SettingsPayload["providers"] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return providers; + return providers.filter((provider) => + `${provider.name} ${provider.label} ${provider.api_base ?? ""} ${provider.default_api_base ?? ""}` + .toLowerCase() + .includes(normalized), + ); +} + +function optionRowsWithCurrent( + options: Array<{ name: string; label: string }>, + value: string, +): Array<{ name: string; label: string }> { + if (!value || options.some((option) => option.name === value)) return options; + return [{ name: value, label: value }, ...options]; +} + +function providerLabel( + providers: Array<{ name: string; label: string }>, + value: string, +): string { + return providers.find((provider) => provider.name === value)?.label ?? value; +} + const PROVIDER_ICONS: Record = { custom: Hexagon, openrouter: Sparkles, @@ -1266,6 +2168,45 @@ function ProviderIcon({ provider }: { provider: string }) { ); } +function OverviewListRow({ + icon: Icon, + title, + value, + caption, + onClick, +}: { + icon: LucideIcon; + title: string; + value: string; + caption: string; + onClick: () => void; +}) { + return ( + + ); +} + function SettingsSectionTitle({ children }: { children: ReactNode }) { return (

    @@ -1306,6 +2247,99 @@ function SettingsRow({ ); } +function ReadOnlyRow({ title, value }: { title: string; value: string }) { + return ( + + + {value} + + + ); +} + +function RestartSettingsFooter({ + dirty, + saving, + pendingRestart, + disabled = false, + message, + dirtyMessage, + pendingMessage, + onSave, + onRestart, + onReset, + isRestarting, +}: { + dirty: boolean; + saving: boolean; + pendingRestart: boolean; + disabled?: boolean; + message?: string; + dirtyMessage?: string; + pendingMessage?: string; + onSave: () => void; + onRestart?: () => void; + onReset?: () => void; + isRestarting?: boolean; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const statusMessage = + message ?? + (pendingRestart && !dirty + ? pendingMessage ?? tx("settings.status.savedRestartApply", "Saved. Restart when ready.") + : dirty + ? dirtyMessage ?? t("settings.status.unsaved") + : undefined); + const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined; + + return ( +
    +
    + {statusMessage} +
    +
    + {pendingRestart && !dirty && onRestart ? ( + + ) : null} + {onReset ? ( + + ) : null} + +
    +
    + ); +} + function SettingsFooter({ dirty, saving, @@ -1318,14 +2352,161 @@ function SettingsFooter({ onSave: () => void; }) { const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const statusMessage = dirty + ? t("settings.status.unsaved") + : saved + ? t("settings.status.savedRestart") + : tx("settings.status.upToDate", "Up to date."); return ( -
    +
    - {saved ? t("settings.status.savedRestart") : t("settings.status.unsaved")} + + {statusMessage} + +
    +
    +
    - +
    + ); +} + +function SettingsStatusMessage({ + children, + tone, +}: { + children?: ReactNode; + tone?: "accent" | "danger"; +}) { + if (!children) return null; + return ( + + {tone ? ( + + ) : null} + {children} + + ); +} + +function StatusPill({ + children, + tone = "neutral", +}: { + children: ReactNode; + tone?: "neutral" | "success" | "warning"; +}) { + return ( + + {children} + + ); +} + +function SegmentedControl({ + value, + options, + onChange, +}: { + value: string; + options: Array<{ value: string; label: string }>; + onChange: (value: string) => void; +}) { + return ( +
    + {options.map((option) => ( + + ))} +
    + ); +} + +function ToggleButton({ + checked, + onChange, + label, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + label: string; +}) { + return ( + + ); +} + +function NumberInput({ + value, + min, + max, + onChange, + suffix, +}: { + value: number; + min: number; + max: number; + onChange: (value: number) => void; + suffix?: string; +}) { + return ( +
    + { + const parsed = Number(event.target.value); + if (Number.isFinite(parsed)) onChange(parsed); + }} + className="h-8 w-24 rounded-full text-[13px]" + /> + {suffix ? {suffix} : null}
    ); } diff --git a/webui/src/components/ui/dialog.tsx b/webui/src/components/ui/dialog.tsx index d4ed442c5..52792bab4 100644 --- a/webui/src/components/ui/dialog.tsx +++ b/webui/src/components/ui/dialog.tsx @@ -24,26 +24,35 @@ const DialogOverlay = React.forwardRef< )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; +interface DialogContentProps + extends React.ComponentPropsWithoutRef { + showCloseButton?: boolean; +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + DialogContentProps +>(({ className, children, showCloseButton = true, ...props }, ref) => ( - - {children} - - - Close - - +
    + + {children} + {showCloseButton ? ( + + + Close + + ) : null} + +
    )); DialogContent.displayName = DialogPrimitive.Content.displayName; diff --git a/webui/src/components/ui/dropdown-menu.tsx b/webui/src/components/ui/dropdown-menu.tsx index bd32944e3..cc5ef6ee2 100644 --- a/webui/src/components/ui/dropdown-menu.tsx +++ b/webui/src/components/ui/dropdown-menu.tsx @@ -47,11 +47,16 @@ const DropdownMenuSubContent = React.forwardRef< )); DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; +interface DropdownMenuContentProps + extends React.ComponentPropsWithoutRef { + portalContainer?: HTMLElement | null; +} + const DropdownMenuContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - + DropdownMenuContentProps +>(({ className, sideOffset = 4, portalContainer, ...props }, ref) => ( + (); + for (const item of value) { + if (typeof item !== "string") continue; + const cleaned = item.trim(); + if (!cleaned || seen.has(cleaned)) continue; + seen.add(cleaned); + out.push(cleaned); + } + return out; +} + +function stringMap(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (typeof raw !== "string") continue; + const cleanedKey = key.trim(); + const cleanedValue = raw.trim(); + if (!cleanedKey || !cleanedValue) continue; + out[cleanedKey] = cleanedValue; + } + return out; +} + +function tagsMap(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [key, raw] of Object.entries(value)) { + const cleanedKey = key.trim(); + if (!cleanedKey) continue; + const tags = uniqueStrings(raw).slice(0, 12); + if (tags.length) out[cleanedKey] = tags; + } + return out; +} + +function boolMap(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [key, raw] of Object.entries(value)) { + const cleanedKey = key.trim(); + if (cleanedKey) out[cleanedKey] = Boolean(raw); + } + return out; +} + +export function normalizeSidebarState(raw: unknown): SidebarStatePayload { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } }; + } + const value = raw as Partial; + const view = value.view && typeof value.view === "object" + ? value.view + : DEFAULT_SIDEBAR_STATE.view; + const density = view.density === "compact" ? "compact" : "comfortable"; + const sort = ["updated_desc", "created_desc", "title_asc"].includes(view.sort) + ? view.sort + : "updated_desc"; + return { + schema_version: 1, + pinned_keys: uniqueStrings(value.pinned_keys), + archived_keys: uniqueStrings(value.archived_keys), + title_overrides: stringMap(value.title_overrides), + tags_by_key: tagsMap(value.tags_by_key), + collapsed_groups: boolMap(value.collapsed_groups), + view: { + density, + show_previews: Boolean(view.show_previews), + show_timestamps: Boolean(view.show_timestamps), + show_archived: Boolean(view.show_archived), + sort, + }, + updated_at: typeof value.updated_at === "string" ? value.updated_at : null, + }; +} + +function pruneMissingSessions( + state: SidebarStatePayload, + sessions: ChatSummary[], +): SidebarStatePayload { + const valid = new Set(sessions.map((session) => session.key)); + const filterKeys = (keys: string[]) => keys.filter((key) => valid.has(key)); + const filterMap = (map: Record): Record => { + const out: Record = {}; + for (const [key, value] of Object.entries(map)) { + if (valid.has(key)) out[key] = value; + } + return out; + }; + return { + ...state, + pinned_keys: filterKeys(state.pinned_keys), + archived_keys: filterKeys(state.archived_keys), + title_overrides: filterMap(state.title_overrides), + tags_by_key: filterMap(state.tags_by_key), + }; +} + +function sameState(a: SidebarStatePayload, b: SidebarStatePayload): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function useSidebarState( + sessions: ChatSummary[], + sessionsLoaded: boolean, +): { + state: SidebarStatePayload; + loading: boolean; + update: ( + updater: (state: SidebarStatePayload) => SidebarStatePayload, + ) => Promise; +} { + const { token } = useClient(); + const tokenRef = useRef(token); + const stateRef = useRef(DEFAULT_SIDEBAR_STATE); + const persistVersionRef = useRef(0); + const [state, setState] = useState(DEFAULT_SIDEBAR_STATE); + const [loading, setLoading] = useState(true); + tokenRef.current = token; + stateRef.current = state; + + useEffect(() => { + let cancelled = false; + setLoading(true); + (async () => { + try { + const loaded = normalizeSidebarState(await fetchSidebarState(tokenRef.current)); + if (cancelled) return; + stateRef.current = loaded; + setState(loaded); + } catch { + if (cancelled) return; + stateRef.current = DEFAULT_SIDEBAR_STATE; + setState(DEFAULT_SIDEBAR_STATE); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const update = useCallback( + async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => { + const next = normalizeSidebarState(updater(stateRef.current)); + const version = persistVersionRef.current + 1; + persistVersionRef.current = version; + stateRef.current = next; + setState(next); + try { + const persisted = normalizeSidebarState( + await persistSidebarState(tokenRef.current, next), + ); + if (persistVersionRef.current !== version) return; + stateRef.current = persisted; + setState(persisted); + } catch { + // Keep the optimistic UI state. Older gateways or transient auth expiry + // should not break the chat list; the next refresh can try again. + } + }, + [], + ); + + const pruned = useMemo(() => { + if (!sessionsLoaded || loading) return state; + return pruneMissingSessions(state, sessions); + }, [loading, sessions, sessionsLoaded, state]); + + useEffect(() => { + if (!sessionsLoaded || loading || sameState(pruned, state)) return; + void update(() => pruned); + }, [loading, pruned, sessionsLoaded, state, update]); + + return { state, loading, update }; +} diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 38dc79d7b..f44332f95 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -45,8 +45,16 @@ "toggleTheme": "Toggle theme", "home": "Home", "newChat": "New chat", - "searchAria": "Search chats", - "searchPlaceholder": "Search chats", + "searchAria": "Search", + "viewOptions": "View", + "compactList": "Compact list", + "showPreviews": "Show previews", + "showTimestamps": "Show time", + "sortLabel": "Sort", + "sortUpdated": "Recently updated", + "sortCreated": "Recently created", + "sortTitle": "Title A-Z", + "searchPlaceholder": "Search", "searchResults": "Results", "noSearchResults": "No matching chats.", "recent": "Recent", @@ -65,12 +73,31 @@ }, "nav": { "general": "General", - "byok": "BYOK" + "byok": "BYOK", + "overview": "Overview", + "appearance": "Appearance", + "models": "Models", + "providers": "Providers", + "image": "Image", + "web": "Web", + "runtime": "Runtime", + "advanced": "Advanced" }, "sections": { "interface": "Interface", "ai": "AI", - "system": "System" + "system": "System", + "status": "Status", + "localPreferences": "Local preferences", + "presets": "Presets", + "imageGeneration": "Image generation", + "imageDefaults": "Defaults", + "webSearch": "Web search", + "webBehavior": "Behavior", + "identity": "Identity", + "safety": "Safety", + "capabilities": "Capabilities", + "integrations": "Integrations" }, "rows": { "theme": "Theme", @@ -78,31 +105,104 @@ "provider": "Provider", "model": "Model", "restart": "Restart nanobot", - "configPath": "Config path" + "configPath": "Config path", + "activePreset": "Active preset", + "gateway": "Gateway", + "restartState": "Restart state", + "pendingChanges": "Pending changes", + "selectedPreset": "Selected preset", + "presetModel": "Preset model", + "density": "Density", + "activityMode": "Activity detail", + "codeWrap": "Code wrapping", + "maxResults": "Max results", + "timeout": "Timeout", + "jinaReader": "Jina reader", + "imageGeneration": "Image generation", + "imageProvider": "Image provider", + "imageProviderStatus": "Provider status", + "imageProviderBase": "Provider base", + "imageModel": "Image model", + "defaultAspectRatio": "Default aspect", + "defaultImageSize": "Default size", + "maxImagesPerTurn": "Max images per turn", + "imageSaveDir": "Save directory", + "botName": "Bot name", + "botIcon": "Bot icon", + "timezone": "Timezone", + "toolHintMaxLength": "Tool hint length", + "workspacePath": "Workspace path", + "heartbeat": "Heartbeat", + "dream": "Dream", + "unifiedSession": "Unified session", + "restrictWorkspace": "Restrict to workspace", + "execTool": "Exec tool", + "execSandbox": "Exec sandbox", + "ssrfWhitelist": "SSRF whitelist", + "mcpServers": "MCP servers", + "pathAppend": "PATH append", + "configurationDocs": "Configuration docs" }, "help": { "theme": "Switch between light and dark appearance.", "language": "Choose the language used by the WebUI.", "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." + "configPath": "The gateway configuration file currently in use.", + "selectedPreset": "Named presets are read-only here; edit them in config.json.", + "presetModel": "Switch to Default to edit model and provider from the WebUI.", + "density": "Stored only in this browser.", + "activityMode": "Choose how much agent activity chrome to show by default.", + "codeWrap": "Keep long code lines readable on smaller screens.", + "maxResults": "Results returned by each web_search call.", + "timeout": "Seconds before a search provider request times out.", + "jinaReader": "Use Jina Reader for web_fetch when available.", + "imageGeneration": "Expose generate_image in chats when a configured image provider is available.", + "imageProvider": "Choose the registry provider used by generate_image.", + "imageProviderStatus": "Image generation reuses provider credentials from Providers.", + "imageModel": "Model name sent to the selected image provider.", + "defaultAspectRatio": "Used when the prompt does not choose an aspect ratio.", + "defaultImageSize": "Size hint sent to providers that support it.", + "maxImagesPerTurn": "Upper bound for one generate_image request.", + "botName": "Shown in runtime surfaces that use the configured bot identity.", + "botIcon": "Short emoji or text shown beside the bot name.", + "timezone": "IANA timezone used by runtime context and schedules.", + "toolHintMaxLength": "Maximum characters shown in tool progress hints.", + "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." }, "values": { "light": "Light", "dark": "Dark", - "notAvailable": "Not available" + "notAvailable": "Not available", + "enabled": "Enabled", + "disabled": "Disabled", + "restartPending": "Restart pending", + "ready": "Ready", + "comfortable": "Comfortable", + "compact": "Compact", + "auto": "Auto", + "expanded": "Expanded", + "on": "On", + "off": "Off", + "configured": "Configured", + "notConfigured": "Not configured" }, "status": { "loading": "Loading settings...", "loadError": "Could not load settings", "unsaved": "Unsaved changes.", - "savedRestart": "Saved. Restart nanobot to apply." + "upToDate": "Up to date.", + "savedRestart": "Saved. Restart nanobot to apply.", + "restartAfterSaving": "Save changes, then restart when ready.", + "savedRestartApply": "Saved. Restart when ready.", + "imageProviderRestart": "Image provider changes saved. Restart when ready." }, "actions": { "save": "Save", "saving": "Saving", "edit": "Edit", - "cancel": "Cancel" + "cancel": "Cancel", + "openDocs": "Open docs" }, "byok": { "description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.", @@ -145,6 +245,26 @@ "missingCredential": "Add the required credential before saving.", "saveHint": "Changes apply to new web search requests." } + }, + "overview": { + "model": "Current model", + "providers": "Providers", + "configuredCount": "{{count}} configured", + "totalProviders": "{{count}} available", + "webSearch": "Web search", + "imageGeneration": "Image generation", + "workspace": "Workspace" + }, + "providers": { + "searchPlaceholder": "Search providers", + "noMatches": "No providers match this search." + }, + "image": { + "selectProvider": "Select provider", + "selectAspect": "Select aspect", + "selectSize": "Select size", + "configureProvider": "Configure provider", + "missingCredential": "Configure this provider before enabling image generation." } }, "chat": { @@ -152,12 +272,30 @@ "loading": "Loading…", "noSessions": "No sessions yet.", "actions": "Chat actions for {{title}}", + "activity": { + "running": "Agent running", + "complete": "Agent finished" + }, + "pin": "Pin", + "unpin": "Unpin", + "rename": "Rename", + "renameTitle": "Rename chat", + "renameDescription": "Choose a local sidebar name for this chat.", + "renamePlaceholder": "Chat name", + "renameSave": "Save", + "archive": "Archive", + "unarchive": "Unarchive", + "showArchived": "Show archived", + "hideArchived": "Hide archived", "delete": "Delete", "newChat": "New chat", "groups": { + "pinned": "Pinned", + "all": "Chats", "today": "Today", "yesterday": "Yesterday", - "earlier": "Earlier" + "earlier": "Earlier", + "archived": "Archived" } }, "deleteConfirm": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 86df20b39..e658803e1 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -30,13 +30,25 @@ "collapse": "Contraer barra lateral", "toggleTheme": "Cambiar tema", "newChat": "Nuevo chat", + "viewOptions": "View", + "compactList": "Compact list", + "showPreviews": "Show previews", + "showTimestamps": "Show time", + "sortLabel": "Sort", + "sortUpdated": "Recently updated", + "sortCreated": "Recently created", + "sortTitle": "Title A-Z", "recent": "Recientes", "refreshSessions": "Actualizar sesiones", "settings": "Configuración", "language": { "label": "Idioma", "ariaLabel": "Cambiar idioma" - } + }, + "searchAria": "Buscar", + "searchPlaceholder": "Buscar", + "searchResults": "Resultados", + "noSearchResults": "No hay chats coincidentes." }, "settings": { "backToChat": "Volver al chat", @@ -46,12 +58,28 @@ }, "nav": { "general": "General", - "byok": "BYOK" + "byok": "BYOK", + "overview": "Overview", + "appearance": "Appearance", + "models": "Models", + "providers": "Providers", + "image": "Image", + "web": "Web", + "runtime": "Runtime", + "advanced": "Advanced" }, "sections": { "interface": "Interfaz", "ai": "IA", - "system": "Sistema" + "system": "Sistema", + "status": "Status", + "localPreferences": "Local preferences", + "presets": "Presets", + "webSearch": "Web search", + "webBehavior": "Behavior", + "identity": "Identity", + "safety": "Safety", + "integrations": "Integrations" }, "rows": { "theme": "Tema", @@ -59,19 +87,70 @@ "provider": "Proveedor", "model": "Modelo", "restart": "Reiniciar nanobot", - "configPath": "Ruta de configuración" + "configPath": "Ruta de configuración", + "activePreset": "Active preset", + "gateway": "Gateway", + "restartState": "Restart state", + "selectedPreset": "Selected preset", + "presetModel": "Preset model", + "density": "Density", + "activityMode": "Activity detail", + "codeWrap": "Code wrapping", + "maxResults": "Max results", + "timeout": "Timeout", + "jinaReader": "Jina reader", + "botName": "Bot name", + "botIcon": "Bot icon", + "timezone": "Timezone", + "toolHintMaxLength": "Tool hint length", + "workspacePath": "Workspace path", + "heartbeat": "Heartbeat", + "dream": "Dream", + "unifiedSession": "Unified session", + "restrictWorkspace": "Restrict to workspace", + "execTool": "Exec tool", + "execSandbox": "Exec sandbox", + "ssrfWhitelist": "SSRF whitelist", + "mcpServers": "MCP servers", + "pathAppend": "PATH append", + "configurationDocs": "Configuration docs" }, "help": { "theme": "Cambia entre apariencia clara y oscura.", "language": "Elige el idioma usado por la WebUI.", "provider": "Selecciona el proveedor para nuevas solicitudes de modelo.", "model": "Define el nombre del modelo predeterminado que usa nanobot.", - "configPath": "El archivo de configuración que usa actualmente el gateway." + "configPath": "El archivo de configuración que usa actualmente el gateway.", + "selectedPreset": "Named presets are read-only here; edit them in config.json.", + "presetModel": "Switch to Default to edit model and provider from the WebUI.", + "density": "Stored only in this browser.", + "activityMode": "Choose how much agent activity chrome to show by default.", + "codeWrap": "Keep long code lines readable on smaller screens.", + "maxResults": "Results returned by each web_search call.", + "timeout": "Seconds before a search provider request times out.", + "jinaReader": "Use Jina Reader for web_fetch when available.", + "botName": "Shown in runtime surfaces that use the configured bot identity.", + "botIcon": "Short emoji or text shown beside the bot name.", + "timezone": "IANA timezone used by runtime context and schedules.", + "toolHintMaxLength": "Maximum characters shown in tool progress hints.", + "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." }, "values": { "light": "Claro", "dark": "Oscuro", - "notAvailable": "No disponible" + "notAvailable": "No disponible", + "enabled": "Enabled", + "disabled": "Disabled", + "restartRequired": "Restart required", + "liveReload": "Live reload ready", + "comfortable": "Comfortable", + "compact": "Compact", + "auto": "Auto", + "expanded": "Expanded", + "on": "On", + "off": "Off", + "configured": "Configured", + "notConfigured": "Not configured" }, "status": { "loading": "Cargando configuración...", @@ -83,7 +162,8 @@ "save": "Guardar", "saving": "Guardando", "edit": "Editar", - "cancel": "Cancelar" + "cancel": "Cancelar", + "openDocs": "Open docs" }, "byok": { "description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.", @@ -126,6 +206,18 @@ "missingCredential": "Añade la credencial requerida antes de guardar.", "saveHint": "Los cambios se aplican a nuevas solicitudes de web search." } + }, + "overview": { + "model": "Current model", + "providers": "Providers", + "configuredCount": "{{count}} configured", + "totalProviders": "{{count}} available", + "webSearch": "Web search", + "workspace": "Workspace" + }, + "providers": { + "searchPlaceholder": "Search providers", + "noMatches": "No providers match this search." } }, "chat": { @@ -133,8 +225,31 @@ "loading": "Cargando…", "noSessions": "Todavía no hay sesiones.", "actions": "Acciones del chat {{title}}", + "activity": { + "running": "Agent running", + "complete": "Agent finished" + }, + "pin": "Pin", + "unpin": "Unpin", + "rename": "Rename", + "renameTitle": "Rename chat", + "renameDescription": "Choose a local sidebar name for this chat.", + "renamePlaceholder": "Chat name", + "renameSave": "Save", + "archive": "Archive", + "unarchive": "Unarchive", + "showArchived": "Show archived", + "hideArchived": "Hide archived", "delete": "Eliminar", - "newChat": "Nuevo chat" + "newChat": "Nuevo chat", + "groups": { + "pinned": "Pinned", + "all": "Chats", + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier", + "archived": "Archived" + } }, "deleteConfirm": { "title": "¿Eliminar este chat?", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index aa2b62144..a6f80e729 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -30,13 +30,25 @@ "collapse": "Réduire la barre latérale", "toggleTheme": "Changer de thème", "newChat": "Nouvelle discussion", + "viewOptions": "View", + "compactList": "Compact list", + "showPreviews": "Show previews", + "showTimestamps": "Show time", + "sortLabel": "Sort", + "sortUpdated": "Recently updated", + "sortCreated": "Recently created", + "sortTitle": "Title A-Z", "recent": "Récentes", "refreshSessions": "Actualiser les sessions", "settings": "Paramètres", "language": { "label": "Langue", "ariaLabel": "Changer de langue" - } + }, + "searchAria": "Rechercher", + "searchPlaceholder": "Rechercher", + "searchResults": "Résultats", + "noSearchResults": "Aucun chat correspondant." }, "settings": { "backToChat": "Retour à la discussion", @@ -46,12 +58,28 @@ }, "nav": { "general": "Général", - "byok": "BYOK" + "byok": "BYOK", + "overview": "Overview", + "appearance": "Appearance", + "models": "Models", + "providers": "Providers", + "image": "Image", + "web": "Web", + "runtime": "Runtime", + "advanced": "Advanced" }, "sections": { "interface": "Interface", "ai": "IA", - "system": "Système" + "system": "Système", + "status": "Status", + "localPreferences": "Local preferences", + "presets": "Presets", + "webSearch": "Web search", + "webBehavior": "Behavior", + "identity": "Identity", + "safety": "Safety", + "integrations": "Integrations" }, "rows": { "theme": "Thème", @@ -59,19 +87,70 @@ "provider": "Fournisseur", "model": "Modèle", "restart": "Redémarrer nanobot", - "configPath": "Chemin de configuration" + "configPath": "Chemin de configuration", + "activePreset": "Active preset", + "gateway": "Gateway", + "restartState": "Restart state", + "selectedPreset": "Selected preset", + "presetModel": "Preset model", + "density": "Density", + "activityMode": "Activity detail", + "codeWrap": "Code wrapping", + "maxResults": "Max results", + "timeout": "Timeout", + "jinaReader": "Jina reader", + "botName": "Bot name", + "botIcon": "Bot icon", + "timezone": "Timezone", + "toolHintMaxLength": "Tool hint length", + "workspacePath": "Workspace path", + "heartbeat": "Heartbeat", + "dream": "Dream", + "unifiedSession": "Unified session", + "restrictWorkspace": "Restrict to workspace", + "execTool": "Exec tool", + "execSandbox": "Exec sandbox", + "ssrfWhitelist": "SSRF whitelist", + "mcpServers": "MCP servers", + "pathAppend": "PATH append", + "configurationDocs": "Configuration docs" }, "help": { "theme": "Basculer entre les apparences claire et sombre.", "language": "Choisissez la langue utilisée par le WebUI.", "provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.", "model": "Définissez le nom du modèle par défaut utilisé par nanobot.", - "configPath": "Le fichier de configuration actuellement utilisé par la passerelle." + "configPath": "Le fichier de configuration actuellement utilisé par la passerelle.", + "selectedPreset": "Named presets are read-only here; edit them in config.json.", + "presetModel": "Switch to Default to edit model and provider from the WebUI.", + "density": "Stored only in this browser.", + "activityMode": "Choose how much agent activity chrome to show by default.", + "codeWrap": "Keep long code lines readable on smaller screens.", + "maxResults": "Results returned by each web_search call.", + "timeout": "Seconds before a search provider request times out.", + "jinaReader": "Use Jina Reader for web_fetch when available.", + "botName": "Shown in runtime surfaces that use the configured bot identity.", + "botIcon": "Short emoji or text shown beside the bot name.", + "timezone": "IANA timezone used by runtime context and schedules.", + "toolHintMaxLength": "Maximum characters shown in tool progress hints.", + "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." }, "values": { "light": "Clair", "dark": "Sombre", - "notAvailable": "Indisponible" + "notAvailable": "Indisponible", + "enabled": "Enabled", + "disabled": "Disabled", + "restartRequired": "Restart required", + "liveReload": "Live reload ready", + "comfortable": "Comfortable", + "compact": "Compact", + "auto": "Auto", + "expanded": "Expanded", + "on": "On", + "off": "Off", + "configured": "Configured", + "notConfigured": "Not configured" }, "status": { "loading": "Chargement des paramètres...", @@ -83,7 +162,8 @@ "save": "Enregistrer", "saving": "Enregistrement", "edit": "Modifier", - "cancel": "Annuler" + "cancel": "Annuler", + "openDocs": "Open docs" }, "byok": { "description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.", @@ -126,6 +206,18 @@ "missingCredential": "Ajoutez l'identifiant requis avant d'enregistrer.", "saveHint": "Les changements s'appliquent aux nouvelles requêtes web search." } + }, + "overview": { + "model": "Current model", + "providers": "Providers", + "configuredCount": "{{count}} configured", + "totalProviders": "{{count}} available", + "webSearch": "Web search", + "workspace": "Workspace" + }, + "providers": { + "searchPlaceholder": "Search providers", + "noMatches": "No providers match this search." } }, "chat": { @@ -133,8 +225,31 @@ "loading": "Chargement…", "noSessions": "Aucune session pour le moment.", "actions": "Actions de la discussion {{title}}", + "activity": { + "running": "Agent running", + "complete": "Agent finished" + }, + "pin": "Pin", + "unpin": "Unpin", + "rename": "Rename", + "renameTitle": "Rename chat", + "renameDescription": "Choose a local sidebar name for this chat.", + "renamePlaceholder": "Chat name", + "renameSave": "Save", + "archive": "Archive", + "unarchive": "Unarchive", + "showArchived": "Show archived", + "hideArchived": "Hide archived", "delete": "Supprimer", - "newChat": "Nouvelle discussion" + "newChat": "Nouvelle discussion", + "groups": { + "pinned": "Pinned", + "all": "Chats", + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier", + "archived": "Archived" + } }, "deleteConfirm": { "title": "Supprimer cette discussion ?", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 062a35731..e64db6029 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -30,13 +30,25 @@ "collapse": "Ciutkan sidebar", "toggleTheme": "Ganti tema", "newChat": "Obrolan baru", + "viewOptions": "View", + "compactList": "Compact list", + "showPreviews": "Show previews", + "showTimestamps": "Show time", + "sortLabel": "Sort", + "sortUpdated": "Recently updated", + "sortCreated": "Recently created", + "sortTitle": "Title A-Z", "recent": "Terbaru", "refreshSessions": "Segarkan sesi", "settings": "Pengaturan", "language": { "label": "Bahasa", "ariaLabel": "Ganti bahasa" - } + }, + "searchAria": "Cari", + "searchPlaceholder": "Cari", + "searchResults": "Hasil", + "noSearchResults": "Tidak ada chat yang cocok." }, "settings": { "backToChat": "Kembali ke obrolan", @@ -46,12 +58,28 @@ }, "nav": { "general": "Umum", - "byok": "BYOK" + "byok": "BYOK", + "overview": "Overview", + "appearance": "Appearance", + "models": "Models", + "providers": "Providers", + "image": "Image", + "web": "Web", + "runtime": "Runtime", + "advanced": "Advanced" }, "sections": { "interface": "Antarmuka", "ai": "AI", - "system": "Sistem" + "system": "Sistem", + "status": "Status", + "localPreferences": "Local preferences", + "presets": "Presets", + "webSearch": "Web search", + "webBehavior": "Behavior", + "identity": "Identity", + "safety": "Safety", + "integrations": "Integrations" }, "rows": { "theme": "Tema", @@ -59,19 +87,70 @@ "provider": "Penyedia", "model": "Model", "restart": "Mulai ulang nanobot", - "configPath": "Path konfigurasi" + "configPath": "Path konfigurasi", + "activePreset": "Active preset", + "gateway": "Gateway", + "restartState": "Restart state", + "selectedPreset": "Selected preset", + "presetModel": "Preset model", + "density": "Density", + "activityMode": "Activity detail", + "codeWrap": "Code wrapping", + "maxResults": "Max results", + "timeout": "Timeout", + "jinaReader": "Jina reader", + "botName": "Bot name", + "botIcon": "Bot icon", + "timezone": "Timezone", + "toolHintMaxLength": "Tool hint length", + "workspacePath": "Workspace path", + "heartbeat": "Heartbeat", + "dream": "Dream", + "unifiedSession": "Unified session", + "restrictWorkspace": "Restrict to workspace", + "execTool": "Exec tool", + "execSandbox": "Exec sandbox", + "ssrfWhitelist": "SSRF whitelist", + "mcpServers": "MCP servers", + "pathAppend": "PATH append", + "configurationDocs": "Configuration docs" }, "help": { "theme": "Beralih antara tampilan terang dan gelap.", "language": "Pilih bahasa yang digunakan WebUI.", "provider": "Pilih penyedia untuk permintaan model baru.", "model": "Atur nama model default yang digunakan nanobot.", - "configPath": "File konfigurasi gateway yang sedang digunakan." + "configPath": "File konfigurasi gateway yang sedang digunakan.", + "selectedPreset": "Named presets are read-only here; edit them in config.json.", + "presetModel": "Switch to Default to edit model and provider from the WebUI.", + "density": "Stored only in this browser.", + "activityMode": "Choose how much agent activity chrome to show by default.", + "codeWrap": "Keep long code lines readable on smaller screens.", + "maxResults": "Results returned by each web_search call.", + "timeout": "Seconds before a search provider request times out.", + "jinaReader": "Use Jina Reader for web_fetch when available.", + "botName": "Shown in runtime surfaces that use the configured bot identity.", + "botIcon": "Short emoji or text shown beside the bot name.", + "timezone": "IANA timezone used by runtime context and schedules.", + "toolHintMaxLength": "Maximum characters shown in tool progress hints.", + "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." }, "values": { "light": "Terang", "dark": "Gelap", - "notAvailable": "Tidak tersedia" + "notAvailable": "Tidak tersedia", + "enabled": "Enabled", + "disabled": "Disabled", + "restartRequired": "Restart required", + "liveReload": "Live reload ready", + "comfortable": "Comfortable", + "compact": "Compact", + "auto": "Auto", + "expanded": "Expanded", + "on": "On", + "off": "Off", + "configured": "Configured", + "notConfigured": "Not configured" }, "status": { "loading": "Memuat pengaturan...", @@ -83,7 +162,8 @@ "save": "Simpan", "saving": "Menyimpan", "edit": "Edit", - "cancel": "Batal" + "cancel": "Batal", + "openDocs": "Open docs" }, "byok": { "description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.", @@ -126,6 +206,18 @@ "missingCredential": "Tambahkan kredensial yang diperlukan sebelum menyimpan.", "saveHint": "Perubahan berlaku untuk permintaan web search baru." } + }, + "overview": { + "model": "Current model", + "providers": "Providers", + "configuredCount": "{{count}} configured", + "totalProviders": "{{count}} available", + "webSearch": "Web search", + "workspace": "Workspace" + }, + "providers": { + "searchPlaceholder": "Search providers", + "noMatches": "No providers match this search." } }, "chat": { @@ -133,8 +225,31 @@ "loading": "Memuat…", "noSessions": "Belum ada sesi.", "actions": "Aksi obrolan untuk {{title}}", + "activity": { + "running": "Agent running", + "complete": "Agent finished" + }, + "pin": "Pin", + "unpin": "Unpin", + "rename": "Rename", + "renameTitle": "Rename chat", + "renameDescription": "Choose a local sidebar name for this chat.", + "renamePlaceholder": "Chat name", + "renameSave": "Save", + "archive": "Archive", + "unarchive": "Unarchive", + "showArchived": "Show archived", + "hideArchived": "Hide archived", "delete": "Hapus", - "newChat": "Obrolan baru" + "newChat": "Obrolan baru", + "groups": { + "pinned": "Pinned", + "all": "Chats", + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier", + "archived": "Archived" + } }, "deleteConfirm": { "title": "Hapus obrolan ini?", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 225c9d8b3..c1bacd12b 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -30,13 +30,25 @@ "collapse": "サイドバーを閉じる", "toggleTheme": "テーマを切り替える", "newChat": "新しいチャット", + "viewOptions": "View", + "compactList": "Compact list", + "showPreviews": "Show previews", + "showTimestamps": "Show time", + "sortLabel": "Sort", + "sortUpdated": "Recently updated", + "sortCreated": "Recently created", + "sortTitle": "Title A-Z", "recent": "最近のチャット", "refreshSessions": "セッションを更新", "settings": "設定", "language": { "label": "言語", "ariaLabel": "言語を変更" - } + }, + "searchAria": "検索", + "searchPlaceholder": "検索", + "searchResults": "検索結果", + "noSearchResults": "一致するチャットはありません。" }, "settings": { "backToChat": "チャットに戻る", @@ -46,12 +58,28 @@ }, "nav": { "general": "一般", - "byok": "BYOK" + "byok": "BYOK", + "overview": "Overview", + "appearance": "Appearance", + "models": "Models", + "providers": "Providers", + "image": "Image", + "web": "Web", + "runtime": "Runtime", + "advanced": "Advanced" }, "sections": { "interface": "インターフェース", "ai": "AI", - "system": "システム" + "system": "システム", + "status": "Status", + "localPreferences": "Local preferences", + "presets": "Presets", + "webSearch": "Web search", + "webBehavior": "Behavior", + "identity": "Identity", + "safety": "Safety", + "integrations": "Integrations" }, "rows": { "theme": "テーマ", @@ -59,19 +87,70 @@ "provider": "プロバイダー", "model": "モデル", "restart": "nanobot を再起動", - "configPath": "設定パス" + "configPath": "設定パス", + "activePreset": "Active preset", + "gateway": "Gateway", + "restartState": "Restart state", + "selectedPreset": "Selected preset", + "presetModel": "Preset model", + "density": "Density", + "activityMode": "Activity detail", + "codeWrap": "Code wrapping", + "maxResults": "Max results", + "timeout": "Timeout", + "jinaReader": "Jina reader", + "botName": "Bot name", + "botIcon": "Bot icon", + "timezone": "Timezone", + "toolHintMaxLength": "Tool hint length", + "workspacePath": "Workspace path", + "heartbeat": "Heartbeat", + "dream": "Dream", + "unifiedSession": "Unified session", + "restrictWorkspace": "Restrict to workspace", + "execTool": "Exec tool", + "execSandbox": "Exec sandbox", + "ssrfWhitelist": "SSRF whitelist", + "mcpServers": "MCP servers", + "pathAppend": "PATH append", + "configurationDocs": "Configuration docs" }, "help": { "theme": "ライト表示とダーク表示を切り替えます。", "language": "WebUI で使用する言語を選択します。", "provider": "新しいモデルリクエストに使うプロバイダーを選択します。", "model": "nanobot が既定で使用するモデル名を設定します。", - "configPath": "現在ゲートウェイが使用している設定ファイルです。" + "configPath": "現在ゲートウェイが使用している設定ファイルです。", + "selectedPreset": "Named presets are read-only here; edit them in config.json.", + "presetModel": "Switch to Default to edit model and provider from the WebUI.", + "density": "Stored only in this browser.", + "activityMode": "Choose how much agent activity chrome to show by default.", + "codeWrap": "Keep long code lines readable on smaller screens.", + "maxResults": "Results returned by each web_search call.", + "timeout": "Seconds before a search provider request times out.", + "jinaReader": "Use Jina Reader for web_fetch when available.", + "botName": "Shown in runtime surfaces that use the configured bot identity.", + "botIcon": "Short emoji or text shown beside the bot name.", + "timezone": "IANA timezone used by runtime context and schedules.", + "toolHintMaxLength": "Maximum characters shown in tool progress hints.", + "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." }, "values": { "light": "ライト", "dark": "ダーク", - "notAvailable": "利用不可" + "notAvailable": "利用不可", + "enabled": "Enabled", + "disabled": "Disabled", + "restartRequired": "Restart required", + "liveReload": "Live reload ready", + "comfortable": "Comfortable", + "compact": "Compact", + "auto": "Auto", + "expanded": "Expanded", + "on": "On", + "off": "Off", + "configured": "Configured", + "notConfigured": "Not configured" }, "status": { "loading": "設定を読み込んでいます...", @@ -83,7 +162,8 @@ "save": "保存", "saving": "保存中", "edit": "編集", - "cancel": "キャンセル" + "cancel": "キャンセル", + "openDocs": "Open docs" }, "byok": { "description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。", @@ -126,6 +206,18 @@ "missingCredential": "保存する前に必要な認証情報を入力してください。", "saveHint": "変更は新しい web search リクエストに適用されます。" } + }, + "overview": { + "model": "Current model", + "providers": "Providers", + "configuredCount": "{{count}} configured", + "totalProviders": "{{count}} available", + "webSearch": "Web search", + "workspace": "Workspace" + }, + "providers": { + "searchPlaceholder": "Search providers", + "noMatches": "No providers match this search." } }, "chat": { @@ -133,8 +225,31 @@ "loading": "読み込み中…", "noSessions": "まだセッションがありません。", "actions": "「{{title}}」のチャット操作", + "activity": { + "running": "Agent running", + "complete": "Agent finished" + }, + "pin": "Pin", + "unpin": "Unpin", + "rename": "Rename", + "renameTitle": "Rename chat", + "renameDescription": "Choose a local sidebar name for this chat.", + "renamePlaceholder": "Chat name", + "renameSave": "Save", + "archive": "Archive", + "unarchive": "Unarchive", + "showArchived": "Show archived", + "hideArchived": "Hide archived", "delete": "削除", - "newChat": "新しいチャット" + "newChat": "新しいチャット", + "groups": { + "pinned": "Pinned", + "all": "Chats", + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier", + "archived": "Archived" + } }, "deleteConfirm": { "title": "このチャットを削除しますか?", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index c3b038609..f94936648 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -30,13 +30,25 @@ "collapse": "사이드바 접기", "toggleTheme": "테마 전환", "newChat": "새 채팅", + "viewOptions": "View", + "compactList": "Compact list", + "showPreviews": "Show previews", + "showTimestamps": "Show time", + "sortLabel": "Sort", + "sortUpdated": "Recently updated", + "sortCreated": "Recently created", + "sortTitle": "Title A-Z", "recent": "최근 대화", "refreshSessions": "세션 새로고침", "settings": "설정", "language": { "label": "언어", "ariaLabel": "언어 변경" - } + }, + "searchAria": "검색", + "searchPlaceholder": "검색", + "searchResults": "결과", + "noSearchResults": "일치하는 채팅이 없습니다." }, "settings": { "backToChat": "채팅으로 돌아가기", @@ -46,12 +58,28 @@ }, "nav": { "general": "일반", - "byok": "BYOK" + "byok": "BYOK", + "overview": "Overview", + "appearance": "Appearance", + "models": "Models", + "providers": "Providers", + "image": "Image", + "web": "Web", + "runtime": "Runtime", + "advanced": "Advanced" }, "sections": { "interface": "인터페이스", "ai": "AI", - "system": "시스템" + "system": "시스템", + "status": "Status", + "localPreferences": "Local preferences", + "presets": "Presets", + "webSearch": "Web search", + "webBehavior": "Behavior", + "identity": "Identity", + "safety": "Safety", + "integrations": "Integrations" }, "rows": { "theme": "테마", @@ -59,19 +87,70 @@ "provider": "제공자", "model": "모델", "restart": "nanobot 재시작", - "configPath": "설정 경로" + "configPath": "설정 경로", + "activePreset": "Active preset", + "gateway": "Gateway", + "restartState": "Restart state", + "selectedPreset": "Selected preset", + "presetModel": "Preset model", + "density": "Density", + "activityMode": "Activity detail", + "codeWrap": "Code wrapping", + "maxResults": "Max results", + "timeout": "Timeout", + "jinaReader": "Jina reader", + "botName": "Bot name", + "botIcon": "Bot icon", + "timezone": "Timezone", + "toolHintMaxLength": "Tool hint length", + "workspacePath": "Workspace path", + "heartbeat": "Heartbeat", + "dream": "Dream", + "unifiedSession": "Unified session", + "restrictWorkspace": "Restrict to workspace", + "execTool": "Exec tool", + "execSandbox": "Exec sandbox", + "ssrfWhitelist": "SSRF whitelist", + "mcpServers": "MCP servers", + "pathAppend": "PATH append", + "configurationDocs": "Configuration docs" }, "help": { "theme": "밝은 모드와 어두운 모드를 전환합니다.", "language": "WebUI에서 사용할 언어를 선택합니다.", "provider": "새 모델 요청에 사용할 제공자를 선택합니다.", "model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.", - "configPath": "현재 게이트웨이가 사용하는 설정 파일입니다." + "configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.", + "selectedPreset": "Named presets are read-only here; edit them in config.json.", + "presetModel": "Switch to Default to edit model and provider from the WebUI.", + "density": "Stored only in this browser.", + "activityMode": "Choose how much agent activity chrome to show by default.", + "codeWrap": "Keep long code lines readable on smaller screens.", + "maxResults": "Results returned by each web_search call.", + "timeout": "Seconds before a search provider request times out.", + "jinaReader": "Use Jina Reader for web_fetch when available.", + "botName": "Shown in runtime surfaces that use the configured bot identity.", + "botIcon": "Short emoji or text shown beside the bot name.", + "timezone": "IANA timezone used by runtime context and schedules.", + "toolHintMaxLength": "Maximum characters shown in tool progress hints.", + "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." }, "values": { "light": "라이트", "dark": "다크", - "notAvailable": "사용할 수 없음" + "notAvailable": "사용할 수 없음", + "enabled": "Enabled", + "disabled": "Disabled", + "restartRequired": "Restart required", + "liveReload": "Live reload ready", + "comfortable": "Comfortable", + "compact": "Compact", + "auto": "Auto", + "expanded": "Expanded", + "on": "On", + "off": "Off", + "configured": "Configured", + "notConfigured": "Not configured" }, "status": { "loading": "설정을 불러오는 중...", @@ -83,7 +162,8 @@ "save": "저장", "saving": "저장 중", "edit": "편집", - "cancel": "취소" + "cancel": "취소", + "openDocs": "Open docs" }, "byok": { "description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.", @@ -126,6 +206,18 @@ "missingCredential": "저장하기 전에 필요한 자격 증명을 입력하세요.", "saveHint": "변경 사항은 새 web search 요청에 적용됩니다." } + }, + "overview": { + "model": "Current model", + "providers": "Providers", + "configuredCount": "{{count}} configured", + "totalProviders": "{{count}} available", + "webSearch": "Web search", + "workspace": "Workspace" + }, + "providers": { + "searchPlaceholder": "Search providers", + "noMatches": "No providers match this search." } }, "chat": { @@ -133,8 +225,31 @@ "loading": "불러오는 중…", "noSessions": "아직 세션이 없습니다.", "actions": "{{title}} 채팅 작업", + "activity": { + "running": "Agent running", + "complete": "Agent finished" + }, + "pin": "Pin", + "unpin": "Unpin", + "rename": "Rename", + "renameTitle": "Rename chat", + "renameDescription": "Choose a local sidebar name for this chat.", + "renamePlaceholder": "Chat name", + "renameSave": "Save", + "archive": "Archive", + "unarchive": "Unarchive", + "showArchived": "Show archived", + "hideArchived": "Hide archived", "delete": "삭제", - "newChat": "새 채팅" + "newChat": "새 채팅", + "groups": { + "pinned": "Pinned", + "all": "Chats", + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier", + "archived": "Archived" + } }, "deleteConfirm": { "title": "이 채팅을 삭제할까요?", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 9efbcd775..805e82ced 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -30,13 +30,25 @@ "collapse": "Thu gọn thanh bên", "toggleTheme": "Chuyển giao diện", "newChat": "Cuộc trò chuyện mới", + "viewOptions": "View", + "compactList": "Compact list", + "showPreviews": "Show previews", + "showTimestamps": "Show time", + "sortLabel": "Sort", + "sortUpdated": "Recently updated", + "sortCreated": "Recently created", + "sortTitle": "Title A-Z", "recent": "Gần đây", "refreshSessions": "Làm mới phiên", "settings": "Cài đặt", "language": { "label": "Ngôn ngữ", "ariaLabel": "Đổi ngôn ngữ" - } + }, + "searchAria": "Tìm kiếm", + "searchPlaceholder": "Tìm kiếm", + "searchResults": "Kết quả", + "noSearchResults": "Không có cuộc trò chuyện phù hợp." }, "settings": { "backToChat": "Quay lại trò chuyện", @@ -46,12 +58,28 @@ }, "nav": { "general": "Chung", - "byok": "BYOK" + "byok": "BYOK", + "overview": "Overview", + "appearance": "Appearance", + "models": "Models", + "providers": "Providers", + "image": "Image", + "web": "Web", + "runtime": "Runtime", + "advanced": "Advanced" }, "sections": { "interface": "Giao diện", "ai": "AI", - "system": "Hệ thống" + "system": "Hệ thống", + "status": "Status", + "localPreferences": "Local preferences", + "presets": "Presets", + "webSearch": "Web search", + "webBehavior": "Behavior", + "identity": "Identity", + "safety": "Safety", + "integrations": "Integrations" }, "rows": { "theme": "Giao diện", @@ -59,19 +87,70 @@ "provider": "Nhà cung cấp", "model": "Mô hình", "restart": "Khởi động lại nanobot", - "configPath": "Đường dẫn cấu hình" + "configPath": "Đường dẫn cấu hình", + "activePreset": "Active preset", + "gateway": "Gateway", + "restartState": "Restart state", + "selectedPreset": "Selected preset", + "presetModel": "Preset model", + "density": "Density", + "activityMode": "Activity detail", + "codeWrap": "Code wrapping", + "maxResults": "Max results", + "timeout": "Timeout", + "jinaReader": "Jina reader", + "botName": "Bot name", + "botIcon": "Bot icon", + "timezone": "Timezone", + "toolHintMaxLength": "Tool hint length", + "workspacePath": "Workspace path", + "heartbeat": "Heartbeat", + "dream": "Dream", + "unifiedSession": "Unified session", + "restrictWorkspace": "Restrict to workspace", + "execTool": "Exec tool", + "execSandbox": "Exec sandbox", + "ssrfWhitelist": "SSRF whitelist", + "mcpServers": "MCP servers", + "pathAppend": "PATH append", + "configurationDocs": "Configuration docs" }, "help": { "theme": "Chuyển giữa giao diện sáng và tối.", "language": "Chọn ngôn ngữ dùng trong WebUI.", "provider": "Chọn nhà cung cấp cho các yêu cầu mô hình mới.", "model": "Đặt tên mô hình mặc định mà nanobot sử dụng.", - "configPath": "Tệp cấu hình gateway hiện đang dùng." + "configPath": "Tệp cấu hình gateway hiện đang dùng.", + "selectedPreset": "Named presets are read-only here; edit them in config.json.", + "presetModel": "Switch to Default to edit model and provider from the WebUI.", + "density": "Stored only in this browser.", + "activityMode": "Choose how much agent activity chrome to show by default.", + "codeWrap": "Keep long code lines readable on smaller screens.", + "maxResults": "Results returned by each web_search call.", + "timeout": "Seconds before a search provider request times out.", + "jinaReader": "Use Jina Reader for web_fetch when available.", + "botName": "Shown in runtime surfaces that use the configured bot identity.", + "botIcon": "Short emoji or text shown beside the bot name.", + "timezone": "IANA timezone used by runtime context and schedules.", + "toolHintMaxLength": "Maximum characters shown in tool progress hints.", + "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." }, "values": { "light": "Sáng", "dark": "Tối", - "notAvailable": "Không khả dụng" + "notAvailable": "Không khả dụng", + "enabled": "Enabled", + "disabled": "Disabled", + "restartRequired": "Restart required", + "liveReload": "Live reload ready", + "comfortable": "Comfortable", + "compact": "Compact", + "auto": "Auto", + "expanded": "Expanded", + "on": "On", + "off": "Off", + "configured": "Configured", + "notConfigured": "Not configured" }, "status": { "loading": "Đang tải cài đặt...", @@ -83,7 +162,8 @@ "save": "Lưu", "saving": "Đang lưu", "edit": "Sửa", - "cancel": "Hủy" + "cancel": "Hủy", + "openDocs": "Open docs" }, "byok": { "description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.", @@ -126,6 +206,18 @@ "missingCredential": "Thêm thông tin bắt buộc trước khi lưu.", "saveHint": "Thay đổi áp dụng cho các yêu cầu web search mới." } + }, + "overview": { + "model": "Current model", + "providers": "Providers", + "configuredCount": "{{count}} configured", + "totalProviders": "{{count}} available", + "webSearch": "Web search", + "workspace": "Workspace" + }, + "providers": { + "searchPlaceholder": "Search providers", + "noMatches": "No providers match this search." } }, "chat": { @@ -133,8 +225,31 @@ "loading": "Đang tải…", "noSessions": "Chưa có phiên nào.", "actions": "Tác vụ cho cuộc trò chuyện {{title}}", + "activity": { + "running": "Agent running", + "complete": "Agent finished" + }, + "pin": "Pin", + "unpin": "Unpin", + "rename": "Rename", + "renameTitle": "Rename chat", + "renameDescription": "Choose a local sidebar name for this chat.", + "renamePlaceholder": "Chat name", + "renameSave": "Save", + "archive": "Archive", + "unarchive": "Unarchive", + "showArchived": "Show archived", + "hideArchived": "Hide archived", "delete": "Xóa", - "newChat": "Cuộc trò chuyện mới" + "newChat": "Cuộc trò chuyện mới", + "groups": { + "pinned": "Pinned", + "all": "Chats", + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier", + "archived": "Archived" + } }, "deleteConfirm": { "title": "Xóa cuộc trò chuyện này?", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 6f1a4be27..18089c7ad 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -33,8 +33,16 @@ "toggleTheme": "切换主题", "home": "首页", "newChat": "新建对话", - "searchAria": "搜索会话", - "searchPlaceholder": "搜索会话", + "searchAria": "搜索", + "viewOptions": "视图", + "compactList": "紧凑列表", + "showPreviews": "显示预览", + "showTimestamps": "显示时间", + "sortLabel": "排序", + "sortUpdated": "最近更新", + "sortCreated": "最近创建", + "sortTitle": "标题 A-Z", + "searchPlaceholder": "搜索", "searchResults": "搜索结果", "noSearchResults": "没有匹配的会话。", "recent": "最近对话", @@ -53,12 +61,31 @@ }, "nav": { "general": "通用", - "byok": "BYOK" + "byok": "BYOK", + "overview": "概览", + "appearance": "外观", + "models": "模型", + "providers": "提供商", + "image": "图片", + "web": "网页", + "runtime": "运行时", + "advanced": "高级" }, "sections": { "interface": "界面", "ai": "AI", - "system": "系统" + "system": "系统", + "status": "状态", + "localPreferences": "本地偏好", + "presets": "预设", + "imageGeneration": "图片生成", + "imageDefaults": "默认值", + "webSearch": "网页搜索", + "webBehavior": "行为", + "identity": "身份", + "safety": "安全", + "capabilities": "能力", + "integrations": "集成" }, "rows": { "theme": "主题", @@ -66,34 +93,107 @@ "provider": "提供商", "model": "模型", "restart": "重启 nanobot", - "configPath": "配置路径" + "configPath": "配置路径", + "activePreset": "当前预设", + "gateway": "网关", + "restartState": "重启状态", + "pendingChanges": "待处理更改", + "selectedPreset": "选中的预设", + "presetModel": "预设模型", + "density": "密度", + "activityMode": "活动细节", + "codeWrap": "代码换行", + "maxResults": "最大结果数", + "timeout": "超时", + "jinaReader": "Jina Reader", + "imageGeneration": "图片生成", + "imageProvider": "图片服务商", + "imageProviderStatus": "服务商状态", + "imageProviderBase": "服务商地址", + "imageModel": "图片模型", + "defaultAspectRatio": "默认比例", + "defaultImageSize": "默认尺寸", + "maxImagesPerTurn": "每轮最多图片数", + "imageSaveDir": "保存目录", + "botName": "Bot 名称", + "botIcon": "Bot 图标", + "timezone": "时区", + "toolHintMaxLength": "工具提示长度", + "workspacePath": "工作区路径", + "heartbeat": "Heartbeat", + "dream": "Dream", + "unifiedSession": "统一会话", + "restrictWorkspace": "限制在工作区内", + "execTool": "Exec 工具", + "execSandbox": "Exec 沙箱", + "ssrfWhitelist": "SSRF 白名单", + "mcpServers": "MCP 服务器", + "pathAppend": "PATH 追加", + "configurationDocs": "配置文档" }, "help": { "theme": "在浅色和深色外观之间切换。", "language": "选择 WebUI 使用的语言。", - "provider": "选择新模型请求使用的服务提供商。", + "provider": "选择新模型请求使用的服务商。", "model": "设置 nanobot 默认使用的模型名称。", - "configPath": "当前网关正在使用的配置文件。" + "configPath": "当前网关正在使用的配置文件。", + "selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。", + "presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。", + "density": "仅保存在当前浏览器。", + "activityMode": "选择默认显示多少 agent 活动细节。", + "codeWrap": "让较小屏幕上的长代码行更易读。", + "maxResults": "每次 web_search 返回的结果数量。", + "timeout": "搜索服务商请求超时秒数。", + "jinaReader": "可用时为 web_fetch 使用 Jina Reader。", + "imageGeneration": "当已配置图片服务商时,在对话中开放 generate_image。", + "imageProvider": "选择 generate_image 使用的服务商。", + "imageProviderStatus": "图片生成复用服务商页里的凭证配置。", + "imageModel": "发送给所选图片服务商的模型名称。", + "defaultAspectRatio": "当提示词没有选择比例时使用。", + "defaultImageSize": "发送给支持该能力的服务商的尺寸提示。", + "maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。", + "botName": "显示在使用 bot 身份的运行时界面里。", + "botIcon": "显示在 bot 名称旁的短 emoji 或文本。", + "timezone": "运行时上下文和计划任务使用的 IANA 时区。", + "toolHintMaxLength": "工具进度提示显示的最大字符数。", + "advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。" }, "values": { "light": "浅色", "dark": "深色", - "notAvailable": "不可用" + "notAvailable": "不可用", + "enabled": "已启用", + "disabled": "已禁用", + "restartPending": "等待重启", + "ready": "就绪", + "comfortable": "舒适", + "compact": "紧凑", + "auto": "自动", + "expanded": "展开", + "on": "开", + "off": "关", + "configured": "已配置", + "notConfigured": "未配置" }, "status": { "loading": "正在加载设置...", "loadError": "无法加载设置", "unsaved": "有未保存的更改。", - "savedRestart": "已保存。重启 nanobot 后生效。" + "upToDate": "已是最新。", + "savedRestart": "已保存。重启 nanobot 后生效。", + "restartAfterSaving": "保存后,可在合适时重启。", + "savedRestartApply": "已保存,可稍后重启。", + "imageProviderRestart": "图片服务商改动已保存,可稍后重启。" }, "actions": { "save": "保存", "saving": "保存中", "edit": "编辑", - "cancel": "取消" + "cancel": "取消", + "openDocs": "打开文档" }, "byok": { - "description": "自带 provider key。Nanobot 会从当前 config 读取这些值,只有已配置的 provider 才能在通用设置里选择。", + "description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。", "configured": "已配置", "notConfigured": "未配置", "configuredSection": "已配置", @@ -105,22 +205,22 @@ "apiKeyPlaceholder": "输入 API key", "apiKeyConfiguredPlaceholder": "留空则保留当前 key", "configuredKeyHint": "已配置的 key", - "apiBasePlaceholder": "使用 provider 默认地址", - "apiKeyRequired": "需要 API key 才能配置此 provider。", + "apiBasePlaceholder": "使用服务商默认地址", + "apiKeyRequired": "需要 API key 才能配置此服务商。", "showApiKey": "显示 API key", "hideApiKey": "隐藏 API key", - "noConfiguredProviders": "没有已配置的 provider", - "configureFirst": "请先在 BYOK 里配置 provider。", + "noConfiguredProviders": "没有已配置的服务商", + "configureFirst": "请先在 BYOK 里配置服务商。", "openByok": "打开 BYOK", "tabs": { "ariaLabel": "BYOK 凭证类型", "llm": "LLM", - "webSearch": "Web Search" + "webSearch": "网页搜索" }, "webSearch": { - "provider": "搜索 provider", - "providerHelp": "选择 web search 工具使用的后端。", - "selectProvider": "选择 provider", + "provider": "搜索服务商", + "providerHelp": "选择网页搜索工具使用的后端。", + "selectProvider": "选择服务商", "credentials": "凭证", "noCredentialRequired": "无需 key", "noCredentialHelp": "DuckDuckGo 不需要保存 API key。", @@ -128,11 +228,31 @@ "baseUrl": "Base URL", "baseUrlHelp": "SearXNG 需要你自己的实例地址。", "baseUrlPlaceholder": "https://search.example.com", - "apiKeyRequired": "这个搜索 provider 需要 API key。", + "apiKeyRequired": "这个搜索服务商需要 API key。", "baseUrlRequired": "SearXNG 需要 Base URL。", "missingCredential": "填写所需凭证后才能保存。", - "saveHint": "改动会应用到新的 web search 请求。" + "saveHint": "改动会应用到新的网页搜索请求。" } + }, + "overview": { + "model": "当前模型", + "providers": "提供商", + "configuredCount": "已配置 {{count}} 个", + "totalProviders": "共 {{count}} 个可用", + "webSearch": "网页搜索", + "imageGeneration": "图片生成", + "workspace": "工作区" + }, + "providers": { + "searchPlaceholder": "搜索服务商", + "noMatches": "没有匹配的服务商。" + }, + "image": { + "selectProvider": "选择服务商", + "selectAspect": "选择比例", + "selectSize": "选择尺寸", + "configureProvider": "配置服务商", + "missingCredential": "启用图片生成前,请先配置这个服务商。" } }, "chat": { @@ -140,12 +260,30 @@ "loading": "加载中…", "noSessions": "还没有会话。", "actions": "“{{title}}” 的会话操作", + "activity": { + "running": "Agent 正在运行", + "complete": "Agent 已完成" + }, + "pin": "置顶", + "unpin": "取消置顶", + "rename": "重命名", + "renameTitle": "重命名对话", + "renameDescription": "为这个对话设置一个仅用于 WebUI 侧边栏的名称。", + "renamePlaceholder": "对话名称", + "renameSave": "保存", + "archive": "归档", + "unarchive": "取消归档", + "showArchived": "显示归档", + "hideArchived": "隐藏归档", "delete": "删除", "newChat": "新建对话", "groups": { + "pinned": "置顶", + "all": "对话", "today": "今天", "yesterday": "昨天", - "earlier": "更早" + "earlier": "更早", + "archived": "已归档" } }, "deleteConfirm": { @@ -282,7 +420,7 @@ }, "status": { "title": "查看状态", - "description": "显示运行时、provider 和 channel 状态。" + "description": "显示运行时、服务商和通道状态。" }, "history": { "title": "查看对话历史", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 1e26b155d..cf1cd4aa6 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -30,13 +30,25 @@ "collapse": "收合側邊欄", "toggleTheme": "切換主題", "newChat": "新增對話", + "viewOptions": "檢視", + "compactList": "緊湊列表", + "showPreviews": "顯示預覽", + "showTimestamps": "顯示時間", + "sortLabel": "排序", + "sortUpdated": "最近更新", + "sortCreated": "最近建立", + "sortTitle": "標題 A-Z", "recent": "最近對話", "refreshSessions": "重新整理會話", "settings": "設定", "language": { "label": "語言", "ariaLabel": "切換語言" - } + }, + "searchAria": "搜尋", + "searchPlaceholder": "搜尋", + "searchResults": "搜尋結果", + "noSearchResults": "沒有符合的對話。" }, "settings": { "backToChat": "返回對話", @@ -46,12 +58,28 @@ }, "nav": { "general": "一般", - "byok": "BYOK" + "byok": "BYOK", + "overview": "Overview", + "appearance": "Appearance", + "models": "Models", + "providers": "Providers", + "image": "Image", + "web": "Web", + "runtime": "Runtime", + "advanced": "Advanced" }, "sections": { "interface": "介面", "ai": "AI", - "system": "系統" + "system": "系統", + "status": "Status", + "localPreferences": "Local preferences", + "presets": "Presets", + "webSearch": "Web search", + "webBehavior": "Behavior", + "identity": "Identity", + "safety": "Safety", + "integrations": "Integrations" }, "rows": { "theme": "主題", @@ -59,19 +87,70 @@ "provider": "提供者", "model": "模型", "restart": "重新啟動 nanobot", - "configPath": "設定檔路徑" + "configPath": "設定檔路徑", + "activePreset": "Active preset", + "gateway": "Gateway", + "restartState": "Restart state", + "selectedPreset": "Selected preset", + "presetModel": "Preset model", + "density": "Density", + "activityMode": "Activity detail", + "codeWrap": "Code wrapping", + "maxResults": "Max results", + "timeout": "Timeout", + "jinaReader": "Jina reader", + "botName": "Bot name", + "botIcon": "Bot icon", + "timezone": "Timezone", + "toolHintMaxLength": "Tool hint length", + "workspacePath": "Workspace path", + "heartbeat": "Heartbeat", + "dream": "Dream", + "unifiedSession": "Unified session", + "restrictWorkspace": "Restrict to workspace", + "execTool": "Exec tool", + "execSandbox": "Exec sandbox", + "ssrfWhitelist": "SSRF whitelist", + "mcpServers": "MCP servers", + "pathAppend": "PATH append", + "configurationDocs": "Configuration docs" }, "help": { "theme": "在淺色與深色外觀之間切換。", "language": "選擇 WebUI 使用的語言。", "provider": "選擇新模型請求使用的服務提供者。", "model": "設定 nanobot 預設使用的模型名稱。", - "configPath": "目前閘道正在使用的設定檔。" + "configPath": "目前閘道正在使用的設定檔。", + "selectedPreset": "Named presets are read-only here; edit them in config.json.", + "presetModel": "Switch to Default to edit model and provider from the WebUI.", + "density": "Stored only in this browser.", + "activityMode": "Choose how much agent activity chrome to show by default.", + "codeWrap": "Keep long code lines readable on smaller screens.", + "maxResults": "Results returned by each web_search call.", + "timeout": "Seconds before a search provider request times out.", + "jinaReader": "Use Jina Reader for web_fetch when available.", + "botName": "Shown in runtime surfaces that use the configured bot identity.", + "botIcon": "Short emoji or text shown beside the bot name.", + "timezone": "IANA timezone used by runtime context and schedules.", + "toolHintMaxLength": "Maximum characters shown in tool progress hints.", + "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." }, "values": { "light": "淺色", "dark": "深色", - "notAvailable": "不可用" + "notAvailable": "不可用", + "enabled": "Enabled", + "disabled": "Disabled", + "restartRequired": "Restart required", + "liveReload": "Live reload ready", + "comfortable": "Comfortable", + "compact": "Compact", + "auto": "Auto", + "expanded": "Expanded", + "on": "On", + "off": "Off", + "configured": "Configured", + "notConfigured": "Not configured" }, "status": { "loading": "正在載入設定...", @@ -83,7 +162,8 @@ "save": "儲存", "saving": "儲存中", "edit": "編輯", - "cancel": "取消" + "cancel": "取消", + "openDocs": "Open docs" }, "byok": { "description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。", @@ -126,6 +206,18 @@ "missingCredential": "填寫必要憑證後才能儲存。", "saveHint": "變更會套用到新的 web search 請求。" } + }, + "overview": { + "model": "Current model", + "providers": "Providers", + "configuredCount": "{{count}} configured", + "totalProviders": "{{count}} available", + "webSearch": "Web search", + "workspace": "Workspace" + }, + "providers": { + "searchPlaceholder": "Search providers", + "noMatches": "No providers match this search." } }, "chat": { @@ -133,8 +225,31 @@ "loading": "載入中…", "noSessions": "目前還沒有會話。", "actions": "「{{title}}」的會話操作", + "activity": { + "running": "Agent 正在執行", + "complete": "Agent 已完成" + }, + "pin": "置頂", + "unpin": "取消置頂", + "rename": "重新命名", + "renameTitle": "重新命名對話", + "renameDescription": "為這個對話設定僅用於 WebUI 側邊欄的名稱。", + "renamePlaceholder": "對話名稱", + "renameSave": "儲存", + "archive": "封存", + "unarchive": "取消封存", + "showArchived": "顯示封存", + "hideArchived": "隱藏封存", "delete": "刪除", - "newChat": "新增對話" + "newChat": "新增對話", + "groups": { + "pinned": "置頂", + "all": "對話", + "today": "今天", + "yesterday": "昨天", + "earlier": "更早", + "archived": "已封存" + } }, "deleteConfirm": { "title": "刪除這個對話?", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 058ca29cc..d3133675a 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,8 +1,10 @@ import type { ChatSummary, + ImageGenerationSettingsUpdate, ProviderSettingsUpdate, SettingsPayload, SettingsUpdate, + SidebarStatePayload, SlashCommand, WebSearchSettingsUpdate, WebuiThreadPersistedPayload, @@ -52,6 +54,7 @@ export async function listSessions( updated_at: string | null; title?: string; preview?: string; + run_started_at?: number | null; }; const body = await request<{ sessions: Row[] }>( `${base}/api/sessions`, @@ -64,6 +67,7 @@ export async function listSessions( updatedAt: s.updated_at, title: s.title ?? "", preview: s.preview ?? "", + runStartedAt: s.run_started_at ?? null, })); } @@ -125,14 +129,43 @@ export async function listSlashCommands( })); } +export async function fetchSidebarState( + token: string, + base: string = "", +): Promise { + return request(`${base}/api/webui/sidebar-state`, token); +} + +export async function updateSidebarState( + token: string, + state: SidebarStatePayload, + base: string = "", +): Promise { + const query = new URLSearchParams(); + query.set("state", JSON.stringify(state)); + return request( + `${base}/api/webui/sidebar-state/update?${query}`, + token, + ); +} + export async function updateSettings( token: string, update: SettingsUpdate, base: string = "", ): Promise { const query = new URLSearchParams(); + if (update.modelPreset !== undefined) { + query.set("model_preset", update.modelPreset ?? "default"); + } if (update.model !== undefined) query.set("model", update.model); if (update.provider !== undefined) query.set("provider", update.provider); + if (update.timezone !== undefined) query.set("timezone", update.timezone); + if (update.botName !== undefined) query.set("bot_name", update.botName); + if (update.botIcon !== undefined) query.set("bot_icon", update.botIcon); + if (update.toolHintMaxLength !== undefined) { + query.set("tool_hint_max_length", String(update.toolHintMaxLength)); + } return request(`${base}/api/settings/update?${query}`, token); } @@ -160,8 +193,31 @@ export async function updateWebSearchSettings( query.set("provider", update.provider); if (update.apiKey !== undefined) query.set("api_key", update.apiKey); if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl); + if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults)); + if (update.timeout !== undefined) query.set("timeout", String(update.timeout)); + if (update.useJinaReader !== undefined) { + query.set("use_jina_reader", String(update.useJinaReader)); + } return request( `${base}/api/settings/web-search/update?${query}`, token, ); } + +export async function updateImageGenerationSettings( + token: string, + update: ImageGenerationSettingsUpdate, + base: string = "", +): Promise { + const query = new URLSearchParams(); + query.set("enabled", String(update.enabled)); + query.set("provider", update.provider); + query.set("model", update.model); + query.set("default_aspect_ratio", update.defaultAspectRatio); + query.set("default_image_size", update.defaultImageSize); + query.set("max_images_per_turn", String(update.maxImagesPerTurn)); + return request( + `${base}/api/settings/image-generation/update?${query}`, + token, + ); +} diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index dfddf688a..0587520a6 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -56,6 +56,7 @@ type StatusHandler = (status: ConnectionStatus) => void; type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void; type SessionUpdateScope = "metadata" | "thread" | string; type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void; +type RunStatusHandler = (chatId: string, startedAt: number | null) => void; /** Structured connection-level errors surfaced to the UI. * @@ -102,6 +103,7 @@ export class NanobotClient { private statusHandlers = new Set(); private runtimeModelHandlers = new Set(); private sessionUpdateHandlers = new Set(); + private runStatusHandlers = new Set(); private errorHandlers = new Set(); // chat_id -> handlers listening on it private chatHandlers = new Map>(); @@ -172,6 +174,16 @@ export class NanobotClient { }; } + onRunStatus(handler: RunStatusHandler): Unsubscribe { + this.runStatusHandlers.add(handler); + for (const [chatId, startedAt] of this.runStartedAtByChatId) { + handler(chatId, startedAt); + } + return () => { + this.runStatusHandlers.delete(handler); + }; + } + /** Subscribe to transport-level faults (see :type:`StreamError`). */ onError(handler: ErrorHandler): Unsubscribe { this.errorHandlers.add(handler); @@ -194,9 +206,12 @@ export class NanobotClient { private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void { if (ev.event !== "goal_status") return; if (ev.status === "running" && typeof ev.started_at === "number") { + const previous = this.runStartedAtByChatId.get(chatId); this.runStartedAtByChatId.set(chatId, ev.started_at); - } else { + if (previous !== ev.started_at) this.emitRunStatus(chatId, ev.started_at); + } else if (this.runStartedAtByChatId.has(chatId)) { this.runStartedAtByChatId.delete(chatId); + this.emitRunStatus(chatId, null); } } @@ -389,6 +404,12 @@ export class NanobotClient { } } + private emitRunStatus(chatId: string, startedAt: number | null): void { + for (const handler of this.runStatusHandlers) { + handler(chatId, startedAt); + } + } + private dispatch(chatId: string, ev: InboundEvent): void { const handlers = this.chatHandlers.get(chatId); if (handlers !== undefined && handlers.size > 0) { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index c8c7e96e1..66342e9ae 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -110,6 +110,30 @@ export interface ChatSummary { updatedAt: string | null; title?: string; preview: string; + /** Unix epoch seconds when this session currently has a turn in flight. */ + runStartedAt?: number | null; +} + +export type SidebarDensity = "comfortable" | "compact"; +export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc"; + +export interface SidebarViewState { + density: SidebarDensity; + show_previews: boolean; + show_timestamps: boolean; + show_archived: boolean; + sort: SidebarSortMode; +} + +export interface SidebarStatePayload { + schema_version: number; + pinned_keys: string[]; + archived_keys: string[]; + title_overrides: Record; + tags_by_key: Record; + collapsed_groups: Record; + view: SidebarViewState; + updated_at?: string | null; } export interface BootstrapResponse { @@ -125,7 +149,28 @@ export interface SettingsPayload { provider: string; resolved_provider: string | null; has_api_key: boolean; + model_preset: string | null; + max_tokens: number; + context_window_tokens: number; + temperature: number; + reasoning_effort: string | null; + timezone: string; + bot_name: string; + bot_icon: string; + tool_hint_max_length: number; }; + model_presets: Array<{ + name: string; + label: string; + active: boolean; + is_default: boolean; + model: string; + provider: string; + max_tokens: number; + context_window_tokens: number; + temperature: number; + reasoning_effort: string | null; + }>; providers: Array<{ name: string; label: string; @@ -139,21 +184,82 @@ export interface SettingsPayload { provider: string; api_key_hint?: string | null; base_url?: string | null; + max_results: number; + timeout: number; providers: Array<{ name: string; label: string; credential: "none" | "api_key" | "base_url"; }>; }; + web: { + enable: boolean; + proxy?: string | null; + user_agent?: string | null; + search: { + max_results: number; + timeout: number; + }; + fetch: { + use_jina_reader: boolean; + }; + }; + image_generation: { + enabled: boolean; + provider: string; + provider_configured: boolean; + model: string; + default_aspect_ratio: string; + default_image_size: string; + max_images_per_turn: number; + save_dir: string; + providers: Array<{ + name: string; + label: string; + configured: boolean; + api_key_hint?: string | null; + api_base?: string | null; + default_api_base?: string | null; + }>; + }; runtime: { config_path: string; + workspace_path: string; + gateway_host: string; + gateway_port: number; + heartbeat: { + enabled: boolean; + interval_s: number; + keep_recent_messages: number; + }; + dream: { + schedule: string; + max_batch_size: number; + max_iterations: number; + annotate_line_ages: boolean; + }; + unified_session: boolean; + }; + advanced: { + restrict_to_workspace: boolean; + ssrf_whitelist_count: number; + mcp_server_count: number; + exec_enabled: boolean; + exec_sandbox?: string | null; + exec_path_append_set: boolean; }; requires_restart: boolean; + restart_required_sections?: Array<"runtime" | "web" | "image">; } export interface SettingsUpdate { model?: string; provider?: string; + modelPreset?: string | null; + timezone?: string; + botName?: string; + botIcon?: string; + toolHintMaxLength?: number; } export interface ProviderSettingsUpdate { @@ -166,6 +272,18 @@ export interface WebSearchSettingsUpdate { provider: string; apiKey?: string; baseUrl?: string; + maxResults?: number; + timeout?: number; + useJinaReader?: boolean; +} + +export interface ImageGenerationSettingsUpdate { + enabled: boolean; + provider: string; + model: string; + defaultAspectRatio: string; + defaultImageSize: string; + maxImagesPerTurn: number; } export interface SlashCommand { diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index baee87f6a..831f36aef 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { deleteSession, + fetchSidebarState, fetchWebuiThread, listSessions, listSlashCommands, + updateSidebarState, + updateImageGenerationSettings, updateProviderSettings, updateSettings, updateWebSearchSettings, @@ -46,12 +49,17 @@ describe("webui API helpers", () => { it("serializes settings updates as a narrow query string", async () => { await updateSettings("tok", { + modelPreset: "default", model: "openrouter/test", provider: "openrouter", + timezone: "Asia/Shanghai", + botName: "nanobot", + botIcon: "nb", + toolHintMaxLength: 120, }); expect(fetch).toHaveBeenCalledWith( - "/api/settings/update?model=openrouter%2Ftest&provider=openrouter", + "/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120", expect.objectContaining({ headers: { Authorization: "Bearer tok" }, }), @@ -77,16 +85,81 @@ describe("webui API helpers", () => { await updateWebSearchSettings("tok", { provider: "searxng", baseUrl: "https://search.example.com", + maxResults: 8, + timeout: 45, + useJinaReader: false, }); expect(fetch).toHaveBeenCalledWith( - "/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com", + "/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false", expect.objectContaining({ headers: { Authorization: "Bearer tok" }, }), ); }); + it("serializes image generation settings updates", async () => { + await updateImageGenerationSettings("tok", { + enabled: true, + provider: "openrouter", + model: "openai/gpt-5.4-image-2", + defaultAspectRatio: "16:9", + defaultImageSize: "2K", + maxImagesPerTurn: 3, + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/settings/image-generation/update?enabled=true&provider=openrouter&model=openai%2Fgpt-5.4-image-2&default_aspect_ratio=16%3A9&default_image_size=2K&max_images_per_turn=3", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + + it("reads and writes persisted sidebar state", async () => { + const state = { + schema_version: 1, + pinned_keys: ["websocket:chat-1"], + archived_keys: ["websocket:old"], + title_overrides: { "websocket:chat-1": "Release" }, + tags_by_key: {}, + collapsed_groups: {}, + view: { + density: "compact" as const, + show_previews: false, + show_timestamps: false, + show_archived: true, + sort: "updated_desc" as const, + }, + updated_at: null, + }; + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => state, + } as Response); + + await expect(fetchSidebarState("tok")).resolves.toEqual(state); + expect(fetch).toHaveBeenCalledWith( + "/api/webui/sidebar-state", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + + await updateSidebarState("tok", state); + const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!; + expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true); + expect(init).toEqual(expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + })); + const encodedState = new URLSearchParams(String(url).split("?", 2)[1]).get("state"); + expect(encodedState).toBeTruthy(); + expect(JSON.parse(encodedState ?? "{}")).toMatchObject({ + pinned_keys: ["websocket:chat-1"], + title_overrides: { "websocket:chat-1": "Release" }, + }); + }); + it("maps generated session titles from the sessions list", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, @@ -97,6 +170,7 @@ describe("webui API helpers", () => { created_at: "2026-05-01T10:00:00", updated_at: "2026-05-01T10:01:00", title: "优化 WebUI 标题", + run_started_at: 1_700_000_000, }, ], }), @@ -107,6 +181,7 @@ describe("webui API helpers", () => { key: "websocket:chat-1", title: "优化 WebUI 标题", preview: "", + runStartedAt: 1_700_000_000, }, ]); }); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 2f48ba408..26a5b4291 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -9,6 +9,8 @@ const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const deleteChatSpy = vi.fn(); const toggleThemeSpy = vi.fn(); const updateUrlSpy = vi.fn(); +const attachSpy = vi.fn(); +const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>(); let mockSessions: ChatSummary[] = []; vi.mock("@/hooks/useSessions", async (importOriginal) => { @@ -67,9 +69,16 @@ vi.mock("@/lib/nanobot-client", () => { onRuntimeModelUpdate = () => () => {}; onError = () => () => {}; onChat = () => () => {}; + onSessionUpdate = () => () => {}; + onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => { + runStatusHandlers.add(handler); + return () => runStatusHandlers.delete(handler); + }; + getRunStartedAt = () => null; + getGoalState = () => undefined; sendMessage = vi.fn(); newChat = vi.fn(); - attach = vi.fn(); + attach = attachSpy; close = vi.fn(); updateUrl = updateUrlSpy; } @@ -89,6 +98,9 @@ describe("App layout", () => { createChatSpy.mockClear(); deleteChatSpy.mockReset(); toggleThemeSpy.mockReset(); + attachSpy.mockReset(); + runStatusHandlers.clear(); + localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1"); vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ token: "tok", ws_path: "/", @@ -175,6 +187,318 @@ describe("App layout", () => { expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); + it("keeps the mobile session action menu inside the sidebar sheet", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Existing chat", + }, + ]; + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: !query.includes("1024px"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); + + const sheet = await screen.findByRole("dialog"); + const mobileSidebar = within(sheet).getByRole("navigation", { + name: "Sidebar navigation", + }); + await waitFor(() => + expect( + within(mobileSidebar).getByRole("button", { name: /^Existing chat$/ }), + ).toBeInTheDocument(), + ); + + fireEvent.pointerDown( + within(mobileSidebar).getByLabelText("Chat actions for Existing chat"), + { button: 0 }, + ); + + const deleteItem = await within(sheet).findByRole("menuitem", { + name: "Delete", + }); + expect(deleteItem).toBeInTheDocument(); + + fireEvent.click(deleteItem); + await waitFor(() => + expect(screen.getByText("Delete this chat?")).toBeInTheDocument(), + ); + }, 15_000); + + it("applies persisted sidebar workspace state from the gateway", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "First chat", + }, + { + key: "websocket:chat-b", + channel: "websocket", + chatId: "chat-b", + createdAt: "2026-04-16T11:00:00Z", + updatedAt: "2026-04-16T11:00:00Z", + preview: "Second chat", + }, + ]; + const initialState = { + schema_version: 1, + pinned_keys: ["websocket:chat-b"], + archived_keys: ["websocket:chat-a"], + title_overrides: { "websocket:chat-b": "Roadmap" }, + tags_by_key: {}, + collapsed_groups: {}, + view: { + density: "comfortable", + show_previews: false, + show_timestamps: false, + show_archived: false, + sort: "updated_desc", + }, + updated_at: null, + }; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (url: string | URL | Request) => { + const href = String(url); + if (href === "/api/webui/sidebar-state") { + return { ok: true, json: async () => initialState }; + } + if (href.startsWith("/api/webui/sidebar-state/update?")) { + const encoded = new URLSearchParams(href.split("?", 2)[1]).get("state"); + return { + ok: true, + json: async () => JSON.parse(encoded ?? "{}"), + }; + } + return { ok: false, status: 404 }; + }), + ); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + await waitFor(() => + expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(), + ); + expect(within(sidebar).getByRole("button", { name: /^Roadmap$/ })).toBeInTheDocument(); + expect(within(sidebar).queryByRole("button", { name: /^First chat$/ })).not.toBeInTheDocument(); + + fireEvent.click(within(sidebar).getByRole("button", { name: "Show archived" })); + await waitFor(() => + expect(within(sidebar).getByText("Archived")).toBeInTheDocument(), + ); + expect(within(sidebar).getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(); + const updateUrl = vi.mocked(fetch).mock.calls + .map(([url]) => String(url)) + .find((url) => url.startsWith("/api/webui/sidebar-state/update?")); + expect(updateUrl).toBeTruthy(); + const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state"); + expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true); + + fireEvent.pointerDown(within(sidebar).getByRole("button", { name: "View" }), { + button: 0, + ctrlKey: false, + }); + fireEvent.click(await screen.findByText("Compact list")); + await waitFor(() => { + const lastUpdateUrl = vi.mocked(fetch).mock.calls + .map(([url]) => String(url)) + .filter((url) => url.startsWith("/api/webui/sidebar-state/update?")) + .at(-1); + const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state"); + expect(JSON.parse(lastEncoded ?? "{}").view.density).toBe("compact"); + }); + + fireEvent.click(screen.getByText("Title A-Z")); + await waitFor(() => { + const lastUpdateUrl = vi.mocked(fetch).mock.calls + .map(([url]) => String(url)) + .filter((url) => url.startsWith("/api/webui/sidebar-state/update?")) + .at(-1); + const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state"); + expect(JSON.parse(lastEncoded ?? "{}").view.sort).toBe("title_asc"); + }); + }); + + it("sorts chats by displayed title when A-Z is persisted", async () => { + mockSessions = [ + { + key: "websocket:zulu", + channel: "websocket", + chatId: "zulu", + createdAt: "2026-04-16T12:00:00Z", + updatedAt: "2026-04-16T12:00:00Z", + title: "Zulu work", + preview: "later", + }, + { + key: "websocket:new", + channel: "websocket", + chatId: "new", + createdAt: "2026-04-15T12:00:00Z", + updatedAt: "2026-04-15T12:00:00Z", + preview: "hi nanobot", + }, + { + key: "websocket:alpha", + channel: "websocket", + chatId: "alpha", + createdAt: "2026-04-14T12:00:00Z", + updatedAt: "2026-04-14T12:00:00Z", + title: "Alpha plan", + preview: "earlier", + }, + ]; + const initialState = { + schema_version: 1, + pinned_keys: [], + archived_keys: [], + title_overrides: {}, + tags_by_key: {}, + collapsed_groups: {}, + view: { + density: "comfortable", + show_previews: false, + show_timestamps: false, + show_archived: false, + sort: "title_asc", + }, + updated_at: null, + }; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (url: string | URL | Request) => { + const href = String(url); + if (href === "/api/webui/sidebar-state") { + return { ok: true, json: async () => initialState }; + } + return { ok: false, status: 404 }; + }), + ); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + await waitFor(() => + expect(within(sidebar).getByText("Chats")).toBeInTheDocument(), + ); + const group = within(sidebar).getByText("Chats").closest("section"); + expect(group).toBeTruthy(); + const labels = within(group as HTMLElement) + .getAllByRole("button") + .map((button) => button.textContent?.trim()) + .filter(Boolean); + + expect(labels).toEqual(["Alpha plan", "New chat", "Zulu work"]); + }); + + it("shows running and completed session indicators in the sidebar", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Working chat", + }, + { + key: "websocket:chat-b", + channel: "websocket", + chatId: "chat-b", + createdAt: "2026-04-16T11:00:00Z", + updatedAt: "2026-04-16T11:00:00Z", + preview: "Quiet chat", + }, + ]; + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + await waitFor(() => + expect( + within(sidebar).getByRole("button", { name: /^Working chat$/ }), + ).toBeInTheDocument(), + ); + + 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).getByTitle("Agent finished")).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ })); + }); + expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument(); + }); + + it("restores sidebar run indicators after a page reload", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Running after reload", + runStartedAt: 12_345, + }, + { + key: "websocket:chat-b", + channel: "websocket", + chatId: "chat-b", + createdAt: "2026-04-16T11:00:00Z", + updatedAt: "2026-04-16T11:00:00Z", + preview: "Completed after reload", + }, + ]; + localStorage.setItem( + "nanobot-webui.sidebar.completed-runs.v1", + JSON.stringify(["chat-b"]), + ); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + await waitFor(() => + expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(), + ); + expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument(); + expect(attachSpy).toHaveBeenCalledWith("chat-a"); + }); + it("opens the settings view from the sidebar footer", async () => { mockSessions = [ { @@ -199,7 +523,42 @@ describe("App layout", () => { provider: "auto", resolved_provider: "openai", has_api_key: true, + model_preset: "default", + max_tokens: 8192, + context_window_tokens: 65536, + temperature: 0.1, + reasoning_effort: null, + timezone: "UTC", + bot_name: "nanobot", + bot_icon: "nb", + tool_hint_max_length: 40, }, + model_presets: [ + { + name: "default", + label: "Default", + active: true, + is_default: true, + model: "openai/gpt-4o", + provider: "auto", + max_tokens: 8192, + context_window_tokens: 65536, + temperature: 0.1, + reasoning_effort: null, + }, + { + name: "deep", + label: "deep", + active: false, + is_default: false, + model: "anthropic/claude-opus-4-5", + provider: "anthropic", + max_tokens: 8192, + context_window_tokens: 200000, + temperature: 0.1, + reasoning_effort: "high", + }, + ], providers: [ { name: "openai", @@ -269,14 +628,74 @@ describe("App layout", () => { provider: "brave", api_key_hint: "BSAo••••ew20", base_url: null, + max_results: 5, + timeout: 30, providers: [ { name: "duckduckgo", label: "DuckDuckGo", credential: "none" }, { name: "brave", label: "Brave Search", credential: "api_key" }, { name: "tavily", label: "Tavily", credential: "api_key" }, ], }, + web: { + enable: true, + proxy: null, + user_agent: null, + search: { max_results: 5, timeout: 30 }, + fetch: { use_jina_reader: true }, + }, + image_generation: { + enabled: false, + provider: "openrouter", + provider_configured: true, + model: "openai/gpt-5.4-image-2", + default_aspect_ratio: "1:1", + default_image_size: "1K", + max_images_per_turn: 4, + save_dir: "generated", + providers: [ + { + name: "openrouter", + label: "OpenRouter", + configured: true, + api_key_hint: "sk-o••••test", + api_base: "https://openrouter.ai/api/v1", + default_api_base: "https://openrouter.ai/api/v1", + }, + { + name: "gemini", + label: "Gemini", + configured: false, + api_key_hint: null, + api_base: null, + default_api_base: "https://generativelanguage.googleapis.com/v1beta/openai/", + }, + ], + }, runtime: { config_path: "/tmp/config.json", + workspace_path: "/tmp/workspace", + gateway_host: "127.0.0.1", + gateway_port: 18790, + heartbeat: { + enabled: true, + interval_s: 1800, + keep_recent_messages: 8, + }, + dream: { + schedule: "every 2h", + max_batch_size: 20, + max_iterations: 15, + annotate_line_ages: true, + }, + unified_session: false, + }, + advanced: { + restrict_to_workspace: false, + ssrf_whitelist_count: 0, + mcp_server_count: 0, + exec_enabled: true, + exec_sandbox: null, + exec_path_append_set: false, }, requires_restart: false, }), @@ -292,21 +711,32 @@ describe("App layout", () => { const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" })); - expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument(); expect(document.title).toBe("Settings · nanobot"); expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument(); const settingsNav = screen.getByRole("navigation", { name: "Settings sections" }); - expect(within(settingsNav).getByRole("button", { name: "General" })).toHaveAttribute( + expect(settingsNav.className).toContain("overflow-x-auto"); + expect(settingsNav.className).not.toContain("grid-cols-2"); + expect(within(settingsNav).getByRole("button", { name: "Overview" })).toHaveAttribute( "aria-current", "page", ); - expect(within(settingsNav).getByRole("button", { name: "BYOK" })).toBeInTheDocument(); + expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument(); + expect(within(settingsNav).getByRole("button", { name: "Providers" })).toBeInTheDocument(); + expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument(); + expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument(); + expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument(); + fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" })); expect(screen.getByText("AI")).toBeInTheDocument(); - expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); - fireEvent.click(within(settingsNav).getByRole("button", { name: "BYOK" })); - expect(screen.getByRole("tab", { name: "LLM" })).toHaveAttribute("aria-selected", "true"); - expect(screen.getByRole("tab", { name: "Web Search" })).toBeInTheDocument(); + const modelInput = screen.getByDisplayValue("openai/gpt-4o"); + expect(modelInput).toBeInTheDocument(); + fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } }); + expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain( + "text-blue-600", + ); + fireEvent.change(modelInput, { target: { value: "openai/gpt-4o" } }); + fireEvent.click(within(settingsNav).getByRole("button", { name: "Providers" })); expect(screen.getByText("OpenRouter")).toBeInTheDocument(); expect(screen.getByText("Ant Ling")).toBeInTheDocument(); expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0); @@ -325,7 +755,14 @@ describe("App layout", () => { expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); - fireEvent.click(screen.getByRole("tab", { name: "Web Search" })); + fireEvent.click(within(settingsNav).getByRole("button", { name: "Image" })); + expect(screen.getByRole("heading", { name: "Image" })).toBeInTheDocument(); + expect(screen.getByText("Provider status")).toBeInTheDocument(); + expect(screen.getByDisplayValue("openai/gpt-5.4-image-2")).toBeInTheDocument(); + expect(screen.getByText("Save directory")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + + fireEvent.click(within(settingsNav).getByRole("button", { name: "Web" })); expect(screen.getByText("Search provider")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument(); expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument(); @@ -339,6 +776,10 @@ describe("App layout", () => { fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" })); expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument(); expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument(); + + fireEvent.click(within(settingsNav).getByRole("button", { name: "Runtime" })); + expect(screen.getByText("Bot name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); }); it("returns from settings to the blank start page when no session was active", async () => { @@ -373,19 +814,94 @@ describe("App layout", () => { provider: "openai", resolved_provider: "openai", has_api_key: true, + model_preset: "default", + max_tokens: 8192, + context_window_tokens: 65536, + temperature: 0.1, + reasoning_effort: null, + timezone: "UTC", + bot_name: "nanobot", + bot_icon: "nb", + tool_hint_max_length: 40, }, + model_presets: [ + { + name: "default", + label: "Default", + active: true, + is_default: true, + model: "openai/gpt-4o", + provider: "openai", + max_tokens: 8192, + context_window_tokens: 65536, + temperature: 0.1, + reasoning_effort: null, + }, + ], providers: [{ name: "openai", label: "OpenAI", configured: true }], web_search: { provider: "duckduckgo", api_key_hint: null, base_url: null, + max_results: 5, + timeout: 30, providers: [ { name: "duckduckgo", label: "DuckDuckGo", credential: "none" }, { name: "brave", label: "Brave Search", credential: "api_key" }, ], }, + web: { + enable: true, + proxy: null, + user_agent: null, + search: { max_results: 5, timeout: 30 }, + fetch: { use_jina_reader: true }, + }, + image_generation: { + enabled: false, + provider: "openrouter", + provider_configured: false, + model: "openai/gpt-5.4-image-2", + default_aspect_ratio: "1:1", + default_image_size: "1K", + max_images_per_turn: 4, + save_dir: "generated", + providers: [ + { + name: "openrouter", + label: "OpenRouter", + configured: false, + api_key_hint: null, + api_base: null, + default_api_base: "https://openrouter.ai/api/v1", + }, + ], + }, runtime: { config_path: "/tmp/config.json", + workspace_path: "/tmp/workspace", + gateway_host: "127.0.0.1", + gateway_port: 18790, + heartbeat: { + enabled: true, + interval_s: 1800, + keep_recent_messages: 8, + }, + dream: { + schedule: "every 2h", + max_batch_size: 20, + max_iterations: 15, + annotate_line_ages: true, + }, + unified_session: false, + }, + advanced: { + restrict_to_workspace: false, + ssrf_whitelist_count: 0, + mcp_server_count: 0, + exec_enabled: true, + exec_sandbox: null, + exec_path_append_set: false, }, requires_restart: false, }), @@ -403,14 +919,14 @@ describe("App layout", () => { await waitFor(() => expect(document.title).toBe("nanobot")); fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" })); - expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Back to chat" })); await waitFor(() => expect(document.title).toBe("nanobot")); expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); }); - it("filters sidebar sessions through the lightweight search row", async () => { + it("filters sessions in the centered search dialog", async () => { mockSessions = [ { key: "websocket:chat-alpha", @@ -437,20 +953,43 @@ describe("App layout", () => { const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument(); expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); + const newChatButton = within(sidebar).getByRole("button", { name: "New chat" }); + const searchButton = within(sidebar).getByRole("button", { name: "Search" }); + expect( + newChatButton.compareDocumentPosition(searchButton) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); - fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), { + fireEvent.click(searchButton); + const dialog = await screen.findByRole("dialog", { name: "Search" }); + expect(dialog).toHaveClass("origin-center"); + expect(dialog.className).not.toContain("translate-x"); + expect(dialog.className).not.toContain("translate-y"); + expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument(); + expect(within(dialog).getByText("Travel ideas")).toBeInTheDocument(); + expect(within(dialog).queryByText("websocket")).not.toBeInTheDocument(); + expect(within(dialog).queryByText("#1")).not.toBeInTheDocument(); + + fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), { target: { value: "planning" }, }); - expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument(); - expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument(); + expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument(); + expect(within(dialog).queryByText("Travel ideas")).not.toBeInTheDocument(); + expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); - fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), { + fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), { target: { value: "road q2" }, }); - expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument(); - expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument(); + expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument(); + expect(within(dialog).queryByText("Travel ideas")).not.toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("button", { name: /Q2 roadmap/ })); + + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument(), + ); }); it("opens a blank start page without creating an empty chat", async () => { diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 031b1aeea..d1359121c 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -8,7 +8,16 @@ import { resources } from "@/i18n"; const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"]; const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"]; -const SETTINGS_NAV_KEYS = ["general", "byok"]; +const SETTINGS_NAV_KEYS = [ + "overview", + "appearance", + "models", + "providers", + "image", + "web", + "runtime", + "advanced", +]; describe("webui i18n", () => { it("switches UI copy and document locale through the language switcher", async () => { @@ -87,4 +96,14 @@ describe("webui i18n", () => { expect(common.settings.byok.configuredKeyHint).toBeTruthy(); } }); + + it("keeps Simplified Chinese settings overview copy localized", () => { + const settings = resources["zh-CN"].common.settings; + + expect(settings.nav.web).toBe("网页"); + expect(settings.sections.webSearch).toBe("网页搜索"); + expect(settings.byok.tabs.webSearch).toBe("网页搜索"); + expect(settings.overview.webSearch).toBe("网页搜索"); + expect(settings.overview.workspace).toBe("工作区"); + }); }); diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 6b434bb34..9db74d461 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -132,6 +132,37 @@ describe("NanobotClient", () => { expect(client.getRunStartedAt("chat-strip")).toBeNull(); }); + it("notifies run status subscribers and replays running chats", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const handler = vi.fn(); + client.onRunStatus(handler); + client.connect(); + lastSocket().fakeOpen(); + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-status", + status: "running", + started_at: 12_345, + }); + expect(handler).toHaveBeenCalledWith("chat-status", 12_345); + + const lateHandler = vi.fn(); + client.onRunStatus(lateHandler); + expect(lateHandler).toHaveBeenCalledWith("chat-status", 12_345); + + lastSocket().fakeMessage({ + event: "goal_status", + chat_id: "chat-status", + status: "idle", + }); + expect(handler).toHaveBeenCalledWith("chat-status", null); + expect(lateHandler).toHaveBeenCalledWith("chat-status", null); + }); + it("records goal_state per chat_id without an onChat subscriber", () => { const client = new NanobotClient({ url: "ws://test",