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
This commit is contained in:
Xubin Ren 2026-05-19 22:42:38 +08:00 committed by GitHub
parent 30fc05c746
commit 57d5276da1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 6306 additions and 1012 deletions

View File

@ -36,17 +36,17 @@ from nanobot.session.goal_state import (
runner_wall_llm_timeout_s, runner_wall_llm_timeout_s,
) )
from nanobot.session.manager import Session, SessionManager 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.document import extract_documents
from nanobot.utils.helpers import image_placeholder_text from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE 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: if TYPE_CHECKING:
from nanobot.config.schema import ( from nanobot.config.schema import (

View File

@ -37,15 +37,27 @@ from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.session.goal_state import goal_state_ws_blob 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.helpers import safe_filename
from nanobot.utils.media_decode import ( from nanobot.utils.media_decode import (
FileSizeExceeded, FileSizeExceeded,
save_base64_data_url, save_base64_data_url,
) )
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.utils.webui_thread_disk import delete_webui_thread from nanobot.webui.settings_api import (
from nanobot.utils.webui_transcript import append_transcript_object, build_webui_thread_response WebUISettingsError,
from nanobot.utils.webui_turn_helpers import websocket_turn_wall_started_at 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: if TYPE_CHECKING:
from nanobot.session.manager import SessionManager 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 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: def _parse_inbound_payload(raw: str) -> str | None:
"""Parse a client frame into text; return None for empty or unrecognized content.""" """Parse a client frame into text; return None for empty or unrecognized content."""
text = raw.strip() text = raw.strip()
@ -501,6 +472,7 @@ class WebSocketChannel(BaseChannel):
static_dist_path.resolve() if static_dist_path is not None else None static_dist_path.resolve() if static_dist_path is not None else None
) )
self._runtime_model_name = runtime_model_name 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 # Process-local secret used to HMAC-sign media URLs. The signed URL is
# the capability — anyone who holds a valid URL can fetch that one # the capability — anyone who holds a valid URL can fetch that one
# file, nothing else. The secret regenerates on restart so links # file, nothing else. The secret regenerates on restart so links
@ -663,6 +635,12 @@ class WebSocketChannel(BaseChannel):
if got == "/api/commands": if got == "/api/commands":
return self._handle_commands(request) 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": if got == "/api/settings/update":
return self._handle_settings_update(request) return self._handle_settings_update(request)
@ -672,6 +650,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/web-search/update": if got == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request) 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) m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m: if m:
return self._handle_session_messages(request, m.group(1)) return self._handle_session_messages(request, m.group(1))
@ -783,221 +764,115 @@ class WebSocketChannel(BaseChannel):
sessions = self._session_manager.list_sessions() sessions = self._session_manager.list_sessions()
# Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc. # Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc.
# keys are not intended for resume over this HTTP surface. # keys are not intended for resume over this HTTP surface.
cleaned = [ cleaned = []
{k: v for k, v in s.items() if k != "path"} for s in sessions:
for s in sessions key = s.get("key")
if isinstance(s.get("key"), str) and s["key"].startswith("websocket:") if not (isinstance(key, str) and 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:
continue continue
providers.append( row = {k: v for k, v in s.items() if k != "path"}
{ chat_id = key.split(":", 1)[1]
"name": spec.name, started_at = websocket_turn_wall_started_at(chat_id)
"label": spec.label, if started_at is not None:
"configured": _provider_configured_for_settings(spec, provider_config), row["run_started_at"] = started_at
"api_key_required": _provider_requires_api_key(spec), cleaned.append(row)
"api_key_hint": _mask_secret_hint(provider_config.api_key), return _http_json_response({"sessions": cleaned})
"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,
}
def _handle_settings(self, request: WsRequest) -> Response: def _handle_settings(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
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: def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
return _http_json_response({"commands": builtin_command_palette()}) 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: def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
from nanobot.config.loader import load_config, save_config
from nanobot.providers.registry import find_by_name
query = _parse_query(request.path) query = _parse_query(request.path)
config = load_config() try:
defaults = config.agents.defaults payload = update_agent_settings(query)
changed = False except WebUISettingsError as e:
return _http_error(e.status, e.message)
model = _query_first(query, "model") return _http_json_response(
if model is not None: self._with_settings_restart_state(payload, section="runtime")
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))
def _handle_settings_provider_update(self, request: WsRequest) -> Response: def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
from nanobot.config.loader import load_config, save_config
from nanobot.providers.registry import find_by_name
query = _parse_query(request.path) query = _parse_query(request.path)
provider_name = (_query_first(query, "provider") or "").strip() try:
if not provider_name: payload = update_provider_settings(query)
return _http_error(400, "provider is required") except WebUISettingsError as e:
spec = find_by_name(provider_name) return _http_error(e.status, e.message)
if spec is None or spec.is_oauth: return _http_json_response(self._with_settings_restart_state(payload, section="image"))
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))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response: def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
from nanobot.config.loader import load_config, save_config
query = _parse_query(request.path) query = _parse_query(request.path)
provider_name = (_query_first(query, "provider") or "").strip().lower() try:
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name) payload = update_web_search_settings(query)
if provider_option is None: except WebUISettingsError as e:
return _http_error(400, "unknown web search provider") return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="web"))
config = load_config() def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
search_config = config.tools.web.search if not self._check_api_token(request):
previous_provider = search_config.provider return _http_error(401, "Unauthorized")
changed = False query = _parse_query(request.path)
try:
def set_value(attr: str, value: str | None) -> None: payload = update_image_generation_settings(query)
nonlocal changed except WebUISettingsError as e:
if getattr(search_config, attr) != value: return _http_error(e.status, e.message)
setattr(search_config, attr, value) return _http_json_response(self._with_settings_restart_state(payload, section="image"))
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))
@staticmethod @staticmethod
def _is_websocket_channel_session_key(key: str) -> bool: def _is_websocket_channel_session_key(key: str) -> bool:

View File

@ -139,6 +139,11 @@ def get_image_gen_provider(name: str) -> type[ImageGenerationProvider] | None:
return _IMAGE_GEN_PROVIDERS.get(name) 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]: def image_gen_provider_configs(config: Any) -> dict[str, Any]:
providers_cfg = config.providers providers_cfg = config.providers
return { return {

View File

@ -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 AgentLoop uses these without importing a concrete channel plugin; only
``channel == "websocket"`` messages are affected. ``channel == "websocket"`` messages are affected.

View File

@ -1,6 +1,42 @@
"""Utility functions for nanobot.""" """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.helpers import ensure_dir
from nanobot.utils.path import abbreviate_path from nanobot.utils.path import abbreviate_path
__all__ = ["ensure_dir", "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),
)

View File

@ -120,5 +120,3 @@ def generated_image_tool_result(artifacts: list[dict[str, Any]]) -> str:
}, },
ensure_ascii=False, ensure_ascii=False,
) )

View File

@ -0,0 +1,2 @@
"""Backend helpers for the bundled WebUI surface."""

View File

@ -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)

View File

@ -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

View File

@ -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 from __future__ import annotations
@ -8,7 +8,7 @@ from loguru import logger
from nanobot.config.paths import get_webui_dir from nanobot.config.paths import get_webui_dir
from nanobot.session.manager import SessionManager 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: def webui_thread_file_path(session_key: str) -> Path:

View File

@ -651,7 +651,7 @@ class TestToolEventProgress:
return False return False
monkeypatch.setattr( 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, fake_title_after_turn,
) )
scheduled_title: list[object] = [] scheduled_title: list[object] = []
@ -698,7 +698,7 @@ class TestToolEventProgress:
raise AssertionError("command-only turns should not generate titles") raise AssertionError("command-only turns should not generate titles")
monkeypatch.setattr( 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, fake_title_after_turn,
) )
scheduled: list[object] = [] scheduled: list[object] = []

View File

@ -11,7 +11,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse from nanobot.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager 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_MAX_TOKENS,
TITLE_GENERATION_REASONING_EFFORT, TITLE_GENERATION_REASONING_EFFORT,
WEBUI_SESSION_METADATA_KEY, WEBUI_SESSION_METADATA_KEY,
@ -143,7 +143,7 @@ def test_webui_title_update_uses_captured_llm_runtime(
return False return False
monkeypatch.setattr( 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, fake_title_after_turn,
) )
coordinator = WebuiTurnCoordinator( coordinator = WebuiTurnCoordinator(

View File

@ -29,7 +29,8 @@ from nanobot.channels.websocket import (
publish_runtime_model_update, publish_runtime_model_update,
) )
from nanobot.config.loader import load_config, save_config 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) --------------- # -- 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) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock() mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1") 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() wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
await channel._maybe_push_turn_run_wall_clock("chat-1") 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) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock() mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1") 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() wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
try: try:
@ -991,6 +992,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
config = Config() config = Config()
config.agents.defaults.model = "openai/gpt-4o" config.agents.defaults.model = "openai/gpt-4o"
config.providers.openai.api_key = "secret-key" 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.provider = "brave"
config.tools.web.search.api_key = "brave-secret" config.tools.web.search.api_key = "brave-secret"
save_config(config, config_path) save_config(config, config_path)
@ -1011,6 +1017,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
body = settings.json() body = settings.json()
assert body["agent"]["model"] == "openai/gpt-4o" assert body["agent"]["model"] == "openai/gpt-4o"
assert body["agent"]["provider"] == "openai" 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"]} providers = {provider["name"]: provider for provider in body["providers"]}
assert providers["openai"]["configured"] is True assert providers["openai"]["configured"] is True
assert providers["openai"]["api_key_hint"] == "secr••••-key" 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["agent"]["has_api_key"] is True
assert body["web_search"]["provider"] == "brave" assert body["web_search"]["provider"] == "brave"
assert body["web_search"]["api_key_hint"] == "brav••••cret" 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"]} search_providers = {provider["name"]: provider for provider in body["web_search"]["providers"]}
assert search_providers["duckduckgo"]["credential"] == "none" assert search_providers["duckduckgo"]["credential"] == "none"
assert search_providers["searxng"]["credential"] == "base_url" 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 "secret-key" not in settings.text
assert "brave-secret" 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 assert provider_body["requires_restart"] is False
provider_rows = {provider["name"]: provider for provider in provider_body["providers"]} provider_rows = {provider["name"]: provider for provider in provider_body["providers"]}
assert provider_rows["openrouter"]["configured"] is True 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 assert "sk-or-test" not in provider_updated.text
local_provider_updated = await _http_get( 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( updated = await _http_get(
"http://127.0.0.1:" "http://127.0.0.1:"
f"{port}/api/settings/update?model=atomic_chat/test" 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"}, headers={"Authorization": "Bearer tok"},
) )
assert updated.status_code == 200 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( search_updated = await _http_get(
"http://127.0.0.1:" "http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=searxng" 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"}, headers={"Authorization": "Bearer tok"},
) )
assert search_updated.status_code == 200 assert search_updated.status_code == 200
search_body = search_updated.json() 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"]["provider"] == "searxng"
assert search_body["web_search"]["api_key_hint"] is None 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"]["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) saved = load_config(config_path)
assert saved.agents.defaults.model == "atomic_chat/test" assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat" 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.openrouter.api_base == "https://openrouter.ai/api/v1"
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1" assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
assert saved.tools.web.search.provider == "searxng" assert saved.tools.web.search.provider == "searxng"
assert saved.tools.web.search.api_key == "" assert saved.tools.web.search.api_key == ""
assert saved.tools.web.search.base_url == "https://search.example.com" 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: finally:
await channel.stop() await channel.stop()
await server_task await server_task
@ -1133,7 +1249,7 @@ def test_settings_payload_normalizes_camel_case_provider(
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", 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" 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 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( @pytest.mark.parametrize(
("value", "expected"), ("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.datastructures import Headers
from websockets.http11 import Request 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) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:c1" key = "websocket:c1"

View File

@ -6,6 +6,7 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from urllib.parse import urlencode
import httpx import httpx
import pytest import pytest
@ -176,13 +177,62 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
await server_task 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 @pytest.mark.asyncio
async def test_session_delete_removes_file( async def test_session_delete_removes_file(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed") 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"}) append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"})
channel = _ch(bus, session_manager=sm, port=29903) channel = _ch(bus, session_manager=sm, port=29903)

View File

@ -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

View File

@ -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"]

View File

@ -2,8 +2,8 @@
from __future__ import annotations from __future__ import annotations
from nanobot.utils.webui_thread_disk import delete_webui_thread, webui_thread_file_path from nanobot.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.transcript import append_transcript_object, webui_transcript_path
def test_delete_webui_thread_removes_legacy_json_and_transcript(tmp_path, monkeypatch) -> None: def test_delete_webui_thread_removes_legacy_json_and_transcript(tmp_path, monkeypatch) -> None:

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from nanobot.utils.webui_transcript import ( from nanobot.webui.transcript import (
WEBUI_TRANSCRIPT_SCHEMA_VERSION, WEBUI_TRANSCRIPT_SCHEMA_VERSION,
append_transcript_object, append_transcript_object,
read_transcript_lines, 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: 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) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t3" key = "websocket:t3"

View File

@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.bus.events import InboundMessage 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) @pytest.fixture(autouse=True)

View File

@ -1,13 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm"; import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
import { SettingsView } from "@/components/settings/SettingsView"; import { SettingsView } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell"; 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 { useSessions } from "@/hooks/useSessions";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh"; import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { useSidebarState } from "@/hooks/useSidebarState";
import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@ -37,6 +40,7 @@ type BootState =
}; };
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; 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 RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272; const SIDEBAR_WIDTH = 272;
const TOKEN_REFRESH_MARGIN_MS = 30_000; const TOKEN_REFRESH_MARGIN_MS = 30_000;
@ -121,6 +125,29 @@ function readSidebarOpen(): boolean {
} }
} }
function readCompletedRunChatIds(): Set<string> {
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<string>): 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() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" }); const [state, setState] = useState<BootState>({ status: "loading" });
@ -293,18 +320,28 @@ function Shell({
const { client } = useClient(); const { client } = useClient();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
const [activeKey, setActiveKey] = useState<string | null>(null); const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat"); const [view, setView] = useState<ShellView>("chat");
const [desktopSidebarOpen, setDesktopSidebarOpen] = const [desktopSidebarOpen, setDesktopSidebarOpen] =
useState<boolean>(readSidebarOpen); useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{ const [pendingDelete, setPendingDelete] = useState<{
key: string; key: string;
label: string; label: string;
} | null>(null); } | null>(null);
const [pendingRename, setPendingRename] = useState<{
key: string;
label: string;
} | null>(null);
const restartSawDisconnectRef = useRef(false); const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null); const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const runningChatIdsRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
try { try {
@ -317,12 +354,58 @@ function Shell({
} }
}, [desktopSidebarOpen]); }, [desktopSidebarOpen]);
useEffect(() => {
writeCompletedRunChatIds(completedChatIds);
}, [completedChatIds]);
const activeSession = useMemo<ChatSummary | null>(() => { const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null; if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null; return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]); }, [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(() => { const closeDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(false); setDesktopSidebarOpen(false);
@ -364,14 +447,129 @@ function Shell({
const onSelectChat = useCallback( const onSelectChat = useCallback(
(key: string) => { (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); setActiveKey(key);
setView("chat"); setView("chat");
setMobileSidebarOpen(false); 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<typeof sidebarState.view>) => {
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(() => { const onOpenSettings = useCallback(() => {
setSessionSearchOpen(false);
setView("settings"); setView("settings");
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, []); }, []);
@ -405,6 +603,35 @@ function Shell({
}); });
}, [client, onModelNameChange]); }, [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(() => { useEffect(() => {
return client.onStatus((status) => { return client.onStatus((status) => {
let startedAt = 0; let startedAt = 0;
@ -452,7 +679,8 @@ function Shell({
}, [pendingDelete, deleteChat, activeKey, sessions]); }, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession const headerTitle = activeSession
? activeSession.title || ? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat")) deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand"); : t("app.brand");
@ -476,7 +704,21 @@ function Shell({
onSelect: onSelectChat, onSelect: onSelectChat,
onRequestDelete: (key: string, label: string) => onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }), setPendingDelete({ key, label }),
onTogglePin,
onRequestRename,
onToggleArchive,
onOpenSettings, 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"; const showMainSidebar = view !== "settings";
@ -513,14 +755,32 @@ function Shell({
<SheetContent <SheetContent
side="left" side="left"
showCloseButton={false} showCloseButton={false}
aria-describedby={undefined}
className="p-0 lg:hidden" className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }} style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
> >
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} /> <SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
<Sidebar
{...sidebarProps}
onCollapse={closeMobileSidebar}
containActionMenus
/>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) : null} ) : null}
{showMainSidebar ? (
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
) : null}
<main className="relative flex h-full min-w-0 flex-1 flex-col"> <main className="relative flex h-full min-w-0 flex-1 flex-col">
<div <div
className={cn( className={cn(
@ -561,6 +821,12 @@ function Shell({
onCancel={() => setPendingDelete(null)} onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete} onConfirm={onConfirmDelete}
/> />
<RenameChatDialog
open={!!pendingRename}
title={pendingRename?.label ?? ""}
onCancel={() => setPendingRename(null)}
onConfirm={onConfirmRename}
/>
{restartToast ? ( {restartToast ? (
<div <div
role="status" role="status"

View File

@ -1,4 +1,12 @@
import { MoreHorizontal, Trash2 } from "lucide-react"; import {
Archive,
ArchiveRestore,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@ -7,15 +15,29 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { deriveTitle } from "@/lib/format"; import { deriveTitle, relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
interface ChatListProps { interface ChatListProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
activeKey: string | null; activeKey: string | null;
onSelect: (key: string) => void; onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => 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<string, string>;
runningChatIds?: string[];
completedChatIds?: string[];
density?: SidebarDensity;
showPreviews?: boolean;
showTimestamps?: boolean;
sort?: SidebarSortMode;
showArchived?: boolean;
actionMenuPortalContainer?: HTMLElement | null;
loading?: boolean; loading?: boolean;
emptyLabel?: string; emptyLabel?: string;
} }
@ -25,6 +47,20 @@ export function ChatList({
activeKey, activeKey,
onSelect, onSelect,
onRequestDelete, onRequestDelete,
onTogglePin,
onRequestRename,
onToggleArchive,
pinnedKeys = [],
archivedKeys = [],
titleOverrides = {},
runningChatIds = [],
completedChatIds = [],
density = "comfortable",
showPreviews = false,
showTimestamps = false,
sort = "updated_desc",
showArchived = false,
actionMenuPortalContainer,
loading, loading,
emptyLabel, emptyLabel,
}: ChatListProps) { }: ChatListProps) {
@ -46,10 +82,25 @@ export function ChatList({
} }
const groups = groupSessions(sessions, { const groups = groupSessions(sessions, {
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
today: t("chat.groups.today"), today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"), yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"), 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 ( return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain"> <div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
@ -66,15 +117,29 @@ export function ChatList({
id: s.chatId.slice(0, 6), id: s.chatId.slice(0, 6),
}); });
const generatedTitle = s.title?.trim() || ""; const generatedTitle = s.title?.trim() || "";
const title = const title = displayTitle(s, titleOverrides, t("chat.newChat"));
generatedTitle || deriveTitle(s.preview, t("chat.newChat"));
const tooltipTitle = 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 ( return (
<li key={s.key} className="min-w-0"> <li key={s.key} className="min-w-0">
<div <div
className={cn( className={cn(
"group flex min-h-8 min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors", "group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
compact ? "min-h-7" : "min-h-8",
active active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]" ? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground", : "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
@ -84,10 +149,24 @@ export function ChatList({
type="button" type="button"
onClick={() => onSelect(s.key)} onClick={() => onSelect(s.key)}
title={tooltipTitle} 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",
)}
> >
<span className="block w-full truncate font-medium leading-5">{title}</span> <span className="block w-full truncate font-medium leading-5">{title}</span>
{showPreview ? (
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
{preview}
</span>
) : null}
{timestamp ? (
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
{timestamp}
</span>
) : null}
</button> </button>
<SessionActivityIndicator state={activityState} />
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger <DropdownMenuTrigger
className={cn( className={cn(
@ -102,8 +181,35 @@ export function ChatList({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()}
> >
<DropdownMenuItem
onSelect={() => onTogglePin(s.key)}
>
{isPinned ? (
<PinOff className="mr-2 h-4 w-4" />
) : (
<Pin className="mr-2 h-4 w-4" />
)}
{isPinned ? t("chat.unpin") : t("chat.pin")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)}
>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onToggleArchive(s.key)}
>
{isArchived ? (
<ArchiveRestore className="mr-2 h-4 w-4" />
) : (
<Archive className="mr-2 h-4 w-4" />
)}
{isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0); 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 (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-3 w-3 animate-spin rounded-full border border-blue-500/25 border-t-blue-500 [animation-duration:1.4s] motion-reduce:animate-none dark:border-blue-400/25 dark:border-t-blue-400" />
</span>
);
}
if (state === "complete") {
const label = t("chat.activity.complete");
return (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.14)] dark:bg-blue-400 dark:shadow-[0_0_0_3px_rgba(96,165,250,0.18)]" />
</span>
);
}
return <span className="h-4 w-4 shrink-0" aria-hidden="true" />;
}
function groupSessions( function groupSessions(
sessions: ChatSummary[], sessions: ChatSummary[],
labels: { today: string; yesterday: string; earlier: string }, labels: {
pinned: string;
all: string;
today: string;
yesterday: string;
earlier: string;
archived: string;
fallbackTitle: string;
},
options: {
pinnedKeys: string[];
archivedKeys: string[];
titleOverrides: Record<string, string>;
showArchived: boolean;
sort: SidebarSortMode;
},
): Array<{ label: string; sessions: ChatSummary[] }> { ): Array<{ label: string; sessions: ChatSummary[] }> {
const now = new Date(); const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>(); const buckets = new Map<string, ChatSummary[]>();
const pinned = new Set(options.pinnedKeys);
const archived = new Set(options.archivedKeys);
const pinnedSessions: ChatSummary[] = [];
const archivedSessions: ChatSummary[] = [];
const normalSessions: ChatSummary[] = [];
for (const session of sessions) { for (const session of sessions) {
if (archived.has(session.key)) {
if (options.showArchived) archivedSessions.push(session);
continue;
}
if (pinned.has(session.key)) {
pinnedSessions.push(session);
continue;
}
if (options.sort === "title_asc") {
normalSessions.push(session);
continue;
}
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? ""); const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today ? labels.today
@ -148,7 +323,101 @@ function groupSessions(
buckets.set(label, bucket); buckets.set(label, bucket);
} }
return [labels.today, labels.yesterday, labels.earlier] const groups = [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({ label, sessions: buckets.get(label) ?? [] })) .map((label) => ({
label,
sessions: sortSessions(
buckets.get(label) ?? [],
options.sort,
options.titleOverrides,
),
}))
.filter((group) => group.sessions.length > 0); .filter((group) => group.sessions.length > 0);
if (options.sort === "title_asc" && normalSessions.length) {
groups.push({
label: labels.all,
sessions: sortSessions(
normalSessions,
options.sort,
options.titleOverrides,
),
});
}
if (pinnedSessions.length) {
groups.unshift({
label: labels.pinned,
sessions: sortSessions(
pinnedSessions,
options.sort,
options.titleOverrides,
),
});
}
if (archivedSessions.length) {
groups.push({
label: labels.archived,
sessions: sortSessions(
archivedSessions,
options.sort,
options.titleOverrides,
),
});
}
return groups;
}
function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
): ChatSummary[] {
const copy = [...sessions];
copy.sort((a, b) => {
if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides),
"en",
{ numeric: true, sensitivity: "base" },
);
if (titleOrder !== 0) return titleOrder;
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
}
const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt");
const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt");
return bTime - aTime;
});
return copy;
}
function titleForSort(
session: ChatSummary,
titleOverrides: Record<string, string>,
): string {
return (
titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, "new chat")
).toLocaleLowerCase("en");
}
function displayTitle(
session: ChatSummary,
titleOverrides: Record<string, string>,
fallbackTitle: string,
): string {
return (
titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, fallbackTitle)
);
}
function sessionTime(
session: ChatSummary,
field: "createdAt" | "updatedAt",
): number {
const primary = Date.parse(session[field] ?? "");
if (Number.isFinite(primary)) return primary;
const fallback = Date.parse(session.updatedAt ?? session.createdAt ?? "");
return Number.isFinite(fallback) ? fallback : 0;
} }

View File

@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
interface RenameChatDialogProps {
open: boolean;
title: string;
onCancel: () => void;
onConfirm: (title: string) => void;
}
export function RenameChatDialog({
open,
title,
onCancel,
onConfirm,
}: RenameChatDialogProps) {
const { t } = useTranslation();
const [value, setValue] = useState(title);
useEffect(() => {
if (open) setValue(title);
}, [open, title]);
const trimmed = value.trim();
return (
<Dialog open={open} onOpenChange={(next) => {
if (!next) onCancel();
}}>
<DialogContent className="max-w-sm rounded-[22px] border-border/70 bg-popover p-5 shadow-2xl">
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
if (!trimmed) return;
onConfirm(trimmed);
}}
>
<DialogHeader className="text-left">
<DialogTitle>{t("chat.renameTitle")}</DialogTitle>
<DialogDescription>
{t("chat.renameDescription")}
</DialogDescription>
</DialogHeader>
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={t("chat.renamePlaceholder")}
autoFocus
maxLength={160}
/>
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" onClick={onCancel}>
{t("deleteConfirm.cancel")}
</Button>
<Button type="submit" disabled={!trimmed}>
{t("chat.renameSave")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,213 @@
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { deriveTitle } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
interface SessionSearchDialogProps {
open: boolean;
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
titleOverrides?: Record<string, string>;
onOpenChange: (open: boolean) => void;
onSelect: (key: string) => void;
}
export function SessionSearchDialog({
open,
sessions,
activeKey,
loading,
titleOverrides = {},
onOpenChange,
onSelect,
}: SessionSearchDialogProps) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const normalizedQuery = query.trim().toLowerCase();
const results = useMemo(() => {
if (!normalizedQuery) return sessions;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return sessions.filter((session) =>
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
);
}, [normalizedQuery, sessions, titleOverrides]);
useEffect(() => {
if (!open) return;
setQuery("");
setHighlightedIndex(0);
window.setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
setHighlightedIndex(0);
}, [normalizedQuery]);
useEffect(() => {
setHighlightedIndex((index) =>
results.length === 0 ? 0 : Math.min(index, results.length - 1),
);
}, [results.length]);
const handleSelect = (key: string) => {
onOpenChange(false);
onSelect(key);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((index) =>
results.length === 0 ? 0 : Math.min(index + 1, results.length - 1),
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((index) => Math.max(index - 1, 0));
return;
}
if (event.key === "Enter") {
const highlighted = results[highlightedIndex];
if (!highlighted) return;
event.preventDefault();
handleSelect(highlighted.key);
}
};
const emptyLabel = normalizedQuery
? t("sidebar.noSearchResults")
: t("chat.noSessions");
const sectionLabel = normalizedQuery
? t("sidebar.searchResults")
: t("sidebar.recent");
if (!open) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
"max-h-[min(34rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] gap-0 overflow-hidden p-0",
"rounded-2xl border border-border/70 bg-popover/95 text-popover-foreground shadow-2xl backdrop-blur-xl",
"sm:rounded-2xl",
)}
>
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
<DialogDescription className="sr-only">
{t("sidebar.searchPlaceholder")}
</DialogDescription>
<div className="flex h-14 items-center gap-3 border-b border-border/60 px-5">
<Search
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
/>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
{sectionLabel}
</div>
{loading && sessions.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{t("chat.loading")}
</div>
) : results.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{emptyLabel}
</div>
) : (
<ul className="space-y-1">
{results.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, t("chat.newChat"));
const preview = session.preview.trim();
const showPreview =
preview.length > 0 &&
preview.toLowerCase() !== title.trim().toLowerCase();
const highlighted = index === highlightedIndex;
const active = session.key === activeKey;
return (
<li key={session.key}>
<button
type="button"
onClick={() => handleSelect(session.key)}
onMouseEnter={() => setHighlightedIndex(index)}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
highlighted
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium leading-5">
{title}
</span>
{showPreview ? (
<span
className={cn(
"block truncate text-[12px] leading-4",
highlighted
? "text-accent-foreground/70"
: "text-muted-foreground",
)}
>
{preview}
</span>
) : null}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</DialogContent>
</Dialog>
);
}
function sessionMatchesTerms(
session: ChatSummary,
terms: string[],
titleOverride?: string,
) {
const haystack = [
titleOverride,
session.title,
session.preview,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return terms.every((term) => haystack.includes(term));
}

View File

@ -1,5 +1,7 @@
import { useMemo, useState } from "react"; import { useState } from "react";
import { import {
Archive,
ListFilter,
Menu, Menu,
Search, Search,
Settings, Settings,
@ -10,9 +12,22 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList"; import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge"; import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils"; import type {
import type { ChatSummary } from "@/lib/types"; ChatSummary,
SidebarSortMode,
SidebarViewState,
} from "@/lib/types";
interface SidebarProps { interface SidebarProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
@ -21,34 +36,33 @@ interface SidebarProps {
onNewChat: () => void; onNewChat: () => void;
onSelect: (key: string) => void; onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onOpenSettings: () => void; onOpenSettings: () => void;
onOpenSearch: () => void;
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void; onCollapse: () => void;
containActionMenus?: boolean;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
runningChatIds?: string[];
completedChatIds?: string[];
viewState?: SidebarViewState;
showArchived?: boolean;
archivedCount?: number;
} }
export function Sidebar(props: SidebarProps) { export function Sidebar(props: SidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [query, setQuery] = useState(""); const [menuPortalContainer, setMenuPortalContainer] =
const normalizedQuery = query.trim().toLowerCase(); useState<HTMLElement | null>(null);
const filteredSessions = useMemo(() => {
if (!normalizedQuery) return props.sessions;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return props.sessions.filter((session) => {
const haystack = [
session.title,
session.preview,
session.chatId,
session.channel,
session.key,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return terms.every((term) => haystack.includes(term));
});
}, [normalizedQuery, props.sessions]);
return ( return (
<nav <nav
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
aria-label={t("sidebar.navigation")} aria-label={t("sidebar.navigation")}
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground" className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
> >
@ -74,27 +88,6 @@ export function Sidebar(props: SidebarProps) {
</div> </div>
<div className="space-y-1.5 px-2 pb-2"> <div className="space-y-1.5 px-2 pb-2">
<label className="relative block">
<span className="sr-only">{t("sidebar.searchAria")}</span>
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70"
aria-hidden
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className={cn(
"h-8 w-full rounded-full border border-transparent bg-sidebar-accent/45",
"pl-8 pr-3 text-[12.5px] text-sidebar-foreground outline-none",
"placeholder:text-muted-foreground/75",
"transition-colors hover:bg-sidebar-accent/65",
"focus:border-sidebar-border/80 focus:bg-sidebar-accent/70",
"focus:ring-1 focus:ring-sidebar-border/70",
)}
/>
</label>
<Button <Button
onClick={props.onNewChat} onClick={props.onNewChat}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground" className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
@ -103,17 +96,55 @@ export function Sidebar(props: SidebarProps) {
<SquarePen className="h-3.5 w-3.5" /> <SquarePen className="h-3.5 w-3.5" />
{t("sidebar.newChat")} {t("sidebar.newChat")}
</Button> </Button>
<Button
type="button"
onClick={props.onOpenSearch}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<Search className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.searchAria")}
</Button>
<SidebarViewMenu
view={props.viewState}
onUpdateView={props.onUpdateView}
/>
{props.archivedCount ? (
<Button
type="button"
onClick={props.onToggleArchived}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<Archive className="h-3.5 w-3.5" aria-hidden />
{props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
</Button>
) : null}
</div> </div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatList <ChatList
sessions={filteredSessions} sessions={props.sessions}
activeKey={props.activeKey} activeKey={props.activeKey}
loading={props.loading} loading={props.loading}
emptyLabel={ emptyLabel={t("chat.noSessions")}
normalizedQuery ? t("sidebar.noSearchResults") : t("chat.noSessions")
}
onSelect={props.onSelect} onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete} onRequestDelete={props.onRequestDelete}
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides}
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
density={props.viewState?.density}
showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort}
showArchived={props.showArchived}
actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined
}
/> />
</div> </div>
<Separator className="bg-sidebar-border/50" /> <Separator className="bg-sidebar-border/50" />
@ -132,3 +163,83 @@ export function Sidebar(props: SidebarProps) {
</nav> </nav>
); );
} }
function SidebarViewMenu({
view,
onUpdateView,
}: {
view?: SidebarViewState;
onUpdateView: (view: Partial<SidebarViewState>) => void;
}) {
const { t } = useTranslation();
const sort = view?.sort ?? "updated_desc";
const setSort = (value: string) => {
if (isSidebarSortMode(value)) onUpdateView({ sort: value });
};
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<ListFilter className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.viewOptions")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.viewOptions")}
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={view?.density === "compact"}
onCheckedChange={(checked) =>
onUpdateView({ density: checked ? "compact" : "comfortable" })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.compactList")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_previews)}
onCheckedChange={(checked) =>
onUpdateView({ show_previews: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showPreviews")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_timestamps)}
onCheckedChange={(checked) =>
onUpdateView({ show_timestamps: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showTimestamps")}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.sortLabel")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="updated_desc">
{t("sidebar.sortUpdated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_desc">
{t("sidebar.sortCreated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title_asc">
{t("sidebar.sortTitle")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
function isSidebarSortMode(value: string): value is SidebarSortMode {
return value === "updated_desc" || value === "created_desc" || value === "title_asc";
}

File diff suppressed because it is too large Load Diff

View File

@ -24,26 +24,35 @@ const DialogOverlay = React.forwardRef<
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
showCloseButton?: boolean;
}
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> DialogContentProps
>(({ className, children, ...props }, ref) => ( >(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
ref={ref} <DialogPrimitive.Content
className={cn( ref={ref}
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg", className={cn(
className, "grid w-full max-w-lg origin-center gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
)} className,
{...props} )}
> {...props}
{children} >
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"> {children}
<X className="h-4 w-4" /> {showCloseButton ? (
<span className="sr-only">Close</span> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
</DialogPrimitive.Close> <X className="h-4 w-4" />
</DialogPrimitive.Content> <span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
</div>
</DialogPortal> </DialogPortal>
)); ));
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;

View File

@ -47,11 +47,16 @@ const DropdownMenuSubContent = React.forwardRef<
)); ));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
interface DropdownMenuContentProps
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
portalContainer?: HTMLElement | null;
}
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> DropdownMenuContentProps
>(({ className, sideOffset = 4, ...props }, ref) => ( >(({ className, sideOffset = 4, portalContainer, ...props }, ref) => (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal container={portalContainer ?? undefined}>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}

View File

@ -0,0 +1,206 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import {
fetchSidebarState,
updateSidebarState as persistSidebarState,
} from "@/lib/api";
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
schema_version: 1,
pinned_keys: [],
archived_keys: [],
title_overrides: {},
tags_by_key: {},
collapsed_groups: {},
view: {
density: "comfortable",
show_previews: false,
show_timestamps: false,
show_archived: false,
sort: "updated_desc",
},
updated_at: null,
};
function uniqueStrings(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const out: string[] = [];
const seen = new Set<string>();
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<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, string> = {};
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<string, string[]> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, string[]> = {};
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<string, boolean> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, boolean> = {};
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<SidebarStatePayload>;
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 = <T,>(map: Record<string, T>): Record<string, T> => {
const out: Record<string, T> = {};
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<void>;
} {
const { token } = useClient();
const tokenRef = useRef(token);
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const persistVersionRef = useRef(0);
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true);
tokenRef.current = token;
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 };
}

View File

@ -45,8 +45,16 @@
"toggleTheme": "Toggle theme", "toggleTheme": "Toggle theme",
"home": "Home", "home": "Home",
"newChat": "New chat", "newChat": "New chat",
"searchAria": "Search chats", "searchAria": "Search",
"searchPlaceholder": "Search chats", "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", "searchResults": "Results",
"noSearchResults": "No matching chats.", "noSearchResults": "No matching chats.",
"recent": "Recent", "recent": "Recent",
@ -65,12 +73,31 @@
}, },
"nav": { "nav": {
"general": "General", "general": "General",
"byok": "BYOK" "byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
}, },
"sections": { "sections": {
"interface": "Interface", "interface": "Interface",
"ai": "AI", "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": { "rows": {
"theme": "Theme", "theme": "Theme",
@ -78,31 +105,104 @@
"provider": "Provider", "provider": "Provider",
"model": "Model", "model": "Model",
"restart": "Restart nanobot", "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": { "help": {
"theme": "Switch between light and dark appearance.", "theme": "Switch between light and dark appearance.",
"language": "Choose the language used by the WebUI.", "language": "Choose the language used by the WebUI.",
"provider": "Select the provider that should serve new model requests.", "provider": "Select the provider that should serve new model requests.",
"model": "Set the default model name used by nanobot.", "model": "Set the default model name used by nanobot.",
"configPath": "The gateway configuration file currently in use." "configPath": "The gateway configuration file currently in use.",
"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": { "values": {
"light": "Light", "light": "Light",
"dark": "Dark", "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": { "status": {
"loading": "Loading settings...", "loading": "Loading settings...",
"loadError": "Could not load settings", "loadError": "Could not load settings",
"unsaved": "Unsaved changes.", "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": { "actions": {
"save": "Save", "save": "Save",
"saving": "Saving", "saving": "Saving",
"edit": "Edit", "edit": "Edit",
"cancel": "Cancel" "cancel": "Cancel",
"openDocs": "Open docs"
}, },
"byok": { "byok": {
"description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.", "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.", "missingCredential": "Add the required credential before saving.",
"saveHint": "Changes apply to new web search requests." "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": { "chat": {
@ -152,12 +272,30 @@
"loading": "Loading…", "loading": "Loading…",
"noSessions": "No sessions yet.", "noSessions": "No sessions yet.",
"actions": "Chat actions for {{title}}", "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", "delete": "Delete",
"newChat": "New chat", "newChat": "New chat",
"groups": { "groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today", "today": "Today",
"yesterday": "Yesterday", "yesterday": "Yesterday",
"earlier": "Earlier" "earlier": "Earlier",
"archived": "Archived"
} }
}, },
"deleteConfirm": { "deleteConfirm": {

View File

@ -30,13 +30,25 @@
"collapse": "Contraer barra lateral", "collapse": "Contraer barra lateral",
"toggleTheme": "Cambiar tema", "toggleTheme": "Cambiar tema",
"newChat": "Nuevo chat", "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", "recent": "Recientes",
"refreshSessions": "Actualizar sesiones", "refreshSessions": "Actualizar sesiones",
"settings": "Configuración", "settings": "Configuración",
"language": { "language": {
"label": "Idioma", "label": "Idioma",
"ariaLabel": "Cambiar idioma" "ariaLabel": "Cambiar idioma"
} },
"searchAria": "Buscar",
"searchPlaceholder": "Buscar",
"searchResults": "Resultados",
"noSearchResults": "No hay chats coincidentes."
}, },
"settings": { "settings": {
"backToChat": "Volver al chat", "backToChat": "Volver al chat",
@ -46,12 +58,28 @@
}, },
"nav": { "nav": {
"general": "General", "general": "General",
"byok": "BYOK" "byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
}, },
"sections": { "sections": {
"interface": "Interfaz", "interface": "Interfaz",
"ai": "IA", "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": { "rows": {
"theme": "Tema", "theme": "Tema",
@ -59,19 +87,70 @@
"provider": "Proveedor", "provider": "Proveedor",
"model": "Modelo", "model": "Modelo",
"restart": "Reiniciar nanobot", "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": { "help": {
"theme": "Cambia entre apariencia clara y oscura.", "theme": "Cambia entre apariencia clara y oscura.",
"language": "Elige el idioma usado por la WebUI.", "language": "Elige el idioma usado por la WebUI.",
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.", "provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
"model": "Define el nombre del modelo predeterminado que usa nanobot.", "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": { "values": {
"light": "Claro", "light": "Claro",
"dark": "Oscuro", "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": { "status": {
"loading": "Cargando configuración...", "loading": "Cargando configuración...",
@ -83,7 +162,8 @@
"save": "Guardar", "save": "Guardar",
"saving": "Guardando", "saving": "Guardando",
"edit": "Editar", "edit": "Editar",
"cancel": "Cancelar" "cancel": "Cancelar",
"openDocs": "Open docs"
}, },
"byok": { "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.", "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.", "missingCredential": "Añade la credencial requerida antes de guardar.",
"saveHint": "Los cambios se aplican a nuevas solicitudes de web search." "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": { "chat": {
@ -133,8 +225,31 @@
"loading": "Cargando…", "loading": "Cargando…",
"noSessions": "Todavía no hay sesiones.", "noSessions": "Todavía no hay sesiones.",
"actions": "Acciones del chat {{title}}", "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", "delete": "Eliminar",
"newChat": "Nuevo chat" "newChat": "Nuevo chat",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "¿Eliminar este chat?", "title": "¿Eliminar este chat?",

View File

@ -30,13 +30,25 @@
"collapse": "Réduire la barre latérale", "collapse": "Réduire la barre latérale",
"toggleTheme": "Changer de thème", "toggleTheme": "Changer de thème",
"newChat": "Nouvelle discussion", "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", "recent": "Récentes",
"refreshSessions": "Actualiser les sessions", "refreshSessions": "Actualiser les sessions",
"settings": "Paramètres", "settings": "Paramètres",
"language": { "language": {
"label": "Langue", "label": "Langue",
"ariaLabel": "Changer de langue" "ariaLabel": "Changer de langue"
} },
"searchAria": "Rechercher",
"searchPlaceholder": "Rechercher",
"searchResults": "Résultats",
"noSearchResults": "Aucun chat correspondant."
}, },
"settings": { "settings": {
"backToChat": "Retour à la discussion", "backToChat": "Retour à la discussion",
@ -46,12 +58,28 @@
}, },
"nav": { "nav": {
"general": "Général", "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": { "sections": {
"interface": "Interface", "interface": "Interface",
"ai": "IA", "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": { "rows": {
"theme": "Thème", "theme": "Thème",
@ -59,19 +87,70 @@
"provider": "Fournisseur", "provider": "Fournisseur",
"model": "Modèle", "model": "Modèle",
"restart": "Redémarrer nanobot", "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": { "help": {
"theme": "Basculer entre les apparences claire et sombre.", "theme": "Basculer entre les apparences claire et sombre.",
"language": "Choisissez la langue utilisée par le WebUI.", "language": "Choisissez la langue utilisée par le WebUI.",
"provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.", "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.", "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": { "values": {
"light": "Clair", "light": "Clair",
"dark": "Sombre", "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": { "status": {
"loading": "Chargement des paramètres...", "loading": "Chargement des paramètres...",
@ -83,7 +162,8 @@
"save": "Enregistrer", "save": "Enregistrer",
"saving": "Enregistrement", "saving": "Enregistrement",
"edit": "Modifier", "edit": "Modifier",
"cancel": "Annuler" "cancel": "Annuler",
"openDocs": "Open docs"
}, },
"byok": { "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.", "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.", "missingCredential": "Ajoutez l'identifiant requis avant d'enregistrer.",
"saveHint": "Les changements s'appliquent aux nouvelles requêtes web search." "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": { "chat": {
@ -133,8 +225,31 @@
"loading": "Chargement…", "loading": "Chargement…",
"noSessions": "Aucune session pour le moment.", "noSessions": "Aucune session pour le moment.",
"actions": "Actions de la discussion {{title}}", "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", "delete": "Supprimer",
"newChat": "Nouvelle discussion" "newChat": "Nouvelle discussion",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "Supprimer cette discussion ?", "title": "Supprimer cette discussion ?",

View File

@ -30,13 +30,25 @@
"collapse": "Ciutkan sidebar", "collapse": "Ciutkan sidebar",
"toggleTheme": "Ganti tema", "toggleTheme": "Ganti tema",
"newChat": "Obrolan baru", "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", "recent": "Terbaru",
"refreshSessions": "Segarkan sesi", "refreshSessions": "Segarkan sesi",
"settings": "Pengaturan", "settings": "Pengaturan",
"language": { "language": {
"label": "Bahasa", "label": "Bahasa",
"ariaLabel": "Ganti bahasa" "ariaLabel": "Ganti bahasa"
} },
"searchAria": "Cari",
"searchPlaceholder": "Cari",
"searchResults": "Hasil",
"noSearchResults": "Tidak ada chat yang cocok."
}, },
"settings": { "settings": {
"backToChat": "Kembali ke obrolan", "backToChat": "Kembali ke obrolan",
@ -46,12 +58,28 @@
}, },
"nav": { "nav": {
"general": "Umum", "general": "Umum",
"byok": "BYOK" "byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
}, },
"sections": { "sections": {
"interface": "Antarmuka", "interface": "Antarmuka",
"ai": "AI", "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": { "rows": {
"theme": "Tema", "theme": "Tema",
@ -59,19 +87,70 @@
"provider": "Penyedia", "provider": "Penyedia",
"model": "Model", "model": "Model",
"restart": "Mulai ulang nanobot", "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": { "help": {
"theme": "Beralih antara tampilan terang dan gelap.", "theme": "Beralih antara tampilan terang dan gelap.",
"language": "Pilih bahasa yang digunakan WebUI.", "language": "Pilih bahasa yang digunakan WebUI.",
"provider": "Pilih penyedia untuk permintaan model baru.", "provider": "Pilih penyedia untuk permintaan model baru.",
"model": "Atur nama model default yang digunakan nanobot.", "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": { "values": {
"light": "Terang", "light": "Terang",
"dark": "Gelap", "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": { "status": {
"loading": "Memuat pengaturan...", "loading": "Memuat pengaturan...",
@ -83,7 +162,8 @@
"save": "Simpan", "save": "Simpan",
"saving": "Menyimpan", "saving": "Menyimpan",
"edit": "Edit", "edit": "Edit",
"cancel": "Batal" "cancel": "Batal",
"openDocs": "Open docs"
}, },
"byok": { "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.", "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.", "missingCredential": "Tambahkan kredensial yang diperlukan sebelum menyimpan.",
"saveHint": "Perubahan berlaku untuk permintaan web search baru." "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": { "chat": {
@ -133,8 +225,31 @@
"loading": "Memuat…", "loading": "Memuat…",
"noSessions": "Belum ada sesi.", "noSessions": "Belum ada sesi.",
"actions": "Aksi obrolan untuk {{title}}", "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", "delete": "Hapus",
"newChat": "Obrolan baru" "newChat": "Obrolan baru",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "Hapus obrolan ini?", "title": "Hapus obrolan ini?",

View File

@ -30,13 +30,25 @@
"collapse": "サイドバーを閉じる", "collapse": "サイドバーを閉じる",
"toggleTheme": "テーマを切り替える", "toggleTheme": "テーマを切り替える",
"newChat": "新しいチャット", "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": "最近のチャット", "recent": "最近のチャット",
"refreshSessions": "セッションを更新", "refreshSessions": "セッションを更新",
"settings": "設定", "settings": "設定",
"language": { "language": {
"label": "言語", "label": "言語",
"ariaLabel": "言語を変更" "ariaLabel": "言語を変更"
} },
"searchAria": "検索",
"searchPlaceholder": "検索",
"searchResults": "検索結果",
"noSearchResults": "一致するチャットはありません。"
}, },
"settings": { "settings": {
"backToChat": "チャットに戻る", "backToChat": "チャットに戻る",
@ -46,12 +58,28 @@
}, },
"nav": { "nav": {
"general": "一般", "general": "一般",
"byok": "BYOK" "byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
}, },
"sections": { "sections": {
"interface": "インターフェース", "interface": "インターフェース",
"ai": "AI", "ai": "AI",
"system": "システム" "system": "システム",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
}, },
"rows": { "rows": {
"theme": "テーマ", "theme": "テーマ",
@ -59,19 +87,70 @@
"provider": "プロバイダー", "provider": "プロバイダー",
"model": "モデル", "model": "モデル",
"restart": "nanobot を再起動", "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": { "help": {
"theme": "ライト表示とダーク表示を切り替えます。", "theme": "ライト表示とダーク表示を切り替えます。",
"language": "WebUI で使用する言語を選択します。", "language": "WebUI で使用する言語を選択します。",
"provider": "新しいモデルリクエストに使うプロバイダーを選択します。", "provider": "新しいモデルリクエストに使うプロバイダーを選択します。",
"model": "nanobot が既定で使用するモデル名を設定します。", "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": { "values": {
"light": "ライト", "light": "ライト",
"dark": "ダーク", "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": { "status": {
"loading": "設定を読み込んでいます...", "loading": "設定を読み込んでいます...",
@ -83,7 +162,8 @@
"save": "保存", "save": "保存",
"saving": "保存中", "saving": "保存中",
"edit": "編集", "edit": "編集",
"cancel": "キャンセル" "cancel": "キャンセル",
"openDocs": "Open docs"
}, },
"byok": { "byok": {
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。", "description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
@ -126,6 +206,18 @@
"missingCredential": "保存する前に必要な認証情報を入力してください。", "missingCredential": "保存する前に必要な認証情報を入力してください。",
"saveHint": "変更は新しい web search リクエストに適用されます。" "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": { "chat": {
@ -133,8 +225,31 @@
"loading": "読み込み中…", "loading": "読み込み中…",
"noSessions": "まだセッションがありません。", "noSessions": "まだセッションがありません。",
"actions": "「{{title}}」のチャット操作", "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": "削除", "delete": "削除",
"newChat": "新しいチャット" "newChat": "新しいチャット",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "このチャットを削除しますか?", "title": "このチャットを削除しますか?",

View File

@ -30,13 +30,25 @@
"collapse": "사이드바 접기", "collapse": "사이드바 접기",
"toggleTheme": "테마 전환", "toggleTheme": "테마 전환",
"newChat": "새 채팅", "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": "최근 대화", "recent": "최근 대화",
"refreshSessions": "세션 새로고침", "refreshSessions": "세션 새로고침",
"settings": "설정", "settings": "설정",
"language": { "language": {
"label": "언어", "label": "언어",
"ariaLabel": "언어 변경" "ariaLabel": "언어 변경"
} },
"searchAria": "검색",
"searchPlaceholder": "검색",
"searchResults": "결과",
"noSearchResults": "일치하는 채팅이 없습니다."
}, },
"settings": { "settings": {
"backToChat": "채팅으로 돌아가기", "backToChat": "채팅으로 돌아가기",
@ -46,12 +58,28 @@
}, },
"nav": { "nav": {
"general": "일반", "general": "일반",
"byok": "BYOK" "byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
}, },
"sections": { "sections": {
"interface": "인터페이스", "interface": "인터페이스",
"ai": "AI", "ai": "AI",
"system": "시스템" "system": "시스템",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
}, },
"rows": { "rows": {
"theme": "테마", "theme": "테마",
@ -59,19 +87,70 @@
"provider": "제공자", "provider": "제공자",
"model": "모델", "model": "모델",
"restart": "nanobot 재시작", "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": { "help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.", "theme": "밝은 모드와 어두운 모드를 전환합니다.",
"language": "WebUI에서 사용할 언어를 선택합니다.", "language": "WebUI에서 사용할 언어를 선택합니다.",
"provider": "새 모델 요청에 사용할 제공자를 선택합니다.", "provider": "새 모델 요청에 사용할 제공자를 선택합니다.",
"model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.", "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": { "values": {
"light": "라이트", "light": "라이트",
"dark": "다크", "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": { "status": {
"loading": "설정을 불러오는 중...", "loading": "설정을 불러오는 중...",
@ -83,7 +162,8 @@
"save": "저장", "save": "저장",
"saving": "저장 중", "saving": "저장 중",
"edit": "편집", "edit": "편집",
"cancel": "취소" "cancel": "취소",
"openDocs": "Open docs"
}, },
"byok": { "byok": {
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.", "description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
@ -126,6 +206,18 @@
"missingCredential": "저장하기 전에 필요한 자격 증명을 입력하세요.", "missingCredential": "저장하기 전에 필요한 자격 증명을 입력하세요.",
"saveHint": "변경 사항은 새 web search 요청에 적용됩니다." "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": { "chat": {
@ -133,8 +225,31 @@
"loading": "불러오는 중…", "loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.", "noSessions": "아직 세션이 없습니다.",
"actions": "{{title}} 채팅 작업", "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": "삭제", "delete": "삭제",
"newChat": "새 채팅" "newChat": "새 채팅",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "이 채팅을 삭제할까요?", "title": "이 채팅을 삭제할까요?",

View File

@ -30,13 +30,25 @@
"collapse": "Thu gọn thanh bên", "collapse": "Thu gọn thanh bên",
"toggleTheme": "Chuyển giao diện", "toggleTheme": "Chuyển giao diện",
"newChat": "Cuộc trò chuyện mới", "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", "recent": "Gần đây",
"refreshSessions": "Làm mới phiên", "refreshSessions": "Làm mới phiên",
"settings": "Cài đặt", "settings": "Cài đặt",
"language": { "language": {
"label": "Ngôn ngữ", "label": "Ngôn ngữ",
"ariaLabel": "Đổi 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": { "settings": {
"backToChat": "Quay lại trò chuyện", "backToChat": "Quay lại trò chuyện",
@ -46,12 +58,28 @@
}, },
"nav": { "nav": {
"general": "Chung", "general": "Chung",
"byok": "BYOK" "byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
}, },
"sections": { "sections": {
"interface": "Giao diện", "interface": "Giao diện",
"ai": "AI", "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": { "rows": {
"theme": "Giao diện", "theme": "Giao diện",
@ -59,19 +87,70 @@
"provider": "Nhà cung cấp", "provider": "Nhà cung cấp",
"model": "Mô hình", "model": "Mô hình",
"restart": "Khởi động lại nanobot", "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": { "help": {
"theme": "Chuyển giữa giao diện sáng và tối.", "theme": "Chuyển giữa giao diện sáng và tối.",
"language": "Chọn ngôn ngữ dùng trong WebUI.", "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.", "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.", "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": { "values": {
"light": "Sáng", "light": "Sáng",
"dark": "Tối", "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": { "status": {
"loading": "Đang tải cài đặt...", "loading": "Đang tải cài đặt...",
@ -83,7 +162,8 @@
"save": "Lưu", "save": "Lưu",
"saving": "Đang lưu", "saving": "Đang lưu",
"edit": "Sửa", "edit": "Sửa",
"cancel": "Hủy" "cancel": "Hủy",
"openDocs": "Open docs"
}, },
"byok": { "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.", "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.", "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." "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": { "chat": {
@ -133,8 +225,31 @@
"loading": "Đang tải…", "loading": "Đang tải…",
"noSessions": "Chưa có phiên nào.", "noSessions": "Chưa có phiên nào.",
"actions": "Tác vụ cho cuộc trò chuyện {{title}}", "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", "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": { "deleteConfirm": {
"title": "Xóa cuộc trò chuyện này?", "title": "Xóa cuộc trò chuyện này?",

View File

@ -33,8 +33,16 @@
"toggleTheme": "切换主题", "toggleTheme": "切换主题",
"home": "首页", "home": "首页",
"newChat": "新建对话", "newChat": "新建对话",
"searchAria": "搜索会话", "searchAria": "搜索",
"searchPlaceholder": "搜索会话", "viewOptions": "视图",
"compactList": "紧凑列表",
"showPreviews": "显示预览",
"showTimestamps": "显示时间",
"sortLabel": "排序",
"sortUpdated": "最近更新",
"sortCreated": "最近创建",
"sortTitle": "标题 A-Z",
"searchPlaceholder": "搜索",
"searchResults": "搜索结果", "searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。", "noSearchResults": "没有匹配的会话。",
"recent": "最近对话", "recent": "最近对话",
@ -53,12 +61,31 @@
}, },
"nav": { "nav": {
"general": "通用", "general": "通用",
"byok": "BYOK" "byok": "BYOK",
"overview": "概览",
"appearance": "外观",
"models": "模型",
"providers": "提供商",
"image": "图片",
"web": "网页",
"runtime": "运行时",
"advanced": "高级"
}, },
"sections": { "sections": {
"interface": "界面", "interface": "界面",
"ai": "AI", "ai": "AI",
"system": "系统" "system": "系统",
"status": "状态",
"localPreferences": "本地偏好",
"presets": "预设",
"imageGeneration": "图片生成",
"imageDefaults": "默认值",
"webSearch": "网页搜索",
"webBehavior": "行为",
"identity": "身份",
"safety": "安全",
"capabilities": "能力",
"integrations": "集成"
}, },
"rows": { "rows": {
"theme": "主题", "theme": "主题",
@ -66,34 +93,107 @@
"provider": "提供商", "provider": "提供商",
"model": "模型", "model": "模型",
"restart": "重启 nanobot", "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": { "help": {
"theme": "在浅色和深色外观之间切换。", "theme": "在浅色和深色外观之间切换。",
"language": "选择 WebUI 使用的语言。", "language": "选择 WebUI 使用的语言。",
"provider": "选择新模型请求使用的服务提供商。", "provider": "选择新模型请求使用的服务商。",
"model": "设置 nanobot 默认使用的模型名称。", "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": { "values": {
"light": "浅色", "light": "浅色",
"dark": "深色", "dark": "深色",
"notAvailable": "不可用" "notAvailable": "不可用",
"enabled": "已启用",
"disabled": "已禁用",
"restartPending": "等待重启",
"ready": "就绪",
"comfortable": "舒适",
"compact": "紧凑",
"auto": "自动",
"expanded": "展开",
"on": "开",
"off": "关",
"configured": "已配置",
"notConfigured": "未配置"
}, },
"status": { "status": {
"loading": "正在加载设置...", "loading": "正在加载设置...",
"loadError": "无法加载设置", "loadError": "无法加载设置",
"unsaved": "有未保存的更改。", "unsaved": "有未保存的更改。",
"savedRestart": "已保存。重启 nanobot 后生效。" "upToDate": "已是最新。",
"savedRestart": "已保存。重启 nanobot 后生效。",
"restartAfterSaving": "保存后,可在合适时重启。",
"savedRestartApply": "已保存,可稍后重启。",
"imageProviderRestart": "图片服务商改动已保存,可稍后重启。"
}, },
"actions": { "actions": {
"save": "保存", "save": "保存",
"saving": "保存中", "saving": "保存中",
"edit": "编辑", "edit": "编辑",
"cancel": "取消" "cancel": "取消",
"openDocs": "打开文档"
}, },
"byok": { "byok": {
"description": "自带 provider key。Nanobot 会从当前 config 读取这些值,只有已配置的 provider 才能在通用设置里选择。", "description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
"configured": "已配置", "configured": "已配置",
"notConfigured": "未配置", "notConfigured": "未配置",
"configuredSection": "已配置", "configuredSection": "已配置",
@ -105,22 +205,22 @@
"apiKeyPlaceholder": "输入 API key", "apiKeyPlaceholder": "输入 API key",
"apiKeyConfiguredPlaceholder": "留空则保留当前 key", "apiKeyConfiguredPlaceholder": "留空则保留当前 key",
"configuredKeyHint": "已配置的 key", "configuredKeyHint": "已配置的 key",
"apiBasePlaceholder": "使用 provider 默认地址", "apiBasePlaceholder": "使用服务商默认地址",
"apiKeyRequired": "需要 API key 才能配置此 provider。", "apiKeyRequired": "需要 API key 才能配置此服务商。",
"showApiKey": "显示 API key", "showApiKey": "显示 API key",
"hideApiKey": "隐藏 API key", "hideApiKey": "隐藏 API key",
"noConfiguredProviders": "没有已配置的 provider", "noConfiguredProviders": "没有已配置的服务商",
"configureFirst": "请先在 BYOK 里配置 provider。", "configureFirst": "请先在 BYOK 里配置服务商。",
"openByok": "打开 BYOK", "openByok": "打开 BYOK",
"tabs": { "tabs": {
"ariaLabel": "BYOK 凭证类型", "ariaLabel": "BYOK 凭证类型",
"llm": "LLM", "llm": "LLM",
"webSearch": "Web Search" "webSearch": "网页搜索"
}, },
"webSearch": { "webSearch": {
"provider": "搜索 provider", "provider": "搜索服务商",
"providerHelp": "选择 web search 工具使用的后端。", "providerHelp": "选择网页搜索工具使用的后端。",
"selectProvider": "选择 provider", "selectProvider": "选择服务商",
"credentials": "凭证", "credentials": "凭证",
"noCredentialRequired": "无需 key", "noCredentialRequired": "无需 key",
"noCredentialHelp": "DuckDuckGo 不需要保存 API key。", "noCredentialHelp": "DuckDuckGo 不需要保存 API key。",
@ -128,11 +228,31 @@
"baseUrl": "Base URL", "baseUrl": "Base URL",
"baseUrlHelp": "SearXNG 需要你自己的实例地址。", "baseUrlHelp": "SearXNG 需要你自己的实例地址。",
"baseUrlPlaceholder": "https://search.example.com", "baseUrlPlaceholder": "https://search.example.com",
"apiKeyRequired": "这个搜索 provider 需要 API key。", "apiKeyRequired": "这个搜索服务商需要 API key。",
"baseUrlRequired": "SearXNG 需要 Base URL。", "baseUrlRequired": "SearXNG 需要 Base URL。",
"missingCredential": "填写所需凭证后才能保存。", "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": { "chat": {
@ -140,12 +260,30 @@
"loading": "加载中…", "loading": "加载中…",
"noSessions": "还没有会话。", "noSessions": "还没有会话。",
"actions": "“{{title}}” 的会话操作", "actions": "“{{title}}” 的会话操作",
"activity": {
"running": "Agent 正在运行",
"complete": "Agent 已完成"
},
"pin": "置顶",
"unpin": "取消置顶",
"rename": "重命名",
"renameTitle": "重命名对话",
"renameDescription": "为这个对话设置一个仅用于 WebUI 侧边栏的名称。",
"renamePlaceholder": "对话名称",
"renameSave": "保存",
"archive": "归档",
"unarchive": "取消归档",
"showArchived": "显示归档",
"hideArchived": "隐藏归档",
"delete": "删除", "delete": "删除",
"newChat": "新建对话", "newChat": "新建对话",
"groups": { "groups": {
"pinned": "置顶",
"all": "对话",
"today": "今天", "today": "今天",
"yesterday": "昨天", "yesterday": "昨天",
"earlier": "更早" "earlier": "更早",
"archived": "已归档"
} }
}, },
"deleteConfirm": { "deleteConfirm": {
@ -282,7 +420,7 @@
}, },
"status": { "status": {
"title": "查看状态", "title": "查看状态",
"description": "显示运行时、provider 和 channel 状态。" "description": "显示运行时、服务商和通道状态。"
}, },
"history": { "history": {
"title": "查看对话历史", "title": "查看对话历史",

View File

@ -30,13 +30,25 @@
"collapse": "收合側邊欄", "collapse": "收合側邊欄",
"toggleTheme": "切換主題", "toggleTheme": "切換主題",
"newChat": "新增對話", "newChat": "新增對話",
"viewOptions": "檢視",
"compactList": "緊湊列表",
"showPreviews": "顯示預覽",
"showTimestamps": "顯示時間",
"sortLabel": "排序",
"sortUpdated": "最近更新",
"sortCreated": "最近建立",
"sortTitle": "標題 A-Z",
"recent": "最近對話", "recent": "最近對話",
"refreshSessions": "重新整理會話", "refreshSessions": "重新整理會話",
"settings": "設定", "settings": "設定",
"language": { "language": {
"label": "語言", "label": "語言",
"ariaLabel": "切換語言" "ariaLabel": "切換語言"
} },
"searchAria": "搜尋",
"searchPlaceholder": "搜尋",
"searchResults": "搜尋結果",
"noSearchResults": "沒有符合的對話。"
}, },
"settings": { "settings": {
"backToChat": "返回對話", "backToChat": "返回對話",
@ -46,12 +58,28 @@
}, },
"nav": { "nav": {
"general": "一般", "general": "一般",
"byok": "BYOK" "byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
}, },
"sections": { "sections": {
"interface": "介面", "interface": "介面",
"ai": "AI", "ai": "AI",
"system": "系統" "system": "系統",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
}, },
"rows": { "rows": {
"theme": "主題", "theme": "主題",
@ -59,19 +87,70 @@
"provider": "提供者", "provider": "提供者",
"model": "模型", "model": "模型",
"restart": "重新啟動 nanobot", "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": { "help": {
"theme": "在淺色與深色外觀之間切換。", "theme": "在淺色與深色外觀之間切換。",
"language": "選擇 WebUI 使用的語言。", "language": "選擇 WebUI 使用的語言。",
"provider": "選擇新模型請求使用的服務提供者。", "provider": "選擇新模型請求使用的服務提供者。",
"model": "設定 nanobot 預設使用的模型名稱。", "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": { "values": {
"light": "淺色", "light": "淺色",
"dark": "深色", "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": { "status": {
"loading": "正在載入設定...", "loading": "正在載入設定...",
@ -83,7 +162,8 @@
"save": "儲存", "save": "儲存",
"saving": "儲存中", "saving": "儲存中",
"edit": "編輯", "edit": "編輯",
"cancel": "取消" "cancel": "取消",
"openDocs": "Open docs"
}, },
"byok": { "byok": {
"description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。", "description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。",
@ -126,6 +206,18 @@
"missingCredential": "填寫必要憑證後才能儲存。", "missingCredential": "填寫必要憑證後才能儲存。",
"saveHint": "變更會套用到新的 web search 請求。" "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": { "chat": {
@ -133,8 +225,31 @@
"loading": "載入中…", "loading": "載入中…",
"noSessions": "目前還沒有會話。", "noSessions": "目前還沒有會話。",
"actions": "「{{title}}」的會話操作", "actions": "「{{title}}」的會話操作",
"activity": {
"running": "Agent 正在執行",
"complete": "Agent 已完成"
},
"pin": "置頂",
"unpin": "取消置頂",
"rename": "重新命名",
"renameTitle": "重新命名對話",
"renameDescription": "為這個對話設定僅用於 WebUI 側邊欄的名稱。",
"renamePlaceholder": "對話名稱",
"renameSave": "儲存",
"archive": "封存",
"unarchive": "取消封存",
"showArchived": "顯示封存",
"hideArchived": "隱藏封存",
"delete": "刪除", "delete": "刪除",
"newChat": "新增對話" "newChat": "新增對話",
"groups": {
"pinned": "置頂",
"all": "對話",
"today": "今天",
"yesterday": "昨天",
"earlier": "更早",
"archived": "已封存"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "刪除這個對話?", "title": "刪除這個對話?",

View File

@ -1,8 +1,10 @@
import type { import type {
ChatSummary, ChatSummary,
ImageGenerationSettingsUpdate,
ProviderSettingsUpdate, ProviderSettingsUpdate,
SettingsPayload, SettingsPayload,
SettingsUpdate, SettingsUpdate,
SidebarStatePayload,
SlashCommand, SlashCommand,
WebSearchSettingsUpdate, WebSearchSettingsUpdate,
WebuiThreadPersistedPayload, WebuiThreadPersistedPayload,
@ -52,6 +54,7 @@ export async function listSessions(
updated_at: string | null; updated_at: string | null;
title?: string; title?: string;
preview?: string; preview?: string;
run_started_at?: number | null;
}; };
const body = await request<{ sessions: Row[] }>( const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`, `${base}/api/sessions`,
@ -64,6 +67,7 @@ export async function listSessions(
updatedAt: s.updated_at, updatedAt: s.updated_at,
title: s.title ?? "", title: s.title ?? "",
preview: s.preview ?? "", 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<SidebarStatePayload> {
return request<SidebarStatePayload>(`${base}/api/webui/sidebar-state`, token);
}
export async function updateSidebarState(
token: string,
state: SidebarStatePayload,
base: string = "",
): Promise<SidebarStatePayload> {
const query = new URLSearchParams();
query.set("state", JSON.stringify(state));
return request<SidebarStatePayload>(
`${base}/api/webui/sidebar-state/update?${query}`,
token,
);
}
export async function updateSettings( export async function updateSettings(
token: string, token: string,
update: SettingsUpdate, update: SettingsUpdate,
base: string = "", base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams(); 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.model !== undefined) query.set("model", update.model);
if (update.provider !== undefined) query.set("provider", update.provider); 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<SettingsPayload>(`${base}/api/settings/update?${query}`, token); return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
} }
@ -160,8 +193,31 @@ export async function updateWebSearchSettings(
query.set("provider", update.provider); query.set("provider", update.provider);
if (update.apiKey !== undefined) query.set("api_key", update.apiKey); if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl); 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<SettingsPayload>( return request<SettingsPayload>(
`${base}/api/settings/web-search/update?${query}`, `${base}/api/settings/web-search/update?${query}`,
token, token,
); );
} }
export async function updateImageGenerationSettings(
token: string,
update: ImageGenerationSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
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<SettingsPayload>(
`${base}/api/settings/image-generation/update?${query}`,
token,
);
}

View File

@ -56,6 +56,7 @@ type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void; type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateScope = "metadata" | "thread" | string; type SessionUpdateScope = "metadata" | "thread" | string;
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void; type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
/** Structured connection-level errors surfaced to the UI. /** Structured connection-level errors surfaced to the UI.
* *
@ -102,6 +103,7 @@ export class NanobotClient {
private statusHandlers = new Set<StatusHandler>(); private statusHandlers = new Set<StatusHandler>();
private runtimeModelHandlers = new Set<RuntimeModelHandler>(); private runtimeModelHandlers = new Set<RuntimeModelHandler>();
private sessionUpdateHandlers = new Set<SessionUpdateHandler>(); private sessionUpdateHandlers = new Set<SessionUpdateHandler>();
private runStatusHandlers = new Set<RunStatusHandler>();
private errorHandlers = new Set<ErrorHandler>(); private errorHandlers = new Set<ErrorHandler>();
// chat_id -> handlers listening on it // chat_id -> handlers listening on it
private chatHandlers = new Map<string, Set<EventHandler>>(); private chatHandlers = new Map<string, Set<EventHandler>>();
@ -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`). */ /** Subscribe to transport-level faults (see :type:`StreamError`). */
onError(handler: ErrorHandler): Unsubscribe { onError(handler: ErrorHandler): Unsubscribe {
this.errorHandlers.add(handler); this.errorHandlers.add(handler);
@ -194,9 +206,12 @@ export class NanobotClient {
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void { private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
if (ev.event !== "goal_status") return; if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") { if (ev.status === "running" && typeof ev.started_at === "number") {
const previous = this.runStartedAtByChatId.get(chatId);
this.runStartedAtByChatId.set(chatId, ev.started_at); 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.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 { private dispatch(chatId: string, ev: InboundEvent): void {
const handlers = this.chatHandlers.get(chatId); const handlers = this.chatHandlers.get(chatId);
if (handlers !== undefined && handlers.size > 0) { if (handlers !== undefined && handlers.size > 0) {

View File

@ -110,6 +110,30 @@ export interface ChatSummary {
updatedAt: string | null; updatedAt: string | null;
title?: string; title?: string;
preview: 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<string, string>;
tags_by_key: Record<string, string[]>;
collapsed_groups: Record<string, boolean>;
view: SidebarViewState;
updated_at?: string | null;
} }
export interface BootstrapResponse { export interface BootstrapResponse {
@ -125,7 +149,28 @@ export interface SettingsPayload {
provider: string; provider: string;
resolved_provider: string | null; resolved_provider: string | null;
has_api_key: boolean; 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<{ providers: Array<{
name: string; name: string;
label: string; label: string;
@ -139,21 +184,82 @@ export interface SettingsPayload {
provider: string; provider: string;
api_key_hint?: string | null; api_key_hint?: string | null;
base_url?: string | null; base_url?: string | null;
max_results: number;
timeout: number;
providers: Array<{ providers: Array<{
name: string; name: string;
label: string; label: string;
credential: "none" | "api_key" | "base_url"; 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: { runtime: {
config_path: string; 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; requires_restart: boolean;
restart_required_sections?: Array<"runtime" | "web" | "image">;
} }
export interface SettingsUpdate { export interface SettingsUpdate {
model?: string; model?: string;
provider?: string; provider?: string;
modelPreset?: string | null;
timezone?: string;
botName?: string;
botIcon?: string;
toolHintMaxLength?: number;
} }
export interface ProviderSettingsUpdate { export interface ProviderSettingsUpdate {
@ -166,6 +272,18 @@ export interface WebSearchSettingsUpdate {
provider: string; provider: string;
apiKey?: string; apiKey?: string;
baseUrl?: 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 { export interface SlashCommand {

View File

@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
deleteSession, deleteSession,
fetchSidebarState,
fetchWebuiThread, fetchWebuiThread,
listSessions, listSessions,
listSlashCommands, listSlashCommands,
updateSidebarState,
updateImageGenerationSettings,
updateProviderSettings, updateProviderSettings,
updateSettings, updateSettings,
updateWebSearchSettings, updateWebSearchSettings,
@ -46,12 +49,17 @@ describe("webui API helpers", () => {
it("serializes settings updates as a narrow query string", async () => { it("serializes settings updates as a narrow query string", async () => {
await updateSettings("tok", { await updateSettings("tok", {
modelPreset: "default",
model: "openrouter/test", model: "openrouter/test",
provider: "openrouter", provider: "openrouter",
timezone: "Asia/Shanghai",
botName: "nanobot",
botIcon: "nb",
toolHintMaxLength: 120,
}); });
expect(fetch).toHaveBeenCalledWith( 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({ expect.objectContaining({
headers: { Authorization: "Bearer tok" }, headers: { Authorization: "Bearer tok" },
}), }),
@ -77,16 +85,81 @@ describe("webui API helpers", () => {
await updateWebSearchSettings("tok", { await updateWebSearchSettings("tok", {
provider: "searxng", provider: "searxng",
baseUrl: "https://search.example.com", baseUrl: "https://search.example.com",
maxResults: 8,
timeout: 45,
useJinaReader: false,
}); });
expect(fetch).toHaveBeenCalledWith( 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({ expect.objectContaining({
headers: { Authorization: "Bearer tok" }, 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 () => { it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({ vi.mocked(fetch).mockResolvedValueOnce({
ok: true, ok: true,
@ -97,6 +170,7 @@ describe("webui API helpers", () => {
created_at: "2026-05-01T10:00:00", created_at: "2026-05-01T10:00:00",
updated_at: "2026-05-01T10:01:00", updated_at: "2026-05-01T10:01:00",
title: "优化 WebUI 标题", title: "优化 WebUI 标题",
run_started_at: 1_700_000_000,
}, },
], ],
}), }),
@ -107,6 +181,7 @@ describe("webui API helpers", () => {
key: "websocket:chat-1", key: "websocket:chat-1",
title: "优化 WebUI 标题", title: "优化 WebUI 标题",
preview: "", preview: "",
runStartedAt: 1_700_000_000,
}, },
]); ]);
}); });

View File

@ -9,6 +9,8 @@ const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn(); const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn(); const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn(); const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
let mockSessions: ChatSummary[] = []; let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => { vi.mock("@/hooks/useSessions", async (importOriginal) => {
@ -67,9 +69,16 @@ vi.mock("@/lib/nanobot-client", () => {
onRuntimeModelUpdate = () => () => {}; onRuntimeModelUpdate = () => () => {};
onError = () => () => {}; onError = () => () => {};
onChat = () => () => {}; onChat = () => () => {};
onSessionUpdate = () => () => {};
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
runStatusHandlers.add(handler);
return () => runStatusHandlers.delete(handler);
};
getRunStartedAt = () => null;
getGoalState = () => undefined;
sendMessage = vi.fn(); sendMessage = vi.fn();
newChat = vi.fn(); newChat = vi.fn();
attach = vi.fn(); attach = attachSpy;
close = vi.fn(); close = vi.fn();
updateUrl = updateUrlSpy; updateUrl = updateUrlSpy;
} }
@ -89,6 +98,9 @@ describe("App layout", () => {
createChatSpy.mockClear(); createChatSpy.mockClear();
deleteChatSpy.mockReset(); deleteChatSpy.mockReset();
toggleThemeSpy.mockReset(); toggleThemeSpy.mockReset();
attachSpy.mockReset();
runStatusHandlers.clear();
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok", token: "tok",
ws_path: "/", ws_path: "/",
@ -175,6 +187,318 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none"); expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000); }, 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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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 () => { it("opens the settings view from the sidebar footer", async () => {
mockSessions = [ mockSessions = [
{ {
@ -199,7 +523,42 @@ describe("App layout", () => {
provider: "auto", provider: "auto",
resolved_provider: "openai", resolved_provider: "openai",
has_api_key: true, 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: [ providers: [
{ {
name: "openai", name: "openai",
@ -269,14 +628,74 @@ describe("App layout", () => {
provider: "brave", provider: "brave",
api_key_hint: "BSAo••••ew20", api_key_hint: "BSAo••••ew20",
base_url: null, base_url: null,
max_results: 5,
timeout: 30,
providers: [ providers: [
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }, { name: "duckduckgo", label: "DuckDuckGo", credential: "none" },
{ name: "brave", label: "Brave Search", credential: "api_key" }, { name: "brave", label: "Brave Search", credential: "api_key" },
{ name: "tavily", label: "Tavily", 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: { runtime: {
config_path: "/tmp/config.json", 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, requires_restart: false,
}), }),
@ -292,21 +711,32 @@ describe("App layout", () => {
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" })); 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(document.title).toBe("Settings · nanobot");
expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument();
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" }); 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", "aria-current",
"page", "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(); expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
expect(screen.getByText("AI")).toBeInTheDocument(); expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); const modelInput = screen.getByDisplayValue("openai/gpt-4o");
fireEvent.click(within(settingsNav).getByRole("button", { name: "BYOK" })); expect(modelInput).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "LLM" })).toHaveAttribute("aria-selected", "true"); fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } });
expect(screen.getByRole("tab", { name: "Web Search" })).toBeInTheDocument(); 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("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument(); expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0); 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.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); 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.getByText("Search provider")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument();
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument(); expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
@ -339,6 +776,10 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" })); fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" }));
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument(); expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.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 () => { it("returns from settings to the blank start page when no session was active", async () => {
@ -373,19 +814,94 @@ describe("App layout", () => {
provider: "openai", provider: "openai",
resolved_provider: "openai", resolved_provider: "openai",
has_api_key: true, 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 }], providers: [{ name: "openai", label: "OpenAI", configured: true }],
web_search: { web_search: {
provider: "duckduckgo", provider: "duckduckgo",
api_key_hint: null, api_key_hint: null,
base_url: null, base_url: null,
max_results: 5,
timeout: 30,
providers: [ providers: [
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }, { name: "duckduckgo", label: "DuckDuckGo", credential: "none" },
{ name: "brave", label: "Brave Search", credential: "api_key" }, { 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: { runtime: {
config_path: "/tmp/config.json", 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, requires_restart: false,
}), }),
@ -403,14 +919,14 @@ describe("App layout", () => {
await waitFor(() => expect(document.title).toBe("nanobot")); await waitFor(() => expect(document.title).toBe("nanobot"));
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" })); 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" })); fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
await waitFor(() => expect(document.title).toBe("nanobot")); await waitFor(() => expect(document.title).toBe("nanobot"));
expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); 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 = [ mockSessions = [
{ {
key: "websocket:chat-alpha", key: "websocket:chat-alpha",
@ -437,20 +953,43 @@ describe("App layout", () => {
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument(); expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).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" }, target: { value: "planning" },
}); });
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument(); expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(sidebar).queryByText("Travel ideas")).not.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" }, target: { value: "road q2" },
}); });
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument(); expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(sidebar).queryByText("Travel ideas")).not.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 () => { it("opens a blank start page without creating an empty chat", async () => {

View File

@ -8,7 +8,16 @@ import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"]; const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"]; 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", () => { describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => { 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(); 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("工作区");
});
}); });

View File

@ -132,6 +132,37 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull(); 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", () => { it("records goal_state per chat_id without an onChat subscriber", () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",