mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 14:28:38 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ddd051161 | ||
|
|
c281e090d0 | ||
|
|
85a452e5c7 | ||
|
|
05d73803e7 | ||
|
|
5d733b1c7c | ||
|
|
71a99b0780 |
+11
-3
@@ -36,6 +36,7 @@ from nanobot.agent.tools.exec_session import ExecSessionManager
|
||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.agent.turn_delivery import (
|
||||
TurnDelivery,
|
||||
@@ -197,6 +198,11 @@ class AgentLoop:
|
||||
def tool_names(self) -> list[str]:
|
||||
return self.tools.tool_names
|
||||
|
||||
@property
|
||||
def last_usage(self) -> Mapping[str, int]:
|
||||
"""Latest aggregate usage exposed through the runtime-control snapshot."""
|
||||
return self._last_usage
|
||||
|
||||
@property
|
||||
def provider(self) -> LLMProvider:
|
||||
"""Provider selected for future turn admissions."""
|
||||
@@ -448,7 +454,6 @@ class AgentLoop:
|
||||
if model_preset:
|
||||
self.set_model_preset(model_preset, publish_update=False)
|
||||
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
||||
self._runtime_vars: dict[str, Any] = {}
|
||||
self._current_iteration: int = 0
|
||||
self.commands = CommandRouter()
|
||||
register_builtin_commands(self.commands)
|
||||
@@ -623,10 +628,13 @@ class AgentLoop:
|
||||
loader = ToolLoader()
|
||||
registered = loader.load(ctx, self.tools)
|
||||
|
||||
# MyTool needs runtime state reference — manual registration
|
||||
# MyTool receives only the explicit runtime-control capability.
|
||||
if self.tools_config.my.enable:
|
||||
self.tools.register(
|
||||
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
|
||||
MyTool(
|
||||
runtime_control=AgentRuntimeControl(self),
|
||||
modify_allowed=self.tools_config.my.allow_set,
|
||||
)
|
||||
)
|
||||
registered.append("my")
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import time
|
||||
import uuid
|
||||
import warnings
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TypedDict
|
||||
@@ -157,6 +158,10 @@ class SubagentManager:
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
|
||||
def runtime_statuses(self) -> Mapping[str, SubagentStatus]:
|
||||
"""Return the observable task statuses used by runtime-control snapshots."""
|
||||
return self._task_statuses
|
||||
|
||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
||||
warnings.warn(
|
||||
|
||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_SKIP_MODULES = frozenset({
|
||||
"base", "schema", "registry", "context", "loader", "config",
|
||||
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
||||
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Explicit runtime state boundary used by :class:`MyTool`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Protocol, TypeAlias, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebToolsConfig
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
|
||||
|
||||
RUNTIME_SNAPSHOT_KEYS = frozenset({
|
||||
"model",
|
||||
"model_preset",
|
||||
"model_presets",
|
||||
"max_iterations",
|
||||
"context_window_tokens",
|
||||
"workspace",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"current_iteration",
|
||||
"_current_iteration",
|
||||
"tool_names",
|
||||
"web_config",
|
||||
"exec_config",
|
||||
"subagents",
|
||||
"_last_usage",
|
||||
})
|
||||
|
||||
RUNTIME_COMMAND_KEYS = frozenset({
|
||||
"model",
|
||||
"model_preset",
|
||||
"max_iterations",
|
||||
"context_window_tokens",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"workspace",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeSnapshot:
|
||||
"""Detached, allowlisted values available to self-inspection."""
|
||||
|
||||
model: str
|
||||
model_preset: str | None
|
||||
model_presets: dict[str, dict[str, object]]
|
||||
max_iterations: int
|
||||
context_window_tokens: int
|
||||
workspace: Path | str
|
||||
provider_retry_mode: str
|
||||
max_tool_result_chars: int
|
||||
current_iteration: int
|
||||
tool_names: list[str]
|
||||
web_config: dict[str, object]
|
||||
exec_config: dict[str, object]
|
||||
subagent_statuses: dict[str, dict[str, object]]
|
||||
last_usage: dict[str, int]
|
||||
scratchpad: dict[str, JsonValue]
|
||||
|
||||
def as_mapping(self) -> Mapping[str, object]:
|
||||
"""Return the fixed public names understood by ``MyTool``."""
|
||||
values: dict[str, object] = {
|
||||
"model": self.model,
|
||||
"model_preset": self.model_preset,
|
||||
"model_presets": self.model_presets,
|
||||
"max_iterations": self.max_iterations,
|
||||
"context_window_tokens": self.context_window_tokens,
|
||||
"workspace": self.workspace,
|
||||
"provider_retry_mode": self.provider_retry_mode,
|
||||
"max_tool_result_chars": self.max_tool_result_chars,
|
||||
"current_iteration": self.current_iteration,
|
||||
"_current_iteration": self.current_iteration,
|
||||
"tool_names": self.tool_names,
|
||||
"web_config": self.web_config,
|
||||
"exec_config": self.exec_config,
|
||||
"subagents": {"_task_statuses": self.subagent_statuses},
|
||||
"_last_usage": self.last_usage,
|
||||
}
|
||||
assert values.keys() == RUNTIME_SNAPSHOT_KEYS
|
||||
return values
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeControl(Protocol):
|
||||
"""The complete runtime capability exposed to ``MyTool``."""
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot: ...
|
||||
|
||||
def set_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
def set_model_preset(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
session_key: str | None,
|
||||
) -> LLMRuntime: ...
|
||||
|
||||
def set_max_iterations(self, value: int) -> None: ...
|
||||
|
||||
def set_context_window_tokens(self, value: int) -> LLMRuntime: ...
|
||||
|
||||
def set_provider_retry_mode(self, value: str) -> None: ...
|
||||
|
||||
def set_max_tool_result_chars(self, value: int) -> None: ...
|
||||
|
||||
def set_workspace_display(self, value: str) -> None: ...
|
||||
|
||||
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None: ...
|
||||
|
||||
|
||||
class _RuntimeControlTarget(Protocol):
|
||||
"""Narrow structural dependency required by ``AgentRuntimeControl``."""
|
||||
|
||||
max_iterations: int
|
||||
provider_retry_mode: str
|
||||
max_tool_result_chars: int
|
||||
web_config: WebToolsConfig
|
||||
exec_config: ExecToolConfig
|
||||
subagents: SubagentManager
|
||||
|
||||
@property
|
||||
def model(self) -> str: ...
|
||||
|
||||
@property
|
||||
def model_preset(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def model_presets(self) -> Mapping[str, ModelPresetConfig]: ...
|
||||
|
||||
@property
|
||||
def context_window_tokens(self) -> int: ...
|
||||
|
||||
@property
|
||||
def workspace(self) -> Path: ...
|
||||
|
||||
@property
|
||||
def current_iteration(self) -> int: ...
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]: ...
|
||||
|
||||
@property
|
||||
def last_usage(self) -> Mapping[str, int]: ...
|
||||
|
||||
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
||||
|
||||
def set_model_preset(self, name: str | None) -> LLMRuntime: ...
|
||||
|
||||
def set_session_model_preset(self, session_key: str, name: str) -> LLMRuntime: ...
|
||||
|
||||
|
||||
class AgentRuntimeControl:
|
||||
"""Allowlisted adapter from agent-loop state to ``RuntimeControl``."""
|
||||
|
||||
def __init__(self, target: _RuntimeControlTarget) -> None:
|
||||
self.__target = target
|
||||
self.__scratchpad: dict[str, JsonValue] = {}
|
||||
self.__workspace_display: str | None = None
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot:
|
||||
target = self.__target
|
||||
return RuntimeSnapshot(
|
||||
model=target.model,
|
||||
model_preset=target.model_preset,
|
||||
model_presets=_snapshot_model_presets(target.model_presets),
|
||||
max_iterations=target.max_iterations,
|
||||
context_window_tokens=target.context_window_tokens,
|
||||
workspace=(
|
||||
self.__workspace_display
|
||||
if self.__workspace_display is not None
|
||||
else target.workspace
|
||||
),
|
||||
provider_retry_mode=target.provider_retry_mode,
|
||||
max_tool_result_chars=target.max_tool_result_chars,
|
||||
current_iteration=target.current_iteration,
|
||||
tool_names=list(target.tool_names),
|
||||
web_config=_snapshot_web_config(target.web_config),
|
||||
exec_config=_snapshot_exec_config(target.exec_config),
|
||||
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
|
||||
last_usage=dict(target.last_usage),
|
||||
scratchpad=_snapshot_json_mapping(self.__scratchpad),
|
||||
)
|
||||
|
||||
def set_model(self, model: str) -> LLMRuntime:
|
||||
return self.__target.set_runtime_model(model)
|
||||
|
||||
def set_model_preset(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
session_key: str | None,
|
||||
) -> LLMRuntime:
|
||||
if session_key is not None:
|
||||
return self.__target.set_session_model_preset(session_key, name)
|
||||
return self.__target.set_model_preset(name)
|
||||
|
||||
def set_max_iterations(self, value: int) -> None:
|
||||
self.__target.max_iterations = value
|
||||
self.__target.subagents.max_iterations = value
|
||||
|
||||
def set_context_window_tokens(self, value: int) -> LLMRuntime:
|
||||
return self.__target.set_runtime_context_window(value)
|
||||
|
||||
def set_provider_retry_mode(self, value: str) -> None:
|
||||
self.__target.provider_retry_mode = value
|
||||
|
||||
def set_max_tool_result_chars(self, value: int) -> None:
|
||||
self.__target.max_tool_result_chars = value
|
||||
|
||||
def set_workspace_display(self, value: str) -> None:
|
||||
"""Preserve MyTool display compatibility without changing path enforcement."""
|
||||
self.__workspace_display = value
|
||||
|
||||
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None:
|
||||
if key not in self.__scratchpad and len(self.__scratchpad) >= max_keys:
|
||||
raise ValueError(f"scratchpad is full (max {max_keys} keys)")
|
||||
self.__scratchpad[key] = value
|
||||
|
||||
|
||||
def _snapshot_model_presets(
|
||||
presets: Mapping[str, ModelPresetConfig],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
return {
|
||||
name: {
|
||||
"label": preset.label,
|
||||
"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,
|
||||
}
|
||||
for name, preset in presets.items()
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_web_config(config: WebToolsConfig) -> dict[str, object]:
|
||||
return {
|
||||
"enable": config.enable,
|
||||
# Proxy URLs may embed credentials. Presence is enough for diagnosis.
|
||||
"proxy": "<configured>" if config.proxy else config.proxy,
|
||||
"user_agent": config.user_agent,
|
||||
"search": {
|
||||
"provider": config.search.provider,
|
||||
"base_url": config.search.base_url,
|
||||
"max_results": config.search.max_results,
|
||||
"timeout": config.search.timeout,
|
||||
},
|
||||
"fetch": {
|
||||
"use_jina_reader": config.fetch.use_jina_reader,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_exec_config(config: ExecToolConfig) -> dict[str, object]:
|
||||
return {
|
||||
"enable": config.enable,
|
||||
"timeout": config.timeout,
|
||||
"path_prepend": config.path_prepend,
|
||||
"path_append": config.path_append,
|
||||
"sandbox": config.sandbox,
|
||||
"sandbox_ro_binds": list(config.sandbox_ro_binds),
|
||||
"sandbox_rw_binds": list(config.sandbox_rw_binds),
|
||||
"allowed_env_keys": list(config.allowed_env_keys),
|
||||
"allow_patterns": list(config.allow_patterns),
|
||||
"deny_patterns": list(config.deny_patterns),
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_subagent_statuses(
|
||||
manager: SubagentManager,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
return {
|
||||
task_id: _snapshot_subagent_status(status)
|
||||
for task_id, status in manager.runtime_statuses().items()
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
|
||||
return {
|
||||
"task_id": status.task_id,
|
||||
"label": status.label,
|
||||
"task_description": status.task_description,
|
||||
"started_at": status.started_at,
|
||||
"phase": status.phase,
|
||||
"iteration": status.iteration,
|
||||
"tool_events": [dict(event) for event in status.tool_events],
|
||||
"usage": dict(status.usage),
|
||||
"stop_reason": status.stop_reason,
|
||||
"error": status.error,
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_json_mapping(values: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return {key: _snapshot_json_value(value) for key, value in values.items()}
|
||||
|
||||
|
||||
def _snapshot_json_value(value: JsonValue) -> JsonValue:
|
||||
if isinstance(value, list):
|
||||
return [_snapshot_json_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _snapshot_json_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
@@ -1,76 +0,0 @@
|
||||
"""RuntimeState protocol: agent loop state exposed to MyTool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebToolsConfig
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
class RuntimeState(Protocol):
|
||||
"""Minimum contract that MyTool requires from its runtime state provider.
|
||||
|
||||
In practice, this is always satisfied by ``AgentLoop``. MyTool also
|
||||
accesses arbitrary attributes dynamically (via ``getattr`` / ``setattr``)
|
||||
for dot-path inspection and modification; those paths are validated at
|
||||
runtime rather than by this protocol.
|
||||
"""
|
||||
|
||||
@property
|
||||
def model(self) -> str: ...
|
||||
|
||||
@property
|
||||
def max_iterations(self) -> int: ...
|
||||
|
||||
@property
|
||||
def current_iteration(self) -> int: ...
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]: ...
|
||||
|
||||
@property
|
||||
def workspace(self) -> Path: ...
|
||||
|
||||
@property
|
||||
def provider_retry_mode(self) -> str: ...
|
||||
|
||||
@property
|
||||
def max_tool_result_chars(self) -> int: ...
|
||||
|
||||
@property
|
||||
def context_window_tokens(self) -> int: ...
|
||||
|
||||
@property
|
||||
def web_config(self) -> WebToolsConfig: ...
|
||||
|
||||
@property
|
||||
def exec_config(self) -> ExecToolConfig: ...
|
||||
|
||||
@property
|
||||
def subagents(self) -> SubagentManager: ...
|
||||
|
||||
@property
|
||||
def _runtime_vars(self) -> dict[str, Any]: ...
|
||||
|
||||
@property
|
||||
def _last_usage(self) -> dict[str, int]: ...
|
||||
|
||||
def _sync_subagent_runtime_limits(self) -> None: ...
|
||||
|
||||
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
||||
|
||||
def set_session_model_preset(
|
||||
self,
|
||||
session_key: str,
|
||||
name: str,
|
||||
) -> LLMRuntime: ...
|
||||
|
||||
@property
|
||||
def model_preset(self) -> str | None: ...
|
||||
+196
-165
@@ -1,8 +1,7 @@
|
||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||
|
||||
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
|
||||
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
|
||||
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
|
||||
# Tool.execute accepts heterogeneous schemas.
|
||||
# pyright: reportIncompatibleMethodOverride=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,7 +13,13 @@ from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool, ToolResult
|
||||
from nanobot.agent.tools.context import current_request_context, current_request_session_key
|
||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||
from nanobot.agent.tools.runtime_control import (
|
||||
RUNTIME_COMMAND_KEYS,
|
||||
RUNTIME_SNAPSHOT_KEYS,
|
||||
JsonValue,
|
||||
RuntimeControl,
|
||||
RuntimeSnapshot,
|
||||
)
|
||||
from nanobot.config_base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -28,25 +33,28 @@ class MyToolConfig(Base):
|
||||
allow_set: bool = False
|
||||
|
||||
|
||||
def _has_real_attr(obj: Any, key: str) -> bool:
|
||||
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
|
||||
if isinstance(obj, dict):
|
||||
return key in obj
|
||||
d = getattr(obj, "__dict__", None)
|
||||
if d is not None and key in d:
|
||||
return True
|
||||
for cls in type(obj).__mro__:
|
||||
if key in cls.__dict__:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
||||
from nanobot.agent.subagent import SubagentStatus
|
||||
|
||||
return isinstance(value, SubagentStatus)
|
||||
|
||||
|
||||
def _is_subagent_status_snapshot(value: object) -> TypeGuard[Mapping[str, object]]:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
return all(
|
||||
field in value
|
||||
for field in ("task_id", "label", "task_description", "started_at", "phase")
|
||||
)
|
||||
|
||||
|
||||
def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
mapping = cast(Mapping[object, object], value)
|
||||
return all(isinstance(key, str) for key in mapping)
|
||||
|
||||
|
||||
class MyTool(Tool):
|
||||
"""Check and set the agent loop's runtime configuration."""
|
||||
|
||||
@@ -79,7 +87,10 @@ class MyTool(Tool):
|
||||
|
||||
READ_ONLY = frozenset({
|
||||
"subagents", # observable but replacing it would break the system
|
||||
"tool_names",
|
||||
"current_iteration",
|
||||
"_current_iteration", # updated by runner only
|
||||
"_last_usage",
|
||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||
"model_presets", # config-derived catalog; changes require config reload
|
||||
@@ -103,13 +114,6 @@ class MyTool(Tool):
|
||||
"private_key", "access_token", "refresh_token", "auth",
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _is_sensitive_field_name(cls, name: str) -> bool:
|
||||
lowered = name.lower()
|
||||
return lowered in cls._SENSITIVE_NAMES or any(
|
||||
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
|
||||
)
|
||||
|
||||
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
||||
@@ -123,15 +127,15 @@ class MyTool(Tool):
|
||||
"context_window_tokens",
|
||||
})
|
||||
|
||||
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
||||
self._runtime_state = runtime_state
|
||||
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
|
||||
self._runtime_control = runtime_control
|
||||
self._modify_allowed = modify_allowed
|
||||
|
||||
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
||||
cls = self.__class__
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
result._runtime_state = self._runtime_state
|
||||
result._runtime_control = self._runtime_control
|
||||
result._modify_allowed = self._modify_allowed
|
||||
return result
|
||||
|
||||
@@ -208,9 +212,12 @@ class MyTool(Tool):
|
||||
# Path resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
||||
def _resolve_path(
|
||||
self,
|
||||
snapshot: RuntimeSnapshot,
|
||||
path: str,
|
||||
) -> tuple[object | None, str | None]:
|
||||
parts = path.split(".")
|
||||
obj: Any = self._runtime_state
|
||||
for part in parts:
|
||||
if part in self._DENIED_ATTRS or part.startswith("__"):
|
||||
return None, f"'{part}' is not accessible"
|
||||
@@ -218,17 +225,13 @@ class MyTool(Tool):
|
||||
return None, f"'{part}' is not accessible"
|
||||
if part.lower() in self._SENSITIVE_NAMES:
|
||||
return None, f"'{part}' is not accessible"
|
||||
try:
|
||||
if isinstance(obj, Mapping):
|
||||
mapping = cast(Mapping[str, Any], obj)
|
||||
if part in mapping:
|
||||
obj = mapping[part]
|
||||
else:
|
||||
obj: object = snapshot.as_mapping()
|
||||
for part in parts:
|
||||
if not _is_string_mapping(obj):
|
||||
return None, f"'{part}' not found"
|
||||
if part not in obj:
|
||||
return None, f"'{part}' not found in mapping"
|
||||
else:
|
||||
obj = getattr(obj, part)
|
||||
except (KeyError, AttributeError) as e:
|
||||
return None, f"'{part}' not found: {e}"
|
||||
obj = obj[part]
|
||||
return obj, None
|
||||
|
||||
@staticmethod
|
||||
@@ -242,20 +245,48 @@ class MyTool(Tool):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
|
||||
elapsed = time.monotonic() - st.started_at
|
||||
tool_summary = ", ".join(
|
||||
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
||||
) or "none"
|
||||
def _format_status(
|
||||
st: "SubagentStatus | Mapping[str, object]",
|
||||
indent: str = " ",
|
||||
) -> str:
|
||||
if isinstance(st, Mapping):
|
||||
started_at = st.get("started_at", time.monotonic())
|
||||
raw_events = st.get("tool_events", [])
|
||||
phase = st.get("phase", "unknown")
|
||||
iteration = st.get("iteration", 0)
|
||||
usage = st.get("usage", {})
|
||||
error = st.get("error")
|
||||
stop_reason = st.get("stop_reason")
|
||||
else:
|
||||
started_at = st.started_at
|
||||
raw_events = st.tool_events
|
||||
phase = st.phase
|
||||
iteration = st.iteration
|
||||
usage = st.usage
|
||||
error = st.error
|
||||
stop_reason = st.stop_reason
|
||||
elapsed = time.monotonic() - (
|
||||
float(started_at) if isinstance(started_at, (int, float)) else time.monotonic()
|
||||
)
|
||||
tool_events = cast(list[object], raw_events) if isinstance(raw_events, list) else []
|
||||
tool_summaries: list[str] = []
|
||||
for raw_event in tool_events[-5:]:
|
||||
if not isinstance(raw_event, Mapping):
|
||||
continue
|
||||
event = cast(Mapping[str, object], raw_event)
|
||||
tool_summaries.append(
|
||||
f"{event.get('name', '?')}({event.get('status', '?')})"
|
||||
)
|
||||
tool_summary = ", ".join(tool_summaries) or "none"
|
||||
lines = [
|
||||
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
|
||||
f"{indent}phase: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
|
||||
f"{indent}tools: {tool_summary}",
|
||||
f"{indent}usage: {st.usage or 'n/a'}",
|
||||
f"{indent}usage: {usage or 'n/a'}",
|
||||
]
|
||||
if st.error:
|
||||
lines.append(f"{indent}error: {st.error}")
|
||||
if st.stop_reason:
|
||||
lines.append(f"{indent}stop_reason: {st.stop_reason}")
|
||||
if error:
|
||||
lines.append(f"{indent}error: {error}")
|
||||
if stop_reason:
|
||||
lines.append(f"{indent}stop_reason: {stop_reason}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
@@ -264,29 +295,38 @@ class MyTool(Tool):
|
||||
header = f"Subagent [{val.task_id}] '{val.label}'"
|
||||
detail = MyTool._format_status(val, " ")
|
||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||
# SubagentManager: delegate to its _task_statuses dict
|
||||
task_statuses = getattr(val, "_task_statuses", None)
|
||||
if isinstance(task_statuses, dict):
|
||||
return MyTool._format_value(task_statuses, key)
|
||||
if _is_subagent_status_snapshot(val):
|
||||
header = f"Subagent [{val['task_id']}] '{val['label']}'"
|
||||
detail = MyTool._format_status(val, " ")
|
||||
return f"{header}\n task: {val['task_description']}\n{detail}"
|
||||
if isinstance(val, Mapping):
|
||||
mapping = cast(Mapping[object, object], val)
|
||||
else:
|
||||
mapping = None
|
||||
if mapping and set(mapping) == {"_task_statuses"}:
|
||||
task_statuses = mapping["_task_statuses"]
|
||||
if isinstance(task_statuses, Mapping):
|
||||
return MyTool._format_value(task_statuses, key)
|
||||
if (
|
||||
mapping
|
||||
and _is_subagent_status(next(iter(mapping.values())))
|
||||
and (
|
||||
_is_subagent_status(next(iter(mapping.values())))
|
||||
or _is_subagent_status_snapshot(next(iter(mapping.values())))
|
||||
)
|
||||
):
|
||||
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
|
||||
prefix = f"{key}: " if key else ""
|
||||
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
|
||||
for tid, st in status_mapping.items():
|
||||
lines = [f"{prefix}{len(mapping)} subagent(s):"]
|
||||
for tid, st in mapping.items():
|
||||
if _is_subagent_status(st):
|
||||
detail = MyTool._format_status(st, " ")
|
||||
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
||||
label = st.label
|
||||
elif _is_subagent_status_snapshot(st):
|
||||
detail = MyTool._format_status(st, " ")
|
||||
label = st.get("label", "?")
|
||||
else:
|
||||
continue
|
||||
lines.append(f" [{tid}] '{label}'\n{detail}")
|
||||
return "\n".join(lines)
|
||||
dynamic_value = cast(Any, val)
|
||||
if hasattr(dynamic_value, "tool_names"):
|
||||
tool_names: Any = getattr(dynamic_value, "tool_names")
|
||||
return f"tools: {len(tool_names)} registered — {tool_names}"
|
||||
# Scalar types — repr is fine
|
||||
if isinstance(val, (str, int, float, bool, type(None))):
|
||||
r = repr(val)
|
||||
@@ -311,32 +351,6 @@ class MyTool(Tool):
|
||||
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
||||
r = repr(sequence)
|
||||
return f"{key}: {r}" if key else r
|
||||
# Complex object — small Pydantic models: show values; others: show field names for navigation
|
||||
value_type = type(cast(object, val))
|
||||
cls_name = value_type.__name__
|
||||
model_fields = cast(object, getattr(value_type, "model_fields", None))
|
||||
if isinstance(model_fields, Mapping) and model_fields:
|
||||
fields = list(cast(Mapping[str, object], model_fields).keys())
|
||||
if len(fields) <= 8:
|
||||
# Small config objects: show field=value pairs
|
||||
pairs: list[str] = []
|
||||
for f in fields:
|
||||
fv = getattr(val, f, "?")
|
||||
if MyTool._is_sensitive_field_name(f):
|
||||
continue
|
||||
if isinstance(fv, (str, int, float, bool, type(None))):
|
||||
pairs.append(f"{f}={fv!r}")
|
||||
else:
|
||||
pairs.append(f"{f}=<{type(fv).__name__}>")
|
||||
preview = ", ".join(pairs)
|
||||
return f"{key}: {preview}" if key else preview
|
||||
else:
|
||||
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
|
||||
fields = [name for name in attributes if not name.startswith("__")]
|
||||
if fields:
|
||||
preview = ", ".join(str(f) for f in fields[:20])
|
||||
suffix = ", ..." if len(fields) > 20 else ""
|
||||
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
|
||||
r = repr(val)
|
||||
return f"{key}: {r}" if key else r
|
||||
|
||||
@@ -366,7 +380,12 @@ class MyTool(Tool):
|
||||
runtime = request_ctx.runtime if request_ctx is not None else None
|
||||
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
||||
return False, None
|
||||
return True, getattr(runtime, key)
|
||||
values: dict[str, object] = {
|
||||
"model": runtime.model,
|
||||
"model_preset": runtime.model_preset,
|
||||
"context_window_tokens": runtime.context_window_tokens,
|
||||
}
|
||||
return True, values[key]
|
||||
|
||||
def _inspect(self, key: str | None) -> str:
|
||||
if not key:
|
||||
@@ -375,62 +394,64 @@ class MyTool(Tool):
|
||||
request_ctx = current_request_context()
|
||||
if request_ctx is None:
|
||||
return ToolResult.error("Error: current request context is unavailable")
|
||||
request_values: dict[str, str | None] = {
|
||||
"channel": request_ctx.channel,
|
||||
"chat_id": request_ctx.chat_id,
|
||||
"sender_id": request_ctx.sender_id,
|
||||
}
|
||||
if key == "request":
|
||||
return self._format_value(
|
||||
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
|
||||
key,
|
||||
)
|
||||
return self._format_value(request_values, key)
|
||||
field = key.removeprefix("request.")
|
||||
if field not in self._REQUEST_FIELDS:
|
||||
return ToolResult.error(f"Error: '{key}' not found")
|
||||
return self._format_value(getattr(request_ctx, field), key)
|
||||
return self._format_value(request_values[field], key)
|
||||
if "." not in key:
|
||||
found, value = self._current_runtime_value(key)
|
||||
if found:
|
||||
return self._format_value(value, key)
|
||||
snapshot = self._runtime_control.snapshot()
|
||||
top = key.split(".")[0]
|
||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
||||
obj, err = self._resolve_path(key)
|
||||
obj, err = self._resolve_path(snapshot, key)
|
||||
if err:
|
||||
# "scratchpad" alias for _runtime_vars
|
||||
if key == "scratchpad":
|
||||
rv = self._runtime_state._runtime_vars
|
||||
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
|
||||
# Fallback: check _runtime_vars for simple keys stored by modify
|
||||
if "." not in key and key in self._runtime_state._runtime_vars:
|
||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||
return (
|
||||
self._format_value(snapshot.scratchpad, "scratchpad")
|
||||
if snapshot.scratchpad
|
||||
else "scratchpad is empty"
|
||||
)
|
||||
if "." not in key and key in snapshot.scratchpad:
|
||||
return self._format_value(snapshot.scratchpad[key], key)
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
# Guard against mock auto-generated attributes
|
||||
if "." not in key and not _has_real_attr(self._runtime_state, key):
|
||||
if key in self._runtime_state._runtime_vars:
|
||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||
return ToolResult.error(f"Error: '{key}' not found")
|
||||
return self._format_value(obj, key)
|
||||
|
||||
def _inspect_all(self) -> str:
|
||||
state = self._runtime_state
|
||||
snapshot = self._runtime_control.snapshot()
|
||||
values = snapshot.as_mapping()
|
||||
parts: list[str] = []
|
||||
# RESTRICTED keys
|
||||
for k in self.RESTRICTED:
|
||||
found, value = self._current_runtime_value(k)
|
||||
parts.append(self._format_value(value if found else getattr(state, k, None), k))
|
||||
parts.append(self._format_value(value if found else values[k], k))
|
||||
found, value = self._current_runtime_value("model_preset")
|
||||
parts.append(self._format_value(
|
||||
value if found else state.model_preset,
|
||||
value if found else snapshot.model_preset,
|
||||
"model_preset",
|
||||
))
|
||||
# Other useful top-level keys shown in description
|
||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
|
||||
if _has_real_attr(state, k):
|
||||
parts.append(self._format_value(getattr(state, k, None), k))
|
||||
# Token usage
|
||||
usage = state._last_usage
|
||||
if usage:
|
||||
parts.append(self._format_value(usage, "_last_usage"))
|
||||
rv = state._runtime_vars
|
||||
if rv:
|
||||
parts.append(self._format_value(rv, "scratchpad"))
|
||||
for k in (
|
||||
"workspace",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"_current_iteration",
|
||||
"web_config",
|
||||
"exec_config",
|
||||
"subagents",
|
||||
):
|
||||
parts.append(self._format_value(values[k], k))
|
||||
if snapshot.last_usage:
|
||||
parts.append(self._format_value(snapshot.last_usage, "_last_usage"))
|
||||
if snapshot.scratchpad:
|
||||
parts.append(self._format_value(snapshot.scratchpad, "scratchpad"))
|
||||
return "\n".join(parts)
|
||||
|
||||
# -- modify --
|
||||
@@ -454,48 +475,49 @@ class MyTool(Tool):
|
||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
||||
parent, err = self._resolve_path(parent_path)
|
||||
snapshot = self._runtime_control.snapshot()
|
||||
_parent, err = self._resolve_path(snapshot, parent_path)
|
||||
if err:
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
if isinstance(parent, dict):
|
||||
parent[leaf] = value
|
||||
else:
|
||||
setattr(parent, leaf, value)
|
||||
self._audit("modify", f"{key} = {value!r}")
|
||||
return f"Set {key} = {value!r}"
|
||||
self._audit("modify", f"READ_ONLY {key}")
|
||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||
if key == "model_preset":
|
||||
return self._modify_model_preset(value)
|
||||
if key in self.RESTRICTED:
|
||||
return self._modify_restricted(key, value)
|
||||
return self._modify_free(key, value)
|
||||
if key in RUNTIME_COMMAND_KEYS:
|
||||
return self._modify_runtime_setting(key, value)
|
||||
if key in RUNTIME_SNAPSHOT_KEYS:
|
||||
self._audit("modify", f"READ_ONLY {key}")
|
||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||
return self._modify_scratchpad(key, value)
|
||||
|
||||
def _modify_model_preset(self, value: Any) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||
name = value.strip()
|
||||
session_key = current_request_session_key()
|
||||
if session_key:
|
||||
old = self._runtime_control.snapshot().model_preset
|
||||
try:
|
||||
runtime = self._runtime_state.set_session_model_preset(
|
||||
session_key,
|
||||
runtime = self._runtime_control.set_model_preset(
|
||||
name,
|
||||
session_key=session_key,
|
||||
)
|
||||
except (KeyError, ValueError) as exc:
|
||||
message = str(exc.args[0]) if exc.args else str(exc)
|
||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||
if session_key:
|
||||
self._audit("modify", f"model_preset = {name!r}")
|
||||
return (
|
||||
f"Set model_preset = {name!r} for the next turn; "
|
||||
f"model will be {runtime.model!r}; "
|
||||
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
||||
)
|
||||
result = self._modify_free("model_preset", name)
|
||||
if isinstance(result, ToolResult) and result.is_error:
|
||||
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
||||
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
|
||||
return (
|
||||
f"{result}; model is now {self._runtime_state.model!r}; "
|
||||
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
||||
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
|
||||
f"context_window_tokens is now {runtime.context_window_tokens!r}"
|
||||
)
|
||||
|
||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||
@@ -508,7 +530,7 @@ class MyTool(Tool):
|
||||
value = expected(value)
|
||||
except (ValueError, TypeError):
|
||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
||||
old = getattr(self._runtime_state, key)
|
||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
||||
if "min" in spec and value < spec["min"]:
|
||||
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
||||
if "max" in spec and value > spec["max"]:
|
||||
@@ -521,41 +543,46 @@ class MyTool(Tool):
|
||||
"during an active session; use a configured model_preset"
|
||||
)
|
||||
if key == "model":
|
||||
self._runtime_state.set_runtime_model(cast(str, value))
|
||||
self._runtime_control.set_model(cast(str, value))
|
||||
elif key == "context_window_tokens":
|
||||
self._runtime_state.set_runtime_context_window(cast(int, value))
|
||||
self._runtime_control.set_context_window_tokens(cast(int, value))
|
||||
else:
|
||||
setattr(self._runtime_state, key, value)
|
||||
if key == "max_iterations" and hasattr(
|
||||
self._runtime_state,
|
||||
"_sync_subagent_runtime_limits",
|
||||
):
|
||||
self._runtime_state._sync_subagent_runtime_limits()
|
||||
self._runtime_control.set_max_iterations(cast(int, value))
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
|
||||
def _modify_free(self, key: str, value: Any) -> str:
|
||||
if _has_real_attr(self._runtime_state, key):
|
||||
old = getattr(self._runtime_state, key)
|
||||
if isinstance(old, (str, int, float, bool)):
|
||||
old_t: type[Any] = type(old)
|
||||
def _modify_runtime_setting(self, key: str, value: Any) -> str:
|
||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
||||
if key == "workspace":
|
||||
if not isinstance(value, str):
|
||||
return ToolResult.error(
|
||||
f"Error: 'workspace' expects str, got {type(value).__name__}"
|
||||
)
|
||||
self._runtime_control.set_workspace_display(value)
|
||||
self._audit("modify", f"workspace: {old!r} -> {value!r}")
|
||||
return f"Set workspace = {value!r} (was {old!r})"
|
||||
old_t = type(old)
|
||||
new_t = cast(type[Any], type(value))
|
||||
if old_t is float and new_t is int:
|
||||
pass # int → float coercion allowed
|
||||
pass
|
||||
elif old_t is not new_t:
|
||||
self._audit(
|
||||
"modify",
|
||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||
)
|
||||
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
|
||||
try:
|
||||
setattr(self._runtime_state, key, value)
|
||||
except (ValueError, KeyError) as e:
|
||||
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
||||
self._audit("modify", f"REJECTED {key}: {message}")
|
||||
return ToolResult.error(f"Error: {message}")
|
||||
return ToolResult.error(
|
||||
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||
)
|
||||
if key == "provider_retry_mode":
|
||||
self._runtime_control.set_provider_retry_mode(cast(str, value))
|
||||
elif key == "max_tool_result_chars":
|
||||
self._runtime_control.set_max_tool_result_chars(cast(int, value))
|
||||
else:
|
||||
raise AssertionError(f"Unhandled runtime command: {key}")
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
|
||||
def _modify_scratchpad(self, key: str, value: Any) -> str:
|
||||
if callable(value):
|
||||
self._audit("modify", f"REJECTED callable {key}")
|
||||
return ToolResult.error("Error: cannot store callable values")
|
||||
@@ -563,12 +590,16 @@ class MyTool(Tool):
|
||||
if err:
|
||||
self._audit("modify", f"REJECTED {key}: {err}")
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
||||
try:
|
||||
self._runtime_control.set_scratchpad(
|
||||
key,
|
||||
cast(JsonValue, value),
|
||||
max_keys=self._MAX_RUNTIME_KEYS,
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
||||
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
|
||||
old = self._runtime_state._runtime_vars.get(key)
|
||||
self._runtime_state._runtime_vars[key] = value
|
||||
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
||||
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
|
||||
self._audit("modify", f"scratchpad.{key} = {value!r}")
|
||||
return f"Set scratchpad.{key} = {value!r}"
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
||||
|
||||
@@ -33,7 +34,6 @@ export function FeishuAssistantsPanel({
|
||||
|
||||
return (
|
||||
<ChannelInstancesPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
@@ -92,6 +92,7 @@ function FeishuInstanceAction({
|
||||
instance: NanobotChannelInstanceInfo;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { client } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const tx = channelTranslator(t, "feishu");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -114,7 +115,7 @@ function FeishuInstanceAction({
|
||||
setError(null);
|
||||
try {
|
||||
onFeaturesUpdate(
|
||||
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
||||
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
|
||||
);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
||||
@@ -101,8 +101,14 @@ class ChannelManager:
|
||||
webui_runtime_surface: str = "browser",
|
||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
config_path: Path | None = None,
|
||||
):
|
||||
if config_path is None:
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
config_path = get_config_path()
|
||||
self.config = config
|
||||
self._config_path = config_path.expanduser().resolve(strict=False)
|
||||
self.bus = bus
|
||||
self._session_manager = session_manager
|
||||
self._cron_service = cron_service
|
||||
@@ -170,6 +176,7 @@ class ChannelManager:
|
||||
static_dist_path=static_path,
|
||||
workspace_path=workspace,
|
||||
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
|
||||
config_path=self._config_path,
|
||||
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
||||
runtime_model_name=self._webui_runtime_model_name,
|
||||
runtime_surface=self._webui_runtime_surface,
|
||||
|
||||
@@ -373,6 +373,13 @@ class WebSocketChannel(BaseChannel):
|
||||
self._conn_default: dict[ServerConnection, str] = {}
|
||||
# Connections authenticated with a one-time token from /webui/bootstrap.
|
||||
self._webui_connections: set[ServerConnection] = set()
|
||||
# Request/reply mutations aren't replayed across reconnects. Tasks may
|
||||
# finish after a client-side deadline so an already-started mutation
|
||||
# isn't ambiguously cancelled halfway through.
|
||||
self._webui_request_tasks: dict[
|
||||
tuple[ServerConnection, str],
|
||||
asyncio.Task[None],
|
||||
] = {}
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
self._server_task: asyncio.Task[None] | None = None
|
||||
|
||||
@@ -758,6 +765,9 @@ class WebSocketChannel(BaseChannel):
|
||||
) -> None:
|
||||
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
||||
t = envelope.get("type")
|
||||
if t == "webui_request":
|
||||
await self._start_webui_request(connection, envelope)
|
||||
return
|
||||
if t == "new_chat":
|
||||
new_id = str(uuid.uuid4())
|
||||
scope = await self._workspace_scope_or_error(
|
||||
@@ -1105,6 +1115,152 @@ class WebSocketChannel(BaseChannel):
|
||||
return
|
||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||
|
||||
async def _start_webui_request(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
envelope: dict[str, Any],
|
||||
) -> None:
|
||||
request_id = envelope.get("request_id")
|
||||
if not isinstance(request_id, str) or re.fullmatch(
|
||||
r"[A-Za-z0-9._:-]{1,128}",
|
||||
request_id,
|
||||
) is None:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="invalid webui request_id",
|
||||
)
|
||||
return
|
||||
if connection not in self._webui_connections:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=403,
|
||||
message="access_denied",
|
||||
)
|
||||
return
|
||||
|
||||
action = envelope.get("action")
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(action, str) or re.fullmatch(
|
||||
r"[a-z][a-z0-9_.]{0,127}",
|
||||
action,
|
||||
) is None:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=400,
|
||||
message="invalid WebUI mutation action",
|
||||
)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=400,
|
||||
message="WebUI mutation payload must be an object",
|
||||
)
|
||||
return
|
||||
|
||||
key = (connection, request_id)
|
||||
if key in self._webui_request_tasks:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=409,
|
||||
message="duplicate WebUI request_id",
|
||||
)
|
||||
return
|
||||
task = asyncio.create_task(
|
||||
self._complete_webui_request(
|
||||
connection,
|
||||
request_id,
|
||||
action,
|
||||
cast(dict[str, Any], payload),
|
||||
)
|
||||
)
|
||||
self._webui_request_tasks[key] = task
|
||||
|
||||
async def _complete_webui_request(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
request_id: str,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
try:
|
||||
response = await self._http_router.dispatch_webui_mutation(
|
||||
connection,
|
||||
action,
|
||||
payload,
|
||||
)
|
||||
status = response.status_code
|
||||
body = bytes(response.body).decode("utf-8", errors="replace").strip()
|
||||
if 200 <= status < 300:
|
||||
try:
|
||||
result = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=502,
|
||||
message="WebUI mutation returned an invalid response",
|
||||
)
|
||||
return
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
result=result,
|
||||
)
|
||||
return
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=status,
|
||||
message=body or response.reason_phrase,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
self.logger.exception("WebUI mutation '{}' failed", action)
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=500,
|
||||
message="WebUI mutation failed",
|
||||
)
|
||||
finally:
|
||||
self._webui_request_tasks.pop((connection, request_id), None)
|
||||
|
||||
async def _send_webui_response(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
request_id: str,
|
||||
*,
|
||||
result: Any = None,
|
||||
status: int | None = None,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
if status is None:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"webui_response",
|
||||
request_id=request_id,
|
||||
ok=True,
|
||||
result=result,
|
||||
)
|
||||
return
|
||||
await self._send_event(
|
||||
connection,
|
||||
"webui_response",
|
||||
request_id=request_id,
|
||||
ok=False,
|
||||
error={
|
||||
"status": status,
|
||||
"message": message or "WebUI mutation failed",
|
||||
},
|
||||
)
|
||||
|
||||
async def _workspace_scope_or_error(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
@@ -1145,6 +1301,12 @@ class WebSocketChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.warning("server task error during shutdown: {}", e)
|
||||
self._server_task = None
|
||||
mutation_tasks = tuple(self._webui_request_tasks.values())
|
||||
for task in mutation_tasks:
|
||||
task.cancel()
|
||||
if mutation_tasks:
|
||||
await asyncio.gather(*mutation_tasks, return_exceptions=True)
|
||||
self._webui_request_tasks.clear()
|
||||
self._subs.clear()
|
||||
self._conn_chats.clear()
|
||||
self._conn_default.clear()
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import websockets
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.frames import Close
|
||||
|
||||
@@ -42,6 +46,12 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
from nanobot.webui.http_utils import (
|
||||
http_error as _http_error,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
http_json_response as _http_json_response,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
issue_route_secret_matches as _issue_route_secret_matches,
|
||||
)
|
||||
@@ -119,6 +129,38 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
||||
)
|
||||
|
||||
|
||||
async def _webui_mutate(
|
||||
client: Any,
|
||||
action: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> httpx.Response:
|
||||
request_id = f"test-{uuid.uuid4().hex}"
|
||||
await client.send(json.dumps({
|
||||
"type": "webui_request",
|
||||
"request_id": request_id,
|
||||
"action": action,
|
||||
"payload": payload or {},
|
||||
}))
|
||||
while True:
|
||||
envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5))
|
||||
if envelope.get("event") != "webui_response":
|
||||
continue
|
||||
if envelope.get("request_id") != request_id:
|
||||
continue
|
||||
if envelope.get("ok") is True:
|
||||
status = 200
|
||||
body = envelope.get("result")
|
||||
else:
|
||||
error = envelope.get("error") or {}
|
||||
status = int(error.get("status") or 500)
|
||||
body = {"error": str(error.get("message") or "WebUI mutation failed")}
|
||||
return httpx.Response(
|
||||
status,
|
||||
json=body,
|
||||
request=httpx.Request("WS", "http://nanobot.local/webui-mutation"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
||||
channel = _ch(MessageBus())
|
||||
@@ -857,6 +899,98 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
|
||||
assert client_connection not in channel._webui_connections
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_webui_request_returns_correlated_success(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel._webui_connections.add(conn)
|
||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
||||
return_value=_http_json_response({"saved": True})
|
||||
)
|
||||
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": "request-1",
|
||||
"action": "settings.provider.update",
|
||||
"payload": {"provider": "openrouter", "apiKey": "secret"},
|
||||
},
|
||||
)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with(
|
||||
conn,
|
||||
"settings.provider.update",
|
||||
{"provider": "openrouter", "apiKey": "secret"},
|
||||
)
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": "request-1",
|
||||
"ok": True,
|
||||
"result": {"saved": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel._webui_connections.add(conn)
|
||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
||||
return_value=_http_error(400, "invalid settings payload")
|
||||
)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": "request-2",
|
||||
"action": "settings.agent.update",
|
||||
"payload": {},
|
||||
},
|
||||
)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": "request-2",
|
||||
"ok": False,
|
||||
"error": {"status": 400, "message": "invalid settings payload"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_request_requires_bootstrap_authenticated_connection(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"static-token-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": "request-3",
|
||||
"action": "settings.agent.update",
|
||||
"payload": {},
|
||||
},
|
||||
)
|
||||
|
||||
channel.gateway.http.dispatch_webui_mutation.assert_not_awaited()
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": "request-3",
|
||||
"ok": False,
|
||||
"error": {"status": 403, "message": "access_denied"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||
bus: MagicMock,
|
||||
@@ -866,23 +1000,33 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
conn.request = SimpleNamespace(headers=Headers())
|
||||
channel._webui_connections.add(conn)
|
||||
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
||||
request_id = "sidebar-large-state"
|
||||
envelope = {
|
||||
"type": "set_sidebar_state",
|
||||
"state": {
|
||||
"type": "webui_request",
|
||||
"request_id": request_id,
|
||||
"action": "sidebar.update",
|
||||
"payload": {"state": {
|
||||
"session_order": session_order,
|
||||
"view": {"sort": "manual"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
assert len(json.dumps(envelope).encode()) > 8_192
|
||||
|
||||
await channel._dispatch_envelope(conn, "webui-client", envelope)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
|
||||
assert saved["session_order"] == session_order
|
||||
assert saved["view"]["sort"] == "manual"
|
||||
conn.send.assert_not_awaited()
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": request_id,
|
||||
"ok": True,
|
||||
"result": saved,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2887,7 +3031,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
webui_client = None
|
||||
try:
|
||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||
webui_client = await websockets.connect(
|
||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=settings-test"
|
||||
)
|
||||
ready = json.loads(await asyncio.wait_for(webui_client.recv(), timeout=5))
|
||||
assert ready["event"] == "ready"
|
||||
|
||||
settings = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
@@ -2971,11 +3123,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert unknown_api.status_code == 404
|
||||
assert "<!doctype html>" not in unknown_api.text.lower()
|
||||
|
||||
provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
provider_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.update",
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"apiKey": "sk-or-test",
|
||||
"apiBase": "https://openrouter.ai/api/v1",
|
||||
},
|
||||
)
|
||||
assert provider_updated.status_code == 200
|
||||
provider_body = provider_updated.json()
|
||||
@@ -2985,11 +3140,9 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert provider_body["image_generation"]["provider_configured"] is True
|
||||
assert "sk-or-test" not in provider_updated.text
|
||||
|
||||
custom_provider_created = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
||||
headers={
|
||||
"Authorization": "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": json.dumps(
|
||||
custom_provider_created = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.create",
|
||||
{
|
||||
"name": "Company Gateway",
|
||||
"apiBase": "https://gateway.example/v1",
|
||||
@@ -2999,8 +3152,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||
"proxy": "http://127.0.0.1:7890",
|
||||
"thinkingStyle": "enable_thinking",
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert custom_provider_created.status_code == 200
|
||||
@@ -3015,11 +3166,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
}
|
||||
assert "sk-company" not in custom_provider_created.text
|
||||
|
||||
local_provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
local_provider_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.update",
|
||||
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
|
||||
)
|
||||
assert local_provider_updated.status_code == 200
|
||||
local_provider_body = local_provider_updated.json()
|
||||
@@ -3029,38 +3179,44 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert local_provider_rows["atomic_chat"]["configured"] is True
|
||||
assert "localhost:1337" in local_provider_updated.text
|
||||
|
||||
updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model=atomic_chat/test"
|
||||
"&provider=atomic_chat&timezone=Asia%2FShanghai"
|
||||
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.agent.update",
|
||||
{
|
||||
"model": "atomic_chat/test",
|
||||
"provider": "atomic_chat",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"tool_hint_max_length": 120,
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
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"},
|
||||
preset_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.agent.update",
|
||||
{"model_preset": "deep"},
|
||||
)
|
||||
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"},
|
||||
bad_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.agent.update",
|
||||
{"model_preset": "missing"},
|
||||
)
|
||||
assert bad_preset.status_code == 400
|
||||
|
||||
created_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
created_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
"label": "Fast writing",
|
||||
"provider": "openai",
|
||||
"model": "openai/gpt-4.1-mini",
|
||||
},
|
||||
)
|
||||
assert created_preset.status_code == 200
|
||||
created_body = created_preset.json()
|
||||
@@ -3074,11 +3230,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||
|
||||
updated_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/update"
|
||||
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
updated_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_configuration.update",
|
||||
{
|
||||
"name": "fast-writing",
|
||||
"label": "Codex",
|
||||
"provider": "openai",
|
||||
"model": "openai/gpt-5.5",
|
||||
},
|
||||
)
|
||||
assert updated_preset.status_code == 200
|
||||
updated_preset_body = updated_preset.json()
|
||||
@@ -3089,11 +3249,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
}
|
||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||
|
||||
call_order_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-call-order/update"
|
||||
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
call_order_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_call_order.update",
|
||||
{"order": ["fast-writing", "deep"]},
|
||||
)
|
||||
assert call_order_updated.status_code == 200
|
||||
call_order_body = call_order_updated.json()
|
||||
@@ -3101,20 +3260,27 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||
|
||||
duplicate_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
duplicate_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
"label": "Fast writing",
|
||||
"provider": "openai",
|
||||
"model": "openai/gpt-4.1-mini",
|
||||
},
|
||||
)
|
||||
assert duplicate_preset.status_code == 409
|
||||
|
||||
search_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/web-search/update?provider=searxng"
|
||||
"&base_url=https%3A%2F%2Fsearch.example.com"
|
||||
"&max_results=8&timeout=45&use_jina_reader=false",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
search_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.web_search.update",
|
||||
{
|
||||
"provider": "searxng",
|
||||
"base_url": "https://search.example.com",
|
||||
"max_results": 8,
|
||||
"timeout": 45,
|
||||
"use_jina_reader": False,
|
||||
},
|
||||
)
|
||||
assert search_updated.status_code == 200
|
||||
search_body = search_updated.json()
|
||||
@@ -3126,10 +3292,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert search_body["web_search"]["max_results"] == 8
|
||||
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
||||
|
||||
network_safety_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
network_safety_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.network_safety.update",
|
||||
{
|
||||
"webui_allow_local_service_access": False,
|
||||
"webui_default_access_mode": "full",
|
||||
},
|
||||
)
|
||||
assert network_safety_updated.status_code == 200
|
||||
network_safety_body = network_safety_updated.json()
|
||||
@@ -3139,13 +3308,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
|
||||
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
||||
|
||||
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"},
|
||||
image_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{
|
||||
"enabled": True,
|
||||
"provider": "openrouter",
|
||||
"model": "openai/gpt-image-1",
|
||||
"default_aspect_ratio": "16:9",
|
||||
"default_image_size": "2K",
|
||||
"max_images_per_turn": 3,
|
||||
},
|
||||
)
|
||||
assert image_updated.status_code == 200
|
||||
image_body = image_updated.json()
|
||||
@@ -3157,11 +3330,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
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"},
|
||||
image_provider_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.update",
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"apiKey": "sk-or-next",
|
||||
"apiBase": "https://openrouter.ai/api/v1",
|
||||
},
|
||||
)
|
||||
assert image_provider_updated.status_code == 200
|
||||
assert image_provider_updated.json()["requires_restart"] is True
|
||||
@@ -3169,17 +3345,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert "sk-or-next" not in image_provider_updated.text
|
||||
assert image_reload.await_count == 2
|
||||
|
||||
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"},
|
||||
bad_web = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.web_search.update",
|
||||
{"provider": "duckduckgo", "max_results": 99},
|
||||
)
|
||||
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"},
|
||||
bad_image = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{"provider": "missing"},
|
||||
)
|
||||
assert bad_image.status_code == 400
|
||||
|
||||
@@ -3216,6 +3392,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert saved.tools.image_generation.default_image_size == "2K"
|
||||
assert saved.tools.image_generation.max_images_per_turn == 3
|
||||
finally:
|
||||
if webui_client is not None:
|
||||
await webui_client.close()
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -3248,11 +3426,17 @@ async def test_image_settings_hot_reload_without_restart(
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
webui_client = None
|
||||
try:
|
||||
response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||
webui_client = await websockets.connect(
|
||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
|
||||
)
|
||||
assert json.loads(await webui_client.recv())["event"] == "ready"
|
||||
response = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -3260,6 +3444,8 @@ async def test_image_settings_hot_reload_without_restart(
|
||||
assert response.json()["restart_required_sections"] == []
|
||||
image_reload.assert_awaited_once_with(bus)
|
||||
finally:
|
||||
if webui_client is not None:
|
||||
await webui_client.close()
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -3291,17 +3477,25 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
webui_client = None
|
||||
try:
|
||||
response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||
webui_client = await websockets.connect(
|
||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
|
||||
)
|
||||
assert json.loads(await webui_client.recv())["event"] == "ready"
|
||||
response = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["requires_restart"] is True
|
||||
assert response.json()["restart_required_sections"] == ["image"]
|
||||
finally:
|
||||
if webui_client is not None:
|
||||
await webui_client.close()
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import type {
|
||||
NanobotFeatureInfo,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
import {
|
||||
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
||||
@@ -64,6 +65,7 @@ export function WeixinPanel({
|
||||
onAction,
|
||||
onFeaturesUpdate,
|
||||
}: ChannelPluginPanelProps) {
|
||||
const { client } = useClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const channelTx = channelTranslator(t, "weixin");
|
||||
@@ -150,7 +152,7 @@ export function WeixinPanel({
|
||||
setSaveState("idle");
|
||||
try {
|
||||
const payload = await configureChannel(
|
||||
context.token,
|
||||
client,
|
||||
"weixin",
|
||||
channelValuesForSave(editableFieldsRef.current, values),
|
||||
{ enable: context.enabled },
|
||||
@@ -168,7 +170,7 @@ export function WeixinPanel({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -669,6 +669,7 @@ def _run_gateway(
|
||||
webui_runtime_surface=webui_runtime_surface,
|
||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||
webui_skill_state_action=_webui_skill_state_action,
|
||||
config_path=Path(config_path),
|
||||
)
|
||||
|
||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
@@ -89,8 +90,8 @@ def _query_first(query: QueryParams, key: str) -> str | None:
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _manager() -> CliAppManager:
|
||||
config = load_config()
|
||||
def _manager(config_path: Path | None = None) -> CliAppManager:
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
cli_cfg = config.tools.cli_apps
|
||||
return CliAppManager(
|
||||
workspace=config.workspace_path,
|
||||
@@ -102,8 +103,12 @@ def _manager() -> CliAppManager:
|
||||
)
|
||||
|
||||
|
||||
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||
manager = _manager()
|
||||
async def cli_apps_payload(
|
||||
*,
|
||||
installed_only: bool = False,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
manager = _manager(config_path) if config_path is not None else _manager()
|
||||
if installed_only:
|
||||
return manager.installed_payload()
|
||||
payload = manager.payload(cache_only=True)
|
||||
@@ -118,11 +123,16 @@ async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||
return payload
|
||||
|
||||
|
||||
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
def cli_apps_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
raise CliAppError("missing CLI app name")
|
||||
manager = _manager()
|
||||
manager = _manager(config_path) if config_path is not None else _manager()
|
||||
if action == "install":
|
||||
return manager.install(name)
|
||||
if action == "update":
|
||||
|
||||
@@ -8,9 +8,11 @@ from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from loguru import logger as default_logger
|
||||
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
@@ -29,6 +31,7 @@ class GatewayServices:
|
||||
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
||||
|
||||
http: GatewayHTTPHandler
|
||||
settings: WebUISettingsServices
|
||||
tokens: GatewayTokenStore
|
||||
media: WebUIMediaGateway
|
||||
ingress: WebUIIngressPolicy
|
||||
@@ -50,6 +53,7 @@ def build_gateway_services(
|
||||
static_dist_path: Path | None,
|
||||
workspace_path: Path,
|
||||
default_restrict_to_workspace: bool,
|
||||
config_path: Path | None = None,
|
||||
runtime_model_name: Callable[[], str | None] | None,
|
||||
runtime_surface: str,
|
||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||
@@ -63,6 +67,7 @@ def build_gateway_services(
|
||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
logger: Any = default_logger,
|
||||
) -> GatewayServices:
|
||||
settings = WebUISettingsServices.create(config_path or get_config_path())
|
||||
tokens = GatewayTokenStore()
|
||||
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
||||
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
||||
@@ -102,6 +107,7 @@ def build_gateway_services(
|
||||
media=media,
|
||||
ingress=ingress,
|
||||
workspaces=workspaces,
|
||||
settings=settings,
|
||||
skills_workspace_path=workspace_path,
|
||||
disabled_skills=disabled_skills,
|
||||
cron_service=cron_service,
|
||||
@@ -115,6 +121,7 @@ def build_gateway_services(
|
||||
)
|
||||
return GatewayServices(
|
||||
http=http,
|
||||
settings=settings,
|
||||
tokens=tokens,
|
||||
media=media,
|
||||
ingress=ingress,
|
||||
|
||||
@@ -14,7 +14,7 @@ from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Mapping, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
||||
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
@@ -25,6 +25,9 @@ from nanobot.utils.helpers import ensure_dir
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.webui.settings_services import WebUISettingsConfig
|
||||
|
||||
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||
_SECRET_QUERY_RE = re.compile(
|
||||
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
||||
@@ -841,8 +844,9 @@ def mcp_presets_payload(
|
||||
*,
|
||||
last_action: dict[str, Any] | None = None,
|
||||
tool_preview: Mapping[str, list[str]] | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
known = _known_preset_names()
|
||||
preset_rows = [
|
||||
_preset_payload(preset, config.tools.mcp_servers)
|
||||
@@ -928,7 +932,11 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
|
||||
await stack.aclose()
|
||||
|
||||
|
||||
async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
async def mcp_presets_test_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Connect to an enabled MCP preset and report its tool surface."""
|
||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||
|
||||
@@ -941,16 +949,22 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
display_name = _display_name_for(name, preset)
|
||||
|
||||
try:
|
||||
config = resolve_config_env_vars(load_config())
|
||||
config = resolve_config_env_vars(
|
||||
load_config(config_path) if config_path is not None else load_config(),
|
||||
config_path=config_path,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return mcp_presets_payload(last_action={
|
||||
return mcp_presets_payload(
|
||||
last_action={
|
||||
"ok": False,
|
||||
"message": _scrub_test_error(str(exc)),
|
||||
"error": _scrub_test_error(str(exc)),
|
||||
"tool_count": 0,
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
})
|
||||
},
|
||||
config_path=config_path,
|
||||
)
|
||||
|
||||
cfg = config.tools.mcp_servers.get(name)
|
||||
if cfg is None:
|
||||
@@ -968,7 +982,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
}
|
||||
return mcp_presets_payload(last_action=last_action)
|
||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
||||
|
||||
if cfg.command and not _command_available(cfg.command):
|
||||
last_action = {
|
||||
@@ -979,7 +993,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
}
|
||||
return mcp_presets_payload(last_action=last_action)
|
||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
||||
|
||||
registry = ToolRegistry()
|
||||
stacks: dict[str, Any] = {}
|
||||
@@ -1040,7 +1054,11 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
|
||||
tool_names = last_action.get("tool_names", [])
|
||||
preview = {name: tool_names} if tool_names else None
|
||||
return mcp_presets_payload(last_action=last_action, tool_preview=preview)
|
||||
return mcp_presets_payload(
|
||||
last_action=last_action,
|
||||
tool_preview=preview,
|
||||
config_path=config_path,
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
||||
@@ -1221,24 +1239,35 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
||||
return out
|
||||
|
||||
|
||||
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
def custom_mcp_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
if action == "custom":
|
||||
name, cfg = _custom_server_from_query(query)
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||
save_config(config, config_path)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_server_action_message(action, name),
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
if action in {"import", "import-cursor"}:
|
||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
||||
config.tools.mcp_servers.update(servers)
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action={
|
||||
save_config(config, config_path)
|
||||
payload = mcp_presets_payload(
|
||||
last_action={
|
||||
"ok": True,
|
||||
"message": f"Imported {len(servers)} MCP server(s).",
|
||||
})
|
||||
},
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
@@ -1249,29 +1278,40 @@ def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
raise McpPresetError("unknown MCP server", status=404)
|
||||
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||
save_config(config, config_path)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_server_action_message(action, name),
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
||||
|
||||
|
||||
def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
def mcp_presets_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
raise McpPresetError("missing MCP preset name")
|
||||
preset = _preset_by_name_optional(name)
|
||||
|
||||
config = load_config()
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
existing = config.tools.mcp_servers.get(name)
|
||||
|
||||
if action == "enable":
|
||||
if preset is None:
|
||||
raise McpPresetError("unknown MCP preset", status=404)
|
||||
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
||||
save_config(config, config_path)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_action_message(action, preset),
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
@@ -1287,7 +1327,7 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
except OSError as exc:
|
||||
cleanup_error = str(exc)
|
||||
del config.tools.mcp_servers[name]
|
||||
save_config(config)
|
||||
save_config(config, config_path)
|
||||
last_action = (
|
||||
_action_message(action, preset)
|
||||
if preset is not None
|
||||
@@ -1303,7 +1343,10 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
||||
)
|
||||
last_action["verification_failed"] = ["managed_paths_absent"]
|
||||
payload = mcp_presets_payload(last_action=last_action)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=last_action,
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
@@ -1339,13 +1382,21 @@ async def mcp_presets_settings_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
reload_mcp: McpReload | None = None,
|
||||
config: WebUISettingsConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||
config_path = config.path if config is not None else None
|
||||
if action is None:
|
||||
return mcp_presets_payload()
|
||||
return mcp_presets_payload(config_path=config_path)
|
||||
if action == "test":
|
||||
return await mcp_presets_test_action(query)
|
||||
if action in _CUSTOM_ACTIONS:
|
||||
return await mcp_presets_test_action(query, config_path=config_path)
|
||||
if config is not None:
|
||||
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
|
||||
payload = await asyncio.to_thread(
|
||||
config.run_serialized,
|
||||
lambda path: operation(action, query, config_path=path),
|
||||
)
|
||||
elif action in _CUSTOM_ACTIONS:
|
||||
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
||||
else:
|
||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Nanobot optional feature helpers for WebUI Settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
@@ -15,9 +16,14 @@ from nanobot.webui.http_utils import query_first
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
|
||||
def nanobot_features_payload() -> dict[str, Any]:
|
||||
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
||||
if config_path is None:
|
||||
return optional_features_payload()
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
return optional_features_payload(config=load_config(config_path))
|
||||
|
||||
|
||||
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
||||
"""Preserve the difference between a global action and an explicit instance."""
|
||||
@@ -32,13 +38,19 @@ def nanobot_features_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
allow_install: bool = True,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (query_first(query, "name") or "").strip()
|
||||
instance_id = nanobot_feature_instance_target(query)
|
||||
if not name:
|
||||
raise OptionalFeatureError("missing feature name")
|
||||
if action == "enable":
|
||||
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
|
||||
return enable_optional_feature(
|
||||
name,
|
||||
config_path=config_path,
|
||||
allow_install=allow_install,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
if action == "disable":
|
||||
try:
|
||||
plugin = load_channel_plugin(name)
|
||||
@@ -50,5 +62,9 @@ def nanobot_features_action(
|
||||
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
||||
status=400,
|
||||
)
|
||||
return disable_optional_feature(name, instance_id=instance_id)
|
||||
return disable_optional_feature(
|
||||
name,
|
||||
config_path=config_path,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
||||
|
||||
+168
-135
@@ -14,11 +14,11 @@ import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from contextlib import suppress
|
||||
from typing import Any, Literal, cast
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
@@ -49,6 +49,9 @@ from nanobot.webui.workspaces import (
|
||||
QueryParams = dict[str, list[str]]
|
||||
RuntimeSurface = Literal["browser", "native"]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
||||
|
||||
|
||||
def _version_payload() -> dict[str, Any]:
|
||||
"""Return version info for the settings payload."""
|
||||
@@ -133,9 +136,6 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
||||
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
||||
_WEBUI_OAUTH_TIMEOUT_S = 600
|
||||
_WEBUI_OAUTH_MAX_FLOWS = 8
|
||||
_webui_oauth_flows: dict[str, tuple[str, Any]] = {}
|
||||
_webui_oauth_flows_lock = threading.Lock()
|
||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
@@ -148,6 +148,21 @@ class WebUISettingsError(ValueError):
|
||||
self.status = status
|
||||
|
||||
|
||||
def _load_settings_config(config_path: Path | None) -> Config:
|
||||
return load_config(config_path) if config_path is not None else load_config()
|
||||
|
||||
|
||||
def _save_settings_config(config: Config, config_path: Path | None) -> None:
|
||||
if config_path is None:
|
||||
save_config(config)
|
||||
else:
|
||||
save_config(config, config_path)
|
||||
|
||||
|
||||
def _settings_config_path(config_path: Path | None) -> Path:
|
||||
return config_path if config_path is not None else get_config_path()
|
||||
|
||||
|
||||
def _normalize_surface(surface: str | None) -> RuntimeSurface:
|
||||
return "native" if surface in {"native", "desktop"} else "browser"
|
||||
|
||||
@@ -764,7 +779,11 @@ def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
|
||||
return rows
|
||||
|
||||
|
||||
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||
def provider_models_payload(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
||||
|
||||
The result is advisory only: users can always type a custom model id. This
|
||||
@@ -775,7 +794,7 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("provider is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||
if resolved_provider is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
@@ -1117,8 +1136,9 @@ def settings_payload(
|
||||
runtime_capability_overrides: dict[str, Any] | None = None,
|
||||
restart_required_sections: list[str] | None = None,
|
||||
apply_state: dict[str, Any] | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
defaults = config.agents.defaults
|
||||
active_preset_name = defaults.model_preset or "default"
|
||||
effective_preset = config.resolve_preset()
|
||||
@@ -1299,7 +1319,7 @@ def settings_payload(
|
||||
"providers": _transcription_provider_rows(config),
|
||||
},
|
||||
"runtime": {
|
||||
"config_path": str(get_config_path().expanduser()),
|
||||
"config_path": str(_settings_config_path(config_path).expanduser()),
|
||||
"workspace_path": str(config.workspace_path),
|
||||
"gateway_host": config.gateway.host,
|
||||
"gateway_port": config.gateway.port,
|
||||
@@ -1341,14 +1361,18 @@ def settings_payload(
|
||||
)
|
||||
|
||||
|
||||
def settings_usage_payload() -> dict[str, Any]:
|
||||
def settings_usage_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
||||
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||
|
||||
|
||||
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
def update_agent_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = _load_settings_config(config_path)
|
||||
defaults = config.agents.defaults
|
||||
changed = False
|
||||
restart_required = False
|
||||
@@ -1425,11 +1449,15 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||
restart_required = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
||||
|
||||
|
||||
def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
def create_model_configuration(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
||||
raw_name = (_query_first(query, "name") or label).strip()
|
||||
model = (_query_first(query, "model") or "").strip()
|
||||
@@ -1443,7 +1471,7 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
raise WebUISettingsError("provider is required")
|
||||
|
||||
name = _model_configuration_slug(raw_name or label)
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
if name in config.model_presets:
|
||||
raise WebUISettingsError("configuration already exists", status=409)
|
||||
_validate_configured_provider(config, provider)
|
||||
@@ -1476,18 +1504,22 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
temperature=temperature if temperature is not None else base.temperature,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
save_config(config)
|
||||
payload = settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
payload = settings_payload(config_path=config_path)
|
||||
payload["created_model_preset"] = name
|
||||
return payload
|
||||
|
||||
|
||||
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
def update_model_configuration(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name or name == "default":
|
||||
raise WebUISettingsError("model configuration is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
preset = config.model_presets.get(name)
|
||||
if preset is None:
|
||||
raise WebUISettingsError("unknown model configuration")
|
||||
@@ -1554,11 +1586,15 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
||||
def update_model_call_order(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_order = _query_first_alias(query, "order", "presetNames")
|
||||
if raw_order is None:
|
||||
raise WebUISettingsError("model call order is required")
|
||||
@@ -1580,7 +1616,7 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
||||
cast(str, name).strip()
|
||||
for name in cast(list[object], order)
|
||||
]
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
_, editable = _model_call_order_state(config)
|
||||
if not editable:
|
||||
raise WebUISettingsError(
|
||||
@@ -1599,13 +1635,17 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
||||
):
|
||||
defaults.model_preset = normalized_order[0]
|
||||
defaults.fallback_models = fallback_models
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
|
||||
def migrate_model_configurations(
|
||||
_query: QueryParams | None = None,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Materialize legacy primary/inline model settings as named presets."""
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
defaults = config.agents.defaults
|
||||
primary = config.resolve_preset()
|
||||
created: list[str] = []
|
||||
@@ -1658,16 +1698,20 @@ def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str,
|
||||
|
||||
if created:
|
||||
defaults.fallback_models = fallback_models
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
def delete_model_configuration(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name or name == "default":
|
||||
raise WebUISettingsError("model configuration is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
if name not in config.model_presets:
|
||||
raise WebUISettingsError("unknown model configuration")
|
||||
defaults = config.agents.defaults
|
||||
@@ -1681,11 +1725,15 @@ def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
del config.model_presets[name]
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
def create_provider_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
||||
if not display_name:
|
||||
raise WebUISettingsError("provider name is required")
|
||||
@@ -1710,7 +1758,7 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
if not api_base:
|
||||
raise WebUISettingsError("API base is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
if _provider_display_name_exists(config, display_name):
|
||||
raise WebUISettingsError("provider already exists", status=409)
|
||||
|
||||
@@ -1719,18 +1767,22 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
updates["api_type"] = "auto"
|
||||
provider_config = _validated_provider_config(None, updates)
|
||||
setattr(config.providers, provider_key, provider_config)
|
||||
save_config(config)
|
||||
payload = settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
payload = settings_payload(config_path=config_path)
|
||||
payload["created_provider"] = provider_key
|
||||
return payload
|
||||
|
||||
|
||||
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
def update_provider_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("provider is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||
if resolved_provider is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
@@ -1772,7 +1824,7 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
changed = updated_provider_config != provider_config
|
||||
if changed:
|
||||
setattr(config.providers, provider_key, updated_provider_config)
|
||||
save_config(config)
|
||||
_save_settings_config(config, config_path)
|
||||
image_config = config.tools.image_generation
|
||||
restart_required = (
|
||||
changed
|
||||
@@ -1780,10 +1832,15 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
and image_config.provider == provider_key
|
||||
and get_image_gen_provider(provider_key) is not None
|
||||
)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
||||
|
||||
|
||||
def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
def login_oauth_provider(
|
||||
query: QueryParams,
|
||||
*,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("provider is required")
|
||||
@@ -1798,7 +1855,10 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||
proxy = resolve_config_env_vars(
|
||||
_load_settings_config(config_path),
|
||||
config_path=config_path,
|
||||
).providers.openai_codex.proxy or None
|
||||
except ValueError as e:
|
||||
raise WebUISettingsError(str(e), status=400) from e
|
||||
remote_browser_value = _query_first(query, "remote_browser")
|
||||
@@ -1816,7 +1876,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
except Exception as e:
|
||||
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
||||
flow_id = secrets.token_urlsafe(24)
|
||||
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||
oauth_flows.register(spec.name, flow_id, flow)
|
||||
return {
|
||||
"status": "authorization_required",
|
||||
"provider": spec.name,
|
||||
@@ -1840,13 +1900,16 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
token = login_github_copilot(print_fn=lambda _message: None)
|
||||
if not (token and token.access):
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
return settings_payload()
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
if spec.name == "xai_grok":
|
||||
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
||||
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
|
||||
proxy = resolve_config_env_vars(
|
||||
_load_settings_config(config_path),
|
||||
config_path=config_path,
|
||||
).providers.xai_grok.proxy or None
|
||||
except ValueError as e:
|
||||
raise WebUISettingsError(str(e), status=400) from e
|
||||
try:
|
||||
@@ -1857,7 +1920,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
except Exception as e:
|
||||
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
||||
flow_id = secrets.token_urlsafe(24)
|
||||
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||
oauth_flows.register(spec.name, flow_id, flow)
|
||||
return {
|
||||
"status": "authorization_required",
|
||||
"provider": spec.name,
|
||||
@@ -1873,6 +1936,9 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
def complete_oauth_provider(
|
||||
query: QueryParams,
|
||||
authorization_response: str | None = None,
|
||||
*,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
flow_id = (_query_first(query, "flow_id") or "").strip()
|
||||
@@ -1882,7 +1948,7 @@ def complete_oauth_provider(
|
||||
if not flow_id:
|
||||
raise WebUISettingsError("flow_id is required")
|
||||
|
||||
flow = _get_webui_oauth_flow(spec.name, flow_id)
|
||||
flow = oauth_flows.get(spec.name, flow_id)
|
||||
if flow is None:
|
||||
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
||||
|
||||
@@ -1904,7 +1970,7 @@ def complete_oauth_provider(
|
||||
except WebUISettingsError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_remove_webui_oauth_flow(spec.name, flow_id, flow)
|
||||
oauth_flows.remove(spec.name, flow_id, flow)
|
||||
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
||||
if token is None:
|
||||
return {
|
||||
@@ -1912,13 +1978,18 @@ def complete_oauth_provider(
|
||||
"provider": spec.name,
|
||||
"flow_id": flow_id,
|
||||
}
|
||||
_remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False)
|
||||
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
|
||||
if not token.access:
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
return settings_payload()
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
def logout_oauth_provider(
|
||||
query: QueryParams,
|
||||
*,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("provider is required")
|
||||
@@ -1932,7 +2003,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
from oauth_cli_kit.storage import FileTokenStorage
|
||||
except ImportError:
|
||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||
_clear_webui_oauth_flows(spec.name)
|
||||
oauth_flows.clear(spec.name)
|
||||
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
||||
elif spec.name == "github_copilot":
|
||||
try:
|
||||
@@ -1943,77 +2014,23 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
elif spec.name == "xai_grok":
|
||||
from nanobot.providers.xai_oauth import logout_xai_oauth
|
||||
|
||||
_clear_webui_oauth_flows(spec.name)
|
||||
oauth_flows.clear(spec.name)
|
||||
logout_xai_oauth()
|
||||
return settings_payload()
|
||||
return settings_payload(config_path=config_path)
|
||||
else:
|
||||
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
||||
|
||||
for path in (token_path, token_path.with_suffix(".lock")):
|
||||
with suppress(FileNotFoundError):
|
||||
path.unlink()
|
||||
return settings_payload()
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None:
|
||||
discarded: list[Any] = []
|
||||
with _webui_oauth_flows_lock:
|
||||
for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()):
|
||||
if existing.expired:
|
||||
discarded.append(_webui_oauth_flows.pop(existing_id)[1])
|
||||
while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS:
|
||||
oldest_id = next(iter(_webui_oauth_flows))
|
||||
discarded.append(_webui_oauth_flows.pop(oldest_id)[1])
|
||||
_webui_oauth_flows[flow_id] = (provider_name, flow)
|
||||
for existing in discarded:
|
||||
existing.cancel()
|
||||
|
||||
|
||||
def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None:
|
||||
with _webui_oauth_flows_lock:
|
||||
registered = _webui_oauth_flows.get(flow_id)
|
||||
if registered is None or registered[0] != provider_name:
|
||||
return None
|
||||
flow = registered[1]
|
||||
if not flow.expired:
|
||||
return flow
|
||||
_webui_oauth_flows.pop(flow_id, None)
|
||||
flow.cancel()
|
||||
return None
|
||||
|
||||
|
||||
def _remove_webui_oauth_flow(
|
||||
provider_name: str,
|
||||
flow_id: str,
|
||||
flow: Any,
|
||||
def update_network_safety_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
cancel: bool = True,
|
||||
) -> None:
|
||||
with _webui_oauth_flows_lock:
|
||||
registered = _webui_oauth_flows.get(flow_id)
|
||||
if (
|
||||
registered is not None
|
||||
and registered[0] == provider_name
|
||||
and registered[1] is flow
|
||||
):
|
||||
_webui_oauth_flows.pop(flow_id)
|
||||
if cancel:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
def _clear_webui_oauth_flows(provider_name: str) -> None:
|
||||
with _webui_oauth_flows_lock:
|
||||
flow_ids = [
|
||||
flow_id
|
||||
for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items()
|
||||
if registered_provider == provider_name
|
||||
]
|
||||
flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids]
|
||||
for flow in flows:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_allow = (
|
||||
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
||||
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
|
||||
@@ -2022,7 +2039,7 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||
if raw_allow is None and raw_default_access_mode is None:
|
||||
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
changed = False
|
||||
if raw_allow is not None:
|
||||
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||
@@ -2031,7 +2048,7 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
_save_settings_config(config, config_path)
|
||||
if raw_default_access_mode is not None:
|
||||
default_access_mode = raw_default_access_mode.strip().lower()
|
||||
if default_access_mode == "restricted":
|
||||
@@ -2042,16 +2059,20 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||
write_webui_default_access_mode(default_access_mode)
|
||||
except ValueError as exc:
|
||||
raise WebUISettingsError(str(exc)) from exc
|
||||
return settings_payload(requires_restart=changed)
|
||||
return settings_payload(requires_restart=changed, config_path=config_path)
|
||||
|
||||
|
||||
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
||||
def update_web_search_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> 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()
|
||||
config = _load_settings_config(config_path)
|
||||
search_config = config.tools.web.search
|
||||
web_config = config.tools.web
|
||||
previous_provider = search_config.provider
|
||||
@@ -2130,13 +2151,17 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
||||
restart_required = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
||||
|
||||
|
||||
def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
||||
def update_api_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update the managed OpenAI-compatible API configuration."""
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
api = config.api
|
||||
|
||||
host = _query_first(query, "host")
|
||||
@@ -2173,12 +2198,16 @@ def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
||||
if not is_loopback_host(api.host) and not api.api_key.strip():
|
||||
raise WebUISettingsError("an API key is required when the API is available on the network")
|
||||
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
def update_image_generation_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = _load_settings_config(config_path)
|
||||
image_config = config.tools.image_generation
|
||||
changed = False
|
||||
|
||||
@@ -2271,12 +2300,16 @@ def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
||||
raise WebUISettingsError("image generation provider is not configured")
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=changed)
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(requires_restart=changed, config_path=config_path)
|
||||
|
||||
|
||||
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
def update_transcription_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = _load_settings_config(config_path)
|
||||
transcription = config.transcription
|
||||
changed = False
|
||||
|
||||
@@ -2341,5 +2374,5 @@ def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
+222
-157
@@ -13,7 +13,6 @@ import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
from urllib.parse import unquote
|
||||
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
@@ -31,7 +30,7 @@ from nanobot.channels.contracts import (
|
||||
)
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
from nanobot.channels.validation import validate_channel_config
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.optional_features import (
|
||||
OptionalFeatureError,
|
||||
extra_installed,
|
||||
@@ -40,7 +39,6 @@ from nanobot.optional_features import (
|
||||
)
|
||||
from nanobot.pairing import approve_code, deny_code, list_pending
|
||||
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
||||
from nanobot.webui.http_utils import case_insensitive_header
|
||||
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
||||
from nanobot.webui.http_utils import query_first as _query_first
|
||||
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
||||
@@ -72,21 +70,13 @@ from nanobot.webui.settings_api import (
|
||||
update_transcription_settings,
|
||||
update_web_search_settings,
|
||||
)
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
from nanobot.webui.version_check import check_for_update
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"
|
||||
_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
|
||||
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
||||
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
|
||||
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
|
||||
_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"
|
||||
_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024
|
||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||
|
||||
_SKIP_FIELD = object()
|
||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||
@@ -112,6 +102,63 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
|
||||
"/api/settings/mcp-presets/tools": "tools",
|
||||
}
|
||||
|
||||
_SETTINGS_MUTATION_PATHS = frozenset({
|
||||
"/api/settings/update",
|
||||
"/api/settings/model-configurations/create",
|
||||
"/api/settings/model-configurations/update",
|
||||
"/api/settings/model-configurations/delete",
|
||||
"/api/settings/model-configurations/migrate",
|
||||
"/api/settings/model-call-order/update",
|
||||
"/api/settings/provider/update",
|
||||
"/api/settings/provider/create",
|
||||
"/api/settings/provider/oauth-login",
|
||||
"/api/settings/provider/oauth-login/complete",
|
||||
"/api/settings/provider/oauth-logout",
|
||||
"/api/settings/web-search/update",
|
||||
"/api/settings/api-service/start",
|
||||
"/api/settings/api-service/stop",
|
||||
"/api/settings/image-generation/update",
|
||||
"/api/settings/transcription/update",
|
||||
"/api/settings/network-safety/update",
|
||||
"/api/settings/cli-apps/install",
|
||||
"/api/settings/cli-apps/update",
|
||||
"/api/settings/cli-apps/uninstall",
|
||||
"/api/settings/cli-apps/test",
|
||||
"/api/settings/nanobot-features/enable",
|
||||
"/api/settings/nanobot-features/disable",
|
||||
"/api/settings/channels/validate",
|
||||
"/api/settings/channels/configure",
|
||||
"/api/settings/pairing/approve",
|
||||
"/api/settings/pairing/deny",
|
||||
*_MCP_PRESET_ACTIONS_BY_PATH,
|
||||
})
|
||||
|
||||
|
||||
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
|
||||
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return cast(dict[str, Any], payload)
|
||||
|
||||
|
||||
def _query_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _payload_query(payload: dict[str, Any]) -> QueryParams:
|
||||
return {
|
||||
key: [_query_value(value)]
|
||||
for key, value in payload.items()
|
||||
if key
|
||||
and key not in {"authorization_response", "channel", "values"}
|
||||
}
|
||||
|
||||
|
||||
class WebUISettingsRouter:
|
||||
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
||||
@@ -119,6 +166,7 @@ class WebUISettingsRouter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: WebUISettingsServices,
|
||||
bus: MessageBus,
|
||||
logger: Any,
|
||||
check_api_token: Callable[[WsRequest], bool],
|
||||
@@ -130,6 +178,7 @@ class WebUISettingsRouter:
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.bus = bus
|
||||
self.logger = logger
|
||||
self._check_api_token = check_api_token
|
||||
@@ -144,6 +193,15 @@ class WebUISettingsRouter:
|
||||
self._channel_connectors: dict[str, Any] = {}
|
||||
|
||||
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
||||
if self.is_mutation_path(path) and not getattr(
|
||||
request,
|
||||
_WEBUI_MUTATION_REQUEST_ATTR,
|
||||
False,
|
||||
):
|
||||
return self._error_response(
|
||||
405,
|
||||
"WebUI mutations require an authenticated WebSocket",
|
||||
)
|
||||
if path == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
if path == "/api/settings/usage":
|
||||
@@ -230,7 +288,17 @@ class WebUISettingsRouter:
|
||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def is_mutation_path(path: str) -> bool:
|
||||
return (
|
||||
path in _SETTINGS_MUTATION_PATHS
|
||||
or _channel_connect_route(path) is not None
|
||||
)
|
||||
|
||||
def _query(self, request: WsRequest) -> QueryParams:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is not None:
|
||||
return _payload_query(payload)
|
||||
return self._parse_query(request.path)
|
||||
|
||||
def _authorized(self, request: WsRequest) -> bool:
|
||||
@@ -260,70 +328,18 @@ class WebUISettingsRouter:
|
||||
)
|
||||
|
||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||
query = self._query(request)
|
||||
raw = request.headers.get(_MCP_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("MCP settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid MCP settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
||||
payload = cast(dict[object, Any], payload)
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if text:
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
return self._query(request)
|
||||
|
||||
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
||||
query = self._query(request)
|
||||
raw = request.headers.get(_PROVIDER_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("provider settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
try:
|
||||
payload = json.loads(unquote(raw))
|
||||
except json.JSONDecodeError:
|
||||
raise WebUISettingsError("invalid provider settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("provider settings payload must be a JSON object")
|
||||
payload = cast(dict[object, Any], payload)
|
||||
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("provider settings payload contains an invalid key")
|
||||
if isinstance(value, str):
|
||||
text = value
|
||||
elif value is None:
|
||||
text = ""
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
return self._query(request)
|
||||
|
||||
def _handle_settings(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
return self._json_response(
|
||||
self._with_restart_state(
|
||||
settings_payload(
|
||||
self.settings.read(
|
||||
settings_payload,
|
||||
surface=self._runtime_surface,
|
||||
runtime_capability_overrides=self._runtime_capabilities,
|
||||
)
|
||||
@@ -333,7 +349,7 @@ class WebUISettingsRouter:
|
||||
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
return self._json_response(settings_usage_payload())
|
||||
return self._json_response(self.settings.read(settings_usage_payload))
|
||||
|
||||
def _handle_settings_pairing(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
@@ -379,7 +395,7 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_agent_settings(self._query(request))
|
||||
payload = self.settings.mutate(update_agent_settings, self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||
@@ -388,7 +404,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = create_model_configuration(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
create_model_configuration,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -397,7 +416,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_model_configuration(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_model_configuration,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -406,7 +428,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = delete_model_configuration(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
delete_model_configuration,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -415,7 +440,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = migrate_model_configurations(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
migrate_model_configurations,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -424,7 +452,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_model_call_order(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_model_call_order,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -433,7 +464,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_provider_settings(self._parse_provider_settings_query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_provider_settings,
|
||||
self._parse_provider_settings_query(request)
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
payload = await self._apply_image_generation_runtime_change(payload)
|
||||
@@ -443,7 +477,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = create_provider_settings(self._parse_provider_settings_query(request))
|
||||
payload = self.settings.mutate(
|
||||
create_provider_settings,
|
||||
self._parse_provider_settings_query(request)
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -452,7 +489,11 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
|
||||
payload = await asyncio.to_thread(
|
||||
self.settings.read,
|
||||
provider_models_payload,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
except Exception:
|
||||
@@ -470,27 +511,33 @@ class WebUISettingsRouter:
|
||||
query = self._query(request)
|
||||
try:
|
||||
if action == "login":
|
||||
payload = await asyncio.to_thread(login_oauth_provider, query)
|
||||
elif action == "complete":
|
||||
authorization_response = case_insensitive_header(
|
||||
request.headers,
|
||||
_OAUTH_CALLBACK_HEADER,
|
||||
) or case_insensitive_header(
|
||||
request.headers,
|
||||
_OAUTH_CODE_HEADER,
|
||||
)
|
||||
if (
|
||||
len(authorization_response.encode("utf-8"))
|
||||
> _OAUTH_RESPONSE_HEADER_MAX_BYTES
|
||||
):
|
||||
raise WebUISettingsError("OAuth authorization response is too large")
|
||||
payload = await asyncio.to_thread(
|
||||
self.settings.read,
|
||||
login_oauth_provider,
|
||||
query,
|
||||
oauth_flows=self.settings.oauth_flows,
|
||||
)
|
||||
elif action == "complete":
|
||||
raw_response = (_mutation_payload(request) or {}).get(
|
||||
"authorization_response"
|
||||
)
|
||||
if raw_response is not None and not isinstance(raw_response, str):
|
||||
raise WebUISettingsError("OAuth authorization response must be a string")
|
||||
authorization_response = raw_response
|
||||
payload = await asyncio.to_thread(
|
||||
self.settings.read,
|
||||
complete_oauth_provider,
|
||||
query,
|
||||
authorization_response or None,
|
||||
oauth_flows=self.settings.oauth_flows,
|
||||
)
|
||||
else:
|
||||
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
||||
payload = await asyncio.to_thread(
|
||||
self.settings.read,
|
||||
logout_oauth_provider,
|
||||
query,
|
||||
oauth_flows=self.settings.oauth_flows,
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
if payload.get("status") in {"authorization_required", "pending"}:
|
||||
@@ -501,7 +548,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_web_search_settings(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_web_search_settings,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="browser"))
|
||||
@@ -520,19 +570,22 @@ class WebUISettingsRouter:
|
||||
return self._unauthorized()
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
nanobot_features_action,
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
{"name": ["api"]},
|
||||
allow_install=self._allow_feature_package_install(connection, request),
|
||||
)
|
||||
update_api_settings(self._parse_api_service_settings_query(request))
|
||||
config = load_config()
|
||||
self.settings.mutate(
|
||||
update_api_settings,
|
||||
self._parse_api_service_settings_query(request),
|
||||
)
|
||||
config = self.settings.config.load()
|
||||
runtime = self._api_runtime()
|
||||
options = ApiStartOptions(
|
||||
host=config.api.host,
|
||||
port=config.api.port,
|
||||
workspace=str(config.workspace_path),
|
||||
config_path=str(get_config_path().expanduser().resolve(strict=False)),
|
||||
config_path=str(self.settings.config.path),
|
||||
)
|
||||
current = runtime.status()
|
||||
result = await asyncio.to_thread(
|
||||
@@ -549,33 +602,12 @@ class WebUISettingsRouter:
|
||||
return self._json_response(self._api_service_payload(last_action="started"))
|
||||
|
||||
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
||||
query = self._query(request)
|
||||
if "api_key" in query or "apiKey" in query:
|
||||
raise WebUISettingsError("API service API key must be provided in the private header")
|
||||
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("API service settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid API service settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("API service settings payload must be a JSON object")
|
||||
payload = cast(dict[str, Any], payload)
|
||||
|
||||
unknown = set(payload) - {"api_key"}
|
||||
if unknown:
|
||||
raise WebUISettingsError("API service settings payload contains an invalid key")
|
||||
payload = _mutation_payload(request)
|
||||
if payload is not None:
|
||||
api_key = payload.get("api_key")
|
||||
if api_key is not None and not isinstance(api_key, str):
|
||||
raise WebUISettingsError("API service API key must be a string")
|
||||
|
||||
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
|
||||
if api_key is not None:
|
||||
merged["api_key"] = [api_key]
|
||||
return merged
|
||||
return self._query(request)
|
||||
|
||||
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
@@ -589,13 +621,11 @@ class WebUISettingsRouter:
|
||||
return self._error_response(500, self._api_runtime_message(result.message))
|
||||
return self._json_response(self._api_service_payload(last_action="stopped"))
|
||||
|
||||
@staticmethod
|
||||
def _api_runtime() -> ApiRuntime:
|
||||
config_path = get_config_path().expanduser().resolve(strict=False)
|
||||
return ApiRuntime(paths=api_runtime_paths(config_path))
|
||||
def _api_runtime(self) -> ApiRuntime:
|
||||
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
||||
|
||||
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
config = self.settings.config.load()
|
||||
status = self._api_runtime().status()
|
||||
extras = optional_dependency_groups()
|
||||
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
||||
@@ -639,7 +669,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_image_generation_settings(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_image_generation_settings,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
payload = await self._apply_image_generation_runtime_change(payload)
|
||||
@@ -674,7 +707,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_transcription_settings(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_transcription_settings,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -683,7 +719,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_network_safety_settings(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_network_safety_settings,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||
@@ -697,7 +736,10 @@ class WebUISettingsRouter:
|
||||
"yes",
|
||||
}
|
||||
try:
|
||||
payload = await cli_apps_payload(installed_only=installed_only)
|
||||
payload = await cli_apps_payload(
|
||||
installed_only=installed_only,
|
||||
config_path=self.settings.config.path,
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load CLI Apps payload")
|
||||
return self._error_response(500, "failed to load CLI Apps")
|
||||
@@ -711,7 +753,12 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
|
||||
payload = await asyncio.to_thread(
|
||||
cli_apps_action,
|
||||
action,
|
||||
self._query(request),
|
||||
config_path=self.settings.config.path,
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
except Exception as e:
|
||||
@@ -726,12 +773,29 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(nanobot_features_payload)
|
||||
payload = await asyncio.to_thread(self._nanobot_features_payload)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load nanobot features")
|
||||
return self._error_response(500, "failed to load nanobot features")
|
||||
return self._json_response(self._with_channel_runtime_status(payload))
|
||||
|
||||
def _nanobot_features_payload(self) -> dict[str, Any]:
|
||||
return nanobot_features_payload(config_path=self.settings.config.path)
|
||||
|
||||
def _nanobot_features_action(
|
||||
self,
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
allow_install: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return self.settings.mutate(
|
||||
nanobot_features_action,
|
||||
action,
|
||||
query,
|
||||
allow_install=allow_install,
|
||||
)
|
||||
|
||||
async def _handle_settings_nanobot_features_action(
|
||||
self,
|
||||
connection: Any,
|
||||
@@ -742,7 +806,7 @@ class WebUISettingsRouter:
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
nanobot_features_action,
|
||||
self._nanobot_features_action,
|
||||
action,
|
||||
self._query(request),
|
||||
allow_install=action != "enable"
|
||||
@@ -850,7 +914,7 @@ class WebUISettingsRouter:
|
||||
saved = await asyncio.to_thread(
|
||||
self._save_channel_config_values,
|
||||
name,
|
||||
self._parse_channel_values_header(request),
|
||||
self._parse_channel_values(request),
|
||||
instance_id,
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
@@ -865,7 +929,7 @@ class WebUISettingsRouter:
|
||||
"saved_keys": saved,
|
||||
}
|
||||
if not enable:
|
||||
features = await asyncio.to_thread(nanobot_features_payload)
|
||||
features = await asyncio.to_thread(self._nanobot_features_payload)
|
||||
features = self._with_channel_runtime_status(features)
|
||||
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
||||
return self._json_response(payload)
|
||||
@@ -876,7 +940,7 @@ class WebUISettingsRouter:
|
||||
|
||||
try:
|
||||
features = await asyncio.to_thread(
|
||||
nanobot_features_action,
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
feature_query,
|
||||
allow_install=self._allow_feature_package_install(connection, request),
|
||||
@@ -906,7 +970,7 @@ class WebUISettingsRouter:
|
||||
payload = await asyncio.to_thread(
|
||||
validate_channel_config,
|
||||
name,
|
||||
self._parse_channel_values_header(request),
|
||||
self._parse_channel_values(request),
|
||||
instance_id=instance_id,
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
@@ -916,19 +980,14 @@ class WebUISettingsRouter:
|
||||
return self._error_response(500, "failed to validate channel settings")
|
||||
return self._json_response(payload)
|
||||
|
||||
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
|
||||
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
|
||||
if not raw:
|
||||
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is None or "values" not in payload:
|
||||
return {}
|
||||
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("channel settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid channel settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
values = payload.get("values")
|
||||
if not isinstance(values, dict):
|
||||
raise WebUISettingsError("channel settings payload must be a JSON object")
|
||||
return cast(dict[str, Any], payload)
|
||||
return cast(dict[str, Any], values)
|
||||
|
||||
def _save_channel_config_values(
|
||||
self,
|
||||
@@ -949,7 +1008,7 @@ class WebUISettingsRouter:
|
||||
if not raw_values:
|
||||
return []
|
||||
|
||||
config = load_config()
|
||||
def update(config: Config) -> list[str]:
|
||||
section = getattr(config.channels, name, None)
|
||||
channel_config = channel_instance_config(
|
||||
plugin,
|
||||
@@ -961,7 +1020,9 @@ class WebUISettingsRouter:
|
||||
prefix = f"channels.{name}."
|
||||
for raw_key, raw_value in raw_values.items():
|
||||
if not raw_key:
|
||||
raise WebUISettingsError("channel settings payload contains an invalid key")
|
||||
raise WebUISettingsError(
|
||||
"channel settings payload contains an invalid key"
|
||||
)
|
||||
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
|
||||
value_type = field_types.get(field)
|
||||
if value_type is None:
|
||||
@@ -985,9 +1046,10 @@ class WebUISettingsRouter:
|
||||
status=400,
|
||||
) from exc
|
||||
setattr(config.channels, name, updated_section)
|
||||
save_config(config)
|
||||
return saved
|
||||
|
||||
return self.settings.config.update(update)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_channel_value(
|
||||
raw_key: str,
|
||||
@@ -1109,14 +1171,14 @@ class WebUISettingsRouter:
|
||||
target["instance_id"] = [str(payload["instance_id"])]
|
||||
try:
|
||||
features = await asyncio.to_thread(
|
||||
nanobot_features_action,
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
target,
|
||||
allow_install=self._allow_feature_package_install(connection, request),
|
||||
)
|
||||
except OptionalFeatureError as exc:
|
||||
features = self._feature_runtime_fallback(
|
||||
nanobot_features_payload(),
|
||||
self._nanobot_features_payload(),
|
||||
message=(
|
||||
f"{channel_name} connected, but enabling channel support failed: "
|
||||
f"{exc.message}"
|
||||
@@ -1137,7 +1199,9 @@ class WebUISettingsRouter:
|
||||
if _is_local_browser_request(connection, request.headers):
|
||||
return True
|
||||
try:
|
||||
return bool(load_config().tools.webui_allow_remote_package_install)
|
||||
return bool(
|
||||
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load remote package install policy")
|
||||
return False
|
||||
@@ -1154,6 +1218,7 @@ class WebUISettingsRouter:
|
||||
action,
|
||||
self._parse_mcp_settings_query(request),
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
config=self.settings.config,
|
||||
)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Gateway-owned state for the WebUI settings surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_WEBUI_OAUTH_MAX_FLOWS = 8
|
||||
|
||||
|
||||
class WebUISettingsConfig:
|
||||
"""Instance-scoped config access with serialized read-modify-write operations."""
|
||||
|
||||
def __init__(self, config_path: Path) -> None:
|
||||
self.path = config_path.expanduser().resolve(strict=False)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def load(self) -> Config:
|
||||
"""Load this gateway's config without consulting the process-global path."""
|
||||
with self._lock:
|
||||
return load_config(self.path)
|
||||
|
||||
def update(self, mutation: Callable[[Config], _T]) -> _T:
|
||||
"""Apply and atomically persist one in-process read-modify-write operation."""
|
||||
with self._lock:
|
||||
config = load_config(self.path)
|
||||
result = mutation(config)
|
||||
save_config(config, self.path)
|
||||
return result
|
||||
|
||||
def run_serialized(self, operation: Callable[[Path], _T]) -> _T:
|
||||
"""Run a path-aware read-modify-write operation under the instance lock."""
|
||||
with self._lock:
|
||||
return operation(self.path)
|
||||
|
||||
|
||||
class WebUIOAuthFlowRegistry:
|
||||
"""Bounded, thread-safe OAuth flows owned by one gateway instance."""
|
||||
|
||||
def __init__(self, *, max_flows: int = _WEBUI_OAUTH_MAX_FLOWS) -> None:
|
||||
if max_flows < 1:
|
||||
raise ValueError("max_flows must be at least one")
|
||||
self._max_flows = max_flows
|
||||
self._flows: dict[str, tuple[str, Any]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(self, provider_name: str, flow_id: str, flow: Any) -> None:
|
||||
discarded: list[Any] = []
|
||||
with self._lock:
|
||||
for existing_id, (_provider_name, existing) in list(self._flows.items()):
|
||||
if existing.expired:
|
||||
discarded.append(self._flows.pop(existing_id)[1])
|
||||
while len(self._flows) >= self._max_flows:
|
||||
oldest_id = next(iter(self._flows))
|
||||
discarded.append(self._flows.pop(oldest_id)[1])
|
||||
self._flows[flow_id] = (provider_name, flow)
|
||||
for existing in discarded:
|
||||
existing.cancel()
|
||||
|
||||
def get(self, provider_name: str, flow_id: str) -> Any | None:
|
||||
with self._lock:
|
||||
registered = self._flows.get(flow_id)
|
||||
if registered is None or registered[0] != provider_name:
|
||||
return None
|
||||
flow = registered[1]
|
||||
if not flow.expired:
|
||||
return flow
|
||||
self._flows.pop(flow_id, None)
|
||||
flow.cancel()
|
||||
return None
|
||||
|
||||
def remove(
|
||||
self,
|
||||
provider_name: str,
|
||||
flow_id: str,
|
||||
flow: Any,
|
||||
*,
|
||||
cancel: bool = True,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
registered = self._flows.get(flow_id)
|
||||
if (
|
||||
registered is not None
|
||||
and registered[0] == provider_name
|
||||
and registered[1] is flow
|
||||
):
|
||||
self._flows.pop(flow_id)
|
||||
if cancel:
|
||||
flow.cancel()
|
||||
|
||||
def clear(self, provider_name: str) -> None:
|
||||
with self._lock:
|
||||
flow_ids = [
|
||||
flow_id
|
||||
for flow_id, (registered_provider, _flow) in self._flows.items()
|
||||
if registered_provider == provider_name
|
||||
]
|
||||
flows = [self._flows.pop(flow_id)[1] for flow_id in flow_ids]
|
||||
for flow in flows:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebUISettingsServices:
|
||||
"""Settings dependencies composed once for a gateway instance."""
|
||||
|
||||
config: WebUISettingsConfig
|
||||
oauth_flows: WebUIOAuthFlowRegistry
|
||||
|
||||
@classmethod
|
||||
def create(cls, config_path: Path) -> WebUISettingsServices:
|
||||
return cls(
|
||||
config=WebUISettingsConfig(config_path),
|
||||
oauth_flows=WebUIOAuthFlowRegistry(),
|
||||
)
|
||||
|
||||
def read(
|
||||
self,
|
||||
operation: Callable[..., _T],
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> _T:
|
||||
"""Run a settings read against this gateway's explicit config path."""
|
||||
return operation(*args, config_path=self.config.path, **kwargs)
|
||||
|
||||
def mutate(
|
||||
self,
|
||||
operation: Callable[..., _T],
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> _T:
|
||||
"""Serialize a path-aware settings read-modify-write operation."""
|
||||
return self.config.run_serialized(
|
||||
lambda config_path: operation(
|
||||
*args,
|
||||
config_path=config_path,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
+175
-25
@@ -17,9 +17,10 @@ import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from loguru import logger
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
@@ -118,7 +119,60 @@ from nanobot.webui.transcript import build_webui_thread_response
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
|
||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||
|
||||
_WEBUI_MUTATION_PATHS = {
|
||||
"automation.enable": "/api/webui/automations/enable",
|
||||
"automation.disable": "/api/webui/automations/disable",
|
||||
"automation.delete": "/api/webui/automations/delete",
|
||||
"automation.run": "/api/webui/automations/run",
|
||||
"automation.update": "/api/webui/automations/update",
|
||||
"skill.install": "/api/webui/skills/install",
|
||||
"skill.update": "/api/webui/skills/update",
|
||||
"skill.delete": "/api/webui/skills/delete",
|
||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||
"settings.agent.update": "/api/settings/update",
|
||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||
"settings.model_configuration.delete": "/api/settings/model-configurations/delete",
|
||||
"settings.model_configuration.migrate": "/api/settings/model-configurations/migrate",
|
||||
"settings.model_call_order.update": "/api/settings/model-call-order/update",
|
||||
"settings.provider.update": "/api/settings/provider/update",
|
||||
"settings.provider.create": "/api/settings/provider/create",
|
||||
"settings.provider.oauth_login": "/api/settings/provider/oauth-login",
|
||||
"settings.provider.oauth_complete": "/api/settings/provider/oauth-login/complete",
|
||||
"settings.provider.oauth_logout": "/api/settings/provider/oauth-logout",
|
||||
"settings.web_search.update": "/api/settings/web-search/update",
|
||||
"settings.api_service.start": "/api/settings/api-service/start",
|
||||
"settings.api_service.stop": "/api/settings/api-service/stop",
|
||||
"settings.image_generation.update": "/api/settings/image-generation/update",
|
||||
"settings.transcription.update": "/api/settings/transcription/update",
|
||||
"settings.network_safety.update": "/api/settings/network-safety/update",
|
||||
"settings.cli_app.install": "/api/settings/cli-apps/install",
|
||||
"settings.cli_app.update": "/api/settings/cli-apps/update",
|
||||
"settings.cli_app.uninstall": "/api/settings/cli-apps/uninstall",
|
||||
"settings.cli_app.test": "/api/settings/cli-apps/test",
|
||||
"settings.feature.enable": "/api/settings/nanobot-features/enable",
|
||||
"settings.feature.disable": "/api/settings/nanobot-features/disable",
|
||||
"settings.channel.validate": "/api/settings/channels/validate",
|
||||
"settings.channel.configure": "/api/settings/channels/configure",
|
||||
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
||||
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
||||
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
||||
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
||||
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
|
||||
}
|
||||
|
||||
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
|
||||
"settings.channel.connect.start": "start",
|
||||
"settings.channel.connect.poll": "poll",
|
||||
"settings.channel.connect.cancel": "cancel",
|
||||
}
|
||||
|
||||
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
|
||||
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
||||
@@ -150,6 +204,7 @@ if TYPE_CHECKING:
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
def _decode_api_key(raw_key: str) -> str | None:
|
||||
key = unquote(raw_key)
|
||||
@@ -159,6 +214,33 @@ def _decode_api_key(raw_key: str) -> str | None:
|
||||
return key
|
||||
|
||||
|
||||
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
|
||||
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return cast(dict[str, Any], payload)
|
||||
|
||||
|
||||
def _request_query(request: WsRequest) -> dict[str, list[str]]:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is None:
|
||||
return _parse_query(request.path)
|
||||
query: dict[str, list[str]] = {}
|
||||
for key, value in payload.items():
|
||||
if not key:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
text = "true" if value else "false"
|
||||
elif value is None:
|
||||
text = ""
|
||||
elif isinstance(value, (dict, list)):
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
else:
|
||||
text = str(value)
|
||||
query[key] = [text]
|
||||
return query
|
||||
|
||||
|
||||
def _default_model_name_from_config() -> str | None:
|
||||
try:
|
||||
from nanobot.config.loader import load_config
|
||||
@@ -211,6 +293,7 @@ class GatewayHTTPHandler:
|
||||
media: WebUIMediaGateway,
|
||||
ingress: WebUIIngressPolicy,
|
||||
workspaces: WebUIWorkspaceController,
|
||||
settings: WebUISettingsServices,
|
||||
skills_workspace_path: Path,
|
||||
disabled_skills: set[str] | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
@@ -231,6 +314,7 @@ class GatewayHTTPHandler:
|
||||
self.media = media
|
||||
self.ingress = ingress
|
||||
self.workspaces = workspaces
|
||||
self.settings = settings
|
||||
self.skills_workspace_path = skills_workspace_path
|
||||
self.disabled_skills: set[str] = (
|
||||
disabled_skills if disabled_skills is not None else set()
|
||||
@@ -249,6 +333,7 @@ class GatewayHTTPHandler:
|
||||
|
||||
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
||||
self.settings_routes = WebUISettingsRouter(
|
||||
settings=settings,
|
||||
bus=bus,
|
||||
logger=self._log,
|
||||
check_api_token=self.check_api_token,
|
||||
@@ -285,11 +370,86 @@ class GatewayHTTPHandler:
|
||||
)
|
||||
|
||||
try:
|
||||
if self._is_webui_mutation_path(got):
|
||||
return _http_error(
|
||||
405,
|
||||
"WebUI mutations require an authenticated WebSocket",
|
||||
)
|
||||
response = await self._dispatch_resolved(connection, request, got)
|
||||
return response
|
||||
finally:
|
||||
self._log_slow_http(got, response, started)
|
||||
|
||||
async def dispatch_webui_mutation(
|
||||
self,
|
||||
connection: Any,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> Response:
|
||||
"""Run one explicitly allowlisted mutation for an authenticated WebUI socket."""
|
||||
path = self._webui_mutation_path(action, payload)
|
||||
if isinstance(path, Response):
|
||||
return path
|
||||
|
||||
source_request = getattr(connection, "request", None)
|
||||
source_headers = getattr(source_request, "headers", None)
|
||||
if source_headers is None:
|
||||
headers = Headers()
|
||||
else:
|
||||
try:
|
||||
headers = Headers(source_headers.raw_items())
|
||||
except (AttributeError, TypeError):
|
||||
try:
|
||||
headers = Headers(source_headers)
|
||||
except TypeError:
|
||||
headers = Headers()
|
||||
request = WsRequest(path, headers)
|
||||
setattr(request, "_nanobot_trusted_proxy_authenticated", True)
|
||||
setattr(request, _WEBUI_MUTATION_REQUEST_ATTR, True)
|
||||
setattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, dict(payload))
|
||||
response = await self._dispatch_resolved(connection, request, path)
|
||||
if isinstance(response, Response):
|
||||
return response
|
||||
return _http_error(404, "WebUI mutation action not found")
|
||||
|
||||
def _is_webui_mutation_path(self, path: str) -> bool:
|
||||
if self.settings_routes.is_mutation_path(path):
|
||||
return True
|
||||
if re.match(r"^/api/sessions/[^/]+/delete$", path):
|
||||
return True
|
||||
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
|
||||
return True
|
||||
return path in {
|
||||
"/api/webui/skills/install",
|
||||
"/api/webui/skills/update",
|
||||
"/api/webui/skills/delete",
|
||||
"/api/webui/sidebar-state/update",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _webui_mutation_path(
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> str | Response:
|
||||
path = _WEBUI_MUTATION_PATHS.get(action)
|
||||
if path is not None:
|
||||
return path
|
||||
if action == "session.delete":
|
||||
key = payload.get("key")
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
return _http_error(400, "missing session key")
|
||||
return f"/api/sessions/{quote(key, safe='')}/delete"
|
||||
connect_action = _WEBUI_CHANNEL_CONNECT_ACTIONS.get(action)
|
||||
if connect_action is not None:
|
||||
channel = payload.get("channel")
|
||||
if not isinstance(channel, str) or re.fullmatch(
|
||||
r"[A-Za-z0-9_-]{1,64}",
|
||||
channel,
|
||||
) is None:
|
||||
return _http_error(400, "invalid channel name")
|
||||
return f"/api/settings/channels/{channel}/connect/{connect_action}"
|
||||
return _http_error(404, "unknown WebUI mutation action")
|
||||
|
||||
async def _dispatch_resolved(
|
||||
self,
|
||||
connection: Any,
|
||||
@@ -646,7 +806,7 @@ class GatewayHTTPHandler:
|
||||
return _http_error(400, "invalid session key")
|
||||
if not _is_websocket_channel_session_key(decoded_key):
|
||||
return _http_error(404, "session not found")
|
||||
query = _parse_query(request.path)
|
||||
query = _request_query(request)
|
||||
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||
automation_jobs = session_automation_jobs(
|
||||
self.cron_service,
|
||||
@@ -742,7 +902,7 @@ class GatewayHTTPHandler:
|
||||
if self.cron_service is None and self.local_trigger_store is None:
|
||||
return _http_error(503, "automation service unavailable")
|
||||
|
||||
query = _parse_query(request.path)
|
||||
query = _request_query(request)
|
||||
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||
if not job_id:
|
||||
return _http_error(400, "missing automation id")
|
||||
@@ -974,7 +1134,7 @@ class GatewayHTTPHandler:
|
||||
if self._skill_install_lock.locked():
|
||||
return _http_error(409, "another skill installation is already in progress")
|
||||
|
||||
query = _parse_query(request.path)
|
||||
query = _request_query(request)
|
||||
provider = _query_first(query, "provider") or "skills_sh"
|
||||
source = _query_first(query, "source") or ""
|
||||
skill_id = _query_first(query, "skill") or ""
|
||||
@@ -1015,7 +1175,7 @@ class GatewayHTTPHandler:
|
||||
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
query = _request_query(request)
|
||||
name = _query_first(query, "name") or ""
|
||||
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
||||
if raw_enabled not in {"true", "false"}:
|
||||
@@ -1047,7 +1207,7 @@ class GatewayHTTPHandler:
|
||||
return _http_error(401, "Unauthorized")
|
||||
if not _is_local_browser_request(connection, request.headers):
|
||||
return _http_error(403, "remote skill deletion is disabled")
|
||||
name = _query_first(_parse_query(request.path), "name") or ""
|
||||
name = _query_first(_request_query(request), "name") or ""
|
||||
try:
|
||||
action = delete_webui_skill(
|
||||
self.skills_workspace_path,
|
||||
@@ -1094,18 +1254,14 @@ class GatewayHTTPHandler:
|
||||
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:
|
||||
payload = _mutation_payload(request)
|
||||
state_value = payload.get("state") if payload is not None else None
|
||||
if state_value 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):
|
||||
if not isinstance(state_value, dict):
|
||||
return _http_error(400, "state must be an object")
|
||||
try:
|
||||
state = write_webui_sidebar_state(cast(dict[str, Any], decoded))
|
||||
state = write_webui_sidebar_state(cast(dict[str, Any], state_value))
|
||||
except ValueError as e:
|
||||
return _http_error(400, str(e))
|
||||
except OSError:
|
||||
@@ -1174,16 +1330,10 @@ class GatewayHTTPHandler:
|
||||
|
||||
|
||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
||||
if not raw:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is None or "values" not in payload:
|
||||
return {}
|
||||
try:
|
||||
values = json.loads(raw)
|
||||
except Exception:
|
||||
try:
|
||||
values = json.loads(unquote(raw))
|
||||
except Exception:
|
||||
return None
|
||||
values = payload.get("values")
|
||||
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
@@ -34,6 +35,13 @@ def _make_loop(tmp_path, presets=None, active_preset=None):
|
||||
)
|
||||
|
||||
|
||||
def _my_tool(loop: AgentLoop) -> MyTool:
|
||||
return MyTool(
|
||||
runtime_control=AgentRuntimeControl(loop),
|
||||
modify_allowed=True,
|
||||
)
|
||||
|
||||
|
||||
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop.model_preset is None
|
||||
@@ -240,7 +248,7 @@ def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
output = tool._inspect_all()
|
||||
assert "model_preset: 'fast'" in output
|
||||
|
||||
@@ -250,7 +258,7 @@ def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
result = tool._modify("model_preset", "fast")
|
||||
assert "Error" not in result
|
||||
assert loop.model_preset == "fast"
|
||||
@@ -263,7 +271,7 @@ def test_self_tool_set_model_preset_switches_back_to_default(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
result = tool._modify("model_preset", "default")
|
||||
|
||||
@@ -280,7 +288,7 @@ def test_self_tool_set_model_preset_unknown_lists_available(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
result = tool._modify("model_preset", "missing")
|
||||
|
||||
@@ -295,7 +303,7 @@ def test_self_tool_sets_model_preset_for_current_session(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="cli",
|
||||
@@ -318,7 +326,7 @@ def test_self_tool_reports_session_preset_provider_configuration_error(tmp_path)
|
||||
loop.set_session_model_preset = MagicMock(
|
||||
side_effect=ValueError("No API key configured for provider 'openai'.")
|
||||
)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="cli",
|
||||
@@ -343,7 +351,7 @@ def test_self_tool_rejects_instance_runtime_changes_in_session(
|
||||
value: object,
|
||||
) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
session = loop.sessions.get_or_create("cli:one")
|
||||
|
||||
with request_context(RequestContext(
|
||||
@@ -366,7 +374,7 @@ def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
||||
assert "Error" not in result
|
||||
assert loop.model_preset is None
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Contract and security regressions for the MyTool runtime boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.runtime_control import (
|
||||
RUNTIME_COMMAND_KEYS,
|
||||
RUNTIME_SNAPSHOT_KEYS,
|
||||
AgentRuntimeControl,
|
||||
RuntimeControl,
|
||||
)
|
||||
from nanobot.agent.tools.self import MyTool, MyToolConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path, *, allow_set: bool = False) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
tools_config = ToolsConfig(my=MyToolConfig(allow_set=allow_set))
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
tools_config=tools_config,
|
||||
)
|
||||
|
||||
|
||||
def _my_tool(loop: AgentLoop) -> MyTool:
|
||||
tool = loop.tools.get("my")
|
||||
assert isinstance(tool, MyTool)
|
||||
return tool
|
||||
|
||||
|
||||
def test_agent_loop_assembles_my_tool_with_runtime_control(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
assert isinstance(tool._runtime_control, RuntimeControl)
|
||||
assert isinstance(tool._runtime_control, AgentRuntimeControl)
|
||||
assert tool._runtime_control is not loop
|
||||
assert not hasattr(tool, "_runtime_state")
|
||||
|
||||
|
||||
def test_runtime_snapshot_has_exact_allowlist_and_redacts_secrets(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.web_config.search.api_key = "search-secret"
|
||||
loop.web_config.proxy = "http://proxy-user:proxy-secret@proxy.example"
|
||||
loop.unlisted_secret = "loop-secret"
|
||||
|
||||
snapshot = _my_tool(loop)._runtime_control.snapshot()
|
||||
values = snapshot.as_mapping()
|
||||
|
||||
assert frozenset(values) == RUNTIME_SNAPSHOT_KEYS
|
||||
assert RUNTIME_COMMAND_KEYS == frozenset({
|
||||
"model",
|
||||
"model_preset",
|
||||
"max_iterations",
|
||||
"context_window_tokens",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"workspace",
|
||||
})
|
||||
assert "provider" not in values
|
||||
assert "sessions" not in values
|
||||
assert "restrict_to_workspace" not in values
|
||||
assert "unlisted_secret" not in values
|
||||
rendered = repr(values)
|
||||
assert "search-secret" not in rendered
|
||||
assert "proxy-secret" not in rendered
|
||||
assert "loop-secret" not in rendered
|
||||
assert snapshot.web_config["proxy"] == "<configured>"
|
||||
|
||||
|
||||
def test_runtime_snapshot_is_detached_from_mutable_config(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
control = _my_tool(loop)._runtime_control
|
||||
snapshot = control.snapshot()
|
||||
search = snapshot.web_config["search"]
|
||||
assert isinstance(search, dict)
|
||||
|
||||
search["provider"] = "mutated"
|
||||
snapshot.exec_config["allow_patterns"] = ["mutated"]
|
||||
snapshot.tool_names.append("mutated")
|
||||
|
||||
refreshed = control.snapshot()
|
||||
refreshed_search = refreshed.web_config["search"]
|
||||
assert isinstance(refreshed_search, dict)
|
||||
assert refreshed_search["provider"] == loop.web_config.search.provider
|
||||
assert refreshed.exec_config["allow_patterns"] == loop.exec_config.allow_patterns
|
||||
assert "mutated" not in refreshed.tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlisted_loop_attributes_cannot_be_read_or_modified(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
loop.unlisted_control_plane = "internal-secret"
|
||||
original_workspace_root = loop.workspace_scopes.default_workspace
|
||||
tool = _my_tool(loop)
|
||||
|
||||
inspected = await tool.execute(action="check", key="unlisted_control_plane")
|
||||
modified = await tool.execute(
|
||||
action="set",
|
||||
key="unlisted_control_plane",
|
||||
value="scratch-value",
|
||||
)
|
||||
nested = await tool.execute(
|
||||
action="set",
|
||||
key="workspace_scopes.default_workspace",
|
||||
value="elsewhere",
|
||||
)
|
||||
|
||||
assert "internal-secret" not in inspected
|
||||
assert "not found" in inspected
|
||||
assert modified == "Set scratchpad.unlisted_control_plane = 'scratch-value'"
|
||||
assert loop.unlisted_control_plane == "internal-secret"
|
||||
assert "Error" in nested
|
||||
assert loop.workspace_scopes.default_workspace == original_workspace_root
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_allow_set_and_public_parameter_schema_are_unchanged(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
assert ToolsConfig().my.allow_set is False
|
||||
assert tool.parameters == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["check", "set"],
|
||||
"description": "Action to perform",
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Dot-path for check/set. Examples: 'max_iterations', 'workspace', "
|
||||
"'provider_retry_mode'. Use 'request.channel', 'request.chat_id', or "
|
||||
"'request.sender_id' for current routing metadata. Use 'model_preset' "
|
||||
"to switch named model presets. For check without key, shows all "
|
||||
"config values."
|
||||
),
|
||||
},
|
||||
"value": {
|
||||
"description": (
|
||||
"New value (for set). Type must match target (int for "
|
||||
"max_iterations/context_window_tokens, str for model/model_preset)."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
}
|
||||
assert "READ-ONLY MODE" in tool.description
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
assert result == "Error: set is disabled (tools.my.allow_set is false)"
|
||||
assert loop.max_iterations != 80
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowlisted_commands_preserve_runtime_side_effects(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
max_iterations = await tool.execute(
|
||||
action="set",
|
||||
key="max_iterations",
|
||||
value=80,
|
||||
)
|
||||
retry_mode = await tool.execute(
|
||||
action="set",
|
||||
key="provider_retry_mode",
|
||||
value="persistent",
|
||||
)
|
||||
scratchpad = await tool.execute(
|
||||
action="set",
|
||||
key="preference",
|
||||
value={"concise": True},
|
||||
)
|
||||
|
||||
assert max_iterations == "Set max_iterations = 80 (was 200)"
|
||||
assert retry_mode == "Set provider_retry_mode = 'persistent' (was 'standard')"
|
||||
assert scratchpad == "Set scratchpad.preference = {'concise': True}"
|
||||
assert loop.max_iterations == 80
|
||||
assert loop.subagents.max_iterations == 80
|
||||
assert loop.provider_retry_mode == "persistent"
|
||||
assert tool._runtime_control.snapshot().scratchpad == {
|
||||
"preference": {"concise": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_exposes_unchanged_my_tool_actions(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
|
||||
checked = await loop.tools.execute("my", {"action": "check", "key": "model"})
|
||||
changed = await loop.tools.execute(
|
||||
"my",
|
||||
{"action": "set", "key": "max_iterations", "value": 80},
|
||||
)
|
||||
|
||||
assert checked == "model: 'test-model'"
|
||||
assert changed == "Set max_iterations = 80 (was 200)"
|
||||
assert loop.max_iterations == 80
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_display_command_cannot_change_path_enforcement(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
result = await tool.execute(action="set", key="workspace", value="elsewhere")
|
||||
|
||||
assert "Set workspace" in result
|
||||
assert tool._runtime_control.snapshot().workspace == "elsewhere"
|
||||
assert loop.workspace == tmp_path
|
||||
assert loop.workspace_scopes.default_workspace == tmp_path
|
||||
@@ -8,10 +8,12 @@ from types import MappingProxyType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebSearchConfig, WebToolsConfig
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -27,13 +29,16 @@ def _make_mock_loop(**overrides):
|
||||
loop.workspace = Path("/tmp/workspace")
|
||||
loop.restrict_to_workspace = False
|
||||
loop._start_time = 1000.0
|
||||
loop.exec_config = MagicMock()
|
||||
loop.exec_config = ExecToolConfig()
|
||||
loop.channels_config = MagicMock()
|
||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
loop._runtime_vars = {}
|
||||
loop.last_usage = loop._last_usage
|
||||
loop._current_iteration = 0
|
||||
loop.current_iteration = loop._current_iteration
|
||||
loop.provider_retry_mode = "standard"
|
||||
loop.max_tool_result_chars = 16000
|
||||
loop.model_preset = None
|
||||
loop.model_presets = {}
|
||||
loop._concurrency_gate = None
|
||||
loop._unified_session = False
|
||||
loop._extra_hooks = []
|
||||
@@ -45,9 +50,7 @@ def _make_mock_loop(**overrides):
|
||||
)
|
||||
|
||||
# web_config mock — needed for check tests
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.enable = True
|
||||
loop.web_config.search = MagicMock()
|
||||
loop.web_config = WebToolsConfig()
|
||||
loop.web_config.search.api_key = "sk-secret-key-12345"
|
||||
|
||||
# Tools registry mock
|
||||
@@ -55,10 +58,13 @@ def _make_mock_loop(**overrides):
|
||||
loop.tools.tool_names = ["read_file", "write_file", "exec", "web_search", "self"]
|
||||
loop.tools.has.side_effect = lambda n: n in loop.tools.tool_names
|
||||
loop.tools.get.return_value = None
|
||||
loop.tool_names = loop.tools.tool_names
|
||||
|
||||
# SubagentManager mock
|
||||
loop.subagents = MagicMock()
|
||||
loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))}
|
||||
loop.subagents._task_statuses = {}
|
||||
loop.subagents.runtime_statuses.side_effect = lambda: loop.subagents._task_statuses
|
||||
loop.subagents.get_running_count = MagicMock(return_value=1)
|
||||
|
||||
for k, v in overrides.items():
|
||||
@@ -67,10 +73,10 @@ def _make_mock_loop(**overrides):
|
||||
return loop
|
||||
|
||||
|
||||
def _make_tool(runtime_state=None):
|
||||
if runtime_state is None:
|
||||
runtime_state = _make_mock_loop()
|
||||
return MyTool(runtime_state=runtime_state)
|
||||
def _make_tool(loop=None):
|
||||
if loop is None:
|
||||
loop = _make_mock_loop()
|
||||
return MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -87,10 +93,10 @@ class TestInspectSummary:
|
||||
assert "context_window_tokens: 65536" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_includes_runtime_vars(self):
|
||||
async def test_inspect_includes_scratchpad(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {"task": "review"}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
tool._runtime_control.set_scratchpad("task", "review", max_keys=64)
|
||||
result = await tool.execute(action="check")
|
||||
assert "task" in result
|
||||
|
||||
@@ -150,9 +156,7 @@ class TestInspectPathNavigation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_config_subfield(self):
|
||||
loop = _make_mock_loop()
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.enable = True
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="web_config.enable")
|
||||
assert "True" in result
|
||||
|
||||
@@ -160,7 +164,7 @@ class TestInspectPathNavigation:
|
||||
async def test_inspect_dict_key_via_dotpath(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
|
||||
assert "100" in result
|
||||
|
||||
@@ -179,20 +183,16 @@ class TestInspectPathNavigation:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
|
||||
class SearchConfig(BaseModel):
|
||||
provider: str = "tavily"
|
||||
api_key: str = "sk-test-secret"
|
||||
base_url: str = ""
|
||||
max_results: int = 5
|
||||
|
||||
loop = _make_mock_loop()
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.search = SearchConfig()
|
||||
loop.web_config.search = WebSearchConfig(
|
||||
provider="tavily",
|
||||
api_key="sk-test-secret",
|
||||
)
|
||||
tool = _make_tool(loop)
|
||||
|
||||
result = await tool.execute(action="check", key="web_config.search")
|
||||
|
||||
assert "provider='tavily'" in result
|
||||
assert "tavily" in result
|
||||
assert "sk-test-secret" not in result
|
||||
assert "api_key" not in result.lower()
|
||||
|
||||
@@ -209,14 +209,14 @@ class TestModifyRestricted:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
assert "Set max_iterations = 80" in result
|
||||
assert tool._runtime_state.max_iterations == 80
|
||||
assert tool._runtime_control.snapshot().max_iterations == 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_restricted_out_of_range(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value=0)
|
||||
assert "Error" in result
|
||||
assert tool._runtime_state.max_iterations == 40
|
||||
assert tool._runtime_control.snapshot().max_iterations == 40
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_restricted_max_exceeded(self):
|
||||
@@ -241,12 +241,12 @@ class TestModifyRestricted:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
||||
assert "Set max_iterations" in result
|
||||
assert tool._runtime_state.max_iterations == 80
|
||||
assert tool._runtime_control.snapshot().max_iterations == 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_context_window_valid(self):
|
||||
loop = _make_mock_loop()
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||
assert "Set context_window_tokens" in result
|
||||
assert loop.context_window_tokens == 131072
|
||||
@@ -324,15 +324,15 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
||||
assert "Set provider_retry_mode" in result
|
||||
assert tool._runtime_state.provider_retry_mode == "persistent"
|
||||
assert tool._runtime_control.snapshot().provider_retry_mode == "persistent"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_new_key_stores_in_runtime_vars(self):
|
||||
"""Modifying a non-existing attribute should store in _runtime_vars."""
|
||||
async def test_modify_new_key_stores_in_scratchpad(self):
|
||||
"""Modifying an unknown key should store it in the scratchpad."""
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
||||
assert "my_custom_var" in result
|
||||
assert tool._runtime_state._runtime_vars["my_custom_var"] == "hello"
|
||||
assert tool._runtime_control.snapshot().scratchpad["my_custom_var"] == "hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_rejects_callable(self):
|
||||
@@ -351,14 +351,14 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||
assert result == "Set scratchpad.items = [1, 2, 3]"
|
||||
assert tool._runtime_state._runtime_vars["items"] == [1, 2, 3]
|
||||
assert tool._runtime_control.snapshot().scratchpad["items"] == [1, 2, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_allows_dict(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
||||
assert result == "Set scratchpad.data = {'a': 1}"
|
||||
assert tool._runtime_state._runtime_vars["data"] == {"a": 1}
|
||||
assert tool._runtime_control.snapshot().scratchpad["data"] == {"a": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_whitespace_key_rejected(self):
|
||||
@@ -396,7 +396,7 @@ class TestModifyFree:
|
||||
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
|
||||
assert "Error" in result
|
||||
assert "str" in result
|
||||
assert tool._runtime_state.provider_retry_mode == "standard"
|
||||
assert tool._runtime_control.snapshot().provider_retry_mode == "standard"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
||||
@@ -404,7 +404,7 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
||||
assert "Error" in result
|
||||
assert tool._runtime_state.max_tool_result_chars == 16000
|
||||
assert tool._runtime_control.snapshot().max_tool_result_chars == 16000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -486,11 +486,12 @@ class TestModifyOpen:
|
||||
assert "protected" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_workspace_allowed(self):
|
||||
"""workspace was READONLY in v1, now freely modifiable."""
|
||||
async def test_modify_workspace_preserves_display_compatibility(self):
|
||||
"""The compatibility value is isolated from filesystem security boundaries."""
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="workspace", value="/new/path")
|
||||
assert "Set workspace" in result
|
||||
assert tool._runtime_control.snapshot().workspace == "/new/path"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_mcp_servers_blocked(self):
|
||||
@@ -584,28 +585,28 @@ class TestUnknownAction:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime_vars limits (from code review)
|
||||
# scratchpad limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRuntimeVarsLimits:
|
||||
class TestScratchpadLimits:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_vars_rejects_at_max_keys(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
async def test_scratchpad_rejects_at_max_keys(self):
|
||||
tool = _make_tool()
|
||||
for i in range(64):
|
||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||
result = await tool.execute(action="set", key="overflow", value="data")
|
||||
assert "full" in result
|
||||
assert "overflow" not in loop._runtime_vars
|
||||
assert "overflow" not in tool._runtime_control.snapshot().scratchpad
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_vars_allows_update_existing_key_at_max(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
async def test_scratchpad_allows_update_existing_key_at_max(self):
|
||||
tool = _make_tool()
|
||||
for i in range(64):
|
||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||
result = await tool.execute(action="set", key="key_0", value="updated")
|
||||
assert "Error" not in result
|
||||
assert loop._runtime_vars["key_0"] == "updated"
|
||||
assert tool._runtime_control.snapshot().scratchpad["key_0"] == "updated"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -844,7 +845,7 @@ class TestInspectTaskStatuses:
|
||||
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
||||
),
|
||||
}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="subagents._task_statuses")
|
||||
assert "abc12345" in result
|
||||
assert "read logs" in result
|
||||
@@ -865,7 +866,7 @@ class TestInspectTaskStatuses:
|
||||
stop_reason="completed",
|
||||
)
|
||||
loop.subagents._task_statuses = {"xyz": status}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
|
||||
assert "search code" in result
|
||||
assert "completed" in result
|
||||
@@ -879,7 +880,10 @@ class TestReadOnlyMode:
|
||||
|
||||
def _make_readonly_tool(self):
|
||||
loop = _make_mock_loop()
|
||||
return MyTool(runtime_state=loop, modify_allowed=False)
|
||||
return MyTool(
|
||||
runtime_control=AgentRuntimeControl(loop),
|
||||
modify_allowed=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_allowed_in_readonly(self):
|
||||
@@ -904,13 +908,13 @@ class TestReadOnlyMode:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime vars check fallback (Fix #1: cross-turn memory)
|
||||
# scratchpad inspection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRuntimeVarsInspectFallback:
|
||||
class TestScratchpadInspection:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_runtime_var_after_modify(self):
|
||||
async def test_inspect_scratchpad_value_after_modify(self):
|
||||
"""Design doc scenario: set then check should return the value."""
|
||||
tool = _make_tool()
|
||||
await tool.execute(action="set", key="user_prefers_concise", value=True)
|
||||
@@ -918,14 +922,14 @@ class TestRuntimeVarsInspectFallback:
|
||||
assert "True" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_runtime_var_string(self):
|
||||
async def test_inspect_scratchpad_string(self):
|
||||
tool = _make_tool()
|
||||
await tool.execute(action="set", key="current_project", value="nanobot")
|
||||
result = await tool.execute(action="check", key="current_project")
|
||||
assert "nanobot" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_runtime_var_dict(self):
|
||||
async def test_inspect_scratchpad_dict(self):
|
||||
tool = _make_tool()
|
||||
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
|
||||
result = await tool.execute(action="check", key="task_meta")
|
||||
@@ -958,7 +962,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.some_config = MagicMock()
|
||||
loop.some_config.password = "hunter2"
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="some_config.password")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -967,7 +971,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.vault = MagicMock()
|
||||
loop.vault.secret = "classified"
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="vault.secret")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -976,7 +980,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.auth_data = MagicMock()
|
||||
loop.auth_data.token = "jwt-payload"
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="auth_data.token")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -992,7 +996,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
async def test_modify_password_blocked(self):
|
||||
loop = _make_mock_loop()
|
||||
loop.some_config = MagicMock()
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="set", key="some_config.password", value="evil")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -1083,8 +1087,8 @@ class TestSecurityAttributeProtection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_model_presets_dotpath_blocked(self):
|
||||
"""The config-derived model preset catalog is inspectable but not mutable."""
|
||||
presets = {"fast": {"model": "fast-model"}}
|
||||
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
||||
presets = {"fast": ModelPresetConfig(model="fast-model")}
|
||||
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
||||
|
||||
result = await tool.execute(
|
||||
action="set",
|
||||
@@ -1093,14 +1097,14 @@ class TestSecurityAttributeProtection:
|
||||
)
|
||||
|
||||
assert "read-only" in result
|
||||
assert presets == {"fast": {"model": "fast-model"}}
|
||||
assert presets == {"fast": ModelPresetConfig(model="fast-model")}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_read_only_model_preset_dotpath(self):
|
||||
presets = MappingProxyType({
|
||||
"fast": ModelPresetConfig(model="fast-model"),
|
||||
})
|
||||
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
||||
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
||||
|
||||
result = await tool.execute(action="check", key="model_presets.fast.model")
|
||||
|
||||
@@ -1150,7 +1154,8 @@ class TestLastUsageInSummary:
|
||||
async def test_last_usage_not_shown_when_empty(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._last_usage = {}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
loop.last_usage = loop._last_usage
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check")
|
||||
assert "_last_usage" not in result
|
||||
|
||||
|
||||
@@ -4,23 +4,23 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
|
||||
loop = MagicMock()
|
||||
loop.max_iterations = 40
|
||||
loop._runtime_vars = {}
|
||||
loop.subagents = MagicMock()
|
||||
loop.subagents.max_iterations = loop.max_iterations
|
||||
|
||||
def _sync_subagent_runtime_limits() -> None:
|
||||
loop.subagents.max_iterations = loop.max_iterations
|
||||
|
||||
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
|
||||
|
||||
tool = MyTool(runtime_state=loop)
|
||||
async def test_my_tool_max_iterations_syncs_subagent_limit(tmp_path) -> None:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
max_iterations=40,
|
||||
)
|
||||
tool = MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
|
||||
|
||||
@@ -651,6 +651,7 @@ def test_plugin_setup_contract_drives_save_and_validation(
|
||||
from nanobot.channels.validation import validate_channel_config
|
||||
from nanobot.config import loader
|
||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
@@ -660,6 +661,7 @@ def test_plugin_setup_contract_drives_save_and_validation(
|
||||
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
|
||||
)
|
||||
router = object.__new__(WebUISettingsRouter)
|
||||
router.settings = WebUISettingsServices.create(config_path)
|
||||
|
||||
saved = router._save_channel_config_values(
|
||||
"setupplugin",
|
||||
@@ -738,6 +740,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
||||
from nanobot.config import loader
|
||||
from nanobot.webui.settings_api import WebUISettingsError
|
||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
@@ -756,6 +759,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
||||
before = config_path.read_text(encoding="utf-8")
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
router = object.__new__(WebUISettingsRouter)
|
||||
router.settings = WebUISettingsServices.create(config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
||||
router._save_channel_config_values(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Shared characterization cases for live and persisted WebUI projection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.webui.transcript import replay_transcript_to_ui_messages
|
||||
|
||||
_FIXTURE_PATH = (
|
||||
Path(__file__).parents[2]
|
||||
/ "webui"
|
||||
/ "src"
|
||||
/ "tests"
|
||||
/ "fixtures"
|
||||
/ "live-replay-event-projection.json"
|
||||
)
|
||||
_SEMANTIC_MESSAGE_FIELDS = (
|
||||
"role",
|
||||
"content",
|
||||
"kind",
|
||||
"traces",
|
||||
"toolEvents",
|
||||
"fileEdits",
|
||||
"images",
|
||||
"media",
|
||||
"cliApps",
|
||||
"mcpPresets",
|
||||
"sessionMentions",
|
||||
"reasoning",
|
||||
"latencyMs",
|
||||
"source",
|
||||
"turnId",
|
||||
"turnPhase",
|
||||
"turnSeq",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_projection(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
segment_aliases: dict[str, str] = {}
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
row = {
|
||||
field: message[field]
|
||||
for field in _SEMANTIC_MESSAGE_FIELDS
|
||||
if field in message and message[field] is not None
|
||||
}
|
||||
segment_id = message.get("activitySegmentId")
|
||||
if isinstance(segment_id, str) and segment_id:
|
||||
row["activitySegmentId"] = segment_aliases.setdefault(
|
||||
segment_id,
|
||||
f"segment-{len(segment_aliases) + 1}",
|
||||
)
|
||||
normalized.append(row)
|
||||
return normalized
|
||||
|
||||
|
||||
def test_replay_matches_shared_live_projection_before_canonical_revision_migration() -> None:
|
||||
"""Lock the known-equivalent subset without defining the future snapshot protocol."""
|
||||
fixture = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
for case in fixture["cases"]:
|
||||
actual = replay_transcript_to_ui_messages(case["transcript"])
|
||||
assert _normalize_projection(actual) == case["expected"], case["name"]
|
||||
@@ -12,7 +12,6 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_clear_webui_oauth_flows,
|
||||
_docs_version,
|
||||
_model_catalog_kind,
|
||||
_oauth_provider_status,
|
||||
@@ -36,11 +35,17 @@ from nanobot.webui.settings_api import (
|
||||
update_transcription_settings,
|
||||
update_web_search_settings,
|
||||
)
|
||||
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
||||
|
||||
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_flows() -> WebUIOAuthFlowRegistry:
|
||||
return WebUIOAuthFlowRegistry()
|
||||
|
||||
|
||||
def test_settings_payload_propagates_preset_resolution_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -1490,6 +1495,7 @@ def test_xai_grok_status_accepts_refreshable_login(
|
||||
def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
config_path = tmp_path / "config.json"
|
||||
@@ -1522,7 +1528,10 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
fake_start,
|
||||
)
|
||||
|
||||
payload = login_oauth_provider({"provider": ["openai-codex"]})
|
||||
payload = login_oauth_provider(
|
||||
{"provider": ["openai-codex"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"proxy": proxy,
|
||||
@@ -1548,15 +1557,17 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api.settings_payload",
|
||||
lambda: {"settings": "ready"},
|
||||
lambda **_kwargs: {"settings": "ready"},
|
||||
)
|
||||
|
||||
pending = complete_oauth_provider(
|
||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
completed = complete_oauth_provider(
|
||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert pending == {
|
||||
@@ -1574,6 +1585,7 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
@@ -1599,10 +1611,11 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||
|
||||
try:
|
||||
payload = login_oauth_provider(
|
||||
{"provider": ["openai-codex"], "remote_browser": ["true"]}
|
||||
{"provider": ["openai-codex"], "remote_browser": ["true"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
finally:
|
||||
_clear_webui_oauth_flows("openai_codex")
|
||||
oauth_flows.clear("openai_codex")
|
||||
|
||||
assert payload["completion_input"] == "callback_url"
|
||||
assert captured["open_browser"] is False
|
||||
@@ -1611,6 +1624,7 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||
|
||||
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
real_import = builtins.__import__
|
||||
|
||||
@@ -1622,7 +1636,10 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
with pytest.raises(WebUISettingsError) as exc:
|
||||
login_oauth_provider({"provider": ["openai-codex"]})
|
||||
login_oauth_provider(
|
||||
{"provider": ["openai-codex"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert str(exc.value) == (
|
||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||
@@ -1632,6 +1649,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||
|
||||
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
real_import = builtins.__import__
|
||||
|
||||
@@ -1643,7 +1661,10 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
with pytest.raises(WebUISettingsError) as exc:
|
||||
login_oauth_provider({"provider": ["github-copilot"]})
|
||||
login_oauth_provider(
|
||||
{"provider": ["github-copilot"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert str(exc.value) == (
|
||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||
@@ -1654,6 +1675,7 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
config_path = tmp_path / "config.json"
|
||||
@@ -1675,7 +1697,10 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||
|
||||
payload = login_oauth_provider({"provider": ["xai-grok"]})
|
||||
payload = login_oauth_provider(
|
||||
{"provider": ["xai-grok"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert captured["proxy"] == proxy
|
||||
assert captured["timeout_s"] == 600
|
||||
@@ -1699,15 +1724,17 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api.settings_payload",
|
||||
lambda: {"settings": "ready"},
|
||||
lambda **_kwargs: {"settings": "ready"},
|
||||
)
|
||||
|
||||
pending = complete_oauth_provider(
|
||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
completed = complete_oauth_provider(
|
||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||
"secret",
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert pending == {
|
||||
@@ -1722,6 +1749,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
@@ -1734,7 +1762,10 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||
|
||||
with pytest.raises(WebUISettingsError) as exc:
|
||||
login_oauth_provider({"provider": ["xai-grok"]})
|
||||
login_oauth_provider(
|
||||
{"provider": ["xai-grok"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert exc.value.status == 502
|
||||
assert str(exc.value) == (
|
||||
@@ -1746,6 +1777,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||
def test_xai_grok_logout_removes_token_through_shared_lock(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
@@ -1759,7 +1791,10 @@ def test_xai_grok_logout_removes_token_through_shared_lock(
|
||||
lambda: token_path,
|
||||
)
|
||||
|
||||
logout_oauth_provider({"provider": ["xai-grok"]})
|
||||
logout_oauth_provider(
|
||||
{"provider": ["xai-grok"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert not token_path.exists()
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ from urllib.parse import parse_qs, urlsplit
|
||||
import pytest
|
||||
from websockets.datastructures import Headers
|
||||
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.webui.http_utils import http_json_response
|
||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
|
||||
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||
return WebUISettingsRouter(
|
||||
settings=WebUISettingsServices.create(get_config_path()),
|
||||
bus=SimpleNamespace(),
|
||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
||||
check_api_token=lambda _request: authorized,
|
||||
@@ -28,27 +31,39 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||
)
|
||||
|
||||
|
||||
def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace:
|
||||
request = SimpleNamespace(path=path, headers=Headers())
|
||||
request._nanobot_webui_mutation_request = True
|
||||
request._nanobot_webui_mutation_payload = payload
|
||||
request._nanobot_trusted_proxy_authenticated = True
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "header_name", "authorization_response"),
|
||||
("provider", "authorization_response"),
|
||||
[
|
||||
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
|
||||
("xai_grok", "secret"),
|
||||
(
|
||||
"openai_codex",
|
||||
"X-Nanobot-OAuth-Callback",
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_completion_reads_private_response_header(
|
||||
async def test_oauth_completion_reads_websocket_payload(
|
||||
monkeypatch,
|
||||
provider: str,
|
||||
header_name: str,
|
||||
authorization_response: str,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def complete(query, authorization_response=None):
|
||||
def complete(
|
||||
query,
|
||||
authorization_response=None,
|
||||
*,
|
||||
oauth_flows=None,
|
||||
config_path=None,
|
||||
):
|
||||
captured.update(query=query, authorization_response=authorization_response)
|
||||
return {
|
||||
"status": "pending",
|
||||
@@ -58,19 +73,13 @@ async def test_oauth_completion_reads_private_response_header(
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
|
||||
router = _router()
|
||||
request = SimpleNamespace(
|
||||
path=(
|
||||
"/api/settings/provider/oauth-login/complete"
|
||||
f"?provider={provider}&flow_id=flow-123"
|
||||
),
|
||||
headers=Headers(
|
||||
[
|
||||
(
|
||||
header_name,
|
||||
authorization_response,
|
||||
)
|
||||
]
|
||||
),
|
||||
request = _mutation_request(
|
||||
"/api/settings/provider/oauth-login/complete",
|
||||
{
|
||||
"provider": provider,
|
||||
"flow_id": "flow-123",
|
||||
"authorization_response": authorization_response,
|
||||
},
|
||||
)
|
||||
|
||||
response = await router.dispatch(
|
||||
@@ -90,28 +99,29 @@ async def test_oauth_completion_reads_private_response_header(
|
||||
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
||||
"authorization_response": authorization_response,
|
||||
}
|
||||
assert authorization_response not in request.path
|
||||
assert request.path == "/api/settings/provider/oauth-login/complete"
|
||||
assert not request.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_path", "route_path", "function_name", "expected_query"),
|
||||
("route_path", "function_name", "payload", "expected_query"),
|
||||
[
|
||||
(
|
||||
"/api/settings/model-configurations/delete?name=spare",
|
||||
"/api/settings/model-configurations/delete",
|
||||
"delete_model_configuration",
|
||||
{"name": "spare"},
|
||||
{"name": ["spare"]},
|
||||
),
|
||||
(
|
||||
"/api/settings/model-configurations/migrate",
|
||||
"/api/settings/model-configurations/migrate",
|
||||
"migrate_model_configurations",
|
||||
{},
|
||||
{},
|
||||
),
|
||||
(
|
||||
"/api/settings/model-call-order/update?order=%5B%22backup%22%5D",
|
||||
"/api/settings/model-call-order/update",
|
||||
"update_model_call_order",
|
||||
{"order": ["backup"]},
|
||||
{"order": ['["backup"]']},
|
||||
),
|
||||
],
|
||||
@@ -119,19 +129,19 @@ async def test_oauth_completion_reads_private_response_header(
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_preset_mutation_routes(
|
||||
monkeypatch,
|
||||
request_path: str,
|
||||
route_path: str,
|
||||
function_name: str,
|
||||
payload: dict[str, object],
|
||||
expected_query: dict[str, list[str]],
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def mutate(query):
|
||||
def mutate(query, *, config_path=None):
|
||||
captured["query"] = query
|
||||
return {"routed": function_name}
|
||||
|
||||
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
|
||||
request = SimpleNamespace(path=request_path, headers=Headers())
|
||||
request = _mutation_request(route_path, payload)
|
||||
|
||||
response = await _router().dispatch(None, request, route_path)
|
||||
|
||||
@@ -141,6 +151,23 @@ async def test_model_preset_mutation_routes(
|
||||
assert captured["query"] == expected_query
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_get_mutation_route_is_method_not_allowed() -> None:
|
||||
path = "/api/settings/provider/update"
|
||||
request = SimpleNamespace(
|
||||
path=f"{path}?provider=openrouter&api_key=must-not-run",
|
||||
headers=Headers(),
|
||||
)
|
||||
|
||||
response = await _router().dispatch(None, request, path)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 405
|
||||
assert json.loads(response.body) == {
|
||||
"error": "WebUI mutations require an authenticated WebSocket"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("update_info", "expected"),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.gateway_services import build_gateway_services
|
||||
from nanobot.webui.settings_api import settings_payload, update_agent_settings, update_api_settings
|
||||
from nanobot.webui.settings_services import (
|
||||
WebUIOAuthFlowRegistry,
|
||||
WebUISettingsServices,
|
||||
)
|
||||
|
||||
|
||||
class _Flow:
|
||||
def __init__(self, *, expired: bool = False) -> None:
|
||||
self.expired = expired
|
||||
self.cancel_count = 0
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancel_count += 1
|
||||
|
||||
|
||||
def _gateway(config_path: Path, workspace: Path):
|
||||
return build_gateway_services(
|
||||
config=WebSocketConfig(),
|
||||
bus=MagicMock(),
|
||||
session_manager=None,
|
||||
static_dist_path=None,
|
||||
workspace_path=workspace,
|
||||
default_restrict_to_workspace=False,
|
||||
config_path=config_path,
|
||||
runtime_model_name=None,
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities_overrides=None,
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_settings_services_isolate_config_paths_and_oauth_flows(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first_path = tmp_path / "first" / "config.json"
|
||||
second_path = tmp_path / "second" / "config.json"
|
||||
first_config = Config()
|
||||
first_config.api.host = "127.0.0.2"
|
||||
second_config = Config()
|
||||
second_config.api.host = "127.0.0.3"
|
||||
save_config(first_config, first_path)
|
||||
save_config(second_config, second_path)
|
||||
|
||||
first = _gateway(first_path, tmp_path / "first-workspace")
|
||||
second = _gateway(second_path, tmp_path / "second-workspace")
|
||||
|
||||
assert first.settings.config.path == first_path.resolve()
|
||||
assert second.settings.config.path == second_path.resolve()
|
||||
assert first.http.settings_routes.settings is first.settings
|
||||
assert second.http.settings_routes.settings is second.settings
|
||||
assert first.settings.config.load().api.host == "127.0.0.2"
|
||||
assert second.settings.config.load().api.host == "127.0.0.3"
|
||||
assert first.settings.read(settings_payload)["api"]["host"] == "127.0.0.2"
|
||||
assert second.settings.read(settings_payload)["api"]["host"] == "127.0.0.3"
|
||||
|
||||
first.settings.mutate(update_api_settings, {"port": ["19001"]})
|
||||
assert load_config(first_path).api.port == 19001
|
||||
assert load_config(second_path).api.port != 19001
|
||||
|
||||
first_flow = _Flow()
|
||||
second_flow = _Flow()
|
||||
first.settings.oauth_flows.register("openai_codex", "same-id", first_flow)
|
||||
second.settings.oauth_flows.register("openai_codex", "same-id", second_flow)
|
||||
|
||||
assert first.settings.oauth_flows.get("openai_codex", "same-id") is first_flow
|
||||
assert second.settings.oauth_flows.get("openai_codex", "same-id") is second_flow
|
||||
first.settings.oauth_flows.clear("openai_codex")
|
||||
assert first_flow.cancel_count == 1
|
||||
assert second_flow.cancel_count == 0
|
||||
|
||||
|
||||
def test_settings_mutations_serialize_read_modify_write(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
services = WebUISettingsServices.create(config_path)
|
||||
first_loaded = threading.Event()
|
||||
release_first = threading.Event()
|
||||
second_started = threading.Event()
|
||||
second_loaded = threading.Event()
|
||||
errors: list[BaseException] = []
|
||||
|
||||
from nanobot.webui import settings_api
|
||||
|
||||
original_load = settings_api._load_settings_config
|
||||
|
||||
def controlled_load(path: Path | None) -> Config:
|
||||
config = original_load(path)
|
||||
if threading.current_thread().name == "settings-first":
|
||||
first_loaded.set()
|
||||
if not release_first.wait(timeout=2):
|
||||
raise TimeoutError("timed out waiting to release first settings mutation")
|
||||
elif threading.current_thread().name == "settings-second":
|
||||
second_loaded.set()
|
||||
return config
|
||||
|
||||
monkeypatch.setattr(settings_api, "_load_settings_config", controlled_load)
|
||||
|
||||
def run_first() -> None:
|
||||
try:
|
||||
services.mutate(update_agent_settings, {"timezone": ["Asia/Tokyo"]})
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
|
||||
errors.append(exc)
|
||||
|
||||
def run_second() -> None:
|
||||
try:
|
||||
second_started.set()
|
||||
services.mutate(update_api_settings, {"host": ["127.0.0.9"]})
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
|
||||
errors.append(exc)
|
||||
|
||||
first = threading.Thread(target=run_first, name="settings-first")
|
||||
second = threading.Thread(target=run_second, name="settings-second")
|
||||
first.start()
|
||||
assert first_loaded.wait(timeout=2)
|
||||
second.start()
|
||||
assert second_started.wait(timeout=2)
|
||||
assert not second_loaded.wait(timeout=0.1)
|
||||
release_first.set()
|
||||
first.join(timeout=2)
|
||||
second.join(timeout=2)
|
||||
|
||||
assert not first.is_alive()
|
||||
assert not second.is_alive()
|
||||
assert not errors
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.timezone == "Asia/Tokyo"
|
||||
assert saved.api.host == "127.0.0.9"
|
||||
|
||||
|
||||
def test_oauth_registry_preserves_expiry_capacity_completion_and_cancel() -> None:
|
||||
registry = WebUIOAuthFlowRegistry(max_flows=2)
|
||||
expired = _Flow(expired=True)
|
||||
oldest = _Flow()
|
||||
newest = _Flow()
|
||||
replacement = _Flow()
|
||||
|
||||
registry.register("openai_codex", "expired", expired)
|
||||
registry.register("openai_codex", "oldest", oldest)
|
||||
assert expired.cancel_count == 1
|
||||
assert registry.get("openai_codex", "expired") is None
|
||||
|
||||
registry.register("xai_grok", "newest", newest)
|
||||
registry.register("openai_codex", "replacement", replacement)
|
||||
assert oldest.cancel_count == 1
|
||||
assert registry.get("openai_codex", "oldest") is None
|
||||
assert registry.get("xai_grok", "newest") is newest
|
||||
assert registry.get("openai_codex", "newest") is None
|
||||
|
||||
registry.remove("xai_grok", "newest", newest, cancel=False)
|
||||
assert newest.cancel_count == 0
|
||||
assert registry.get("xai_grok", "newest") is None
|
||||
|
||||
registry.clear("openai_codex")
|
||||
assert replacement.cancel_count == 1
|
||||
assert registry.get("openai_codex", "replacement") is None
|
||||
+413
-52
@@ -8,12 +8,30 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||
import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import type { SidebarDeleteItem } from "@/components/ChatList";
|
||||
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
||||
import {
|
||||
WORKBENCH_STORAGE_KEY,
|
||||
MAX_WORKBENCH_PANES,
|
||||
addWorkbenchPane,
|
||||
attachWorkbenchPane,
|
||||
detachWorkbenchPane,
|
||||
ensureWorkbenchTab,
|
||||
focusWorkbenchPane,
|
||||
parseWorkbenchState,
|
||||
promoteWorkbenchPane,
|
||||
reconcileWorkbench,
|
||||
setWorkbenchLayout,
|
||||
workbenchChildPaneKeys,
|
||||
workbenchTab,
|
||||
type WorkbenchState,
|
||||
} from "@/components/workbench/workbench-model";
|
||||
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
@@ -120,6 +138,14 @@ const RenameChatDialog = lazy(async () => {
|
||||
return { default: module.RenameChatDialog };
|
||||
});
|
||||
|
||||
function readWorkbenchState(): WorkbenchState {
|
||||
try {
|
||||
return parseWorkbenchState(window.localStorage.getItem(WORKBENCH_STORAGE_KEY));
|
||||
} catch {
|
||||
return parseWorkbenchState(null);
|
||||
}
|
||||
}
|
||||
|
||||
function SurfaceLoadingFallback() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -316,13 +342,23 @@ function AuthForm({
|
||||
onSecret: (secret: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState("");
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [validationError, setValidationError] = useState<"required" | "invalid" | null>(
|
||||
failed ? "invalid" : null,
|
||||
);
|
||||
const errorMessage = validationError ? t(`app.auth.${validationError}`) : null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const secret = value.trim();
|
||||
if (!secret) return;
|
||||
if (!secret) {
|
||||
setValidationError("required");
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
onSecret(secret);
|
||||
};
|
||||
@@ -333,27 +369,57 @@ function AuthForm({
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full max-w-sm flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
|
||||
</div>
|
||||
{failed && (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
{t("app.auth.invalid")}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-sm font-medium text-foreground">
|
||||
<label htmlFor="webui-access-password">{t("app.auth.label")}</label>
|
||||
</h1>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("app.auth.placeholder")}
|
||||
ref={inputRef}
|
||||
id="webui-access-password"
|
||||
name="webui-access-password"
|
||||
type={passwordVisible ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
disabled={submitting}
|
||||
aria-invalid={validationError ? true : undefined}
|
||||
aria-describedby={validationError ? "webui-auth-error" : undefined}
|
||||
className="pr-10 focus-visible:ring-1 focus-visible:ring-ring/30 focus-visible:ring-offset-0"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={submitting}
|
||||
aria-label={t(
|
||||
passwordVisible ? "app.auth.hidePassword" : "app.auth.showPassword",
|
||||
)}
|
||||
aria-controls="webui-access-password"
|
||||
onClick={() => setPasswordVisible((visible) => !visible)}
|
||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{passwordVisible ? (
|
||||
<EyeOff className="h-4 w-4" strokeWidth={1.75} aria-hidden />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" strokeWidth={1.75} aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<p id="webui-auth-error" role="alert" className="text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!value.trim() || submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("app.auth.submit")}
|
||||
</Button>
|
||||
@@ -994,9 +1060,18 @@ function Shell({
|
||||
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
|
||||
const [workbenchState, setWorkbenchState] = useState(readWorkbenchState);
|
||||
const [creatingPane, setCreatingPane] = useState(false);
|
||||
const childPaneKeys = useMemo(
|
||||
() => workbenchChildPaneKeys(workbenchState),
|
||||
[workbenchState],
|
||||
);
|
||||
const topicSessions = useMemo(
|
||||
() => sessions.filter((session) => !childPaneKeys.has(session.key)),
|
||||
[childPaneKeys, sessions],
|
||||
);
|
||||
const [pendingDelete, setPendingDelete] = useState<{
|
||||
key: string;
|
||||
label: string;
|
||||
items: SidebarDeleteItem[];
|
||||
automations?: SessionAutomationJob[];
|
||||
} | null>(null);
|
||||
const [pendingRename, setPendingRename] = useState<{
|
||||
@@ -1117,6 +1192,17 @@ function Shell({
|
||||
}
|
||||
}, [hostSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
WORKBENCH_STORAGE_KEY,
|
||||
JSON.stringify(workbenchState),
|
||||
);
|
||||
} catch {
|
||||
// ignore storage errors (private mode, etc.)
|
||||
}
|
||||
}, [workbenchState]);
|
||||
|
||||
useEffect(() => {
|
||||
writeSessionUpdateChatIds(updatedChatIds);
|
||||
}, [updatedChatIds]);
|
||||
@@ -1180,9 +1266,19 @@ function Shell({
|
||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey, temporarySessions]);
|
||||
const activeTabState = useMemo(() => (
|
||||
activeKey && !temporarySessions[activeKey]
|
||||
? workbenchTab(workbenchState, activeKey)
|
||||
: null
|
||||
), [activeKey, temporarySessions, workbenchState]);
|
||||
const activePaneSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeTabState) return activeSession;
|
||||
return sessions.find((session) => session.key === activeTabState.activePaneKey)
|
||||
?? activeSession;
|
||||
}, [activeSession, activeTabState, sessions]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const activeChatId = activeSession?.chatId ?? null;
|
||||
const activeChatId = activePaneSession?.chatId ?? null;
|
||||
useEffect(() => {
|
||||
activeChatIdRef.current = activeChatId;
|
||||
if (!activeChatId) return;
|
||||
@@ -1202,13 +1298,13 @@ function Shell({
|
||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||
return workspaceOverrides[activeChatId];
|
||||
}
|
||||
if (activeSession?.workspaceScope) {
|
||||
return activeSession.workspaceScope;
|
||||
if (activePaneSession?.workspaceScope) {
|
||||
return activePaneSession.workspaceScope;
|
||||
}
|
||||
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
||||
}, [
|
||||
activeChatId,
|
||||
activeSession?.workspaceScope,
|
||||
activePaneSession?.workspaceScope,
|
||||
draftWorkspaceScope,
|
||||
temporaryChatRequested,
|
||||
workspaceOverrides,
|
||||
@@ -1244,6 +1340,18 @@ function Shell({
|
||||
});
|
||||
}, [loading, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const validKeys = new Set(sessions.map((session) => session.key));
|
||||
setWorkbenchState((current) => {
|
||||
const reconciled = reconcileWorkbench(current, validKeys);
|
||||
if (!activeKey || temporarySessions[activeKey] || !validKeys.has(activeKey)) {
|
||||
return reconciled;
|
||||
}
|
||||
return ensureWorkbenchTab(reconciled, activeKey);
|
||||
});
|
||||
}, [activeKey, loading, sessions, temporarySessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
||||
@@ -1748,7 +1856,7 @@ function Shell({
|
||||
});
|
||||
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));
|
||||
const next = topicSessions.find((session) => !archived.has(session.key));
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: next?.key ?? null,
|
||||
@@ -1756,7 +1864,7 @@ function Shell({
|
||||
});
|
||||
}
|
||||
},
|
||||
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
|
||||
[activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
|
||||
);
|
||||
|
||||
const onReorderSessions = useCallback(
|
||||
@@ -1785,6 +1893,47 @@ function Shell({
|
||||
setSessionSearchOpen(true);
|
||||
}, []);
|
||||
|
||||
const onAddPane = useCallback(async () => {
|
||||
const tabKey = activeKey;
|
||||
if (
|
||||
!tabKey
|
||||
|| !activeSession
|
||||
|| creatingPane
|
||||
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|
||||
|| temporarySessionsRef.current[tabKey]
|
||||
) return;
|
||||
setMobileSidebarOpen(false);
|
||||
setSessionSearchOpen(false);
|
||||
setCreatingPane(true);
|
||||
try {
|
||||
const scope = activeWorkspaceScope;
|
||||
const chatId = await createChat(scope);
|
||||
const paneKey = `websocket:${chatId}`;
|
||||
setWorkbenchState((current) => addWorkbenchPane(current, tabKey, paneKey));
|
||||
if (scope) {
|
||||
setWorkspaceOverrides((current) => ({
|
||||
...current,
|
||||
[chatId]: normalizeWorkspaceScope(scope),
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create pane", error);
|
||||
if (error instanceof Error && error.message.startsWith("workspace_scope_rejected:")) {
|
||||
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
||||
}
|
||||
} finally {
|
||||
setCreatingPane(false);
|
||||
}
|
||||
}, [
|
||||
activeKey,
|
||||
activeSession,
|
||||
activeTabState,
|
||||
activeWorkspaceScope,
|
||||
createChat,
|
||||
creatingPane,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
@@ -1862,15 +2011,15 @@ function Shell({
|
||||
setMobileSidebarOpen(false);
|
||||
const nextKey = (() => {
|
||||
if (!activeKey) return null;
|
||||
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
||||
return sessions[0]?.key ?? null;
|
||||
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
||||
return topicSessions[0]?.key ?? null;
|
||||
})();
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: nextKey,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
}, [activeKey, navigate, sessions]);
|
||||
}, [activeKey, navigate, topicSessions]);
|
||||
|
||||
const onRestart = useCallback(() => {
|
||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||
@@ -1977,31 +2126,43 @@ function Shell({
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useDeferredTitleRefresh(
|
||||
temporaryChatActive ? null : activeSession,
|
||||
temporaryChatActive ? null : activePaneSession,
|
||||
refresh,
|
||||
);
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
const key = pendingDelete.key;
|
||||
const items = pendingDelete.items;
|
||||
const deletingKeys = new Set(items.map((item) => item.key));
|
||||
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||
const deletingActive = activeKey === key;
|
||||
const currentIndex = sessions.findIndex((s) => s.key === key);
|
||||
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
||||
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
||||
const fallbackKey = deletingActive
|
||||
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
|
||||
? (
|
||||
topicSessions.slice(currentIndex + 1).find((session) => (
|
||||
!deletingKeys.has(session.key)
|
||||
))?.key
|
||||
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
||||
!deletingKeys.has(session.key)
|
||||
))?.key
|
||||
?? null
|
||||
)
|
||||
: activeKey;
|
||||
try {
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
const result = await deleteChat(
|
||||
key,
|
||||
item.key,
|
||||
hasAutomations ? { deleteAutomations: true } : undefined,
|
||||
);
|
||||
if (result.blocked_by_automations) {
|
||||
setPendingDelete({
|
||||
...pendingDelete,
|
||||
items: items.slice(index),
|
||||
automations: result.automations ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPendingDelete(null);
|
||||
if (deletingActive) {
|
||||
navigate({
|
||||
@@ -2013,18 +2174,24 @@ function Shell({
|
||||
} catch (e) {
|
||||
console.error("Failed to delete session", e);
|
||||
}
|
||||
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
|
||||
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
||||
|
||||
const onRequestDelete = useCallback(async (key: string, label: string) => {
|
||||
let automations: SessionAutomationJob[] = [];
|
||||
try {
|
||||
automations = await getSessionAutomations(key);
|
||||
} catch {
|
||||
// Delete remains protected by the backend block; prefetch only improves the first prompt.
|
||||
}
|
||||
setPendingDelete({ key, label, automations });
|
||||
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
||||
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
||||
if (uniqueItems.length === 0) return;
|
||||
const automationResults = await Promise.allSettled(
|
||||
uniqueItems.map((item) => getSessionAutomations(item.key)),
|
||||
);
|
||||
const automations = automationResults.flatMap((result) => (
|
||||
result.status === "fulfilled" ? result.value : []
|
||||
));
|
||||
setPendingDelete({ items: uniqueItems, automations });
|
||||
}, [getSessionAutomations]);
|
||||
|
||||
const onRequestDelete = useCallback((key: string, label: string) => {
|
||||
void onRequestDeleteMany([{ key, label }]);
|
||||
}, [onRequestDeleteMany]);
|
||||
|
||||
const visiblePairingRequests = useMemo(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
@@ -2041,7 +2208,7 @@ function Shell({
|
||||
setPairingBusyCode(code);
|
||||
setPairingError(null);
|
||||
try {
|
||||
const payload = await runPairingAction(getToken(), action, code);
|
||||
const payload = await runPairingAction(client, action, code);
|
||||
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||
setSnoozedPairingCodes((current) => {
|
||||
if (!current.has(code)) return current;
|
||||
@@ -2056,7 +2223,7 @@ function Shell({
|
||||
setPairingBusyCode(null);
|
||||
}
|
||||
},
|
||||
[getToken, refreshPairingRequests],
|
||||
[client, refreshPairingRequests],
|
||||
);
|
||||
|
||||
const onDismissPairingRequest = useCallback((code: string) => {
|
||||
@@ -2069,13 +2236,117 @@ function Shell({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const titleForSession = useCallback((session: ChatSummary) => (
|
||||
sidebarState.title_overrides[session.key]
|
||||
|| session.title
|
||||
|| deriveTitle(session.preview, t("chat.newChat"))
|
||||
), [sidebarState.title_overrides, t]);
|
||||
|
||||
const headerTitle = temporaryChatActive
|
||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
||||
: activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
activeSession.title ||
|
||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||
? titleForSession(activeSession)
|
||||
: t("app.brand");
|
||||
const workbenchPaneSessions = useMemo(() => {
|
||||
if (!activeTabState) return [];
|
||||
const byKey = new Map(sessions.map((session) => [session.key, session]));
|
||||
return activeTabState.paneKeys
|
||||
.map((key) => byKey.get(key))
|
||||
.filter((session): session is ChatSummary => session !== undefined);
|
||||
}, [activeTabState, sessions]);
|
||||
const paneChromeEnabled = Boolean(
|
||||
activeKey && activeSession && !temporaryChatActive && activeTabState,
|
||||
);
|
||||
const renderedWorkbenchPanes = useMemo(() => {
|
||||
if (paneChromeEnabled && activeKey) {
|
||||
return workbenchPaneSessions.map((session) => ({
|
||||
key: session.key,
|
||||
reactKey: session.key === activeKey ? "tab-root" : `pane:${session.key}`,
|
||||
title: titleForSession(session),
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
key: activeKey ?? "new-topic",
|
||||
reactKey: "tab-root",
|
||||
title: headerTitle,
|
||||
}];
|
||||
}, [activeKey, headerTitle, paneChromeEnabled, titleForSession, workbenchPaneSessions]);
|
||||
const renderedActivePaneKey = paneChromeEnabled && activeTabState
|
||||
? activeTabState.activePaneKey
|
||||
: renderedWorkbenchPanes[0].key;
|
||||
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
|
||||
? activeTabState.layout
|
||||
: "columns";
|
||||
const sidebarPaneGroups = useMemo(() => {
|
||||
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
||||
return Object.fromEntries(topicSessions.map((topic) => {
|
||||
const tab = workbenchTab(workbenchState, topic.key);
|
||||
const panes = tab.paneKeys
|
||||
.map((key) => sessionsByKey.get(key))
|
||||
.filter((session): session is ChatSummary => session !== undefined)
|
||||
.map((session) => ({
|
||||
key: session.key,
|
||||
chatId: session.chatId,
|
||||
title: titleForSession(session),
|
||||
}));
|
||||
return [topic.key, {
|
||||
topicKey: topic.key,
|
||||
activePaneKey: tab.activePaneKey,
|
||||
panes,
|
||||
}];
|
||||
}));
|
||||
}, [sessions, titleForSession, topicSessions, workbenchState]);
|
||||
const attachableTabKeys = useMemo(() => (
|
||||
topicSessions
|
||||
.filter((session) => workbenchTab(workbenchState, session.key).paneKeys.length === 1)
|
||||
.map((session) => session.key)
|
||||
), [topicSessions, workbenchState]);
|
||||
const paneAcceptingTabKeys = useMemo(() => (
|
||||
topicSessions
|
||||
.filter((session) => (
|
||||
workbenchTab(workbenchState, session.key).paneKeys.length < MAX_WORKBENCH_PANES
|
||||
))
|
||||
.map((session) => session.key)
|
||||
), [topicSessions, workbenchState]);
|
||||
const activePaneLimitReached = Boolean(
|
||||
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
|
||||
);
|
||||
|
||||
const onActivateWorkbenchPane = useCallback((paneKey: string) => {
|
||||
if (!activeKey) return;
|
||||
setWorkbenchState((current) => focusWorkbenchPane(current, activeKey, paneKey));
|
||||
}, [activeKey]);
|
||||
|
||||
const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => {
|
||||
setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey));
|
||||
if (activeKey !== tabKey) {
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: tabKey,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
}
|
||||
}, [activeKey, navigate]);
|
||||
|
||||
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
||||
setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
|
||||
}, []);
|
||||
|
||||
const onPromoteWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
||||
setWorkbenchState((current) => promoteWorkbenchPane(current, tabKey, paneKey));
|
||||
}, []);
|
||||
|
||||
const onAttachWorkbenchPane = useCallback((paneKey: string, tabKey: string) => {
|
||||
if (paneKey === tabKey) return;
|
||||
setWorkbenchState((current) => attachWorkbenchPane(current, tabKey, paneKey));
|
||||
if (activeKey === paneKey) {
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: tabKey,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
}
|
||||
}, [activeKey, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "settings") {
|
||||
@@ -2108,7 +2379,7 @@ function Shell({
|
||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||
|
||||
const sidebarProps = {
|
||||
sessions,
|
||||
sessions: topicSessions,
|
||||
temporarySessions: temporarySessionList,
|
||||
activeKey: view === "chat" ? activeKey : null,
|
||||
loading,
|
||||
@@ -2117,9 +2388,17 @@ function Shell({
|
||||
onSelect: onSelectChat,
|
||||
onCloseTemporaryChat,
|
||||
onRequestDelete,
|
||||
onRequestDeleteMany,
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
paneGroups: sidebarPaneGroups,
|
||||
onSelectPane: onSelectSidebarPane,
|
||||
onDetachPane: onDetachWorkbenchPane,
|
||||
onPromotePane: onPromoteWorkbenchPane,
|
||||
attachableTabKeys,
|
||||
paneAcceptingTabKeys,
|
||||
onAttachPane: onAttachWorkbenchPane,
|
||||
onReorderSessions,
|
||||
onToggleGroup,
|
||||
onRequestRenameProject,
|
||||
@@ -2142,7 +2421,9 @@ function Shell({
|
||||
updatedChatIds: updatedChatIdList,
|
||||
viewState: sidebarState.view,
|
||||
showArchived: sidebarState.view.show_archived,
|
||||
archivedCount: sidebarState.archived_keys.length,
|
||||
archivedCount: topicSessions.filter(
|
||||
(session) => sidebarState.archived_keys.includes(session.key),
|
||||
).length,
|
||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||
};
|
||||
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
||||
@@ -2278,7 +2559,7 @@ function Shell({
|
||||
<SessionSearchDialog
|
||||
open
|
||||
onOpenChange={setSessionSearchOpen}
|
||||
sessions={sessions}
|
||||
sessions={topicSessions}
|
||||
activeKey={activeKey}
|
||||
loading={loading}
|
||||
titleOverrides={sidebarState.title_overrides}
|
||||
@@ -2297,6 +2578,23 @@ function Shell({
|
||||
view !== "chat" && "hidden",
|
||||
)}
|
||||
>
|
||||
<PaneWorkbench
|
||||
panes={renderedWorkbenchPanes}
|
||||
activePaneKey={renderedActivePaneKey}
|
||||
layout={renderedWorkbenchLayout}
|
||||
chrome={paneChromeEnabled}
|
||||
addPaneDisabled={creatingPane || activePaneLimitReached}
|
||||
onActivatePane={onActivateWorkbenchPane}
|
||||
onAddPane={onAddPane}
|
||||
onLayoutChange={(layout) => {
|
||||
if (!activeKey) return;
|
||||
setWorkbenchState((current) => (
|
||||
setWorkbenchLayout(current, activeKey, layout)
|
||||
));
|
||||
}}
|
||||
renderPane={(pane, context) => {
|
||||
if (!paneChromeEnabled) {
|
||||
return (
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
sessions={sessions}
|
||||
@@ -2309,7 +2607,9 @@ function Shell({
|
||||
}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
||||
onCreateChat={
|
||||
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
|
||||
}
|
||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
@@ -2327,6 +2627,66 @@ function Shell({
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
skills={skills}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const paneSession = workbenchPaneSessions.find(
|
||||
(session) => session.key === pane.key,
|
||||
);
|
||||
if (!paneSession) return null;
|
||||
const paneScope = workspaceOverrides[paneSession.chatId]
|
||||
?? paneSession.workspaceScope
|
||||
?? workspaces?.default_scope
|
||||
?? null;
|
||||
const paneRunning = runningChatIds.has(paneSession.chatId);
|
||||
return (
|
||||
<ThreadShell
|
||||
session={paneSession}
|
||||
sessions={sessions}
|
||||
title={pane.title}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onForkChat={onForkChat}
|
||||
onTurnEnd={context.active ? onTurnEnd : () => void refresh()}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
hideSidebarToggle={!context.active}
|
||||
hideSidebarToggleForHostChrome={context.active}
|
||||
hostChromeTitleInset={hostSidebarCollapsed}
|
||||
hideThemeButton={!context.active}
|
||||
hideHeaderTitle
|
||||
headerActions={context.headerActions}
|
||||
headerPortalTarget={context.headerPortalTarget}
|
||||
headerActive={context.active}
|
||||
composerPortalTarget={context.composerPortalTarget}
|
||||
composerActive={context.active}
|
||||
composerInputAriaLabel={t("workbench.composerAria", {
|
||||
defaultValue: "Message {{title}}",
|
||||
title: pane.title,
|
||||
})}
|
||||
workspaceScope={paneScope}
|
||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||
workspaceControls={workspaces?.controls ?? null}
|
||||
workspaceScopeDisabled={paneRunning}
|
||||
workspaceError={context.active ? workspaceError : null}
|
||||
onWorkspaceScopeChange={(scope) => {
|
||||
if (paneRunning) return;
|
||||
const next = normalizeWorkspaceScope(scope);
|
||||
setWorkspaceError(null);
|
||||
setWorkspaceOverrides((current) => ({
|
||||
...current,
|
||||
[paneSession.chatId]: next,
|
||||
}));
|
||||
client.setWorkspaceScope(paneSession.chatId, next);
|
||||
}}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
skills={skills}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{view !== "chat" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
@@ -2358,7 +2718,8 @@ function Shell({
|
||||
<Suspense fallback={null}>
|
||||
<DeleteConfirm
|
||||
open
|
||||
title={pendingDelete.label}
|
||||
title={pendingDelete.items[0]?.label ?? ""}
|
||||
count={pendingDelete.items.length}
|
||||
automations={pendingDelete.automations}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
BringToFront,
|
||||
CornerDownRight,
|
||||
Folder,
|
||||
ListChecks,
|
||||
MessageCircleDashed,
|
||||
MoreHorizontal,
|
||||
PanelsTopLeft,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Square,
|
||||
SquareCheckBig,
|
||||
SquareMinus,
|
||||
Trash2,
|
||||
Unplug,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,6 +36,9 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -43,7 +57,13 @@ import {
|
||||
visibleSessionsForGroup,
|
||||
type ChatGroupLabels,
|
||||
} from "@/lib/chat-groups";
|
||||
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
||||
import {
|
||||
clearDraggedSession,
|
||||
writeDraggedPane,
|
||||
writeDraggedSession,
|
||||
type DraggedPane,
|
||||
} from "@/lib/session-drag";
|
||||
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
|
||||
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||
@@ -52,6 +72,21 @@ const INITIAL_VISIBLE_SESSIONS = 160;
|
||||
const VISIBLE_SESSIONS_INCREMENT = 160;
|
||||
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
|
||||
|
||||
export interface SidebarPaneGroup {
|
||||
topicKey: string;
|
||||
activePaneKey: string;
|
||||
panes: Array<{
|
||||
key: string;
|
||||
chatId: string;
|
||||
title: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SidebarDeleteItem {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ChatListProps {
|
||||
sessions: ChatSummary[];
|
||||
temporarySessions?: ChatSummary[];
|
||||
@@ -59,9 +94,17 @@ interface ChatListProps {
|
||||
onSelect: (key: string) => void;
|
||||
onCloseTemporaryChat?: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onToggleArchive: (key: string) => void;
|
||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
||||
attachableTabKeys?: string[];
|
||||
paneAcceptingTabKeys?: string[];
|
||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
||||
onReorderSessions?: (keys: string[]) => void;
|
||||
onToggleGroup?: (groupId: string) => void;
|
||||
onRequestRenameProject?: (projectKey: string, label: string) => void;
|
||||
@@ -92,9 +135,17 @@ export const ChatList = memo(function ChatList({
|
||||
onSelect,
|
||||
onCloseTemporaryChat,
|
||||
onRequestDelete,
|
||||
onRequestDeleteMany,
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
paneGroups = {},
|
||||
onSelectPane,
|
||||
onDetachPane,
|
||||
onPromotePane,
|
||||
attachableTabKeys = [],
|
||||
paneAcceptingTabKeys = [],
|
||||
onAttachPane,
|
||||
onReorderSessions,
|
||||
onToggleGroup,
|
||||
onRequestRenameProject,
|
||||
@@ -124,7 +175,49 @@ export const ChatList = memo(function ChatList({
|
||||
edge: "before" | "after";
|
||||
key: string;
|
||||
} | null>(null);
|
||||
const [draggedSessionHeight, setDraggedSessionHeight] = useState(0);
|
||||
const [draggedPane, setDraggedPane] = useState<DraggedPane | null>(null);
|
||||
const [tabAttachTargetKey, setTabAttachTargetKey] = useState<string | null>(null);
|
||||
const tabAttachTargetRef = useRef<string | null>(null);
|
||||
const tabRowRefs = useRef(new Map<string, HTMLLIElement>());
|
||||
const pendingTabRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
||||
const tabLayoutAnimationsRef = useRef(new Map<string, Animation>());
|
||||
const [deleteSelectionMode, setDeleteSelectionMode] = useState(false);
|
||||
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const selectedPaneGroup = activeKey ? paneGroups[activeKey] : undefined;
|
||||
const selectedRowKey = selectedPaneGroup
|
||||
? selectedPaneGroup.activePaneKey
|
||||
: activeKey;
|
||||
const attachableTabs = useMemo(() => new Set(attachableTabKeys), [attachableTabKeys]);
|
||||
const paneAcceptingTabs = useMemo(
|
||||
() => new Set(paneAcceptingTabKeys),
|
||||
[paneAcceptingTabKeys],
|
||||
);
|
||||
const deleteItemsByKey = useMemo(() => {
|
||||
const items = new Map<string, SidebarDeleteItem>();
|
||||
for (const group of Object.values(paneGroups)) {
|
||||
for (const pane of group.panes) {
|
||||
items.set(pane.key, { key: pane.key, label: pane.title });
|
||||
}
|
||||
}
|
||||
for (const session of sessions) {
|
||||
if (items.has(session.key)) continue;
|
||||
items.set(session.key, {
|
||||
key: session.key,
|
||||
label: displayTitle(session, titleOverrides, t("chat.newChat")),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [paneGroups, sessions, t, titleOverrides]);
|
||||
const paneMoveTargets = useMemo(() => sessions
|
||||
.filter((session) => paneAcceptingTabs.has(session.key))
|
||||
.map((session) => ({
|
||||
key: session.key,
|
||||
title: deleteItemsByKey.get(session.key)?.label ?? session.title ?? session.chatId,
|
||||
})), [deleteItemsByKey, paneAcceptingTabs, sessions]);
|
||||
const labels = useMemo<ChatGroupLabels>(() => ({
|
||||
pinned: t("chat.groups.pinned"),
|
||||
all: t("chat.groups.all"),
|
||||
@@ -196,6 +289,80 @@ export const ChatList = memo(function ChatList({
|
||||
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||
}, [showArchived, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteSelectionMode) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
setDeleteSelectionMode(false);
|
||||
setSelectedDeleteKeys(new Set());
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [deleteSelectionMode]);
|
||||
|
||||
const measureTabRows = useCallback(() => {
|
||||
const rects = new Map<string, DOMRect>();
|
||||
for (const [key, row] of tabRowRefs.current) {
|
||||
rects.set(key, row.getBoundingClientRect());
|
||||
}
|
||||
return rects;
|
||||
}, []);
|
||||
|
||||
const updateTabAttachTarget = useCallback((next: string | null) => {
|
||||
if (tabAttachTargetRef.current === next) return;
|
||||
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
|
||||
tabLayoutAnimationsRef.current.clear();
|
||||
pendingTabRectsRef.current = measureTabRows();
|
||||
tabAttachTargetRef.current = next;
|
||||
setTabAttachTargetKey(next);
|
||||
}, [measureTabRows]);
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
clearDraggedSession();
|
||||
setDraggedSessionKey(null);
|
||||
setDraggedPane(null);
|
||||
setSessionDropTarget(null);
|
||||
updateTabAttachTarget(null);
|
||||
setDraggedSessionHeight(0);
|
||||
}, [updateTabAttachTarget]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previousRects = pendingTabRectsRef.current;
|
||||
if (!previousRects) return;
|
||||
pendingTabRectsRef.current = null;
|
||||
const nextRects = measureTabRows();
|
||||
const reduceMotion = typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduceMotion) return;
|
||||
for (const [key, nextRect] of nextRects) {
|
||||
const previousRect = previousRects.get(key);
|
||||
const row = tabRowRefs.current.get(key);
|
||||
if (!previousRect || !row || typeof row.animate !== "function") continue;
|
||||
const deltaY = previousRect.top - nextRect.top;
|
||||
if (Math.abs(deltaY) < 0.5) continue;
|
||||
const animation = row.animate(
|
||||
[
|
||||
{ transform: `translateY(${deltaY}px)` },
|
||||
{ transform: "translateY(0)" },
|
||||
],
|
||||
{
|
||||
duration: 180,
|
||||
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||
},
|
||||
);
|
||||
tabLayoutAnimationsRef.current.set(key, animation);
|
||||
animation.addEventListener("finish", () => {
|
||||
if (tabLayoutAnimationsRef.current.get(key) === animation) {
|
||||
tabLayoutAnimationsRef.current.delete(key);
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
}, [measureTabRows, tabAttachTargetKey]);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
|
||||
}, []);
|
||||
|
||||
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||
@@ -218,10 +385,48 @@ export const ChatList = memo(function ChatList({
|
||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||
|
||||
const canReorderSession = (targetKey: string) => (
|
||||
!!draggedSessionKey
|
||||
!deleteSelectionMode
|
||||
&& !!draggedSessionKey
|
||||
&& draggedSessionKey !== targetKey
|
||||
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
|
||||
);
|
||||
const beginDeleteSelection = (keys: string[]) => {
|
||||
setDeleteSelectionMode(true);
|
||||
setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key))));
|
||||
};
|
||||
const toggleDeleteSelection = (keys: string[]) => {
|
||||
setSelectedDeleteKeys((current) => {
|
||||
const next = new Set(current);
|
||||
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
|
||||
const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key));
|
||||
for (const key of validKeys) {
|
||||
if (remove) next.delete(key);
|
||||
else next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const closeDeleteSelection = () => {
|
||||
setDeleteSelectionMode(false);
|
||||
setSelectedDeleteKeys(new Set());
|
||||
};
|
||||
const requestDeleteItems = (items: SidebarDeleteItem[]) => {
|
||||
if (items.length === 0) return;
|
||||
if (onRequestDeleteMany) onRequestDeleteMany(items);
|
||||
else if (items.length === 1) onRequestDelete(items[0].key, items[0].label);
|
||||
};
|
||||
const requestDeleteKeys = (keys: string[]) => {
|
||||
requestDeleteItems(keys
|
||||
.map((key) => deleteItemsByKey.get(key))
|
||||
.filter((item): item is SidebarDeleteItem => item !== undefined));
|
||||
};
|
||||
const confirmDeleteSelection = () => {
|
||||
requestDeleteKeys(Array.from(selectedDeleteKeys));
|
||||
closeDeleteSelection();
|
||||
};
|
||||
const draggedItemTitle = draggedPane
|
||||
? deleteItemsByKey.get(draggedPane.paneKey)?.label
|
||||
: draggedSessionKey ? deleteItemsByKey.get(draggedSessionKey)?.label : undefined;
|
||||
const reorderSession = (targetKey: string, edge: "before" | "after") => {
|
||||
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
|
||||
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
|
||||
@@ -240,7 +445,7 @@ export const ChatList = memo(function ChatList({
|
||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
||||
<SidebarSelectionHighlight
|
||||
targetRef={activeRowRef}
|
||||
activeId={activeKey}
|
||||
activeId={draggedSessionKey || draggedPane ? null : selectedRowKey}
|
||||
scope="sessions"
|
||||
data-chat-list-content
|
||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
||||
@@ -265,6 +470,12 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
|
||||
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
|
||||
const reorderOffsets = sessionReorderOffsets(
|
||||
visibleSessions.map((session) => session.key),
|
||||
draggedSessionKey,
|
||||
sessionDropTarget,
|
||||
draggedSessionHeight,
|
||||
);
|
||||
|
||||
return (
|
||||
<section key={group.id} aria-label={group.label} className="relative z-[1]">
|
||||
@@ -298,12 +509,28 @@ export const ChatList = memo(function ChatList({
|
||||
{group.kind === "project" && collapsedGroups[group.id] ? null : (
|
||||
<ul className="space-y-0.5">
|
||||
{visibleSessions.map((s) => {
|
||||
const active = s.key === activeKey;
|
||||
const topicActive = s.key === activeKey;
|
||||
const paneGroup = paneGroups[s.key];
|
||||
const fallbackTitle = t("chat.fallbackTitle", {
|
||||
id: s.chatId.slice(0, 6),
|
||||
});
|
||||
const generatedTitle = s.title?.trim() || "";
|
||||
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
|
||||
const resolvedPaneGroup = paneGroup ?? {
|
||||
topicKey: s.key,
|
||||
activePaneKey: s.key,
|
||||
panes: [{ key: s.key, chatId: s.chatId, title }],
|
||||
};
|
||||
const active = topicActive && resolvedPaneGroup.activePaneKey === s.key;
|
||||
const paneCount = resolvedPaneGroup.panes.length;
|
||||
const tabDeleteKeys = resolvedPaneGroup.panes.map((pane) => pane.key);
|
||||
const tabSelected = tabDeleteKeys.every((key) => (
|
||||
selectedDeleteKeys.has(key)
|
||||
));
|
||||
const tabPartiallySelected = !tabSelected && tabDeleteKeys.some((key) => (
|
||||
selectedDeleteKeys.has(key)
|
||||
));
|
||||
const isAttachTarget = tabAttachTargetKey === s.key;
|
||||
const tooltipTitle =
|
||||
titleOverrides[s.key]?.trim() ||
|
||||
generatedTitle ||
|
||||
@@ -318,24 +545,83 @@ export const ChatList = memo(function ChatList({
|
||||
const projectMode = group.kind === "project";
|
||||
const activityState = running.has(s.chatId)
|
||||
? "running"
|
||||
: updated.has(s.chatId) && !active
|
||||
: updated.has(s.chatId) && !topicActive
|
||||
? "updated"
|
||||
: null;
|
||||
return (
|
||||
<li
|
||||
key={s.key}
|
||||
className="relative min-w-0"
|
||||
ref={(element) => {
|
||||
if (element) tabRowRefs.current.set(s.key, element);
|
||||
else tabRowRefs.current.delete(s.key);
|
||||
}}
|
||||
data-session-dragging={draggedSessionKey === s.key ? "true" : undefined}
|
||||
data-session-displaced={reorderOffsets.has(s.key) ? "true" : undefined}
|
||||
data-tab-attach-target={tabAttachTargetKey === s.key ? "true" : undefined}
|
||||
className={cn(
|
||||
"relative min-w-0 rounded-xl transition-[transform,opacity,background-color,box-shadow] duration-200 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
|
||||
draggedSessionKey === s.key && "opacity-0",
|
||||
isAttachTarget
|
||||
&& "bg-sidebar-accent/35 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]",
|
||||
)}
|
||||
style={{
|
||||
transform: reorderOffsets.has(s.key)
|
||||
? `translateY(${reorderOffsets.get(s.key)}px)`
|
||||
: undefined,
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const relativeY = rect.height > 0
|
||||
? (event.clientY - rect.top) / rect.height
|
||||
: 0.5;
|
||||
const paneCanAttach = Boolean(
|
||||
!deleteSelectionMode
|
||||
&& draggedPane
|
||||
&& draggedPane.sourceTabKey !== s.key
|
||||
&& paneAcceptingTabs.has(s.key)
|
||||
&& onAttachPane,
|
||||
);
|
||||
const tabCanAttach = Boolean(
|
||||
!deleteSelectionMode
|
||||
&& draggedSessionKey
|
||||
&& draggedSessionKey !== s.key
|
||||
&& attachableTabs.has(draggedSessionKey)
|
||||
&& paneAcceptingTabs.has(s.key)
|
||||
&& relativeY >= 0.25
|
||||
&& relativeY <= 0.75
|
||||
&& onAttachPane,
|
||||
);
|
||||
if (paneCanAttach || tabCanAttach) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setSessionDropTarget(null);
|
||||
updateTabAttachTarget(s.key);
|
||||
return;
|
||||
}
|
||||
updateTabAttachTarget(null);
|
||||
if (!canReorderSession(s.key)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setSessionDropTarget({
|
||||
const nextTarget = {
|
||||
key: s.key,
|
||||
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
|
||||
});
|
||||
} as const;
|
||||
setSessionDropTarget((current) => (
|
||||
current?.key === nextTarget.key && current.edge === nextTarget.edge
|
||||
? current
|
||||
: nextTarget
|
||||
));
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (tabAttachTargetKey === s.key && onAttachPane) {
|
||||
const paneKey = draggedPane?.paneKey ?? draggedSessionKey;
|
||||
if (paneKey) {
|
||||
event.preventDefault();
|
||||
onAttachPane(paneKey, s.key);
|
||||
}
|
||||
resetDragState();
|
||||
return;
|
||||
}
|
||||
if (!canReorderSession(s.key)) return;
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
@@ -343,23 +629,13 @@ export const ChatList = memo(function ChatList({
|
||||
? "before"
|
||||
: "after";
|
||||
reorderSession(s.key, edge);
|
||||
setDraggedSessionKey(null);
|
||||
setSessionDropTarget(null);
|
||||
resetDragState();
|
||||
}}
|
||||
>
|
||||
{sessionDropTarget?.key === s.key ? (
|
||||
<span
|
||||
aria-hidden
|
||||
data-session-drop-edge={sessionDropTarget.edge}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-2 z-20 h-0.5 rounded-full bg-primary",
|
||||
sessionDropTarget.edge === "before" ? "-top-px" : "-bottom-px",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={active ? activeRowRef : undefined}
|
||||
data-chat-row={s.key}
|
||||
data-sidebar-tab={s.key}
|
||||
className={cn(
|
||||
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
@@ -367,36 +643,75 @@ export const ChatList = memo(function ChatList({
|
||||
active
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||
isAttachTarget
|
||||
&& "bg-sidebar-accent/65 text-sidebar-accent-foreground",
|
||||
deleteSelectionMode && (tabSelected || tabPartiallySelected)
|
||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.key)}
|
||||
draggable
|
||||
onClick={() => {
|
||||
if (deleteSelectionMode) {
|
||||
toggleDeleteSelection(tabDeleteKeys);
|
||||
return;
|
||||
}
|
||||
if (topicActive && paneGroup && onSelectPane) {
|
||||
onSelectPane(s.key, s.key);
|
||||
return;
|
||||
}
|
||||
onSelect(s.key);
|
||||
}}
|
||||
draggable={!deleteSelectionMode}
|
||||
onDragStart={(event) => {
|
||||
setDraggedSessionKey(s.key);
|
||||
setDraggedPane(null);
|
||||
setSessionDropTarget(null);
|
||||
updateTabAttachTarget(null);
|
||||
setDraggedSessionHeight(
|
||||
event.currentTarget.closest("li")?.getBoundingClientRect().height
|
||||
?? event.currentTarget.getBoundingClientRect().height,
|
||||
);
|
||||
writeDraggedSession(event.dataTransfer, s.key);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
clearDraggedSession();
|
||||
setDraggedSessionKey(null);
|
||||
setSessionDropTarget(null);
|
||||
}}
|
||||
onDragEnd={resetDragState}
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-pressed={deleteSelectionMode ? tabSelected : undefined}
|
||||
title={tooltipTitle}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 overflow-hidden text-left",
|
||||
"cursor-grab active:cursor-grabbing",
|
||||
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left",
|
||||
deleteSelectionMode
|
||||
? "cursor-default"
|
||||
: "cursor-grab active:cursor-grabbing",
|
||||
compact ? "py-1" : "py-1.5",
|
||||
projectMode && "pl-7",
|
||||
)}
|
||||
>
|
||||
{deleteSelectionMode ? (
|
||||
<SelectionIndicator
|
||||
checked={tabSelected}
|
||||
partial={tabPartiallySelected}
|
||||
/>
|
||||
) : paneCount > 1 || isAttachTarget ? (
|
||||
<PanelsTopLeft
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60"
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
{projectMode ? (
|
||||
<span className="flex w-full min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
{paneCount > 1 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
|
||||
>
|
||||
{paneCount}/{MAX_WORKBENCH_PANES}
|
||||
</span>
|
||||
) : null}
|
||||
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
||||
{timestamp ? (
|
||||
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
|
||||
@@ -409,6 +724,14 @@ export const ChatList = memo(function ChatList({
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
{paneCount > 1 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
|
||||
>
|
||||
{paneCount}/{MAX_WORKBENCH_PANES}
|
||||
</span>
|
||||
) : null}
|
||||
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
||||
</span>
|
||||
)}
|
||||
@@ -422,15 +745,16 @@ export const ChatList = memo(function ChatList({
|
||||
{timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
<SessionActivityIndicator state={activityState} />
|
||||
<DropdownMenu modal={false}>
|
||||
{!deleteSelectionMode ? <DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
|
||||
"focus-visible:opacity-100",
|
||||
active && "opacity-100",
|
||||
topicActive && "opacity-100",
|
||||
)}
|
||||
aria-label={t("chat.actions", { title })}
|
||||
>
|
||||
@@ -442,6 +766,17 @@ export const ChatList = memo(function ChatList({
|
||||
portalContainer={actionMenuPortalContainer}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{paneGroup
|
||||
&& paneGroup.panes.findIndex((pane) => pane.key === s.key) > 0
|
||||
&& onPromotePane ? (
|
||||
<DropdownMenuItem onSelect={() => onPromotePane(s.key, s.key)}>
|
||||
<BringToFront className="h-4 w-4 shrink-0" />
|
||||
{t("workbench.promotePane", {
|
||||
defaultValue: "Make {{title}} the primary pane",
|
||||
title,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onTogglePin(s.key)}
|
||||
>
|
||||
@@ -468,18 +803,71 @@ export const ChatList = memo(function ChatList({
|
||||
)}
|
||||
{isArchived ? t("chat.unarchive") : t("chat.archive")}
|
||||
</DropdownMenuItem>
|
||||
{attachableTabs.has(s.key) && onAttachPane ? (
|
||||
<MoveToTabSubmenu
|
||||
targets={paneMoveTargets.filter((target) => target.key !== s.key)}
|
||||
onMove={(targetKey) => onAttachPane(s.key, targetKey)}
|
||||
/>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => beginDeleteSelection(tabDeleteKeys)}
|
||||
>
|
||||
<ListChecks className="h-4 w-4 shrink-0" />
|
||||
{t("chat.select", { defaultValue: "Select" })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
tone="destructive"
|
||||
onSelect={() => {
|
||||
window.setTimeout(() => onRequestDelete(s.key, title), 0);
|
||||
window.setTimeout(() => requestDeleteKeys(tabDeleteKeys), 0);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
{t("chat.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu> : null}
|
||||
</div>
|
||||
{paneCount > 1 || isAttachTarget ? (
|
||||
<ActivePaneRows
|
||||
group={resolvedPaneGroup}
|
||||
tabTitle={title}
|
||||
tabActive={topicActive}
|
||||
activeRowRef={activeRowRef}
|
||||
running={running}
|
||||
updated={updated}
|
||||
onSelectPane={onSelectPane}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onRequestRename={onRequestRename}
|
||||
onDetachPane={onDetachPane}
|
||||
onPromotePane={onPromotePane}
|
||||
moveTargets={paneMoveTargets.filter((target) => (
|
||||
target.key !== resolvedPaneGroup.topicKey
|
||||
))}
|
||||
onAttachPane={onAttachPane}
|
||||
deleteSelectionMode={deleteSelectionMode}
|
||||
selectedDeleteKeys={selectedDeleteKeys}
|
||||
onToggleDeleteSelection={toggleDeleteSelection}
|
||||
onBeginDeleteSelection={beginDeleteSelection}
|
||||
dropPreview={isAttachTarget && draggedItemTitle ? {
|
||||
paneTitle: draggedItemTitle,
|
||||
targetTitle: title,
|
||||
} : null}
|
||||
draggedPaneKey={draggedPane?.paneKey ?? null}
|
||||
onPaneDragStart={(event, pane) => {
|
||||
setDraggedPane(pane);
|
||||
setDraggedSessionKey(null);
|
||||
setSessionDropTarget(null);
|
||||
updateTabAttachTarget(null);
|
||||
setDraggedSessionHeight(
|
||||
event.currentTarget.closest("li")?.getBoundingClientRect().height
|
||||
?? event.currentTarget.getBoundingClientRect().height,
|
||||
);
|
||||
writeDraggedPane(event.dataTransfer, pane);
|
||||
}}
|
||||
onPaneDragEnd={resetDragState}
|
||||
actionMenuPortalContainer={actionMenuPortalContainer}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -510,11 +898,329 @@ export const ChatList = memo(function ChatList({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{deleteSelectionMode ? (
|
||||
<div
|
||||
data-testid="delete-selection-bar"
|
||||
className="sticky bottom-2 z-30 mx-1 mt-3 flex min-h-11 items-center gap-2 rounded-2xl border border-sidebar-border/80 bg-popover/95 p-1.5 pl-2 shadow-[0_10px_30px_rgba(15,23,42,0.14)] backdrop-blur-xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDeleteSelection}
|
||||
aria-label={t("chat.cancelSelection", {
|
||||
defaultValue: "Cancel selection",
|
||||
})}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate px-1 text-[12.5px] font-medium text-foreground/85">
|
||||
{t("chat.selectedCount", {
|
||||
defaultValue: "{{count}} selected",
|
||||
count: selectedDeleteKeys.size,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedDeleteKeys.size === 0}
|
||||
onClick={confirmDeleteSelection}
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-destructive px-3 text-[12px] font-semibold text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("chat.deleteSelected", { defaultValue: "Delete" })}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarSelectionHighlight>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function sessionReorderOffsets(
|
||||
keys: string[],
|
||||
draggedKey: string | null,
|
||||
target: { edge: "before" | "after"; key: string } | null,
|
||||
draggedHeight: number,
|
||||
): Map<string, number> {
|
||||
const offsets = new Map<string, number>();
|
||||
if (!draggedKey || !target || draggedHeight <= 0) return offsets;
|
||||
const sourceIndex = keys.indexOf(draggedKey);
|
||||
if (sourceIndex < 0 || target.key === draggedKey) return offsets;
|
||||
const remaining = keys.filter((key) => key !== draggedKey);
|
||||
const targetIndex = remaining.indexOf(target.key);
|
||||
if (targetIndex < 0) return offsets;
|
||||
const finalIndex = targetIndex + (target.edge === "after" ? 1 : 0);
|
||||
|
||||
if (sourceIndex < finalIndex) {
|
||||
for (let index = sourceIndex + 1; index <= finalIndex; index += 1) {
|
||||
offsets.set(keys[index], -draggedHeight);
|
||||
}
|
||||
} else if (sourceIndex > finalIndex) {
|
||||
for (let index = finalIndex; index < sourceIndex; index += 1) {
|
||||
offsets.set(keys[index], draggedHeight);
|
||||
}
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function ActivePaneRows({
|
||||
group,
|
||||
tabTitle,
|
||||
tabActive,
|
||||
activeRowRef,
|
||||
running,
|
||||
updated,
|
||||
onSelectPane,
|
||||
onRequestDelete,
|
||||
onRequestRename,
|
||||
onDetachPane,
|
||||
onPromotePane,
|
||||
moveTargets,
|
||||
onAttachPane,
|
||||
deleteSelectionMode,
|
||||
selectedDeleteKeys,
|
||||
onToggleDeleteSelection,
|
||||
onBeginDeleteSelection,
|
||||
dropPreview,
|
||||
draggedPaneKey,
|
||||
onPaneDragStart,
|
||||
onPaneDragEnd,
|
||||
actionMenuPortalContainer,
|
||||
}: {
|
||||
group: SidebarPaneGroup;
|
||||
tabTitle: string;
|
||||
tabActive: boolean;
|
||||
activeRowRef: RefObject<HTMLDivElement>;
|
||||
running: ReadonlySet<string>;
|
||||
updated: ReadonlySet<string>;
|
||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
||||
moveTargets: Array<{ key: string; title: string }>;
|
||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
||||
deleteSelectionMode: boolean;
|
||||
selectedDeleteKeys: ReadonlySet<string>;
|
||||
onToggleDeleteSelection: (keys: string[]) => void;
|
||||
onBeginDeleteSelection: (keys: string[]) => void;
|
||||
dropPreview: { paneTitle: string; targetTitle: string } | null;
|
||||
draggedPaneKey: string | null;
|
||||
onPaneDragStart: (event: DragEvent<HTMLButtonElement>, pane: DraggedPane) => void;
|
||||
onPaneDragEnd: () => void;
|
||||
actionMenuPortalContainer?: HTMLElement | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const childPanes = group.panes.filter((pane) => pane.key !== group.topicKey);
|
||||
|
||||
return (
|
||||
<ul
|
||||
aria-label={t("workbench.panesInTab", {
|
||||
defaultValue: "Panes in {{title}}",
|
||||
title: tabTitle,
|
||||
})}
|
||||
className={cn(
|
||||
"relative ml-5 mr-1 mt-0.5 space-y-0.5 rounded-bl-lg border-l border-sidebar-border/60 py-0.5 pl-2 pr-0.5",
|
||||
dropPreview && "pb-1",
|
||||
)}
|
||||
>
|
||||
{childPanes.map((pane) => {
|
||||
const index = group.panes.findIndex((candidate) => candidate.key === pane.key);
|
||||
const active = tabActive && pane.key === group.activePaneKey;
|
||||
const activityState = running.has(pane.chatId)
|
||||
? "running"
|
||||
: updated.has(pane.chatId) && !active
|
||||
? "updated"
|
||||
: null;
|
||||
const paneActionsLabel = t("workbench.paneActions", {
|
||||
defaultValue: "{{title}} pane actions",
|
||||
title: pane.title,
|
||||
});
|
||||
const selected = selectedDeleteKeys.has(pane.key);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={pane.key}
|
||||
data-pane-dragging={draggedPaneKey === pane.key ? "true" : undefined}
|
||||
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-sidebar-border/45"
|
||||
>
|
||||
<div
|
||||
ref={active ? activeRowRef : undefined}
|
||||
data-chat-row={pane.key}
|
||||
data-sidebar-pane={pane.key}
|
||||
className={cn(
|
||||
"group/pane flex min-h-7 min-w-0 items-center gap-1 rounded-lg px-2 text-[12.5px]",
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
active
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/72 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||
deleteSelectionMode && selected
|
||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (deleteSelectionMode) {
|
||||
onToggleDeleteSelection([pane.key]);
|
||||
return;
|
||||
}
|
||||
onSelectPane?.(group.topicKey, pane.key);
|
||||
}}
|
||||
draggable={!deleteSelectionMode}
|
||||
onDragStart={(event) => onPaneDragStart(event, {
|
||||
paneKey: pane.key,
|
||||
sourceTabKey: group.topicKey,
|
||||
})}
|
||||
onDragEnd={onPaneDragEnd}
|
||||
aria-current={active ? "true" : undefined}
|
||||
aria-pressed={deleteSelectionMode ? selected : undefined}
|
||||
title={pane.title}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2 py-1 text-left font-medium leading-5",
|
||||
deleteSelectionMode ? "cursor-default" : "cursor-grab active:cursor-grabbing",
|
||||
)}
|
||||
>
|
||||
{deleteSelectionMode ? (
|
||||
<SelectionIndicator checked={selected} partial={false} />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate">{pane.title}</span>
|
||||
</button>
|
||||
<SessionActivityIndicator state={activityState} />
|
||||
{!deleteSelectionMode ? <DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover/pane:opacity-100",
|
||||
"focus-visible:opacity-100",
|
||||
active && "opacity-100",
|
||||
)}
|
||||
aria-label={paneActionsLabel}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={ACTION_MENU_CONTENT_CLASS}
|
||||
portalContainer={actionMenuPortalContainer}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{index > 0 && onPromotePane ? (
|
||||
<DropdownMenuItem onSelect={() => onPromotePane(group.topicKey, pane.key)}>
|
||||
<BringToFront className="h-4 w-4 shrink-0" />
|
||||
{t("workbench.promotePane", {
|
||||
defaultValue: "Make {{title}} the primary pane",
|
||||
title: pane.title,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onRequestRename(pane.key, pane.title)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 shrink-0" />
|
||||
{t("chat.rename")}
|
||||
</DropdownMenuItem>
|
||||
{onDetachPane ? (
|
||||
<DropdownMenuItem onSelect={() => onDetachPane(group.topicKey, pane.key)}>
|
||||
<Unplug className="h-4 w-4 shrink-0" />
|
||||
{t("workbench.detachPane", {
|
||||
defaultValue: "Move {{title}} to its own topic",
|
||||
title: pane.title,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onAttachPane ? (
|
||||
<MoveToTabSubmenu
|
||||
targets={moveTargets}
|
||||
onMove={(targetKey) => onAttachPane(pane.key, targetKey)}
|
||||
/>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onBeginDeleteSelection([pane.key])}
|
||||
>
|
||||
<ListChecks className="h-4 w-4 shrink-0" />
|
||||
{t("chat.select", { defaultValue: "Select" })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
tone="destructive"
|
||||
onSelect={() => {
|
||||
window.setTimeout(() => onRequestDelete(pane.key, pane.title), 0);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
{t("chat.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{dropPreview ? (
|
||||
<li
|
||||
data-pane-drop-preview
|
||||
role="status"
|
||||
aria-label={t("workbench.dropPane", {
|
||||
defaultValue: "Move {{pane}} into {{tab}}",
|
||||
pane: dropPreview.paneTitle,
|
||||
tab: dropPreview.targetTitle,
|
||||
})}
|
||||
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-primary/45"
|
||||
>
|
||||
<div className="flex min-h-7 items-center gap-2 rounded-lg border border-primary/30 bg-primary/[0.07] px-2 text-[12.5px] font-medium text-foreground/80 shadow-[inset_0_0_0_1px_hsl(var(--background)/0.5)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-150">
|
||||
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-primary/75" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">{dropPreview.paneTitle}</span>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionIndicator({
|
||||
checked,
|
||||
partial,
|
||||
}: {
|
||||
checked: boolean;
|
||||
partial: boolean;
|
||||
}) {
|
||||
const Icon = partial ? SquareMinus : checked ? SquareCheckBig : Square;
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
checked || partial ? "text-primary" : "text-muted-foreground/55",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveToTabSubmenu({
|
||||
targets,
|
||||
onMove,
|
||||
}: {
|
||||
targets: Array<{ key: string; title: string }>;
|
||||
onMove: (targetKey: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (targets.length === 0) return null;
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<PanelsTopLeft className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{t("workbench.moveToTab", { defaultValue: "Move to tab" })}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{targets.map((target) => (
|
||||
<DropdownMenuItem key={target.key} onSelect={() => onMove(target.key)}>
|
||||
<span className="max-w-56 truncate">{target.title}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function TemporaryChatSection({
|
||||
sessions,
|
||||
activeKey,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types";
|
||||
interface DeleteConfirmProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
count?: number;
|
||||
automations?: SessionAutomationJob[];
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
@@ -26,6 +27,7 @@ interface DeleteConfirmProps {
|
||||
export function DeleteConfirm({
|
||||
open,
|
||||
title,
|
||||
count = 1,
|
||||
automations = [],
|
||||
onCancel,
|
||||
onConfirm,
|
||||
@@ -33,6 +35,7 @@ export function DeleteConfirm({
|
||||
const { t } = useTranslation();
|
||||
const locale = currentLocale();
|
||||
const hasAutomations = automations.length > 0;
|
||||
const multiple = count > 1;
|
||||
const visibleAutomations = automations.slice(0, 4);
|
||||
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
||||
return (
|
||||
@@ -47,11 +50,24 @@ export function DeleteConfirm({
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||
{t("deleteConfirm.title", { title })}
|
||||
{multiple
|
||||
? t("deleteConfirm.titleMany", {
|
||||
defaultValue: "Delete {{count}} topics and panes?",
|
||||
count,
|
||||
})
|
||||
: t("deleteConfirm.title", { title })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
||||
{hasAutomations
|
||||
? t("deleteConfirm.automationsDescription")
|
||||
? multiple
|
||||
? t("deleteConfirm.automationsDescriptionMany", {
|
||||
defaultValue: "Linked automations will also be deleted.",
|
||||
})
|
||||
: t("deleteConfirm.automationsDescription")
|
||||
: multiple
|
||||
? t("deleteConfirm.descriptionMany", {
|
||||
defaultValue: "This action cannot be undone.",
|
||||
})
|
||||
: t("deleteConfirm.description")}
|
||||
</AlertDialogDescription>
|
||||
{hasAutomations ? (
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import {
|
||||
ChatList,
|
||||
type SidebarDeleteItem,
|
||||
type SidebarPaneGroup,
|
||||
} from "@/components/ChatList";
|
||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||
import {
|
||||
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
||||
@@ -39,9 +43,17 @@ interface SidebarProps {
|
||||
onSelect: (key: string) => void;
|
||||
onCloseTemporaryChat?: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onToggleArchive: (key: string) => void;
|
||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
||||
attachableTabKeys?: string[];
|
||||
paneAcceptingTabKeys?: string[];
|
||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
||||
onReorderSessions: (keys: string[]) => void;
|
||||
onToggleGroup: (groupId: string) => void;
|
||||
onRequestRenameProject: (projectKey: string, label: string) => void;
|
||||
@@ -230,9 +242,17 @@ export function Sidebar(props: SidebarProps) {
|
||||
onSelect={props.onSelect}
|
||||
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
||||
onRequestDelete={props.onRequestDelete}
|
||||
onRequestDeleteMany={props.onRequestDeleteMany}
|
||||
onTogglePin={props.onTogglePin}
|
||||
onRequestRename={props.onRequestRename}
|
||||
onToggleArchive={props.onToggleArchive}
|
||||
paneGroups={props.paneGroups}
|
||||
onSelectPane={props.onSelectPane}
|
||||
onDetachPane={props.onDetachPane}
|
||||
onPromotePane={props.onPromotePane}
|
||||
attachableTabKeys={props.attachableTabKeys}
|
||||
paneAcceptingTabKeys={props.paneAcceptingTabKeys}
|
||||
onAttachPane={props.onAttachPane}
|
||||
onReorderSessions={props.onReorderSessions}
|
||||
onToggleGroup={props.onToggleGroup}
|
||||
onRequestRenameProject={props.onRequestRenameProject}
|
||||
|
||||
@@ -724,7 +724,7 @@ export function SettingsView({
|
||||
hostChromeInset = false,
|
||||
}: SettingsViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getToken, token } = useClient();
|
||||
const { client, getToken, token } = useClient();
|
||||
const pageVisible = usePageVisibility();
|
||||
const remoteBrowserAccess =
|
||||
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
||||
@@ -872,7 +872,7 @@ export function SettingsView({
|
||||
const poll = async () => {
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
getToken(),
|
||||
client,
|
||||
providerOAuthFlow.provider,
|
||||
providerOAuthFlow.flow_id,
|
||||
);
|
||||
@@ -902,7 +902,7 @@ export function SettingsView({
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]);
|
||||
}, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSettings || settings !== null) return;
|
||||
@@ -1301,7 +1301,7 @@ export function SettingsView({
|
||||
}
|
||||
setModelConfigurationSaving(true);
|
||||
try {
|
||||
const payload = await createModelConfiguration(token, {
|
||||
const payload = await createModelConfiguration(client, {
|
||||
label,
|
||||
provider,
|
||||
model,
|
||||
@@ -1319,7 +1319,7 @@ export function SettingsView({
|
||||
|
||||
let finalPayload = payload;
|
||||
if (nextOrder) {
|
||||
const orderedPayload = await updateModelCallOrder(token, nextOrder);
|
||||
const orderedPayload = await updateModelCallOrder(client, nextOrder);
|
||||
applyPayload(orderedPayload);
|
||||
finalPayload = orderedPayload;
|
||||
}
|
||||
@@ -1345,7 +1345,7 @@ export function SettingsView({
|
||||
const reasoningEffort = form.reasoningEffort || null;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = await updateModelConfiguration(token, {
|
||||
const payload = await updateModelConfiguration(client, {
|
||||
name: selectedPreset.name,
|
||||
label:
|
||||
form.presetLabel.trim() !== selectedPreset.label
|
||||
@@ -1431,7 +1431,7 @@ export function SettingsView({
|
||||
setModelCallOrder(nextOrder);
|
||||
setModelCallOrderSaving(true);
|
||||
try {
|
||||
const payload = await updateModelCallOrder(token, nextOrder);
|
||||
const payload = await updateModelCallOrder(client, nextOrder);
|
||||
applyPayload(payload, { preserveAgentForm: true });
|
||||
onModelNameChange(payload.agent.model || null);
|
||||
setError(null);
|
||||
@@ -1447,7 +1447,7 @@ export function SettingsView({
|
||||
if (modelMigrationSaving) return;
|
||||
setModelMigrationSaving(true);
|
||||
try {
|
||||
const payload = await migrateModelConfigurations(token);
|
||||
const payload = await migrateModelConfigurations(client);
|
||||
applyPayload(payload);
|
||||
onModelNameChange(payload.agent.model || null);
|
||||
setError(null);
|
||||
@@ -1469,7 +1469,7 @@ export function SettingsView({
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name);
|
||||
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
|
||||
applyPayload(payload);
|
||||
setModelPresetPendingDelete(null);
|
||||
setError(null);
|
||||
@@ -1484,7 +1484,7 @@ export function SettingsView({
|
||||
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
||||
setImageGenerationSaving(true);
|
||||
try {
|
||||
const payload = await updateImageGenerationSettings(token, imageGenerationForm);
|
||||
const payload = await updateImageGenerationSettings(client, imageGenerationForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||
@@ -1502,7 +1502,7 @@ export function SettingsView({
|
||||
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
||||
setTranscriptionSaving(true);
|
||||
try {
|
||||
const payload = await updateTranscriptionSettings(token, transcriptionForm);
|
||||
const payload = await updateTranscriptionSettings(client, transcriptionForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||
@@ -1520,7 +1520,7 @@ export function SettingsView({
|
||||
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
||||
setNetworkSafetySaving(true);
|
||||
try {
|
||||
const payload = await updateNetworkSafetySettings(token, networkSafetyForm);
|
||||
const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
@@ -1544,7 +1544,7 @@ export function SettingsView({
|
||||
try {
|
||||
let latest = nanobotFeatures;
|
||||
for (const name of missing) {
|
||||
latest = await enableNanobotFeature(token, name);
|
||||
latest = await enableNanobotFeature(client, name);
|
||||
if (latest.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
@@ -1568,8 +1568,8 @@ export function SettingsView({
|
||||
setApiServiceError(null);
|
||||
try {
|
||||
const payload = action === "start"
|
||||
? await startApiService(token, values!)
|
||||
: await stopApiService(token);
|
||||
? await startApiService(client, values!)
|
||||
: await stopApiService(client);
|
||||
setApiService(payload);
|
||||
const refreshed = await fetchNanobotFeatures(token);
|
||||
setNanobotFeatures(refreshed);
|
||||
@@ -1622,7 +1622,7 @@ export function SettingsView({
|
||||
if (field === "region") update.region = providerForm.region.trim();
|
||||
if (field === "profile") update.profile = providerForm.profile.trim();
|
||||
}
|
||||
const payload = await updateProviderSettings(token, update);
|
||||
const payload = await updateProviderSettings(client, update);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||
@@ -1656,7 +1656,7 @@ export function SettingsView({
|
||||
if (providerSaving) return false;
|
||||
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
|
||||
try {
|
||||
const payload = await createProviderSettings(token, {
|
||||
const payload = await createProviderSettings(client, {
|
||||
name: draft.name.trim(),
|
||||
apiKey: draft.apiKey.trim() || undefined,
|
||||
apiBase: draft.apiBase.trim(),
|
||||
@@ -1698,12 +1698,11 @@ export function SettingsView({
|
||||
const payload =
|
||||
action === "login"
|
||||
? await loginProviderOAuth(
|
||||
token,
|
||||
client,
|
||||
providerName,
|
||||
"",
|
||||
providerName === "openai_codex" && remoteBrowserAccess,
|
||||
)
|
||||
: await logoutProviderOAuth(token, providerName);
|
||||
: await logoutProviderOAuth(client, providerName);
|
||||
if (isProviderOAuthAuthorizationRequired(payload)) {
|
||||
try {
|
||||
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
|
||||
@@ -1739,7 +1738,7 @@ export function SettingsView({
|
||||
setProviderOAuthDialogError(null);
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
token,
|
||||
client,
|
||||
flow.provider,
|
||||
flow.flow_id,
|
||||
authorizationResponse,
|
||||
@@ -1798,7 +1797,7 @@ export function SettingsView({
|
||||
update.apiKey = apiKey;
|
||||
}
|
||||
if (provider.credential === "base_url") update.baseUrl = baseUrl;
|
||||
const payload = await updateWebSearchSettings(token, update);
|
||||
const payload = await updateWebSearchSettings(client, update);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart || webFetchRestartRequired) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||
@@ -1903,7 +1902,7 @@ export function SettingsView({
|
||||
setCliAppsMessage(null);
|
||||
setCliAppsError(null);
|
||||
try {
|
||||
const payload = await runCliAppAction(token, action, name);
|
||||
const payload = await runCliAppAction(client, action, name);
|
||||
setCliApps(payload);
|
||||
if (action !== "test") {
|
||||
notifyCliAppsChanged(payload);
|
||||
@@ -1934,8 +1933,8 @@ export function SettingsView({
|
||||
setNanobotFeaturesError(null);
|
||||
try {
|
||||
const payload = action === "enable"
|
||||
? await enableNanobotFeature(token, name)
|
||||
: await disableNanobotFeature(token, name);
|
||||
? await enableNanobotFeature(client, name)
|
||||
: await disableNanobotFeature(client, name);
|
||||
setNanobotFeatures(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
@@ -1955,7 +1954,7 @@ export function SettingsView({
|
||||
setAutomationAction(key);
|
||||
setAutomationsError(null);
|
||||
try {
|
||||
const payload = await runAutomationAction(token, action, job.id);
|
||||
const payload = await runAutomationAction(client, action, job.id);
|
||||
setAutomations(payload);
|
||||
if (action === "delete") setAutomationPendingDelete(null);
|
||||
if (action === "run") {
|
||||
@@ -1977,7 +1976,7 @@ export function SettingsView({
|
||||
setAutomationAction(key);
|
||||
setAutomationsError(null);
|
||||
try {
|
||||
const payload = await updateAutomation(token, job.id, values);
|
||||
const payload = await updateAutomation(client, job.id, values);
|
||||
setAutomations(payload);
|
||||
setAutomationPendingEdit(null);
|
||||
} catch (err) {
|
||||
@@ -1997,7 +1996,7 @@ export function SettingsView({
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
try {
|
||||
const payload = await runMcpPresetAction(token, action, name, values);
|
||||
const payload = await runMcpPresetAction(client, action, name, values);
|
||||
setMcpPresets(payload);
|
||||
setMcpMessage(payload.last_action?.message ?? null);
|
||||
if (action !== "test") {
|
||||
@@ -2024,7 +2023,7 @@ export function SettingsView({
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
try {
|
||||
const payload = await saveCustomMcpServer(token, {
|
||||
const payload = await saveCustomMcpServer(client, {
|
||||
name,
|
||||
transport: customMcpForm.transport,
|
||||
command: customMcpForm.command,
|
||||
@@ -2054,7 +2053,7 @@ export function SettingsView({
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
try {
|
||||
const payload = await importMcpConfig(token, mcpConfigImport);
|
||||
const payload = await importMcpConfig(client, mcpConfigImport);
|
||||
setMcpPresets(payload);
|
||||
setMcpMessage(payload.last_action?.message ?? null);
|
||||
notifyMcpPresetsChanged(payload);
|
||||
@@ -2075,7 +2074,7 @@ export function SettingsView({
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
try {
|
||||
const payload = await updateMcpServerTools(token, name, enabledTools);
|
||||
const payload = await updateMcpServerTools(client, name, enabledTools);
|
||||
setMcpPresets(payload);
|
||||
setMcpMessage(payload.last_action?.message ?? null);
|
||||
notifyMcpPresetsChanged(payload);
|
||||
@@ -2328,6 +2327,7 @@ export function SettingsView({
|
||||
onAction={handleAutomationAction}
|
||||
onRequestEdit={setAutomationPendingEdit}
|
||||
onRequestDelete={setAutomationPendingDelete}
|
||||
onBackToChat={onBackToChat}
|
||||
/>
|
||||
);
|
||||
case "skills":
|
||||
@@ -2448,7 +2448,7 @@ export function SettingsView({
|
||||
onSave={handleAutomationEdit}
|
||||
/>
|
||||
|
||||
<main
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
|
||||
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
|
||||
@@ -2512,7 +2512,7 @@ export function SettingsView({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2578,9 +2578,9 @@ function SettingsSidebar({
|
||||
{t("settings.backToChat")}
|
||||
</button>
|
||||
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
|
||||
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
|
||||
<h1 className="text-[18px] font-normal tracking-normal text-foreground">
|
||||
{t("settings.sidebar.title")}
|
||||
</h2>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
@@ -3042,7 +3042,13 @@ function AppearanceSettings({
|
||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.brandLogos", "Brand logos")}>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.brandLogos", "Brand logos")}
|
||||
description={tx(
|
||||
"settings.legal.thirdPartyBrands",
|
||||
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
|
||||
)}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={localPrefs.brandLogos}
|
||||
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
||||
@@ -5488,6 +5494,7 @@ function AutomationsSettings({
|
||||
onAction,
|
||||
onRequestEdit,
|
||||
onRequestDelete,
|
||||
onBackToChat,
|
||||
}: {
|
||||
payload: AutomationsPayload | null;
|
||||
loading: boolean;
|
||||
@@ -5502,6 +5509,7 @@ function AutomationsSettings({
|
||||
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
||||
onRequestEdit: (job: SessionAutomationJob) => void;
|
||||
onRequestDelete: (job: SessionAutomationJob) => void;
|
||||
onBackToChat: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||
@@ -5549,6 +5557,7 @@ function AutomationsSettings({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{jobs.length ? (
|
||||
<section className="shrink-0">
|
||||
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
||||
@@ -5617,6 +5626,7 @@ function AutomationsSettings({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
||||
@@ -5674,13 +5684,35 @@ function AutomationsSettings({
|
||||
: tx("settings.automations.empty", "No automations yet.")}
|
||||
</div>
|
||||
{!jobs.length ? (
|
||||
<>
|
||||
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
||||
{tx(
|
||||
"settings.automations.emptyHint",
|
||||
"Create one from where it should run so nanobot keeps the right context.",
|
||||
"Create automations in a chat so they keep the right context.",
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4 rounded-full"
|
||||
onClick={onBackToChat}
|
||||
>
|
||||
{tx("settings.automations.emptyAction", "Open a chat")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4 rounded-full"
|
||||
onClick={() => {
|
||||
onQueryChange("");
|
||||
onFilterChange("all");
|
||||
}}
|
||||
>
|
||||
{tx("settings.automations.clearFilters", "Clear filters")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -7290,10 +7322,6 @@ function ChannelsSettings({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className={cn("shrink-0 pt-2", showingCompactDetail && "hidden")}>
|
||||
<ThirdPartyBrandNotice />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7396,6 +7424,23 @@ function AppsCatalogSettings({
|
||||
(cliAppsLoading || mcpPresetsLoading) &&
|
||||
!cliApps &&
|
||||
!mcpPresets;
|
||||
const cliAppCount = cliApps?.apps.length ?? 0;
|
||||
const emptyTitle = normalizedQuery
|
||||
? tx("settings.apps.empty", "No tools match your search.")
|
||||
: filter === "cli"
|
||||
? tx("settings.apps.emptyApps", "No apps available.")
|
||||
: filter === "mcp"
|
||||
? tx("settings.apps.emptyIntegrations", "No integrations available.")
|
||||
: tx("settings.apps.emptyReady", "No tools are ready yet.");
|
||||
const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery
|
||||
? null
|
||||
: filter === "cli"
|
||||
? "mcp"
|
||||
: filter === "mcp"
|
||||
? (cliAppCount ? "cli" : null)
|
||||
: cliAppCount
|
||||
? "cli"
|
||||
: "mcp";
|
||||
const statusMessage =
|
||||
cliError ||
|
||||
mcpError ||
|
||||
@@ -7484,7 +7529,35 @@ function AppsCatalogSettings({
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
||||
{tx("settings.apps.empty", "No tools match this view.")}
|
||||
<p>{emptyTitle}</p>
|
||||
{normalizedQuery ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4 rounded-full"
|
||||
onClick={() => onQueryChange("")}
|
||||
>
|
||||
{tx("settings.apps.clearSearch", "Clear search")}
|
||||
</Button>
|
||||
) : emptyBrowseTarget ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4 rounded-full"
|
||||
onClick={() => onFilterChange(emptyBrowseTarget)}
|
||||
>
|
||||
{emptyBrowseTarget === "cli"
|
||||
? tx("settings.apps.browseApps", "Browse apps")
|
||||
: tx("settings.apps.browseIntegrations", "Browse integrations")}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
||||
{tx(
|
||||
"settings.apps.emptyIntegrationsHint",
|
||||
"Add a custom integration below.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -7500,8 +7573,6 @@ function AppsCatalogSettings({
|
||||
onImportConfig={onImportMcpConfig}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ThirdPartyBrandNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9326,18 +9397,6 @@ function ProviderPickerIcon({
|
||||
);
|
||||
}
|
||||
|
||||
function ThirdPartyBrandNotice() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
|
||||
{t("settings.legal.thirdPartyBrands", {
|
||||
defaultValue:
|
||||
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function orderUnconfiguredProviders(
|
||||
providers: SettingsPayload["providers"],
|
||||
): SettingsPayload["providers"] {
|
||||
|
||||
@@ -269,7 +269,7 @@ function SkillDetailSheet({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { getToken } = useClient();
|
||||
const { client, getToken } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -321,7 +321,7 @@ function SkillDetailSheet({
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
|
||||
const payload = await updateSkillEnabled(client, activeSkill.name, !enabled);
|
||||
notifySkillsChanged(payload);
|
||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||
if (updated) {
|
||||
@@ -345,7 +345,7 @@ function SkillDetailSheet({
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await deleteSkill(getToken(), activeSkill.name);
|
||||
const payload = await deleteSkill(client, activeSkill.name);
|
||||
notifySkillsChanged(payload);
|
||||
onOpenChange(false);
|
||||
} catch (reason) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function SkillsMarketplace({
|
||||
installing: string;
|
||||
onInstallingChange: (skillId: string) => void;
|
||||
}) {
|
||||
const { getToken } = useClient();
|
||||
const { client, getToken } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||
@@ -161,7 +161,7 @@ export function SkillsMarketplace({
|
||||
setError("");
|
||||
try {
|
||||
const payload = await installMarketplaceSkill(
|
||||
getToken(),
|
||||
client,
|
||||
skill.provider,
|
||||
skill.source,
|
||||
skill.skill_id,
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export type ChannelInstancesPanelCustomization = {
|
||||
countLabel?: (runningCount: number) => string;
|
||||
@@ -50,7 +51,6 @@ export type ChannelInstancesPanelCustomization = {
|
||||
};
|
||||
|
||||
export function ChannelInstancesPanel({
|
||||
token,
|
||||
feature,
|
||||
showBrandLogos,
|
||||
chatAppsDocsUrl,
|
||||
@@ -58,7 +58,6 @@ export function ChannelInstancesPanel({
|
||||
onFeaturesUpdate,
|
||||
customization = {},
|
||||
}: {
|
||||
token: string;
|
||||
feature: NanobotFeatureInfo;
|
||||
showBrandLogos: boolean;
|
||||
chatAppsDocsUrl?: string;
|
||||
@@ -66,6 +65,7 @@ export function ChannelInstancesPanel({
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
customization?: ChannelInstancesPanelCustomization;
|
||||
}) {
|
||||
const { client } = useClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const displayName = localizedChannelDisplayName(feature, t);
|
||||
@@ -111,8 +111,8 @@ export function ChannelInstancesPanel({
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = checked
|
||||
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
|
||||
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
|
||||
? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
|
||||
: await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
|
||||
onFeaturesUpdate(payload);
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
@@ -127,7 +127,7 @@ export function ChannelInstancesPanel({
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await configureChannel(
|
||||
token,
|
||||
client,
|
||||
feature.name,
|
||||
channelValuesForSave(instanceFields, fieldValues),
|
||||
{ enable: selected.enabled, instanceId: selected.id },
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ChannelConnectPayload,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export type ChannelQrConnectLabels = {
|
||||
qrAlt: string;
|
||||
@@ -43,7 +44,6 @@ export type ChannelQrConnectPendingContext = {
|
||||
};
|
||||
|
||||
export function ChannelQrConnectFlow({
|
||||
token,
|
||||
channelName,
|
||||
startOptions = {},
|
||||
idleLabel,
|
||||
@@ -69,6 +69,7 @@ export function ChannelQrConnectFlow({
|
||||
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
|
||||
suppressSucceeded?: boolean;
|
||||
}) {
|
||||
const { client } = useClient();
|
||||
const pageVisible = usePageVisibility();
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
@@ -78,8 +79,6 @@ export function ChannelQrConnectFlow({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||
const pollInFlight = useRef(false);
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
const startDomain = startOptions.domain;
|
||||
const startInstanceId = startOptions.instanceId;
|
||||
const startMode = startOptions.mode;
|
||||
@@ -129,7 +128,7 @@ export function ChannelQrConnectFlow({
|
||||
pollInFlight.current = true;
|
||||
try {
|
||||
const payload = await pollChannelConnect(
|
||||
tokenRef.current,
|
||||
client,
|
||||
channelName,
|
||||
sessionId,
|
||||
);
|
||||
@@ -163,6 +162,7 @@ export function ChannelQrConnectFlow({
|
||||
};
|
||||
}, [
|
||||
channelName,
|
||||
client,
|
||||
connect?.interval_ms,
|
||||
connect?.session_id,
|
||||
connect?.status,
|
||||
@@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await startChannelConnect(tokenRef.current, channelName, {
|
||||
const payload = await startChannelConnect(client, channelName, {
|
||||
domain: startDomain,
|
||||
instanceId: startInstanceId,
|
||||
mode: startMode,
|
||||
@@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
|
||||
}, [channelName, client, startDomain, startForce, startInstanceId, startMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||
@@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await cancelChannelConnect(
|
||||
tokenRef.current,
|
||||
client,
|
||||
channelName,
|
||||
connect.session_id,
|
||||
);
|
||||
@@ -223,10 +223,9 @@ export function ChannelQrConnectFlow({
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await pollChannelConnect(
|
||||
tokenRef.current,
|
||||
client,
|
||||
channelName,
|
||||
connect.session_id,
|
||||
"",
|
||||
params,
|
||||
);
|
||||
setConnect((current) => ({
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export function ChannelCatalogRow({
|
||||
feature,
|
||||
@@ -148,7 +149,6 @@ export function ChannelSetupPanel({
|
||||
if (feature.instances !== undefined) {
|
||||
return (
|
||||
<ChannelInstancesPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
@@ -269,6 +269,7 @@ function ChannelSetupSurface({
|
||||
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { client } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
@@ -345,7 +346,7 @@ function ChannelSetupSurface({
|
||||
setNotice(null);
|
||||
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
||||
try {
|
||||
const validationPayload = await validateChannel(token, feature.name, values);
|
||||
const validationPayload = await validateChannel(client, feature.name, values);
|
||||
setValidation(validationPayload);
|
||||
if (!validationPayload.can_enable) {
|
||||
setNotice(
|
||||
@@ -355,7 +356,7 @@ function ChannelSetupSurface({
|
||||
return;
|
||||
}
|
||||
const payload = await configureChannel(
|
||||
token,
|
||||
client,
|
||||
feature.name,
|
||||
values,
|
||||
{ enable: true },
|
||||
@@ -377,7 +378,7 @@ function ChannelSetupSurface({
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await validateChannel(
|
||||
token,
|
||||
client,
|
||||
feature.name,
|
||||
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
||||
);
|
||||
|
||||
@@ -179,9 +179,14 @@ function getVoiceShortcutLabel(): string {
|
||||
}
|
||||
|
||||
interface ThreadComposerProps {
|
||||
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
|
||||
onSend: (
|
||||
content: string,
|
||||
images?: SendAttachment[],
|
||||
options?: SendOptions,
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
inputAriaLabel?: string;
|
||||
isStreaming?: boolean;
|
||||
modelLabel?: string | null;
|
||||
modelDetail?: string | null;
|
||||
@@ -936,6 +941,7 @@ export function ThreadComposer({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputAriaLabel,
|
||||
isStreaming = false,
|
||||
modelLabel = null,
|
||||
modelDetail = null,
|
||||
@@ -981,6 +987,8 @@ export function ThreadComposer({
|
||||
end: number;
|
||||
} | null>(null);
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [sendPending, setSendPending] = useState(false);
|
||||
const interactionDisabled = !!disabled || sendPending;
|
||||
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||
@@ -1071,7 +1079,7 @@ export function ThreadComposer({
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
if (interactionDisabled || files.length === 0) return;
|
||||
secondEnterPromptIdRef.current = null;
|
||||
const { rejected } = enqueue(files);
|
||||
if (rejected.length > 0) {
|
||||
@@ -1080,7 +1088,7 @@ export function ThreadComposer({
|
||||
setInlineError(null);
|
||||
}
|
||||
},
|
||||
[enqueue, formatRejection],
|
||||
[enqueue, formatRejection, interactionDisabled],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -1093,18 +1101,20 @@ export function ThreadComposer({
|
||||
} = useClipboardAndDrop(addFiles);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || hasTouchPrimaryPointer) return;
|
||||
if (interactionDisabled || hasTouchPrimaryPointer || (workspaceError && showProjectPicker)) {
|
||||
return;
|
||||
}
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const id = requestAnimationFrame(() => el.focus());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [disabled, hasTouchPrimaryPointer]);
|
||||
}, [hasTouchPrimaryPointer, interactionDisabled, showProjectPicker, workspaceError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusRequest || disabled) return;
|
||||
if (!focusRequest || interactionDisabled) return;
|
||||
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [disabled, focusRequest]);
|
||||
}, [focusRequest, interactionDisabled]);
|
||||
|
||||
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
||||
|
||||
@@ -1118,15 +1128,17 @@ export function ThreadComposer({
|
||||
|
||||
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
|
||||
const canSend =
|
||||
!disabled
|
||||
!interactionDisabled
|
||||
&& !modelNeedsSetup
|
||||
&& !encoding
|
||||
&& !hasErrors
|
||||
&& hasComposerContent;
|
||||
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
|
||||
const canOpenModelSettings = Boolean(
|
||||
modelNeedsSetup && onModelBadgeClick && !interactionDisabled,
|
||||
);
|
||||
const canQueueGuidance =
|
||||
isStreaming
|
||||
&& !disabled
|
||||
&& !interactionDisabled
|
||||
&& !modelNeedsSetup
|
||||
&& !encoding
|
||||
&& !hasErrors
|
||||
@@ -1134,14 +1146,14 @@ export function ThreadComposer({
|
||||
&& !value.trimStart().startsWith("/");
|
||||
|
||||
const slashQuery = useMemo(() => {
|
||||
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
||||
if (interactionDisabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
||||
const commandToken = value.slice(1);
|
||||
if (/\s/.test(commandToken)) return null;
|
||||
return commandToken.toLowerCase();
|
||||
}, [disabled, slashMenuDismissed, value]);
|
||||
}, [interactionDisabled, slashMenuDismissed, value]);
|
||||
|
||||
const skillQuery = useMemo(() => {
|
||||
if (disabled || slashMenuDismissed) return null;
|
||||
if (interactionDisabled || slashMenuDismissed) return null;
|
||||
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
|
||||
@@ -1151,7 +1163,7 @@ export function ThreadComposer({
|
||||
start: match.index,
|
||||
text: match[1].toLowerCase(),
|
||||
};
|
||||
}, [cursorPosition, disabled, slashMenuDismissed, value]);
|
||||
}, [cursorPosition, interactionDisabled, slashMenuDismissed, value]);
|
||||
|
||||
const visibleSlashCommands = useMemo(() => {
|
||||
if (!(isStreaming && onStop)) return slashCommands;
|
||||
@@ -1279,7 +1291,7 @@ export function ThreadComposer({
|
||||
|
||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||
if (disabled || cliAppMenuDismissed) return null;
|
||||
if (interactionDisabled || cliAppMenuDismissed) return null;
|
||||
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
|
||||
@@ -1290,7 +1302,7 @@ export function ThreadComposer({
|
||||
start: caret - query.length - 1,
|
||||
end: caret,
|
||||
};
|
||||
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
||||
}, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
|
||||
|
||||
const availableSessionMentions = useMemo(
|
||||
() => sessionMentionOptions(
|
||||
@@ -1580,7 +1592,7 @@ export function ThreadComposer({
|
||||
}, VOICE_ERROR_VISIBLE_MS);
|
||||
}, [clearVoiceErrorTimers, t]);
|
||||
const voiceRecorder = useVoiceRecorder({
|
||||
disabled,
|
||||
disabled: interactionDisabled,
|
||||
onClearError: clearInlineError,
|
||||
onError: setVoiceError,
|
||||
onTranscript: appendTranscription,
|
||||
@@ -1714,7 +1726,7 @@ export function ThreadComposer({
|
||||
clearDraggedSession();
|
||||
const preview = sessionDragPreview;
|
||||
setSessionDragPreview(null);
|
||||
if (disabled) return true;
|
||||
if (interactionDisabled) return true;
|
||||
const sessionKey = readDraggedSession(event.dataTransfer);
|
||||
const mention = availableSessionMentions.find(
|
||||
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
|
||||
@@ -1732,11 +1744,17 @@ export function ThreadComposer({
|
||||
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
|
||||
);
|
||||
return true;
|
||||
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
|
||||
}, [
|
||||
availableSessionMentions,
|
||||
insertMentionCandidate,
|
||||
interactionDisabled,
|
||||
sessionDragPreview,
|
||||
value.length,
|
||||
]);
|
||||
|
||||
const previewSessionDrop = useCallback((event: React.DragEvent) => {
|
||||
if (!hasDraggedSession(event.dataTransfer)) return false;
|
||||
if (disabled) {
|
||||
if (interactionDisabled) {
|
||||
event.dataTransfer.dropEffect = "none";
|
||||
setSessionDragPreview(null);
|
||||
return true;
|
||||
@@ -1765,7 +1783,7 @@ export function ThreadComposer({
|
||||
: { mention, start, end }
|
||||
));
|
||||
return true;
|
||||
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
|
||||
}, [activeSessionMentions, availableSessionMentions, interactionDisabled, value.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionDragPreview) return;
|
||||
@@ -2008,7 +2026,16 @@ export function ThreadComposer({
|
||||
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
||||
const finalizeActiveTurn =
|
||||
slashLifecycle === "finalize_active_turn";
|
||||
onSend(
|
||||
const finishSend = () => {
|
||||
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
|
||||
setQueuedPrompts([]);
|
||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||
// preview here without affecting the rendered message.
|
||||
clear();
|
||||
clearComposerText(!hasTouchPrimaryPointer);
|
||||
onQuotedContextChange?.(null);
|
||||
};
|
||||
const result = onSend(
|
||||
content,
|
||||
payload,
|
||||
isSlashSideChannel
|
||||
@@ -2019,13 +2046,19 @@ export function ThreadComposer({
|
||||
}
|
||||
: options,
|
||||
);
|
||||
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
|
||||
setQueuedPrompts([]);
|
||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||
// preview here without affecting the rendered message.
|
||||
clear();
|
||||
clearComposerText(!hasTouchPrimaryPointer);
|
||||
onQuotedContextChange?.(null);
|
||||
if (result instanceof Promise) {
|
||||
setSendPending(true);
|
||||
void result
|
||||
.then((accepted) => {
|
||||
if (accepted !== false) finishSend();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to send message", error);
|
||||
})
|
||||
.finally(() => setSendPending(false));
|
||||
return;
|
||||
}
|
||||
if (result !== false) finishSend();
|
||||
}, [
|
||||
activeCliMentionApps,
|
||||
activeMcpPresetMentions,
|
||||
@@ -2168,7 +2201,7 @@ export function ThreadComposer({
|
||||
[removeChip],
|
||||
);
|
||||
|
||||
const attachButtonDisabled = disabled || full;
|
||||
const attachButtonDisabled = interactionDisabled || full;
|
||||
const showVoiceButton = Boolean(onTranscribeAudio);
|
||||
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
|
||||
time: voiceRecorder.elapsedLabel,
|
||||
@@ -2253,7 +2286,7 @@ export function ThreadComposer({
|
||||
isHero
|
||||
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
||||
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||
disabled && "opacity-60",
|
||||
interactionDisabled && "opacity-60",
|
||||
sessionDragPreview && "ring-1 ring-primary/25",
|
||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||
goalState?.active &&
|
||||
@@ -2368,8 +2401,8 @@ export function ThreadComposer({
|
||||
onPaste={onPaste}
|
||||
rows={1}
|
||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
disabled={interactionDisabled}
|
||||
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
inputTextClasses,
|
||||
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||
@@ -2443,7 +2476,7 @@ export function ThreadComposer({
|
||||
) : workspaceScope && !workspaceControlsHidden ? (
|
||||
<WorkspaceAccessMenu
|
||||
scope={workspaceScope}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
disabled={interactionDisabled || workspaceScopeDisabled}
|
||||
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
||||
isHero={isHero}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
@@ -2521,7 +2554,7 @@ export function ThreadComposer({
|
||||
<Button
|
||||
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
||||
size="icon"
|
||||
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
|
||||
disabled={showStopButton ? interactionDisabled : !canSend && !canOpenModelSettings}
|
||||
aria-label={
|
||||
showStopButton
|
||||
? t("thread.composer.stop")
|
||||
@@ -2562,7 +2595,7 @@ export function ThreadComposer({
|
||||
<div className="composer-workspace-drawer-content">
|
||||
<WorkspaceProjectPicker
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
|
||||
disabled={interactionDisabled || workspaceScopeDisabled || !showProjectPicker}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
|
||||
@@ -17,8 +17,11 @@ interface ThreadHeaderProps {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hideSidebarToggle?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideTitle?: boolean;
|
||||
actions?: ReactNode;
|
||||
minimal?: boolean;
|
||||
promptNavigatorAction?: ReactNode;
|
||||
sessionInfoAction?: ReactNode;
|
||||
@@ -33,8 +36,11 @@ export function ThreadHeader({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hideSidebarToggle = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideTitle = false,
|
||||
actions,
|
||||
minimal = false,
|
||||
promptNavigatorAction,
|
||||
sessionInfoAction,
|
||||
@@ -54,6 +60,7 @@ export function ThreadHeader({
|
||||
)}
|
||||
>
|
||||
<div className="relative flex min-w-0 items-center gap-2">
|
||||
{!hideSidebarToggle ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -66,7 +73,8 @@ export function ThreadHeader({
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!minimal ? (
|
||||
) : null}
|
||||
{!minimal && !hideTitle ? (
|
||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||
</div>
|
||||
@@ -76,6 +84,7 @@ export function ThreadHeader({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{sessionInfoAction}
|
||||
{promptNavigatorAction}
|
||||
{actions}
|
||||
{onTemporaryChatEnabledChange ? (
|
||||
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
@@ -311,9 +312,17 @@ interface ThreadShellProps {
|
||||
theme?: "light" | "dark";
|
||||
onToggleTheme?: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hideSidebarToggle?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideHeaderTitle?: boolean;
|
||||
hideHeader?: boolean;
|
||||
headerActions?: ReactNode;
|
||||
headerPortalTarget?: HTMLElement | null;
|
||||
headerActive?: boolean;
|
||||
composerPortalTarget?: HTMLElement | null;
|
||||
composerActive?: boolean;
|
||||
composerInputAriaLabel?: string;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
@@ -598,9 +607,17 @@ export function ThreadShell({
|
||||
theme = "light",
|
||||
onToggleTheme = () => {},
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hideSidebarToggle = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideHeaderTitle = false,
|
||||
hideHeader = false,
|
||||
headerActions,
|
||||
headerPortalTarget,
|
||||
headerActive = true,
|
||||
composerPortalTarget,
|
||||
composerActive = true,
|
||||
composerInputAriaLabel,
|
||||
workspaceScope = null,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
@@ -1261,7 +1278,7 @@ export function ThreadShell({
|
||||
|
||||
const handleWelcomeSend = useCallback(
|
||||
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||
if (booting) return;
|
||||
if (booting) return false;
|
||||
setBooting(true);
|
||||
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
||||
setPendingFirstTargetChatId(null);
|
||||
@@ -1270,12 +1287,13 @@ export function ThreadShell({
|
||||
pendingFirstRef.current = null;
|
||||
setPendingFirstTargetChatId(null);
|
||||
setBooting(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (localModelPreset) {
|
||||
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
|
||||
}
|
||||
setPendingFirstTargetChatId(newId);
|
||||
return true;
|
||||
},
|
||||
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
|
||||
);
|
||||
@@ -1404,6 +1422,7 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleThreadSend}
|
||||
disabled={!chatId}
|
||||
inputAriaLabel={composerInputAriaLabel}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
showHeroComposer
|
||||
@@ -1448,6 +1467,7 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleWelcomeSend}
|
||||
disabled={booting}
|
||||
inputAriaLabel={composerInputAriaLabel}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
booting
|
||||
@@ -1507,18 +1527,18 @@ export function ThreadShell({
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{!hideHeader ? (
|
||||
const threadHeader = !hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||
hideSidebarToggle={hideSidebarToggle}
|
||||
hostChromeTitleInset={hostChromeTitleInset}
|
||||
hideThemeButton={hideThemeButton}
|
||||
hideTitle={hideHeaderTitle}
|
||||
actions={headerActions}
|
||||
minimal={!session && !loading}
|
||||
promptNavigatorAction={promptNavigatorAction}
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
@@ -1528,7 +1548,12 @@ export function ThreadShell({
|
||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{headerPortalTarget === undefined ? threadHeader : null}
|
||||
<FilePreviewAvailabilityProvider
|
||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||
>
|
||||
@@ -1538,7 +1563,7 @@ export function ThreadShell({
|
||||
temporary={temporary}
|
||||
isStreaming={turnActive}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
composer={composerPortalTarget === undefined ? composer : null}
|
||||
activeTurnId={viewportTurnId}
|
||||
activeTurnStartedHere={activeTurnStartedHere}
|
||||
conversationKey={historyKey}
|
||||
@@ -1558,6 +1583,19 @@ export function ThreadShell({
|
||||
/>
|
||||
</FilePreviewAvailabilityProvider>
|
||||
</div>
|
||||
{headerPortalTarget && headerActive
|
||||
? createPortal(threadHeader, headerPortalTarget)
|
||||
: null}
|
||||
{composerPortalTarget ? createPortal(
|
||||
<div
|
||||
hidden={!composerActive}
|
||||
aria-hidden={!composerActive}
|
||||
data-testid={composerActive ? "active-pane-composer" : undefined}
|
||||
>
|
||||
{composer}
|
||||
</div>,
|
||||
composerPortalTarget,
|
||||
) : null}
|
||||
{filePreviewPath && historyKey ? (
|
||||
<FilePreviewPanel
|
||||
sessionKey={historyKey}
|
||||
|
||||
@@ -37,7 +37,7 @@ interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
temporary?: boolean;
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
composer?: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
scrollToBottomSignal?: number;
|
||||
activeTurnId?: string | null;
|
||||
@@ -61,6 +61,7 @@ interface ThreadViewportProps {
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const NEAR_TOP_PX = 96;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
@@ -266,11 +267,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||
? forkBoundaryMessageCount - hiddenMessageCount
|
||||
: null;
|
||||
const hasComposer = composer !== null && composer !== undefined;
|
||||
const scrollButtonBottom =
|
||||
keyboardInsetBottom
|
||||
+ (composerDockHeight > 0
|
||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
||||
: hasComposer
|
||||
? DEFAULT_SCROLL_BUTTON_BOTTOM_PX
|
||||
: EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX);
|
||||
const scrollViewportStyle =
|
||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||
|
||||
@@ -661,7 +665,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
||||
data-layout={hasMessages ? "thread" : "hero"}
|
||||
data-layout={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
|
||||
className={cn(
|
||||
"thread-layout mx-auto grid min-h-full w-full",
|
||||
hasMessages
|
||||
@@ -699,11 +703,17 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
|
||||
<div
|
||||
className={cn(
|
||||
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
|
||||
hasComposer && "sm:items-end sm:pb-8",
|
||||
)}
|
||||
>
|
||||
{emptyState}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasComposer ? (
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
@@ -746,11 +756,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasComposer ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -34,6 +34,14 @@ import {
|
||||
shortWorkspacePath,
|
||||
} from "@/lib/workspace";
|
||||
|
||||
function workspacePathPlaceholder(defaultWorkspacePath: string, macPlaceholder: string): string {
|
||||
const normalized = defaultWorkspacePath.trim().replace(/\\/g, "/");
|
||||
const windowsDrive = normalized.match(/^([A-Za-z]):\//)?.[1];
|
||||
if (windowsDrive) return `${windowsDrive.toUpperCase()}:\\path\\to\\project`;
|
||||
if (normalized.startsWith("/Users/")) return macPlaceholder;
|
||||
return "/home/name/project";
|
||||
}
|
||||
|
||||
export function WorkspaceProjectPicker({
|
||||
isHero,
|
||||
compact = false,
|
||||
@@ -60,6 +68,9 @@ export function WorkspaceProjectPicker({
|
||||
const [pathDraft, setPathDraft] = useState("");
|
||||
const [pathError, setPathError] = useState<string | null>(null);
|
||||
const [pickingFolder, setPickingFolder] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
const pathErrorId = useId();
|
||||
const currentProjectScope = selectedProjectScope(scope, defaultScope);
|
||||
const projectLabel = currentProjectScope
|
||||
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
|
||||
@@ -82,9 +93,17 @@ export function WorkspaceProjectPicker({
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error && visible && !disabled) setOpen(true);
|
||||
if (!error || !visible || disabled) return;
|
||||
const frame = window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [disabled, error, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !error) return;
|
||||
const frame = window.requestAnimationFrame(() => pathInputRef.current?.focus());
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [error, open]);
|
||||
|
||||
const applyProjectPath = useCallback(
|
||||
(projectPath: string, projectName?: string) => {
|
||||
const base = scope ?? defaultScope;
|
||||
@@ -129,6 +148,7 @@ export function WorkspaceProjectPicker({
|
||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||
)}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled || pickingFolder}
|
||||
aria-label={t("thread.composer.workspace.projectAria")}
|
||||
@@ -164,6 +184,7 @@ export function WorkspaceProjectPicker({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.workspace.projectAria")}
|
||||
@@ -221,14 +242,20 @@ export function WorkspaceProjectPicker({
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={pathDraft}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
setPathDraft(event.target.value);
|
||||
setPathError(null);
|
||||
}}
|
||||
placeholder={t("workspace.dialog.manualPlaceholder")}
|
||||
placeholder={workspacePathPlaceholder(
|
||||
defaultScope.project_path,
|
||||
t("workspace.dialog.manualPlaceholder"),
|
||||
)}
|
||||
aria-label={t("workspace.dialog.manual")}
|
||||
aria-invalid={pathError || error ? true : undefined}
|
||||
aria-describedby={pathError || error ? pathErrorId : undefined}
|
||||
className={cn(
|
||||
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
|
||||
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
|
||||
@@ -243,13 +270,22 @@ export function WorkspaceProjectPicker({
|
||||
</Button>
|
||||
</form>
|
||||
{pathError || error ? (
|
||||
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
|
||||
<p
|
||||
id={pathErrorId}
|
||||
role="alert"
|
||||
className="px-1 text-[11.5px] font-medium text-destructive"
|
||||
>
|
||||
{pathError ?? error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!compact && error && !open ? (
|
||||
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Circle } from "lucide-react";
|
||||
import { ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import {
|
||||
floatingItemClassName,
|
||||
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const menuItemClassName =
|
||||
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
||||
@@ -115,6 +116,41 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(menuItemClassName, inset && "pl-8", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
floatingSurfaceClassName,
|
||||
floatingSurfaceMotionClassName,
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),22rem)] min-w-[11rem] overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -123,5 +159,8 @@ export {
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
Columns2,
|
||||
Grid2X2,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Rows2,
|
||||
Square,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type FocusEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface WorkbenchPane {
|
||||
key: string;
|
||||
reactKey?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface PaneRenderContext {
|
||||
active: boolean;
|
||||
headerPortalTarget: HTMLElement | null | undefined;
|
||||
composerPortalTarget: HTMLElement | null | undefined;
|
||||
headerActions: ReactNode;
|
||||
}
|
||||
|
||||
interface PaneWorkbenchProps {
|
||||
panes: WorkbenchPane[];
|
||||
activePaneKey: string;
|
||||
layout: WorkbenchLayout;
|
||||
chrome?: boolean;
|
||||
addPaneDisabled?: boolean;
|
||||
onActivatePane: (key: string) => void;
|
||||
onAddPane: () => void;
|
||||
onLayoutChange: (layout: WorkbenchLayout) => void;
|
||||
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
|
||||
}
|
||||
|
||||
const LAYOUT_MOTION_DURATION_MS = 260;
|
||||
const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)";
|
||||
|
||||
const LAYOUT_CONTROLS: Array<{
|
||||
icon: LucideIcon;
|
||||
layout: WorkbenchLayout;
|
||||
label: string;
|
||||
}> = [
|
||||
{ icon: Columns2, layout: "columns", label: "Columns" },
|
||||
{ icon: Rows2, layout: "rows", label: "Rows" },
|
||||
{ icon: Grid2X2, layout: "grid", label: "Grid" },
|
||||
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
|
||||
{ icon: Square, layout: "monocle", label: "Monocle" },
|
||||
];
|
||||
|
||||
function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSProperties {
|
||||
const count = Math.max(1, paneCount);
|
||||
switch (layout) {
|
||||
case "columns":
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
};
|
||||
case "rows":
|
||||
return {
|
||||
gridTemplateColumns: "minmax(0, 1fr)",
|
||||
gridTemplateRows: `repeat(${count}, minmax(0, 1fr))`,
|
||||
};
|
||||
case "grid": {
|
||||
const columns = Math.ceil(Math.sqrt(count));
|
||||
const rows = Math.ceil(count / columns);
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
||||
};
|
||||
}
|
||||
case "main-stack":
|
||||
return count === 1
|
||||
? {
|
||||
gridTemplateColumns: "minmax(0, 1fr)",
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
}
|
||||
: {
|
||||
gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)",
|
||||
gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`,
|
||||
};
|
||||
case "monocle":
|
||||
return {
|
||||
gridTemplateColumns: "minmax(0, 1fr)",
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function paneCellStyle(
|
||||
layout: WorkbenchLayout,
|
||||
paneCount: number,
|
||||
index: number,
|
||||
): CSSProperties | undefined {
|
||||
if (layout !== "main-stack" || paneCount < 2) return undefined;
|
||||
return index === 0
|
||||
? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` }
|
||||
: { gridColumn: 2, gridRow: index };
|
||||
}
|
||||
|
||||
function isPaneAction(target: EventTarget | null): boolean {
|
||||
return target instanceof Element
|
||||
&& target.closest("[data-workbench-pane-action]") !== null;
|
||||
}
|
||||
|
||||
function HeaderIconButton({
|
||||
disabled,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="host-no-drag h-8 w-8 shrink-0 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneWorkbench({
|
||||
panes,
|
||||
activePaneKey,
|
||||
layout,
|
||||
chrome = true,
|
||||
addPaneDisabled = false,
|
||||
onActivatePane,
|
||||
onAddPane,
|
||||
onLayoutChange,
|
||||
renderPane,
|
||||
}: PaneWorkbenchProps) {
|
||||
const { t } = useTranslation();
|
||||
const compact = useMediaQuery("(max-width: 767px)");
|
||||
const effectiveLayout = compact ? "monocle" : layout;
|
||||
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
|
||||
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
|
||||
const paneRefs = useRef(new Map<string, HTMLElement>());
|
||||
const lastRectsRef = useRef(new Map<string, DOMRect>());
|
||||
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
||||
const animationsRef = useRef(new Map<string, Animation>());
|
||||
const paneOrder = useMemo(() => panes.map((pane) => pane.key).join("\u0000"), [panes]);
|
||||
|
||||
const measurePanes = useCallback(() => {
|
||||
const rects = new Map<string, DOMRect>();
|
||||
for (const [key, element] of paneRefs.current) {
|
||||
if (!element.hidden) rects.set(key, element.getBoundingClientRect());
|
||||
}
|
||||
return rects;
|
||||
}, []);
|
||||
|
||||
const captureLayout = useCallback(() => {
|
||||
pendingRectsRef.current = measurePanes();
|
||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||
animationsRef.current.clear();
|
||||
}, [measurePanes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
|
||||
pendingRectsRef.current = null;
|
||||
const nextRects = measurePanes();
|
||||
const reduceMotion = typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
if (!reduceMotion) {
|
||||
for (const [key, nextRect] of nextRects) {
|
||||
const previousRect = previousRects.get(key);
|
||||
const element = paneRefs.current.get(key);
|
||||
if (!element) continue;
|
||||
if (!previousRect) {
|
||||
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
|
||||
const animation = element.animate(
|
||||
[
|
||||
{ opacity: 0, transform: "translateY(5px) scale(0.995)" },
|
||||
{ opacity: 1, transform: "translateY(0) scale(1)" },
|
||||
],
|
||||
{
|
||||
duration: 180,
|
||||
easing: LAYOUT_MOTION_EASING,
|
||||
fill: "backwards",
|
||||
},
|
||||
);
|
||||
animationsRef.current.set(key, animation);
|
||||
animation.addEventListener("finish", () => {
|
||||
if (animationsRef.current.get(key) === animation) {
|
||||
animationsRef.current.delete(key);
|
||||
}
|
||||
}, { once: true });
|
||||
continue;
|
||||
}
|
||||
if (previousRect.width === 0 || previousRect.height === 0) continue;
|
||||
const deltaX = previousRect.left - nextRect.left;
|
||||
const deltaY = previousRect.top - nextRect.top;
|
||||
const scaleX = previousRect.width / nextRect.width;
|
||||
const scaleY = previousRect.height / nextRect.height;
|
||||
if (
|
||||
Math.abs(deltaX) < 0.5
|
||||
&& Math.abs(deltaY) < 0.5
|
||||
&& Math.abs(scaleX - 1) < 0.002
|
||||
&& Math.abs(scaleY - 1) < 0.002
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (typeof element.animate !== "function") continue;
|
||||
const animation = element.animate(
|
||||
[
|
||||
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` },
|
||||
{ transform: "translate(0, 0) scale(1, 1)" },
|
||||
],
|
||||
{
|
||||
duration: LAYOUT_MOTION_DURATION_MS,
|
||||
easing: LAYOUT_MOTION_EASING,
|
||||
},
|
||||
);
|
||||
animationsRef.current.set(key, animation);
|
||||
animation.addEventListener("finish", () => {
|
||||
if (animationsRef.current.get(key) === animation) {
|
||||
animationsRef.current.delete(key);
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
lastRectsRef.current = nextRects;
|
||||
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||
}, []);
|
||||
|
||||
const activatePane = useCallback((key: string, target: EventTarget | null) => {
|
||||
if (key === activePaneKey || isPaneAction(target)) return;
|
||||
captureLayout();
|
||||
onActivatePane(key);
|
||||
}, [activePaneKey, captureLayout, onActivatePane]);
|
||||
|
||||
const handlePanePointerDown = useCallback((
|
||||
key: string,
|
||||
event: PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
activatePane(key, event.target);
|
||||
}, [activatePane]);
|
||||
|
||||
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
|
||||
activatePane(key, event.target);
|
||||
}, [activatePane]);
|
||||
|
||||
const gridStyle = paneGridStyle(effectiveLayout, panes.length);
|
||||
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
|
||||
?? LAYOUT_CONTROLS[0];
|
||||
const headerActions = chrome ? (
|
||||
<div
|
||||
data-workbench-pane-action
|
||||
className="host-no-drag flex items-center gap-0.5"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("workbench.layout", {
|
||||
defaultValue: "Pane layout",
|
||||
})}
|
||||
className="host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<currentLayout.icon className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("workbench.layout", { defaultValue: "Pane layout" })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={layout}
|
||||
onValueChange={(value) => {
|
||||
const next = value as WorkbenchLayout;
|
||||
if (next === layout) return;
|
||||
captureLayout();
|
||||
onLayoutChange(next);
|
||||
}}
|
||||
>
|
||||
{LAYOUT_CONTROLS.map((control) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={control.layout}
|
||||
value={control.layout}
|
||||
>
|
||||
<control.icon aria-hidden />
|
||||
{t(`workbench.layouts.${control.layout}`, {
|
||||
defaultValue: control.label,
|
||||
})}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<HeaderIconButton
|
||||
disabled={addPaneDisabled}
|
||||
icon={Plus}
|
||||
label={t("workbench.addPane", { defaultValue: "Add pane" })}
|
||||
onClick={() => {
|
||||
captureLayout();
|
||||
onAddPane();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t("workbench.aria", { defaultValue: "Conversation workbench" })}
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
|
||||
>
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
{chrome ? (
|
||||
<header className="shrink-0 bg-background">
|
||||
<div
|
||||
ref={setHeaderPortalTarget}
|
||||
data-testid="workbench-header-host"
|
||||
/>
|
||||
</header>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 bg-background">
|
||||
<div
|
||||
data-testid="pane-grid"
|
||||
data-layout={effectiveLayout}
|
||||
className={cn(
|
||||
"grid h-full min-h-0 min-w-0 overflow-hidden",
|
||||
chrome && panes.length > 1 && "gap-px bg-border/55",
|
||||
)}
|
||||
style={gridStyle}
|
||||
>
|
||||
{panes.map((pane, index) => {
|
||||
const active = pane.key === activePaneKey;
|
||||
const hidden = effectiveLayout === "monocle" && !active;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={pane.reactKey ?? pane.key}
|
||||
ref={(element) => {
|
||||
if (element) paneRefs.current.set(pane.key, element);
|
||||
else paneRefs.current.delete(pane.key);
|
||||
}}
|
||||
hidden={hidden}
|
||||
aria-label={pane.title}
|
||||
data-active={active ? "true" : "false"}
|
||||
data-testid={`workbench-pane-${pane.key}`}
|
||||
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
|
||||
onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
|
||||
className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
style={paneCellStyle(effectiveLayout, panes.length, index)}
|
||||
>
|
||||
{renderPane(pane, {
|
||||
active,
|
||||
headerPortalTarget: chrome ? headerPortalTarget : undefined,
|
||||
composerPortalTarget: chrome ? composerPortalTarget : undefined,
|
||||
headerActions,
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chrome ? (
|
||||
<footer className="shrink-0 bg-background px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
||||
<div
|
||||
ref={setComposerPortalTarget}
|
||||
data-testid="workbench-composer-host"
|
||||
className="mx-auto w-full max-w-[58rem]"
|
||||
/>
|
||||
</footer>
|
||||
) : null}
|
||||
</TooltipProvider>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
export const WORKBENCH_STORAGE_KEY = "nanobot.webui.workbench.v2";
|
||||
export const MAX_WORKBENCH_PANES = 4;
|
||||
|
||||
export const WORKBENCH_LAYOUTS = [
|
||||
"columns",
|
||||
"rows",
|
||||
"grid",
|
||||
"main-stack",
|
||||
"monocle",
|
||||
] as const;
|
||||
|
||||
export type WorkbenchLayout = (typeof WORKBENCH_LAYOUTS)[number];
|
||||
|
||||
export interface WorkbenchTabState {
|
||||
paneKeys: string[];
|
||||
activePaneKey: string;
|
||||
layout: WorkbenchLayout;
|
||||
}
|
||||
|
||||
export interface WorkbenchState {
|
||||
version: 2;
|
||||
tabs: Record<string, WorkbenchTabState>;
|
||||
}
|
||||
|
||||
export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
|
||||
version: 2,
|
||||
tabs: {},
|
||||
};
|
||||
|
||||
function isLayout(value: unknown): value is WorkbenchLayout {
|
||||
return typeof value === "string"
|
||||
&& (WORKBENCH_LAYOUTS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function uniqueKeys(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return Array.from(new Set(
|
||||
value.filter((key): key is string => typeof key === "string" && key.length > 0),
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeTab(value: unknown, tabKey: string): WorkbenchTabState {
|
||||
const candidate = value && typeof value === "object"
|
||||
? value as Partial<WorkbenchTabState>
|
||||
: {};
|
||||
const paneKeys = uniqueKeys(candidate.paneKeys);
|
||||
const normalizedPaneKeys = (paneKeys.includes(tabKey)
|
||||
? paneKeys
|
||||
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
|
||||
return {
|
||||
paneKeys: normalizedPaneKeys,
|
||||
activePaneKey:
|
||||
typeof candidate.activePaneKey === "string"
|
||||
&& normalizedPaneKeys.includes(candidate.activePaneKey)
|
||||
? candidate.activePaneKey
|
||||
: normalizedPaneKeys[0],
|
||||
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkbenchState(serialized: string | null): WorkbenchState {
|
||||
if (!serialized) return EMPTY_WORKBENCH_STATE;
|
||||
try {
|
||||
const parsed = JSON.parse(serialized) as { version?: unknown; tabs?: unknown };
|
||||
if (
|
||||
parsed.version !== 2
|
||||
|| !parsed.tabs
|
||||
|| typeof parsed.tabs !== "object"
|
||||
|| Array.isArray(parsed.tabs)
|
||||
) {
|
||||
return EMPTY_WORKBENCH_STATE;
|
||||
}
|
||||
const tabs = Object.fromEntries(
|
||||
Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab, tabKey)]),
|
||||
);
|
||||
return { version: 2, tabs };
|
||||
} catch {
|
||||
return EMPTY_WORKBENCH_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultWorkbenchTab(tabKey: string): WorkbenchTabState {
|
||||
return {
|
||||
paneKeys: [tabKey],
|
||||
activePaneKey: tabKey,
|
||||
layout: "columns",
|
||||
};
|
||||
}
|
||||
|
||||
export function workbenchTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
): WorkbenchTabState {
|
||||
return state.tabs[tabKey] ?? defaultWorkbenchTab(tabKey);
|
||||
}
|
||||
|
||||
function updateTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
update: (tab: WorkbenchTabState) => WorkbenchTabState,
|
||||
): WorkbenchState {
|
||||
const current = workbenchTab(state, tabKey);
|
||||
const next = update(current);
|
||||
if (state.tabs[tabKey] === next) return state;
|
||||
return {
|
||||
version: 2,
|
||||
tabs: {
|
||||
...state.tabs,
|
||||
[tabKey]: next,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureWorkbenchTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
): WorkbenchState {
|
||||
if (state.tabs[tabKey]) return state;
|
||||
return updateTab(state, tabKey, (tab) => tab);
|
||||
}
|
||||
|
||||
export function addWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => {
|
||||
if (tab.paneKeys.includes(paneKey)) {
|
||||
if (tab.activePaneKey === paneKey) return tab;
|
||||
return { ...tab, activePaneKey: paneKey };
|
||||
}
|
||||
if (tab.paneKeys.length >= MAX_WORKBENCH_PANES) return tab;
|
||||
return {
|
||||
...tab,
|
||||
paneKeys: [...tab.paneKeys, paneKey],
|
||||
activePaneKey: paneKey,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function focusWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => (
|
||||
tab.paneKeys.includes(paneKey) && tab.activePaneKey !== paneKey
|
||||
? { ...tab, activePaneKey: paneKey }
|
||||
: tab
|
||||
));
|
||||
}
|
||||
|
||||
export function detachWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => {
|
||||
const index = tab.paneKeys.indexOf(paneKey);
|
||||
if (index < 0 || paneKey === tabKey || tab.paneKeys.length === 1) return tab;
|
||||
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
|
||||
const activePaneKey = tab.activePaneKey === paneKey
|
||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
||||
: tab.activePaneKey;
|
||||
return { ...tab, paneKeys, activePaneKey };
|
||||
});
|
||||
}
|
||||
|
||||
export function attachWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
targetTabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
if (!targetTabKey || !paneKey || targetTabKey === paneKey) return state;
|
||||
|
||||
const sourceEntry = Object.entries(state.tabs).find(([, tab]) => (
|
||||
tab.paneKeys.includes(paneKey)
|
||||
));
|
||||
const sourceTabKey = sourceEntry?.[0];
|
||||
const sourceTab = sourceEntry?.[1];
|
||||
if (sourceTabKey === targetTabKey) {
|
||||
return focusWorkbenchPane(state, targetTabKey, paneKey);
|
||||
}
|
||||
if (sourceTabKey === paneKey && sourceTab && sourceTab.paneKeys.length > 1) {
|
||||
return state;
|
||||
}
|
||||
const targetBeforeMove = state.tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
|
||||
if (
|
||||
!targetBeforeMove.paneKeys.includes(paneKey)
|
||||
&& targetBeforeMove.paneKeys.length >= MAX_WORKBENCH_PANES
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tabs = { ...state.tabs };
|
||||
if (sourceTabKey && sourceTab) {
|
||||
if (sourceTabKey === paneKey) {
|
||||
delete tabs[sourceTabKey];
|
||||
} else {
|
||||
const index = sourceTab.paneKeys.indexOf(paneKey);
|
||||
const paneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
|
||||
tabs[sourceTabKey] = {
|
||||
...sourceTab,
|
||||
paneKeys,
|
||||
activePaneKey: sourceTab.activePaneKey === paneKey
|
||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
||||
: sourceTab.activePaneKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const targetTab = tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
|
||||
tabs[targetTabKey] = targetTab.paneKeys.includes(paneKey)
|
||||
? { ...targetTab, activePaneKey: paneKey }
|
||||
: {
|
||||
...targetTab,
|
||||
paneKeys: [...targetTab.paneKeys, paneKey],
|
||||
activePaneKey: paneKey,
|
||||
};
|
||||
return { version: 2, tabs };
|
||||
}
|
||||
|
||||
export function promoteWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => {
|
||||
const index = tab.paneKeys.indexOf(paneKey);
|
||||
if (index <= 0) return tab;
|
||||
return {
|
||||
...tab,
|
||||
paneKeys: [paneKey, ...tab.paneKeys.filter((key) => key !== paneKey)],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function setWorkbenchLayout(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
layout: WorkbenchLayout,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => (
|
||||
tab.layout === layout ? tab : { ...tab, layout }
|
||||
));
|
||||
}
|
||||
|
||||
export function reconcileWorkbench(
|
||||
state: WorkbenchState,
|
||||
validKeys: ReadonlySet<string>,
|
||||
): WorkbenchState {
|
||||
const tabs: Record<string, WorkbenchTabState> = {};
|
||||
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
||||
if (!validKeys.has(tabKey)) continue;
|
||||
const paneKeys = tab.paneKeys.filter((key) => validKeys.has(key));
|
||||
const normalizedPaneKeys = (paneKeys.includes(tabKey)
|
||||
? paneKeys
|
||||
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
|
||||
tabs[tabKey] = {
|
||||
...tab,
|
||||
paneKeys: normalizedPaneKeys,
|
||||
activePaneKey: normalizedPaneKeys.includes(tab.activePaneKey)
|
||||
? tab.activePaneKey
|
||||
: normalizedPaneKeys[0],
|
||||
};
|
||||
}
|
||||
const serializedCurrent = JSON.stringify(state.tabs);
|
||||
const serializedNext = JSON.stringify(tabs);
|
||||
return serializedCurrent === serializedNext ? state : { version: 2, tabs };
|
||||
}
|
||||
|
||||
export function workbenchChildPaneKeys(state: WorkbenchState): Set<string> {
|
||||
const childKeys = new Set<string>();
|
||||
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
||||
for (const paneKey of tab.paneKeys) {
|
||||
if (paneKey !== tabKey) childKeys.add(paneKey);
|
||||
}
|
||||
}
|
||||
return childKeys;
|
||||
}
|
||||
@@ -360,6 +360,9 @@
|
||||
.thread-layout[data-layout="thread"] {
|
||||
grid-template-rows: minmax(0, 1fr) auto 0fr;
|
||||
}
|
||||
.thread-layout[data-layout="external"] {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.thread-layout[data-layout="hero"] {
|
||||
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
||||
@@ -564,6 +567,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.workbench-pane {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
||||
@keyframes goal-shell-glow-breathe {
|
||||
0%,
|
||||
|
||||
@@ -10,6 +10,23 @@ import {
|
||||
} from "@/lib/tool-traces";
|
||||
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import {
|
||||
closeReasoningStream,
|
||||
filterCoveredFileEditToolEvents,
|
||||
finalizeStreamedTurn,
|
||||
findActiveAssistantPlaceholderIndex,
|
||||
findFileEditTraceIndex,
|
||||
findStreamingAssistantIndex,
|
||||
isReasoningOnlyPlaceholder,
|
||||
matchesTurn,
|
||||
mergeFileEdits,
|
||||
pruneReasoningOnlyPlaceholders,
|
||||
replaceMessageAt,
|
||||
stampLastAssistantCompletion,
|
||||
stripCoveredFileEditToolHintsFromMessages,
|
||||
turnFieldsFromEvent,
|
||||
} from "@/lib/thread-event-projection";
|
||||
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
|
||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
import type {
|
||||
InboundEvent,
|
||||
@@ -19,11 +36,8 @@ import type {
|
||||
SessionMention,
|
||||
GoalStateWsPayload,
|
||||
MessageDeliveryStatus,
|
||||
ToolProgressEvent,
|
||||
UIMediaAttachment,
|
||||
UIFileEdit,
|
||||
UIMessage,
|
||||
UITurnPhase,
|
||||
WorkspaceScopePayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -41,54 +55,9 @@ type PendingStreamEvent =
|
||||
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
||||
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
||||
|
||||
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
|
||||
|
||||
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
|
||||
const STREAM_END_IDLE_DELAY_MS = 1000;
|
||||
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
||||
|
||||
function turnFieldsFromEvent(
|
||||
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
|
||||
fallbackPhase?: UITurnPhase,
|
||||
): UIMessageTurnFields {
|
||||
const fields: UIMessageTurnFields = {};
|
||||
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
|
||||
fields.turnId = ev.turn_id;
|
||||
}
|
||||
const phase = ev.turn_phase ?? fallbackPhase;
|
||||
if (phase) fields.turnPhase = phase;
|
||||
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
|
||||
fields.turnSeq = ev.turn_seq;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
|
||||
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
|
||||
}
|
||||
|
||||
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
|
||||
* as streaming until ``turn_end`` for visual continuity, but they must not
|
||||
* receive later delta segments. */
|
||||
function findStreamingAssistantIndex(
|
||||
prev: UIMessage[],
|
||||
closedStreamIds: ReadonlySet<string>,
|
||||
turn: UIMessageTurnFields = {},
|
||||
): number | null {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const m = prev[i];
|
||||
if (m.kind === "trace") continue;
|
||||
if (
|
||||
m.role === "assistant"
|
||||
&& m.isStreaming
|
||||
&& !closedStreamIds.has(m.id)
|
||||
&& matchesTurn(m, turn)
|
||||
) return i;
|
||||
if (m.role === "user") break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
|
||||
*
|
||||
@@ -152,102 +121,6 @@ function attachReasoningChunk(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent assistant placeholder that an incoming answer
|
||||
* delta should adopt instead of spawning a parallel row. We look for an
|
||||
* empty-content assistant turn that is still marked ``isStreaming`` —
|
||||
* typically created earlier by ``reasoning_delta``. Anything else means
|
||||
* the model already produced an answer in a previous turn, so the new
|
||||
* delta belongs in a fresh row.
|
||||
*/
|
||||
function findActiveAssistantPlaceholderIndex(
|
||||
prev: UIMessage[],
|
||||
turn: UIMessageTurnFields = {},
|
||||
): number | null {
|
||||
const last = prev[prev.length - 1];
|
||||
if (!last) return null;
|
||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||
if (last.content.length > 0) return null;
|
||||
if (!last.isStreaming) return null;
|
||||
if (!matchesTurn(last, turn)) return null;
|
||||
return prev.length - 1;
|
||||
}
|
||||
|
||||
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
|
||||
const next = prev.slice();
|
||||
next[index] = message;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the active reasoning stream segment, if any. Idempotent: a
|
||||
* ``reasoning_end`` with no preceding deltas is a harmless no-op.
|
||||
*/
|
||||
function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
if (!candidate.reasoningStreaming) continue;
|
||||
const latencyMs =
|
||||
candidate.latencyMs === undefined
|
||||
&& Number.isFinite(candidate.createdAt)
|
||||
&& candidate.createdAt > 1_000_000_000_000
|
||||
? Math.max(0, Math.round(Date.now() - candidate.createdAt))
|
||||
: candidate.latencyMs;
|
||||
const merged: UIMessage = {
|
||||
...candidate,
|
||||
reasoningStreaming: false,
|
||||
...(latencyMs !== undefined ? { latencyMs } : {}),
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
function isReasoningOnlyPlaceholder(message: UIMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant"
|
||||
&& message.kind !== "trace"
|
||||
&& message.content.trim().length === 0
|
||||
&& !!message.reasoning
|
||||
&& !message.reasoningStreaming
|
||||
&& !message.media?.length
|
||||
);
|
||||
}
|
||||
|
||||
function isToolTrace(message: UIMessage | undefined): boolean {
|
||||
return message?.kind === "trace";
|
||||
}
|
||||
|
||||
function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
|
||||
return prev.filter((message, index) => {
|
||||
if (!isReasoningOnlyPlaceholder(message)) return true;
|
||||
// A reasoning-only assistant row immediately followed by tool traces is
|
||||
// the live equivalent of a persisted assistant tool-call message with
|
||||
// empty content, reasoning_content, and tool_calls. Keep it so live render
|
||||
// and history replay stay isomorphic.
|
||||
return isToolTrace(prev[index + 1]);
|
||||
});
|
||||
}
|
||||
|
||||
function stampLastAssistantCompletion(
|
||||
prev: UIMessage[],
|
||||
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
|
||||
turnId?: string,
|
||||
): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const m = prev[i];
|
||||
if (
|
||||
m.role === "assistant"
|
||||
&& m.kind !== "trace"
|
||||
&& (!turnId || !m.turnId || m.turnId === turnId)
|
||||
) {
|
||||
const merged: UIMessage = { ...m, ...completion, isStreaming: false };
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
function absorbCompleteAssistantMessage(
|
||||
prev: UIMessage[],
|
||||
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
||||
@@ -275,193 +148,6 @@ function absorbCompleteAssistantMessage(
|
||||
];
|
||||
}
|
||||
|
||||
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
||||
if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
||||
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
||||
return `${edit.tool}|${edit.path}`;
|
||||
}
|
||||
|
||||
function fileEditToolEventKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
||||
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
||||
return fileEditKey(edit);
|
||||
}
|
||||
|
||||
function toolEventFileEditKey(event: ToolProgressEvent): string | null {
|
||||
const fn = (event as { function?: { name?: unknown } }).function;
|
||||
const name = typeof event.name === "string"
|
||||
? event.name
|
||||
: typeof fn?.name === "string"
|
||||
? fn.name
|
||||
: "";
|
||||
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
||||
if (!name || !callId || !FILE_EDIT_TOOL_NAMES.has(name)) return null;
|
||||
return `${callId}|${name}`;
|
||||
}
|
||||
|
||||
function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent): boolean {
|
||||
const key = toolEventFileEditKey(event);
|
||||
if (!key) return false;
|
||||
return messages.some((message) =>
|
||||
message.fileEdits?.some((edit) => fileEditToolEventKey(edit) === key),
|
||||
);
|
||||
}
|
||||
|
||||
function filterCoveredFileEditToolEvents(
|
||||
messages: UIMessage[],
|
||||
events: ToolProgressEvent[],
|
||||
): ToolProgressEvent[] {
|
||||
if (events.length === 0) return events;
|
||||
return events.filter((event) => !hasFileEditForToolEvent(messages, event));
|
||||
}
|
||||
|
||||
function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage {
|
||||
const incomingKeys = new Set(edits.map(fileEditToolEventKey));
|
||||
const events = message.toolEvents ?? [];
|
||||
if (!events.length || incomingKeys.size === 0) return message;
|
||||
|
||||
const removedTraceLines = new Set<string>();
|
||||
const keptEvents: ToolProgressEvent[] = [];
|
||||
let changed = false;
|
||||
for (const event of events) {
|
||||
const key = toolEventFileEditKey(event);
|
||||
if (key && incomingKeys.has(key)) {
|
||||
changed = true;
|
||||
for (const line of toolTraceLinesFromEvents([event])) {
|
||||
removedTraceLines.add(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
keptEvents.push(event);
|
||||
}
|
||||
if (!changed) return message;
|
||||
|
||||
const previousTraces = message.traces?.length
|
||||
? message.traces
|
||||
: message.content
|
||||
? [message.content]
|
||||
: [];
|
||||
const nextTraces = previousTraces.filter((line) => !removedTraceLines.has(line));
|
||||
return {
|
||||
...message,
|
||||
traces: nextTraces,
|
||||
content: nextTraces[nextTraces.length - 1] ?? "",
|
||||
toolEvents: keptEvents.length ? keptEvents : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function traceMessageIsEmpty(message: UIMessage): boolean {
|
||||
const traces = message.traces;
|
||||
const hasTrace = traces?.length
|
||||
? traces.some((line) => line.trim().length > 0)
|
||||
: (message.content ?? "").trim().length > 0;
|
||||
return (
|
||||
message.kind === "trace"
|
||||
&& !hasTrace
|
||||
&& !message.toolEvents?.length
|
||||
&& !message.fileEdits?.length
|
||||
&& !message.media?.length
|
||||
);
|
||||
}
|
||||
|
||||
function stripCoveredFileEditToolHintsFromMessages(
|
||||
messages: UIMessage[],
|
||||
edits: UIFileEdit[],
|
||||
turn: UIMessageTurnFields,
|
||||
): UIMessage[] {
|
||||
if (edits.length === 0) return messages;
|
||||
let next = messages;
|
||||
for (let i = next.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = next[i];
|
||||
if (candidate.role === "user") break;
|
||||
if (candidate.kind !== "trace") continue;
|
||||
if (!matchesTurn(candidate, turn)) continue;
|
||||
const cleaned = stripCoveredFileEditToolHints(candidate, edits);
|
||||
if (cleaned === candidate) continue;
|
||||
if (next === messages) next = [...messages];
|
||||
if (traceMessageIsEmpty(cleaned)) {
|
||||
next.splice(i, 1);
|
||||
} else {
|
||||
next[i] = cleaned;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
|
||||
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
|
||||
const inferredStatus =
|
||||
edit.phase === "error"
|
||||
? "error"
|
||||
: edit.phase === "end"
|
||||
? "done"
|
||||
: "editing";
|
||||
const normalized: UIFileEdit = {
|
||||
...edit,
|
||||
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
|
||||
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
|
||||
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
|
||||
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
|
||||
? edit.status
|
||||
: inferredStatus,
|
||||
};
|
||||
if (edit.pending && !edit.path) normalized.pending = true;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
|
||||
const next = [...(existing ?? [])];
|
||||
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
|
||||
for (const raw of incoming) {
|
||||
const edit = normalizeFileEdit(raw);
|
||||
if (!edit) continue;
|
||||
const key = fileEditKey(edit);
|
||||
let existingIndex = indexByKey.get(key);
|
||||
if (existingIndex === undefined && edit.path) {
|
||||
const eventKey = fileEditToolEventKey(edit);
|
||||
const pendingIndex = next.findIndex((existing) =>
|
||||
!existing.path && existing.pending && fileEditToolEventKey(existing) === eventKey,
|
||||
);
|
||||
if (pendingIndex >= 0) existingIndex = pendingIndex;
|
||||
}
|
||||
if (existingIndex === undefined) {
|
||||
indexByKey.set(key, next.length);
|
||||
next.push(edit);
|
||||
continue;
|
||||
}
|
||||
const merged = { ...next[existingIndex], ...edit };
|
||||
if (edit.path && !edit.pending) delete merged.pending;
|
||||
next[existingIndex] = merged;
|
||||
indexByKey.set(key, existingIndex);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function findFileEditTraceIndex(
|
||||
prev: UIMessage[],
|
||||
segmentId: string | null,
|
||||
incoming: UIFileEdit[],
|
||||
): number | null {
|
||||
const incomingKeys = new Set(incoming.map(fileEditKey));
|
||||
const incomingToolEventKeys = new Set(incoming.map(fileEditToolEventKey));
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
if (candidate.role === "user") break;
|
||||
if (candidate.kind !== "trace") continue;
|
||||
if (segmentId && candidate.activitySegmentId === segmentId) return i;
|
||||
for (const existing of candidate.fileEdits ?? []) {
|
||||
if (
|
||||
incomingKeys.has(fileEditKey(existing))
|
||||
|| (
|
||||
!existing.path
|
||||
&& existing.pending
|
||||
&& incomingToolEventKeys.has(fileEditToolEventKey(existing))
|
||||
)
|
||||
) return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
||||
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
||||
@@ -507,17 +193,6 @@ function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
||||
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
||||
}
|
||||
|
||||
function finalizeStreamedTurn(
|
||||
prev: UIMessage[],
|
||||
turn: UIMessageTurnFields = {},
|
||||
): UIMessage[] {
|
||||
return prev.map((m) =>
|
||||
m.isStreaming && matchesTurn(m, turn)
|
||||
? { ...m, isStreaming: false, reasoningStreaming: false }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
|
||||
function eventTurnId(ev: InboundEvent): string | undefined {
|
||||
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||
}
|
||||
@@ -1104,7 +779,7 @@ export function useNanobotStream(
|
||||
|
||||
if (ev.event === "reasoning_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
setMessages((prev) => closeReasoningStream(prev));
|
||||
setMessages((prev) => closeReasoningStream(prev, Date.now()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1174,12 +849,15 @@ export function useNanobotStream(
|
||||
const line = ev.text;
|
||||
if (!line) return;
|
||||
if (fileEditSegmentRef.current) clearActivitySegment();
|
||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
|
||||
setMessages((prev) => closeReasoningStream(
|
||||
attachReasoningChunk(
|
||||
prev,
|
||||
line,
|
||||
{ ensure: ensureActivitySegmentId },
|
||||
turnFieldsFromEvent(ev, "reasoning"),
|
||||
)));
|
||||
),
|
||||
Date.now(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||
|
||||
@@ -257,13 +257,13 @@ export function useSessions(): {
|
||||
|
||||
const deleteChat = useCallback(
|
||||
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||
const result = await apiDeleteSession(tokenRef.current, key, options);
|
||||
const result = await apiDeleteSession(client, key, options);
|
||||
if (!result.deleted) return result;
|
||||
optimisticKeysRef.current.delete(key);
|
||||
setSessions((prev) => prev.filter((s) => s.key !== key));
|
||||
return result;
|
||||
},
|
||||
[],
|
||||
[client],
|
||||
);
|
||||
|
||||
const getSessionAutomations = useCallback(async (key: string) => {
|
||||
|
||||
@@ -144,6 +144,8 @@ export function useSidebarState(
|
||||
const { client, token } = useClient();
|
||||
const tokenRef = useRef(token);
|
||||
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
||||
const connectionOpenRef = useRef(client.status === "open");
|
||||
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
|
||||
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
||||
const [loading, setLoading] = useState(true);
|
||||
tokenRef.current = token;
|
||||
@@ -171,14 +173,32 @@ export function useSidebarState(
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persist = useCallback((next: SidebarStatePayload) => {
|
||||
if (!connectionOpenRef.current) {
|
||||
pendingPersistenceRef.current = next;
|
||||
return;
|
||||
}
|
||||
void client.setSidebarState(next).catch(() => {
|
||||
// Sidebar persistence is best-effort; the optimistic local state remains usable.
|
||||
});
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => client.onStatus((status) => {
|
||||
connectionOpenRef.current = status === "open";
|
||||
if (status !== "open" || pendingPersistenceRef.current === null) return;
|
||||
const pending = pendingPersistenceRef.current;
|
||||
pendingPersistenceRef.current = null;
|
||||
persist(pending);
|
||||
}), [client, persist]);
|
||||
|
||||
const update = useCallback(
|
||||
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
||||
const next = normalizeSidebarState(updater(stateRef.current));
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
client.setSidebarState(next);
|
||||
persist(next);
|
||||
},
|
||||
[client],
|
||||
[persist],
|
||||
);
|
||||
|
||||
const pruned = useMemo(() => {
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication required",
|
||||
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
|
||||
"placeholder": "Password",
|
||||
"label": "Password",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"submit": "Connect",
|
||||
"invalid": "Invalid password. Try again."
|
||||
"required": "Enter your password.",
|
||||
"invalid": "Incorrect password. Try again."
|
||||
},
|
||||
"account": {
|
||||
"section": "Account",
|
||||
@@ -595,7 +596,14 @@
|
||||
"searchPlaceholder": "Search tools",
|
||||
"featured": "Tools",
|
||||
"loading": "Loading Apps...",
|
||||
"empty": "No tools match this view.",
|
||||
"empty": "No tools match your search.",
|
||||
"emptyApps": "No apps available.",
|
||||
"emptyIntegrations": "No integrations available.",
|
||||
"emptyReady": "No tools are ready yet.",
|
||||
"clearSearch": "Clear search",
|
||||
"browseApps": "Browse apps",
|
||||
"browseIntegrations": "Browse integrations",
|
||||
"emptyIntegrationsHint": "Add a custom integration below.",
|
||||
"restartRequired": "Restart nanobot to apply updated apps and integrations."
|
||||
},
|
||||
"channels": {
|
||||
@@ -698,7 +706,9 @@
|
||||
"loading": "Loading automations...",
|
||||
"noMatches": "No automations match this view.",
|
||||
"empty": "No automations yet.",
|
||||
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
|
||||
"emptyHint": "Create automations in a chat so they keep the right context.",
|
||||
"emptyAction": "Open a chat",
|
||||
"clearFilters": "Clear filters",
|
||||
"oneShot": "One-time",
|
||||
"systemTask": "System-managed automation",
|
||||
"localTrigger": "Local trigger",
|
||||
@@ -955,6 +965,10 @@
|
||||
"unarchive": "Unarchive",
|
||||
"showArchived": "Show archived",
|
||||
"hideArchived": "Hide archived",
|
||||
"select": "Select",
|
||||
"cancelSelection": "Cancel selection",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"deleteSelected": "Delete",
|
||||
"delete": "Delete",
|
||||
"newChat": "New topic",
|
||||
"groups": {
|
||||
@@ -969,10 +983,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Delete this topic?",
|
||||
"titleMany": "Delete {{count}} topics and panes?",
|
||||
"description": "This action cannot be undone.",
|
||||
"descriptionMany": "This action cannot be undone.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete",
|
||||
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
||||
"automationsDescriptionMany": "Linked automations will also be deleted.",
|
||||
"moreAutomations": "+ {{count}} more",
|
||||
"confirmWithAutomations": "Delete",
|
||||
"schedule": {
|
||||
@@ -1368,6 +1385,26 @@
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Conversation workbench",
|
||||
"panes": "Panes",
|
||||
"panesInTab": "Panes in {{title}}",
|
||||
"dropPane": "Move {{pane}} into {{tab}}",
|
||||
"moveToTab": "Move to tab",
|
||||
"layout": "Pane layout",
|
||||
"addPane": "Add pane",
|
||||
"promotePane": "Make {{title}} the primary pane",
|
||||
"paneActions": "{{title}} pane actions",
|
||||
"detachPane": "Move {{title}} to its own topic",
|
||||
"composerAria": "Message {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Columns",
|
||||
"rows": "Rows",
|
||||
"grid": "Grid",
|
||||
"main-stack": "Main and stack",
|
||||
"monocle": "Monocle"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Dismiss",
|
||||
"close": "Close",
|
||||
@@ -1381,7 +1418,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace was not changed",
|
||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||
"body": "The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Message was not sent",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Autenticación requerida",
|
||||
"hint": "Introduce el secreto configurado como tokenIssueSecret en la configuración del gateway.",
|
||||
"placeholder": "Contraseña",
|
||||
"label": "Contraseña",
|
||||
"showPassword": "Mostrar contraseña",
|
||||
"hidePassword": "Ocultar contraseña",
|
||||
"submit": "Conectar",
|
||||
"invalid": "Contraseña no válida. Inténtalo de nuevo."
|
||||
"required": "Introduce la contraseña.",
|
||||
"invalid": "Contraseña incorrecta. Inténtalo de nuevo."
|
||||
},
|
||||
"account": {
|
||||
"section": "Cuenta",
|
||||
@@ -582,7 +583,14 @@
|
||||
"searchPlaceholder": "Buscar aplicaciones",
|
||||
"featured": "Herramientas",
|
||||
"loading": "Cargando aplicaciones...",
|
||||
"empty": "Ninguna herramienta coincide con esta vista.",
|
||||
"empty": "Ninguna herramienta coincide con tu búsqueda.",
|
||||
"emptyApps": "No hay aplicaciones disponibles.",
|
||||
"emptyIntegrations": "No hay integraciones disponibles.",
|
||||
"emptyReady": "Todavía no hay herramientas listas.",
|
||||
"clearSearch": "Borrar búsqueda",
|
||||
"browseApps": "Explorar aplicaciones",
|
||||
"browseIntegrations": "Explorar integraciones",
|
||||
"emptyIntegrationsHint": "Añade una integración personalizada abajo.",
|
||||
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
|
||||
},
|
||||
"channels": {
|
||||
@@ -685,7 +693,9 @@
|
||||
"loading": "Cargando automatizaciones...",
|
||||
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
|
||||
"empty": "Aún no hay automatizaciones.",
|
||||
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
|
||||
"emptyHint": "Crea automatizaciones en un chat para que conserven el contexto correcto.",
|
||||
"emptyAction": "Abrir un chat",
|
||||
"clearFilters": "Borrar filtros",
|
||||
"oneShot": "Una vez",
|
||||
"systemTask": "Automatización administrada por el sistema",
|
||||
"localTrigger": "Activador local",
|
||||
@@ -942,6 +952,10 @@
|
||||
"unarchive": "Desarchivar",
|
||||
"showArchived": "Mostrar archivados",
|
||||
"hideArchived": "Ocultar archivados",
|
||||
"select": "Seleccionar",
|
||||
"cancelSelection": "Cancelar selección",
|
||||
"selectedCount": "{{count}} seleccionados",
|
||||
"deleteSelected": "Eliminar",
|
||||
"delete": "Eliminar",
|
||||
"newChat": "Nuevo tema",
|
||||
"groups": {
|
||||
@@ -956,10 +970,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "¿Eliminar este chat?",
|
||||
"titleMany": "¿Eliminar {{count}} chats y paneles?",
|
||||
"description": "Esta acción no se puede deshacer.",
|
||||
"descriptionMany": "Esta acción no se puede deshacer.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Eliminar",
|
||||
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
|
||||
"automationsDescriptionMany": "También se eliminarán las automatizaciones vinculadas.",
|
||||
"moreAutomations": "+ {{count}} más",
|
||||
"confirmWithAutomations": "Eliminar",
|
||||
"schedule": {
|
||||
@@ -1355,6 +1372,26 @@
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Área de conversaciones",
|
||||
"panes": "Paneles",
|
||||
"panesInTab": "Paneles de {{title}}",
|
||||
"dropPane": "Mover {{pane}} a {{tab}}",
|
||||
"moveToTab": "Mover a una pestaña",
|
||||
"layout": "Diseño de paneles",
|
||||
"addPane": "Añadir panel",
|
||||
"promotePane": "Convertir {{title}} en el panel principal",
|
||||
"paneActions": "Acciones del panel {{title}}",
|
||||
"detachPane": "Mover {{title}} a su propio tema",
|
||||
"composerAria": "Mensaje para {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Columnas",
|
||||
"rows": "Filas",
|
||||
"grid": "Cuadrícula",
|
||||
"main-stack": "Principal y pila",
|
||||
"monocle": "Monóculo"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Cerrar",
|
||||
"close": "Cerrar",
|
||||
@@ -1368,7 +1405,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "El espacio de trabajo no cambió",
|
||||
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
|
||||
"body": "El gateway rechazó este proyecto o modo de acceso. Elige un proyecto existente u otro modo de acceso e inténtalo de nuevo."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "El mensaje no se envió",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentification requise",
|
||||
"hint": "Saisissez le secret configuré comme tokenIssueSecret dans la configuration de votre gateway.",
|
||||
"placeholder": "Mot de passe",
|
||||
"label": "Mot de passe",
|
||||
"showPassword": "Afficher le mot de passe",
|
||||
"hidePassword": "Masquer le mot de passe",
|
||||
"submit": "Se connecter",
|
||||
"invalid": "Mot de passe invalide. Réessayez."
|
||||
"required": "Saisissez le mot de passe.",
|
||||
"invalid": "Mot de passe incorrect. Réessayez."
|
||||
},
|
||||
"account": {
|
||||
"section": "Compte",
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "Rechercher des applications",
|
||||
"featured": "Outils",
|
||||
"loading": "Chargement des applications...",
|
||||
"empty": "Aucun outil ne correspond à cette vue.",
|
||||
"empty": "Aucun outil ne correspond à votre recherche.",
|
||||
"emptyApps": "Aucune application disponible.",
|
||||
"emptyIntegrations": "Aucune intégration disponible.",
|
||||
"emptyReady": "Aucun outil n’est encore prêt.",
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"browseApps": "Parcourir les applications",
|
||||
"browseIntegrations": "Parcourir les intégrations",
|
||||
"emptyIntegrationsHint": "Ajoutez une intégration personnalisée ci-dessous.",
|
||||
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "Chargement des automatisations...",
|
||||
"noMatches": "Aucune automatisation ne correspond à cette vue.",
|
||||
"empty": "Aucune automatisation pour le moment.",
|
||||
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
|
||||
"emptyHint": "Créez les automatisations dans un chat afin de conserver le bon contexte.",
|
||||
"emptyAction": "Ouvrir un chat",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"oneShot": "Ponctuelle",
|
||||
"systemTask": "Automatisation gérée par le système",
|
||||
"localTrigger": "Déclencheur local",
|
||||
@@ -941,6 +951,10 @@
|
||||
"unarchive": "Désarchiver",
|
||||
"showArchived": "Afficher les archives",
|
||||
"hideArchived": "Masquer les archives",
|
||||
"select": "Sélectionner",
|
||||
"cancelSelection": "Annuler la sélection",
|
||||
"selectedCount": "{{count}} sélectionnés",
|
||||
"deleteSelected": "Supprimer",
|
||||
"delete": "Supprimer",
|
||||
"newChat": "Nouveau sujet",
|
||||
"groups": {
|
||||
@@ -955,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Supprimer cette discussion ?",
|
||||
"titleMany": "Supprimer {{count}} discussions et volets ?",
|
||||
"description": "Cette action est irréversible.",
|
||||
"descriptionMany": "Cette action est irréversible.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer",
|
||||
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
|
||||
"automationsDescriptionMany": "Les automatisations liées seront également supprimées.",
|
||||
"moreAutomations": "+ {{count}} autres",
|
||||
"confirmWithAutomations": "Supprimer",
|
||||
"schedule": {
|
||||
@@ -1354,6 +1371,26 @@
|
||||
"copy": "Copier",
|
||||
"copied": "Copié"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Espace de conversations",
|
||||
"panes": "Volets",
|
||||
"panesInTab": "Volets dans {{title}}",
|
||||
"dropPane": "Déplacer {{pane}} dans {{tab}}",
|
||||
"moveToTab": "Déplacer vers un onglet",
|
||||
"layout": "Disposition des volets",
|
||||
"addPane": "Ajouter un volet",
|
||||
"promotePane": "Définir {{title}} comme volet principal",
|
||||
"paneActions": "Actions du volet {{title}}",
|
||||
"detachPane": "Déplacer {{title}} vers son propre sujet",
|
||||
"composerAria": "Message à {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Colonnes",
|
||||
"rows": "Lignes",
|
||||
"grid": "Grille",
|
||||
"main-stack": "Principal et pile",
|
||||
"monocle": "Monocle"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Fermer",
|
||||
"close": "Fermer",
|
||||
@@ -1367,7 +1404,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "L’espace de travail n’a pas changé",
|
||||
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
||||
"body": "La passerelle a refusé ce projet ou ce mode d’accès. Choisissez un projet existant ou un autre mode d’accès, puis réessayez."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Le message n’a pas été envoyé",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Autentikasi diperlukan",
|
||||
"hint": "Masukkan secret yang dikonfigurasi sebagai tokenIssueSecret di konfigurasi gateway.",
|
||||
"placeholder": "Kata sandi",
|
||||
"label": "Kata sandi",
|
||||
"showPassword": "Tampilkan kata sandi",
|
||||
"hidePassword": "Sembunyikan kata sandi",
|
||||
"submit": "Hubungkan",
|
||||
"invalid": "Kata sandi tidak valid. Coba lagi."
|
||||
"required": "Masukkan kata sandi.",
|
||||
"invalid": "Kata sandi salah. Coba lagi."
|
||||
},
|
||||
"account": {
|
||||
"section": "Akun",
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "Cari aplikasi",
|
||||
"featured": "Alat",
|
||||
"loading": "Memuat aplikasi...",
|
||||
"empty": "Tidak ada alat yang cocok dengan tampilan ini.",
|
||||
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
||||
"emptyApps": "Tidak ada aplikasi yang tersedia.",
|
||||
"emptyIntegrations": "Tidak ada integrasi yang tersedia.",
|
||||
"emptyReady": "Belum ada alat yang siap.",
|
||||
"clearSearch": "Hapus pencarian",
|
||||
"browseApps": "Jelajahi aplikasi",
|
||||
"browseIntegrations": "Jelajahi integrasi",
|
||||
"emptyIntegrationsHint": "Tambahkan integrasi khusus di bawah.",
|
||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "Memuat otomasi...",
|
||||
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
||||
"empty": "Belum ada otomasi.",
|
||||
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
|
||||
"emptyHint": "Buat otomatisasi di chat agar konteks yang tepat tetap tersimpan.",
|
||||
"emptyAction": "Buka chat",
|
||||
"clearFilters": "Hapus filter",
|
||||
"oneShot": "Satu kali",
|
||||
"systemTask": "Automasi yang dikelola sistem",
|
||||
"localTrigger": "Pemicu lokal",
|
||||
@@ -941,6 +951,10 @@
|
||||
"unarchive": "Batalkan arsip",
|
||||
"showArchived": "Tampilkan yang diarsipkan",
|
||||
"hideArchived": "Sembunyikan yang diarsipkan",
|
||||
"select": "Pilih",
|
||||
"cancelSelection": "Batalkan pilihan",
|
||||
"selectedCount": "{{count}} dipilih",
|
||||
"deleteSelected": "Hapus",
|
||||
"delete": "Hapus",
|
||||
"newChat": "Topik baru",
|
||||
"groups": {
|
||||
@@ -955,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Hapus obrolan ini?",
|
||||
"titleMany": "Hapus {{count}} obrolan dan panel?",
|
||||
"description": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"descriptionMany": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"cancel": "Batal",
|
||||
"confirm": "Hapus",
|
||||
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
|
||||
"automationsDescriptionMany": "Automasi terkait juga akan dihapus.",
|
||||
"moreAutomations": "+ {{count}} lagi",
|
||||
"confirmWithAutomations": "Hapus",
|
||||
"schedule": {
|
||||
@@ -1354,6 +1371,26 @@
|
||||
"copy": "Salin",
|
||||
"copied": "Tersalin"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Ruang kerja percakapan",
|
||||
"panes": "Panel",
|
||||
"panesInTab": "Panel di {{title}}",
|
||||
"dropPane": "Pindahkan {{pane}} ke {{tab}}",
|
||||
"moveToTab": "Pindahkan ke tab",
|
||||
"layout": "Tata letak panel",
|
||||
"addPane": "Tambah panel",
|
||||
"promotePane": "Jadikan {{title}} panel utama",
|
||||
"paneActions": "Tindakan panel {{title}}",
|
||||
"detachPane": "Pindahkan {{title}} ke topik tersendiri",
|
||||
"composerAria": "Pesan untuk {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Kolom",
|
||||
"rows": "Baris",
|
||||
"grid": "Kisi",
|
||||
"main-stack": "Utama dan tumpukan",
|
||||
"monocle": "Panel tunggal"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Tutup",
|
||||
"close": "Tutup",
|
||||
@@ -1367,7 +1404,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Ruang kerja tidak berubah",
|
||||
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai ruang kerja sebelumnya."
|
||||
"body": "Gateway menolak proyek atau mode akses ini. Pilih proyek yang sudah ada atau mode akses lain, lalu coba lagi."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Pesan tidak terkirim",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||
},
|
||||
"auth": {
|
||||
"title": "認証が必要です",
|
||||
"hint": "gateway 設定の tokenIssueSecret に指定されたシークレットを入力してください。",
|
||||
"placeholder": "パスワード",
|
||||
"label": "パスワード",
|
||||
"showPassword": "パスワードを表示",
|
||||
"hidePassword": "パスワードを隠す",
|
||||
"submit": "接続",
|
||||
"invalid": "パスワードが無効です。もう一度お試しください。"
|
||||
"required": "パスワードを入力してください。",
|
||||
"invalid": "パスワードが正しくありません。もう一度お試しください。"
|
||||
},
|
||||
"account": {
|
||||
"section": "アカウント",
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "アプリを検索",
|
||||
"featured": "ツール",
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "この表示に一致するツールはありません。",
|
||||
"empty": "検索条件に一致するツールはありません。",
|
||||
"emptyApps": "利用できるアプリはありません。",
|
||||
"emptyIntegrations": "利用できる連携はありません。",
|
||||
"emptyReady": "使用可能なツールはまだありません。",
|
||||
"clearSearch": "検索をクリア",
|
||||
"browseApps": "アプリを見る",
|
||||
"browseIntegrations": "連携を見る",
|
||||
"emptyIntegrationsHint": "下からカスタム連携を追加できます。",
|
||||
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "自動タスクを読み込み中...",
|
||||
"noMatches": "この表示に一致する自動タスクはありません。",
|
||||
"empty": "自動タスクはまだありません。",
|
||||
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
|
||||
"emptyHint": "正しいコンテキストを保持するには、チャットで自動化を作成してください。",
|
||||
"emptyAction": "チャットを開く",
|
||||
"clearFilters": "フィルターをクリア",
|
||||
"oneShot": "一回限り",
|
||||
"systemTask": "システム管理の自動タスク",
|
||||
"localTrigger": "ローカルトリガー",
|
||||
@@ -941,6 +951,10 @@
|
||||
"unarchive": "アーカイブを解除",
|
||||
"showArchived": "アーカイブ済みを表示",
|
||||
"hideArchived": "アーカイブ済みを隠す",
|
||||
"select": "選択",
|
||||
"cancelSelection": "選択を解除",
|
||||
"selectedCount": "{{count}} 件を選択中",
|
||||
"deleteSelected": "削除",
|
||||
"delete": "削除",
|
||||
"newChat": "新しいトピック",
|
||||
"groups": {
|
||||
@@ -955,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "このチャットを削除しますか?",
|
||||
"titleMany": "{{count}} 件のチャットとペインを削除しますか?",
|
||||
"description": "この操作は元に戻せません。",
|
||||
"descriptionMany": "この操作は元に戻せません。",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "削除",
|
||||
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
|
||||
"automationsDescriptionMany": "関連する自動タスクも削除されます。",
|
||||
"moreAutomations": "他 {{count}} 件",
|
||||
"confirmWithAutomations": "削除",
|
||||
"schedule": {
|
||||
@@ -1354,6 +1371,26 @@
|
||||
"copy": "コピー",
|
||||
"copied": "コピーしました"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "会話ワークベンチ",
|
||||
"panes": "ペイン",
|
||||
"panesInTab": "{{title}} のペイン",
|
||||
"dropPane": "{{pane}} を {{tab}} に移動",
|
||||
"moveToTab": "タブへ移動",
|
||||
"layout": "ペインレイアウト",
|
||||
"addPane": "ペインを追加",
|
||||
"promotePane": "{{title}} をメインペインにする",
|
||||
"paneActions": "{{title}} ペインの操作",
|
||||
"detachPane": "{{title}} を独立したトピックに移動",
|
||||
"composerAria": "{{title}} へのメッセージ",
|
||||
"layouts": {
|
||||
"columns": "列",
|
||||
"rows": "行",
|
||||
"grid": "グリッド",
|
||||
"main-stack": "メインとスタック",
|
||||
"monocle": "モノクル"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "閉じる",
|
||||
"close": "閉じる",
|
||||
@@ -1367,7 +1404,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "ワークスペースは変更されませんでした",
|
||||
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
|
||||
"body": "このプロジェクトまたはアクセスモードはゲートウェイに拒否されました。既存のプロジェクトまたは別のアクセスモードを選択して、もう一度お試しください。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "メッセージは送信されませんでした",
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||
},
|
||||
"auth": {
|
||||
"title": "인증이 필요합니다",
|
||||
"hint": "gateway 설정의 tokenIssueSecret에 구성된 비밀 값을 입력하세요.",
|
||||
"placeholder": "비밀번호",
|
||||
"label": "비밀번호",
|
||||
"showPassword": "비밀번호 표시",
|
||||
"hidePassword": "비밀번호 숨기기",
|
||||
"submit": "연결",
|
||||
"required": "비밀번호를 입력하세요.",
|
||||
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
|
||||
},
|
||||
"account": {
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "앱 검색",
|
||||
"featured": "도구",
|
||||
"loading": "앱을 불러오는 중...",
|
||||
"empty": "이 보기에 일치하는 도구가 없습니다.",
|
||||
"empty": "검색과 일치하는 도구가 없습니다.",
|
||||
"emptyApps": "사용 가능한 앱이 없습니다.",
|
||||
"emptyIntegrations": "사용 가능한 연동이 없습니다.",
|
||||
"emptyReady": "아직 준비된 도구가 없습니다.",
|
||||
"clearSearch": "검색 지우기",
|
||||
"browseApps": "앱 둘러보기",
|
||||
"browseIntegrations": "연동 둘러보기",
|
||||
"emptyIntegrationsHint": "아래에서 사용자 지정 연동을 추가하세요.",
|
||||
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "자동화를 불러오는 중...",
|
||||
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
||||
"empty": "아직 자동화가 없습니다.",
|
||||
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
|
||||
"emptyHint": "올바른 컨텍스트를 유지하려면 채팅에서 자동화를 만드세요.",
|
||||
"emptyAction": "채팅 열기",
|
||||
"clearFilters": "필터 지우기",
|
||||
"oneShot": "일회성",
|
||||
"systemTask": "시스템 관리 자동화",
|
||||
"localTrigger": "로컬 트리거",
|
||||
@@ -941,6 +951,10 @@
|
||||
"unarchive": "보관 해제",
|
||||
"showArchived": "보관된 항목 표시",
|
||||
"hideArchived": "보관된 항목 숨기기",
|
||||
"select": "선택",
|
||||
"cancelSelection": "선택 취소",
|
||||
"selectedCount": "{{count}}개 선택됨",
|
||||
"deleteSelected": "삭제",
|
||||
"delete": "삭제",
|
||||
"newChat": "새 주제",
|
||||
"groups": {
|
||||
@@ -955,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "이 채팅을 삭제할까요?",
|
||||
"titleMany": "채팅과 창 {{count}}개를 삭제할까요?",
|
||||
"description": "이 작업은 되돌릴 수 없습니다.",
|
||||
"descriptionMany": "이 작업은 되돌릴 수 없습니다.",
|
||||
"cancel": "취소",
|
||||
"confirm": "삭제",
|
||||
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
|
||||
"automationsDescriptionMany": "연결된 자동화도 함께 삭제됩니다.",
|
||||
"moreAutomations": "+ {{count}}개 더",
|
||||
"confirmWithAutomations": "삭제",
|
||||
"schedule": {
|
||||
@@ -1354,6 +1371,26 @@
|
||||
"copy": "복사",
|
||||
"copied": "복사됨"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "대화 워크벤치",
|
||||
"panes": "창",
|
||||
"panesInTab": "{{title}}의 창",
|
||||
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
|
||||
"moveToTab": "탭으로 이동",
|
||||
"layout": "창 레이아웃",
|
||||
"addPane": "창 추가",
|
||||
"promotePane": "{{title}}을(를) 기본 창으로 설정",
|
||||
"paneActions": "{{title}} 창 작업",
|
||||
"detachPane": "{{title}}을(를) 별도 주제로 이동",
|
||||
"composerAria": "{{title}}에 메시지 보내기",
|
||||
"layouts": {
|
||||
"columns": "열",
|
||||
"rows": "행",
|
||||
"grid": "그리드",
|
||||
"main-stack": "기본 창과 스택",
|
||||
"monocle": "단일 창"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "닫기",
|
||||
"close": "닫기",
|
||||
@@ -1367,7 +1404,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "작업공간이 변경되지 않았습니다",
|
||||
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
|
||||
"body": "게이트웨이가 이 프로젝트 또는 접근 모드를 거부했습니다. 기존 프로젝트나 다른 접근 모드를 선택한 후 다시 시도하세요."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "메시지가 전송되지 않았습니다",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Verifique se o gateway está em execução (`nanobot gateway`) e se esta página está aberta na mesma máquina."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Autenticação necessária",
|
||||
"hint": "Informe o segredo configurado como tokenIssueSecret na configuração do gateway.",
|
||||
"placeholder": "Senha",
|
||||
"label": "Senha",
|
||||
"showPassword": "Mostrar senha",
|
||||
"hidePassword": "Ocultar senha",
|
||||
"submit": "Conectar",
|
||||
"invalid": "Senha inválida. Tente novamente."
|
||||
"required": "Digite a senha.",
|
||||
"invalid": "Senha incorreta. Tente novamente."
|
||||
},
|
||||
"account": {
|
||||
"section": "Conta",
|
||||
@@ -595,7 +596,14 @@
|
||||
"searchPlaceholder": "Buscar ferramentas",
|
||||
"featured": "Ferramentas",
|
||||
"loading": "Carregando aplicativos...",
|
||||
"empty": "Nenhuma ferramenta corresponde a esta visualização.",
|
||||
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
||||
"emptyApps": "Nenhum aplicativo disponível.",
|
||||
"emptyIntegrations": "Nenhuma integração disponível.",
|
||||
"emptyReady": "Ainda não há ferramentas prontas.",
|
||||
"clearSearch": "Limpar busca",
|
||||
"browseApps": "Explorar aplicativos",
|
||||
"browseIntegrations": "Explorar integrações",
|
||||
"emptyIntegrationsHint": "Adicione uma integração personalizada abaixo.",
|
||||
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
|
||||
},
|
||||
"channels": {
|
||||
@@ -698,7 +706,9 @@
|
||||
"loading": "Carregando automações...",
|
||||
"noMatches": "Nenhuma automação corresponde a esta visualização.",
|
||||
"empty": "Nenhuma automação ainda.",
|
||||
"emptyHint": "Crie uma de onde ela deve rodar para que o nanobot mantenha o contexto correto.",
|
||||
"emptyHint": "Crie automações em uma conversa para que mantenham o contexto correto.",
|
||||
"emptyAction": "Abrir uma conversa",
|
||||
"clearFilters": "Limpar filtros",
|
||||
"oneShot": "Uma vez",
|
||||
"systemTask": "Automação gerenciada pelo sistema",
|
||||
"localTrigger": "Gatilho local",
|
||||
@@ -955,6 +965,10 @@
|
||||
"unarchive": "Desarquivar",
|
||||
"showArchived": "Mostrar arquivadas",
|
||||
"hideArchived": "Ocultar arquivadas",
|
||||
"select": "Selecionar",
|
||||
"cancelSelection": "Cancelar seleção",
|
||||
"selectedCount": "{{count}} selecionados",
|
||||
"deleteSelected": "Excluir",
|
||||
"delete": "Excluir",
|
||||
"newChat": "Novo tópico",
|
||||
"groups": {
|
||||
@@ -969,10 +983,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Excluir esta conversa?",
|
||||
"titleMany": "Excluir {{count}} conversas e painéis?",
|
||||
"description": "Esta ação não pode ser desfeita.",
|
||||
"descriptionMany": "Esta ação não pode ser desfeita.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Excluir",
|
||||
"automationsDescription": "Esta conversa possui automações agendadas. Excluí-la também excluirá essas automações.",
|
||||
"automationsDescriptionMany": "As automações vinculadas também serão excluídas.",
|
||||
"moreAutomations": "+ {{count}} a mais",
|
||||
"confirmWithAutomations": "Excluir",
|
||||
"schedule": {
|
||||
@@ -1368,6 +1385,26 @@
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Área de conversas",
|
||||
"panes": "Painéis",
|
||||
"panesInTab": "Painéis em {{title}}",
|
||||
"dropPane": "Mover {{pane}} para {{tab}}",
|
||||
"moveToTab": "Mover para uma aba",
|
||||
"layout": "Layout de painéis",
|
||||
"addPane": "Adicionar painel",
|
||||
"promotePane": "Tornar {{title}} o painel principal",
|
||||
"paneActions": "Ações do painel {{title}}",
|
||||
"detachPane": "Mover {{title}} para seu próprio tópico",
|
||||
"composerAria": "Mensagem para {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Colunas",
|
||||
"rows": "Linhas",
|
||||
"grid": "Grade",
|
||||
"main-stack": "Principal e pilha",
|
||||
"monocle": "Monóculo"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Descartar",
|
||||
"close": "Fechar",
|
||||
@@ -1381,7 +1418,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "O espaço de trabalho não foi alterado",
|
||||
"body": "O nanobot manteve o espaço de trabalho anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
|
||||
"body": "O gateway rejeitou este projeto ou modo de acesso. Escolha um projeto existente ou outro modo de acesso e tente novamente."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "A mensagem não foi enviada",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Cần xác thực",
|
||||
"hint": "Nhập secret được cấu hình là tokenIssueSecret trong cấu hình gateway.",
|
||||
"placeholder": "Mật khẩu",
|
||||
"label": "Mật khẩu",
|
||||
"showPassword": "Hiện mật khẩu",
|
||||
"hidePassword": "Ẩn mật khẩu",
|
||||
"submit": "Kết nối",
|
||||
"invalid": "Mật khẩu không hợp lệ. Hãy thử lại."
|
||||
"required": "Nhập mật khẩu.",
|
||||
"invalid": "Mật khẩu không đúng. Hãy thử lại."
|
||||
},
|
||||
"account": {
|
||||
"section": "Tài khoản",
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "Tìm ứng dụng",
|
||||
"featured": "Công cụ",
|
||||
"loading": "Đang tải ứng dụng...",
|
||||
"empty": "Không có công cụ phù hợp với chế độ xem này.",
|
||||
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
|
||||
"emptyApps": "Không có ứng dụng nào.",
|
||||
"emptyIntegrations": "Không có tích hợp nào.",
|
||||
"emptyReady": "Chưa có công cụ nào sẵn sàng.",
|
||||
"clearSearch": "Xóa tìm kiếm",
|
||||
"browseApps": "Xem ứng dụng",
|
||||
"browseIntegrations": "Xem tích hợp",
|
||||
"emptyIntegrationsHint": "Thêm tích hợp tùy chỉnh ở bên dưới.",
|
||||
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "Đang tải tự động hóa...",
|
||||
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
|
||||
"empty": "Chưa có tự động hóa.",
|
||||
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
|
||||
"emptyHint": "Tạo tác vụ tự động trong cuộc trò chuyện để giữ đúng ngữ cảnh.",
|
||||
"emptyAction": "Mở cuộc trò chuyện",
|
||||
"clearFilters": "Xóa bộ lọc",
|
||||
"oneShot": "Một lần",
|
||||
"systemTask": "Tự động hóa do hệ thống quản lý",
|
||||
"localTrigger": "Trình kích hoạt cục bộ",
|
||||
@@ -941,6 +951,10 @@
|
||||
"unarchive": "Bỏ lưu trữ",
|
||||
"showArchived": "Hiện mục đã lưu trữ",
|
||||
"hideArchived": "Ẩn mục đã lưu trữ",
|
||||
"select": "Chọn",
|
||||
"cancelSelection": "Hủy chọn",
|
||||
"selectedCount": "Đã chọn {{count}} mục",
|
||||
"deleteSelected": "Xóa",
|
||||
"delete": "Xóa",
|
||||
"newChat": "Chủ đề mới",
|
||||
"groups": {
|
||||
@@ -955,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Xóa cuộc trò chuyện này?",
|
||||
"titleMany": "Xóa {{count}} cuộc trò chuyện và khung?",
|
||||
"description": "Không thể hoàn tác thao tác này.",
|
||||
"descriptionMany": "Không thể hoàn tác thao tác này.",
|
||||
"cancel": "Hủy",
|
||||
"confirm": "Xóa",
|
||||
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
|
||||
"automationsDescriptionMany": "Các tự động hóa liên kết cũng sẽ bị xóa.",
|
||||
"moreAutomations": "+ {{count}} mục nữa",
|
||||
"confirmWithAutomations": "Xóa",
|
||||
"schedule": {
|
||||
@@ -1354,6 +1371,26 @@
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Không gian hội thoại",
|
||||
"panes": "Khung",
|
||||
"panesInTab": "Các khung trong {{title}}",
|
||||
"dropPane": "Di chuyển {{pane}} vào {{tab}}",
|
||||
"moveToTab": "Di chuyển vào thẻ",
|
||||
"layout": "Bố cục khung",
|
||||
"addPane": "Thêm khung",
|
||||
"promotePane": "Đặt {{title}} làm khung chính",
|
||||
"paneActions": "Thao tác cho khung {{title}}",
|
||||
"detachPane": "Chuyển {{title}} thành chủ đề riêng",
|
||||
"composerAria": "Nhắn tin cho {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Cột",
|
||||
"rows": "Hàng",
|
||||
"grid": "Lưới",
|
||||
"main-stack": "Khung chính và ngăn xếp",
|
||||
"monocle": "Một khung"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Đóng",
|
||||
"close": "Đóng",
|
||||
@@ -1367,7 +1404,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Không gian làm việc không thay đổi",
|
||||
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ không gian làm việc trước đó."
|
||||
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập này. Chọn dự án hiện có hoặc chế độ truy cập khác rồi thử lại."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Tin nhắn chưa được gửi",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
|
||||
},
|
||||
"auth": {
|
||||
"title": "需要验证",
|
||||
"hint": "请输入网关配置中的 tokenIssueSecret。",
|
||||
"placeholder": "密码",
|
||||
"label": "密码",
|
||||
"showPassword": "显示密码",
|
||||
"hidePassword": "隐藏密码",
|
||||
"submit": "连接",
|
||||
"invalid": "密码无效,请重试。"
|
||||
"required": "请输入密码。",
|
||||
"invalid": "密码错误,请重试。"
|
||||
},
|
||||
"account": {
|
||||
"section": "账户",
|
||||
@@ -595,7 +596,14 @@
|
||||
"searchPlaceholder": "搜索工具",
|
||||
"featured": "工具",
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "当前视图没有匹配的工具。",
|
||||
"empty": "没有与搜索条件匹配的工具。",
|
||||
"emptyApps": "暂无可用应用。",
|
||||
"emptyIntegrations": "暂无可用集成。",
|
||||
"emptyReady": "还没有就绪的工具。",
|
||||
"clearSearch": "清除搜索",
|
||||
"browseApps": "浏览应用",
|
||||
"browseIntegrations": "浏览集成",
|
||||
"emptyIntegrationsHint": "可在下方添加自定义集成。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
|
||||
},
|
||||
"channels": {
|
||||
@@ -698,7 +706,9 @@
|
||||
"loading": "正在加载自动任务...",
|
||||
"noMatches": "当前视图没有匹配的自动任务。",
|
||||
"empty": "暂无自动任务。",
|
||||
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
|
||||
"emptyHint": "请在对话中创建自动任务,以便保留正确的上下文。",
|
||||
"emptyAction": "打开对话",
|
||||
"clearFilters": "清除筛选",
|
||||
"oneShot": "一次性",
|
||||
"systemTask": "系统管理的自动任务",
|
||||
"localTrigger": "本地触发器",
|
||||
@@ -955,6 +965,10 @@
|
||||
"unarchive": "取消归档",
|
||||
"showArchived": "显示归档",
|
||||
"hideArchived": "隐藏归档",
|
||||
"select": "选择",
|
||||
"cancelSelection": "取消选择",
|
||||
"selectedCount": "已选择 {{count}} 项",
|
||||
"deleteSelected": "删除",
|
||||
"delete": "删除",
|
||||
"newChat": "新建话题",
|
||||
"groups": {
|
||||
@@ -969,10 +983,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "删除这个话题?",
|
||||
"titleMany": "删除这 {{count}} 个话题和窗格?",
|
||||
"description": "此操作无法撤销。",
|
||||
"descriptionMany": "此操作无法撤销。",
|
||||
"cancel": "取消",
|
||||
"confirm": "删除",
|
||||
"automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。",
|
||||
"automationsDescriptionMany": "关联的自动任务也会一并删除。",
|
||||
"moreAutomations": "另有 {{count}} 个",
|
||||
"confirmWithAutomations": "删除",
|
||||
"schedule": {
|
||||
@@ -1368,6 +1385,26 @@
|
||||
"copy": "复制",
|
||||
"copied": "已复制"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "会话工作台",
|
||||
"panes": "窗格",
|
||||
"panesInTab": "{{title}} 中的窗格",
|
||||
"dropPane": "将 {{pane}} 移入 {{tab}}",
|
||||
"moveToTab": "移动到标签页",
|
||||
"layout": "窗格布局",
|
||||
"addPane": "添加窗格",
|
||||
"promotePane": "将 {{title}} 设为主窗格",
|
||||
"paneActions": "{{title}} 窗格操作",
|
||||
"detachPane": "将 {{title}} 移至独立主题",
|
||||
"composerAria": "向 {{title}} 发送消息",
|
||||
"layouts": {
|
||||
"columns": "列布局",
|
||||
"rows": "行布局",
|
||||
"grid": "网格",
|
||||
"main-stack": "主窗格与堆栈",
|
||||
"monocle": "单窗格"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "关闭",
|
||||
"close": "关闭",
|
||||
@@ -1381,7 +1418,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "工作区未更改",
|
||||
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
|
||||
"body": "网关拒绝了此项目或访问权限。请选择已存在的项目或其他访问权限,然后重试。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "消息未发送",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。"
|
||||
},
|
||||
"auth": {
|
||||
"title": "需要驗證",
|
||||
"hint": "請輸入閘道設定中 tokenIssueSecret 所設定的金鑰。",
|
||||
"placeholder": "密碼",
|
||||
"label": "密碼",
|
||||
"showPassword": "顯示密碼",
|
||||
"hidePassword": "隱藏密碼",
|
||||
"submit": "連線",
|
||||
"invalid": "密碼無效,請再試一次。"
|
||||
"required": "請輸入密碼。",
|
||||
"invalid": "密碼錯誤,請再試一次。"
|
||||
},
|
||||
"account": {
|
||||
"section": "帳戶",
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "搜尋工具",
|
||||
"featured": "工具",
|
||||
"loading": "正在載入應用程式…",
|
||||
"empty": "沒有符合條件的工具。",
|
||||
"empty": "沒有符合搜尋條件的工具。",
|
||||
"emptyApps": "沒有可用的應用程式。",
|
||||
"emptyIntegrations": "沒有可用的整合服務。",
|
||||
"emptyReady": "尚無就緒的工具。",
|
||||
"clearSearch": "清除搜尋",
|
||||
"browseApps": "瀏覽應用程式",
|
||||
"browseIntegrations": "瀏覽整合服務",
|
||||
"emptyIntegrationsHint": "可在下方新增自訂整合服務。",
|
||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "正在載入自動任務…",
|
||||
"noMatches": "目前沒有符合條件的自動任務。",
|
||||
"empty": "尚無自動任務。",
|
||||
"emptyHint": "請從自動任務預定執行的對話中建立,讓 nanobot 保留正確的對話脈絡。",
|
||||
"emptyHint": "請在聊天中建立自動任務,以保留正確的對話脈絡。",
|
||||
"emptyAction": "開啟聊天",
|
||||
"clearFilters": "清除篩選",
|
||||
"oneShot": "單次",
|
||||
"systemTask": "系統管理的自動任務",
|
||||
"localTrigger": "本機觸發器",
|
||||
@@ -941,6 +951,10 @@
|
||||
"unarchive": "取消封存",
|
||||
"showArchived": "顯示封存",
|
||||
"hideArchived": "隱藏封存",
|
||||
"select": "選取",
|
||||
"cancelSelection": "取消選取",
|
||||
"selectedCount": "已選取 {{count}} 項",
|
||||
"deleteSelected": "刪除",
|
||||
"delete": "刪除",
|
||||
"newChat": "新增話題",
|
||||
"groups": {
|
||||
@@ -955,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "刪除這個話題?",
|
||||
"titleMany": "刪除這 {{count}} 個話題和窗格?",
|
||||
"description": "此操作無法復原。",
|
||||
"descriptionMany": "此操作無法復原。",
|
||||
"cancel": "取消",
|
||||
"confirm": "刪除",
|
||||
"automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。",
|
||||
"automationsDescriptionMany": "關聯的自動任務也會一併刪除。",
|
||||
"moreAutomations": "另有 {{count}} 個",
|
||||
"confirmWithAutomations": "刪除",
|
||||
"schedule": {
|
||||
@@ -1354,6 +1371,26 @@
|
||||
"copy": "複製",
|
||||
"copied": "已複製"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "對話工作台",
|
||||
"panes": "窗格",
|
||||
"panesInTab": "{{title}} 中的窗格",
|
||||
"dropPane": "將 {{pane}} 移入 {{tab}}",
|
||||
"moveToTab": "移動到分頁",
|
||||
"layout": "窗格佈局",
|
||||
"addPane": "新增窗格",
|
||||
"promotePane": "將 {{title}} 設為主窗格",
|
||||
"paneActions": "{{title}} 窗格操作",
|
||||
"detachPane": "將 {{title}} 移至獨立主題",
|
||||
"composerAria": "傳送訊息給 {{title}}",
|
||||
"layouts": {
|
||||
"columns": "欄佈局",
|
||||
"rows": "列佈局",
|
||||
"grid": "網格",
|
||||
"main-stack": "主窗格與堆疊",
|
||||
"monocle": "單窗格"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "關閉",
|
||||
"close": "關閉",
|
||||
@@ -1367,7 +1404,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "工作區未變更",
|
||||
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
|
||||
"body": "閘道拒絕了此專案或存取模式。請選擇現有專案或其他存取模式,然後再試一次。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "訊息未傳送",
|
||||
|
||||
+263
-359
@@ -44,6 +44,8 @@ import type {
|
||||
import { fetchWithTimeout } from "./http";
|
||||
|
||||
const API_READ_TIMEOUT_MS = 20_000;
|
||||
const API_MUTATION_TIMEOUT_MS = 20_000;
|
||||
const PACKAGE_MUTATION_TIMEOUT_MS = 150_000;
|
||||
const SLASH_COMMAND_LIFECYCLES = new Set<SlashCommandLifecycle>([
|
||||
"side_channel",
|
||||
"finalize_active_turn",
|
||||
@@ -58,12 +60,6 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
|
||||
&& SLASH_COMMAND_LIFECYCLES.has(value as SlashCommandLifecycle)
|
||||
);
|
||||
}
|
||||
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
|
||||
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
|
||||
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
|
||||
const OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback";
|
||||
const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
@@ -73,6 +69,14 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebUIMutationTransport {
|
||||
requestMutation<T>(
|
||||
action: string,
|
||||
payload?: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
token: string,
|
||||
@@ -109,7 +113,27 @@ async function request<T>(
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefined {
|
||||
async function mutation<T>(
|
||||
transport: WebUIMutationTransport,
|
||||
action: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
timeoutMs: number = API_MUTATION_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await transport.requestMutation<T>(action, payload, timeoutMs);
|
||||
} catch (reason) {
|
||||
const status = (
|
||||
typeof reason === "object"
|
||||
&& reason !== null
|
||||
&& "status" in reason
|
||||
&& typeof reason.status === "number"
|
||||
) ? reason.status : 500;
|
||||
const message = reason instanceof Error ? reason.message : "WebUI mutation failed";
|
||||
throw new ApiError(status, message);
|
||||
}
|
||||
}
|
||||
|
||||
function compactMcpValues(values: Record<string, unknown>): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined) return;
|
||||
@@ -120,12 +144,7 @@ function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefin
|
||||
}
|
||||
payload[key] = value;
|
||||
});
|
||||
if (!Object.keys(payload).length) return undefined;
|
||||
return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
|
||||
}
|
||||
|
||||
function automationValuesHeader(values: AutomationUpdatePayload): HeadersInit {
|
||||
return { "X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)) };
|
||||
return payload;
|
||||
}
|
||||
|
||||
function splitKey(key: string): { channel: string; chatId: string } {
|
||||
@@ -261,37 +280,19 @@ export async function fetchAutomations(
|
||||
}
|
||||
|
||||
export async function runAutomationAction(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
action: "enable" | "disable" | "delete" | "run",
|
||||
id: string,
|
||||
base: string = "",
|
||||
): Promise<AutomationsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("id", id);
|
||||
return request<AutomationsPayload>(
|
||||
`${base}/api/webui/automations/${action}?${query}`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
return mutation<AutomationsPayload>(transport, `automation.${action}`, { id });
|
||||
}
|
||||
|
||||
export async function updateAutomation(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
id: string,
|
||||
values: AutomationUpdatePayload,
|
||||
base: string = "",
|
||||
): Promise<AutomationsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("id", id);
|
||||
return request<AutomationsPayload>(
|
||||
`${base}/api/webui/automations/update?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: automationValuesHeader(values),
|
||||
},
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
return mutation<AutomationsPayload>(transport, "automation.update", { id, values });
|
||||
}
|
||||
|
||||
export async function fetchSkills(
|
||||
@@ -320,28 +321,18 @@ export async function fetchSkillDetail(
|
||||
}
|
||||
|
||||
export async function updateSkillEnabled(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
base: string = "",
|
||||
): Promise<SkillActionPayload> {
|
||||
const params = new URLSearchParams({ name, enabled: String(enabled) });
|
||||
return request<SkillActionPayload>(
|
||||
`${base}/api/webui/skills/update?${params}`,
|
||||
token,
|
||||
);
|
||||
return mutation<SkillActionPayload>(transport, "skill.update", { name, enabled });
|
||||
}
|
||||
|
||||
export async function deleteSkill(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<SkillActionPayload> {
|
||||
const params = new URLSearchParams({ name });
|
||||
return request<SkillActionPayload>(
|
||||
`${base}/api/webui/skills/delete?${params}`,
|
||||
token,
|
||||
);
|
||||
return mutation<SkillActionPayload>(transport, "skill.delete", { name });
|
||||
}
|
||||
|
||||
export async function searchMarketplaceSkills(
|
||||
@@ -389,37 +380,33 @@ export async function fetchMarketplaceSkillTrends(
|
||||
}
|
||||
|
||||
export async function installMarketplaceSkill(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
provider: Exclude<MarketplaceProvider, "all">,
|
||||
source: string,
|
||||
skill: string,
|
||||
version: string = "",
|
||||
base: string = "",
|
||||
): Promise<SkillInstallPayload> {
|
||||
const params = new URLSearchParams({ provider, source, skill });
|
||||
if (version) params.set("version", version);
|
||||
return request<SkillInstallPayload>(
|
||||
`${base}/api/webui/skills/install?${params}`,
|
||||
token,
|
||||
undefined,
|
||||
150_000,
|
||||
return mutation<SkillInstallPayload>(
|
||||
transport,
|
||||
"skill.install",
|
||||
{ provider, source, skill, ...(version ? { version } : {}) },
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
key: string,
|
||||
optionsOrBase?: { deleteAutomations?: boolean } | string,
|
||||
base: string = "",
|
||||
): Promise<SessionDeleteResult> {
|
||||
const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
|
||||
const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base;
|
||||
const query = new URLSearchParams();
|
||||
if (options?.deleteAutomations) query.set("delete_automations", "true");
|
||||
const suffix = query.toString() ? `?${query}` : "";
|
||||
return request<SessionDeleteResult>(
|
||||
`${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`,
|
||||
token,
|
||||
return mutation<SessionDeleteResult>(
|
||||
transport,
|
||||
"session.delete",
|
||||
{
|
||||
key,
|
||||
...(options?.deleteAutomations ? { delete_automations: true } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -520,56 +507,50 @@ export async function fetchApiService(token: string, base: string = ""): Promise
|
||||
}
|
||||
|
||||
export async function startApiService(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
values: { host: string; port: number; timeout: number; apiKey?: string },
|
||||
base: string = "",
|
||||
): Promise<ApiServicePayload> {
|
||||
const query = new URLSearchParams({
|
||||
return mutation<ApiServicePayload>(
|
||||
transport,
|
||||
"settings.api_service.start",
|
||||
{
|
||||
host: values.host,
|
||||
port: String(values.port),
|
||||
timeout: String(values.timeout),
|
||||
});
|
||||
const headers = values.apiKey === undefined
|
||||
? undefined
|
||||
: { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) };
|
||||
return request<ApiServicePayload>(
|
||||
`${base}/api/settings/api-service/start?${query}`,
|
||||
token,
|
||||
{ headers },
|
||||
port: values.port,
|
||||
timeout: values.timeout,
|
||||
...(values.apiKey !== undefined ? { api_key: values.apiKey } : {}),
|
||||
},
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
|
||||
return request<ApiServicePayload>(`${base}/api/settings/api-service/stop`, token);
|
||||
export async function stopApiService(
|
||||
transport: WebUIMutationTransport,
|
||||
): Promise<ApiServicePayload> {
|
||||
return mutation<ApiServicePayload>(transport, "settings.api_service.stop");
|
||||
}
|
||||
|
||||
export async function enableNanobotFeature(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<NanobotFeaturesPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<NanobotFeaturesPayload>(
|
||||
`${base}/api/settings/nanobot-features/enable?${query}`,
|
||||
token,
|
||||
return mutation<NanobotFeaturesPayload>(
|
||||
transport,
|
||||
"settings.feature.enable",
|
||||
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableNanobotFeature(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<NanobotFeaturesPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<NanobotFeaturesPayload>(
|
||||
`${base}/api/settings/nanobot-features/disable?${query}`,
|
||||
token,
|
||||
return mutation<NanobotFeaturesPayload>(
|
||||
transport,
|
||||
"settings.feature.disable",
|
||||
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -586,21 +567,15 @@ export async function fetchPairingRequests(
|
||||
}
|
||||
|
||||
export async function runPairingAction(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
action: "approve" | "deny",
|
||||
code: string,
|
||||
base: string = "",
|
||||
): Promise<PairingPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("code", code);
|
||||
return request<PairingPayload>(
|
||||
`${base}/api/settings/pairing/${action}?${query}`,
|
||||
token,
|
||||
);
|
||||
return mutation<PairingPayload>(transport, `settings.pairing.${action}`, { code });
|
||||
}
|
||||
|
||||
export async function startChannelConnect(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
channel: string,
|
||||
options: {
|
||||
domain?: string;
|
||||
@@ -608,104 +583,95 @@ export async function startChannelConnect(
|
||||
mode?: "replace" | "create";
|
||||
force?: boolean;
|
||||
} = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
if (options.domain) query.set("domain", options.domain);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
if (options.mode) query.set("mode", options.mode);
|
||||
if (options.force) query.set("force", "true");
|
||||
const suffix = query.toString();
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`,
|
||||
token,
|
||||
return mutation<ChannelConnectPayload>(
|
||||
transport,
|
||||
"settings.channel.connect.start",
|
||||
{
|
||||
channel,
|
||||
...(options.domain ? { domain: options.domain } : {}),
|
||||
...(options.instanceId ? { instance_id: options.instanceId } : {}),
|
||||
...(options.mode ? { mode: options.mode } : {}),
|
||||
...(options.force ? { force: true } : {}),
|
||||
},
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function pollChannelConnect(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
channel: string,
|
||||
sessionId: string,
|
||||
base: string = "",
|
||||
params: Readonly<Record<string, string>> = {},
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("session_id", sessionId);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (key !== "session_id") query.set(key, value);
|
||||
});
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
|
||||
token,
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(params).filter(([key]) => key !== "session_id"),
|
||||
);
|
||||
return mutation<ChannelConnectPayload>(
|
||||
transport,
|
||||
"settings.channel.connect.poll",
|
||||
{ channel, session_id: sessionId, ...values },
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelChannelConnect(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
channel: string,
|
||||
sessionId: string,
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("session_id", sessionId);
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/cancel?${query}`,
|
||||
token,
|
||||
return mutation<ChannelConnectPayload>(
|
||||
transport,
|
||||
"settings.channel.connect.cancel",
|
||||
{ channel, session_id: sessionId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function configureChannel(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
values: Record<string, string>,
|
||||
options: { enable?: boolean; instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelConfigurePayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.enable !== undefined) query.set("enable", String(options.enable));
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<ChannelConfigurePayload>(
|
||||
`${base}/api/settings/channels/configure?${query}`,
|
||||
token,
|
||||
return mutation<ChannelConfigurePayload>(
|
||||
transport,
|
||||
"settings.channel.configure",
|
||||
{
|
||||
headers: {
|
||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||
},
|
||||
name,
|
||||
values,
|
||||
...(options.enable !== undefined ? { enable: options.enable } : {}),
|
||||
...(options.instanceId ? { instance_id: options.instanceId } : {}),
|
||||
},
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateChannel(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelValidationPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<ChannelValidationPayload>(
|
||||
`${base}/api/settings/channels/validate?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||
},
|
||||
},
|
||||
return mutation<ChannelValidationPayload>(
|
||||
transport,
|
||||
"settings.channel.validate",
|
||||
{ name, values, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function runCliAppAction(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
action: "install" | "update" | "uninstall" | "test",
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<CliAppsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
|
||||
return mutation<CliAppsPayload>(
|
||||
transport,
|
||||
`settings.cli_app.${action}`,
|
||||
{ name },
|
||||
action === "install" || action === "update"
|
||||
? PACKAGE_MUTATION_TIMEOUT_MS
|
||||
: API_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchMcpPresets(
|
||||
@@ -736,55 +702,45 @@ export async function fetchProviderModels(
|
||||
}
|
||||
|
||||
export async function runMcpPresetAction(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
action: "enable" | "remove" | "test",
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/${action}?${query}`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader(values) },
|
||||
return mutation<McpPresetsPayload>(
|
||||
transport,
|
||||
`settings.mcp.${action}`,
|
||||
{ name, ...compactMcpValues(values) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveCustomMcpServer(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
values: Record<string, string>,
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/custom`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader(values) },
|
||||
return mutation<McpPresetsPayload>(
|
||||
transport,
|
||||
"settings.mcp.custom",
|
||||
compactMcpValues(values),
|
||||
);
|
||||
}
|
||||
|
||||
export async function importMcpConfig(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
config: string,
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/import`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader({ config }) },
|
||||
);
|
||||
return mutation<McpPresetsPayload>(transport, "settings.mcp.import", { config });
|
||||
}
|
||||
|
||||
export async function updateMcpServerTools(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
enabledTools: string[],
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/tools`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) },
|
||||
return mutation<McpPresetsPayload>(
|
||||
transport,
|
||||
"settings.mcp.tools",
|
||||
{ name, enabled_tools: enabledTools },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -835,280 +791,228 @@ export async function fetchSidebarState(
|
||||
}
|
||||
|
||||
export async function updateSidebarState(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
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,
|
||||
);
|
||||
return mutation<SidebarStatePayload>(transport, "sidebar.update", { state });
|
||||
}
|
||||
|
||||
export async function updateSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: SettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (update.modelPreset !== undefined) {
|
||||
query.set("model_preset", update.modelPreset ?? "default");
|
||||
payload.model_preset = update.modelPreset ?? "default";
|
||||
}
|
||||
if (update.model !== undefined) query.set("model", update.model);
|
||||
if (update.provider !== undefined) query.set("provider", update.provider);
|
||||
if (update.model !== undefined) payload.model = update.model;
|
||||
if (update.provider !== undefined) payload.provider = update.provider;
|
||||
if (update.contextWindowTokens !== undefined) {
|
||||
query.set("context_window_tokens", String(update.contextWindowTokens));
|
||||
payload.context_window_tokens = update.contextWindowTokens;
|
||||
}
|
||||
if (update.timezone !== undefined) query.set("timezone", update.timezone);
|
||||
if (update.timezone !== undefined) payload.timezone = update.timezone;
|
||||
if (update.toolHintMaxLength !== undefined) {
|
||||
query.set("tool_hint_max_length", String(update.toolHintMaxLength));
|
||||
payload.tool_hint_max_length = update.toolHintMaxLength;
|
||||
}
|
||||
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
||||
return mutation<SettingsPayload>(transport, "settings.agent.update", payload);
|
||||
}
|
||||
|
||||
function appendModelGenerationSettings(
|
||||
query: URLSearchParams,
|
||||
function modelGenerationSettingsPayload(
|
||||
configuration: Pick<
|
||||
ModelConfigurationCreate,
|
||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
||||
>,
|
||||
): void {
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (configuration.maxTokens !== undefined) {
|
||||
query.set("max_tokens", String(configuration.maxTokens));
|
||||
payload.max_tokens = configuration.maxTokens;
|
||||
}
|
||||
if (configuration.contextWindowTokens !== undefined) {
|
||||
query.set("context_window_tokens", String(configuration.contextWindowTokens));
|
||||
payload.context_window_tokens = configuration.contextWindowTokens;
|
||||
}
|
||||
if (configuration.temperature !== undefined) {
|
||||
query.set("temperature", String(configuration.temperature));
|
||||
payload.temperature = configuration.temperature;
|
||||
}
|
||||
if (configuration.reasoningEffort !== undefined) {
|
||||
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
|
||||
payload.reasoning_effort = configuration.reasoningEffort ?? "";
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function createModelConfiguration(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
configuration: ModelConfigurationCreate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
if (configuration.name !== undefined) query.set("name", configuration.name);
|
||||
query.set("label", configuration.label);
|
||||
query.set("provider", configuration.provider);
|
||||
query.set("model", configuration.model);
|
||||
appendModelGenerationSettings(query, configuration);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/create?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
...(configuration.name !== undefined ? { name: configuration.name } : {}),
|
||||
label: configuration.label,
|
||||
provider: configuration.provider,
|
||||
model: configuration.model,
|
||||
...modelGenerationSettingsPayload(configuration),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateModelConfiguration(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
configuration: ModelConfigurationUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", configuration.name);
|
||||
if (configuration.label !== undefined) query.set("label", configuration.label);
|
||||
if (configuration.provider !== undefined) query.set("provider", configuration.provider);
|
||||
if (configuration.model !== undefined) query.set("model", configuration.model);
|
||||
appendModelGenerationSettings(query, configuration);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/update?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.model_configuration.update",
|
||||
{
|
||||
name: configuration.name,
|
||||
...(configuration.label !== undefined ? { label: configuration.label } : {}),
|
||||
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
|
||||
...(configuration.model !== undefined ? { model: configuration.model } : {}),
|
||||
...modelGenerationSettingsPayload(configuration),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteModelConfiguration(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams({ name });
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/delete?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.model_configuration.delete",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
export async function migrateModelConfigurations(
|
||||
token: string,
|
||||
base: string = "",
|
||||
transport: WebUIMutationTransport,
|
||||
): Promise<SettingsPayload> {
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/migrate`,
|
||||
token,
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.model_configuration.migrate");
|
||||
}
|
||||
|
||||
export async function updateModelCallOrder(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
order: string[],
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams({ order: JSON.stringify(order) });
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-call-order/update?${query}`,
|
||||
token,
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.model_call_order.update", { order });
|
||||
}
|
||||
|
||||
export async function updateProviderSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: ProviderSettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const { provider, ...values } = update;
|
||||
const query = new URLSearchParams({ provider });
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/provider/update?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(values)),
|
||||
},
|
||||
},
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.provider.update", { ...update });
|
||||
}
|
||||
|
||||
export async function createProviderSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: ProviderCreationUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/provider/create`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(update)),
|
||||
},
|
||||
},
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.provider.create", { ...update });
|
||||
}
|
||||
|
||||
export async function loginProviderOAuth(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
provider: string,
|
||||
base: string = "",
|
||||
remoteBrowserAccess: boolean = false,
|
||||
): Promise<ProviderOAuthLoginResult> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", provider);
|
||||
if (remoteBrowserAccess) query.set("remote_browser", "true");
|
||||
return request<ProviderOAuthLoginResult>(
|
||||
`${base}/api/settings/provider/oauth-login?${query}`,
|
||||
token,
|
||||
{ cache: "no-store" },
|
||||
return mutation<ProviderOAuthLoginResult>(
|
||||
transport,
|
||||
"settings.provider.oauth_login",
|
||||
{ provider, ...(remoteBrowserAccess ? { remote_browser: true } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeProviderOAuth(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
provider: string,
|
||||
flowId: string,
|
||||
authorizationResponse?: string,
|
||||
base: string = "",
|
||||
): Promise<ProviderOAuthCompletionResult> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", provider);
|
||||
query.set("flow_id", flowId);
|
||||
const responseHeader = provider === "openai_codex"
|
||||
? OAUTH_CALLBACK_HEADER
|
||||
: OAUTH_CODE_HEADER;
|
||||
const headers = authorizationResponse
|
||||
? { [responseHeader]: authorizationResponse }
|
||||
: undefined;
|
||||
return request<ProviderOAuthCompletionResult>(
|
||||
`${base}/api/settings/provider/oauth-login/complete?${query}`,
|
||||
token,
|
||||
{ cache: "no-store", ...(headers ? { headers } : {}) },
|
||||
return mutation<ProviderOAuthCompletionResult>(
|
||||
transport,
|
||||
"settings.provider.oauth_complete",
|
||||
{
|
||||
provider,
|
||||
flow_id: flowId,
|
||||
...(authorizationResponse ? { authorization_response: authorizationResponse } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function logoutProviderOAuth(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
provider: string,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", provider);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/provider/oauth-logout?${query}`,
|
||||
token,
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.provider.oauth_logout", { provider });
|
||||
}
|
||||
|
||||
export async function updateWebSearchSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: WebSearchSettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", update.provider);
|
||||
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
|
||||
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl);
|
||||
if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults));
|
||||
if (update.timeout !== undefined) query.set("timeout", String(update.timeout));
|
||||
if (update.useJinaReader !== undefined) {
|
||||
query.set("use_jina_reader", String(update.useJinaReader));
|
||||
}
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/web-search/update?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.web_search.update",
|
||||
{
|
||||
provider: update.provider,
|
||||
...(update.apiKey !== undefined ? { api_key: update.apiKey } : {}),
|
||||
...(update.baseUrl !== undefined ? { base_url: update.baseUrl } : {}),
|
||||
...(update.maxResults !== undefined ? { max_results: update.maxResults } : {}),
|
||||
...(update.timeout !== undefined ? { timeout: update.timeout } : {}),
|
||||
...(update.useJinaReader !== undefined
|
||||
? { use_jina_reader: update.useJinaReader }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateNetworkSafetySettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: NetworkSafetySettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("webui_allow_local_service_access", String(update.webuiAllowLocalServiceAccess));
|
||||
query.set("webui_default_access_mode", update.webuiDefaultAccessMode);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/network-safety/update?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.network_safety.update",
|
||||
{
|
||||
webui_allow_local_service_access: update.webuiAllowLocalServiceAccess,
|
||||
webui_default_access_mode: update.webuiDefaultAccessMode,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateImageGenerationSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
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,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.image_generation.update",
|
||||
{
|
||||
enabled: update.enabled,
|
||||
provider: update.provider,
|
||||
model: update.model,
|
||||
default_aspect_ratio: update.defaultAspectRatio,
|
||||
default_image_size: update.defaultImageSize,
|
||||
max_images_per_turn: update.maxImagesPerTurn,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateTranscriptionSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: TranscriptionSettingsUpdate,
|
||||
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("language", update.language);
|
||||
query.set("max_duration_sec", String(update.maxDurationSec));
|
||||
query.set("max_upload_mb", String(update.maxUploadMb));
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/transcription/update?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.transcription.update",
|
||||
{
|
||||
enabled: update.enabled,
|
||||
provider: update.provider,
|
||||
model: update.model,
|
||||
language: update.language,
|
||||
max_duration_sec: update.maxDurationSec,
|
||||
max_upload_mb: update.maxUploadMb,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +108,16 @@ interface PendingRequest<T> {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class WebUIMutationError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = "WebUIMutationError";
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingChatRequest extends PendingRequest<string> {
|
||||
temporary: boolean;
|
||||
}
|
||||
@@ -203,6 +213,7 @@ export class NanobotClient {
|
||||
private pendingNewChat: PendingChatRequest | null = null;
|
||||
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
||||
private pendingWebUIRequests = new Map<string, PendingRequest<unknown>>();
|
||||
// Frames queued while the socket is not yet OPEN
|
||||
private sendQueue: Outbound[] = [];
|
||||
private reconnectAttempts = 0;
|
||||
@@ -807,6 +818,60 @@ export class NanobotClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one non-replayable WebUI mutation over the authenticated socket.
|
||||
* A client-side timeout only abandons the reply; the server may finish work
|
||||
* that already started, so timed-out requests are never retried automatically.
|
||||
*/
|
||||
requestMutation<T>(
|
||||
action: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
timeoutMs: number = 20_000,
|
||||
): Promise<T> {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WS_OPEN) {
|
||||
return Promise.reject(
|
||||
new WebUIMutationError(503, "WebUI connection is not open"),
|
||||
);
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
const frame: Outbound = {
|
||||
type: "webui_request",
|
||||
request_id: requestId,
|
||||
action,
|
||||
payload,
|
||||
};
|
||||
if (!this.frameFitsTransport(frame)) {
|
||||
return Promise.reject(
|
||||
new WebUIMutationError(413, "WebUI mutation payload is too large"),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingWebUIRequests.delete(requestId);
|
||||
reject(
|
||||
new WebUIMutationError(
|
||||
504,
|
||||
`WebUI request timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.pendingWebUIRequests.set(requestId, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
try {
|
||||
socket.send(JSON.stringify(frame));
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
this.pendingWebUIRequests.delete(requestId);
|
||||
reject(new WebUIMutationError(503, "Could not send WebUI request"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Ask the server to create a non-destructive fork before a user-message index. */
|
||||
forkChat(
|
||||
sourceChatId: string,
|
||||
@@ -914,8 +979,8 @@ export class NanobotClient {
|
||||
});
|
||||
}
|
||||
|
||||
setSidebarState(state: SidebarStatePayload): void {
|
||||
this.queueSend({ type: "set_sidebar_state", state });
|
||||
setSidebarState(state: SidebarStatePayload): Promise<SidebarStatePayload> {
|
||||
return this.requestMutation<SidebarStatePayload>("sidebar.update", { state });
|
||||
}
|
||||
|
||||
// -- internals ---------------------------------------------------------
|
||||
@@ -965,6 +1030,23 @@ export class NanobotClient {
|
||||
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
|
||||
}
|
||||
|
||||
if (parsed.event === "webui_response") {
|
||||
const pending = this.pendingWebUIRequests.get(parsed.request_id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingWebUIRequests.delete(parsed.request_id);
|
||||
if (parsed.ok) {
|
||||
pending.resolve(parsed.result);
|
||||
} else {
|
||||
const status = Number.isFinite(parsed.error?.status)
|
||||
? parsed.error.status
|
||||
: 500;
|
||||
const message = parsed.error?.message || "WebUI mutation failed";
|
||||
pending.reject(new WebUIMutationError(status, message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.event === "error" && !parsed.turn_id) {
|
||||
const fallback = this.legacyRejectionTarget(parsed);
|
||||
if (fallback) {
|
||||
@@ -1151,6 +1233,13 @@ export class NanobotClient {
|
||||
this.pendingNewChat = null;
|
||||
}
|
||||
this.rejectAllTranscriptions("socket closed");
|
||||
for (const pending of this.pendingWebUIRequests.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(
|
||||
new WebUIMutationError(503, "Socket closed before WebUI response"),
|
||||
);
|
||||
}
|
||||
this.pendingWebUIRequests.clear();
|
||||
for (const pending of this.pendingSystemCommands.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("socket closed"));
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
|
||||
export const PANE_DRAG_TYPE = "application/x-nanobot-pane";
|
||||
|
||||
export interface DraggedPane {
|
||||
paneKey: string;
|
||||
sourceTabKey: string;
|
||||
}
|
||||
|
||||
let activeSessionKey: string | null = null;
|
||||
let activePane: DraggedPane | null = null;
|
||||
|
||||
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
|
||||
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
|
||||
@@ -13,6 +20,7 @@ export function readDraggedSession(dataTransfer: DataTransfer): string | null {
|
||||
|
||||
export function clearDraggedSession(): void {
|
||||
activeSessionKey = null;
|
||||
activePane = null;
|
||||
}
|
||||
|
||||
export function writeDraggedSession(
|
||||
@@ -23,3 +31,27 @@ export function writeDraggedSession(
|
||||
dataTransfer.effectAllowed = "copyMove";
|
||||
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
|
||||
}
|
||||
|
||||
export function readDraggedPane(dataTransfer: DataTransfer): DraggedPane | null {
|
||||
const serialized = dataTransfer.getData(PANE_DRAG_TYPE).trim();
|
||||
if (serialized) {
|
||||
try {
|
||||
const parsed = JSON.parse(serialized) as Partial<DraggedPane>;
|
||||
if (parsed.paneKey && parsed.sourceTabKey) {
|
||||
return { paneKey: parsed.paneKey, sourceTabKey: parsed.sourceTabKey };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the in-memory payload used while the native drag is active.
|
||||
}
|
||||
}
|
||||
return activePane;
|
||||
}
|
||||
|
||||
export function writeDraggedPane(
|
||||
dataTransfer: DataTransfer,
|
||||
pane: DraggedPane,
|
||||
): void {
|
||||
activePane = pane;
|
||||
writeDraggedSession(dataTransfer, pane.paneKey);
|
||||
dataTransfer.setData(PANE_DRAG_TYPE, JSON.stringify(pane));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { toolTraceLinesFromEvents } from "@/lib/tool-traces";
|
||||
import type {
|
||||
ToolProgressEvent,
|
||||
UIFileEdit,
|
||||
UIMessage,
|
||||
UITurnPhase,
|
||||
} from "@/lib/types";
|
||||
|
||||
export type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
|
||||
|
||||
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
|
||||
|
||||
/**
|
||||
* PR3 projection seam: replay can share these folds once GatewayContext exposes
|
||||
* an ordered canonical-event sequence and a monotonic per-thread revision.
|
||||
* Snapshot acceptance and revision comparison stay outside this projection;
|
||||
* until then, history continues to consume server-projected UIMessage snapshots.
|
||||
*/
|
||||
|
||||
export function turnFieldsFromEvent(
|
||||
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
|
||||
fallbackPhase?: UITurnPhase,
|
||||
): UIMessageTurnFields {
|
||||
const fields: UIMessageTurnFields = {};
|
||||
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
|
||||
fields.turnId = ev.turn_id;
|
||||
}
|
||||
const phase = ev.turn_phase ?? fallbackPhase;
|
||||
if (phase) fields.turnPhase = phase;
|
||||
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
|
||||
fields.turnSeq = ev.turn_seq;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
|
||||
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
|
||||
}
|
||||
|
||||
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
|
||||
* as streaming until ``turn_end`` for visual continuity, but they must not
|
||||
* receive later delta segments. */
|
||||
export function findStreamingAssistantIndex(
|
||||
prev: UIMessage[],
|
||||
closedStreamIds: ReadonlySet<string>,
|
||||
turn: UIMessageTurnFields = {},
|
||||
): number | null {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const m = prev[i];
|
||||
if (m.kind === "trace") continue;
|
||||
if (
|
||||
m.role === "assistant"
|
||||
&& m.isStreaming
|
||||
&& !closedStreamIds.has(m.id)
|
||||
&& matchesTurn(m, turn)
|
||||
) return i;
|
||||
if (m.role === "user") break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent assistant placeholder that an incoming answer
|
||||
* delta should adopt instead of spawning a parallel row.
|
||||
*/
|
||||
export function findActiveAssistantPlaceholderIndex(
|
||||
prev: UIMessage[],
|
||||
turn: UIMessageTurnFields = {},
|
||||
): number | null {
|
||||
const last = prev[prev.length - 1];
|
||||
if (!last) return null;
|
||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||
if (last.content.length > 0) return null;
|
||||
if (!last.isStreaming) return null;
|
||||
if (!matchesTurn(last, turn)) return null;
|
||||
return prev.length - 1;
|
||||
}
|
||||
|
||||
export function replaceMessageAt(
|
||||
prev: UIMessage[],
|
||||
index: number,
|
||||
message: UIMessage,
|
||||
): UIMessage[] {
|
||||
const next = prev.slice();
|
||||
next[index] = message;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Close the active reasoning stream segment. ``now`` is supplied by the caller
|
||||
* so the projection remains deterministic for replay and fixture tests. */
|
||||
export function closeReasoningStream(prev: UIMessage[], now: number): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
if (!candidate.reasoningStreaming) continue;
|
||||
const latencyMs =
|
||||
candidate.latencyMs === undefined
|
||||
&& Number.isFinite(candidate.createdAt)
|
||||
&& candidate.createdAt > 1_000_000_000_000
|
||||
? Math.max(0, Math.round(now - candidate.createdAt))
|
||||
: candidate.latencyMs;
|
||||
const merged: UIMessage = {
|
||||
...candidate,
|
||||
reasoningStreaming: false,
|
||||
...(latencyMs !== undefined ? { latencyMs } : {}),
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
export function isReasoningOnlyPlaceholder(message: UIMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant"
|
||||
&& message.kind !== "trace"
|
||||
&& message.content.trim().length === 0
|
||||
&& !!message.reasoning
|
||||
&& !message.reasoningStreaming
|
||||
&& !message.media?.length
|
||||
);
|
||||
}
|
||||
|
||||
function isToolTrace(message: UIMessage | undefined): boolean {
|
||||
return message?.kind === "trace";
|
||||
}
|
||||
|
||||
export function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
|
||||
return prev.filter((message, index) => {
|
||||
if (!isReasoningOnlyPlaceholder(message)) return true;
|
||||
// A reasoning-only assistant row immediately followed by tool traces is
|
||||
// the live equivalent of a persisted assistant tool-call message with
|
||||
// empty content, reasoning_content, and tool_calls. Keep it so live render
|
||||
// and history replay stay isomorphic.
|
||||
return isToolTrace(prev[index + 1]);
|
||||
});
|
||||
}
|
||||
|
||||
export function stampLastAssistantCompletion(
|
||||
prev: UIMessage[],
|
||||
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
|
||||
turnId?: string,
|
||||
): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const m = prev[i];
|
||||
if (
|
||||
m.role === "assistant"
|
||||
&& m.kind !== "trace"
|
||||
&& (!turnId || !m.turnId || m.turnId === turnId)
|
||||
) {
|
||||
const merged: UIMessage = { ...m, ...completion, isStreaming: false };
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
||||
if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
||||
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
||||
return `${edit.tool}|${edit.path}`;
|
||||
}
|
||||
|
||||
function fileEditToolEventKey(
|
||||
edit: Pick<UIFileEdit, "call_id" | "tool" | "path">,
|
||||
): string {
|
||||
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
||||
return fileEditKey(edit);
|
||||
}
|
||||
|
||||
function toolEventFileEditKey(event: ToolProgressEvent): string | null {
|
||||
const fn = (event as { function?: { name?: unknown } }).function;
|
||||
const name = typeof event.name === "string"
|
||||
? event.name
|
||||
: typeof fn?.name === "string"
|
||||
? fn.name
|
||||
: "";
|
||||
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
||||
if (!name || !callId || !FILE_EDIT_TOOL_NAMES.has(name)) return null;
|
||||
return `${callId}|${name}`;
|
||||
}
|
||||
|
||||
function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent): boolean {
|
||||
const key = toolEventFileEditKey(event);
|
||||
if (!key) return false;
|
||||
return messages.some((message) =>
|
||||
message.fileEdits?.some((edit) => fileEditToolEventKey(edit) === key),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterCoveredFileEditToolEvents(
|
||||
messages: UIMessage[],
|
||||
events: ToolProgressEvent[],
|
||||
): ToolProgressEvent[] {
|
||||
if (events.length === 0) return events;
|
||||
return events.filter((event) => !hasFileEditForToolEvent(messages, event));
|
||||
}
|
||||
|
||||
function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage {
|
||||
const incomingKeys = new Set(edits.map(fileEditToolEventKey));
|
||||
const events = message.toolEvents ?? [];
|
||||
if (!events.length || incomingKeys.size === 0) return message;
|
||||
|
||||
const removedTraceLines = new Set<string>();
|
||||
const keptEvents: ToolProgressEvent[] = [];
|
||||
let changed = false;
|
||||
for (const event of events) {
|
||||
const key = toolEventFileEditKey(event);
|
||||
if (key && incomingKeys.has(key)) {
|
||||
changed = true;
|
||||
for (const line of toolTraceLinesFromEvents([event])) {
|
||||
removedTraceLines.add(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
keptEvents.push(event);
|
||||
}
|
||||
if (!changed) return message;
|
||||
|
||||
const previousTraces = message.traces?.length
|
||||
? message.traces
|
||||
: message.content
|
||||
? [message.content]
|
||||
: [];
|
||||
const nextTraces = previousTraces.filter((line) => !removedTraceLines.has(line));
|
||||
return {
|
||||
...message,
|
||||
traces: nextTraces,
|
||||
content: nextTraces[nextTraces.length - 1] ?? "",
|
||||
toolEvents: keptEvents.length ? keptEvents : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function traceMessageIsEmpty(message: UIMessage): boolean {
|
||||
const traces = message.traces;
|
||||
const hasTrace = traces?.length
|
||||
? traces.some((line) => line.trim().length > 0)
|
||||
: (message.content ?? "").trim().length > 0;
|
||||
return (
|
||||
message.kind === "trace"
|
||||
&& !hasTrace
|
||||
&& !message.toolEvents?.length
|
||||
&& !message.fileEdits?.length
|
||||
&& !message.media?.length
|
||||
);
|
||||
}
|
||||
|
||||
export function stripCoveredFileEditToolHintsFromMessages(
|
||||
messages: UIMessage[],
|
||||
edits: UIFileEdit[],
|
||||
turn: UIMessageTurnFields,
|
||||
): UIMessage[] {
|
||||
if (edits.length === 0) return messages;
|
||||
let next = messages;
|
||||
for (let i = next.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = next[i];
|
||||
if (candidate.role === "user") break;
|
||||
if (candidate.kind !== "trace") continue;
|
||||
if (!matchesTurn(candidate, turn)) continue;
|
||||
const cleaned = stripCoveredFileEditToolHints(candidate, edits);
|
||||
if (cleaned === candidate) continue;
|
||||
if (next === messages) next = [...messages];
|
||||
if (traceMessageIsEmpty(cleaned)) {
|
||||
next.splice(i, 1);
|
||||
} else {
|
||||
next[i] = cleaned;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
|
||||
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
|
||||
const inferredStatus =
|
||||
edit.phase === "error"
|
||||
? "error"
|
||||
: edit.phase === "end"
|
||||
? "done"
|
||||
: "editing";
|
||||
const normalized: UIFileEdit = {
|
||||
...edit,
|
||||
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
|
||||
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
|
||||
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
|
||||
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
|
||||
? edit.status
|
||||
: inferredStatus,
|
||||
};
|
||||
if (edit.pending && !edit.path) normalized.pending = true;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function mergeFileEdits(
|
||||
existing: UIFileEdit[] | undefined,
|
||||
incoming: UIFileEdit[],
|
||||
): UIFileEdit[] {
|
||||
const next = [...(existing ?? [])];
|
||||
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
|
||||
for (const raw of incoming) {
|
||||
const edit = normalizeFileEdit(raw);
|
||||
if (!edit) continue;
|
||||
const key = fileEditKey(edit);
|
||||
let existingIndex = indexByKey.get(key);
|
||||
if (existingIndex === undefined && edit.path) {
|
||||
const eventKey = fileEditToolEventKey(edit);
|
||||
const pendingIndex = next.findIndex((existing) =>
|
||||
!existing.path && existing.pending && fileEditToolEventKey(existing) === eventKey,
|
||||
);
|
||||
if (pendingIndex >= 0) existingIndex = pendingIndex;
|
||||
}
|
||||
if (existingIndex === undefined) {
|
||||
indexByKey.set(key, next.length);
|
||||
next.push(edit);
|
||||
continue;
|
||||
}
|
||||
const merged = { ...next[existingIndex], ...edit };
|
||||
if (edit.path && !edit.pending) delete merged.pending;
|
||||
next[existingIndex] = merged;
|
||||
indexByKey.set(key, existingIndex);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function findFileEditTraceIndex(
|
||||
prev: UIMessage[],
|
||||
segmentId: string | null,
|
||||
incoming: UIFileEdit[],
|
||||
): number | null {
|
||||
const incomingKeys = new Set(incoming.map(fileEditKey));
|
||||
const incomingToolEventKeys = new Set(incoming.map(fileEditToolEventKey));
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
if (candidate.role === "user") break;
|
||||
if (candidate.kind !== "trace") continue;
|
||||
if (segmentId && candidate.activitySegmentId === segmentId) return i;
|
||||
for (const existing of candidate.fileEdits ?? []) {
|
||||
if (
|
||||
incomingKeys.has(fileEditKey(existing))
|
||||
|| (
|
||||
!existing.path
|
||||
&& existing.pending
|
||||
&& incomingToolEventKeys.has(fileEditToolEventKey(existing))
|
||||
)
|
||||
) return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function finalizeStreamedTurn(
|
||||
prev: UIMessage[],
|
||||
turn: UIMessageTurnFields = {},
|
||||
): UIMessage[] {
|
||||
return prev.map((m) =>
|
||||
m.isStreaming && matchesTurn(m, turn)
|
||||
? { ...m, isStreaming: false, reasoningStreaming: false }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
@@ -1261,6 +1261,18 @@ export type InboundEvent =
|
||||
detail?: string;
|
||||
provider?: string;
|
||||
}
|
||||
| {
|
||||
event: "webui_response";
|
||||
request_id: string;
|
||||
ok: true;
|
||||
result: unknown;
|
||||
}
|
||||
| {
|
||||
event: "webui_response";
|
||||
request_id: string;
|
||||
ok: false;
|
||||
error: { status: number; message: string };
|
||||
}
|
||||
| {
|
||||
event: "error";
|
||||
chat_id?: string;
|
||||
@@ -1339,6 +1351,12 @@ export interface FilePreviewPayload {
|
||||
export type Outbound =
|
||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||
| { type: "new_temporary_chat" }
|
||||
| {
|
||||
type: "webui_request";
|
||||
request_id: string;
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||
|
||||
+303
-352
@@ -59,8 +59,19 @@ import {
|
||||
validateChannel,
|
||||
} from "@/lib/api";
|
||||
|
||||
const requestMutation = vi.fn();
|
||||
const mutationTransport = {
|
||||
requestMutation: <T>(
|
||||
action: string,
|
||||
payload?: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
) => requestMutation(action, payload, timeoutMs) as Promise<T>,
|
||||
};
|
||||
|
||||
describe("webui API helpers", () => {
|
||||
beforeEach(() => {
|
||||
requestMutation.mockReset();
|
||||
requestMutation.mockResolvedValue({});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
@@ -184,88 +195,74 @@ describe("webui API helpers", () => {
|
||||
|
||||
it("validates channel settings with form values", async () => {
|
||||
await validateChannel(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"slack",
|
||||
{ "channels.slack.botToken": "xoxb-test" },
|
||||
{ instanceId: "default" },
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/channels/validate?name=slack&instance_id=default",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||
"channels.slack.botToken": "xoxb-test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.channel.validate",
|
||||
{
|
||||
name: "slack",
|
||||
instance_id: "default",
|
||||
values: { "channels.slack.botToken": "xoxb-test" },
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("configures channels through the WebSocket HTTP shim", async () => {
|
||||
it("configures channels through the authenticated WebSocket", async () => {
|
||||
await configureChannel(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"discord",
|
||||
{ "channels.discord.token": "saved-secret" },
|
||||
{ enable: true },
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/channels/configure?name=discord&enable=true",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||
"channels.discord.token": "saved-secret",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.channel.configure",
|
||||
{
|
||||
name: "discord",
|
||||
enable: true,
|
||||
values: { "channels.discord.token": "saved-secret" },
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes channel QR connect helpers", async () => {
|
||||
await startChannelConnect("tok", "weixin", { force: true });
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/start?force=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
it("serializes channel QR connect request envelopes", async () => {
|
||||
await startChannelConnect(mutationTransport, "weixin", { force: true });
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.channel.connect.start",
|
||||
{ channel: "weixin", force: true },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await pollChannelConnect("tok", "weixin", "session+/=");
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await pollChannelConnect(mutationTransport, "weixin", "session+/=");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.channel.connect.poll",
|
||||
{ channel: "weixin", session_id: "session+/=" },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await cancelChannelConnect("tok", "weixin", "session+/=");
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await cancelChannelConnect(mutationTransport, "weixin", "session+/=");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.channel.connect.cancel",
|
||||
{ channel: "weixin", session_id: "session+/=" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes workspace automation actions", async () => {
|
||||
await runAutomationAction("tok", "disable", "job 1/2");
|
||||
await runAutomationAction(mutationTransport, "disable", "job 1/2");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/disable?id=job+1%2F2",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"automation.disable",
|
||||
{ id: "job 1/2" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -275,19 +272,14 @@ describe("webui API helpers", () => {
|
||||
message: "Ask 今日 quiz",
|
||||
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
|
||||
} as const;
|
||||
await updateAutomation("tok", "job 1/2", values);
|
||||
await updateAutomation(mutationTransport, "job 1/2", values);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/update?id=job+1%2F2",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
|
||||
},
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"automation.update",
|
||||
{ id: "job 1/2", values },
|
||||
20_000,
|
||||
);
|
||||
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the WebUI skill summary", async () => {
|
||||
@@ -348,66 +340,66 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes provider install coordinates", async () => {
|
||||
it("sends provider install coordinates without placing them in a URL", async () => {
|
||||
await installMarketplaceSkill(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"skillhub",
|
||||
"@tencent/skills",
|
||||
"ima-skills",
|
||||
"1.1.8",
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"skill.install",
|
||||
{
|
||||
provider: "skillhub",
|
||||
source: "@tencent/skills",
|
||||
skill: "ima-skills",
|
||||
version: "1.1.8",
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates and deletes installed skills with encoded names", async () => {
|
||||
await updateSkillEnabled("tok", "custom skill", false);
|
||||
it("updates and deletes installed skills over the WebSocket", async () => {
|
||||
await updateSkillEnabled(mutationTransport, "custom skill", false);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/update?name=custom+skill&enabled=false",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"skill.update",
|
||||
{ name: "custom skill", enabled: false },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await deleteSkill("tok", "custom skill");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/delete?name=custom+skill",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await deleteSkill(mutationTransport, "custom skill");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"skill.delete",
|
||||
{ name: "custom skill" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1");
|
||||
it("sends the session key in a mutation payload", async () => {
|
||||
await deleteSession(mutationTransport, "websocket:chat-1");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/delete",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"session.delete",
|
||||
{ key: "websocket:chat-1" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the automation cascade flag when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
|
||||
await deleteSession(mutationTransport, "websocket:chat-1", { deleteAutomations: true });
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"session.delete",
|
||||
{ key: "websocket:chat-1", delete_automations: true },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes settings updates as a narrow query string", async () => {
|
||||
await updateSettings("tok", {
|
||||
it("serializes settings updates as a narrow mutation payload", async () => {
|
||||
await updateSettings(mutationTransport, {
|
||||
modelPreset: "default",
|
||||
model: "openrouter/test",
|
||||
provider: "openrouter",
|
||||
@@ -416,11 +408,17 @@ describe("webui API helpers", () => {
|
||||
toolHintMaxLength: 120,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&tool_hint_max_length=120",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.agent.update",
|
||||
{
|
||||
model_preset: "default",
|
||||
model: "openrouter/test",
|
||||
provider: "openrouter",
|
||||
context_window_tokens: 262144,
|
||||
timezone: "Asia/Shanghai",
|
||||
tool_hint_max_length: 120,
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -436,7 +434,7 @@ describe("webui API helpers", () => {
|
||||
});
|
||||
|
||||
it("serializes model configuration creation", async () => {
|
||||
await createModelConfiguration("tok", {
|
||||
await createModelConfiguration(mutationTransport, {
|
||||
label: "Fast writing",
|
||||
provider: "openai",
|
||||
model: "openai/gpt-4.1-mini",
|
||||
@@ -446,16 +444,23 @@ describe("webui API helpers", () => {
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini&max_tokens=4096&context_window_tokens=128000&temperature=0.4&reasoning_effort=high",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
label: "Fast writing",
|
||||
provider: "openai",
|
||||
model: "openai/gpt-4.1-mini",
|
||||
max_tokens: 4096,
|
||||
context_window_tokens: 128000,
|
||||
temperature: 0.4,
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model configuration updates", async () => {
|
||||
await updateModelConfiguration("tok", {
|
||||
await updateModelConfiguration(mutationTransport, {
|
||||
name: "codex",
|
||||
label: "Codex",
|
||||
provider: "openai_codex",
|
||||
@@ -466,42 +471,47 @@ describe("webui API helpers", () => {
|
||||
reasoningEffort: null,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&max_tokens=8192&context_window_tokens=65536&temperature=0&reasoning_effort=",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.model_configuration.update",
|
||||
{
|
||||
name: "codex",
|
||||
label: "Codex",
|
||||
provider: "openai_codex",
|
||||
model: "openai-codex/gpt-5.5",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0,
|
||||
reasoning_effort: "",
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model preset deletion and migration", async () => {
|
||||
await deleteModelConfiguration("tok", "spare");
|
||||
await migrateModelConfigurations("tok");
|
||||
await deleteModelConfiguration(mutationTransport, "spare");
|
||||
await migrateModelConfigurations(mutationTransport);
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
expect(requestMutation).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/settings/model-configurations/delete?name=spare",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
"settings.model_configuration.delete",
|
||||
{ name: "spare" },
|
||||
20_000,
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
expect(requestMutation).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/settings/model-configurations/migrate",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
"settings.model_configuration.migrate",
|
||||
{},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model call order as an ordered JSON array", async () => {
|
||||
await updateModelCallOrder("tok", ["backup", "primary"]);
|
||||
await updateModelCallOrder(mutationTransport, ["backup", "primary"]);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.model_call_order.update",
|
||||
{ order: ["backup", "primary"] },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -516,28 +526,20 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
updateModelConfiguration("tok", {
|
||||
name: "codex",
|
||||
model: "openai-codex/gpt-5.5",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
await expect(fetchApiService("tok")).rejects.toMatchObject({
|
||||
status: 200,
|
||||
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces API error response bodies", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "npm error ENOTEMPTY",
|
||||
}),
|
||||
it("surfaces correlated WebSocket mutation errors", async () => {
|
||||
requestMutation.mockRejectedValueOnce(
|
||||
Object.assign(new Error("npm error ENOTEMPTY"), { status: 500 }),
|
||||
);
|
||||
|
||||
await expect(runCliAppAction("tok", "install", "hyperframes")).rejects.toMatchObject({
|
||||
await expect(
|
||||
runCliAppAction(mutationTransport, "install", "hyperframes"),
|
||||
).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: "npm error ENOTEMPTY",
|
||||
});
|
||||
@@ -555,50 +557,45 @@ describe("webui API helpers", () => {
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("serializes provider settings updates without returning secrets", async () => {
|
||||
await updateProviderSettings("tok", {
|
||||
it("keeps provider secrets in the WebSocket payload", async () => {
|
||||
await updateProviderSettings(mutationTransport, {
|
||||
provider: "openrouter",
|
||||
apiKey: "sk-or-test",
|
||||
apiBase: "https://openrouter.ai/api/v1",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/update?provider=openrouter",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.provider.update",
|
||||
{
|
||||
provider: "openrouter",
|
||||
apiKey: "sk-or-test",
|
||||
apiBase: "https://openrouter.ai/api/v1",
|
||||
})),
|
||||
},
|
||||
}),
|
||||
20_000,
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes OAuth provider advanced settings", async () => {
|
||||
await updateProviderSettings("tok", {
|
||||
await updateProviderSettings(mutationTransport, {
|
||||
provider: "xai_grok",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
extraBody: '{"tools":[]}',
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/update?provider=xai_grok",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.provider.update",
|
||||
{
|
||||
provider: "xai_grok",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
extraBody: '{"tools":[]}',
|
||||
})),
|
||||
},
|
||||
}),
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes custom provider creation with advanced settings", async () => {
|
||||
await createProviderSettings("tok", {
|
||||
const update = {
|
||||
name: "Company Gateway",
|
||||
apiKey: "sk-company",
|
||||
apiBase: "https://gateway.example/v1",
|
||||
@@ -607,25 +604,13 @@ describe("webui API helpers", () => {
|
||||
extraQuery: '{"api-version":"2026-01-01"}',
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
thinkingStyle: "enable_thinking",
|
||||
});
|
||||
};
|
||||
await createProviderSettings(mutationTransport, update);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/create",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
name: "Company Gateway",
|
||||
apiKey: "sk-company",
|
||||
apiBase: "https://gateway.example/v1",
|
||||
extraHeaders: '{"X-Tenant":"engineering"}',
|
||||
extraBody: '{"service_tier":"priority"}',
|
||||
extraQuery: '{"api-version":"2026-01-01"}',
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
thinkingStyle: "enable_thinking",
|
||||
})),
|
||||
},
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.provider.create",
|
||||
update,
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -641,74 +626,65 @@ describe("webui API helpers", () => {
|
||||
});
|
||||
|
||||
it("serializes provider OAuth login and logout actions", async () => {
|
||||
await loginProviderOAuth("tok", "openai_codex");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login?provider=openai_codex",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await loginProviderOAuth(mutationTransport, "openai_codex");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_login",
|
||||
{ provider: "openai_codex" },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await loginProviderOAuth("tok", "openai_codex", "", true);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await loginProviderOAuth(mutationTransport, "openai_codex", true);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_login",
|
||||
{ provider: "openai_codex", remote_browser: true },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await completeProviderOAuth("tok", "xai_grok", "flow-123");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await completeProviderOAuth(mutationTransport, "xai_grok", "flow-123");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_complete",
|
||||
{ provider: "xai_grok", flow_id: "flow-123" },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await completeProviderOAuth(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"xai_grok",
|
||||
"flow-123",
|
||||
"secret",
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-OAuth-Code": "secret",
|
||||
},
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_complete",
|
||||
{ provider: "xai_grok", flow_id: "flow-123", authorization_response: "secret" },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await completeProviderOAuth(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"openai_codex",
|
||||
"flow-codex",
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-OAuth-Callback":
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_complete",
|
||||
{
|
||||
provider: "openai_codex",
|
||||
flow_id: "flow-codex",
|
||||
authorization_response: "http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
},
|
||||
}),
|
||||
20_000,
|
||||
);
|
||||
|
||||
await logoutProviderOAuth("tok", "openai_codex");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-logout?provider=openai_codex",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await logoutProviderOAuth(mutationTransport, "openai_codex");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_logout",
|
||||
{ provider: "openai_codex" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes web search settings updates", async () => {
|
||||
await updateWebSearchSettings("tok", {
|
||||
await updateWebSearchSettings(mutationTransport, {
|
||||
provider: "searxng",
|
||||
baseUrl: "https://search.example.com",
|
||||
maxResults: 8,
|
||||
@@ -716,30 +692,37 @@ describe("webui API helpers", () => {
|
||||
useJinaReader: false,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.web_search.update",
|
||||
{
|
||||
provider: "searxng",
|
||||
base_url: "https://search.example.com",
|
||||
max_results: 8,
|
||||
timeout: 45,
|
||||
use_jina_reader: false,
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes network safety settings updates", async () => {
|
||||
await updateNetworkSafetySettings("tok", {
|
||||
await updateNetworkSafetySettings(mutationTransport, {
|
||||
webuiAllowLocalServiceAccess: false,
|
||||
webuiDefaultAccessMode: "full",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.network_safety.update",
|
||||
{
|
||||
webui_allow_local_service_access: false,
|
||||
webui_default_access_mode: "full",
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes image generation settings updates", async () => {
|
||||
await updateImageGenerationSettings("tok", {
|
||||
await updateImageGenerationSettings(mutationTransport, {
|
||||
enabled: true,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
@@ -748,11 +731,17 @@ describe("webui API helpers", () => {
|
||||
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" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.image_generation.update",
|
||||
{
|
||||
enabled: true,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
default_aspect_ratio: "16:9",
|
||||
default_image_size: "2K",
|
||||
max_images_per_turn: 3,
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -774,12 +763,11 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await runCliAppAction("tok", "install", "gimp");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/cli-apps/install?name=gimp",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await runCliAppAction(mutationTransport, "install", "gimp");
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.cli_app.install",
|
||||
{ name: "gimp" },
|
||||
150_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -819,20 +807,18 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await enableNanobotFeature("tok", "matrix");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/nanobot-features/enable?name=matrix",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await enableNanobotFeature(mutationTransport, "matrix");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.feature.enable",
|
||||
{ name: "matrix" },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await disableNanobotFeature("tok", "matrix");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/nanobot-features/disable?name=matrix",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await disableNanobotFeature(mutationTransport, "matrix");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.feature.disable",
|
||||
{ name: "matrix" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -843,34 +829,31 @@ describe("webui API helpers", () => {
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
|
||||
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
await startApiService(
|
||||
mutationTransport,
|
||||
{ host: "127.0.0.1", port: 8900, timeout: 120 },
|
||||
);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.api_service.start",
|
||||
{ host: "127.0.0.1", port: 8900, timeout: 120 },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await startApiService(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-token"),
|
||||
expect.anything(),
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.api_service.start",
|
||||
{ host: "0.0.0.0", port: 8900, timeout: 120, api_key: "secret-token" },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await stopApiService("tok");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/stop",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
await stopApiService(mutationTransport);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.api_service.stop",
|
||||
{},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -891,71 +874,46 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await runMcpPresetAction("tok", "enable", "browserbase", {
|
||||
await runMcpPresetAction(mutationTransport, "enable", "browserbase", {
|
||||
browserbase_api_key: "bb_live_test",
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/enable?name=browserbase",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
browserbase_api_key: "bb_live_test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.mcp.enable",
|
||||
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
|
||||
await saveCustomMcpServer("tok", {
|
||||
const custom = {
|
||||
name: "docs",
|
||||
transport: "stdio",
|
||||
command: "npx",
|
||||
args: '["-y","docs-mcp"]',
|
||||
env: '{"API_KEY":"secret"}',
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/custom",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
name: "docs",
|
||||
transport: "stdio",
|
||||
command: "npx",
|
||||
args: '["-y","docs-mcp"]',
|
||||
env: '{"API_KEY":"secret"}',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
await saveCustomMcpServer(mutationTransport, custom);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.mcp.custom",
|
||||
custom,
|
||||
20_000,
|
||||
);
|
||||
|
||||
await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/import",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
config: '{"mcpServers":{"docs":{"command":"npx"}}}',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
await importMcpConfig(
|
||||
mutationTransport,
|
||||
'{"mcpServers":{"docs":{"command":"npx"}}}',
|
||||
);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.mcp.import",
|
||||
{ config: '{"mcpServers":{"docs":{"command":"npx"}}}' },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await updateMcpServerTools("tok", "docs", ["search", "fetch"]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/tools",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
name: "docs",
|
||||
enabled_tools: ["search", "fetch"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
await updateMcpServerTools(mutationTransport, "docs", ["search", "fetch"]);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.mcp.tools",
|
||||
{ name: "docs", enabled_tools: ["search", "fetch"] },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -991,19 +949,12 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
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" },
|
||||
project_name_overrides: { "/Users/me/nanobot": "Core" },
|
||||
});
|
||||
await updateSidebarState(mutationTransport, state);
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"sidebar.update",
|
||||
{ state },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches workspace project state", async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const setSidebarStateSpy = vi.fn();
|
||||
const requestMutationSpy = vi.fn();
|
||||
const discardTemporaryChatSpy = vi.fn();
|
||||
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
|
||||
const sendMessageSpy = vi.fn();
|
||||
@@ -163,7 +164,24 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: refreshSpy,
|
||||
createChat: createChatSpy,
|
||||
createChat: async (scope?: WorkspaceScopePayload | null) => {
|
||||
const chatId = await createChatSpy(scope);
|
||||
const now = new Date().toISOString();
|
||||
setSessions((prev: ChatSummary[]) => [
|
||||
{
|
||||
key: `websocket:${chatId}`,
|
||||
channel: "websocket",
|
||||
chatId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
title: "",
|
||||
preview: "",
|
||||
workspaceScope: scope ?? null,
|
||||
},
|
||||
...prev.filter((session) => session.chatId !== chatId),
|
||||
]);
|
||||
return chatId;
|
||||
},
|
||||
forkChat: async () => "fork-chat",
|
||||
getSessionAutomations: getSessionAutomationsSpy,
|
||||
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||
@@ -242,6 +260,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
||||
newTemporaryChat = newTemporaryChatSpy;
|
||||
attach = attachSpy;
|
||||
setSidebarState = setSidebarStateSpy;
|
||||
requestMutation = requestMutationSpy;
|
||||
discardTemporaryChat = discardTemporaryChatSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
@@ -270,7 +289,8 @@ describe("App layout", () => {
|
||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset().mockResolvedValue({});
|
||||
requestMutationSpy.mockReset();
|
||||
discardTemporaryChatSpy.mockReset();
|
||||
let temporaryChatCounter = 0;
|
||||
newTemporaryChatSpy.mockImplementation(async () => (
|
||||
@@ -287,6 +307,8 @@ describe("App layout", () => {
|
||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||
localStorage.removeItem("nanobot-webui.restartStartedAt");
|
||||
localStorage.removeItem("nanobot-webui.restartRoute");
|
||||
localStorage.removeItem("nanobot.webui.workbench.v1");
|
||||
localStorage.removeItem("nanobot.webui.workbench.v2");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
api_token: "api-tok",
|
||||
@@ -315,11 +337,68 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
||||
.toBeInTheDocument();
|
||||
const password = screen.getByLabelText("Password");
|
||||
expect(password).toHaveAttribute(
|
||||
"autocomplete",
|
||||
"current-password",
|
||||
);
|
||||
expect(password).not.toHaveAttribute("placeholder");
|
||||
expect(screen.queryByText("Authentication required")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Incorrect password. Try again."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles password visibility without changing the password", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
||||
new Error("bootstrap failed: HTTP 401"),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByLabelText("Password");
|
||||
await user.type(password, "correct horse battery staple");
|
||||
expect(password).toHaveAttribute("type", "password");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show password" }));
|
||||
|
||||
expect(password).toHaveAttribute("type", "text");
|
||||
expect(password).toHaveValue("correct horse battery staple");
|
||||
const hidePassword = screen.getByRole("button", { name: "Hide password" });
|
||||
expect(hidePassword).toHaveFocus();
|
||||
|
||||
await user.click(hidePassword);
|
||||
|
||||
expect(password).toHaveAttribute("type", "password");
|
||||
expect(password).toHaveValue("correct horse battery staple");
|
||||
expect(screen.getByRole("button", { name: "Show password" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("explains and focuses an empty auth password", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValue(
|
||||
new Error("bootstrap failed: HTTP 401"),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByLabelText("Password");
|
||||
const connect = screen.getByRole("button", { name: "Connect" });
|
||||
expect(connect).toBeEnabled();
|
||||
fireEvent.click(connect);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Enter your password.",
|
||||
);
|
||||
expect(password).toHaveAttribute("aria-invalid", "true");
|
||||
expect(password).toHaveAttribute("aria-describedby", "webui-auth-error");
|
||||
expect(password).toHaveFocus();
|
||||
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the auth form when bootstrap does not issue an API token", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
||||
new BootstrapAuthRequiredError(
|
||||
@@ -329,8 +408,11 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Incorrect password. Try again."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -341,11 +423,16 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByPlaceholderText("Password");
|
||||
const password = await screen.findByLabelText("Password");
|
||||
fireEvent.change(password, { target: { value: "wrong-password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(await screen.findByText("Invalid password. Try again.")).toBeInTheDocument();
|
||||
const retryPassword = await screen.findByLabelText("Password");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Incorrect password. Try again.",
|
||||
);
|
||||
expect(retryPassword).toHaveAttribute("aria-invalid", "true");
|
||||
expect(retryPassword).toHaveFocus();
|
||||
expect(fetchBootstrap).toHaveBeenLastCalledWith("", "wrong-password");
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -365,6 +452,21 @@ describe("App layout", () => {
|
||||
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses one main landmark and a page heading in desktop settings", async () => {
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
const { container } = render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("main")).toHaveLength(1);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("places Automations after Skills in the main sidebar", async () => {
|
||||
render(<App />);
|
||||
|
||||
@@ -402,8 +504,9 @@ describe("App layout", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const firstMessage = "keep this first turn visible";
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
target: { value: "/model" },
|
||||
target: { value: firstMessage },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
@@ -413,6 +516,7 @@ describe("App layout", () => {
|
||||
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText(firstMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a new temporary chat from the hero each time", async () => {
|
||||
@@ -652,6 +756,57 @@ describe("App layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the first message when the gateway rejects a project", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
createChatSpy.mockRejectedValueOnce(
|
||||
new Error("workspace_scope_rejected:project_path must be an existing directory"),
|
||||
);
|
||||
mockFetchRoutes({
|
||||
"/api/workspaces": {
|
||||
schema_version: 1,
|
||||
default_access_mode: "restricted",
|
||||
default_scope: {
|
||||
project_path: "C:\\workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "restricted",
|
||||
restrict_to_workspace: true,
|
||||
},
|
||||
controls: { can_change_project: true, can_use_full_access: true },
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Choose project" }));
|
||||
fireEvent.change(await screen.findByLabelText("Paste path"), {
|
||||
target: { value: "C:\\missing-project" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
|
||||
|
||||
const message = screen.getByLabelText("Message input");
|
||||
fireEvent.change(message, { target: { value: "keep this first message" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
||||
expect(message).toHaveValue("keep this first message");
|
||||
const projectButton = screen.getByRole("button", { name: "Choose project" });
|
||||
await waitFor(() => expect(projectButton).toHaveFocus());
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
|
||||
);
|
||||
fireEvent.click(projectButton);
|
||||
const projectPath = await screen.findByLabelText("Paste path");
|
||||
expect(projectPath).toHaveValue("C:\\missing-project");
|
||||
expect(projectPath).toHaveAttribute("aria-invalid", "true");
|
||||
expect(projectPath).toHaveFocus();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
|
||||
);
|
||||
expect(window.location.hash).toBe("");
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("restores the Settings route after a restart fallback hash", async () => {
|
||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||
@@ -746,7 +901,8 @@ describe("App layout", () => {
|
||||
}],
|
||||
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
|
||||
},
|
||||
"/api/webui/skills/update?name=github&enabled=false": {
|
||||
});
|
||||
requestMutationSpy.mockResolvedValueOnce({
|
||||
skills: [
|
||||
{
|
||||
name: "cron",
|
||||
@@ -774,12 +930,7 @@ describe("App layout", () => {
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
last_action: {
|
||||
name: "github",
|
||||
enabled: false,
|
||||
deleted: false,
|
||||
},
|
||||
},
|
||||
last_action: { name: "github", enabled: false, deleted: false },
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -879,14 +1030,10 @@ describe("App layout", () => {
|
||||
},
|
||||
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
|
||||
},
|
||||
"/api/webui/skills/delete?name=custom-skill": {
|
||||
});
|
||||
requestMutationSpy.mockResolvedValueOnce({
|
||||
skills: [],
|
||||
last_action: {
|
||||
name: "custom-skill",
|
||||
enabled: false,
|
||||
deleted: true,
|
||||
},
|
||||
},
|
||||
last_action: { name: "custom-skill", enabled: false, deleted: true },
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -1018,9 +1165,8 @@ describe("App layout", () => {
|
||||
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
||||
trends: { "acme/agent-skills/react-testing": [] },
|
||||
},
|
||||
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing":
|
||||
() => pendingInstall,
|
||||
});
|
||||
requestMutationSpy.mockImplementationOnce(() => pendingInstall);
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -1068,11 +1214,14 @@ describe("App layout", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: expect.any(String) },
|
||||
}),
|
||||
expect(requestMutationSpy).toHaveBeenCalledWith(
|
||||
"skill.install",
|
||||
{
|
||||
provider: "skills_sh",
|
||||
source: "acme/agent-skills",
|
||||
skill: "react-testing",
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||
@@ -1230,14 +1379,12 @@ describe("App layout", () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/webui/automations": { jobs: [pastOneShot] },
|
||||
"/api/webui/automations/update?id=past-one-shot": {
|
||||
jobs: [
|
||||
{
|
||||
});
|
||||
requestMutationSpy.mockResolvedValueOnce({
|
||||
jobs: [{
|
||||
...pastOneShot,
|
||||
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -1263,19 +1410,17 @@ describe("App layout", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/update?id=past-one-shot",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
const updateCall = vi.mocked(fetch).mock.calls.find(
|
||||
([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot",
|
||||
);
|
||||
expect(updateCall).toBeTruthy();
|
||||
const headers = updateCall?.[1]?.headers as Record<string, string>;
|
||||
expect(JSON.parse(decodeURIComponent(headers["X-Nanobot-Automation-Values"]))).toEqual({
|
||||
expect(requestMutationSpy).toHaveBeenCalledWith(
|
||||
"automation.update",
|
||||
{
|
||||
id: "past-one-shot",
|
||||
values: {
|
||||
name: "Past one-shot",
|
||||
message: "Updated one-shot message",
|
||||
},
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1525,6 +1670,60 @@ describe("App layout", () => {
|
||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||
}, 15_000);
|
||||
|
||||
it("deletes multiple selected topics through one confirmation", 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",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-c",
|
||||
channel: "websocket",
|
||||
chatId: "chat-c",
|
||||
createdAt: "2026-04-16T12:00:00Z",
|
||||
updatedAt: "2026-04-16T12:00:00Z",
|
||||
preview: "Third chat",
|
||||
},
|
||||
];
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.pointerDown(within(sidebar).getByLabelText(
|
||||
"Topic actions for First chat",
|
||||
), { button: 0 });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Second chat" }));
|
||||
expect(within(sidebar).getByText("2 selected")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Delete" }));
|
||||
expect(await screen.findByText("Delete 2 topics and panes?")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledTimes(2));
|
||||
expect(deleteChatSpy.mock.calls.map(([key]) => key)).toEqual([
|
||||
"websocket:chat-a",
|
||||
"websocket:chat-b",
|
||||
]);
|
||||
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
|
||||
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-b");
|
||||
expect(within(sidebar).getByRole("button", { name: "Third chat" }))
|
||||
.toBeInTheDocument();
|
||||
}, 15_000);
|
||||
|
||||
it("shows localized bound automations in the first delete confirmation", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
@@ -1698,6 +1897,9 @@ describe("App layout", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
act(() => {
|
||||
statusHandlers.forEach((handler) => handler("open"));
|
||||
});
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
await waitFor(() =>
|
||||
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
|
||||
@@ -2450,17 +2652,14 @@ describe("App layout", () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": initialSettings,
|
||||
});
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
window.history.replaceState(null, "", "/#/settings?section=runtime");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("UTC")).toBeInTheDocument();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) =>
|
||||
String(input).startsWith("/api/settings/update?timezone="),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
requestMutationSpy.mock.calls.some(([action]) => action === "settings.agent.update"),
|
||||
).toBe(false);
|
||||
expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Used for schedules and time-aware replies."),
|
||||
@@ -2819,6 +3018,109 @@ describe("App layout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps panes and layout scoped to the current topic tab", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
createChatSpy.mockResolvedValueOnce("chat-pane");
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-alpha",
|
||||
channel: "websocket",
|
||||
chatId: "chat-alpha",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
title: "Alpha",
|
||||
preview: "Alpha notes",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-beta",
|
||||
channel: "websocket",
|
||||
chatId: "chat-beta",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
title: "Beta",
|
||||
preview: "Beta notes",
|
||||
},
|
||||
];
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/#/chat/websocket%3Achat-alpha",
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const grid = await screen.findByTestId("pane-grid");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add pane" }));
|
||||
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
||||
|
||||
await waitFor(() => expect(grid.children).toHaveLength(2));
|
||||
expect(window.location.hash).toBe("#/chat/websocket%3Achat-alpha");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "New topic"]);
|
||||
|
||||
const activeComposer = screen.getByTestId("active-pane-composer");
|
||||
const paneInput = within(activeComposer).getByRole("textbox", {
|
||||
name: "Message New topic",
|
||||
});
|
||||
fireEvent.change(paneInput, { target: { value: "route this to the new pane" } });
|
||||
fireEvent.keyDown(paneInput, { key: "Enter" });
|
||||
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
|
||||
expect(sendMessageSpy.mock.calls.at(-1)?.[0]).toBe("chat-pane");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Pane layout" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
||||
expect(grid).toHaveAttribute("data-layout", "rows");
|
||||
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const paneTopicButton = within(sidebar)
|
||||
.getAllByRole("button", { name: "New topic" })
|
||||
.find((button) => button.closest("[data-sidebar-pane]"));
|
||||
expect(paneTopicButton).toBeDefined();
|
||||
expect(paneTopicButton?.closest("[data-sidebar-pane]"))
|
||||
.toHaveAttribute("data-sidebar-pane", "websocket:chat-pane");
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Beta" }));
|
||||
await waitFor(() => {
|
||||
const nextGrid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(nextGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Beta"]);
|
||||
expect(nextGrid).toHaveAttribute("data-layout", "columns");
|
||||
});
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Alpha" }));
|
||||
await waitFor(() => {
|
||||
const restoredGrid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(restoredGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "New topic"]);
|
||||
expect(restoredGrid).toHaveAttribute("data-layout", "rows");
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||
name: "New topic pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(screen.getByRole("menuitem", {
|
||||
name: "Move New topic to its own topic",
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
|
||||
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("opens search from the keyboard shortcut", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { createEvent, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||
import { PANE_DRAG_TYPE, SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||
@@ -42,6 +42,26 @@ function rect({
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function dragOverAt(
|
||||
element: Element,
|
||||
clientY: number,
|
||||
dataTransfer: Record<string, unknown>,
|
||||
): void {
|
||||
const event = createEvent.dragOver(element, { dataTransfer });
|
||||
Object.defineProperty(event, "clientY", { value: clientY });
|
||||
fireEvent(element, event);
|
||||
}
|
||||
|
||||
function dropAt(
|
||||
element: Element,
|
||||
clientY: number,
|
||||
dataTransfer: Record<string, unknown>,
|
||||
): void {
|
||||
const event = createEvent.drop(element, { dataTransfer });
|
||||
Object.defineProperty(event, "clientY", { value: clientY });
|
||||
fireEvent(element, event);
|
||||
}
|
||||
|
||||
describe("ChatList", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -82,7 +102,13 @@ describe("ChatList", () => {
|
||||
fireEvent.dragEnd(reference, { dataTransfer });
|
||||
});
|
||||
|
||||
it("reorders chats around a Codex-style insertion line", () => {
|
||||
it("reorders topics with a displaced-neighbor preview instead of an insertion line", () => {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 284,
|
||||
height: 32,
|
||||
}));
|
||||
const onReorderSessions = vi.fn();
|
||||
const sessions = [
|
||||
session({ chatId: "alpha", title: "Alpha" }),
|
||||
@@ -112,10 +138,14 @@ describe("ChatList", () => {
|
||||
};
|
||||
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
|
||||
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
|
||||
fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer });
|
||||
expect(charlieRow.querySelector("[data-session-drop-edge='after']"))
|
||||
.toBeInTheDocument();
|
||||
fireEvent.drop(charlieRow, { clientY: 1, dataTransfer });
|
||||
dragOverAt(charlieRow, 24, dataTransfer);
|
||||
expect(document.querySelector("[data-session-drop-edge]")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Bravo" }).closest("li"))
|
||||
.toHaveAttribute("data-session-displaced", "true");
|
||||
expect(charlieRow).toHaveStyle({ transform: "translateY(-32px)" });
|
||||
expect(screen.getByRole("button", { name: "Alpha" }).closest("li"))
|
||||
.toHaveAttribute("data-session-dragging", "true");
|
||||
dropAt(charlieRow, 24, dataTransfer);
|
||||
|
||||
expect(onReorderSessions).toHaveBeenCalledWith([
|
||||
"websocket:bravo",
|
||||
@@ -152,6 +182,228 @@ describe("ChatList", () => {
|
||||
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
||||
});
|
||||
|
||||
it("shows every tab's pane membership in the sidebar tree", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onSelectPane = vi.fn();
|
||||
const onDetachPane = vi.fn();
|
||||
const onPromotePane = vi.fn();
|
||||
const onRequestRename = vi.fn();
|
||||
const onAttachPane = vi.fn();
|
||||
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "root", title: "Root topic" }),
|
||||
session({ chatId: "target", title: "Target tab" }),
|
||||
]}
|
||||
activeKey="websocket:root"
|
||||
paneGroups={{
|
||||
"websocket:root": {
|
||||
topicKey: "websocket:root",
|
||||
activePaneKey: "websocket:child",
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||
],
|
||||
},
|
||||
"websocket:target": {
|
||||
topicKey: "websocket:target",
|
||||
activePaneKey: "websocket:target-child",
|
||||
panes: [
|
||||
{ key: "websocket:target", chatId: "target", title: "Target tab" },
|
||||
{
|
||||
key: "websocket:target-child",
|
||||
chatId: "target-child",
|
||||
title: "Target research",
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
onSelectPane={onSelectPane}
|
||||
onDetachPane={onDetachPane}
|
||||
onPromotePane={onPromotePane}
|
||||
paneAcceptingTabKeys={["websocket:target"]}
|
||||
onAttachPane={onAttachPane}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={onRequestRename}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const child = screen.getByRole("button", { name: "Research pane" });
|
||||
expect(child.closest("[data-sidebar-pane]"))
|
||||
.toHaveAttribute("data-sidebar-pane", "websocket:child");
|
||||
expect(child).toHaveAttribute("aria-current", "true");
|
||||
const targetTabRow = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
|
||||
const targetChild = within(targetTabRow).getByRole("button", {
|
||||
name: "Target research",
|
||||
});
|
||||
expect(targetChild.closest("[data-sidebar-pane]"))
|
||||
.toHaveAttribute("data-sidebar-pane", "websocket:target-child");
|
||||
expect(targetChild).not.toHaveAttribute("aria-current");
|
||||
fireEvent.click(targetChild);
|
||||
expect(onSelectPane).toHaveBeenCalledWith(
|
||||
"websocket:target",
|
||||
"websocket:target-child",
|
||||
);
|
||||
fireEvent.click(child);
|
||||
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Root topic" }));
|
||||
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:root");
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Research pane pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
const moveToTab = await screen.findByRole("menuitem", { name: "Move to tab" });
|
||||
fireEvent.pointerMove(moveToTab, { pointerType: "mouse" });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab" }));
|
||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Research pane pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", {
|
||||
name: "Move Research pane to its own topic",
|
||||
}));
|
||||
expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
||||
|
||||
const dataTransfer = {
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
setData: vi.fn(),
|
||||
};
|
||||
fireEvent.dragStart(child, { dataTransfer });
|
||||
expect(child.closest("li")).toHaveAttribute("data-pane-dragging", "true");
|
||||
expect(child.closest("li")).not.toHaveClass("opacity-0");
|
||||
const targetTab = screen.getByRole("button", { name: "Target tab" });
|
||||
dragOverAt(targetTab.closest("li")!, 0, dataTransfer);
|
||||
expect(targetTab.closest("li"))
|
||||
.toHaveAttribute("data-tab-attach-target", "true");
|
||||
expect(within(targetTab.closest("li")!).getByRole("status", {
|
||||
name: "Move Research pane into Target tab",
|
||||
})).toHaveTextContent("Research pane");
|
||||
dropAt(targetTab.closest("li")!, 0, dataTransfer);
|
||||
expect(dataTransfer.setData).toHaveBeenCalledWith(
|
||||
PANE_DRAG_TYPE,
|
||||
JSON.stringify({
|
||||
paneKey: "websocket:child",
|
||||
sourceTabKey: "websocket:root",
|
||||
}),
|
||||
);
|
||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
|
||||
});
|
||||
|
||||
it("selects a whole tab or individual panes for one bulk delete", async () => {
|
||||
const onRequestDeleteMany = vi.fn();
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "root", title: "Root topic" }),
|
||||
session({ chatId: "target", title: "Target tab" }),
|
||||
]}
|
||||
activeKey="websocket:root"
|
||||
paneGroups={{
|
||||
"websocket:root": {
|
||||
topicKey: "websocket:root",
|
||||
activePaneKey: "websocket:root",
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||
],
|
||||
},
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onRequestDeleteMany={onRequestDeleteMany}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Topic actions for Root topic",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Root topic" }))
|
||||
.toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Research pane" }))
|
||||
.toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Target tab" }));
|
||||
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByTestId("delete-selection-bar")).getByRole("button", {
|
||||
name: "Delete",
|
||||
}));
|
||||
|
||||
expect(onRequestDeleteMany).toHaveBeenCalledWith([
|
||||
{ key: "websocket:root", label: "Root topic" },
|
||||
{ key: "websocket:child", label: "Research pane" },
|
||||
{ key: "websocket:target", label: "Target tab" },
|
||||
]);
|
||||
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reattaches a one-pane tab through the center of another tab", () => {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 284,
|
||||
height: 32,
|
||||
}));
|
||||
const onAttachPane = vi.fn();
|
||||
const onReorderSessions = vi.fn();
|
||||
const dataTransfer = {
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
setData: vi.fn(),
|
||||
};
|
||||
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "detached", title: "Detached pane" }),
|
||||
session({ chatId: "target", title: "Target tab" }),
|
||||
]}
|
||||
activeKey={null}
|
||||
attachableTabKeys={["websocket:detached", "websocket:target"]}
|
||||
paneAcceptingTabKeys={["websocket:detached", "websocket:target"]}
|
||||
onAttachPane={onAttachPane}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
onReorderSessions={onReorderSessions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const detached = screen.getByRole("button", { name: "Detached pane" });
|
||||
fireEvent.dragStart(detached, {
|
||||
dataTransfer,
|
||||
});
|
||||
expect(detached.closest("li"))
|
||||
.toHaveAttribute("data-session-dragging", "true");
|
||||
const target = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
|
||||
dragOverAt(target, 16, dataTransfer);
|
||||
expect(target).toHaveAttribute("data-tab-attach-target", "true");
|
||||
expect(document.querySelector("[data-session-displaced='true']"))
|
||||
.not.toBeInTheDocument();
|
||||
dropAt(target, 16, dataTransfer);
|
||||
|
||||
expect(onAttachPane).toHaveBeenCalledWith(
|
||||
"websocket:detached",
|
||||
"websocket:target",
|
||||
);
|
||||
expect(onReorderSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows temporary chats separately and lets the user reopen or close them", async () => {
|
||||
const temporarySession = session({
|
||||
key: "temporary:temporary-one",
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "reasoning_then_streamed_answer",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"initial_messages": [
|
||||
{
|
||||
"id": "fixture-user-reasoning",
|
||||
"role": "user",
|
||||
"content": "Explain event projection.",
|
||||
"turnId": "turn-reasoning",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1,
|
||||
"createdAt": 1700000000000
|
||||
}
|
||||
],
|
||||
"live_events": [
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Compare ",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "state.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "reasoning_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Use one ",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "fold.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 7
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"latency_ms": 42,
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 8
|
||||
}
|
||||
],
|
||||
"transcript": [
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Explain event projection.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
"created_at_ms": 1700000000000
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Compare ",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "state.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "reasoning_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Use one ",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "fold.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 7
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"latency_ms": 42,
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 8
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Explain event projection.",
|
||||
"turnId": "turn-reasoning",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Use one fold.",
|
||||
"reasoning": "Compare state.",
|
||||
"activitySegmentId": "segment-1",
|
||||
"latencyMs": 42,
|
||||
"turnId": "turn-reasoning",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "length_recovery_merges_answer_segments",
|
||||
"chat_id": "fixture-length",
|
||||
"initial_messages": [
|
||||
{
|
||||
"id": "fixture-user-length",
|
||||
"role": "user",
|
||||
"content": "Continue after the limit.",
|
||||
"turnId": "turn-length",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1,
|
||||
"createdAt": 1700000001000
|
||||
}
|
||||
],
|
||||
"live_events": [
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "first ",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "first ",
|
||||
"resuming": true,
|
||||
"merge_next": true,
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "second",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-length",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-length",
|
||||
"latency_ms": 17,
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 6
|
||||
}
|
||||
],
|
||||
"transcript": [
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "Continue after the limit.",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
"created_at_ms": 1700000001000
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "first ",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "first ",
|
||||
"resuming": true,
|
||||
"merge_next": true,
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "second",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-length",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-length",
|
||||
"latency_ms": 17,
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 6
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Continue after the limit.",
|
||||
"turnId": "turn-length",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "first second",
|
||||
"latencyMs": 17,
|
||||
"turnId": "turn-length",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tool_activity_then_complete_answer",
|
||||
"chat_id": "fixture-activity",
|
||||
"initial_messages": [
|
||||
{
|
||||
"id": "fixture-user-activity",
|
||||
"role": "user",
|
||||
"content": "Inspect the projection code.",
|
||||
"turnId": "turn-activity",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1,
|
||||
"createdAt": 1700000002000
|
||||
}
|
||||
],
|
||||
"live_events": [
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-activity",
|
||||
"kind": "tool_hint",
|
||||
"text": "search projection helpers",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Review results.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "reasoning_end",
|
||||
"chat_id": "fixture-activity",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Projection matches.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-activity",
|
||||
"latency_ms": 8,
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 7
|
||||
}
|
||||
],
|
||||
"transcript": [
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Inspect the projection code.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
"created_at_ms": 1700000002000
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-activity",
|
||||
"kind": "tool_hint",
|
||||
"text": "search projection helpers",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Review results.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "reasoning_end",
|
||||
"chat_id": "fixture-activity",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Projection matches.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-activity",
|
||||
"latency_ms": 8,
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 7
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Inspect the projection code.",
|
||||
"turnId": "turn-activity",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "search projection helpers",
|
||||
"kind": "trace",
|
||||
"traces": [
|
||||
"search projection helpers"
|
||||
],
|
||||
"activitySegmentId": "segment-1",
|
||||
"turnId": "turn-activity",
|
||||
"turnPhase": "activity",
|
||||
"turnSeq": 2
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Projection matches.",
|
||||
"reasoning": "Review results.",
|
||||
"activitySegmentId": "segment-1",
|
||||
"latencyMs": 8,
|
||||
"turnId": "turn-activity",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "file_edit_lifecycle_merges_by_call_and_path",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"initial_messages": [
|
||||
{
|
||||
"id": "fixture-user-file-edit",
|
||||
"role": "user",
|
||||
"content": "Update app.py.",
|
||||
"turnId": "turn-file-edit",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1,
|
||||
"createdAt": 1700000003000
|
||||
}
|
||||
],
|
||||
"live_events": [
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "start",
|
||||
"added": 0,
|
||||
"deleted": 0,
|
||||
"status": "editing"
|
||||
}
|
||||
],
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "end",
|
||||
"added": 3,
|
||||
"deleted": 1,
|
||||
"status": "done"
|
||||
}
|
||||
],
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"text": "Updated app.py.",
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"latency_ms": 9,
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 5
|
||||
}
|
||||
],
|
||||
"transcript": [
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"text": "Update app.py.",
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
"created_at_ms": 1700000003000
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "start",
|
||||
"added": 0,
|
||||
"deleted": 0,
|
||||
"status": "editing"
|
||||
}
|
||||
],
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "end",
|
||||
"added": 3,
|
||||
"deleted": 1,
|
||||
"status": "done"
|
||||
}
|
||||
],
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"text": "Updated app.py.",
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"latency_ms": 9,
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 5
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Update app.py.",
|
||||
"turnId": "turn-file-edit",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "",
|
||||
"kind": "trace",
|
||||
"traces": [],
|
||||
"fileEdits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "end",
|
||||
"added": 3,
|
||||
"deleted": 1,
|
||||
"status": "done"
|
||||
}
|
||||
],
|
||||
"activitySegmentId": "segment-1",
|
||||
"turnId": "turn-file-edit",
|
||||
"turnPhase": "activity",
|
||||
"turnSeq": 3
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Updated app.py.",
|
||||
"latencyMs": 9,
|
||||
"turnId": "turn-file-edit",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -71,6 +71,122 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("correlates successful WebUI mutation replies by request id", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
const pending = client.requestMutation<{ saved: boolean }>(
|
||||
"settings.provider.update",
|
||||
{ provider: "openrouter", apiKey: "secret" },
|
||||
);
|
||||
const frame = JSON.parse(socket.sent.at(-1) as string);
|
||||
expect(frame).toMatchObject({
|
||||
type: "webui_request",
|
||||
action: "settings.provider.update",
|
||||
payload: { provider: "openrouter", apiKey: "secret" },
|
||||
});
|
||||
expect(frame.request_id).toEqual(expect.any(String));
|
||||
|
||||
socket.fakeMessage({
|
||||
event: "webui_response",
|
||||
request_id: frame.request_id,
|
||||
ok: true,
|
||||
result: { saved: true },
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toEqual({ saved: true });
|
||||
});
|
||||
|
||||
it("surfaces correlated WebUI mutation errors with status", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
const pending = client.requestMutation("settings.channel.configure", {});
|
||||
const requestId = JSON.parse(socket.sent.at(-1) as string).request_id;
|
||||
socket.fakeMessage({
|
||||
event: "webui_response",
|
||||
request_id: requestId,
|
||||
ok: false,
|
||||
error: { status: 400, message: "missing channel name" },
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: "missing channel name",
|
||||
});
|
||||
});
|
||||
|
||||
it("times out WebUI mutations without replaying them", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
const pending = expect(
|
||||
client.requestMutation("skill.install", { skill: "docs" }, 25),
|
||||
).rejects.toMatchObject({
|
||||
status: 504,
|
||||
message: "WebUI request timed out after 25ms",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await pending;
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects in-flight WebUI mutations when the socket closes", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
const pending = client.requestMutation("session.delete", {
|
||||
key: "websocket:chat-1",
|
||||
});
|
||||
socket.fakeCloseWithCode(1006);
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
status: 503,
|
||||
message: "Socket closed before WebUI response",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue WebUI mutations before the authenticated socket opens", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
|
||||
await expect(client.requestMutation("settings.agent.update", {})).rejects.toMatchObject({
|
||||
status: 503,
|
||||
message: "WebUI connection is not open",
|
||||
});
|
||||
expect(lastSocket().sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps temporary chats out of attachment and reconnect state", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
@@ -1071,7 +1187,7 @@ describe("NanobotClient", () => {
|
||||
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
|
||||
});
|
||||
|
||||
it("sends large sidebar ordering state outside the HTTP request line", () => {
|
||||
it("sends large sidebar ordering state as a correlated WebUI request", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
@@ -1102,11 +1218,29 @@ describe("NanobotClient", () => {
|
||||
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.setSidebarState(state);
|
||||
const pending = client.setSidebarState(state);
|
||||
|
||||
const [serialized] = lastSocket().sent;
|
||||
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
|
||||
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
|
||||
const request = JSON.parse(serialized) as {
|
||||
type: string;
|
||||
request_id: string;
|
||||
action: string;
|
||||
payload: { state: SidebarStatePayload };
|
||||
};
|
||||
expect(request).toEqual({
|
||||
type: "webui_request",
|
||||
request_id: expect.any(String),
|
||||
action: "sidebar.update",
|
||||
payload: { state },
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "webui_response",
|
||||
request_id: request.request_id,
|
||||
ok: true,
|
||||
result: state,
|
||||
});
|
||||
await expect(pending).resolves.toEqual(state);
|
||||
});
|
||||
|
||||
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
||||
import {
|
||||
EMPTY_WORKBENCH_STATE,
|
||||
addWorkbenchPane,
|
||||
focusWorkbenchPane,
|
||||
setWorkbenchLayout,
|
||||
workbenchTab,
|
||||
} from "@/components/workbench/workbench-model";
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function WorkbenchHarness() {
|
||||
const [state, setState] = useState(() => (
|
||||
addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta")
|
||||
));
|
||||
const tab = workbenchTab(state, "alpha");
|
||||
const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" };
|
||||
|
||||
return (
|
||||
<PaneWorkbench
|
||||
panes={tab.paneKeys.map((key) => ({ key, title: titles[key] }))}
|
||||
activePaneKey={tab.activePaneKey}
|
||||
layout={tab.layout}
|
||||
onActivatePane={(key) => setState((current) => (
|
||||
focusWorkbenchPane(current, "alpha", key)
|
||||
))}
|
||||
onAddPane={vi.fn()}
|
||||
onLayoutChange={(layout) => setState((current) => (
|
||||
setWorkbenchLayout(current, "alpha", layout)
|
||||
))}
|
||||
renderPane={(pane, context) => (
|
||||
<>
|
||||
<button type="button">Focus {pane.title}</button>
|
||||
{context.headerPortalTarget && context.active ? createPortal(
|
||||
context.headerActions,
|
||||
context.headerPortalTarget,
|
||||
) : null}
|
||||
{context.composerPortalTarget ? createPortal(
|
||||
<div hidden={!context.active}>
|
||||
<textarea aria-label={`Composer ${pane.title}`} />
|
||||
</div>,
|
||||
context.composerPortalTarget,
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PaneWorkbench", () => {
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const originalAnimate = HTMLElement.prototype.animate;
|
||||
const animate = vi.fn(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}) as unknown as Animation);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
HTMLElement.prototype.animate = animate;
|
||||
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
if (!this.classList.contains("workbench-pane")) {
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
}
|
||||
const layout = this.parentElement?.dataset.layout;
|
||||
const index = Array.from(this.parentElement?.children ?? []).indexOf(this);
|
||||
return layout === "rows"
|
||||
? rect(0, index * 500, 1000, 500)
|
||||
: rect(index * 500, 0, 500, 1000);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.animate = originalAnimate;
|
||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("focuses without reordering and keeps only the focused composer visible", () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(screen.getByLabelText("Composer Beta")).toBeVisible();
|
||||
expect(screen.getByLabelText("Composer Alpha")).not.toBeVisible();
|
||||
|
||||
fireEvent.pointerDown(
|
||||
within(screen.getByRole("region", { name: "Alpha" }))
|
||||
.getByRole("button", { name: "Focus Alpha" }),
|
||||
);
|
||||
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(screen.getByLabelText("Composer Alpha")).toBeVisible();
|
||||
expect(screen.getByLabelText("Composer Beta")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps one shared layout control and animates geometry changes", async () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const header = screen.getByTestId("workbench-header-host");
|
||||
expect(within(header).getAllByRole("button", { name: "Pane layout" })).toHaveLength(1);
|
||||
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
||||
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
|
||||
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -350,6 +350,30 @@ function longPress(badge: HTMLElement, pointerId = 7) {
|
||||
}
|
||||
|
||||
describe("ThreadComposer", () => {
|
||||
it("locks an async send and keeps the draft when it is rejected", async () => {
|
||||
let resolveSend!: (accepted: boolean) => void;
|
||||
const onSend = vi.fn(() => new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}));
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "keep this pending draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
|
||||
await act(async () => resolveSend(false));
|
||||
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
expect(input).toHaveValue("keep this pending draft");
|
||||
});
|
||||
|
||||
it("dismisses the touch keyboard after a successful send", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: query === "(hover: none) and (pointer: coarse)",
|
||||
@@ -1113,6 +1137,36 @@ describe("ThreadComposer", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Windows", "D:\\Users\\test\\.nanobot\\workspace", "D:\\path\\to\\project"],
|
||||
["macOS", "/Users/test/.nanobot/workspace", "/Users/name/project"],
|
||||
["Linux", "/home/test/.nanobot/workspace", "/home/name/project"],
|
||||
])("uses a %s path example for the project picker", async (_, projectPath, placeholder) => {
|
||||
const user = userEvent.setup();
|
||||
const defaultScope = {
|
||||
project_path: projectPath,
|
||||
project_name: "workspace",
|
||||
access_mode: "restricted" as const,
|
||||
restrict_to_workspace: true,
|
||||
};
|
||||
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Ask anything..."
|
||||
variant="hero"
|
||||
workspaceScope={defaultScope}
|
||||
workspaceDefaultScope={defaultScope}
|
||||
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
||||
onWorkspaceScopeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
expect(await screen.findByLabelText("Paste path")).toHaveAttribute("placeholder", placeholder);
|
||||
});
|
||||
|
||||
it("slides project controls closed without offering a compact replacement", () => {
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
|
||||
@@ -4,10 +4,67 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import type { ConnectionStatus, InboundEvent, GoalStateWsPayload } from "@/lib/types";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
GoalStateWsPayload,
|
||||
InboundEvent,
|
||||
UIMessage,
|
||||
} from "@/lib/types";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import projectionFixture from "./fixtures/live-replay-event-projection.json";
|
||||
|
||||
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
|
||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
||||
|
||||
interface ProjectionFixtureCase {
|
||||
name: string;
|
||||
chat_id: string;
|
||||
initial_messages: UIMessage[];
|
||||
live_events: InboundEvent[];
|
||||
expected: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const PROJECTION_FIXTURE_CASES = (
|
||||
projectionFixture as unknown as { cases: ProjectionFixtureCase[] }
|
||||
).cases;
|
||||
const SEMANTIC_MESSAGE_FIELDS = [
|
||||
"role",
|
||||
"content",
|
||||
"kind",
|
||||
"traces",
|
||||
"toolEvents",
|
||||
"fileEdits",
|
||||
"images",
|
||||
"media",
|
||||
"cliApps",
|
||||
"mcpPresets",
|
||||
"sessionMentions",
|
||||
"reasoning",
|
||||
"latencyMs",
|
||||
"source",
|
||||
"turnId",
|
||||
"turnPhase",
|
||||
"turnSeq",
|
||||
] as const satisfies ReadonlyArray<keyof UIMessage>;
|
||||
|
||||
function normalizeProjection(messages: UIMessage[]): Array<Record<string, unknown>> {
|
||||
const segmentAliases = new Map<string, string>();
|
||||
return messages.map((message) => {
|
||||
const row: Record<string, unknown> = {};
|
||||
for (const field of SEMANTIC_MESSAGE_FIELDS) {
|
||||
const value = message[field];
|
||||
if (value !== undefined && value !== null) row[field] = value;
|
||||
}
|
||||
if (message.activitySegmentId) {
|
||||
let alias = segmentAliases.get(message.activitySegmentId);
|
||||
if (!alias) {
|
||||
alias = `segment-${segmentAliases.size + 1}`;
|
||||
segmentAliases.set(message.activitySegmentId, alias);
|
||||
}
|
||||
row.activitySegmentId = alias;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
|
||||
@@ -2841,3 +2898,21 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("live/replay projection before canonical-event revision migration", () => {
|
||||
it.each(PROJECTION_FIXTURE_CASES)("matches the shared $name fixture", (fixtureCase) => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream(fixtureCase.chat_id, fixtureCase.initial_messages),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
for (const event of fixtureCase.live_events) {
|
||||
act(() => {
|
||||
fake.emit(fixtureCase.chat_id, event);
|
||||
});
|
||||
}
|
||||
|
||||
expect(normalizeProjection(result.current.messages)).toEqual(fixtureCase.expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,8 +109,9 @@ describe("useSessions", () => {
|
||||
]);
|
||||
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
|
||||
|
||||
const client = fakeClient();
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
wrapper: wrap(client),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
||||
@@ -119,7 +120,7 @@ describe("useSessions", () => {
|
||||
await result.current.deleteChat("websocket:chat-a");
|
||||
});
|
||||
|
||||
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
|
||||
expect(api.deleteSession).toHaveBeenCalledWith(client, "websocket:chat-a", undefined);
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
EMPTY_WORKBENCH_STATE,
|
||||
MAX_WORKBENCH_PANES,
|
||||
addWorkbenchPane,
|
||||
attachWorkbenchPane,
|
||||
detachWorkbenchPane,
|
||||
ensureWorkbenchTab,
|
||||
focusWorkbenchPane,
|
||||
parseWorkbenchState,
|
||||
promoteWorkbenchPane,
|
||||
reconcileWorkbench,
|
||||
setWorkbenchLayout,
|
||||
workbenchChildPaneKeys,
|
||||
workbenchTab,
|
||||
} from "@/components/workbench/workbench-model";
|
||||
|
||||
describe("workbench model", () => {
|
||||
it("gives every topic its own one-pane tab by default", () => {
|
||||
const state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a");
|
||||
|
||||
expect(workbenchTab(state, "topic-a")).toEqual({
|
||||
paneKeys: ["topic-a"],
|
||||
activePaneKey: "topic-a",
|
||||
layout: "columns",
|
||||
});
|
||||
expect(state.tabs["topic-b"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps pane membership, focus, and layout scoped to a tab", () => {
|
||||
let state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a");
|
||||
state = ensureWorkbenchTab(state, "topic-b");
|
||||
state = addWorkbenchPane(state, "topic-a", "topic-c");
|
||||
state = setWorkbenchLayout(state, "topic-a", "main-stack");
|
||||
|
||||
expect(workbenchTab(state, "topic-a")).toEqual({
|
||||
paneKeys: ["topic-a", "topic-c"],
|
||||
activePaneKey: "topic-c",
|
||||
layout: "main-stack",
|
||||
});
|
||||
expect(workbenchTab(state, "topic-b")).toEqual({
|
||||
paneKeys: ["topic-b"],
|
||||
activePaneKey: "topic-b",
|
||||
layout: "columns",
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses without reordering and promotes only when asked", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b");
|
||||
state = addWorkbenchPane(state, "topic-a", "topic-c");
|
||||
state = focusWorkbenchPane(state, "topic-a", "topic-b");
|
||||
|
||||
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
|
||||
"topic-a",
|
||||
"topic-b",
|
||||
"topic-c",
|
||||
]);
|
||||
|
||||
state = promoteWorkbenchPane(state, "topic-a", "topic-b");
|
||||
expect(workbenchTab(state, "topic-a")).toMatchObject({
|
||||
paneKeys: ["topic-b", "topic-a", "topic-c"],
|
||||
activePaneKey: "topic-b",
|
||||
});
|
||||
});
|
||||
|
||||
it("detaches child panes, keeps the root, and chooses the adjacent focus", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b");
|
||||
state = addWorkbenchPane(state, "topic-a", "topic-c");
|
||||
state = focusWorkbenchPane(state, "topic-a", "topic-b");
|
||||
state = detachWorkbenchPane(state, "topic-a", "topic-b");
|
||||
|
||||
expect(workbenchTab(state, "topic-a")).toMatchObject({
|
||||
paneKeys: ["topic-a", "topic-c"],
|
||||
activePaneKey: "topic-c",
|
||||
});
|
||||
|
||||
state = detachWorkbenchPane(state, "topic-a", "topic-c");
|
||||
state = detachWorkbenchPane(state, "topic-a", "topic-a");
|
||||
expect(workbenchTab(state, "topic-a").paneKeys).toEqual(["topic-a"]);
|
||||
});
|
||||
|
||||
it("moves a pane between tabs and can reattach a one-pane tab", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
|
||||
state = ensureWorkbenchTab(state, "topic-b");
|
||||
state = attachWorkbenchPane(state, "topic-b", "pane-a");
|
||||
|
||||
expect(workbenchTab(state, "topic-a")).toMatchObject({
|
||||
paneKeys: ["topic-a"],
|
||||
activePaneKey: "topic-a",
|
||||
});
|
||||
expect(workbenchTab(state, "topic-b")).toMatchObject({
|
||||
paneKeys: ["topic-b", "pane-a"],
|
||||
activePaneKey: "pane-a",
|
||||
});
|
||||
|
||||
state = ensureWorkbenchTab(state, "topic-c");
|
||||
state = attachWorkbenchPane(state, "topic-b", "topic-c");
|
||||
expect(state.tabs["topic-c"]).toBeUndefined();
|
||||
expect(workbenchTab(state, "topic-b").paneKeys).toEqual([
|
||||
"topic-b",
|
||||
"pane-a",
|
||||
"topic-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not collapse a multi-pane tab into another tab", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
|
||||
state = ensureWorkbenchTab(state, "topic-b");
|
||||
|
||||
expect(attachWorkbenchPane(state, "topic-b", "topic-a")).toBe(state);
|
||||
});
|
||||
|
||||
it("caps every tab at four panes", () => {
|
||||
let state = EMPTY_WORKBENCH_STATE;
|
||||
for (let index = 1; index <= MAX_WORKBENCH_PANES; index += 1) {
|
||||
state = addWorkbenchPane(state, "topic-a", `pane-${index}`);
|
||||
}
|
||||
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
|
||||
"topic-a",
|
||||
"pane-1",
|
||||
"pane-2",
|
||||
"pane-3",
|
||||
]);
|
||||
|
||||
const beforeAttach = state;
|
||||
state = attachWorkbenchPane(state, "topic-a", "standalone");
|
||||
expect(state).toBe(beforeAttach);
|
||||
});
|
||||
|
||||
it("identifies only sessions attached beneath another topic", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
|
||||
state = addWorkbenchPane(state, "topic-b", "pane-b");
|
||||
|
||||
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-a", "pane-b"]));
|
||||
|
||||
state = detachWorkbenchPane(state, "topic-a", "pane-a");
|
||||
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-b"]));
|
||||
});
|
||||
|
||||
it("repairs persisted state and removes deleted sessions", () => {
|
||||
const parsed = parseWorkbenchState(JSON.stringify({
|
||||
version: 2,
|
||||
tabs: {
|
||||
"topic-a": {
|
||||
paneKeys: ["topic-a", "topic-b", "topic-b", 9],
|
||||
activePaneKey: "missing",
|
||||
layout: "unknown",
|
||||
},
|
||||
deleted: {
|
||||
paneKeys: ["deleted"],
|
||||
activePaneKey: "deleted",
|
||||
layout: "grid",
|
||||
},
|
||||
},
|
||||
}));
|
||||
const reconciled = reconcileWorkbench(parsed, new Set(["topic-a"]));
|
||||
|
||||
expect(reconciled).toEqual({
|
||||
version: 2,
|
||||
tabs: {
|
||||
"topic-a": {
|
||||
paneKeys: ["topic-a"],
|
||||
activePaneKey: "topic-a",
|
||||
layout: "columns",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parseWorkbenchState(JSON.stringify({ version: 1, tabs: {} })))
|
||||
.toEqual(EMPTY_WORKBENCH_STATE);
|
||||
expect(parseWorkbenchState("not-json")).toEqual(EMPTY_WORKBENCH_STATE);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user