feat(webui): add project workspaces and access controls (#4007)

* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
This commit is contained in:
Xubin Ren 2026-05-29 03:42:53 +08:00 committed by GitHub
parent 84428136e6
commit 3a420136bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
111 changed files with 9972 additions and 1822 deletions

2
.gitignore vendored
View File

@ -6,6 +6,8 @@
.env
.web
.orion
nanobot-desktop/
desktop/
# Claude / AI assistant artifacts
docs/superpowers/

View File

@ -68,11 +68,13 @@ class ContextBuilder:
skill_names: list[str] | None = None,
channel: str | None = None,
session_summary: str | None = None,
workspace: Path | None = None,
) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
parts = [self._get_identity(channel=channel)]
root = workspace or self.workspace
parts = [self._get_identity(channel=channel, workspace=root)]
bootstrap = self._load_bootstrap_files()
bootstrap = self._load_bootstrap_files(root)
if bootstrap:
parts.append(bootstrap)
@ -106,9 +108,10 @@ class ContextBuilder:
return "\n\n---\n\n".join(parts)
def _get_identity(self, channel: str | None = None) -> str:
def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str:
"""Get the core identity section."""
workspace_path = str(self.workspace.expanduser().resolve())
root = workspace or self.workspace
workspace_path = str(root.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
@ -152,12 +155,13 @@ class ContextBuilder:
return _to_blocks(left) + _to_blocks(right)
def _load_bootstrap_files(self) -> str:
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
"""Load all bootstrap files from workspace."""
parts = []
root = workspace or self.workspace
for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename
file_path = root / filename
if file_path.exists():
content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}")
@ -185,11 +189,18 @@ class ContextBuilder:
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
current_runtime_lines: Sequence[str] | None = None,
workspace: Path | None = None,
runtime_state: Any | None = None,
inbound_message: Any | None = None,
skip_runtime_lines: bool = False,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
root = workspace or self.workspace
extra = [
*goal_state_runtime_lines(session_metadata),
]
if runtime_state is not None and inbound_message is not None:
extra.extend(runtime_lines(runtime_state, inbound_message, root, skip=skip_runtime_lines))
if current_runtime_lines:
extra.extend(line for line in current_runtime_lines if line)
runtime_ctx = self._build_runtime_context(
@ -210,7 +221,15 @@ class ContextBuilder:
else:
merged = user_content + [{"type": "text", "text": runtime_ctx}]
messages = [
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel, session_summary=session_summary)},
{
"role": "system",
"content": self.build_system_prompt(
skill_names,
channel=channel,
session_summary=session_summary,
workspace=root,
),
},
*history,
]
if messages[-1].get("role") == current_role:

View File

@ -25,8 +25,14 @@ from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRun
from nanobot.agent.subagent import SubagentManager
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.context import RequestContext, bind_request_context, reset_request_context
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.security.workspace_access import (
WorkspaceScopeResolver,
bind_workspace_scope,
reset_workspace_scope,
)
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
@ -114,7 +120,6 @@ class TurnContext:
pending_queue: asyncio.Queue | None = None
pending_summary: str | None = None
turn_wall_started_at: float = field(default_factory=time.time)
turn_latency_ms: int | None = None
@ -241,6 +246,10 @@ class AgentLoop:
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace
self.workspace_scopes = WorkspaceScopeResolver(
default_workspace=workspace,
default_restrict_to_workspace=restrict_to_workspace,
)
self._start_time = time.time()
self._last_usage: dict[str, int] = {}
self._pending_turn_latency_ms: dict[str, int] = {}
@ -470,6 +479,7 @@ class AgentLoop:
provider_snapshot_loader=self._provider_snapshot_loader,
image_generation_provider_configs=self._image_generation_provider_configs,
timezone=self.context.timezone or "UTC",
workspace_sandbox=self.workspace_scopes.sandbox_status,
)
loader = ToolLoader()
registered = loader.load(ctx, self.tools)
@ -493,7 +503,7 @@ class AgentLoop:
session_key: str | None = None,
) -> None:
"""Update context for all tools that need routing info."""
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.context import ContextAware
if session_key is not None:
effective_key = session_key
@ -575,6 +585,7 @@ class AgentLoop:
pending_summary: str | None,
) -> list[dict[str, Any]]:
"""Build the initial message list for the LLM turn."""
scope = self.workspace_scopes.for_message(msg, session.metadata)
return self.context.build_messages(
history=history,
current_message=image_generation_prompt(msg.content, msg.metadata),
@ -583,7 +594,10 @@ class AgentLoop:
chat_id=self._runtime_chat_id(msg),
sender_id=msg.sender_id,
session_summary=pending_summary,
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace),
session_metadata=session.metadata,
workspace=scope.project_path,
runtime_state=self,
inbound_message=msg,
)
async def _dispatch_command_inline(
@ -733,7 +747,21 @@ class AgentLoop:
return items
active_session_key = session.key if session else session_key
effective_scope = self.workspace_scopes.for_turn(
channel=channel,
message_metadata=metadata,
session_metadata=session.metadata if session is not None else None,
)
request_ctx = RequestContext(
channel=channel,
chat_id=chat_id,
message_id=message_id,
session_key=active_session_key,
metadata=dict(metadata or {}),
)
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
request_token = bind_request_context(request_ctx)
workspace_token = bind_workspace_scope(effective_scope)
# Build continuation message that embeds the active goal objective so
# the LLM can see it even if earlier Runtime Context was truncated.
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
@ -753,7 +781,7 @@ class AgentLoop:
hook=hook,
error_message="Sorry, I encountered an error calling the AI model.",
concurrent_tools=True,
workspace=self.workspace,
workspace=effective_scope.project_path,
session_key=session.key if session else None,
context_window_tokens=self.context_window_tokens,
context_block_limit=self.context_block_limit,
@ -774,6 +802,8 @@ class AgentLoop:
goal_continue_message=_goal_continue,
))
finally:
reset_workspace_scope(workspace_token)
reset_request_context(request_token)
reset_file_states(file_state_token)
self._last_usage = result.usage
if result.stop_reason == "max_iterations":
@ -1063,6 +1093,7 @@ class AgentLoop:
}
history = session.get_history(**_hist_kwargs)
current_role = "assistant" if is_subagent else "user"
workspace_scope = self.workspace_scopes.for_message(msg, session.metadata)
messages = self.context.build_messages(
history=history,
@ -1072,7 +1103,11 @@ class AgentLoop:
current_role=current_role,
sender_id=msg.sender_id,
session_summary=pending,
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace, skip=is_subagent),
session_metadata=session.metadata,
workspace=workspace_scope.project_path,
runtime_state=self,
inbound_message=msg,
skip_runtime_lines=is_subagent,
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
@ -1248,6 +1283,7 @@ class AgentLoop:
if ctx.session is None:
ctx.session = self.sessions.get_or_create(ctx.session_key)
mark_webui_session(ctx.session, msg.metadata)
self.workspace_scopes.persist_message_scope(ctx.session, msg)
if self._restore_runtime_checkpoint(ctx.session):
self.sessions.save(ctx.session)
@ -1315,7 +1351,10 @@ class AgentLoop:
)
ctx.initial_messages = self._build_initial_messages(
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
ctx.msg,
ctx.session,
ctx.history,
ctx.pending_summary,
)
ctx.user_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session
@ -1618,10 +1657,16 @@ class AgentLoop:
channel=channel, sender_id="user", chat_id=chat_id,
content=content, media=media or [],
)
return await self._process_message(
msg,
session_key=session_key,
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
)
try:
return await self._process_message(
msg,
session_key=session_key,
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
)
finally:
if channel == "websocket":
await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
self._webui_turns.discard(session_key)

View File

@ -16,6 +16,12 @@ from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.file_state import FileStates
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.security.workspace_access import (
WorkspaceScope,
bind_workspace_scope,
reset_workspace_scope,
workspace_sandbox_status,
)
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults, ToolsConfig
@ -128,6 +134,10 @@ class SubagentManager:
config=cfg,
workspace=str(root.resolve()),
file_state_store=FileStates(),
workspace_sandbox=workspace_sandbox_status(
restrict_to_workspace=cfg.restrict_to_workspace,
workspace=root,
),
)
ToolLoader().load(ctx, registry, scope="subagent")
return registry
@ -146,6 +156,7 @@ class SubagentManager:
session_key: str | None = None,
origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> str:
"""Spawn a subagent to execute a task in the background."""
task_id = str(uuid.uuid4())[:8]
@ -162,7 +173,14 @@ class SubagentManager:
bg_task = asyncio.create_task(
self._run_subagent(
task_id, task, display_label, origin, status, origin_message_id, temperature
task_id,
task,
display_label,
origin,
status,
origin_message_id,
temperature,
workspace_scope,
)
)
self._running_tasks[task_id] = bg_task
@ -191,6 +209,7 @@ class SubagentManager:
status: SubagentStatus,
origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> None:
"""Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label)
@ -200,8 +219,13 @@ class SubagentManager:
status.iteration = payload.get("iteration", status.iteration)
try:
tools = self._build_tools()
system_prompt = self._build_subagent_prompt()
root = workspace_scope.project_path if workspace_scope is not None else self.workspace
cfg = None
if workspace_scope is not None:
cfg = self._subagent_tools_config()
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
tools = self._build_tools(workspace=root, tools_config=cfg)
system_prompt = self._build_subagent_prompt(workspace=root)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": task},
@ -213,21 +237,27 @@ class SubagentManager:
if self._llm_wall_timeout_for_session
else None
)
result = await self.runner.run(AgentRunSpec(
initial_messages=messages,
tools=tools,
model=self.model,
temperature=temperature,
max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars,
hook=_SubagentHook(task_id, status),
max_iterations_message="Task completed but no final response was generated.",
error_message=None,
fail_on_tool_error=True,
checkpoint_callback=_on_checkpoint,
session_key=sess_key,
llm_timeout_s=llm_timeout,
))
token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
try:
result = await self.runner.run(AgentRunSpec(
initial_messages=messages,
tools=tools,
model=self.model,
temperature=temperature,
max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars,
hook=_SubagentHook(task_id, status),
max_iterations_message="Task completed but no final response was generated.",
error_message=None,
fail_on_tool_error=True,
checkpoint_callback=_on_checkpoint,
session_key=sess_key,
workspace=root,
llm_timeout_s=llm_timeout,
))
finally:
if token is not None:
reset_workspace_scope(token)
status.phase = "done"
status.stop_reason = result.stop_reason
@ -321,20 +351,21 @@ class SubagentManager:
lines.append(f"- {result.error}")
return "\n".join(lines) or (result.error or "Error: subagent execution failed.")
def _build_subagent_prompt(self) -> str:
def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
"""Build a focused system prompt for the subagent."""
from nanobot.agent.context import ContextBuilder
from nanobot.agent.skills import SkillsLoader
time_ctx = ContextBuilder._build_runtime_context(None, None)
root = workspace or self.workspace
skills_summary = SkillsLoader(
self.workspace,
root,
disabled_skills=self.disabled_skills,
).build_skills_summary()
return render_template(
"agent/subagent_system.md",
time_ctx=time_ctx,
workspace=str(self.workspace),
workspace=str(root),
skills_summary=skills_summary or "",
)

View File

@ -88,11 +88,11 @@ def _format_summary(summary: _PatchSummary) -> str:
items=ObjectSchema(
path=StringSchema("Relative path to the file to edit."),
action=StringSchema(
"Operation type: replace (find and replace text), add (append new content or create file), delete (remove text).",
enum=["replace", "add", "delete"],
"Operation type: replace or add.",
enum=["replace", "add"],
),
old_text=StringSchema(
"Exact text to search for in the file. Required for replace and delete.",
"Exact text to search for in the file. Required for replace.",
nullable=True,
),
new_text=StringSchema(
@ -124,7 +124,8 @@ class ApplyPatchTool(_FsTool):
def description(self) -> str:
return (
"Default tool for code edits. Supports multi-file changes in a single call. "
"Provide a list of structured edits, each specifying a file path, action (replace/add/delete), and the text to change. "
"Provide a list of structured edits, each specifying a file path, action "
"(replace/add), and the exact text to change. "
"Paths must be relative. Set dry_run=true to validate and preview without writing files. "
"Use edit_file only for small exact replacements on a single file."
)
@ -140,7 +141,6 @@ class ApplyPatchTool(_FsTool):
raise _PatchError("must provide edits")
writes: dict[Path, str] = {}
deletes: set[Path] = set()
summaries: list[_PatchSummary] = []
for edit in edits:
@ -183,7 +183,6 @@ class ApplyPatchTool(_FsTool):
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
action_name = "update"
else:
@ -191,7 +190,6 @@ class ApplyPatchTool(_FsTool):
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
writes[source] = new_norm
deletes.discard(source)
added = _text_line_count(new_norm)
deleted = 0
action_name = "add"
@ -246,7 +244,6 @@ class ApplyPatchTool(_FsTool):
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
@ -254,62 +251,6 @@ class ApplyPatchTool(_FsTool):
)
)
elif action == "delete":
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for delete: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
else:
raise _PatchError(f"file to update does not exist: {path}")
if pending is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {path}")
uses_crlf = "\r\n" in content
norm_content = content.replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n")
pos = norm_content.find(norm_old)
if pos < 0:
raise _PatchError(f"old_text not found in {path}")
if norm_content.find(norm_old, pos + 1) >= 0:
raise _PatchError(f"old_text appears multiple times in {path}")
if norm_old == norm_content:
deletes.add(source)
writes.pop(source, None)
added, deleted = 0, _text_line_count(content)
summaries.append(
_PatchSummary(
action="delete", path=path, added=added, deleted=deleted
)
)
else:
new_norm = (
norm_content[:pos] + norm_content[pos + len(norm_old) :]
)
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
action="update", path=path, added=added, deleted=deleted
)
)
else:
raise _PatchError(f"unknown action: {action}")
@ -319,13 +260,10 @@ class ApplyPatchTool(_FsTool):
)
backups: dict[Path, bytes | None] = {}
for path in set(writes) | deletes:
for path in writes:
backups[path] = path.read_bytes() if path.exists() else None
try:
for path in deletes:
if path.exists():
path.unlink()
for path, content in writes.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="")
@ -339,7 +277,7 @@ class ApplyPatchTool(_FsTool):
path.write_bytes(data)
raise
for path in set(writes) | deletes:
for path in writes:
self._file_states.record_write(path)
return "Patch applied:\n" + "\n".join(
_format_summary(summary) for summary in summaries

View File

@ -9,6 +9,7 @@ from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import Base
@ -113,7 +114,12 @@ class CliAppsTool(Tool):
working_dir: str | None = None,
timeout: int | None = None,
) -> str:
manager = CliAppManager(workspace=self.workspace, runtime=self.runtime)
access = current_tool_workspace(
self.workspace,
restrict_to_workspace=self.restrict_to_workspace,
)
workspace = access.project_path or self.workspace
manager = CliAppManager(workspace=workspace, runtime=self.runtime)
try:
return manager.run(
name,
@ -121,7 +127,7 @@ class CliAppsTool(Tool):
json_output=bool(json),
working_dir=working_dir,
timeout=timeout,
restrict_to_workspace=self.restrict_to_workspace,
restrict_to_workspace=access.restrict_to_workspace,
)
except CliAppError as exc:
return f"Error: {exc.message}"

View File

@ -1,9 +1,15 @@
"""Runtime context for tool construction."""
from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from typing import Any, Callable, Protocol, runtime_checkable
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
"nanobot_tool_request_context",
default=None,
)
@dataclass(frozen=True)
class RequestContext:
@ -21,6 +27,23 @@ class ContextAware(Protocol):
...
def bind_request_context(ctx: RequestContext) -> Token[RequestContext | None]:
return _CURRENT_REQUEST_CONTEXT.set(ctx)
def reset_request_context(token: Token[RequestContext | None]) -> None:
_CURRENT_REQUEST_CONTEXT.reset(token)
def current_request_context() -> RequestContext | None:
return _CURRENT_REQUEST_CONTEXT.get()
def current_request_session_key() -> str | None:
ctx = current_request_context()
return ctx.session_key if ctx else None
@dataclass
class ToolContext:
config: Any
@ -33,3 +56,4 @@ class ToolContext:
provider_snapshot_loader: Callable[[], Any] | None = None
image_generation_provider_configs: dict[str, Any] | None = None
timezone: str = "UTC"
workspace_sandbox: Any | None = None

View File

@ -10,6 +10,7 @@ from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
@ -43,6 +44,7 @@ class ExecSessionInfo:
idle_s: float
remaining_s: float
returncode: int | None
owner_session_key: str | None = None
class _ExecSession:
@ -54,11 +56,13 @@ class _ExecSession:
command: str,
cwd: str,
timeout: int | None,
owner_session_key: str | None = None,
) -> None:
self.session_id = session_id
self.process = process
self.command = command
self.cwd = cwd
self.owner_session_key = owner_session_key
self.started_at = time.monotonic()
# timeout None/0 means no limit; an infinite deadline is never reached.
self.deadline = time.monotonic() + timeout if timeout else float("inf")
@ -175,6 +179,7 @@ class ExecSessionManager:
login: bool,
yield_time_ms: int,
max_output_chars: int,
owner_session_key: str | None = None,
) -> tuple[str, _SessionPoll]:
async with self._lock:
await self._cleanup_locked()
@ -188,6 +193,7 @@ class ExecSessionManager:
command=command,
cwd=cwd,
timeout=timeout,
owner_session_key=owner_session_key,
)
self._sessions[session_id] = session
@ -206,12 +212,19 @@ class ExecSessionManager:
terminate: bool,
yield_time_ms: int,
max_output_chars: int,
owner_session_key: str | None = None,
) -> _SessionPoll:
async with self._lock:
await self._cleanup_locked()
session = self._sessions.get(session_id)
if session is None:
raise KeyError(session_id)
if (
owner_session_key
and session.owner_session_key
and session.owner_session_key != owner_session_key
):
raise KeyError(session_id)
if chars:
error = await session.write(chars)
@ -236,7 +249,7 @@ class ExecSessionManager:
self._sessions.pop(session_id, None)
return poll
async def list(self) -> list[ExecSessionInfo]:
async def list(self, *, owner_session_key: str | None = None) -> list[ExecSessionInfo]:
async with self._lock:
await self._cleanup_locked()
now = time.monotonic()
@ -249,8 +262,12 @@ class ExecSessionManager:
idle_s=max(0.0, now - session.last_access),
remaining_s=max(0.0, session.deadline - now),
returncode=session.process.returncode,
owner_session_key=session.owner_session_key,
)
for session_id, session in sorted(self._sessions.items())
if not owner_session_key
or not session.owner_session_key
or session.owner_session_key == owner_session_key
]
async def _cleanup_locked(self) -> None:
@ -477,6 +494,7 @@ class WriteStdinTool(Tool):
terminate=terminate,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
max_output_chars=output_limit,
owner_session_key=current_request_session_key(),
)
return format_session_poll(session_id, poll)
except KeyError:
@ -510,6 +528,7 @@ class WriteStdinTool(Tool):
terminate=terminate if first else False,
yield_time_ms=step_ms,
max_output_chars=max_output_chars,
owner_session_key=current_request_session_key(),
)
first = False
if poll.output:
@ -573,7 +592,9 @@ class ListExecSessionsTool(Tool):
async def execute(self, **kwargs: Any) -> str:
try:
sessions = await self._manager.list()
sessions = await self._manager.list(
owner_session_key=current_request_session_key(),
)
if not sessions:
return "No active exec sessions."
lines = []

View File

@ -10,6 +10,7 @@ from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
@ -28,10 +29,18 @@ class _FsTool(Tool):
allowed_dir: Path | None = None,
extra_allowed_dirs: list[Path] | None = None,
file_states: FileStates | None = None,
restrict_to_workspace: bool | None = None,
sandbox_restricts_workspace: bool = False,
):
self._workspace = workspace
self._allowed_dir = allowed_dir
self._extra_allowed_dirs = extra_allowed_dirs
self._restrict_to_workspace = (
bool(restrict_to_workspace)
if restrict_to_workspace is not None
else allowed_dir is not None
)
self._sandbox_restricts_workspace = sandbox_restricts_workspace
# Explicit state is used by isolated runners like Dream/subagents.
# Main AgentLoop tools leave this unset and resolve state from the
# current async task, which keeps shared tool instances session-safe.
@ -46,13 +55,16 @@ class _FsTool(Tool):
ctx.config.restrict_to_workspace
or ctx.config.exec.sandbox
)
sandbox_restricts = bool(ctx.config.exec.sandbox)
allowed_dir = Path(ctx.workspace) if restrict else None
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
extra_read = [BUILTIN_SKILLS_DIR]
return cls(
workspace=Path(ctx.workspace),
allowed_dir=allowed_dir,
extra_allowed_dirs=extra_read,
file_states=ctx.file_state_store,
restrict_to_workspace=ctx.config.restrict_to_workspace,
sandbox_restricts_workspace=sandbox_restricts,
)
@property
@ -62,13 +74,21 @@ class _FsTool(Tool):
return current_file_states(self._fallback_file_states)
def _resolve(self, path: str) -> Path:
access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_workspace,
sandbox_restricts_workspace=self._sandbox_restricts_workspace,
)
return resolve_workspace_path(
path,
self._workspace,
self._allowed_dir,
access.project_path,
access.allowed_root,
self._extra_allowed_dirs,
)
def _display_workspace(self) -> Path | None:
return current_tool_workspace(self._workspace).project_path
# ---------------------------------------------------------------------------
# read_file

View File

@ -14,6 +14,7 @@ from nanobot.agent.tools.schema import (
StringSchema,
tool_parameters_schema,
)
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.providers.image_generation import (
@ -21,6 +22,7 @@ from nanobot.providers.image_generation import (
ImageGenerationProvider,
get_image_gen_provider,
)
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
from nanobot.utils.artifacts import (
ArtifactError,
generated_image_tool_result,
@ -131,18 +133,22 @@ class ImageGenerationTool(Tool):
return cls(**kwargs)
def _resolve_reference_image(self, value: str) -> str:
raw_path = Path(value).expanduser()
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path
access = current_tool_workspace(self.workspace, restrict_to_workspace=True)
workspace = access.project_path or self.workspace
try:
resolved = path.resolve(strict=True)
except OSError as exc:
raise ImageGenerationError(f"reference image not found: {value}") from exc
allowed_roots = [self.workspace.resolve(), get_media_dir().resolve()]
if not any(_is_relative_to(resolved, root) for root in allowed_roots):
resolved = resolve_allowed_path(
value,
workspace=workspace,
allowed_root=access.allowed_root,
extra_allowed_roots=[get_media_dir()] if access.allowed_root is not None else None,
strict=True,
)
except WorkspaceBoundaryError as exc:
raise ImageGenerationError(
"reference_images must be inside the workspace or nanobot media directory"
)
) from exc
except OSError as exc:
raise ImageGenerationError(f"reference image not found: {value}") from exc
if not resolved.is_file():
raise ImageGenerationError(f"reference image is not a file: {value}")
raw = resolved.read_bytes()
@ -201,11 +207,3 @@ class ImageGenerationTool(Tool):
return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc:
return f"Error: {exc}"
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True

View File

@ -8,6 +8,7 @@ from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path
@ -149,15 +150,19 @@ class MessageTool(Tool, ContextAware):
def _resolve_media(self, media: list[str]) -> list[str]:
"""Resolve local media attachments and enforce workspace restriction when enabled."""
resolved: list[str] = []
allowed_dir = self._workspace if self._restrict_to_workspace else None
access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_workspace,
)
workspace = access.project_path or self._workspace
for p in media:
if p.startswith(("http://", "https://")):
resolved.append(p)
elif not self._restrict_to_workspace:
elif not access.restrict_to_workspace:
path = Path(p).expanduser()
resolved.append(p if path.is_absolute() else str(self._workspace / path))
resolved.append(p if path.is_absolute() else str(workspace / path))
else:
resolved.append(str(resolve_workspace_path(p, self._workspace, allowed_dir)))
resolved.append(str(resolve_workspace_path(p, workspace, access.allowed_root)))
return resolved
async def execute(

View File

@ -3,21 +3,15 @@
from pathlib import Path
from nanobot.config.paths import get_media_dir
WORKSPACE_BOUNDARY_NOTE = (
" (this is a hard policy boundary, not a transient failure; "
"do not retry with shell tricks or alternative tools, and ask "
"the user how to proceed if the resource is genuinely required)"
from nanobot.security.workspace_policy import (
is_path_within,
resolve_allowed_path,
)
def is_under(path: Path, directory: Path) -> bool:
"""Return True when path resolves under directory."""
try:
path.relative_to(directory.resolve())
return True
except ValueError:
return False
return is_path_within(path, directory)
def resolve_workspace_path(
@ -27,16 +21,10 @@ def resolve_workspace_path(
extra_allowed_dirs: list[Path] | None = None,
) -> Path:
"""Resolve path against workspace and enforce allowed directory containment."""
p = Path(path).expanduser()
if not p.is_absolute() and workspace:
p = workspace / p
resolved = p.resolve()
if allowed_dir:
media_path = get_media_dir().resolve()
all_dirs = [allowed_dir, media_path, *(extra_allowed_dirs or [])]
if not any(is_under(resolved, d) for d in all_dirs):
raise PermissionError(
f"Path {path} is outside allowed directory {allowed_dir}"
+ WORKSPACE_BOUNDARY_NOTE
)
return resolved
extra_roots = [get_media_dir(), *(extra_allowed_dirs or [])] if allowed_dir else None
return resolve_allowed_path(
path,
workspace=workspace,
allowed_root=allowed_dir,
extra_allowed_roots=extra_roots,
)

View File

@ -42,6 +42,9 @@ class RuntimeState(Protocol):
@property
def exec_config(self) -> Any: ...
@property
def workspace_sandbox(self) -> Any: ...
@property
def subagents(self) -> Any: ...

View File

@ -101,9 +101,10 @@ class _SearchTool(_FsTool):
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
def _display_path(self, target: Path, root: Path) -> str:
if self._workspace:
workspace = self._display_workspace()
if workspace:
with suppress(ValueError):
return target.relative_to(self._workspace).as_posix()
return target.relative_to(workspace).as_posix()
return target.relative_to(root).as_posix()
def _iter_files(self, root: Path) -> Iterable[Path]:

View File

@ -3,16 +3,18 @@
from __future__ import annotations
import time
from typing import Any
from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.agent.subagent import SubagentStatus
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config.schema import Base
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentStatus
class MyToolConfig(Base):
"""Self-inspection tool configuration."""
@ -33,6 +35,12 @@ def _has_real_attr(obj: Any, key: str) -> bool:
return False
def _is_subagent_status(value: Any) -> bool:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
class MyTool(Tool, ContextAware):
"""Check and set the agent loop's runtime configuration."""
@ -68,6 +76,7 @@ class MyTool(Tool, ContextAware):
"_current_iteration", # updated by runner only
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
"web_config", # inspect allowed (e.g. check enable), modify blocked
"workspace_sandbox", # read-only view of workspace enforcement level
})
_DENIED_ATTRS = frozenset({
@ -214,7 +223,7 @@ class MyTool(Tool, ContextAware):
# ------------------------------------------------------------------
@staticmethod
def _format_status(st: SubagentStatus, indent: str = " ") -> str:
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:]
@ -232,14 +241,14 @@ class MyTool(Tool, ContextAware):
@staticmethod
def _format_value(val: Any, key: str = "") -> str:
if isinstance(val, SubagentStatus):
if _is_subagent_status(val):
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
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
return MyTool._format_value(val._task_statuses, key)
if isinstance(val, dict) and val and isinstance(next(iter(val.values())), SubagentStatus):
if isinstance(val, dict) and val and _is_subagent_status(next(iter(val.values()))):
prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(val)} subagent(s):"]
for tid, st in val.items():
@ -349,7 +358,7 @@ class MyTool(Tool, ContextAware):
parts.append(self._format_value(getattr(state, k, None), k))
parts.append(self._format_value(state.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", "subagents"):
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

View File

@ -25,10 +25,13 @@ from nanobot.agent.tools.exec_session import (
clamp_session_int,
format_session_poll,
)
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.sandbox import wrap_command
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.security.workspace_policy import is_path_within
_IS_WINDOWS = sys.platform == "win32"
@ -140,6 +143,7 @@ class ExecTool(Tool):
working_dir=ctx.workspace,
timeout=cfg.timeout,
restrict_to_workspace=ctx.config.restrict_to_workspace,
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
sandbox=cfg.sandbox,
path_append=cfg.path_append,
allowed_env_keys=cfg.allowed_env_keys,
@ -154,6 +158,8 @@ class ExecTool(Tool):
deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
webui_allow_local_service_access: bool = True,
allow_local_preview_access: bool | None = None,
sandbox: str = "",
path_append: str = "",
allowed_env_keys: list[str] | None = None,
@ -183,6 +189,9 @@ class ExecTool(Tool):
]
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
if allow_local_preview_access is not None:
webui_allow_local_service_access = allow_local_preview_access
self.webui_allow_local_service_access = webui_allow_local_service_access
self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@ -313,6 +322,7 @@ class ExecTool(Tool):
shell_program=prepared.shell_program,
login=prepared.login,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
owner_session_key=current_request_session_key(),
max_output_chars=clamp_session_int(
max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS,
@ -346,29 +356,39 @@ class ExecTool(Tool):
shell: str | None = None,
login: bool | None = None,
) -> _PreparedCommand | str:
cwd = working_dir or self.working_dir or os.getcwd()
access = current_tool_workspace(
self.working_dir,
restrict_to_workspace=self.restrict_to_workspace,
sandbox_restricts_workspace=bool(self.sandbox),
)
workspace_root = str(access.project_path) if access.project_path is not None else self.working_dir
cwd = working_dir or workspace_root or os.getcwd()
# Prevent an LLM-supplied working_dir from escaping the configured
# workspace when restrict_to_workspace is enabled (#2826). Without
# this, a caller can pass working_dir="/etc" and then all absolute
# paths under /etc would pass the _guard_command check that anchors
# on cwd.
if self.restrict_to_workspace and self.working_dir:
if access.restrict_to_workspace and workspace_root:
try:
requested = Path(cwd).expanduser().resolve()
workspace_root = Path(self.working_dir).expanduser().resolve()
resolved_root = Path(workspace_root).expanduser().resolve()
except Exception:
return (
"Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE
)
if requested != workspace_root and workspace_root not in requested.parents:
if not is_path_within(requested, resolved_root):
return (
"Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE
)
guard_error = self._guard_command(command, cwd)
guard_error = self._guard_command(
command,
cwd,
restrict_to_workspace=access.restrict_to_workspace,
)
if guard_error:
return guard_error
@ -379,7 +399,7 @@ class ExecTool(Tool):
self.sandbox,
)
else:
workspace = self.working_dir or cwd
workspace = workspace_root or cwd
command = wrap_command(self.sandbox, command, workspace, cwd)
cwd = str(Path(workspace).resolve())
@ -528,7 +548,13 @@ class ExecTool(Tool):
env[key] = val
return env
def _guard_command(self, command: str, cwd: str) -> str | None:
def _guard_command(
self,
command: str,
cwd: str,
*,
restrict_to_workspace: bool | None = None,
) -> str | None:
"""Best-effort safety guard for potentially destructive commands."""
cmd = command.strip()
lower = cmd.lower()
@ -548,11 +574,17 @@ class ExecTool(Tool):
return "Error: Command blocked by allowlist filter (not in allowlist)"
from nanobot.security.network import contains_internal_url
if contains_internal_url(cmd):
if contains_internal_url(
cmd,
allow_loopback=current_scope_allows_loopback(
enabled=self.webui_allow_local_service_access,
),
):
# The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)"
if self.restrict_to_workspace:
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
if should_restrict:
if "..\\" in cmd or "../" in cmd:
return (
"Error: Command blocked by safety guard (path traversal detected)"
@ -577,11 +609,9 @@ class ExecTool(Tool):
continue
media_path = get_media_dir().resolve()
if (p.is_absolute()
and cwd_path not in p.parents
and p != cwd_path
and media_path not in p.parents
and p != media_path
if p.is_absolute() and not (
is_path_within(p, cwd_path)
or is_path_within(p, media_path)
):
return (
"Error: Command blocked by safety guard (path outside working dir)"

View File

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_workspace_scope
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
@ -91,4 +92,5 @@ class SpawnTool(Tool, ContextAware):
session_key=self._session_key.get(),
origin_message_id=self._origin_message_id.get(),
temperature=temperature,
workspace_scope=current_workspace_scope(),
)

View File

@ -20,6 +20,7 @@ import httpx
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.paths import get_runtime_subdir
from nanobot.security.workspace_policy import is_path_within
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
@ -32,6 +33,7 @@ _MAX_ARTIFACT_REPORT = 12
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
_ARTIFACT_EXTENSIONS = frozenset({
".csv",
".drawio",
@ -362,6 +364,12 @@ def _truncate(text: str, limit: int = _MAX_TOOL_OUTPUT_CHARS) -> str:
return text[:limit] + f"\n\n... truncated {omitted} characters ..."
def _catalog_description(app: dict[str, Any]) -> str:
"""Return catalog copy without implying vendor endorsement."""
description = str(app.get("description") or "")
return _ENDORSEMENT_WORD_RE.sub("", description).strip()
class CliAppManager:
"""Manage CLI-Anything registry entries and local install state."""
@ -554,7 +562,7 @@ class CliAppManager:
"name": name,
"display_name": app.get("display_name") or name,
"category": app.get("category") or "uncategorized",
"description": app.get("description") or "",
"description": _catalog_description(app),
"requires": app.get("requires") or "",
"source": app.get("_source") or "harness",
"entry_point": entry_point,
@ -630,7 +638,7 @@ class CliAppManager:
app_id=name,
display_name=str(app.get("display_name") or name),
version=str(app.get("version") or ""),
description=str(app.get("description") or ""),
description=_catalog_description(app),
category=str(app.get("category") or "uncategorized"),
source=f"cli-anything:{app.get('_source') or 'harness'}",
logo_url=logo_url,
@ -802,7 +810,7 @@ class CliAppManager:
name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = str(app.get("description") or f"Use {display} from nanobot.")
description = _catalog_description(app) or f"Use {display} from nanobot."
return f"""---
name: {_safe_skill_name(name)}
description: >-
@ -1018,7 +1026,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
cwd = Path(working_dir).expanduser() if working_dir else self.workspace
cwd = cwd.resolve(strict=False)
workspace = self.workspace.resolve(strict=False)
if restrict_to_workspace and cwd != workspace and not cwd.is_relative_to(workspace):
if restrict_to_workspace and not is_path_within(cwd, workspace):
raise CliAppError("working_dir is outside the configured workspace")
return cwd

View File

@ -57,11 +57,17 @@ class ChannelManager:
*,
session_manager: "SessionManager | None" = None,
webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
):
self.config = config
self.bus = bus
self._session_manager = session_manager
self._webui_runtime_model_name = webui_runtime_model_name
self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
@ -107,12 +113,15 @@ class ChannelManager:
if cls.name == "websocket":
if self._session_manager is not None:
kwargs["session_manager"] = self._session_manager
static_path = _default_webui_dist()
static_path = _default_webui_dist() if self._webui_static_dist else None
if static_path is not None:
kwargs["static_dist_path"] = static_path
kwargs["workspace_path"] = self.config.workspace_path
kwargs["restrict_to_workspace"] = self.config.tools.restrict_to_workspace
if self._webui_runtime_model_name is not None:
kwargs["runtime_model_name"] = self._webui_runtime_model_name
kwargs["runtime_surface"] = self._webui_runtime_surface
kwargs["runtime_capabilities_overrides"] = self._webui_runtime_capabilities
channel = cls(section, self.bus, **kwargs)
channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key

View File

@ -11,6 +11,8 @@ from typing import Any, Literal, TypeAlias
from pydantic import Field
from nanobot.security.workspace_policy import is_path_within
try:
import nh3
from mistune import create_markdown
@ -344,11 +346,7 @@ class MatrixChannel(BaseChannel):
"""Check path is inside workspace (when restriction enabled)."""
if not self._restrict_to_workspace or not self._workspace:
return True
try:
path.resolve(strict=False).relative_to(self._workspace)
return True
except ValueError:
return False
return is_path_within(path, self._workspace)
def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]:
"""Deduplicate and resolve outbound attachment paths."""

View File

@ -18,19 +18,24 @@ import ssl
import time
import uuid
from collections.abc import Callable
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
from urllib.parse import parse_qs, unquote, urlparse
from loguru import logger
from pydantic import Field, field_validator, model_validator
from websockets.asyncio.server import ServerConnection, serve
from websockets.asyncio.server import ServerConnection, serve, unix_serve
from websockets.datastructures import Headers
from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScopeError,
)
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
@ -48,9 +53,15 @@ from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_c
from nanobot.webui.settings_api import (
WebUISettingsError,
create_model_configuration,
decorate_settings_payload,
login_oauth_provider,
logout_oauth_provider,
runtime_capabilities,
settings_payload,
update_agent_settings,
update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
update_web_search_settings,
)
@ -73,6 +84,9 @@ from nanobot.webui.transcript import (
build_webui_thread_response,
rewrite_local_markdown_images,
)
from nanobot.webui.workspaces import (
WebUIWorkspaceController,
)
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
@ -100,6 +114,41 @@ def _normalize_config_path(path: str) -> str:
return _strip_trailing_slash(path)
def _case_insensitive_header(headers: Any, key: str) -> str:
"""Read a header from websockets/http test stubs without assuming casing."""
try:
value = headers.get(key)
except Exception:
value = None
if value is None:
try:
value = headers.get(key.lower())
except Exception:
value = None
return str(value or "").strip()
def _safe_host_header(value: str) -> str:
"""Return a safe Host header value, or empty when it should not be echoed."""
value = value.strip()
if not value:
return ""
if re.fullmatch(r"\[[0-9A-Fa-f:.]+\](?::\d{1,5})?", value):
return value
if re.fullmatch(r"[A-Za-z0-9.-]+(?::\d{1,5})?", value):
return value
return ""
def _host_for_url(host: str, port: int) -> str:
host = host.strip()
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"{host}:{port}"
class WebSocketConfig(Base):
"""WebSocket server channel configuration.
@ -123,6 +172,7 @@ class WebSocketConfig(Base):
enabled: bool = False
host: str = "127.0.0.1"
port: int = 8765
unix_socket_path: str = ""
path: str = "/"
token: str = ""
token_issue_path: str = ""
@ -141,6 +191,19 @@ class WebSocketConfig(Base):
ssl_certfile: str = ""
ssl_keyfile: str = ""
@field_validator("unix_socket_path")
@classmethod
def unix_socket_path_format(cls, value: str) -> str:
value = value.strip()
if not value:
return ""
if "\x00" in value:
raise ValueError("unix_socket_path must not contain NUL bytes")
path = Path(value).expanduser()
if not path.is_absolute():
raise ValueError("unix_socket_path must be an absolute path")
return str(path)
@field_validator("path")
@classmethod
def path_must_start_with_slash(cls, value: str) -> str:
@ -503,7 +566,10 @@ class WebSocketChannel(BaseChannel):
session_manager: "SessionManager | None" = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
restrict_to_workspace: bool = False,
runtime_model_name: Callable[[], str | None] | None = None,
runtime_surface: str = "browser",
runtime_capabilities_overrides: dict[str, Any] | None = None,
):
if isinstance(config, dict):
config = WebSocketConfig.model_validate(config)
@ -530,7 +596,20 @@ class WebSocketChannel(BaseChannel):
if workspace_path is not None
else get_workspace_path()
).resolve(strict=False)
self._default_restrict_to_workspace = restrict_to_workspace
self._webui_workspaces = WebUIWorkspaceController(
session_manager=self._session_manager,
default_workspace=self._workspace_path,
default_restrict_to_workspace=self._default_restrict_to_workspace,
)
self._runtime_model_name = runtime_model_name
self._runtime_surface = (
"native" if runtime_surface in {"native", "desktop"} else "browser"
)
self._runtime_capabilities = runtime_capabilities(
self._runtime_surface,
runtime_capabilities_overrides,
)
self._settings_restart_sections: set[str] = set()
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
# Process-local secret used to HMAC-sign media URLs. The signed URL is
@ -695,6 +774,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/commands":
return self._handle_commands(request)
if got == "/api/workspaces":
return self._handle_workspaces(connection, request)
if got == "/api/webui/sidebar-state":
return self._handle_webui_sidebar_state(request)
@ -707,15 +789,27 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/model-configurations/create":
return self._handle_settings_model_configuration_create(request)
if got == "/api/settings/model-configurations/update":
return self._handle_settings_model_configuration_update(request)
if got == "/api/settings/provider/update":
return self._handle_settings_provider_update(request)
if got == "/api/settings/provider/oauth-login":
return await self._handle_settings_provider_oauth(request, "login")
if got == "/api/settings/provider/oauth-logout":
return await self._handle_settings_provider_oauth(request, "logout")
if got == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if got == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if got == "/api/settings/network-safety/update":
return self._handle_settings_network_safety_update(request)
if got == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request)
@ -773,6 +867,12 @@ class WebSocketChannel(BaseChannel):
return connection.respond(403, "Forbidden")
return self._authorize_websocket_handshake(connection, query)
# API clients should never receive the SPA shell for an unknown route.
# Returning HTML here makes the WebUI fail with "Unexpected token <"
# when a dev server is pointed at an older gateway.
if got.startswith("/api/"):
return _http_error(404, "API route not found")
# 5. Static SPA serving (only if a build directory was wired in).
if self._static_dist_path is not None:
response = self._serve_static(got)
@ -832,15 +932,32 @@ class WebSocketChannel(BaseChannel):
# while the REST surface keeps validating the other until TTL expiry.
self._issued_tokens[token] = expiry
self._api_tokens[token] = expiry
ws_url = self._bootstrap_ws_url(request)
return _http_json_response(
{
"token": token,
"ws_path": self._expected_path(),
"ws_url": ws_url,
"expires_in": self.config.token_ttl_s,
"model_name": _resolve_bootstrap_model_name(self._runtime_model_name),
"runtime_surface": self._runtime_surface,
"runtime_capabilities": self._runtime_capabilities,
}
)
def _bootstrap_ws_url(self, request: Any) -> str:
"""Absolute WS URL clients should prefer over a dev-server proxy."""
headers = getattr(request, "headers", {}) or {}
host = _safe_host_header(_case_insensitive_header(headers, "Host"))
if not host:
host = _host_for_url(self.config.host, self.config.port)
proto = _case_insensitive_header(headers, "X-Forwarded-Proto")
proto = proto.split(",", 1)[0].strip().lower()
secure = proto in {"https", "wss"} or bool(self.config.ssl_certfile.strip())
scheme = "wss" if secure else "ws"
return f"{scheme}://{host}{self._expected_path()}"
def _handle_sessions_list(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@ -859,13 +976,29 @@ class WebSocketChannel(BaseChannel):
started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None:
row["run_started_at"] = started_at
scope = self._webui_workspaces.scope_for_session_key(key)
row["workspace_scope"] = scope.payload()
cleaned.append(row)
return _http_json_response({"sessions": cleaned})
def _handle_workspaces(self, connection: Any, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
self._webui_workspaces.payload(controls_available=_is_localhost(connection))
)
def _handle_settings(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(self._with_settings_restart_state(settings_payload()))
return _http_json_response(
self._with_settings_restart_state(
settings_payload(
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
)
)
)
def _with_settings_restart_state(
self,
@ -876,14 +1009,16 @@ class WebSocketChannel(BaseChannel):
"""Keep restart-required state alive for this gateway process."""
if section and payload.get("requires_restart"):
self._settings_restart_sections.add(section)
if self._settings_restart_sections:
payload = dict(payload)
sections = sorted(self._settings_restart_sections)
payload = dict(payload)
if sections:
payload["requires_restart"] = True
payload["restart_required_sections"] = sorted(self._settings_restart_sections)
else:
payload = dict(payload)
payload["restart_required_sections"] = []
return payload
return decorate_settings_payload(
payload,
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
restart_required_sections=sections,
)
def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
@ -939,6 +1074,16 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_model_configuration(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@ -949,6 +1094,19 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
async def _handle_settings_provider_oauth(self, request: WsRequest, action: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
if action == "login":
payload = await asyncio.to_thread(login_oauth_provider, query)
else:
payload = await asyncio.to_thread(logout_oauth_provider, query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@ -957,7 +1115,7 @@ class WebSocketChannel(BaseChannel):
payload = update_web_search_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="web"))
return _http_json_response(self._with_settings_restart_state(payload, section="browser"))
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
@ -969,6 +1127,16 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_network_safety_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="runtime"))
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@ -1058,13 +1226,19 @@ class WebSocketChannel(BaseChannel):
return _http_error(400, "invalid session key")
if not self._is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
scope = self._webui_workspaces.scope_for_session_key(decoded_key)
data = build_webui_thread_response(
decoded_key,
augment_user_media=self._augment_transcript_user_media,
augment_assistant_text=self._rewrite_local_markdown_images,
augment_assistant_text=lambda text: rewrite_local_markdown_images(
text,
workspace_path=scope.project_path,
sign_path=self._sign_or_stage_media_path,
),
)
if data is None:
return _http_error(404, "webui thread not found")
data["workspace_scope"] = scope.payload()
return _http_json_response(data)
def _try_append_webui_transcript(self, chat_id: str, wire: dict[str, Any]) -> None:
@ -1359,34 +1533,63 @@ class WebSocketChannel(BaseChannel):
await self._connection_loop(connection)
self.logger.info(
"WebSocket server listening on {}://{}:{}{}",
scheme,
self.config.host,
self.config.port,
self.config.path,
"WebSocket server listening on {}",
(
f"unix:{self.config.unix_socket_path}{self.config.path}"
if self.config.unix_socket_path
else f"{scheme}://{self.config.host}:{self.config.port}{self.config.path}"
),
)
if self.config.token_issue_path:
self.logger.info(
"WebSocket token issue route: {}://{}:{}{}",
scheme,
self.config.host,
self.config.port,
_normalize_config_path(self.config.token_issue_path),
"WebSocket token issue route: {}",
(
f"unix:{self.config.unix_socket_path}{_normalize_config_path(self.config.token_issue_path)}"
if self.config.unix_socket_path
else (
f"{scheme}://{self.config.host}:{self.config.port}"
f"{_normalize_config_path(self.config.token_issue_path)}"
)
),
)
async def runner() -> None:
async with serve(
handler,
self.config.host,
self.config.port,
process_request=process_request,
max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s,
ssl=ssl_context,
):
socket_path = self.config.unix_socket_path
if socket_path:
path_obj = Path(socket_path)
path_obj.parent.mkdir(parents=True, exist_ok=True)
with suppress(FileNotFoundError):
path_obj.unlink()
server = await unix_serve(
handler,
socket_path,
process_request=process_request,
max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s,
)
with suppress(OSError):
path_obj.chmod(0o600)
else:
server = await serve(
handler,
self.config.host,
self.config.port,
process_request=process_request,
max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s,
ssl=ssl_context,
)
try:
assert self._stop_event is not None
await self._stop_event.wait()
finally:
server.close()
await server.wait_closed()
if socket_path:
with suppress(FileNotFoundError):
Path(socket_path).unlink()
self._server_task = asyncio.create_task(runner())
await self._server_task
@ -1530,8 +1733,25 @@ class WebSocketChannel(BaseChannel):
t = envelope.get("type")
if t == "new_chat":
new_id = str(uuid.uuid4())
scope = await self._workspace_scope_or_error(
connection,
lambda: self._webui_workspaces.scope_for_new_chat(
envelope,
controls_available=_is_localhost(connection),
),
)
if scope is None:
return
self._webui_workspaces.persist_scope(new_id, scope)
self._attach(connection, new_id)
await self._send_event(connection, "attached", chat_id=new_id)
await self._send_event(
connection,
"session_updated",
chat_id=new_id,
scope="metadata",
workspace_scope=scope.payload(),
)
await self._hydrate_after_subscribe(new_id)
return
if t == "attach":
@ -1543,6 +1763,32 @@ class WebSocketChannel(BaseChannel):
await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid)
return
if t == "set_workspace_scope":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
scope = await self._workspace_scope_or_error(
connection,
lambda: self._webui_workspaces.scope_for_set_request(
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
controls_available=_is_localhost(connection),
),
chat_id=cid,
)
if scope is None:
return
self._webui_workspaces.persist_scope(cid, scope)
await self._send_event(
connection,
"session_updated",
chat_id=cid,
scope="metadata",
workspace_scope=scope.payload(),
)
return
if t == "message":
cid = envelope.get("chat_id")
content = envelope.get("content")
@ -1574,6 +1820,18 @@ class WebSocketChannel(BaseChannel):
if not content.strip() and not media_paths:
await self._send_event(connection, "error", detail="missing content")
return
scope = await self._workspace_scope_or_error(
connection,
lambda: self._webui_workspaces.scope_for_message(
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
controls_available=_is_localhost(connection),
),
chat_id=cid,
)
if scope is None:
return
# Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid)
@ -1587,6 +1845,8 @@ class WebSocketChannel(BaseChannel):
mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets"))
if mcp_presets:
metadata["mcp_presets"] = mcp_presets
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
self._webui_workspaces.persist_scope(cid, scope)
image_generation = envelope.get("image_generation")
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
aspect_ratio = image_generation.get("aspect_ratio")
@ -1605,6 +1865,25 @@ class WebSocketChannel(BaseChannel):
return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
async def _workspace_scope_or_error(
self,
connection: Any,
resolver: Callable[[], Any],
*,
chat_id: str | None = None,
) -> Any | None:
try:
return resolver()
except WorkspaceScopeError as exc:
await self._send_event(
connection,
"error",
detail="workspace_scope_rejected",
reason=exc.message,
**({"chat_id": chat_id} if chat_id else {}),
)
return None
async def stop(self) -> None:
if not self._running:
return

View File

@ -720,11 +720,144 @@ def gateway(
_run_gateway(cfg, port=port)
def _load_or_create_desktop_config(config: str | None, workspace: str | None) -> Config:
"""Load the desktop-owned config, creating it on first launch."""
from nanobot.config.loader import (
get_config_path,
load_config,
resolve_config_env_vars,
save_config,
set_config_path,
)
from nanobot.config.schema import Config as NanobotConfig
config_path = Path(config).expanduser().resolve() if config else get_config_path()
set_config_path(config_path)
created = False
if config_path.exists():
try:
loaded = resolve_config_env_vars(load_config(config_path))
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
else:
loaded = NanobotConfig()
created = True
if workspace:
workspace_path = Path(workspace).expanduser()
loaded.agents.defaults.workspace = str(workspace_path)
created = True
if created:
save_config(loaded, config_path)
return loaded
def _configure_desktop_gateway(
config: Config,
*,
webui_port: int,
webui_socket: str | None,
token_issue_secret: str,
) -> None:
"""Force a local WebSocket-only gateway for the desktop app process."""
config.gateway.host = "127.0.0.1"
config.gateway.port = webui_port
config.gateway.heartbeat.enabled = False
extras = dict(getattr(config.channels, "__pydantic_extra__", None) or {})
for name, section in list(extras.items()):
if name == "websocket":
continue
if isinstance(section, dict):
extras[name] = {**section, "enabled": False}
else:
with suppress(Exception):
setattr(section, "enabled", False)
extras[name] = section
websocket_cfg = extras.get("websocket")
if not isinstance(websocket_cfg, dict):
websocket_cfg = {}
websocket_cfg.update(
{
"enabled": True,
"host": "127.0.0.1",
"port": webui_port,
"unix_socket_path": webui_socket or "",
"path": "/",
"token_issue_secret": token_issue_secret,
"websocket_requires_token": True,
"allow_from": ["*"],
"streaming": True,
}
)
extras["websocket"] = websocket_cfg
config.channels.__pydantic_extra__ = extras
@app.command("desktop-gateway", hidden=True)
def desktop_gateway(
webui_port: int = typer.Option(0, "--webui-port", min=0, max=65535),
webui_socket: str | None = typer.Option(None, "--webui-socket", help="Unix socket path for desktop IPC"),
token_issue_secret: str = typer.Option(..., "--token-issue-secret"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Desktop workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Desktop config file"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Start the private local gateway used by nanobot Desktop."""
if not token_issue_secret.strip():
console.print("[red]Error: --token-issue-secret is required[/red]")
raise typer.Exit(1)
if webui_port <= 0 and not (webui_socket or "").strip():
console.print("[red]Error: --webui-port or --webui-socket is required[/red]")
raise typer.Exit(1)
if verbose:
logger.remove(_log_handler_id)
logger.add(
sys.stderr,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <5}</level> | "
"<cyan>{extra[channel]}</cyan> | "
"<level>{message}</level>"
),
level="DEBUG",
colorize=None,
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
)
cfg = _load_or_create_desktop_config(config, workspace)
_configure_desktop_gateway(
cfg,
webui_port=webui_port,
webui_socket=webui_socket,
token_issue_secret=token_issue_secret,
)
_run_gateway(
cfg,
port=webui_port,
webui_static_dist=False,
webui_runtime_surface="native",
webui_runtime_capabilities={
"can_restart_engine": True,
"can_pick_folder": True,
"can_open_logs": True,
"can_export_diagnostics": True,
},
health_server_enabled=False,
)
def _run_gateway(
config: Config,
*,
port: int | None = None,
open_browser_url: str | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
health_server_enabled: bool = True,
) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.tools.cron import CronTool
@ -957,6 +1090,9 @@ def _run_gateway(
bus,
session_manager=session_manager,
webui_runtime_model_name=_webui_runtime_model_name,
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
)
def _pick_heartbeat_target() -> tuple[str, str]:
@ -1088,8 +1224,9 @@ def _run_gateway(
tasks = [
agent.run(),
channels.start_all(),
_health_server(config.gateway.host, port),
]
if health_server_enabled:
tasks.append(_health_server(config.gateway.host, port))
if open_browser_url:
tasks.append(_open_browser_when_ready())
await asyncio.gather(*tasks)

View File

@ -295,7 +295,16 @@ class ToolsConfig(Base):
image_generation: ImageGenerationToolConfig = Field(
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
)
restrict_to_workspace: bool = False # restrict all tool access to workspace directory
restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible
webui_allow_local_service_access: bool = Field(
default=True,
validation_alias=AliasChoices(
"webuiAllowLocalServiceAccess",
"webui_allow_local_service_access",
"allowLocalPreviewAccess",
"allow_local_preview_access",
),
) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
@ -314,6 +323,11 @@ class Config(BaseSettings):
validation_alias=AliasChoices("modelPresets", "model_presets"),
)
def __init__(self, **values: Any) -> None:
if not type(self).__pydantic_complete__:
_resolve_tool_config_refs()
super().__init__(**values)
@model_validator(mode="after")
def _validate_model_preset(self) -> "Config":
if "default" in self.model_presets:

View File

@ -15,7 +15,7 @@ from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses import (
consume_sse,
consume_sse_with_reasoning,
convert_messages,
convert_tools,
)
@ -41,6 +41,7 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
"""Shared request logic for both chat() and chat_stream()."""
@ -62,28 +63,36 @@ class OpenAICodexProvider(LLMProvider):
"tool_choice": tool_choice or "auto",
"parallel_tool_calls": True,
}
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
reasoning_options = _build_reasoning_options(reasoning_effort)
if reasoning_options:
body["reasoning"] = reasoning_options
if tools:
body["tools"] = convert_tools(tools)
try:
try:
content, tool_calls, finish_reason = await _request_codex(
content, tool_calls, finish_reason, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=True,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason = await _request_codex(
content, tool_calls, finish_reason, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=False,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
reasoning_content=reasoning_content,
)
except Exception as e:
response = _codex_error_response(e)
exc_type = "CodexHTTPError" if isinstance(e, _CodexHTTPError) else type(e).__name__
@ -118,7 +127,6 @@ class OpenAICodexProvider(LLMProvider):
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
return await self._call_codex(
messages,
tools,
@ -126,6 +134,7 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort,
tool_choice,
on_content_delta,
on_thinking_delta,
on_tool_call_delta,
)
@ -139,6 +148,16 @@ def _strip_model_prefix(model: str) -> str:
return model
def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str] | None:
"""Opt in to visible summaries without changing provider-default effort."""
if reasoning_effort and reasoning_effort.lower() == "none":
return {"effort": "none"}
options = {"summary": "auto"}
if reasoning_effort:
options["effort"] = reasoning_effort
return options
def _build_headers(account_id: str, token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
@ -176,8 +195,9 @@ async def _request_codex(
body: dict[str, Any],
verify: bool,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
) -> tuple[str, list[ToolCallRequest], str, str | None]:
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:
@ -194,7 +214,12 @@ async def _request_codex(
error_code=error_code,
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw),
)
return await consume_sse(response, on_content_delta, on_tool_call_delta)
return await consume_sse_with_reasoning(
response,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
)
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:

View File

@ -10,6 +10,7 @@ from nanobot.providers.openai_responses.parsing import (
FINISH_REASON_MAP,
consume_sdk_stream,
consume_sse,
consume_sse_with_reasoning,
iter_sse,
map_finish_reason,
parse_response_output,
@ -22,6 +23,7 @@ __all__ = [
"split_tool_call_id",
"iter_sse",
"consume_sse",
"consume_sse_with_reasoning",
"consume_sdk_stream",
"map_finish_reason",
"parse_response_output",

View File

@ -65,10 +65,28 @@ async def consume_sse(
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
content, tool_calls, finish_reason, _ = await consume_sse_with_reasoning(
response,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
)
return content, tool_calls, finish_reason
async def consume_sse_with_reasoning(
response: httpx.Response,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, str | None]:
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
content = ""
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop"
reasoning_content: str | None = None
streamed_reasoning = False
async for event in iter_sse(response):
event_type = event.get("type")
@ -94,6 +112,26 @@ async def consume_sse(
content += delta_text
if on_content_delta and delta_text:
await on_content_delta(delta_text)
elif event_type == "response.reasoning_summary_text.delta":
delta_text = event.get("delta") or ""
if delta_text:
reasoning_content = (reasoning_content or "") + delta_text
streamed_reasoning = True
if on_reasoning_delta:
await on_reasoning_delta(delta_text)
elif event_type == "response.reasoning_summary_text.done":
text = event.get("text") or ""
if text and not streamed_reasoning and not reasoning_content:
reasoning_content = text
if on_reasoning_delta:
await on_reasoning_delta(text)
elif event_type == "response.reasoning_summary_part.done":
part = event.get("part") or {}
text = part.get("text") if part.get("type") == "summary_text" else None
if text and not streamed_reasoning and not reasoning_content:
reasoning_content = text
if on_reasoning_delta:
await on_reasoning_delta(text)
elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
@ -108,7 +146,15 @@ async def consume_sse(
elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
arguments = event.get("arguments") or ""
tool_call_buffers[call_id]["arguments"] = arguments
if on_tool_call_delta:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments": str(arguments),
})
elif event_type == "response.output_item.done":
item = event.get("item") or {}
if item.get("type") == "function_call":
@ -117,6 +163,13 @@ async def consume_sse(
continue
buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(buf.get("name") or item.get("name") or ""),
"arguments": str(args_raw),
})
try:
args = json.loads(args_raw)
except Exception:
@ -135,14 +188,44 @@ async def consume_sse(
arguments=args,
)
)
elif item.get("type") == "reasoning" and not reasoning_content:
summary = _extract_reasoning_summary_from_output([item])
if summary:
reasoning_content = summary
if on_reasoning_delta:
await on_reasoning_delta(summary)
elif event_type == "response.completed":
status = (event.get("response") or {}).get("status")
response_obj = event.get("response") or {}
status = response_obj.get("status")
finish_reason = map_finish_reason(status)
if not reasoning_content:
summary = _extract_reasoning_summary_from_output(response_obj.get("output") or [])
if summary:
reasoning_content = summary
if on_reasoning_delta:
await on_reasoning_delta(summary)
elif event_type in {"error", "response.failed"}:
detail = event.get("error") or event.get("message") or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
return content, tool_calls, finish_reason
return content, tool_calls, finish_reason, reasoning_content
def _extract_reasoning_summary_from_output(output: Any) -> str | None:
parts: list[str] = []
for item in output or []:
if not isinstance(item, dict):
dump = getattr(item, "model_dump", None)
item = dump() if callable(dump) else vars(item)
if item.get("type") != "reasoning":
continue
for summary in item.get("summary") or []:
if not isinstance(summary, dict):
dump = getattr(summary, "model_dump", None)
summary = dump() if callable(dump) else vars(summary)
if summary.get("type") == "summary_text" and summary.get("text"):
parts.append(summary["text"])
return "".join(parts) or None
def parse_response_output(response: Any) -> LLMResponse:
@ -230,6 +313,7 @@ async def consume_sdk_stream(
content = ""
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop"
usage: dict[str, int] = {}
reasoning_content: str | None = None
@ -272,7 +356,15 @@ async def consume_sdk_stream(
elif event_type == "response.function_call_arguments.done":
call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] = getattr(event, "arguments", "") or ""
arguments = getattr(event, "arguments", "") or ""
tool_call_buffers[call_id]["arguments"] = arguments
if on_tool_call_delta:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments": str(arguments),
})
elif event_type == "response.output_item.done":
item = getattr(event, "item", None)
if item and getattr(item, "type", None) == "function_call":
@ -281,6 +373,13 @@ async def consume_sdk_stream(
continue
buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or getattr(item, "arguments", None) or "{}"
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(buf.get("name") or getattr(item, "name", None) or ""),
"arguments": str(args_raw),
})
try:
args = json.loads(args_raw)
except Exception:

View File

@ -42,9 +42,14 @@ def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
return any(addr in net for net in _BLOCKED_NETWORKS)
def validate_url_target(url: str) -> tuple[bool, str]:
def validate_url_target(url: str, *, allow_loopback: bool = False) -> tuple[bool, str]:
"""Validate a URL is safe to fetch: scheme, hostname, and resolved IPs.
``allow_loopback`` is intentionally narrow: it only permits literal
loopback hosts (localhost, 127.0.0.0/8, ::1) when every resolved address is
loopback. It does not allow RFC1918, link-local, metadata, or public DNS
names that happen to resolve to loopback.
Returns (ok, error_message). When ok is True, error_message is empty.
"""
try:
@ -66,11 +71,16 @@ def validate_url_target(url: str) -> tuple[bool, str]:
except socket.gaierror:
return False, f"Cannot resolve hostname: {hostname}"
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
for info in infos:
try:
addr = ipaddress.ip_address(info[4][0])
except ValueError:
continue
addrs.append(addr)
if allow_loopback and _is_allowed_loopback_target(hostname, addrs):
return True, ""
for addr in addrs:
if _is_private(addr):
return False, f"Blocked: {hostname} resolves to private/internal address {addr}"
@ -109,11 +119,25 @@ def validate_resolved_url(url: str) -> tuple[bool, str]:
return True, ""
def contains_internal_url(command: str) -> bool:
def contains_internal_url(command: str, *, allow_loopback: bool = False) -> bool:
"""Return True if the command string contains a URL targeting an internal/private address."""
for m in _URL_RE.finditer(command):
url = m.group(0)
ok, _ = validate_url_target(url)
ok, _ = validate_url_target(url, allow_loopback=allow_loopback)
if not ok:
return True
return False
def _is_allowed_loopback_target(
hostname: str,
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address],
) -> bool:
if not addrs or not all(addr.is_loopback for addr in addrs):
return False
normalized = hostname.rstrip(".").lower()
if normalized == "localhost":
return True
with suppress(ValueError):
return ipaddress.ip_address(hostname).is_loopback
return False

View File

@ -0,0 +1,430 @@
"""Workspace access scope and sandbox capability helpers."""
from __future__ import annotations
import os
from contextvars import ContextVar, Token
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
WorkspaceAccessMode = Literal["restricted", "full"]
WORKSPACE_SCOPE_METADATA_KEY = "workspace_scope"
_ACCESS_MODES = {"restricted", "full"}
_TRUE_VALUES = {"1", "true", "yes", "on", "enabled"}
_FALSE_VALUES = {"0", "false", "no", "off", "disabled", ""}
_PROVIDER_LABELS = {
"none": "None",
"unknown": "Unknown system sandbox",
"macos_app_sandbox": "macOS App Sandbox",
"bwrap": "Bubblewrap",
}
_CURRENT_WORKSPACE_SCOPE: ContextVar["WorkspaceScope | None"] = ContextVar(
"nanobot_workspace_scope",
default=None,
)
class WorkspaceScopeError(ValueError):
"""Raised when a requested WebUI workspace scope is invalid."""
status = 400
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass(frozen=True)
class WorkspaceSandboxStatus:
"""Resolved workspace sandbox state for runtime display and tooling."""
restrict_to_workspace: bool
workspace_root: str
level: str
enforced: bool
provider: str
provider_label: str
summary: str
def as_dict(self) -> dict[str, object]:
return {
"restrict_to_workspace": self.restrict_to_workspace,
"workspace_root": self.workspace_root,
"level": self.level,
"enforced": self.enforced,
"provider": self.provider,
"provider_label": self.provider_label,
"summary": self.summary,
}
@dataclass(frozen=True)
class WorkspaceScope:
"""Effective project root and access mode for one agent turn."""
project_path: Path
access_mode: WorkspaceAccessMode
restrict_to_workspace: bool
sandbox_status: WorkspaceSandboxStatus
source_channel: str | None = None
@property
def project_name(self) -> str:
return self.project_path.name or str(self.project_path)
def metadata(self) -> dict[str, str]:
return {
"project_path": str(self.project_path),
"access_mode": self.access_mode,
}
def payload(self) -> dict[str, Any]:
return {
**self.metadata(),
"project_name": self.project_name,
"restrict_to_workspace": self.restrict_to_workspace,
"sandbox_status": self.sandbox_status.as_dict(),
}
@dataclass(frozen=True)
class ToolWorkspace:
"""Workspace policy resolved for a tool call."""
project_path: Path | None
restrict_to_workspace: bool
scope: WorkspaceScope | None = None
@property
def allowed_root(self) -> Path | None:
if self.restrict_to_workspace and self.project_path is not None:
return self.project_path
return None
@dataclass(frozen=True)
class WorkspaceScopeResolver:
"""Resolve the effective workspace scope at an agent turn boundary."""
default_workspace: str | Path
default_restrict_to_workspace: bool
scoped_channel: str = "websocket"
@property
def sandbox_status(self) -> WorkspaceSandboxStatus:
return self.default().sandbox_status
def default(self) -> WorkspaceScope:
return default_workspace_scope(
self.default_workspace,
self.default_restrict_to_workspace,
)
def for_message(
self,
msg: Any,
session_metadata: Any,
) -> WorkspaceScope:
return self.for_turn(
channel=getattr(msg, "channel", None),
message_metadata=getattr(msg, "metadata", None),
session_metadata=session_metadata,
)
def for_turn(
self,
*,
channel: str | None,
message_metadata: Any,
session_metadata: Any,
) -> WorkspaceScope:
if channel != self.scoped_channel:
return self.default()
return resolve_effective_workspace_scope(
message_metadata=message_metadata,
session_metadata=session_metadata,
default_workspace=self.default_workspace,
default_restrict_to_workspace=self.default_restrict_to_workspace,
source_channel=channel,
)
def persist_message_scope(self, session: Any, msg: Any) -> None:
if getattr(msg, "channel", None) != self.scoped_channel:
return
metadata = getattr(msg, "metadata", None)
if not isinstance(metadata, dict):
return
raw = metadata.get(WORKSPACE_SCOPE_METADATA_KEY)
if isinstance(raw, dict):
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = dict(raw)
def workspace_sandbox_status(
*,
restrict_to_workspace: bool,
workspace: str | Path,
environ: dict[str, str] | None = None,
) -> WorkspaceSandboxStatus:
"""Return how workspace restriction is enforced in the current host."""
workspace_root = str(Path(workspace).expanduser().resolve(strict=False))
provider = _env_system_provider(environ)
if not restrict_to_workspace:
return WorkspaceSandboxStatus(
restrict_to_workspace=False,
workspace_root=workspace_root,
level="off",
enforced=False,
provider="none",
provider_label=_provider_label("none"),
summary="Workspace restriction is disabled.",
)
if provider:
label = _provider_label(provider)
return WorkspaceSandboxStatus(
restrict_to_workspace=True,
workspace_root=workspace_root,
level="system",
enforced=True,
provider=provider,
provider_label=label,
summary=f"Workspace restriction is system-enforced by {label}.",
)
return WorkspaceSandboxStatus(
restrict_to_workspace=True,
workspace_root=workspace_root,
level="application",
enforced=False,
provider="none",
provider_label=_provider_label("none"),
summary="Workspace restriction uses nanobot application-level guards.",
)
def default_access_mode(restrict_to_workspace: bool) -> WorkspaceAccessMode:
return "restricted" if restrict_to_workspace else "full"
def build_workspace_scope(
project_path: str | Path,
access_mode: str,
*,
source_channel: str | None = None,
) -> WorkspaceScope:
mode = _normalize_access_mode(access_mode)
root = Path(project_path).expanduser().resolve(strict=False)
restrict = mode == "restricted"
return WorkspaceScope(
project_path=root,
access_mode=mode,
restrict_to_workspace=restrict,
sandbox_status=workspace_sandbox_status(
restrict_to_workspace=restrict,
workspace=root,
),
source_channel=source_channel,
)
def default_workspace_scope(
workspace: str | Path,
restrict_to_workspace: bool,
*,
source_channel: str | None = None,
) -> WorkspaceScope:
return build_workspace_scope(
workspace,
default_access_mode(restrict_to_workspace),
source_channel=source_channel,
)
def validate_workspace_scope_payload(
raw: Any,
*,
default_workspace: str | Path,
default_restrict_to_workspace: bool,
source_channel: str | None = None,
) -> WorkspaceScope:
"""Validate a client-requested workspace scope."""
if raw is None:
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=source_channel,
)
if not isinstance(raw, dict):
raise WorkspaceScopeError("workspace_scope must be an object")
raw_path = raw.get("project_path") or raw.get("path")
if raw_path is None or raw_path == "":
raw_path = str(Path(default_workspace).expanduser().resolve(strict=False))
if not isinstance(raw_path, str):
raise WorkspaceScopeError("project_path must be a string")
if "\0" in raw_path:
raise WorkspaceScopeError("project_path contains invalid characters")
project = Path(raw_path).expanduser()
if not project.is_absolute():
raise WorkspaceScopeError("project_path must be absolute")
project = project.resolve(strict=False)
if not project.is_dir():
raise WorkspaceScopeError("project_path must be an existing directory")
raw_mode = raw.get("access_mode")
if raw_mode is None:
raw_mode = default_access_mode(default_restrict_to_workspace)
if not isinstance(raw_mode, str):
raise WorkspaceScopeError("access_mode must be a string")
return build_workspace_scope(project, raw_mode, source_channel=source_channel)
def workspace_scope_from_metadata(
metadata: Any,
*,
default_workspace: str | Path,
default_restrict_to_workspace: bool,
source_channel: str | None = None,
) -> WorkspaceScope:
"""Resolve persisted metadata, falling back safely for old or stale sessions."""
if not isinstance(metadata, dict):
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=source_channel,
)
try:
return validate_workspace_scope_payload(
metadata.get(WORKSPACE_SCOPE_METADATA_KEY),
default_workspace=default_workspace,
default_restrict_to_workspace=default_restrict_to_workspace,
source_channel=source_channel,
)
except WorkspaceScopeError:
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=source_channel,
)
def resolve_effective_workspace_scope(
*,
message_metadata: Any,
session_metadata: Any,
default_workspace: str | Path,
default_restrict_to_workspace: bool,
source_channel: str | None = None,
) -> WorkspaceScope:
if isinstance(message_metadata, dict) and WORKSPACE_SCOPE_METADATA_KEY in message_metadata:
return workspace_scope_from_metadata(
message_metadata,
default_workspace=default_workspace,
default_restrict_to_workspace=default_restrict_to_workspace,
source_channel=source_channel,
)
return workspace_scope_from_metadata(
session_metadata,
default_workspace=default_workspace,
default_restrict_to_workspace=default_restrict_to_workspace,
source_channel=source_channel,
)
def bind_workspace_scope(scope: WorkspaceScope) -> Token[WorkspaceScope | None]:
return _CURRENT_WORKSPACE_SCOPE.set(scope)
def reset_workspace_scope(token: Token[WorkspaceScope | None]) -> None:
_CURRENT_WORKSPACE_SCOPE.reset(token)
def current_workspace_scope() -> WorkspaceScope | None:
return _CURRENT_WORKSPACE_SCOPE.get()
def current_tool_workspace(
default_workspace: str | Path | None,
*,
restrict_to_workspace: bool = False,
sandbox_restricts_workspace: bool = False,
) -> ToolWorkspace:
"""Return the workspace/access policy for the current tool call."""
scope = current_workspace_scope()
project_path = (
scope.project_path
if scope is not None
else Path(default_workspace).expanduser() if default_workspace is not None else None
)
restrict = (
scope.restrict_to_workspace
if scope is not None
else bool(restrict_to_workspace)
) or sandbox_restricts_workspace
return ToolWorkspace(
project_path=project_path,
restrict_to_workspace=restrict,
scope=scope,
)
def current_scope_allows_loopback(*, enabled: bool) -> bool:
"""Return True when the current WebUI Full Access turn may touch loopback URLs."""
scope = current_workspace_scope()
return bool(
enabled
and scope is not None
and scope.source_channel == "websocket"
and scope.access_mode == "full"
and not scope.restrict_to_workspace
)
def _env_system_provider(environ: dict[str, str] | None = None) -> str | None:
env = environ if environ is not None else os.environ
explicit_provider = env.get("NANOBOT_WORKSPACE_SANDBOX_PROVIDER")
enforced = env.get("NANOBOT_WORKSPACE_SANDBOX_ENFORCED")
compatibility = env.get("NANOBOT_SANDBOX_ENFORCED")
marker = enforced if enforced is not None else compatibility
if marker is None:
return None
normalized_marker = marker.strip().lower()
if normalized_marker in _FALSE_VALUES:
return None
if normalized_marker in _TRUE_VALUES:
return _normalize_provider(explicit_provider)
return _normalize_provider(marker)
def _normalize_provider(value: str | None) -> str:
if not value:
return "unknown"
normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
return normalized or "unknown"
def _provider_label(provider: str) -> str:
if provider in _PROVIDER_LABELS:
return _PROVIDER_LABELS[provider]
return provider.replace("_", " ").title()
def _normalize_access_mode(value: str) -> WorkspaceAccessMode:
mode = value.strip().lower().replace("_", "-")
if mode == "restrict":
mode = "restricted"
if mode == "full-access":
mode = "full"
if mode not in _ACCESS_MODES:
raise WorkspaceScopeError("access_mode must be restricted or full")
return mode # type: ignore[return-value]

View File

@ -0,0 +1,85 @@
"""Workspace path boundary helpers.
These helpers are application-level guards. They make path decisions
consistent across tools, but they are not a replacement for an OS sandbox.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable
WORKSPACE_BOUNDARY_NOTE = (
" (this is a hard policy boundary, not a transient failure; "
"do not retry with shell tricks or alternative tools, and ask "
"the user how to proceed if the resource is genuinely required)"
)
class WorkspaceBoundaryError(PermissionError):
"""Raised when a requested path escapes an allowed workspace boundary."""
def resolve_path(path: str | Path, workspace: str | Path | None = None, *, strict: bool = False) -> Path:
"""Resolve *path*, interpreting relative paths against *workspace* when set."""
candidate = Path(path).expanduser()
if not candidate.is_absolute() and workspace is not None:
candidate = Path(workspace).expanduser() / candidate
return candidate.resolve(strict=strict)
def is_path_within(path: str | Path, root: str | Path) -> bool:
"""Return True when *path* resolves to *root* or a descendant of *root*."""
try:
resolved_path = Path(path).expanduser().resolve(strict=False)
resolved_root = Path(root).expanduser().resolve(strict=False)
resolved_path.relative_to(resolved_root)
return True
except (OSError, RuntimeError, TypeError, ValueError):
return False
def is_path_allowed(path: str | Path, roots: Iterable[str | Path]) -> bool:
"""Return True when *path* is inside any allowed root."""
return any(is_path_within(path, root) for root in roots)
def require_path_within(
path: str | Path,
root: str | Path,
*,
message: str | None = None,
) -> Path:
"""Resolve *path* and require it to be inside *root*."""
resolved = Path(path).expanduser().resolve(strict=False)
if not is_path_within(resolved, root):
raise WorkspaceBoundaryError(
message
or f"Path {path} is outside allowed directory {Path(root).expanduser()}"
+ WORKSPACE_BOUNDARY_NOTE
)
return resolved
def resolve_allowed_path(
path: str | Path,
*,
workspace: str | Path | None = None,
allowed_root: str | Path | None = None,
extra_allowed_roots: Iterable[str | Path] | None = None,
strict: bool = False,
) -> Path:
"""Resolve a path and enforce containment in allowed roots when configured."""
resolved = resolve_path(path, workspace, strict=False)
if allowed_root is None:
return resolve_path(path, workspace, strict=strict) if strict else resolved
roots = [allowed_root, *(extra_allowed_roots or [])]
if not is_path_allowed(resolved, roots):
raise WorkspaceBoundaryError(
f"Path {path} is outside allowed directory {Path(allowed_root).expanduser()}"
+ WORKSPACE_BOUNDARY_NOTE
)
if strict:
return resolve_path(path, workspace, strict=True)
return resolved

View File

@ -299,6 +299,7 @@ def build_file_edit_end_event(
deleted=deleted,
approximate=False,
binary=(after.binary or after.oversized or after.unreadable) and not counted,
operation="delete" if tracker.before.exists and not after.exists else None,
)
@ -324,6 +325,7 @@ def build_file_edit_live_event(
*,
added: int,
deleted: int = 0,
operation: str | None = None,
) -> dict[str, Any]:
"""Build an approximate in-progress event while tool-call arguments stream."""
return _event_payload(
@ -333,6 +335,7 @@ def build_file_edit_live_event(
added=added,
deleted=deleted,
approximate=True,
operation=operation,
)
@ -454,15 +457,14 @@ class StreamingFileEditTracker:
segment_end = path_matches[i + 1].start() if i + 1 < len(path_matches) else len(state.arguments)
segment = state.arguments[segment_start:segment_end]
action_match = re.search(r'"action"\s*:\s*"(replace|add|delete)"', segment)
action_match = re.search(r'"action"\s*:\s*"(replace|add)"', segment)
action = action_match.group(1) if action_match else "replace"
old_text = _extract_json_string_prefix(segment, "old_text") or ""
new_text = _extract_json_string_prefix(segment, "new_text") or ""
added = _text_line_count(new_text) if action in ("replace", "add") else 0
deleted = _text_line_count(old_text) if action in ("replace", "delete") else 0
delete_file = action == "delete"
deleted = _text_line_count(old_text) if action == "replace" else 0
file_state = state.patch_files.get(raw_path)
if file_state is None:
@ -475,8 +477,6 @@ class StreamingFileEditTracker:
)
file_state = _StreamingPatchFileState(tracker=tracker)
state.patch_files[raw_path] = file_state
if delete_file and added == 0 and deleted == 0 and file_state.tracker.before.countable:
deleted = _text_line_count(file_state.tracker.before.text or "")
if not file_state.should_emit(added, deleted, now):
continue
file_state.mark_emitted(added, deleted, now)
@ -916,6 +916,7 @@ def _event_payload(
deleted: int,
approximate: bool,
binary: bool = False,
operation: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"version": 1,
@ -931,6 +932,8 @@ def _event_payload(
}
if binary:
payload["binary"] = True
if operation:
payload["operation"] = operation
return payload

View File

@ -124,7 +124,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="playwright",
display_name="Playwright",
category="browser",
description="Local browser inspection and automation with the official Playwright MCP server.",
description="Local browser inspection and automation with Playwright's MCP server.",
docs_url="https://playwright.dev/docs/getting-started-mcp",
transport="stdio",
install_supported=True,
@ -216,7 +216,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="microsoft-learn",
display_name="Microsoft Learn",
category="docs",
description="Search and fetch official Microsoft Learn documentation through Microsoft's hosted MCP server.",
description="Search and fetch Microsoft Learn documentation through Microsoft's hosted MCP server.",
docs_url="https://learn.microsoft.com/en-us/training/support/mcp",
transport="streamableHttp",
install_supported=True,
@ -307,7 +307,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="figma",
display_name="Figma",
category="design",
description="Read design context from Figma using the official local Dev Mode MCP server.",
description="Read design context from Figma using the local Dev Mode MCP server.",
docs_url="https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server",
transport="streamableHttp",
install_supported=True,
@ -325,7 +325,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="github",
display_name="GitHub",
category="code",
description="Repository, issue, and pull request workflows via GitHub's official MCP server.",
description="Repository, issue, and pull request workflows via GitHub's MCP server.",
docs_url="https://github.com/github/github-mcp-server",
transport="stdio",
install_supported=True,

View File

@ -7,7 +7,9 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
from __future__ import annotations
import re
from typing import Any
import time
from contextlib import suppress
from typing import Any, Literal
from zoneinfo import ZoneInfo
from nanobot.config.loader import get_config_path, load_config, save_config
@ -17,8 +19,48 @@ from nanobot.providers.image_generation import (
image_gen_provider_names,
)
from nanobot.providers.registry import PROVIDERS, find_by_name
from nanobot.security.workspace_access import workspace_sandbox_status
from nanobot.webui.workspaces import (
read_webui_default_access_mode,
write_webui_default_access_mode,
)
QueryParams = dict[str, list[str]]
RuntimeSurface = Literal["browser", "native"]
_RUNTIME_CAPABILITIES = {
"can_restart_engine": False,
"can_pick_folder": False,
"can_open_logs": False,
"can_export_diagnostics": False,
}
_NATIVE_RUNTIME_CAPABILITIES = {
**_RUNTIME_CAPABILITIES,
"can_restart_engine": True,
"can_pick_folder": True,
"can_open_logs": True,
"can_export_diagnostics": True,
}
_BROWSER_RESTART_BEHAVIOR_BY_SECTION = {
"appearance": "none",
"models": "none",
"providers": "none",
"runtime": "engineRestart",
"browser": "engineRestart",
"image": "engineRestart",
"apps": "engineRestart",
"advanced": "appRestart",
}
_NATIVE_RESTART_BEHAVIOR_BY_SECTION = {
**_BROWSER_RESTART_BEHAVIOR_BY_SECTION,
"runtime": "engineRestart",
"browser": "engineRestart",
"image": "engineRestart",
"apps": "engineRestart",
}
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
@ -55,6 +97,70 @@ class WebUISettingsError(ValueError):
self.status = status
def _normalize_surface(surface: str | None) -> RuntimeSurface:
return "native" if surface in {"native", "desktop"} else "browser"
def runtime_capabilities(
surface: str | None = "browser",
overrides: dict[str, Any] | None = None,
) -> dict[str, bool]:
"""Return the capability flags exposed to the WebUI runtime."""
base = (
_NATIVE_RUNTIME_CAPABILITIES
if _normalize_surface(surface) == "native"
else _RUNTIME_CAPABILITIES
)
result = dict(base)
for key, value in (overrides or {}).items():
if key in result:
result[key] = bool(value)
return result
def restart_behavior_by_section(surface: str | None = "browser") -> dict[str, str]:
return dict(
_NATIVE_RESTART_BEHAVIOR_BY_SECTION
if _normalize_surface(surface) == "native"
else _BROWSER_RESTART_BEHAVIOR_BY_SECTION
)
def decorate_settings_payload(
payload: dict[str, Any],
*,
surface: str | None = "browser",
runtime_capability_overrides: dict[str, Any] | None = None,
restart_required_sections: list[str] | None = None,
apply_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Attach runtime-surface metadata without changing the core settings shape."""
surface_value = _normalize_surface(surface)
sections = restart_required_sections
if sections is None:
raw_sections = payload.get("restart_required_sections") or []
sections = [str(section) for section in raw_sections if isinstance(section, str)]
sections = sorted(dict.fromkeys(sections))
result = dict(payload)
result["surface"] = surface_value
result["runtime_surface"] = surface_value
result["runtime_capabilities"] = runtime_capabilities(
surface_value,
runtime_capability_overrides,
)
result["restart_behavior_by_section"] = restart_behavior_by_section(surface_value)
result["restart_required_sections"] = sections
if sections:
result["requires_restart"] = True
else:
result["requires_restart"] = bool(result.get("requires_restart", False))
result["apply_state"] = apply_state or {
"status": "pending" if result["requires_restart"] else "idle",
"sections": sections,
}
return result
def _query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
@ -83,9 +189,57 @@ def _provider_requires_api_key(spec: Any) -> bool:
return True
def _oauth_provider_status(spec: Any) -> dict[str, Any]:
if not getattr(spec, "is_oauth", False):
return {"configured": False, "account": None, "expires_at": None, "login_supported": False}
if spec.name == "openai_codex":
try:
from oauth_cli_kit import get_token as get_codex_token
except Exception:
return {
"configured": False,
"account": None,
"expires_at": None,
"login_supported": False,
}
token = None
with suppress(Exception):
token = get_codex_token()
expires_at = getattr(token, "expires", None) if token else None
return {
"configured": bool(token and token.access),
"account": getattr(token, "account_id", None) if token else None,
"expires_at": expires_at,
"login_supported": True,
}
if spec.name == "github_copilot":
try:
from nanobot.providers.github_copilot_provider import get_github_copilot_login_status
except Exception:
return {
"configured": False,
"account": None,
"expires_at": None,
"login_supported": False,
}
token = None
with suppress(Exception):
token = get_github_copilot_login_status()
return {
"configured": bool(token and token.access and token.expires > int(time.time() * 1000)),
"account": getattr(token, "account_id", None) if token else None,
"expires_at": getattr(token, "expires", None) if token else None,
"login_supported": True,
}
return {"configured": False, "account": None, "expires_at": None, "login_supported": False}
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if spec.is_oauth:
return True
return bool(_oauth_provider_status(spec)["configured"])
if _provider_requires_api_key(spec):
return bool(provider_config.api_key)
return bool(
@ -144,6 +298,7 @@ def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
"name": name,
"label": spec.label if spec is not None else name,
"configured": configured,
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
"api_key_hint": _mask_secret_hint(
getattr(provider_config, "api_key", None)
),
@ -156,7 +311,14 @@ def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
return rows
def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
def settings_payload(
*,
requires_restart: bool = False,
surface: str | None = "browser",
runtime_capability_overrides: dict[str, Any] | None = None,
restart_required_sections: list[str] | None = None,
apply_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
config = load_config()
defaults = config.agents.defaults
active_preset_name = defaults.model_preset or "default"
@ -179,17 +341,27 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
providers = []
for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth:
if provider_config is None:
continue
oauth_status = _oauth_provider_status(spec) if spec.is_oauth else None
row = {
"name": spec.name,
"label": spec.label,
"configured": _provider_configured_for_settings(spec, provider_config),
"configured": (
bool(oauth_status["configured"])
if oauth_status is not None
else _provider_configured_for_settings(spec, provider_config)
),
"auth_type": "oauth" if spec.is_oauth else "api_key",
"api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
}
if oauth_status is not None:
row["oauth_account"] = oauth_status["account"]
row["oauth_expires_at"] = oauth_status["expires_at"]
row["oauth_login_supported"] = oauth_status["login_supported"]
if spec.name == "openai":
row["api_type"] = provider_config.api_type
providers.append(row)
@ -241,7 +413,11 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
)
exec_config = config.tools.exec
return {
sandbox_status = workspace_sandbox_status(
restrict_to_workspace=config.tools.restrict_to_workspace,
workspace=config.workspace_path,
)
payload = {
"agent": {
"model": effective_preset.model,
"provider": selected_provider,
@ -312,6 +488,11 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
},
"advanced": {
"restrict_to_workspace": config.tools.restrict_to_workspace,
"workspace_sandbox": sandbox_status.as_dict(),
"webui_allow_local_service_access": config.tools.webui_allow_local_service_access,
"allow_local_preview_access": config.tools.webui_allow_local_service_access,
"webui_default_access_mode": read_webui_default_access_mode(),
"private_service_protection_enabled": True,
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
"mcp_server_count": len(config.tools.mcp_servers),
"exec_enabled": exec_config.enable,
@ -320,6 +501,13 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
},
"requires_restart": requires_restart,
}
return decorate_settings_payload(
payload,
surface=surface,
runtime_capability_overrides=runtime_capability_overrides,
restart_required_sections=restart_required_sections,
apply_state=apply_state,
)
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
@ -444,6 +632,54 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
return settings_payload()
def update_model_configuration(query: QueryParams) -> 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()
preset = config.model_presets.get(name)
if preset is None:
raise WebUISettingsError("unknown model configuration")
changed = False
label = _query_first_alias(query, "label", "displayName")
if label is not None:
label = label.strip()
if not label:
raise WebUISettingsError("label is required")
if preset.label != label:
preset.label = label
changed = True
model = _query_first(query, "model")
if model is not None:
model = model.strip()
if not model:
raise WebUISettingsError("model is required")
if preset.model != model:
preset.model = model
changed = True
provider = _query_first(query, "provider")
if provider is not None:
provider = provider.strip()
if not provider:
raise WebUISettingsError("provider is required")
_validate_configured_provider(config, provider)
if preset.provider != provider:
preset.provider = provider
changed = True
if config.agents.defaults.model_preset != name:
config.agents.defaults.model_preset = name
changed = True
if changed:
save_config(config)
return settings_payload()
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
@ -495,6 +731,114 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
return settings_payload(requires_restart=restart_required)
def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
raise WebUISettingsError("provider is required")
spec = find_by_name(provider_name)
if spec is None or not spec.is_oauth:
raise WebUISettingsError("unknown OAuth provider")
if spec.name == "openai_codex":
try:
from oauth_cli_kit import get_token, login_oauth_interactive
except ImportError:
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
token = None
with suppress(Exception):
token = get_token()
if not (token and token.access):
messages: list[str] = []
token = login_oauth_interactive(
print_fn=lambda message: messages.append(str(message)),
prompt_fn=lambda _prompt: "",
)
if not (token and token.access):
raise WebUISettingsError("OAuth login failed", status=401)
return settings_payload()
if spec.name == "github_copilot":
try:
from nanobot.providers.github_copilot_provider import (
get_github_copilot_login_status,
login_github_copilot,
)
except ImportError:
raise WebUISettingsError("GitHub Copilot OAuth support is unavailable", status=500) from None
token = get_github_copilot_login_status()
if not token:
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()
raise WebUISettingsError("OAuth login is not supported for this provider")
def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
raise WebUISettingsError("provider is required")
spec = find_by_name(provider_name)
if spec is None or not spec.is_oauth:
raise WebUISettingsError("unknown OAuth provider")
if spec.name == "openai_codex":
try:
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage
except ImportError:
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
elif spec.name == "github_copilot":
try:
from nanobot.providers.github_copilot_provider import get_storage
except ImportError:
raise WebUISettingsError("GitHub Copilot OAuth support is unavailable", status=500) from None
token_path = get_storage().get_token_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()
def update_network_safety_settings(query: QueryParams) -> 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")
)
raw_default_access_mode = _query_first_alias(query, "webui_default_access_mode", "webuiDefaultAccessMode")
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()
changed = False
if raw_allow is not None:
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
if config.tools.webui_allow_local_service_access != webui_allow_local_service_access:
config.tools.webui_allow_local_service_access = webui_allow_local_service_access
changed = True
if changed:
save_config(config)
if raw_default_access_mode is not None:
default_access_mode = raw_default_access_mode.strip().lower()
if default_access_mode == "restricted":
default_access_mode = "default"
if default_access_mode not in {"default", "full"}:
raise WebUISettingsError("webui_default_access_mode must be default or full")
try:
write_webui_default_access_mode(default_access_mode)
except ValueError as exc:
raise WebUISettingsError(str(exc)) from exc
return settings_payload(requires_restart=changed)
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip().lower()
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)

View File

@ -38,6 +38,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
"pinned_keys": [],
"archived_keys": [],
"title_overrides": {},
"project_name_overrides": {},
"tags_by_key": {},
"collapsed_groups": {},
"view": {
@ -136,6 +137,9 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
state["project_name_overrides"] = _clean_title_overrides(
raw.get("project_name_overrides")
)
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
state["view"] = _clean_view(raw.get("view"))
@ -190,4 +194,3 @@ def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
finally:
os.close(dir_fd)
return state

View File

@ -28,6 +28,11 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
".webp",
".gif",
})
_FILE_EDIT_TOOL_NAMES: frozenset[str] = frozenset({
"write_file",
"edit_file",
"apply_patch",
})
def rewrite_local_markdown_images(
@ -200,6 +205,19 @@ def _tool_event_key(event: dict[str, Any]) -> str:
return _format_tool_call_trace(event) or json.dumps(event, sort_keys=True, ensure_ascii=False)
def _tool_event_file_edit_key(event: dict[str, Any]) -> str | None:
call_id = event.get("call_id")
if not isinstance(call_id, str) or not call_id:
return None
name = event.get("name")
if not isinstance(name, str) or not name:
fn = event.get("function")
name = fn.get("name") if isinstance(fn, dict) else ""
if not isinstance(name, str) or name not in _FILE_EDIT_TOOL_NAMES:
return None
return f"{call_id}|{name}"
def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not isinstance(previous, list) or not previous:
return incoming
@ -222,6 +240,87 @@ def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[di
return merged
def _file_edit_key(edit: dict[str, Any]) -> str:
call_id = str(edit.get("call_id") or "")
tool = str(edit.get("tool") or "")
if call_id:
return f"{call_id}|{tool}"
return f"{tool}|{edit.get('path') or ''}"
def _message_has_file_edit_for_tool_event(
message: dict[str, Any],
event: dict[str, Any],
) -> bool:
key = _tool_event_file_edit_key(event)
if not key:
return False
edits = message.get("fileEdits")
if not isinstance(edits, list):
return False
return any(isinstance(edit, dict) and _file_edit_key(edit) == key for edit in edits)
def _filter_covered_file_edit_tool_events(
messages: list[dict[str, Any]],
events: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if not events:
return events
return [
event
for event in events
if not any(_message_has_file_edit_for_tool_event(message, event) for message in messages)
]
def _strip_covered_file_edit_tool_hints(
message: dict[str, Any],
edits: list[dict[str, Any]],
) -> dict[str, Any]:
incoming_keys = {
_file_edit_key(edit)
for edit in edits
if isinstance(edit, dict)
}
events = message.get("toolEvents")
if not incoming_keys or not isinstance(events, list):
return message
kept_events: list[dict[str, Any]] = []
removed_trace_lines: set[str] = set()
changed = False
for event in events:
if not isinstance(event, dict):
continue
key = _tool_event_file_edit_key(event)
if key and key in incoming_keys:
changed = True
removed_trace_lines.update(tool_trace_lines_from_events([event]))
continue
kept_events.append(event)
if not changed:
return message
raw_traces = message.get("traces")
if isinstance(raw_traces, list):
previous_traces = [trace for trace in raw_traces if isinstance(trace, str)]
else:
content = message.get("content")
previous_traces = [content] if isinstance(content, str) and content else []
next_traces = [trace for trace in previous_traces if trace not in removed_trace_lines]
next_message = {
**message,
"traces": next_traces,
"content": next_traces[-1] if next_traces else "",
}
if kept_events:
next_message["toolEvents"] = kept_events
else:
next_message.pop("toolEvents", None)
return next_message
def _merge_unique_tool_trace_lines(
previous_traces: list[str],
lines: list[str],
@ -343,6 +442,40 @@ def replay_transcript_to_ui_messages(
return None
return str(last.get("id"))
def demote_interrupted_assistant(segment: str) -> None:
nonlocal buffer_message_id, buffer_parts
for i in range(len(messages) - 1, -1, -1):
candidate = messages[i]
if candidate.get("role") == "user":
break
content = candidate.get("content")
if (
candidate.get("role") != "assistant"
or candidate.get("kind") == "trace"
or not candidate.get("isStreaming")
or not isinstance(content, str)
or not content.strip()
or candidate.get("media")
):
continue
reasoning_parts = [
part
for part in (candidate.get("reasoning"), content)
if isinstance(part, str) and part.strip()
]
messages[i] = {
**candidate,
"content": "",
"reasoning": "\n\n".join(reasoning_parts),
"reasoningStreaming": False,
"isStreaming": False,
"activitySegmentId": candidate.get("activitySegmentId") or segment,
}
if buffer_message_id == candidate.get("id"):
buffer_message_id = None
buffer_parts = []
return
def close_reasoning(prev: list[dict[str, Any]]) -> None:
for i in range(len(prev) - 1, -1, -1):
if prev[i].get("reasoningStreaming"):
@ -404,13 +537,6 @@ def replay_transcript_to_ui_messages(
active_activity_segment_id = None
active_file_edit_segment_id = None
def _file_edit_key(edit: dict[str, Any]) -> str:
call_id = str(edit.get("call_id") or "")
tool = str(edit.get("tool") or "")
if call_id:
return f"{call_id}|{tool}"
return f"{tool}|{edit.get('path') or ''}"
def find_file_edit_trace_index(
segment: str | None,
edits: list[dict[str, Any]],
@ -420,16 +546,23 @@ def replay_transcript_to_ui_messages(
candidate = messages[i]
if candidate.get("role") == "user":
break
if candidate.get("kind") != "trace" or not candidate.get("fileEdits"):
if candidate.get("kind") != "trace":
continue
if segment and candidate.get("activitySegmentId") == segment:
return i
existing_edits = candidate.get("fileEdits")
if not isinstance(existing_edits, list):
continue
for existing in existing_edits:
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
return i
if isinstance(existing_edits, list):
for existing in existing_edits:
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
return i
existing_tool_events = candidate.get("toolEvents")
if isinstance(existing_tool_events, list):
for event in existing_tool_events:
if not isinstance(event, dict):
continue
key = _tool_event_file_edit_key(event)
if key and key in incoming_keys:
return i
return None
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
@ -437,11 +570,16 @@ def replay_transcript_to_ui_messages(
if not edits:
return
segment = active_file_edit_segment_id
if not segment:
segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
demote_interrupted_assistant(segment)
target_index = find_file_edit_trace_index(segment, edits)
if target_index is not None:
last = messages[target_index]
segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
active_file_edit_segment_id = segment
last = _strip_covered_file_edit_tool_hints(last, edits)
else:
if not segment:
segment = _new_activity_segment(activate=False)
@ -620,12 +758,21 @@ def replay_transcript_to_ui_messages(
continue
if kind in ("tool_hint", "progress"):
structured_events = _normalize_tool_events(rec.get("tool_events"))
structured = tool_trace_lines_from_events(rec.get("tool_events"))
visible_structured_events = _filter_covered_file_edit_tool_events(messages, structured_events)
structured = tool_trace_lines_from_events(visible_structured_events)
text = rec.get("text")
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
if structured:
trace_lines = structured
elif structured_events:
trace_lines = []
elif isinstance(text, str) and text:
trace_lines = [text]
else:
trace_lines = []
if not trace_lines:
continue
segment = _ensure_activity_segment()
demote_interrupted_assistant(segment)
last = messages[-1] if messages else None
if (
last
@ -636,7 +783,7 @@ def replay_transcript_to_ui_messages(
prev_traces = list(last.get("traces") or [last.get("content")])
if structured:
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
if not added and not structured_events:
if not added and not visible_structured_events:
continue
else:
merged_traces = prev_traces + trace_lines
@ -644,8 +791,8 @@ def replay_transcript_to_ui_messages(
**last,
"traces": merged_traces,
"content": merged_traces[-1],
"toolEvents": _merge_tool_events(last.get("toolEvents"), structured_events)
if structured_events
"toolEvents": _merge_tool_events(last.get("toolEvents"), visible_structured_events)
if visible_structured_events
else last.get("toolEvents"),
"activitySegmentId": last.get("activitySegmentId") or segment,
}
@ -658,7 +805,7 @@ def replay_transcript_to_ui_messages(
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
**({"toolEvents": structured_events} if structured_events else {}),
**({"toolEvents": visible_structured_events} if visible_structured_events else {}),
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},

283
nanobot/webui/workspaces.py Normal file
View File

@ -0,0 +1,283 @@
"""Persisted WebUI project workspace state."""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScope,
WorkspaceScopeError,
build_workspace_scope,
default_workspace_scope,
validate_workspace_scope_payload,
)
WEBUI_WORKSPACE_STATE_SCHEMA_VERSION = 1
_MAX_STATE_FILE_BYTES = 128 * 1024
_DEFAULT_ACCESS_MODES = {"default", "full"}
_LEGACY_RESTRICTED_DEFAULT_ACCESS_MODE = "restricted"
_WEBUI_SCOPE_CHANNEL = "websocket"
def webui_workspace_state_path() -> Path:
return get_webui_dir() / "workspace-state.json"
def default_webui_workspace_state() -> dict[str, Any]:
return {
"schema_version": WEBUI_WORKSPACE_STATE_SCHEMA_VERSION,
"default_access_mode": "default",
"updated_at": None,
}
def normalize_webui_workspace_state(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raw = {}
state = default_webui_workspace_state()
updated_at = raw.get("updated_at")
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
default_access_mode = raw.get("default_access_mode")
if default_access_mode in _DEFAULT_ACCESS_MODES:
state["default_access_mode"] = default_access_mode
return state
def read_webui_workspace_state() -> dict[str, Any]:
path = webui_workspace_state_path()
if not path.is_file():
return default_webui_workspace_state()
try:
if path.stat().st_size > _MAX_STATE_FILE_BYTES:
logger.warning("webui workspace state too large, ignoring: {}", path)
return default_webui_workspace_state()
with open(path, encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning("read webui workspace state failed {}: {}", path, e)
return default_webui_workspace_state()
return normalize_webui_workspace_state(raw)
def write_webui_workspace_state(raw: dict[str, Any]) -> dict[str, Any]:
state = normalize_webui_workspace_state(raw)
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
encoded = json.dumps(
state,
ensure_ascii=False,
indent=2,
sort_keys=True,
).encode("utf-8")
if len(encoded) > _MAX_STATE_FILE_BYTES:
raise ValueError("workspace state is too large")
path = webui_workspace_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "wb") as f:
f.write(encoded)
f.write(b"\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
try:
dir_fd = os.open(path.parent, os.O_RDONLY)
except OSError:
return state
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
return state
def read_webui_default_access_mode() -> str:
state = read_webui_workspace_state()
mode = state.get("default_access_mode")
return mode if mode in _DEFAULT_ACCESS_MODES else "default"
def write_webui_default_access_mode(mode: str) -> bool:
if mode == _LEGACY_RESTRICTED_DEFAULT_ACCESS_MODE:
mode = "default"
if mode not in _DEFAULT_ACCESS_MODES:
raise ValueError("default access mode must be default or full")
state = read_webui_workspace_state()
changed = state.get("default_access_mode") != mode
if changed:
state["default_access_mode"] = mode
write_webui_workspace_state(state)
return changed
def default_scope_for_webui(
default_workspace: Path,
default_restrict_to_workspace: bool,
) -> WorkspaceScope:
mode = read_webui_default_access_mode()
if mode == "default":
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=_WEBUI_SCOPE_CHANNEL,
)
return build_workspace_scope(default_workspace, mode, source_channel=_WEBUI_SCOPE_CHANNEL)
def workspaces_payload(
*,
default_workspace: Path,
default_restrict_to_workspace: bool,
controls_available: bool,
) -> dict[str, Any]:
default_access_mode = read_webui_default_access_mode()
default_scope = (
default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=_WEBUI_SCOPE_CHANNEL,
)
if default_access_mode == "default"
else build_workspace_scope(default_workspace, default_access_mode, source_channel=_WEBUI_SCOPE_CHANNEL)
)
return {
"schema_version": WEBUI_WORKSPACE_STATE_SCHEMA_VERSION,
"default_access_mode": default_access_mode,
"default_scope": default_scope.payload(),
"controls": {
"can_change_project": controls_available,
"can_use_full_access": controls_available,
},
}
class WebUIWorkspaceController:
"""Own WebUI project scope persistence and validation."""
def __init__(
self,
*,
session_manager: Any | None,
default_workspace: Path,
default_restrict_to_workspace: bool,
) -> None:
self._sessions = session_manager
self._default_workspace = default_workspace
self._default_restrict_to_workspace = default_restrict_to_workspace
def default_scope(self) -> WorkspaceScope:
return default_scope_for_webui(
self._default_workspace,
self._default_restrict_to_workspace,
)
def scope_for_session_key(self, session_key: str) -> WorkspaceScope:
if self._sessions is None:
return self.default_scope()
data = self._sessions.read_session_file(session_key)
metadata = data.get("metadata", {}) if isinstance(data, dict) else {}
if not isinstance(metadata, dict) or WORKSPACE_SCOPE_METADATA_KEY not in metadata:
return self.default_scope()
try:
return validate_workspace_scope_payload(
metadata.get(WORKSPACE_SCOPE_METADATA_KEY),
default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace,
source_channel=_WEBUI_SCOPE_CHANNEL,
)
except WorkspaceScopeError:
return self.default_scope()
def payload(self, *, controls_available: bool) -> dict[str, Any]:
return workspaces_payload(
default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace,
controls_available=controls_available,
)
def scope_from_envelope(
self,
envelope: dict[str, Any],
*,
session_key: str | None,
controls_available: bool,
) -> WorkspaceScope:
raw = envelope.get(WORKSPACE_SCOPE_METADATA_KEY)
if raw is None and session_key:
scope = self.scope_for_session_key(session_key)
elif raw is None:
scope = self.default_scope()
else:
scope = validate_workspace_scope_payload(
raw,
default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace,
source_channel=_WEBUI_SCOPE_CHANNEL,
)
if not controls_available and scope.metadata() != self.default_scope().metadata():
raise WorkspaceScopeError("workspace controls are localhost-only", status=403)
return scope
def scope_for_new_chat(
self,
envelope: dict[str, Any],
*,
controls_available: bool,
) -> WorkspaceScope:
return self.scope_from_envelope(
envelope,
session_key=None,
controls_available=controls_available,
)
def scope_for_set_request(
self,
envelope: dict[str, Any],
*,
chat_id: str,
chat_running: bool,
controls_available: bool,
) -> WorkspaceScope:
if chat_running:
raise WorkspaceScopeError("chat_running", status=409)
return self.scope_from_envelope(
envelope,
session_key=f"websocket:{chat_id}",
controls_available=controls_available,
)
def scope_for_message(
self,
envelope: dict[str, Any],
*,
chat_id: str,
chat_running: bool,
controls_available: bool,
) -> WorkspaceScope:
scope = self.scope_from_envelope(
envelope,
session_key=f"websocket:{chat_id}",
controls_available=controls_available,
)
if (
WORKSPACE_SCOPE_METADATA_KEY in envelope
and chat_running
and scope.metadata() != self.scope_for_session_key(f"websocket:{chat_id}").metadata()
):
raise WorkspaceScopeError("chat_running", status=409)
return scope
def persist_scope(self, chat_id: str, scope: WorkspaceScope) -> None:
if self._sessions is not None:
session = self._sessions.get_or_create(f"websocket:{chat_id}")
session.metadata["webui"] = True
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
self._sessions.save(session)

View File

@ -0,0 +1,55 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
def _make_loop(tmp_path):
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings(max_tokens=0)
provider.estimate_prompt_tokens.return_value = (0, "test-counter")
response = LLMResponse(content="done", tool_calls=[])
provider.chat_with_retry = AsyncMock(return_value=response)
provider.chat_stream_with_retry = AsyncMock(return_value=response)
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
model="test-model",
)
loop.tools.get_definitions = MagicMock(return_value=[])
return loop
@pytest.mark.asyncio
async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
loop = _make_loop(tmp_path)
response = await loop.process_direct(
"deliver reminder",
session_key="cron:reminder-1",
channel="websocket",
chat_id="chat-1",
)
assert response is not None
assert response.content == "done"
events = []
while loop.bus.outbound_size:
events.append(await loop.bus.consume_outbound())
statuses = [
event.metadata
for event in events
if event.metadata.get("_goal_status") is True
]
assert [status["goal_status"] for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].get("started_at"), float)
assert "started_at" not in statuses[1]

View File

@ -0,0 +1,344 @@
import json
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.agent.tools.cli_apps import CliAppsTool
from nanobot.agent.tools.filesystem import ReadFileTool
from nanobot.agent.tools.image_generation import ImageGenerationError, ImageGenerationTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScopeError,
bind_workspace_scope,
default_workspace_scope,
reset_workspace_scope,
validate_workspace_scope_payload,
workspace_scope_from_metadata,
)
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig
PNG_BYTES = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
b"\x00\x00\x00\x01\x08\x04\x00\x00\x00\xb5\x1c\x0c\x02"
b"\x00\x00\x00\x0bIDATx\xdacd\xfc\xff\x1f\x00\x03\x03"
b"\x02\x00\xef\xbf\xa7\xdb\x00\x00\x00\x00IEND\xaeB`\x82"
)
def test_workspace_scope_defaults_match_legacy_config(tmp_path: Path) -> None:
unrestricted = default_workspace_scope(tmp_path, restrict_to_workspace=False)
restricted = default_workspace_scope(tmp_path, restrict_to_workspace=True)
assert unrestricted.project_path == tmp_path.resolve()
assert unrestricted.access_mode == "full"
assert unrestricted.restrict_to_workspace is False
assert restricted.access_mode == "restricted"
assert restricted.restrict_to_workspace is True
def test_workspace_scope_rejects_invalid_project_path(tmp_path: Path) -> None:
with pytest.raises(WorkspaceScopeError, match="absolute"):
validate_workspace_scope_payload(
{"project_path": "relative/project", "access_mode": "restricted"},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
with pytest.raises(WorkspaceScopeError, match="existing directory"):
validate_workspace_scope_payload(
{"project_path": str(tmp_path / "missing"), "access_mode": "restricted"},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
def test_workspace_scope_accepts_home_relative_project_path(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
home = tmp_path / "home"
project = home / "Desktop" / "Photos"
project.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
scope = validate_workspace_scope_payload(
{"project_path": "~/Desktop/Photos", "access_mode": "restricted"},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
assert scope.project_path == project.resolve()
assert scope.metadata()["project_path"] == str(project.resolve())
def test_workspace_scope_metadata_falls_back_for_stale_session(tmp_path: Path) -> None:
scope = workspace_scope_from_metadata(
{
WORKSPACE_SCOPE_METADATA_KEY: {
"project_path": str(tmp_path / "missing"),
"access_mode": "restricted",
}
},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
assert scope.project_path == tmp_path.resolve()
assert scope.access_mode == "full"
@pytest.mark.asyncio
async def test_filesystem_tool_uses_current_restricted_workspace_scope(tmp_path: Path) -> None:
project = tmp_path / "project"
project.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("nope")
inside = project / "inside.txt"
inside.write_text("ok")
tool = ReadFileTool(workspace=tmp_path, restrict_to_workspace=False)
scope = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "restricted"},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
token = bind_workspace_scope(scope)
try:
assert "ok" in await tool.execute(path="inside.txt")
assert "outside allowed directory" in await tool.execute(path=str(outside))
finally:
reset_workspace_scope(token)
@pytest.mark.asyncio
async def test_exec_tool_uses_scope_project_as_default_cwd(tmp_path: Path) -> None:
project = tmp_path / "project"
project.mkdir()
tool = ExecTool(working_dir=str(tmp_path), restrict_to_workspace=False, timeout=5)
scope = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "restricted"},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
token = bind_workspace_scope(scope)
try:
result = await tool.execute(command="printf ok > scoped-marker.txt")
finally:
reset_workspace_scope(token)
assert "Exit code: 0" in result
assert (project / "scoped-marker.txt").read_text() == "ok"
@pytest.mark.asyncio
async def test_exec_full_scope_allows_explicit_cwd_outside_project(tmp_path: Path) -> None:
project = tmp_path / "project"
outside = tmp_path / "outside"
project.mkdir()
outside.mkdir()
tool = ExecTool(working_dir=str(tmp_path), restrict_to_workspace=True, timeout=5)
scope = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "full"},
default_workspace=tmp_path,
default_restrict_to_workspace=True,
)
token = bind_workspace_scope(scope)
try:
result = await tool.execute(command="printf ok > outside-marker.txt", working_dir=str(outside))
finally:
reset_workspace_scope(token)
assert "Exit code: 0" in result
assert (outside / "outside-marker.txt").read_text() == "ok"
def test_image_reference_scope_restricted_blocks_outside_and_full_allows(tmp_path: Path) -> None:
project = tmp_path / "project"
outside = tmp_path / "outside"
project.mkdir()
outside.mkdir()
ref = outside / "ref.png"
ref.write_bytes(PNG_BYTES)
tool = ImageGenerationTool(
workspace=tmp_path,
config=ImageGenerationToolConfig(enabled=True),
provider_config=ProviderConfig(api_key="sk-test"),
)
restricted = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "restricted"},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
token = bind_workspace_scope(restricted)
try:
with pytest.raises(ImageGenerationError, match="inside the workspace"):
tool._resolve_reference_image(str(ref))
finally:
reset_workspace_scope(token)
full = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "full"},
default_workspace=tmp_path,
default_restrict_to_workspace=True,
)
token = bind_workspace_scope(full)
try:
assert tool._resolve_reference_image(str(ref)) == str(ref.resolve())
finally:
reset_workspace_scope(token)
def test_message_media_scope_restricted_blocks_outside_and_full_allows(tmp_path: Path) -> None:
project = tmp_path / "project"
outside = tmp_path / "outside"
project.mkdir()
outside.mkdir()
media = outside / "shot.png"
media.write_bytes(PNG_BYTES)
tool = MessageTool(workspace=tmp_path, restrict_to_workspace=True)
restricted = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "restricted"},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
token = bind_workspace_scope(restricted)
try:
with pytest.raises(PermissionError):
tool._resolve_media([str(media)])
finally:
reset_workspace_scope(token)
full = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "full"},
default_workspace=tmp_path,
default_restrict_to_workspace=True,
)
token = bind_workspace_scope(full)
try:
assert tool._resolve_media([str(media)]) == [str(media)]
finally:
reset_workspace_scope(token)
@pytest.mark.asyncio
async def test_cli_app_scope_controls_working_dir(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
project = tmp_path / "project"
outside = tmp_path / "outside"
data_dir = tmp_path / "data"
project.mkdir()
outside.mkdir()
registry = {
"meta": {},
"clis": [
{
"name": "demo",
"display_name": "Demo",
"version": "1.0",
"description": "demo",
"category": "test",
"install_cmd": "pip install demo",
"entry_point": "demo-cli",
}
],
}
data_dir.mkdir()
(data_dir / "harness_registry_cache.json").write_text(
json.dumps({"_cached_at": time.time(), "data": registry}),
encoding="utf-8",
)
(data_dir / "public_registry_cache.json").write_text(
json.dumps({"_cached_at": time.time(), "data": {"meta": {}, "clis": []}}),
encoding="utf-8",
)
CliAppManager(workspace=project, data_dir=data_dir)._save_installed(
{"demo": {"entry_point": "demo-cli"}}
)
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda entry: "/usr/bin/demo-cli" if entry == "demo-cli" else None,
)
seen: dict[str, str] = {}
def fake_run(argv, **kwargs):
seen["cwd"] = kwargs["cwd"]
return SimpleNamespace(returncode=0, stdout="ok", stderr="")
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
tool = CliAppsTool(
workspace=tmp_path,
restrict_to_workspace=True,
runtime=CliAppsRuntimeConfig(run_timeout=5),
)
restricted = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "restricted"},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
token = bind_workspace_scope(restricted)
try:
blocked = await tool.execute(name="demo", working_dir=str(outside))
finally:
reset_workspace_scope(token)
assert "outside the configured workspace" in blocked
full = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "full"},
default_workspace=tmp_path,
default_restrict_to_workspace=True,
)
token = bind_workspace_scope(full)
try:
result = await tool.execute(name="demo", working_dir=str(outside))
finally:
reset_workspace_scope(token)
assert "CLI app 'demo' exited 0" in result
assert seen["cwd"] == str(outside.resolve())
@pytest.mark.asyncio
async def test_spawn_tool_forwards_current_workspace_scope(tmp_path: Path) -> None:
project = tmp_path / "project"
project.mkdir()
scope = validate_workspace_scope_payload(
{"project_path": str(project), "access_mode": "restricted"},
default_workspace=tmp_path,
default_restrict_to_workspace=False,
)
class Manager:
max_concurrent_subagents = 4
def __init__(self) -> None:
self.seen = None
def get_running_count(self) -> int:
return 0
async def spawn(self, **kwargs):
self.seen = kwargs
return "spawned"
manager = Manager()
tool = SpawnTool(manager) # type: ignore[arg-type]
token = bind_workspace_scope(scope)
try:
result = await tool.execute(task="inspect")
finally:
reset_workspace_scope(token)
assert result == "spawned"
assert manager.seen["workspace_scope"] == scope

View File

@ -30,6 +30,8 @@ from nanobot.channels.websocket import (
)
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.webui.settings_api import settings_payload, update_provider_settings
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
@ -57,6 +59,14 @@ def bus() -> MagicMock:
return b
@pytest.fixture(autouse=True)
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(
"nanobot.webui.workspaces.get_webui_dir",
lambda: tmp_path / "webui",
)
async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
"""Run GET in a thread to avoid blocking the asyncio loop shared with websockets."""
return await asyncio.to_thread(
@ -64,6 +74,15 @@ async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Re
)
async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]:
"""Receive until a specific websocket event appears."""
for _ in range(10):
payload = json.loads(await client.recv())
if payload.get("event") == event:
return payload
raise AssertionError(f"websocket event {event!r} was not received")
def test_normalize_http_path_strips_trailing_slash_except_root() -> None:
assert _normalize_http_path("/chat/") == "/chat"
assert _normalize_http_path("/chat?x=1") == "/chat"
@ -81,6 +100,19 @@ def test_normalize_config_path_matches_request() -> None:
assert _normalize_config_path("/") == "/"
def test_websocket_config_accepts_absolute_unix_socket(tmp_path) -> None:
socket_path = tmp_path / "engine.sock"
cfg = WebSocketConfig(unix_socket_path=str(socket_path))
assert cfg.unix_socket_path == str(socket_path)
def test_websocket_config_rejects_relative_unix_socket() -> None:
with pytest.raises(ValueError, match="absolute path"):
WebSocketConfig(unix_socket_path="engine.sock")
def test_parse_query_extracts_token_and_client_id() -> None:
query = _parse_query("/?token=secret&client_id=u1")
assert query.get("token") == ["secret"]
@ -204,6 +236,291 @@ async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> No
assert "webui" not in msg.metadata
@pytest.mark.asyncio
async def test_webui_message_scope_inherits_persisted_session_scope(
bus: MagicMock,
tmp_path,
) -> None:
default_workspace = tmp_path / "default"
project = tmp_path / "project"
default_workspace.mkdir()
project.mkdir()
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=sessions,
workspace_path=default_workspace,
restrict_to_workspace=True,
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-scope",
"workspace_scope": {
"project_path": str(project),
"access_mode": "full",
},
},
)
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-scope", "content": "hello", "webui": True},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.metadata["workspace_scope"] == {
"project_path": str(project.resolve()),
"access_mode": "full",
}
@pytest.mark.asyncio
async def test_webui_scope_expands_home_project_path(
bus: MagicMock,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
default_workspace = tmp_path / "default"
home = tmp_path / "home"
project = home / "Desktop" / "Photos"
default_workspace.mkdir()
project.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=SessionManager(tmp_path / "sessions"),
workspace_path=default_workspace,
restrict_to_workspace=True,
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-scope",
"workspace_scope": {
"project_path": "~/Desktop/Photos",
"access_mode": "restricted",
},
},
)
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-scope", "content": "hello", "webui": True},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.metadata["workspace_scope"] == {
"project_path": str(project.resolve()),
"access_mode": "restricted",
}
@pytest.mark.asyncio
async def test_webui_scope_rejects_missing_project_path(bus: MagicMock, tmp_path) -> None:
default_workspace = tmp_path / "default"
default_workspace.mkdir()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=SessionManager(tmp_path / "sessions"),
workspace_path=default_workspace,
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-scope",
"workspace_scope": {
"project_path": str(tmp_path / "missing"),
"access_mode": "restricted",
},
},
)
conn.send.assert_awaited()
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "error"
assert payload["detail"] == "workspace_scope_rejected"
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path) -> None:
default_workspace = tmp_path / "default"
project = tmp_path / "project"
other = tmp_path / "other"
default_workspace.mkdir()
project.mkdir()
other.mkdir()
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=sessions,
workspace_path=default_workspace,
restrict_to_workspace=True,
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-running",
"workspace_scope": {
"project_path": str(project),
"access_mode": "restricted",
},
},
)
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-running"] = 123.0
try:
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "message",
"chat_id": "chat-running",
"content": "hello",
"webui": True,
"workspace_scope": {
"project_path": str(other),
"access_mode": "full",
},
},
)
finally:
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "error"
assert payload["detail"] == "workspace_scope_rejected"
assert payload["reason"] == "chat_running"
assert payload["chat_id"] == "chat-running"
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_webui_set_workspace_scope_rejects_running_chat(bus: MagicMock, tmp_path) -> None:
default_workspace = tmp_path / "default"
project = tmp_path / "project"
other = tmp_path / "other"
default_workspace.mkdir()
project.mkdir()
other.mkdir()
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=sessions,
workspace_path=default_workspace,
restrict_to_workspace=True,
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-running",
"workspace_scope": {
"project_path": str(project),
"access_mode": "restricted",
},
},
)
conn.send.reset_mock()
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-running"] = 123.0
try:
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-running",
"workspace_scope": {
"project_path": str(other),
"access_mode": "full",
},
},
)
finally:
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "error"
assert payload["detail"] == "workspace_scope_rejected"
assert payload["reason"] == "chat_running"
assert payload["chat_id"] == "chat-running"
saved = sessions.read_session_file("websocket:chat-running")
assert saved["metadata"]["workspace_scope"] == {
"project_path": str(project.resolve()),
"access_mode": "restricted",
}
@pytest.mark.asyncio
async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp_path) -> None:
default_workspace = tmp_path / "default"
project = tmp_path / "project"
default_workspace.mkdir()
project.mkdir()
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=sessions,
workspace_path=default_workspace,
restrict_to_workspace=True,
)
conn = AsyncMock()
conn.remote_address = ("203.0.113.8", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-remote",
"workspace_scope": {
"project_path": str(project),
"access_mode": "full",
},
},
)
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "error"
assert payload["detail"] == "workspace_scope_rejected"
assert payload["reason"] == "workspace controls are localhost-only"
assert payload["chat_id"] == "chat-remote"
assert sessions.read_session_file("websocket:chat-remote") is None
@pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock()
@ -1067,6 +1384,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
config.tools.web.search.api_key = "brave-secret"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
"nanobot.webui.settings_api._oauth_provider_status",
lambda _spec: {
"configured": False,
"account": None,
"expires_at": None,
"login_supported": True,
},
)
channel = _ch(bus, port=port)
channel._api_tokens["tok"] = time.monotonic() + 300
@ -1103,6 +1429,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert providers["atomic_chat"]["configured"] is False
assert providers["atomic_chat"]["api_key_required"] is False
assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1"
assert providers["openai_codex"]["auth_type"] == "oauth"
assert providers["openai_codex"]["configured"] is False
assert body["agent"]["has_api_key"] is True
assert body["web_search"]["provider"] == "brave"
assert body["web_search"]["api_key_hint"] == "brav••••cret"
@ -1121,18 +1449,29 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
}
assert image_providers["openrouter"]["label"] == "OpenRouter"
assert image_providers["openrouter"]["configured"] is False
assert image_providers["openai_codex"]["configured"] is True
assert image_providers["openai_codex"]["auth_type"] == "oauth"
assert image_providers["openai_codex"]["configured"] is False
assert image_providers["gemini"]["label"] == "Gemini"
assert body["runtime"]["config_path"] == str(config_path)
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
assert workspace_path.endswith("/.nanobot/workspace")
assert body["runtime"]["gateway_port"] == 18790
assert body["advanced"]["exec_enabled"] is True
assert body["advanced"]["webui_allow_local_service_access"] is True
assert body["advanced"]["webui_default_access_mode"] == "default"
assert body["advanced"]["private_service_protection_enabled"] is True
assert body["advanced"]["mcp_server_count"] == 0
assert body["restart_required_sections"] == []
assert "secret-key" not in settings.text
assert "brave-secret" not in settings.text
unknown_api = await _http_get(
f"http://127.0.0.1:{port}/api/settings/model-configurations/missing",
headers={"Authorization": "Bearer tok"},
)
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"
@ -1204,6 +1543,21 @@ 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"},
)
assert updated_preset.status_code == 200
updated_preset_body = updated_preset.json()
assert updated_preset_body["agent"]["model_preset"] == "fast-writing"
assert updated_preset_body["agent"]["model"] == "openai/gpt-5.5"
updated_presets = {
preset["name"]: preset for preset in updated_preset_body["model_presets"]
}
assert updated_presets["fast-writing"]["label"] == "Codex"
duplicate_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create"
@ -1222,13 +1576,26 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert search_updated.status_code == 200
search_body = search_updated.json()
assert search_body["requires_restart"] is True
assert search_body["restart_required_sections"] == ["runtime", "web"]
assert search_body["restart_required_sections"] == ["browser", "runtime"]
assert search_body["web_search"]["provider"] == "searxng"
assert search_body["web_search"]["api_key_hint"] is None
assert search_body["web_search"]["base_url"] == "https://search.example.com"
assert search_body["web_search"]["max_results"] == 8
assert search_body["web"]["fetch"]["use_jina_reader"] is False
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"},
)
assert network_safety_updated.status_code == 200
network_safety_body = network_safety_updated.json()
assert network_safety_body["requires_restart"] is True
assert network_safety_body["restart_required_sections"] == ["browser", "runtime"]
assert network_safety_body["advanced"]["webui_allow_local_service_access"] is False
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"
@ -1240,7 +1607,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert image_updated.status_code == 200
image_body = image_updated.json()
assert image_body["requires_restart"] is True
assert image_body["restart_required_sections"] == ["image", "runtime", "web"]
assert image_body["restart_required_sections"] == ["browser", "image", "runtime"]
assert image_body["image_generation"]["enabled"] is True
assert image_body["image_generation"]["model"] == "openai/gpt-image-1"
assert image_body["image_generation"]["default_aspect_ratio"] == "16:9"
@ -1256,9 +1623,9 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert image_provider_updated.status_code == 200
assert image_provider_updated.json()["requires_restart"] is True
assert image_provider_updated.json()["restart_required_sections"] == [
"browser",
"image",
"runtime",
"web",
]
assert "sk-or-next" not in image_provider_updated.text
@ -1280,8 +1647,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat"
assert saved.agents.defaults.model_preset == "fast-writing"
assert saved.model_presets["fast-writing"].label == "Fast writing"
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
assert saved.model_presets["fast-writing"].label == "Codex"
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
assert saved.model_presets["fast-writing"].provider == "openai"
assert saved.agents.defaults.timezone == "Asia/Shanghai"
assert saved.agents.defaults.bot_name == "Nano"
@ -1296,6 +1663,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert saved.tools.web.search.max_results == 8
assert saved.tools.web.search.timeout == 45
assert saved.tools.web.fetch.use_jina_reader is False
assert saved.tools.webui_allow_local_service_access is False
assert saved.tools.image_generation.enabled is True
assert saved.tools.image_generation.provider == "openrouter"
assert saved.tools.image_generation.model == "openai/gpt-image-1"
@ -1335,6 +1703,43 @@ async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> No
await server_task
@pytest.mark.asyncio
async def test_bootstrap_exposes_native_surface(bus: MagicMock) -> None:
port = 29893
channel = WebSocketChannel(
{
"enabled": True,
"allowFrom": ["*"],
"host": "127.0.0.1",
"port": port,
"path": "/ws",
"tokenIssueSecret": "native-secret",
"websocketRequiresToken": True,
},
bus,
runtime_surface="native",
runtime_capabilities_overrides={"can_pick_folder": True},
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
response = await _http_get(
f"http://127.0.0.1:{port}/webui/bootstrap",
headers={"X-Nanobot-Auth": "native-secret"},
)
assert response.status_code == 200
body = response.json()
assert body["runtime_surface"] == "native"
assert body["runtime_capabilities"]["can_pick_folder"] is True
assert body["runtime_capabilities"]["can_restart_engine"] is True
assert body["token"].startswith("nbwt_")
finally:
await channel.stop()
await server_task
def test_settings_payload_normalizes_camel_case_provider(
bus: MagicMock,
monkeypatch,
@ -1365,6 +1770,44 @@ def test_settings_payload_exposes_api_type_only_for_openai(monkeypatch, tmp_path
assert "api_type" not in providers["custom"]
def test_settings_payload_reports_workspace_sandbox(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.restrict_to_workspace = True
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setenv("NANOBOT_SANDBOX_ENFORCED", "macos_app_sandbox")
body = settings_payload()
sandbox = body["advanced"]["workspace_sandbox"]
assert sandbox["restrict_to_workspace"] is True
assert sandbox["level"] == "system"
assert sandbox["enforced"] is True
assert sandbox["provider"] == "macos_app_sandbox"
assert sandbox["provider_label"] == "macOS App Sandbox"
def test_settings_payload_includes_native_runtime_surface(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
body = settings_payload(
surface="native",
runtime_capability_overrides={"can_open_logs": True},
restart_required_sections=["runtime"],
)
assert body["surface"] == "native"
assert body["runtime_surface"] == "native"
assert body["runtime_capabilities"]["can_open_logs"] is True
assert body["runtime_capabilities"]["can_restart_engine"] is True
assert body["restart_behavior_by_section"]["runtime"] == "engineRestart"
assert body["requires_restart"] is True
assert body["apply_state"] == {"status": "pending", "sections": ["runtime"]}
def test_update_provider_settings_ignores_api_type_for_non_openai(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@ -1671,6 +2114,8 @@ async def test_multiplex_new_chat_roundtrip(bus: MagicMock) -> None:
OutboundMessage(channel="websocket", chat_id=new_chat, content="ok")
)
reply = json.loads(await client.recv())
if reply["event"] == "session_updated":
reply = json.loads(await client.recv())
assert reply["event"] == "message"
assert reply["chat_id"] == new_chat
assert reply["text"] == "ok"
@ -1691,16 +2136,16 @@ async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
await client.recv() # ready
await client.send(json.dumps({"type": "new_chat"}))
chat_a = json.loads(await client.recv())["chat_id"]
chat_a = (await _recv_ws_event(client, "attached"))["chat_id"]
await client.send(json.dumps({"type": "new_chat"}))
chat_b = json.loads(await client.recv())["chat_id"]
chat_b = (await _recv_ws_event(client, "attached"))["chat_id"]
assert chat_a != chat_b
# Push A → client sees A only (FIFO over the single WS).
await channel.send(
OutboundMessage(channel="websocket", chat_id=chat_a, content="for-A")
)
msg_a = json.loads(await client.recv())
msg_a = await _recv_ws_event(client, "message")
assert msg_a["chat_id"] == chat_a
assert msg_a["text"] == "for-A"
@ -1708,7 +2153,7 @@ async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
await channel.send(
OutboundMessage(channel="websocket", chat_id=chat_b, content="for-B")
)
msg_b = json.loads(await client.recv())
msg_b = await _recv_ws_event(client, "message")
assert msg_b["chat_id"] == chat_b
assert msg_b["text"] == "for-B"
finally:
@ -1830,6 +2275,9 @@ def test_sessions_list_includes_active_run_started_at() -> None:
assert resp.status_code == 200
body = json.loads(resp.body.decode())
workspace_scope = body["sessions"][0].pop("workspace_scope")
assert workspace_scope["project_path"] == str(channel._workspace_path)
assert workspace_scope["access_mode"] in {"restricted", "full"}
assert body["sessions"] == [
{
"key": "websocket:chat-1",

View File

@ -95,6 +95,7 @@ async def test_bootstrap_returns_token_for_localhost(
body = resp.json()
assert body["token"].startswith("nbwt_")
assert body["ws_path"] == "/"
assert body["ws_url"] == "ws://127.0.0.1:29901/"
assert body["expires_in"] > 0
assert isinstance(body.get("model_name"), str)
finally:
@ -734,6 +735,17 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
assert body["token"].startswith("nbwt_")
def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1", port=29931)
resp = channel._handle_bootstrap(
_LOCAL,
_FakeReq({"Host": "nanobot.example", "X-Forwarded-Proto": "https"}),
)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["ws_url"] == "wss://nanobot.example/"
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1")
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)

View File

@ -1521,6 +1521,35 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path)
assert "port 18792" in result.stdout
def test_configure_desktop_gateway_forces_local_websocket_only() -> None:
from nanobot.cli.commands import _configure_desktop_gateway
config = Config()
config.channels.__pydantic_extra__ = {
"telegram": {"enabled": True, "token": "x"},
"websocket": {"enabled": False, "port": 8765},
}
_configure_desktop_gateway(
config,
webui_port=29888,
webui_socket="/tmp/nanobot-test.sock",
token_issue_secret="secret",
)
extras = config.channels.__pydantic_extra__ or {}
assert config.gateway.host == "127.0.0.1"
assert config.gateway.port == 29888
assert config.gateway.heartbeat.enabled is False
assert extras["telegram"]["enabled"] is False
assert extras["websocket"]["enabled"] is True
assert extras["websocket"]["host"] == "127.0.0.1"
assert extras["websocket"]["port"] == 29888
assert extras["websocket"]["unix_socket_path"] == "/tmp/nanobot-test.sock"
assert extras["websocket"]["token_issue_secret"] == "secret"
assert extras["websocket"]["websocket_requires_token"] is True
def test_gateway_health_endpoint_binds_and_serves_expected_responses(
monkeypatch, tmp_path: Path
) -> None:

View File

@ -143,6 +143,8 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
assert apps["gimp"]["install_supported"] is True
assert apps["gimp"]["source"] == "harness+public"
assert apps["gimp"]["description"] == "Public duplicate entry"
assert apps["feishu"]["description"] == "Lark CLI"
assert apps["feishu"]["manifest"]["description"] == "Lark CLI"
assert apps["clibrowser"]["install_supported"] is False
assert apps["jimeng"]["install_supported"] is False
assert apps["suno"]["install_supported"] is True

View File

@ -223,3 +223,24 @@ def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
ok, _ = validate_url_target("http://ts.local/api")
assert not ok
def test_load_config_defaults_local_service_access_to_enabled(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
config = load_config(config_path)
assert config.tools.webui_allow_local_service_access is True
def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"tools": {"allowLocalPreviewAccess": False}}),
encoding="utf-8",
)
config = load_config(config_path)
assert config.tools.webui_allow_local_service_access is False

View File

@ -12,6 +12,7 @@ import nanobot.providers.base as provider_base
from nanobot.providers.openai_codex_provider import (
OpenAICodexProvider,
_codex_error_response,
_build_reasoning_options,
_CodexHTTPError,
_friendly_error,
_request_codex,
@ -128,11 +129,12 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
body,
verify,
on_content_delta=None,
on_thinking_delta=None,
on_tool_call_delta=None,
):
_ = on_tool_call_delta
_ = on_thinking_delta, on_tool_call_delta
bodies.append(body)
return "ok", [], "stop"
return "ok", [], "stop", None
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
@ -257,7 +259,7 @@ async def test_codex_retry_uses_structured_timeout_metadata(monkeypatch) -> None
calls += 1
if calls == 1:
raise httpx.ReadTimeout("")
return "ok", [], "stop"
return "ok", [], "stop", None
async def fake_sleep(delay: float) -> None:
delays.append(delay)
@ -397,3 +399,56 @@ def test_codex_429_classification_uses_raw_error_semantics(
error_type, error_code = provider_base.LLMProvider._extract_error_type_code(raw)
assert _should_retry_status(429, error_type, error_code, raw) is expected_retry
def test_codex_reasoning_options_request_summary_without_forcing_effort() -> None:
assert _build_reasoning_options(None) == {"summary": "auto"}
assert _build_reasoning_options("high") == {"summary": "auto", "effort": "high"}
assert _build_reasoning_options("none") == {"effort": "none"}
@pytest.mark.asyncio
async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
monkeypatch.setattr(
"nanobot.providers.openai_codex_provider.get_codex_token",
lambda: SimpleNamespace(account_id="acct", access="token"),
)
async def fake_request(
url,
headers,
body,
verify,
on_content_delta=None,
on_thinking_delta=None,
on_tool_call_delta=None,
):
_ = url, headers, verify, on_tool_call_delta
assert body["reasoning"] == {"summary": "auto", "effort": "medium"}
if on_content_delta:
await on_content_delta("answer")
if on_thinking_delta:
await on_thinking_delta("summary")
return "answer", [], "stop", "summary"
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
provider = OpenAICodexProvider()
content_deltas: list[str] = []
thinking_deltas: list[str] = []
response = await provider.chat_stream(
[{"role": "user", "content": "hi"}],
reasoning_effort="medium",
on_content_delta=lambda delta: _append(content_deltas, delta),
on_thinking_delta=lambda delta: _append(thinking_deltas, delta),
)
assert content_deltas == ["answer"]
assert thinking_deltas == ["summary"]
assert response.content == "answer"
assert response.reasoning_content == "summary"
async def _append(target: list[str], value: str) -> None:
target.append(value)

View File

@ -1,10 +1,10 @@
"""Tests for the shared openai_responses converters and parsers."""
import json
from unittest.mock import MagicMock, patch
import pytest
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses.converters import (
convert_messages,
convert_tools,
@ -13,6 +13,8 @@ from nanobot.providers.openai_responses.converters import (
)
from nanobot.providers.openai_responses.parsing import (
consume_sdk_stream,
consume_sse,
consume_sse_with_reasoning,
map_finish_reason,
parse_response_output,
)
@ -434,6 +436,166 @@ class TestParseResponseOutput:
assert result.usage["total_tokens"] == 150
# ======================================================================
# parsing - consume_sse
# ======================================================================
class _SseResponse:
def __init__(self, events: list[dict]):
self._events = events
async def aiter_lines(self):
for event in self._events:
yield f"data: {json.dumps(event)}"
yield ""
class TestConsumeSse:
@pytest.mark.asyncio
async def test_legacy_consume_sse_returns_three_tuple(self):
response = _SseResponse([
{"type": "response.output_text.delta", "delta": "hi"},
{"type": "response.completed", "response": {"status": "completed"}},
])
content, tool_calls, finish_reason = await consume_sse(response)
assert content == "hi"
assert tool_calls == []
assert finish_reason == "stop"
@pytest.mark.asyncio
async def test_reasoning_summary_delta_extracted(self):
response = _SseResponse([
{"type": "response.reasoning_summary_text.delta", "delta": "thinking "},
{"type": "response.reasoning_summary_text.delta", "delta": "briefly"},
{"type": "response.output_text.delta", "delta": "answer"},
{"type": "response.completed", "response": {"status": "completed"}},
])
deltas: list[str] = []
async def on_reasoning(delta: str) -> None:
deltas.append(delta)
content, tool_calls, finish_reason, reasoning = await consume_sse_with_reasoning(
response,
on_reasoning_delta=on_reasoning,
)
assert content == "answer"
assert tool_calls == []
assert finish_reason == "stop"
assert reasoning == "thinking briefly"
assert deltas == ["thinking ", "briefly"]
@pytest.mark.asyncio
async def test_reasoning_summary_from_completed_response(self):
response = _SseResponse([
{
"type": "response.completed",
"response": {
"status": "completed",
"output": [
{"type": "reasoning", "summary": [
{"type": "summary_text", "text": "cached "},
{"type": "summary_text", "text": "summary"},
]},
],
},
},
])
_, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "cached summary"
@pytest.mark.asyncio
async def test_reasoning_summary_from_done_item(self):
response = _SseResponse([
{
"type": "response.output_item.done",
"item": {
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "done summary"}],
},
},
{"type": "response.completed", "response": {"status": "completed", "output": []}},
])
deltas: list[str] = []
async def on_reasoning(delta: str) -> None:
deltas.append(delta)
_, _, _, reasoning = await consume_sse_with_reasoning(
response,
on_reasoning_delta=on_reasoning,
)
assert reasoning == "done summary"
assert deltas == ["done summary"]
@pytest.mark.asyncio
async def test_reasoning_summary_part_done_extracted(self):
response = _SseResponse([
{
"type": "response.reasoning_summary_part.done",
"part": {"type": "summary_text", "text": "part summary"},
},
{"type": "response.completed", "response": {"status": "completed"}},
])
_, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "part summary"
@pytest.mark.asyncio
async def test_tool_call_done_arguments_callback(self):
response = _SseResponse([
{
"type": "response.output_item.added",
"item": {
"type": "function_call",
"call_id": "c1",
"id": "fc1",
"name": "write_file",
"arguments": "",
},
},
{
"type": "response.function_call_arguments.done",
"call_id": "c1",
"arguments": '{"path":"a.txt","content":"hello\\n"}',
},
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"call_id": "c1",
"id": "fc1",
"name": "write_file",
"arguments": '{"path":"a.txt","content":"hello\\n"}',
},
},
{"type": "response.completed", "response": {"status": "completed"}},
])
deltas: list[dict] = []
async def cb(delta: dict) -> None:
deltas.append(delta)
await consume_sse_with_reasoning(response, on_tool_call_delta=cb)
assert deltas == [
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
{
"call_id": "c1",
"name": "write_file",
"arguments": '{"path":"a.txt","content":"hello\\n"}',
},
]
# ======================================================================
# parsing - consume_sdk_stream
# ======================================================================
@ -544,6 +706,46 @@ class TestConsumeSdkStream:
"arguments_delta": '{"path":"a.txt","content":"',
},
{"call_id": "c1", "name": "write_file", "arguments_delta": "hello\\n"},
{
"call_id": "c1",
"name": "write_file",
"arguments": '{"path":"a.txt","content":"hello\\n"}',
},
]
@pytest.mark.asyncio
async def test_tool_call_done_item_arguments_callback_without_delta(self):
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
item_added.name = "write_file"
ev1 = MagicMock(type="response.output_item.added", item=item_added)
item_done = MagicMock(
type="function_call",
call_id="c1",
id="fc1",
arguments='{"path":"late.txt","content":"done\\n"}',
)
item_done.name = "write_file"
ev2 = MagicMock(type="response.output_item.done", item=item_done)
resp_obj = MagicMock(status="completed", usage=None, output=[])
ev3 = MagicMock(type="response.completed", response=resp_obj)
deltas: list[dict] = []
async def cb(delta: dict) -> None:
deltas.append(delta)
async def stream():
for e in [ev1, ev2, ev3]:
yield e
await consume_sdk_stream(stream(), on_tool_call_delta=cb)
assert deltas == [
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
{
"call_id": "c1",
"name": "write_file",
"arguments": '{"path":"late.txt","content":"done\\n"}',
},
]
@pytest.mark.asyncio

View File

@ -49,7 +49,7 @@ def test_rejects_missing_domain():
])
def test_blocks_private_ipv4(ip: str, label: str):
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("evil.com", [ip])):
ok, err = validate_url_target(f"http://evil.com/path")
ok, err = validate_url_target("http://evil.com/path")
assert not ok, f"Should block {label} ({ip})"
assert "private" in err.lower() or "blocked" in err.lower()
@ -92,6 +92,21 @@ def test_detects_wget_localhost():
assert contains_internal_url("wget http://localhost:8080/secret")
def test_loopback_exception_allows_literal_localhost_only():
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("localhost", ["127.0.0.1"])):
assert not contains_internal_url("curl http://localhost:8765/", allow_loopback=True)
def test_loopback_exception_rejects_public_name_resolving_to_loopback():
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["127.0.0.1"])):
assert contains_internal_url("curl http://example.com:8765/", allow_loopback=True)
def test_loopback_exception_rejects_metadata():
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("169.254.169.254", ["169.254.169.254"])):
assert contains_internal_url("curl http://169.254.169.254/latest/meta-data/", allow_loopback=True)
def test_allows_normal_curl():
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["93.184.216.34"])):
assert not contains_internal_url("curl https://example.com/api/data")

View File

@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
import pytest
from nanobot.security.workspace_policy import (
WorkspaceBoundaryError,
is_path_within,
resolve_allowed_path,
)
def test_resolve_allowed_path_accepts_workspace_relative_path(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
target = workspace / "src" / "main.py"
target.parent.mkdir()
target.write_text("print('ok')", encoding="utf-8")
resolved = resolve_allowed_path("src/main.py", workspace=workspace, allowed_root=workspace)
assert resolved == target.resolve()
def test_resolve_allowed_path_blocks_parent_traversal(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "secret.txt"
outside.write_text("secret", encoding="utf-8")
with pytest.raises(WorkspaceBoundaryError, match="outside allowed directory"):
resolve_allowed_path("../secret.txt", workspace=workspace, allowed_root=workspace)
def test_resolve_allowed_path_blocks_symlink_escape(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
secret = outside / "secret.txt"
secret.write_text("secret", encoding="utf-8")
link = workspace / "linked-secret.txt"
try:
link.symlink_to(secret)
except OSError as exc:
pytest.skip(f"symlink creation is unavailable: {exc}")
assert not is_path_within(link, workspace)
with pytest.raises(WorkspaceBoundaryError):
resolve_allowed_path("linked-secret.txt", workspace=workspace, allowed_root=workspace)
def test_resolve_allowed_path_allows_extra_root(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
media = tmp_path / "media"
media.mkdir()
image = media / "image.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
resolved = resolve_allowed_path(
image,
workspace=workspace,
allowed_root=workspace,
extra_allowed_roots=[media],
)
assert resolved == image.resolve()

View File

@ -0,0 +1,68 @@
from pathlib import Path
from nanobot.security.workspace_access import workspace_sandbox_status
def test_workspace_sandbox_disabled(tmp_path: Path) -> None:
status = workspace_sandbox_status(
restrict_to_workspace=False,
workspace=tmp_path,
environ={},
)
assert status.level == "off"
assert status.enforced is False
assert status.provider == "none"
assert status.as_dict()["workspace_root"] == str(tmp_path.resolve())
def test_workspace_sandbox_application_guard(tmp_path: Path) -> None:
status = workspace_sandbox_status(
restrict_to_workspace=True,
workspace=tmp_path,
environ={},
)
assert status.level == "application"
assert status.enforced is False
assert status.provider == "none"
assert "application-level" in status.summary
def test_workspace_sandbox_system_provider_from_compact_env(tmp_path: Path) -> None:
status = workspace_sandbox_status(
restrict_to_workspace=True,
workspace=tmp_path,
environ={"NANOBOT_SANDBOX_ENFORCED": "macos_app_sandbox"},
)
assert status.level == "system"
assert status.enforced is True
assert status.provider == "macos_app_sandbox"
assert status.provider_label == "macOS App Sandbox"
def test_workspace_sandbox_system_provider_from_boolean_env(tmp_path: Path) -> None:
status = workspace_sandbox_status(
restrict_to_workspace=True,
workspace=tmp_path,
environ={
"NANOBOT_WORKSPACE_SANDBOX_ENFORCED": "true",
"NANOBOT_WORKSPACE_SANDBOX_PROVIDER": "macOS App Sandbox",
},
)
assert status.level == "system"
assert status.enforced is True
assert status.provider == "macos_app_sandbox"
def test_workspace_sandbox_false_env_does_not_enforce(tmp_path: Path) -> None:
status = workspace_sandbox_status(
restrict_to_workspace=True,
workspace=tmp_path,
environ={"NANOBOT_WORKSPACE_SANDBOX_ENFORCED": "false"},
)
assert status.level == "application"
assert status.enforced is False

View File

@ -65,6 +65,7 @@ async def test_spawn_tool_keeps_task_local_context() -> None:
session_key: str,
origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope=None,
) -> str:
seen.append((origin_channel, origin_chat_id, session_key))
return f"{origin_channel}:{origin_chat_id}:{task}"
@ -178,6 +179,7 @@ async def test_spawn_tool_basic_set_context_and_execute() -> None:
session_key,
origin_message_id=None,
temperature=None,
workspace_scope=None,
):
seen.append((origin_channel, origin_chat_id, session_key))
return f"ok: {task}"
@ -211,6 +213,7 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
session_key,
origin_message_id=None,
temperature=None,
workspace_scope=None,
):
seen.append((origin_channel, origin_chat_id, session_key))
return "ok"

View File

@ -89,7 +89,7 @@ def test_apply_patch_edits_add_to_existing_file(tmp_path):
)
def test_apply_patch_edits_delete(tmp_path):
def test_apply_patch_rejects_delete_action(tmp_path):
target = tmp_path / "utils.py"
target.write_text("def unused():\n pass\ndef used():\n return 1\n")
tool = ApplyPatchTool(workspace=tmp_path)
@ -106,51 +106,8 @@ def test_apply_patch_edits_delete(tmp_path):
)
)
assert "update utils.py" in result
assert target.read_text() == "def used():\n return 1\n"
def test_apply_patch_edits_delete_entire_file(tmp_path):
target = tmp_path / "obsolete.txt"
target.write_text("remove me\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "obsolete.txt",
"action": "delete",
"old_text": "remove me\n",
}
]
)
)
assert "delete obsolete.txt" in result
assert not target.exists()
def test_apply_patch_edits_delete_substring_with_surrounding_whitespace(tmp_path):
target = tmp_path / "keep_whitespace.txt"
target.write_text(" token \n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "keep_whitespace.txt",
"action": "delete",
"old_text": "token",
}
]
)
)
assert "update keep_whitespace.txt" in result
assert target.exists()
assert target.read_text() == " \n"
assert "unknown action: delete" in result
assert target.read_text() == "def unused():\n pass\ndef used():\n return 1\n"
def test_apply_patch_edits_batch_multiple_files(tmp_path):
@ -319,8 +276,9 @@ def test_apply_patch_edits_rolls_back_when_late_operation_fails(tmp_path):
},
{
"path": "missing.txt",
"action": "delete",
"action": "replace",
"old_text": "remove me",
"new_text": "removed",
},
]
)

View File

@ -9,6 +9,7 @@ from unittest.mock import patch
import pytest
from nanobot.agent.tools.shell import ExecTool
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope
def _fake_resolve_private(hostname, port, family=0, type_=0):
@ -42,6 +43,70 @@ async def test_exec_blocks_wget_localhost():
assert "Error" in result
def test_exec_full_workspace_scope_allows_loopback(tmp_path):
tool = ExecTool(working_dir=str(tmp_path))
scope = build_workspace_scope(tmp_path, "full", source_channel="websocket")
token = bind_workspace_scope(scope)
try:
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
finally:
reset_workspace_scope(token)
assert error is None
def test_exec_core_full_workspace_scope_blocks_loopback(tmp_path):
tool = ExecTool(working_dir=str(tmp_path))
scope = build_workspace_scope(tmp_path, "full")
token = bind_workspace_scope(scope)
try:
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
finally:
reset_workspace_scope(token)
assert error is not None
assert "internal/private" in error
def test_exec_full_workspace_scope_blocks_loopback_when_local_service_disabled(tmp_path):
tool = ExecTool(working_dir=str(tmp_path), webui_allow_local_service_access=False)
scope = build_workspace_scope(tmp_path, "full", source_channel="websocket")
token = bind_workspace_scope(scope)
try:
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
finally:
reset_workspace_scope(token)
assert error is not None
assert "internal/private" in error
def test_exec_restricted_workspace_scope_blocks_loopback(tmp_path):
tool = ExecTool(working_dir=str(tmp_path))
scope = build_workspace_scope(tmp_path, "restricted", source_channel="websocket")
token = bind_workspace_scope(scope)
try:
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
finally:
reset_workspace_scope(token)
assert error is not None
assert "internal/private" in error
def test_exec_full_workspace_scope_still_blocks_metadata(tmp_path):
tool = ExecTool(working_dir=str(tmp_path))
scope = build_workspace_scope(tmp_path, "full", source_channel="websocket")
token = bind_workspace_scope(scope)
try:
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_private):
error = tool._guard_command("curl http://169.254.169.254/latest/meta-data/", str(tmp_path))
finally:
reset_workspace_scope(token)
assert error is not None
assert "internal/private" in error
@pytest.mark.asyncio
async def test_exec_allows_normal_commands():
tool = ExecTool(timeout=5)

View File

@ -5,8 +5,6 @@ from dataclasses import fields
from typing import Any
from unittest.mock import MagicMock
import pytest
from nanobot.agent.tools.base import Tool
@ -115,6 +113,31 @@ def test_discover_skips_private_classes():
assert not cls.__name__.startswith("_")
def test_loader_registers_exec_with_real_tools_config(tmp_path):
"""Real config objects catch bad ctx.config attribute paths that mocks hide."""
from types import SimpleNamespace
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import ToolsConfig
ctx = ToolContext(
config=ToolsConfig(),
workspace=str(tmp_path),
bus=None,
subagent_manager=SimpleNamespace(
get_running_count=lambda: 0,
max_concurrent_subagents=4,
),
cron_service=None,
timezone="UTC",
)
registry = ToolRegistry()
registered = ToolLoader().load(ctx, registry)
assert "exec" in registered
assert registry.has("exec")
# --- Task 4: _FsTool.create() ---
from pathlib import Path

View File

@ -12,6 +12,7 @@ import pytest
from nanobot.agent.tools import web as web_module
from nanobot.agent.tools.web import WebFetchTool
from nanobot.config.schema import WebFetchConfig
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope
_REAL_GETADDRINFO = socket.getaddrinfo
@ -45,6 +46,24 @@ async def test_web_fetch_blocks_localhost():
assert "error" in data
@pytest.mark.asyncio
async def test_web_fetch_blocks_localhost_even_in_full_workspace_scope(tmp_path):
tool = WebFetchTool()
scope = build_workspace_scope(tmp_path, "full")
def _resolve_localhost(hostname, port, family=0, type_=0):
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))]
token = bind_workspace_scope(scope)
try:
with patch("nanobot.security.network.socket.getaddrinfo", _resolve_localhost):
result = await tool.execute(url="http://localhost/admin")
finally:
reset_workspace_scope(token)
data = json.loads(result)
assert "error" in data
@pytest.mark.asyncio
async def test_web_fetch_result_contains_untrusted_flag():
"""When fetch succeeds, result JSON must include untrusted=True and the banner."""

View File

@ -86,13 +86,10 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
(tmp_path / "src").mkdir()
existing = tmp_path / "src" / "existing.py"
existing.write_text("old\nkeep\n", encoding="utf-8")
delete_me = tmp_path / "src" / "delete_me.py"
delete_me.write_text("gone\n", encoding="utf-8")
edits = [
{"path": "src/new.py", "action": "add", "new_text": "fresh"},
{"path": "src/existing.py", "action": "replace", "old_text": "old", "new_text": "new"},
{"path": "src/delete_me.py", "action": "delete", "old_text": "gone\n"},
]
trackers = prepare_file_edit_trackers(
@ -106,18 +103,15 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
assert [tracker.display_path for tracker in trackers] == [
"src/new.py",
"src/existing.py",
"src/delete_me.py",
]
(tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
existing.write_text("new\nkeep\n", encoding="utf-8")
delete_me.unlink()
events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers]
by_path = {event["path"]: event for event in events}
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
assert (by_path["src/delete_me.py"]["added"], by_path["src/delete_me.py"]["deleted"]) == (0, 1)
def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None:

View File

@ -27,6 +27,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
"pinned_keys": ["websocket:a", "websocket:a", "", 123],
"archived_keys": ["websocket:b"],
"title_overrides": {"websocket:a": " Release notes ", "bad": ""},
"project_name_overrides": {"/repo": " Core ", "bad": ""},
"tags_by_key": {"websocket:a": ["work", "work", ""]},
"collapsed_groups": {"Earlier": 1},
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
@ -41,6 +42,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
assert state["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"]
assert state["title_overrides"] == {"websocket:a": "Release notes"}
assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["tags_by_key"] == {"websocket:a": ["work"]}
assert state["collapsed_groups"] == {"Earlier": True}
assert state["view"] == {
@ -60,6 +62,7 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
"pinned_keys": ["websocket:a"],
"archived_keys": ["websocket:b"],
"title_overrides": {"websocket:a": "Release"},
"project_name_overrides": {"/repo": "Core"},
"view": {"density": "compact", "show_previews": True},
}
)
@ -67,6 +70,7 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
assert state["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"]
assert state["title_overrides"] == {"websocket:a": "Release"}
assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["view"]["density"] == "compact"
assert state["view"]["show_previews"] is True
assert webui_sidebar_state_path().is_file()

View File

@ -122,6 +122,103 @@ def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) ->
assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
def test_replay_file_edit_absorbs_matching_write_tool_event() -> None:
msgs = replay_transcript_to_ui_messages([
{
"event": "message",
"chat_id": "t-file",
"text": 'write_file({"path":"foo.txt"})',
"kind": "tool_hint",
"tool_events": [
{
"phase": "start",
"call_id": "call-write",
"name": "write_file",
"arguments": {"path": "foo.txt", "content": "hello\n"},
},
],
},
{
"event": "file_edit",
"chat_id": "t-file",
"edits": [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "start",
"added": 1,
"deleted": 0,
"approximate": True,
"status": "editing",
},
],
},
{
"event": "message",
"chat_id": "t-file",
"text": "",
"kind": "progress",
"tool_events": [
{
"phase": "end",
"call_id": "call-write",
"name": "write_file",
"arguments": {"path": "foo.txt", "content": "hello\n"},
"result": "ok",
},
],
},
])
assert len(msgs) == 1
assert msgs[0]["kind"] == "trace"
assert msgs[0]["traces"] == []
assert "toolEvents" not in msgs[0]
assert msgs[0]["fileEdits"] == [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "start",
"added": 1,
"deleted": 0,
"approximate": True,
"status": "editing",
},
]
def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
msgs = replay_transcript_to_ui_messages([
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
{"event": "stream_end", "chat_id": "t-stream"},
{
"event": "message",
"chat_id": "t-stream",
"text": 'exec({"cmd":"ls"})',
"kind": "tool_hint",
},
{
"event": "stream_end",
"chat_id": "t-stream",
"text": "Done. Open index.html to play.",
},
])
assert len(msgs) == 3
assert msgs[0]["role"] == "assistant"
assert msgs[0]["content"] == ""
assert msgs[0]["reasoning"] == "I will inspect first."
assert "isStreaming" not in msgs[0]
assert msgs[1]["kind"] == "trace"
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
assert msgs[2]["role"] == "assistant"
assert msgs[2]["content"] == "Done. Open index.html to play."
def test_replay_tool_events_dedupes_finish_after_start() -> None:
msgs = replay_transcript_to_ui_messages([
{

View File

@ -0,0 +1,154 @@
import json
from nanobot.security.workspace_access import default_workspace_scope
from nanobot.session.manager import SessionManager
from nanobot.webui.workspaces import (
WebUIWorkspaceController,
read_webui_default_access_mode,
read_webui_workspace_state,
webui_workspace_state_path,
write_webui_default_access_mode,
workspaces_payload,
)
def test_workspace_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
state = read_webui_workspace_state()
assert state["default_access_mode"] == "default"
assert webui_workspace_state_path() == tmp_path / "webui" / "workspace-state.json"
def test_workspace_state_ignores_legacy_project_history(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
project = tmp_path / "project"
project.mkdir()
path = webui_workspace_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"recent_projects": [
{"project_path": str(project)},
{"project_path": str(tmp_path / "missing")},
],
"last_scope": {
"project_path": str(project),
"access_mode": "full",
},
}
),
encoding="utf-8",
)
state = read_webui_workspace_state()
assert "recent_projects" not in state
assert "last_scope" not in state
assert state["default_access_mode"] == "default"
def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
default.mkdir()
payload = workspaces_payload(
default_workspace=default,
default_restrict_to_workspace=False,
controls_available=True,
)
assert payload["default_scope"]["project_path"] == str(default.resolve())
assert payload["default_scope"]["access_mode"] == "full"
assert payload["default_access_mode"] == "default"
assert payload["controls"]["can_change_project"] is True
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
default.mkdir()
payload = workspaces_payload(
default_workspace=default,
default_restrict_to_workspace=False,
controls_available=False,
)
assert payload["default_scope"]["project_path"] == str(default.resolve())
assert payload["controls"]["can_change_project"] is False
assert payload["controls"]["can_use_full_access"] is False
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
default.mkdir()
assert write_webui_default_access_mode("full") is True
assert write_webui_default_access_mode("full") is False
payload = workspaces_payload(
default_workspace=default,
default_restrict_to_workspace=True,
controls_available=True,
)
assert payload["default_access_mode"] == "full"
assert payload["default_scope"]["project_path"] == str(default.resolve())
assert payload["default_scope"]["access_mode"] == "full"
def test_legacy_restricted_webui_default_access_mode_maps_to_default(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
assert write_webui_default_access_mode("restricted") is False
assert read_webui_default_access_mode() == "default"
def test_webui_default_access_applies_to_unscoped_old_sessions(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
default.mkdir()
sessions = SessionManager(tmp_path / "sessions")
sessions.save(sessions.get_or_create("websocket:old-chat"))
write_webui_default_access_mode("full")
controller = WebUIWorkspaceController(
session_manager=sessions,
default_workspace=default,
default_restrict_to_workspace=True,
)
scope = controller.scope_for_session_key("websocket:old-chat")
new_scope = controller.scope_for_new_chat({}, controls_available=True)
assert scope.project_path == default.resolve()
assert scope.access_mode == "full"
assert new_scope.access_mode == "full"
def test_webui_default_access_does_not_override_explicit_session_scope(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
project = tmp_path / "project"
default.mkdir()
project.mkdir()
sessions = SessionManager(tmp_path / "sessions")
controller = WebUIWorkspaceController(
session_manager=sessions,
default_workspace=default,
default_restrict_to_workspace=True,
)
explicit = default_workspace_scope(project, restrict_to_workspace=False)
controller.persist_scope("explicit-chat", explicit)
scope = controller.scope_for_session_key("websocket:explicit-chat")
assert scope.project_path == project.resolve()
assert scope.access_mode == "full"

View File

@ -1,10 +1,20 @@
from __future__ import annotations
import json
import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
from nanobot.webui.settings_api import WebUISettingsError, create_model_configuration
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.webui.settings_api import (
WebUISettingsError,
_oauth_provider_status,
create_model_configuration,
settings_payload,
update_model_configuration,
update_network_safety_settings,
)
from nanobot.providers.registry import find_by_name
def test_create_model_configuration_writes_label_and_selects(
@ -65,3 +75,237 @@ def test_create_model_configuration_rejects_unconfigured_provider(
"model": ["openai/gpt-4.1"],
}
)
def test_update_model_configuration_edits_named_preset_and_selects(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.openai.api_key = "sk-test"
config.model_presets["codex"] = ModelPresetConfig(
label="Old Codex",
provider="openai",
model="openai/gpt-4.1",
)
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
"nanobot.webui.settings_api._oauth_provider_status",
lambda spec: {
"configured": spec.name == "openai_codex",
"account": "acct-test",
"expires_at": 123,
"login_supported": True,
},
)
payload = update_model_configuration(
{
"name": ["codex"],
"label": ["Codex"],
"provider": ["openai_codex"],
"model": ["openai-codex/gpt-5.5"],
}
)
assert payload["agent"]["model_preset"] == "codex"
assert payload["agent"]["model"] == "openai-codex/gpt-5.5"
saved = load_config(config_path)
assert saved.agents.defaults.model_preset == "codex"
assert saved.model_presets["codex"].label == "Codex"
assert saved.model_presets["codex"].provider == "openai_codex"
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
def test_update_model_configuration_rejects_default_preset(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError, match="model configuration is required"):
update_model_configuration({"name": ["default"], "model": ["openai/gpt-4.1"]})
def test_settings_payload_includes_oauth_provider_status(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def fake_oauth_status(spec):
if spec.name == "openai_codex":
return {
"configured": True,
"account": "acct-test",
"expires_at": 123,
"login_supported": True,
}
return {
"configured": False,
"account": None,
"expires_at": None,
"login_supported": True,
}
monkeypatch.setattr("nanobot.webui.settings_api._oauth_provider_status", fake_oauth_status)
payload = settings_payload()
providers = {row["name"]: row for row in payload["providers"]}
assert providers["openai_codex"]["auth_type"] == "oauth"
assert providers["openai_codex"]["configured"] is True
assert providers["openai_codex"]["oauth_account"] == "acct-test"
def test_settings_payload_includes_network_safety_fields(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.webui_allow_local_service_access = False
config.tools.ssrf_whitelist = ["100.64.0.0/10"]
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
payload = settings_payload()
assert payload["advanced"]["webui_allow_local_service_access"] is False
assert payload["advanced"]["allow_local_preview_access"] is False
assert payload["advanced"]["webui_default_access_mode"] == "default"
assert payload["advanced"]["private_service_protection_enabled"] is True
assert payload["advanced"]["ssrf_whitelist_count"] == 1
def test_update_network_safety_settings_writes_local_service_flag(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
payload = update_network_safety_settings(
{
"webui_allow_local_service_access": ["false"],
"webui_default_access_mode": ["full"],
}
)
saved = load_config(config_path)
saved_raw = json.loads(config_path.read_text(encoding="utf-8"))
assert saved.tools.webui_allow_local_service_access is False
assert saved_raw["tools"]["webuiAllowLocalServiceAccess"] is False
assert "allowLocalPreviewAccess" not in saved_raw["tools"]
assert payload["advanced"]["webui_allow_local_service_access"] is False
assert payload["advanced"]["webui_default_access_mode"] == "full"
assert payload["requires_restart"] is True
def test_update_network_safety_settings_accepts_legacy_restricted_default_access(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
payload = update_network_safety_settings({"webui_default_access_mode": ["restricted"]})
assert payload["advanced"]["webui_default_access_mode"] == "default"
def test_update_network_safety_settings_default_access_is_webui_only(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
before = config_path.read_text(encoding="utf-8")
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
payload = update_network_safety_settings({"webui_default_access_mode": ["full"]})
saved = load_config(config_path)
assert config_path.read_text(encoding="utf-8") == before
assert saved.tools.restrict_to_workspace is False
assert payload["advanced"]["webui_default_access_mode"] == "full"
assert payload["requires_restart"] is False
def test_openai_codex_oauth_status_uses_available_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_get_token():
return type(
"Token",
(),
{
"access": "access-token",
"refresh": "refresh-token",
"expires": 2_000_000_000_000,
"account_id": "acct-codex",
},
)()
monkeypatch.setattr("oauth_cli_kit.get_token", fake_get_token)
status = _oauth_provider_status(find_by_name("openai_codex"))
assert status["configured"] is True
assert status["account"] == "acct-codex"
def test_openai_codex_oauth_status_rejects_unavailable_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_get_token():
raise RuntimeError("refresh failed")
monkeypatch.setattr("oauth_cli_kit.get_token", fake_get_token)
status = _oauth_provider_status(find_by_name("openai_codex"))
assert status["configured"] is False
assert status["account"] is None
def test_create_model_configuration_accepts_configured_oauth_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
"nanobot.webui.settings_api._oauth_provider_status",
lambda spec: {
"configured": spec.name == "openai_codex",
"account": "acct-test",
"expires_at": 123,
"login_supported": True,
},
)
payload = create_model_configuration(
{
"label": ["Codex"],
"provider": ["openai_codex"],
"model": ["openai-codex/gpt-5.1-codex"],
}
)
assert payload["agent"]["model_preset"] == "codex"
saved = load_config(config_path)
assert saved.model_presets["codex"].provider == "openai_codex"

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Menu, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
@ -23,9 +24,21 @@ import {
import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type { ChatSummary } from "@/lib/types";
import type {
ChatSummary,
RuntimeSurface,
SettingsPayload,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
import {
createRuntimeHost,
toRuntimeSurface,
} from "@/lib/runtime";
import { projectNameFromPath } from "@/lib/workspace";
type BootState =
| { status: "loading" }
@ -37,6 +50,7 @@ type BootState =
token: string;
tokenExpiresAt: number;
modelName: string | null;
runtimeSurface: RuntimeSurface;
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
@ -149,6 +163,67 @@ function writeCompletedRunChatIds(chatIds: Set<string>): void {
}
}
function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePayload {
const accessMode = scope.access_mode === "restricted" ? "restricted" : "full";
return {
...scope,
project_name: scope.project_name ?? projectNameFromPath(scope.project_path),
access_mode: accessMode,
restrict_to_workspace: accessMode === "restricted",
};
}
function HostChrome({
onToggleSidebar,
theme,
onToggleTheme,
showThemeButton = true,
}: {
onToggleSidebar?: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
showThemeButton?: boolean;
}) {
const { t } = useTranslation();
return (
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 flex h-11 items-start justify-between bg-transparent px-3 pt-2 text-foreground/90">
<div className="flex min-w-[8rem] items-center">
{onToggleSidebar ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className="host-no-drag pointer-events-auto ml-[88px] h-8 w-8 rounded-xl text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Menu className="h-4 w-4" />
</Button>
) : null}
</div>
{showThemeButton ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="host-no-drag pointer-events-auto h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
) : (
<div aria-hidden className="h-8 w-8" />
)}
</header>
);
}
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
@ -163,13 +238,20 @@ export default function App() {
const boot = await fetchBootstrap("", secret);
if (cancelled) return;
if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = toRuntimeSurface(boot.runtime_surface);
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const client = new NanobotClient({
url,
socketFactory: runtimeHost.socketFactory,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
const refreshedUrl = deriveWsUrl(refreshed.ws_path, refreshed.token);
const refreshedUrl = deriveWsUrl(
refreshed.ws_path,
refreshed.token,
refreshed.ws_url,
);
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
setState((current) =>
current.status === "ready" && current.client === client
@ -178,6 +260,10 @@ export default function App() {
token: refreshed.token,
tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName,
runtimeSurface:
refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: current.runtimeSurface,
}
: current,
);
@ -195,6 +281,7 @@ export default function App() {
token: boot.token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null,
runtimeSurface,
});
} catch (e) {
if (cancelled) return;
@ -219,7 +306,7 @@ export default function App() {
const timer = window.setTimeout(async () => {
try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
client.updateUrl(url);
setState((current) =>
@ -229,6 +316,9 @@ export default function App() {
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface: boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: current.runtimeSurface,
}
: current,
);
@ -304,20 +394,26 @@ export default function App() {
token={state.token}
modelName={state.modelName}
>
<Shell onModelNameChange={handleModelNameChange} onLogout={handleLogout} />
<Shell
runtimeSurface={state.runtimeSurface}
onModelNameChange={handleModelNameChange}
onLogout={handleLogout}
/>
</ClientProvider>
);
}
function Shell({
runtimeSurface,
onModelNameChange,
onLogout,
}: {
runtimeSurface: RuntimeSurface;
onModelNameChange: (modelName: string | null) => void;
onLogout: () => void;
}) {
const { t, i18n } = useTranslation();
const { client } = useClient();
const { client, token } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const { state: sidebarState, update: updateSidebarState } =
@ -325,7 +421,7 @@ function Shell({
const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat");
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview");
const [desktopSidebarOpen, setDesktopSidebarOpen] =
const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
@ -337,23 +433,48 @@ function Shell({
key: string;
label: string;
} | null>(null);
const [pendingProjectRename, setPendingProjectRename] = useState<{
key: string;
label: string;
} | null>(null);
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [draftWorkspaceScope, setDraftWorkspaceScope] =
useState<WorkspaceScopePayload | null>(null);
const [workspaceOverrides, setWorkspaceOverrides] =
useState<Record<string, WorkspaceScopePayload>>({});
const runningChatIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
fetchSettings(token)
.then((payload) => {
if (!cancelled) setSettingsSnapshot(payload);
})
.catch(() => {
if (!cancelled) setSettingsSnapshot(null);
});
return () => {
cancelled = true;
};
}, [token]);
useEffect(() => {
try {
window.localStorage.setItem(
SIDEBAR_STORAGE_KEY,
desktopSidebarOpen ? "1" : "0",
hostSidebarOpen ? "1" : "0",
);
} catch {
// ignore storage errors (private mode, etc.)
}
}, [desktopSidebarOpen]);
}, [hostSidebarOpen]);
useEffect(() => {
writeCompletedRunChatIds(completedChatIds);
@ -365,6 +486,36 @@ function Shell({
}, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId];
}
if (activeSession?.workspaceScope) {
return activeSession.workspaceScope;
}
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
}, [
activeChatId,
activeSession?.workspaceScope,
draftWorkspaceScope,
workspaceOverrides,
workspaces?.default_scope,
]);
const activeChatRunning = activeChatId ? runningChatIds.has(activeChatId) : false;
const refreshWorkspaces = useCallback(async () => {
try {
const payload = await fetchWorkspaces(token);
setWorkspaces(payload);
} catch {
setWorkspaces(null);
}
}, [token]);
useEffect(() => {
void refreshWorkspaces();
}, [refreshWorkspaces]);
useEffect(() => {
if (loading) return;
@ -375,8 +526,34 @@ function Shell({
);
return next.size === current.size ? current : next;
});
setWorkspaceOverrides((current) => {
const entries = Object.entries(current).filter(([chatId]) => knownChatIds.has(chatId));
return entries.length === Object.keys(current).length ? current : Object.fromEntries(entries);
});
}, [loading, sessions]);
useEffect(() => {
return client.onSessionUpdate((_chatId, _scope, workspaceScope) => {
if (!workspaceScope) return;
const next = normalizeWorkspaceScope(workspaceScope);
setWorkspaceOverrides((current) => ({
...current,
[_chatId]: next,
}));
setDraftWorkspaceScope(next);
setWorkspaceError(null);
void refreshWorkspaces();
});
}, [client, refreshWorkspaces]);
useEffect(() => {
return client.onError((error) => {
if (error.kind !== "workspace_scope_rejected") return;
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
void refreshWorkspaces();
});
}, [client, refreshWorkspaces, t]);
useEffect(() => {
if (loading) return;
const activeRunIds = sessions
@ -408,12 +585,12 @@ function Shell({
});
}, [client, loading, sessions]);
const closeDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(false);
const closeHostSidebar = useCallback(() => {
setHostSidebarOpen(false);
}, []);
const openDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(true);
const openHostSidebar = useCallback(() => {
setHostSidebarOpen(true);
}, []);
const closeMobileSidebar = useCallback(() => {
@ -421,38 +598,88 @@ function Shell({
}, []);
const toggleSidebar = useCallback(() => {
const isDesktop =
const isNativeHost =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches;
if (isDesktop) {
setDesktopSidebarOpen((v) => !v);
if (isNativeHost) {
setHostSidebarOpen((v) => !v);
} else {
setMobileSidebarOpen((v) => !v);
}
}, []);
const onCreateChat = useCallback(async () => {
const applyWorkspaceScope = useCallback(
(scope: WorkspaceScopePayload) => {
const next = normalizeWorkspaceScope(scope);
setWorkspaceError(null);
if (activeChatId) {
if (!activeChatRunning) {
client.setWorkspaceScope(activeChatId, next);
}
return;
}
setDraftWorkspaceScope(next);
},
[activeChatId, activeChatRunning, client],
);
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
try {
const chatId = await createChat();
const scope = workspaceScope ?? activeWorkspaceScope;
const chatId = await createChat(scope);
setActiveKey(`websocket:${chatId}`);
setView("chat");
setMobileSidebarOpen(false);
if (scope) {
setWorkspaceOverrides((current) => ({
...current,
[chatId]: normalizeWorkspaceScope(scope),
}));
}
return chatId;
} catch (e) {
console.error("Failed to create chat", e);
if (e instanceof Error && e.message.startsWith("workspace_scope_rejected:")) {
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
}
return null;
}
}, [createChat]);
}, [activeWorkspaceScope, createChat, t]);
const onNewChat = useCallback(() => {
setActiveKey(null);
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setView("chat");
setMobileSidebarOpen(false);
}, []);
const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope;
const trimmed = projectPath.trim();
if (!base || !trimmed) {
onNewChat();
return;
}
setActiveKey(null);
setDraftWorkspaceScope(normalizeWorkspaceScope({
project_path: trimmed,
project_name: projectName || projectNameFromPath(trimmed),
access_mode: base.access_mode,
restrict_to_workspace: base.access_mode === "restricted",
}));
setWorkspaceError(null);
setView("chat");
setMobileSidebarOpen(false);
},
[activeWorkspaceScope, onNewChat, workspaces?.default_scope],
);
const onSelectChat = useCallback(
(key: string) => {
const selectedChatId = sessions.find((session) => session.key === key)?.chatId;
const selected = sessions.find((session) => session.key === key);
const selectedChatId = selected?.chatId;
if (selectedChatId) {
setCompletedChatIds((current) => {
if (!current.has(selectedChatId)) return current;
@ -461,6 +688,12 @@ function Shell({
return next;
});
}
if (selected?.workspaceScope) {
setDraftWorkspaceScope(normalizeWorkspaceScope(selected.workspaceScope));
} else {
setDraftWorkspaceScope(null);
}
setWorkspaceError(null);
setActiveKey(key);
setView("chat");
setMobileSidebarOpen(false);
@ -512,6 +745,61 @@ function Shell({
[pendingRename, updateSidebarState],
);
const onToggleGroup = useCallback(
(groupId: string) => {
void updateSidebarState((current) => {
const collapsedGroups = { ...current.collapsed_groups };
if (groupId === "workspace:chats" || groupId === "date:all") {
if (collapsedGroups[groupId] === false) {
delete collapsedGroups[groupId];
} else {
collapsedGroups[groupId] = false;
}
return {
...current,
collapsed_groups: collapsedGroups,
};
}
if (collapsedGroups[groupId]) {
delete collapsedGroups[groupId];
} else {
collapsedGroups[groupId] = true;
}
return {
...current,
collapsed_groups: collapsedGroups,
};
});
},
[updateSidebarState],
);
const onRequestRenameProject = useCallback((key: string, label: string) => {
setPendingProjectRename({ key, label });
}, []);
const onConfirmProjectRename = useCallback(
(title: string) => {
if (!pendingProjectRename) return;
const key = pendingProjectRename.key;
setPendingProjectRename(null);
void updateSidebarState((current) => {
const projectNameOverrides = { ...current.project_name_overrides };
const cleaned = title.trim();
if (cleaned) {
projectNameOverrides[key] = cleaned;
} else {
delete projectNameOverrides[key];
}
return {
...current,
project_name_overrides: projectNameOverrides,
};
});
},
[pendingProjectRename, updateSidebarState],
);
const onToggleArchive = useCallback(
(key: string) => {
void updateSidebarState((current) => {
@ -547,19 +835,6 @@ function Shell({
}));
}, [updateSidebarState]);
const onUpdateSidebarView = useCallback(
(viewUpdate: Partial<typeof sidebarState.view>) => {
void updateSidebarState((current) => ({
...current,
view: {
...current.view,
...viewUpdate,
},
}));
},
[updateSidebarState],
);
const onOpenSessionSearch = useCallback(() => {
setMobileSidebarOpen(false);
setSessionSearchOpen(true);
@ -742,117 +1017,165 @@ function Shell({
onTogglePin,
onRequestRename,
onToggleArchive,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
onOpenSettings,
onOpenApps,
onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" ? "apps" as const : null,
onToggleArchived,
onUpdateView: onUpdateSidebarView,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
titleOverrides: sidebarState.title_overrides,
projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList,
completedChatIds: completedChatIdList,
viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
};
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const isNativeHostSetupSurface = effectiveRuntimeSurface === "native";
const showHostChrome = isNativeHostSetupSurface;
const showMainSidebar = view !== "settings";
return (
<ThemeProvider theme={theme}>
<div className="relative flex h-full w-full overflow-hidden">
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
<div
className={cn(
"relative h-full w-full overflow-hidden",
showHostChrome && "bg-sidebar",
)}
>
{showHostChrome ? (
<HostChrome
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
theme={theme}
onToggleTheme={toggle}
showThemeButton={view !== "chat"}
/>
) : null}
<div
className={cn(
"relative flex h-full w-full overflow-hidden",
)}
>
{/* Host sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{
width: hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
}}
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar",
!showHostChrome && "shadow-inner-right",
)}
>
<Sidebar
{...sidebarProps}
collapsed={!hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
/>
</div>
</aside>
) : null}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
aria-describedby={undefined}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
<Sidebar
{...sidebarProps}
onCollapse={closeMobileSidebar}
containActionMenus
/>
</SheetContent>
</Sheet>
) : null}
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
<main
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome &&
"rounded-l-[28px] shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.45)] dark:shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.85)]",
)}
style={{
width: desktopSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
}}
>
<div
className="absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar shadow-inner-right"
className={cn(
"absolute inset-0 flex flex-col",
view !== "chat" && "invisible pointer-events-none",
)}
>
<Sidebar
{...sidebarProps}
collapsed={!desktopSidebarOpen}
onCollapse={closeDesktopSidebar}
onExpand={openDesktopSidebar}
/>
</div>
</aside>
) : null}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
aria-describedby={undefined}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
<Sidebar
{...sidebarProps}
onCollapse={closeMobileSidebar}
containActionMenus
/>
</SheetContent>
</Sheet>
) : null}
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
className={cn(
"absolute inset-0 flex flex-col",
view !== "chat" && "invisible pointer-events-none",
)}
>
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleOnDesktop
/>
</div>
{view !== "chat" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
initialSection={settingsInitialSection}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onLogout={onLogout}
onRestart={onRestart}
isRestarting={isRestarting}
hideSidebarToggleForHostChrome
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
/>
</div>
)}
</main>
{view !== "chat" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onSettingsChange={setSettingsSnapshot}
onWorkspaceSettingsChange={refreshWorkspaces}
onLogout={onLogout}
onRestart={onRestart}
isRestarting={isRestarting}
hostChromeInset={showHostChrome}
/>
</div>
)}
</main>
</div>
<DeleteConfirm
open={!!pendingDelete}
@ -866,6 +1189,15 @@ function Shell({
onCancel={() => setPendingRename(null)}
onConfirm={onConfirmRename}
/>
<RenameChatDialog
open={!!pendingProjectRename}
title={pendingProjectRename?.label ?? ""}
dialogTitle={t("chat.renameProjectTitle")}
description={t("chat.renameProjectDescription")}
placeholder={t("chat.renameProjectPlaceholder")}
onCancel={() => setPendingProjectRename(null)}
onConfirm={onConfirmProjectRename}
/>
{restartToast ? (
<div
role="status"

View File

@ -7,10 +7,12 @@ import {
import {
Archive,
ArchiveRestore,
Folder,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Plus,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@ -22,6 +24,17 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { deriveTitle, relativeTime } from "@/lib/format";
import {
COLLAPSED_CHATS_VISIBLE_COUNT,
displayTitle,
groupSessions,
isCollapsedProject,
isFoldableChatsGroup,
isFoldedChatsGroup,
limitGroups,
visibleSessionsForGroup,
type ChatGroupLabels,
} from "@/lib/chat-groups";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
@ -36,9 +49,14 @@ interface ChatListProps {
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onToggleGroup?: (groupId: string) => void;
onRequestRenameProject?: (projectKey: string, label: string) => void;
onNewChatInProject?: (projectPath: string, projectName: string) => void;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
completedChatIds?: string[];
density?: SidebarDensity;
@ -46,6 +64,7 @@ interface ChatListProps {
showTimestamps?: boolean;
sort?: SidebarSortMode;
showArchived?: boolean;
defaultWorkspacePath?: string | null;
actionMenuPortalContainer?: HTMLElement | null;
loading?: boolean;
emptyLabel?: string;
@ -59,9 +78,14 @@ export const ChatList = memo(function ChatList({
onTogglePin,
onRequestRename,
onToggleArchive,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
pinnedKeys = [],
archivedKeys = [],
titleOverrides = {},
projectNameOverrides = {},
collapsedGroups = {},
runningChatIds = [],
completedChatIds = [],
density = "comfortable",
@ -69,19 +93,21 @@ export const ChatList = memo(function ChatList({
showTimestamps = false,
sort = "updated_desc",
showArchived = false,
defaultWorkspacePath,
actionMenuPortalContainer,
loading,
emptyLabel,
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const labels = useMemo(() => ({
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
archived: t("chat.groups.archived"),
projects: t("chat.groups.projects"),
fallbackTitle: t("chat.newChat"),
}), [t]);
const groups = useMemo(
@ -89,8 +115,10 @@ export const ChatList = memo(function ChatList({
pinnedKeys,
archivedKeys,
titleOverrides,
projectNameOverrides,
showArchived,
sort,
defaultWorkspacePath,
}),
[
archivedKeys,
@ -100,15 +128,21 @@ export const ChatList = memo(function ChatList({
showArchived,
sort,
titleOverrides,
projectNameOverrides,
defaultWorkspacePath,
],
);
const limitedGroups = useMemo(
() => limitGroups(groups, visibleLimit, activeKey),
[activeKey, groups, visibleLimit],
() => limitGroups(groups, visibleLimit, activeKey, collapsedGroups),
[activeKey, collapsedGroups, groups, visibleLimit],
);
const totalSessionCount = useMemo(
() => groups.reduce((total, group) => total + group.sessions.length, 0),
[groups],
() => groups.reduce(
(total, group) =>
total + (isCollapsedProject(group, collapsedGroups) ? 0 : group.sessions.length),
0,
),
[collapsedGroups, groups],
);
const visibleSessionCount = useMemo(
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
@ -143,131 +177,194 @@ export const ChatList = memo(function ChatList({
const compact = density === "compact";
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<div className="min-w-0 space-y-3 px-2 py-1.5">
{limitedGroups.map((group) => (
<section key={group.label} aria-label={group.label}>
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{group.label}
</div>
<ul className="space-y-0.5">
{group.sessions.map((s) => {
const active = s.key === activeKey;
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 tooltipTitle =
titleOverrides[s.key]?.trim() ||
generatedTitle ||
deriveTitle(s.preview, fallbackTitle);
const isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key);
const preview = s.preview.trim();
const showPreview = showPreviews && preview && preview !== title;
const timestamp = showTimestamps
? relativeTime(s.updatedAt ?? s.createdAt)
: "";
const activityState = running.has(s.chatId)
? "running"
: completed.has(s.chatId)
? "complete"
: null;
return (
<li key={s.key} className="min-w-0">
<div
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
compact ? "min-h-7" : "min-h-8",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
>
<button
type="button"
onClick={() => onSelect(s.key)}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
compact ? "py-1" : "py-1.5",
)}
>
<span className="block w-full truncate font-medium leading-5">{title}</span>
{showPreview ? (
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
{preview}
</span>
) : null}
{timestamp ? (
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
{timestamp}
</span>
) : null}
</button>
<SessionActivityIndicator state={activityState} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger
{limitedGroups.map((group, index) => {
const foldableChatsGroup = isFoldableChatsGroup(group);
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
const visibleSessions = visibleSessionsForGroup(
group,
activeKey,
collapsedGroups,
);
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
return (
<section key={group.id} aria-label={group.label}>
{group.kind === "project"
&& limitedGroups[index - 1]?.kind !== "project" ? (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{labels.projects}
</div>
) : null}
{group.kind === "project" ? (
<ProjectGroupHeader
label={group.label}
path={group.projectPath}
collapsed={Boolean(collapsedGroups[group.id])}
onToggle={() => onToggleGroup?.(group.id)}
onRequestRename={
group.projectKey && onRequestRenameProject
? () => onRequestRenameProject(group.projectKey ?? "", group.label)
: undefined
}
onNewChat={
group.projectPath && onNewChatInProject
? () => onNewChatInProject(group.projectPath ?? "", group.label)
: undefined
}
actionMenuPortalContainer={actionMenuPortalContainer}
updatedAt={showTimestamps ? group.updatedAt : null}
/>
) : (
<ChatsGroupHeader label={group.label} />
)}
{group.kind === "project" && collapsedGroups[group.id] ? null : (
<ul className="space-y-0.5">
{visibleSessions.map((s) => {
const active = s.key === activeKey;
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 tooltipTitle =
titleOverrides[s.key]?.trim() ||
generatedTitle ||
deriveTitle(s.preview, fallbackTitle);
const isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key);
const preview = s.preview.trim();
const showPreview = showPreviews && preview && preview !== title;
const timestamp = showTimestamps
? relativeTime(s.updatedAt ?? s.createdAt)
: "";
const projectMode = group.kind === "project";
const activityState = running.has(s.chatId)
? "running"
: completed.has(s.chatId) && !active
? "complete"
: null;
return (
<li key={s.key} className="min-w-0">
<div
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",
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
compact ? "min-h-7" : "min-h-8",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => onTogglePin(s.key)}
>
{isPinned ? (
<PinOff className="mr-2 h-4 w-4" />
) : (
<Pin className="mr-2 h-4 w-4" />
<button
type="button"
onClick={() => onSelect(s.key)}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
compact ? "py-1" : "py-1.5",
projectMode && "pl-7",
)}
{isPinned ? t("chat.unpin") : t("chat.pin")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)}
>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onToggleArchive(s.key)}
>
{isArchived ? (
<ArchiveRestore className="mr-2 h-4 w-4" />
{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>
{timestamp ? (
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
{timestamp}
</span>
) : null}
</span>
) : (
<Archive className="mr-2 h-4 w-4" />
<span className="block w-full truncate font-medium leading-5">
{title}
</span>
)}
{isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</section>
))}
{showPreview ? (
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
{preview}
</span>
) : null}
{timestamp && !projectMode ? (
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
{timestamp}
</span>
) : null}
</button>
<SessionActivityIndicator state={activityState} />
<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",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => onTogglePin(s.key)}
>
{isPinned ? (
<PinOff className="mr-2 h-4 w-4" />
) : (
<Pin className="mr-2 h-4 w-4" />
)}
{isPinned ? t("chat.unpin") : t("chat.pin")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)}
>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onToggleArchive(s.key)}
>
{isArchived ? (
<ArchiveRestore className="mr-2 h-4 w-4" />
) : (
<Archive className="mr-2 h-4 w-4" />
)}
{isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
)}
{foldableChatsGroup && canToggleFold ? (
<ChatsFoldFooter
folded={foldedChatsGroup}
hiddenCount={hiddenInGroup}
onToggle={() => onToggleGroup?.(group.id)}
/>
) : null}
</section>
);
})}
{hiddenSessionCount > 0 ? (
<div className="px-2 pb-2 pt-1">
<button
@ -277,7 +374,7 @@ export const ChatList = memo(function ChatList({
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT),
)
}
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-sidebar-foreground"
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground/65 transition-colors hover:bg-sidebar-accent/65 hover:text-muted-foreground"
>
{t("chat.showMore", { count: hiddenSessionCount })}
</button>
@ -288,6 +385,133 @@ export const ChatList = memo(function ChatList({
);
});
function ProjectGroupHeader({
label,
path,
collapsed,
onToggle,
onRequestRename,
onNewChat,
actionMenuPortalContainer,
updatedAt,
}: {
label: string;
path?: string;
collapsed: boolean;
onToggle: () => void;
onRequestRename?: () => void;
onNewChat?: () => void;
actionMenuPortalContainer?: HTMLElement | null;
updatedAt?: string | null;
}) {
const { t } = useTranslation();
return (
<div
title={path}
className="group flex min-w-0 items-center gap-1 px-1 pb-1 pt-1 text-[12px] font-medium text-muted-foreground/78"
>
<button
type="button"
aria-expanded={!collapsed}
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-sidebar-accent/45 hover:text-sidebar-foreground"
>
<Folder className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
{updatedAt ? (
<span className="shrink-0 text-[11px] text-muted-foreground/55">
{relativeTime(updatedAt)}
</span>
) : null}
{onRequestRename ? (
<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-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100",
)}
aria-label={t("chat.actions", { title: label })}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem onSelect={onRequestRename}>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{onNewChat ? (
<button
type="button"
aria-label={t("chat.newInProject", { project: label })}
title={t("chat.newInProject", { project: label })}
onClick={(event) => {
event.stopPropagation();
onNewChat();
}}
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100",
)}
>
<Plus className="h-3.5 w-3.5" />
</button>
) : null}
</div>
);
}
function ChatsGroupHeader({ label }: { label: string }) {
return (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{label}
</div>
);
}
function ChatsFoldFooter({
folded,
hiddenCount,
onToggle,
}: {
folded: boolean;
hiddenCount: number;
onToggle: () => void;
}) {
const { t, i18n } = useTranslation();
const collapsedFallback = i18n.resolvedLanguage?.startsWith("zh")
? `已折叠 ${hiddenCount} 个对话`
: `${hiddenCount} hidden chats`;
return (
<div className="px-2 pb-1 pt-1">
<button
type="button"
onClick={onToggle}
className="h-7 w-full rounded-xl text-left text-[12px] font-medium text-muted-foreground/65 transition-colors hover:bg-sidebar-accent/50 hover:text-muted-foreground"
>
<span className="px-2">
{folded
? t("chat.collapsed", {
count: hiddenCount,
defaultValue: collapsedFallback,
})
: t("chat.showLess")}
</span>
</button>
</div>
);
}
function SessionActivityIndicator({
state,
}: {
@ -316,202 +540,10 @@ function SessionActivityIndicator({
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.14)] dark:bg-blue-400 dark:shadow-[0_0_0_3px_rgba(96,165,250,0.18)]" />
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
</span>
);
}
return <span className="h-4 w-4 shrink-0" aria-hidden="true" />;
}
function groupSessions(
sessions: ChatSummary[],
labels: {
pinned: string;
all: string;
today: string;
yesterday: string;
earlier: string;
archived: string;
fallbackTitle: string;
},
options: {
pinnedKeys: string[];
archivedKeys: string[];
titleOverrides: Record<string, string>;
showArchived: boolean;
sort: SidebarSortMode;
},
): Array<{ label: string; sessions: ChatSummary[] }> {
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>();
const pinned = new Set(options.pinnedKeys);
const archived = new Set(options.archivedKeys);
const pinnedSessions: ChatSummary[] = [];
const archivedSessions: ChatSummary[] = [];
const normalSessions: ChatSummary[] = [];
for (const session of sessions) {
if (archived.has(session.key)) {
if (options.showArchived) archivedSessions.push(session);
continue;
}
if (pinned.has(session.key)) {
pinnedSessions.push(session);
continue;
}
if (options.sort === "title_asc") {
normalSessions.push(session);
continue;
}
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
? labels.yesterday
: labels.earlier;
const bucket = buckets.get(label) ?? [];
bucket.push(session);
buckets.set(label, bucket);
}
const groups = [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({
label,
sessions: sortSessions(
buckets.get(label) ?? [],
options.sort,
options.titleOverrides,
),
}))
.filter((group) => group.sessions.length > 0);
if (options.sort === "title_asc" && normalSessions.length) {
groups.push({
label: labels.all,
sessions: sortSessions(
normalSessions,
options.sort,
options.titleOverrides,
),
});
}
if (pinnedSessions.length) {
groups.unshift({
label: labels.pinned,
sessions: sortSessions(
pinnedSessions,
options.sort,
options.titleOverrides,
),
});
}
if (archivedSessions.length) {
groups.push({
label: labels.archived,
sessions: sortSessions(
archivedSessions,
options.sort,
options.titleOverrides,
),
});
}
return groups;
}
function limitGroups(
groups: Array<{ label: string; sessions: ChatSummary[] }>,
limit: number,
activeKey: string | null,
): Array<{ label: string; sessions: ChatSummary[] }> {
let remaining = Math.max(0, limit);
let activeVisible = !activeKey;
const out: Array<{ label: string; sessions: ChatSummary[] }> = [];
for (const group of groups) {
const visible = remaining > 0
? group.sessions.slice(0, remaining)
: [];
remaining -= visible.length;
if (activeKey && visible.some((session) => session.key === activeKey)) {
activeVisible = true;
}
if (visible.length > 0) {
out.push({ label: group.label, sessions: visible });
}
}
if (activeVisible || !activeKey) return out;
for (const group of groups) {
const active = group.sessions.find((session) => session.key === activeKey);
if (!active) continue;
const existing = out.find((item) => item.label === group.label);
if (existing) {
existing.sessions = [...existing.sessions, active];
} else {
out.push({ label: group.label, sessions: [active] });
}
return out;
}
return out;
}
function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
): ChatSummary[] {
const copy = [...sessions];
copy.sort((a, b) => {
if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides),
"en",
{ numeric: true, sensitivity: "base" },
);
if (titleOrder !== 0) return titleOrder;
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
}
const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt");
const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt");
return bTime - aTime;
});
return copy;
}
function titleForSort(
session: ChatSummary,
titleOverrides: Record<string, string>,
): string {
return (
titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, "new chat")
).toLocaleLowerCase("en");
}
function displayTitle(
session: ChatSummary,
titleOverrides: Record<string, string>,
fallbackTitle: string,
): string {
return (
titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, fallbackTitle)
);
}
function sessionTime(
session: ChatSummary,
field: "createdAt" | "updatedAt",
): number {
const primary = Date.parse(session[field] ?? "");
if (Number.isFinite(primary)) return primary;
const fallback = Date.parse(session.updatedAt ?? session.createdAt ?? "");
return Number.isFinite(fallback) ? fallback : 0;
}

View File

@ -15,6 +15,9 @@ import { Input } from "@/components/ui/input";
interface RenameChatDialogProps {
open: boolean;
title: string;
dialogTitle?: string;
description?: string;
placeholder?: string;
onCancel: () => void;
onConfirm: (title: string) => void;
}
@ -22,6 +25,9 @@ interface RenameChatDialogProps {
export function RenameChatDialog({
open,
title,
dialogTitle,
description,
placeholder,
onCancel,
onConfirm,
}: RenameChatDialogProps) {
@ -48,15 +54,15 @@ export function RenameChatDialog({
}}
>
<DialogHeader className="text-left">
<DialogTitle>{t("chat.renameTitle")}</DialogTitle>
<DialogTitle>{dialogTitle ?? t("chat.renameTitle")}</DialogTitle>
<DialogDescription>
{t("chat.renameDescription")}
{description ?? t("chat.renameDescription")}
</DialogDescription>
</DialogHeader>
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={t("chat.renamePlaceholder")}
placeholder={placeholder ?? t("chat.renamePlaceholder")}
autoFocus
maxLength={160}
/>

View File

@ -1,7 +1,6 @@
import { useState, type ReactNode } from "react";
import {
Archive,
ListFilter,
Menu,
Search,
Settings,
@ -13,20 +12,9 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import type {
ChatSummary,
SidebarSortMode,
SidebarViewState,
} from "@/lib/types";
import { cn } from "@/lib/utils";
@ -41,12 +29,14 @@ interface SidebarProps {
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void;
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | null;
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void;
onExpand?: () => void;
containActionMenus?: boolean;
@ -54,11 +44,15 @@ interface SidebarProps {
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
completedChatIds?: string[];
viewState?: SidebarViewState;
showArchived?: boolean;
archivedCount?: number;
defaultWorkspacePath?: string | null;
hostChromeInset?: boolean;
}
export function Sidebar(props: SidebarProps) {
@ -72,11 +66,15 @@ export function Sidebar(props: SidebarProps) {
<nav
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
aria-label={t("sidebar.navigation")}
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
className={cn(
"flex h-full w-full min-w-0 flex-col bg-sidebar text-sidebar-foreground",
!props.hostChromeInset && "border-r border-sidebar-border/60",
)}
>
<div
className={cn(
"flex items-center px-3 pb-2.5 pt-3",
"flex items-center px-3 pb-2.5",
props.hostChromeInset ? "pt-[2.85rem]" : "pt-3",
collapsed ? "w-14 justify-start" : "justify-between",
)}
>
@ -101,7 +99,7 @@ export function Sidebar(props: SidebarProps) {
draggable={false}
/>
</button>
{!collapsed && (
{!collapsed && !props.hostChromeInset && (
<Button
variant="ghost"
size="icon"
@ -139,11 +137,6 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarViewMenu
compact={collapsed}
view={props.viewState}
onUpdateView={props.onUpdateView}
/>
{props.archivedCount ? (
<SidebarActionButton
collapsed={collapsed}
@ -170,9 +163,14 @@ export function Sidebar(props: SidebarProps) {
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
density={props.viewState?.density}
@ -180,6 +178,7 @@ export function Sidebar(props: SidebarProps) {
showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort}
showArchived={props.showArchived}
defaultWorkspacePath={props.defaultWorkspacePath}
actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined
}
@ -261,102 +260,3 @@ function SidebarActionButton({
</Button>
);
}
function SidebarViewMenu({
compact = false,
view,
onUpdateView,
}: {
compact?: boolean;
view?: SidebarViewState;
onUpdateView: (view: Partial<SidebarViewState>) => void;
}) {
const { t } = useTranslation();
const sort = view?.sort ?? "updated_desc";
const setSort = (value: string) => {
if (isSidebarSortMode(value)) onUpdateView({ sort: value });
};
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
aria-label={t("sidebar.viewOptions")}
title={compact ? t("sidebar.viewOptions") : undefined}
className={cn(
"h-8 min-w-0 overflow-hidden font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
compact
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 rounded-full px-3 text-[12.5px]",
)}
variant="ghost"
>
<ListFilter className="h-4 w-4 shrink-0" aria-hidden />
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
compact
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
{t("sidebar.viewOptions")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.viewOptions")}
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={view?.density === "compact"}
onCheckedChange={(checked) =>
onUpdateView({ density: checked ? "compact" : "comfortable" })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.compactList")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_previews)}
onCheckedChange={(checked) =>
onUpdateView({ show_previews: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showPreviews")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_timestamps)}
onCheckedChange={(checked) =>
onUpdateView({ show_timestamps: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showTimestamps")}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.sortLabel")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="updated_desc">
{t("sidebar.sortUpdated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_desc">
{t("sidebar.sortCreated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title_asc">
{t("sidebar.sortTitle")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
function isSidebarSortMode(value: string): value is SidebarSortMode {
return value === "updated_desc" || value === "created_desc" || value === "title_asc";
}

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,7 @@ interface ActivityCounts {
hasDiffStats: boolean;
hasEditingFiles: boolean;
hasFailedFiles: boolean;
hasDeletedFiles: boolean;
primaryFilePath?: string;
primaryFileTooltipPath?: string;
primaryCliName?: string;
@ -66,6 +67,7 @@ interface FileEditSummary {
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
}
@ -126,6 +128,7 @@ function countActivity(
let hasDiffStats = false;
let hasEditingFiles = false;
let failedFileCount = 0;
let deletedFileCount = 0;
let primaryFilePath: string | undefined;
let primaryFileTooltipPath: string | undefined;
for (const edit of fileEdits) {
@ -137,6 +140,9 @@ function countActivity(
if (edit.status === "error") {
failedFileCount += 1;
}
if (edit.operation === "delete") {
deletedFileCount += 1;
}
if (edit.status === "error" || edit.binary) {
continue;
}
@ -158,6 +164,7 @@ function countActivity(
hasDiffStats,
hasEditingFiles,
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
hasDeletedFiles: fileEdits.length > 0 && deletedFileCount === fileEdits.length,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
@ -217,6 +224,7 @@ export function AgentActivityCluster({
hasDiffStats,
hasEditingFiles,
hasFailedFiles,
hasDeletedFiles,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
@ -245,6 +253,7 @@ export function AgentActivityCluster({
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const hasOnlyFileActivity = fileCount > 0 && messages.every(messageHasOnlyFileActivity);
const durationMs = activityDurationMs(messages, isTurnStreaming, now, turnLatencyMs);
const activityDuration = formatActivityDuration(durationMs);
const thoughtLabel = isTurnStreaming
@ -263,13 +272,13 @@ export function AgentActivityCluster({
? hasPendingFileEdit && !singleFilePath
? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" })
: singleFilePath
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
file: shortFileName(singleFilePath),
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{file}}`,
})
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
count: fileCount,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} files`,
})
: "";
@ -410,6 +419,25 @@ export function AgentActivityCluster({
if (!hasVisibleActivity) return null;
if (hasOnlyFileActivity) {
return (
<FileEditFlatActivity
edits={fileEdits}
active={isTurnStreaming}
hasBodyBelow={hasBodyBelow}
summary={summary}
singleFilePath={singleFilePath}
singleFileTooltipPath={singleFileTooltipPath}
hasLiveEditingFiles={hasLiveEditingFiles}
hasFailedFiles={hasFailedFiles}
hasDeletedFiles={hasDeletedFiles}
added={added}
deleted={deleted}
hasDiffStats={hasDiffStats}
/>
);
}
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@ -426,7 +454,7 @@ export function AgentActivityCluster({
active={isTurnStreaming}
className="min-w-0"
>
{singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles) : thoughtLabel}
{singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles) : thoughtLabel}
</StreamingLabelSheen>
{singleFilePath ? (
<FileReferenceChip
@ -502,6 +530,77 @@ export function AgentActivityCluster({
);
}
function messageHasOnlyFileActivity(message: UIMessage): boolean {
if (message.kind !== "trace" || !message.fileEdits?.length) return false;
return traceLines(message).every((line) => !line.trim() || isFileEditTraceLine(line));
}
function FileEditFlatActivity({
edits,
active,
hasBodyBelow,
summary,
singleFilePath,
singleFileTooltipPath,
hasLiveEditingFiles,
hasFailedFiles,
hasDeletedFiles,
added,
deleted,
hasDiffStats,
}: {
edits: FileEditSummary[];
active: boolean;
hasBodyBelow: boolean;
summary: string;
singleFilePath?: string;
singleFileTooltipPath?: string;
hasLiveEditingFiles: boolean;
hasFailedFiles: boolean;
hasDeletedFiles: boolean;
added: number;
deleted: number;
hasDiffStats: boolean;
}) {
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")} aria-label={summary}>
<div
className={cn(
"flex max-w-full items-center gap-1.5 px-1 py-1",
"text-[12.5px] text-muted-foreground/72",
)}
>
<StreamingLabelSheen active={active} className="min-w-0">
{singleFilePath
? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)
: summary}
</StreamingLabelSheen>
{singleFilePath ? (
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
testId="activity-header-file-reference"
/>
) : null}
{hasDiffStats ? (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
<DiffPair added={added} deleted={deleted} />
</span>
) : null}
</div>
{showRows ? (
<div className="mt-0.5 pl-4">
<FileEditGroup edits={edits} />
</div>
) : null}
</div>
);
}
function shortFileName(path: string): string {
return path.split(/[\\/]/).pop() || path;
}
@ -1039,6 +1138,10 @@ function isMcpRunTraceLine(line: string): boolean {
return MCP_TOOL_NAME_RE.test(line.trim().split("(", 1)[0] ?? "");
}
function isFileEditTraceLine(line: string): boolean {
return /^(write_file|edit_file|apply_patch)\(/.test(line.trim());
}
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
if (!match) return null;
@ -1365,18 +1468,21 @@ function mcpRunLabelDefault(run: McpRunSummary, active: boolean): string {
return active && run.status === "running" ? "Using" : "Used";
}
function fileActivityVerb(editing: boolean, failed: boolean): string {
function fileActivityVerb(editing: boolean, failed: boolean, deleted: boolean): string {
if (failed) return "Failed";
if (deleted) return editing ? "Deleting" : "Deleted";
return editing ? "Editing" : "Edited";
}
function fileActivitySummaryKey(editing: boolean, failed: boolean): string {
function fileActivitySummaryKey(editing: boolean, failed: boolean, deleted: boolean): string {
if (failed) return "message.fileActivityFailedOne";
if (deleted) return editing ? "message.fileActivityDeletingOne" : "message.fileActivityDeletedOne";
return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne";
}
function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
function fileActivityManySummaryKey(editing: boolean, failed: boolean, deleted: boolean): string {
if (failed) return "message.fileActivityFailedMany";
if (deleted) return editing ? "message.fileActivityDeletingMany" : "message.fileActivityDeletedMany";
return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
}
@ -1419,6 +1525,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
hasSuccessfulChange: boolean;
hasActiveEditing: boolean;
hasFailed: boolean;
operation?: UIFileEdit["operation"];
error?: string;
}
@ -1440,6 +1547,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
hasSuccessfulChange: false,
hasActiveEditing: false,
hasFailed: false,
operation: undefined,
};
byPath.set(key, summary);
order.push(key);
@ -1451,6 +1559,9 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
if (edit.absolute_path) {
summary.absolute_path = edit.absolute_path;
}
if (edit.operation === "delete") {
summary.operation = "delete";
}
summary.pending = summary.pending || !!edit.pending || !edit.path;
if (!edit.path && edit.pending) {
if (active && edit.status === "editing") {
@ -1515,6 +1626,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
approximate: summary.approximate,
binary: summary.binary,
status,
operation: summary.operation,
pending: summary.pending && !summary.path,
error: summary.error,
}];
@ -1525,6 +1637,23 @@ function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">):
return edit.added > 0 || edit.deleted > 0;
}
function formatFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
const cleaned = firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}
function CliRunGroup({
runs,
active,
@ -1758,8 +1887,15 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
: "";
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs">
<li
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs"
title={failureDetail || edit.absolute_path || edit.path}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-5 w-5 shrink-0 place-items-center text-muted-foreground/50">
{failed ? (
@ -1789,13 +1925,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
/>
)}
{failed ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
{t("message.fileEditFailed", { defaultValue: "Failed" })}
</span>
) : null}
{edit.approximate && !failed ? (
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
{failureDetail}
</span>
) : null}
</div>

View File

@ -62,10 +62,15 @@ function resolveCopy(
title: t("errors.messageTooBig.title"),
body: t("errors.messageTooBig.body"),
};
case "workspace_scope_rejected":
return {
title: t("errors.workspaceScopeRejected.title"),
body: t("errors.workspaceScopeRejected.body"),
};
default: {
// Exhaustiveness guard: if a new StreamError kind is added, TS will
// complain here until we add a corresponding i18n branch.
const _exhaustive: never = error.kind;
const _exhaustive: never = error;
return { title: String(_exhaustive), body: "" };
}
}

View File

@ -43,6 +43,10 @@ import {
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
WorkspaceAccessMenu,
WorkspaceProjectPicker,
} from "@/components/thread/WorkspaceControls";
import {
useAttachedImages,
type AttachedImage,
@ -58,6 +62,8 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
SlashCommand,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import {
inferProviderFromModelName,
@ -88,6 +94,7 @@ interface ThreadComposerProps {
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
imageGenerationEnabled?: boolean;
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
@ -95,6 +102,12 @@ interface ThreadComposerProps {
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@ -471,11 +484,18 @@ export function ThreadComposer({
slashCommands = [],
cliApps = [],
mcpPresets = [],
imageGenerationEnabled = true,
imageMode: controlledImageMode,
onImageModeChange,
onStop,
runStartedAt = null,
goalState,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onWorkspaceScopeChange,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
@ -495,7 +515,13 @@ export function ThreadComposer({
const aspectControlRef = useRef<HTMLDivElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const isHero = variant === "hero";
const imageMode = controlledImageMode ?? uncontrolledImageMode;
const showProjectPicker =
isHero
&& !!workspaceDefaultScope
&& !!onWorkspaceScopeChange
&& workspaceControls?.can_change_project !== false;
const requestedImageMode = controlledImageMode ?? uncontrolledImageMode;
const imageMode = imageGenerationEnabled && requestedImageMode;
const setImageMode = useCallback(
(enabled: boolean) => {
if (controlledImageMode === undefined) {
@ -505,6 +531,13 @@ export function ThreadComposer({
},
[controlledImageMode, onImageModeChange],
);
useEffect(() => {
if (imageGenerationEnabled || !requestedImageMode) return;
setImageMode(false);
setAspectMenuOpen(false);
}, [imageGenerationEnabled, requestedImageMode, setImageMode]);
const resolvedPlaceholder = isStreaming
? t("thread.composer.placeholderStreaming")
: imageMode
@ -574,16 +607,17 @@ export function ThreadComposer({
}, [disabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => {
if (!(isStreaming && onStop)) return slashCommands;
if (slashCommands.some((command) => command.command === "/stop")) return slashCommands;
const baseCommands = slashCommands.filter((command) => command.command !== "/stop");
if (!(isStreaming && onStop)) return baseCommands;
const stopCommand = slashCommands.find((command) => command.command === "/stop") ?? {
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn for this chat.",
icon: "square",
};
return [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn for this chat.",
icon: "square",
},
...slashCommands,
stopCommand,
...baseCommands,
];
}, [isStreaming, onStop, slashCommands]);
@ -845,13 +879,6 @@ export function ThreadComposer({
const chooseSlashCommand = useCallback(
(command: SlashCommand) => {
const nextRecents = [
command.command,
...recentSlashCommands.filter((item) => item !== command.command),
].slice(0, SLASH_RECENTS_LIMIT);
setRecentSlashCommands(nextRecents);
storeSlashRecents(nextRecents);
if (command.command === "/stop" && isStreaming && onStop) {
onStop();
setValue("");
@ -862,6 +889,13 @@ export function ThreadComposer({
return;
}
const nextRecents = [
command.command,
...recentSlashCommands.filter((item) => item !== command.command),
].slice(0, SLASH_RECENTS_LIMIT);
setRecentSlashCommands(nextRecents);
storeSlashRecents(nextRecents);
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
@ -1051,10 +1085,15 @@ export function ThreadComposer({
const attachButtonDisabled = disabled || full;
const showStopButton = isStreaming && !!onStop;
const centerHeroPlaceholder =
isHero && value.length === 0 && images.length === 0 && !isStreaming;
const inputTextClasses = cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
? cn(
"min-h-[78px] px-5 text-[15px] leading-6",
centerHeroPlaceholder ? "pb-2 pt-[27px]" : "pb-1.5 pt-4",
)
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
);
@ -1093,11 +1132,12 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
"group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
"after:pointer-events-none after:absolute after:inset-[-1px] after:rounded-[inherit] after:border after:border-blue-300/75 after:opacity-0 after:transition-opacity after:duration-200 focus-within:after:opacity-100 dark:after:border-blue-400/55",
isHero
? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]"
: "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]",
"focus-within:ring-1 focus-within:ring-foreground/8",
"focus-within:border-blue-300/75 dark:focus-within:border-blue-400/55",
disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active &&
@ -1184,11 +1224,11 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"flex items-center justify-between gap-2",
isHero ? "px-4 pb-4" : "px-3 pb-2",
"flex items-center justify-between",
isHero ? cn("gap-1.5 px-4", showProjectPicker ? "pb-1.5" : "pb-3.5") : "gap-2 px-3 pb-2",
)}
>
<div className="flex min-w-0 items-center gap-2">
<div className={cn("flex min-w-0 flex-1 items-center", isHero ? "gap-1.5" : "gap-2")}>
<input
ref={fileInputRef}
type="file"
@ -1207,36 +1247,46 @@ export function ThreadComposer({
className={cn(
"rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
</Button>
<div ref={aspectControlRef} className="relative flex items-center gap-1">
<Button
type="button"
variant="ghost"
disabled={disabled}
aria-pressed={imageMode}
aria-label={t("thread.composer.imageMode.toggle")}
onClick={() => {
setImageMode(!imageMode);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
className={cn(
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
"h-9 text-[12px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
)}
>
<ImageIcon className={cn("mr-1.5", isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
{t("thread.composer.imageMode.label")}
</Button>
{imageMode ? (
{workspaceScope ? (
<WorkspaceAccessMenu
scope={workspaceScope}
disabled={disabled || workspaceScopeDisabled}
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
isHero={isHero}
onChange={onWorkspaceScopeChange}
/>
) : null}
{imageGenerationEnabled ? (
<div ref={aspectControlRef} className="relative flex items-center gap-1">
<Button
type="button"
variant="ghost"
disabled={disabled}
aria-pressed={imageMode}
aria-label={t("thread.composer.imageMode.toggle")}
onClick={() => {
setImageMode(!imageMode);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
className={cn(
"max-w-[11rem] rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
isHero ? "h-8 text-[11.5px]" : "h-9 text-[12px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
)}
>
<ImageIcon className={cn("mr-1.5", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
<span className="truncate">{t("thread.composer.imageMode.label")}</span>
</Button>
{imageMode ? (
<Button
type="button"
variant="ghost"
@ -1247,25 +1297,28 @@ export function ThreadComposer({
onClick={() => setAspectMenuOpen((open) => !open)}
className={cn(
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
"h-9 text-[12px]",
isHero ? "h-8 text-[11.5px]" : "h-9 text-[12px]",
)}
>
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
<ChevronDown className={cn("ml-1.5", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
</Button>
) : null}
{imageMode && aspectMenuOpen ? (
<ImageAspectMenu
selected={imageAspectRatio}
isHero={isHero}
onSelect={(ratio) => {
setImageAspectRatio(ratio);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
/>
) : null}
</div>
) : null}
{imageMode && aspectMenuOpen ? (
<ImageAspectMenu
selected={imageAspectRatio}
isHero={isHero}
onSelect={(ratio) => {
setImageAspectRatio(ratio);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
/>
) : null}
</div>
) : null}
</div>
<div className={cn("flex shrink-0 items-center", isHero ? "gap-1.5" : "gap-2")}>
{modelLabel ? (
<ComposerModelBadge
label={modelLabel}
@ -1274,39 +1327,42 @@ export function ThreadComposer({
isHero={isHero}
/>
) : null}
{!isHero ? (
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
{t("thread.composer.sendHint")}
</span>
) : null}
<Button
type={showStopButton ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? onStop : undefined}
className={cn(
"rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
isHero ? "h-8 w-8" : "h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{showStopButton ? (
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-3.5 w-3.5")} />
) : isStreaming ? (
<Loader2 className={cn(isHero ? "h-4 w-4" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4 w-4" : "h-4 w-4")} />
)}
</Button>
</div>
<span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
<Button
type={showStopButton ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? onStop : undefined}
className={cn(
"rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
"h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{showStopButton ? (
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-2.5 w-2.5")} />
) : isStreaming ? (
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
)}
</Button>
</div>
<WorkspaceProjectPicker
isHero={isHero}
disabled={disabled || workspaceScopeDisabled}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
error={workspaceError}
onChange={onWorkspaceScopeChange}
/>
</div>
</form>
);
@ -1338,14 +1394,14 @@ function ComposerModelBadge({
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
isHero ? "h-9 max-w-[13.5rem] gap-2 px-2.5 text-[12px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)}
>
<span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
"h-5 w-5",
isHero ? "h-[18px] w-[18px]" : "h-5 w-5",
)}
style={{
borderColor: brand ? `${brand.color}28` : undefined,
@ -1357,21 +1413,21 @@ function ComposerModelBadge({
<img
src={logoUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
onError={() => setLogoIndex((index) => index + 1)}
/>
) : brand ? (
<span
className={cn(
"grid h-full w-full place-items-center rounded-full text-white",
"text-[8px]",
isHero ? "text-[7.5px]" : "text-[8px]",
)}
style={{ backgroundColor: brand.color }}
>
{brand.initials.slice(0, 2)}
</span>
) : (
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3 w-3" : "h-3 w-3")} />
)}
</span>
<span className="truncate">{label}</span>
@ -1440,6 +1496,23 @@ interface CliAppMentionPaletteProps {
onChoose: (candidate: MentionCandidate) => void;
}
function useSelectedOptionScroll(selectedIndex: number) {
const containerRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const option = container.querySelector<HTMLElement>(
`[data-palette-index="${selectedIndex}"]`,
);
if (typeof option?.scrollIntoView === "function") {
option.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
return containerRef;
}
function ImageAspectMenu({
selected,
isHero,
@ -1506,6 +1579,7 @@ function CliAppMentionPalette({
0,
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
const listRef = useSelectedOptionScroll(selectedIndex);
return (
<div
role="listbox"
@ -1522,7 +1596,7 @@ function CliAppMentionPalette({
<div className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
{t("thread.composer.mentions.label")}
</div>
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
<div ref={listRef} className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
{candidates.map((candidate, index) => {
const selected = index === selectedIndex;
const name = candidate.name;
@ -1540,6 +1614,7 @@ function CliAppMentionPalette({
key={`${candidate.kind}-${name}`}
type="button"
role="option"
data-palette-index={index}
aria-selected={selected}
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
onMouseEnter={() => onHover(index)}
@ -1640,6 +1715,7 @@ function SlashCommandPalette({
0,
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
const listRef = useSelectedOptionScroll(selectedIndex);
return (
<div
role="listbox"
@ -1653,7 +1729,7 @@ function SlashCommandPalette({
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
<div ref={listRef} className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{commands.map((command, index) => {
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
const selected = index === selectedIndex;
@ -1669,6 +1745,7 @@ function SlashCommandPalette({
key={command.command}
type="button"
role="option"
data-palette-index={index}
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {

View File

@ -9,7 +9,7 @@ interface ThreadHeaderProps {
onToggleSidebar: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleOnDesktop?: boolean;
hideSidebarToggleForHostChrome?: boolean;
minimal?: boolean;
}
@ -18,7 +18,7 @@ export function ThreadHeader({
onToggleSidebar,
theme,
onToggleTheme,
hideSidebarToggleOnDesktop = false,
hideSidebarToggleForHostChrome = false,
minimal = false,
}: ThreadHeaderProps) {
const { t } = useTranslation();
@ -32,7 +32,7 @@ export function ThreadHeader({
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:hidden",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
@ -57,7 +57,7 @@ export function ThreadHeader({
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:hidden",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />

View File

@ -59,7 +59,7 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
cluster.push(current);
i += 1;
}
out.push({ type: "cluster", messages: cluster });
pushActivityCluster(out, cluster);
continue;
}
const previous = out[out.length - 1];
@ -85,6 +85,42 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
return out;
}
function pushActivityCluster(out: DisplayUnit[], cluster: UIMessage[]) {
const previous = out[out.length - 1];
if (
previous?.type !== "single"
|| !shouldPlaceLateActivityBeforeAssistant(out, previous.message)
) {
out.push({ type: "cluster", messages: cluster });
return;
}
const beforeAssistant = out[out.length - 2];
if (beforeAssistant?.type === "cluster" && canMergeActivityClusters(beforeAssistant.messages, cluster)) {
beforeAssistant.messages.push(...cluster);
return;
}
out.splice(out.length - 1, 0, { type: "cluster", messages: cluster });
}
function shouldPlaceLateActivityBeforeAssistant(out: DisplayUnit[], message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.isStreaming) return true;
if (hasTurnLatency(message)) return true;
const beforeAssistant = out[out.length - 2];
return beforeAssistant?.type === "cluster";
}
function hasTurnLatency(message: UIMessage): boolean {
return (
typeof message.latencyMs === "number"
&& Number.isFinite(message.latencyMs)
&& message.latencyMs >= 0
);
}
function clusterSegmentId(messages: UIMessage[]): string | undefined {
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
}
@ -115,6 +151,19 @@ function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boole
return segmentId === message.activitySegmentId;
}
function canMergeActivityClusters(target: UIMessage[], incoming: UIMessage[]): boolean {
let segmentId = clusterSegmentId(target);
let includesFileEdits = clusterHasFileEdits(target);
for (const message of incoming) {
if (!canJoinActivityCluster(segmentId, includesFileEdits, message)) return false;
if (!segmentId && message.activitySegmentId) {
segmentId = message.activitySegmentId;
}
includesFileEdits = includesFileEdits || hasFileEdits(message);
}
return true;
}
function assistantHasInlineReasoning(message: UIMessage): boolean {
return (
message.role === "assistant"
@ -261,8 +310,14 @@ function activityClusterTurnLatencyMs(
}
function currentActivityClusterIndex(units: DisplayUnit[]): number {
const last = units.length - 1;
return units[last]?.type === "cluster" ? last : -1;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "cluster") return i;
if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
if (unit.message.role === "user") break;
return -1;
}
return -1;
}
function unitKey(unit: DisplayUnit, index: number): string {

View File

@ -1,16 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
BookOpen,
ChevronRight,
Code2,
ImageIcon,
LayoutGrid,
Lightbulb,
MoreHorizontal,
Palette,
Sparkles,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
@ -31,7 +19,16 @@ import {
isMcpPresetsPayload,
} from "@/lib/mcp-preset-events";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type { ChatSummary, CliAppInfo, McpPresetInfo, SettingsPayload, SlashCommand, UIMessage } from "@/lib/types";
import type {
ChatSummary,
CliAppInfo,
McpPresetInfo,
SettingsPayload,
SlashCommand,
UIMessage,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
@ -60,11 +57,19 @@ interface ThreadShellProps {
onToggleSidebar: () => void;
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: () => Promise<string | null>;
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleOnDesktop?: boolean;
hideSidebarToggleForHostChrome?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@ -110,23 +115,17 @@ function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload |
};
}
const QUICK_ACTION_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
{ key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" },
{ key: "code", icon: Code2, tone: "text-[#eba45d]" },
{ key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" },
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
const HERO_GREETING_KEYS = [
"thread.empty.greetings.workOn",
"thread.empty.greetings.start",
"thread.empty.greetings.build",
"thread.empty.greetings.tackle",
] as const;
const IMAGE_QUICK_ACTION_KEYS = [
{ key: "icon", icon: ImageIcon, tone: "text-[#4f9de8]" },
{ key: "sticker", icon: Sparkles, tone: "text-[#f25b8f]" },
{ key: "poster", icon: Palette, tone: "text-[#eba45d]" },
{ key: "product", icon: LayoutGrid, tone: "text-[#53c59d]" },
{ key: "portrait", icon: ImageIcon, tone: "text-[#a877e7]" },
{ key: "edit", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
] as const;
function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
const index = Math.floor(Math.random() * HERO_GREETING_KEYS.length);
return HERO_GREETING_KEYS[index] ?? HERO_GREETING_KEYS[0];
}
interface PendingFirstMessage {
content: string;
@ -142,7 +141,15 @@ export function ThreadShell({
onTurnEnd,
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleOnDesktop = false,
hideSidebarToggleForHostChrome = false,
hideHeader = false,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onWorkspaceScopeChange,
settingsSnapshot = null,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@ -159,8 +166,9 @@ export function ThreadShell({
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroImageMode, setHeroImageMode] = useState(false);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
@ -198,22 +206,46 @@ export function ThreadShell({
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
const wasShowingHeroComposerRef = useRef(showHeroComposer);
const modelBadge = useMemo(
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const imageGenerationEnabled = settings?.image_generation.enabled === true;
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
}
wasShowingHeroComposerRef.current = showHeroComposer;
}, [showHeroComposer]);
const withWorkspaceScope = useCallback(
(options?: SendOptions): SendOptions | undefined => {
if (!workspaceScope) return options;
return {
...(options ?? {}),
workspaceScope,
};
},
[workspaceScope],
);
const refreshModelSettings = useCallback(async () => {
try {
setSettings(await fetchSettings(token));
} catch {
setSettings(null);
if (!settingsSnapshot) setSettings(null);
}
}, [token]);
}, [settingsSnapshot, token]);
useEffect(() => {
if (settingsSnapshot) {
setSettings(settingsSnapshot);
return;
}
void refreshModelSettings();
}, [refreshModelSettings]);
}, [refreshModelSettings, settingsSnapshot]);
useEffect(() => {
return client.onRuntimeModelUpdate(() => {
@ -433,64 +465,22 @@ export function ThreadShell({
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = { content, images, options };
const newId = await onCreateChat?.();
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
const newId = await onCreateChat?.(workspaceScope);
if (!newId) {
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onCreateChat],
[booting, onCreateChat, withWorkspaceScope, workspaceScope],
);
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
setScrollToBottomSignal((value) => value + 1);
send(content, images, options);
send(content, images, withWorkspaceScope(options));
},
[send],
);
const handleQuickAction = useCallback(
(prompt: string) => {
const options: SendOptions | undefined = heroImageMode
? { imageGeneration: { enabled: true, aspect_ratio: null } }
: undefined;
if (session) {
handleThreadSend(prompt, undefined, options);
return;
}
void handleWelcomeSend(prompt, undefined, options);
},
[handleThreadSend, handleWelcomeSend, heroImageMode, session],
);
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
const quickActionPrefix = heroImageMode
? "thread.empty.imageQuickActions"
: "thread.empty.quickActions";
const quickActions = (
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
{quickActionItems.map(({ key, icon: Icon, tone }) => {
const title = t(`${quickActionPrefix}.${key}.title`);
const prompt = t(`${quickActionPrefix}.${key}.prompt`);
return (
<button
key={key}
type="button"
onClick={() => handleQuickAction(prompt)}
disabled={booting || isStreaming}
className="group flex min-h-[136px] flex-col justify-between rounded-[20px] border border-black/[0.035] bg-card px-5 py-5 text-left shadow-[0_14px_34px_rgba(15,23,42,0.07)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.10)] disabled:pointer-events-none disabled:opacity-60 dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]"
>
<Icon className={`h-[18px] w-[18px] ${tone}`} strokeWidth={2} />
<span className="max-w-[7.5rem] text-[15px] font-medium leading-[1.28] tracking-[-0.01em] text-foreground/82">
{title}
</span>
<ChevronRight className="h-4 w-4 self-end text-muted-foreground/45 transition-colors group-hover:text-muted-foreground" />
</button>
);
})}
</div>
[send, withWorkspaceScope],
);
const composer = (
@ -518,11 +508,18 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
runStartedAt={runStartedAt}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>
) : (
<ThreadComposer
@ -541,13 +538,19 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>
)}
{showHeroComposer ? quickActions : null}
</>
);
@ -558,21 +561,23 @@ export function ThreadShell({
) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
{t("thread.empty.greeting")}
{t(heroGreetingKey)}
</h1>
</div>
);
return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
minimal={!session && !loading}
/>
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
minimal={!session && !loading}
/>
) : null}
<ThreadViewport
messages={displayMessages}
isStreaming={isStreaming}

View File

@ -271,9 +271,11 @@ export function ThreadViewport({
</div>
) : (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
<div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
<div className="flex w-full max-w-[58rem] flex-col gap-6">
{emptyState}
<div className="flex w-full flex-1 items-center justify-center py-10 sm:py-12">
<div className="relative w-full max-w-[58rem]">
<div className="absolute inset-x-0 bottom-[calc(100%+1.5rem)] flex justify-center">
{emptyState}
</div>
<div className="w-full">{composer}</div>
</div>
</div>

View File

@ -0,0 +1,326 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import type {
WorkspaceAccessMode,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { getHostApi } from "@/lib/runtime";
import { cn } from "@/lib/utils";
import {
isAbsoluteWorkspacePath,
projectNameFromPath,
scopeWithAccessMode,
selectedProjectScope,
shortWorkspacePath,
} from "@/lib/workspace";
export function WorkspaceProjectPicker({
isHero,
disabled,
scope,
defaultScope,
controls,
error,
onChange,
}: {
isHero: boolean;
disabled?: boolean;
scope: WorkspaceScopePayload | null;
defaultScope: WorkspaceScopePayload | null;
controls: WorkspacesPayload["controls"] | null;
error?: string | null;
onChange?: (scope: WorkspaceScopePayload) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [pathDraft, setPathDraft] = useState("");
const [pathError, setPathError] = useState<string | null>(null);
const [pickingFolder, setPickingFolder] = useState(false);
const currentProjectScope = selectedProjectScope(scope, defaultScope);
const projectLabel = currentProjectScope
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
: t("thread.composer.workspace.projectPlaceholder");
const visible = isHero
&& !!defaultScope
&& !!onChange
&& controls?.can_change_project !== false;
const hostApi = getHostApi();
const nativeProjectPicker = !!hostApi;
useEffect(() => {
if (!open) return;
setPathDraft(currentProjectScope?.project_path ?? "");
setPathError(null);
}, [currentProjectScope?.project_path, open]);
useEffect(() => {
if (error && visible) setOpen(true);
}, [error, visible]);
const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => {
const base = scope ?? defaultScope;
const trimmed = projectPath.trim();
if (!base || !onChange) return;
if (!trimmed || !isAbsoluteWorkspacePath(trimmed)) {
setPathError(t("workspace.dialog.absolutePathRequired"));
return;
}
onChange({
...base,
project_path: trimmed,
project_name: projectName || projectNameFromPath(trimmed),
restrict_to_workspace: base.access_mode === "restricted",
});
setPathError(null);
setOpen(false);
},
[defaultScope, onChange, scope, t],
);
const pickNativeFolder = useCallback(async () => {
if (!hostApi || disabled) return;
setPickingFolder(true);
try {
const picked = await hostApi.pickFolder();
if (picked) applyProjectPath(picked);
} catch (err) {
setPathError((err as Error).message);
} finally {
setPickingFolder(false);
}
}, [applyProjectPath, disabled, hostApi]);
if (!visible || !defaultScope || !onChange) return null;
if (nativeProjectPicker) {
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<button
type="button"
disabled={disabled || pickingFolder}
aria-label={t("thread.composer.workspace.projectAria")}
title={currentProjectScope?.project_path}
onClick={() => void pickNativeFolder()}
className={cn(
"inline-flex h-7 max-w-[18rem] items-center gap-2 rounded-full px-2.5",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
</button>
{pathError || error ? (
<span role="alert" className="ml-2 truncate text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</span>
) : null}
</div>
);
}
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")}
className={cn(
"inline-flex h-7 max-w-[18rem] items-center gap-2 rounded-full px-2.5",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
sideOffset={8}
className="w-[min(25rem,calc(100vw-2rem))] rounded-[22px]"
>
<DropdownMenuItem
onSelect={() => applyProjectPath(defaultScope.project_path, defaultScope.project_name)}
className="flex min-h-[48px] cursor-default gap-3 rounded-[16px] px-3 py-2.5 focus:bg-muted/55"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/80">
<Folder className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold text-foreground">
{t("workspace.dialog.defaultProject")}
</span>
<span className="block truncate text-[11.5px] text-muted-foreground">
{shortWorkspacePath(defaultScope.project_path)}
</span>
</span>
{!currentProjectScope ? <Check className="h-4 w-4 text-foreground/80" /> : null}
</DropdownMenuItem>
<div className="my-1 h-px bg-border/45" />
<div
className="space-y-1.5 px-1.5 py-1.5"
onKeyDown={(event) => {
if (event.key !== "Escape") event.stopPropagation();
}}
>
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
applyProjectPath(pathDraft);
}}
>
<Input
value={pathDraft}
disabled={disabled}
onChange={(event) => {
setPathDraft(event.target.value);
setPathError(null);
}}
placeholder={t("workspace.dialog.manualPlaceholder")}
aria-label={t("workspace.dialog.manual")}
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",
)}
/>
<Button
type="submit"
disabled={disabled || !pathDraft.trim()}
className="h-9 shrink-0 rounded-full px-3 text-[12px]"
>
{t("workspace.dialog.usePath")}
</Button>
</form>
{pathError || error ? (
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</p>
) : null}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function WorkspaceAccessMenu({
scope,
disabled,
canUseFullAccess,
isHero,
onChange,
}: {
scope: WorkspaceScopePayload;
disabled?: boolean;
canUseFullAccess: boolean;
isHero: boolean;
onChange?: (scope: WorkspaceScopePayload) => void;
}) {
const { t } = useTranslation();
const mode = scope.access_mode;
const isFull = mode === "full";
const setMode = (value: WorkspaceAccessMode) => {
if (value === "full" && !canUseFullAccess) return;
if (value === mode) return;
onChange?.(scopeWithAccessMode(scope, value));
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled || !onChange}>
<Button
type="button"
variant="ghost"
aria-label={t("thread.composer.workspace.accessAria")}
className={cn(
"max-w-[12.5rem] rounded-[10px] border border-transparent font-semibold shadow-none",
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
isFull
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
: "bg-transparent text-muted-foreground hover:bg-foreground/[0.045] hover:text-foreground dark:hover:bg-white/[0.06]",
)}
>
{isFull ? (
<AlertTriangle className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
) : (
<Hand className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
)}
<span className="truncate">
{t(isFull ? "thread.composer.workspace.full" : "thread.composer.workspace.default")}
</span>
<ChevronDown className={cn("ml-1.5 shrink-0", isHero ? "h-3 w-3" : "h-3 w-3")} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<AccessMenuItem
icon={<Hand className="h-4 w-4" />}
label={t("thread.composer.workspace.default")}
selected={mode === "restricted"}
onSelect={() => setMode("restricted")}
/>
<AccessMenuItem
icon={<AlertTriangle className="h-4 w-4" />}
label={t("thread.composer.workspace.full")}
selected={mode === "full"}
disabled={!canUseFullAccess}
warning
onSelect={() => setMode("full")}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AccessMenuItem({
icon,
label,
selected,
disabled,
warning,
onSelect,
}: {
icon: ReactNode;
label: string;
selected: boolean;
disabled?: boolean;
warning?: boolean;
onSelect: () => void;
}) {
return (
<DropdownMenuItem
disabled={disabled}
onSelect={onSelect}
className={cn(
"flex h-10 items-center gap-3 rounded-xl px-3 text-[13.5px] font-semibold",
warning && "text-orange-600 focus:text-orange-600 dark:text-orange-300 dark:focus:text-orange-300",
)}
>
<span className="grid h-5 w-5 shrink-0 place-items-center text-current" aria-hidden>
{icon}
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
}

View File

@ -5,7 +5,6 @@ import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
@ -128,8 +127,5 @@ export {
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@ -5,9 +5,7 @@ import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -114,12 +112,9 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -11,6 +11,12 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuContentClassName =
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
const menuItemClassName =
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
@ -20,14 +26,15 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
menuItemClassName,
"data-[state=open]:bg-foreground/[0.055] dark:data-[state=open]:bg-white/[0.08]",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
@ -39,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg",
menuContentClassName,
className,
)}
{...props}
@ -61,7 +68,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
menuContentClassName,
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
@ -79,7 +87,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
menuItemClassName,
inset && "pl-8",
className,
)}
@ -95,15 +103,16 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
menuItemClassName,
"pl-8 pr-2.5",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
<Check className="h-3.5 w-3.5" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@ -119,12 +128,13 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
menuItemClassName,
"pl-8 pr-2.5",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
@ -143,7 +153,7 @@ const DropdownMenuLabel = React.forwardRef<
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
"px-2.5 pb-1.5 pt-1 text-[12px] font-semibold text-muted-foreground",
inset && "pl-8",
className,
)}
@ -158,7 +168,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
{...props}
/>
));

View File

@ -6,8 +6,6 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = React.forwardRef<
@ -90,14 +88,6 @@ const SheetContent = React.forwardRef<
));
SheetContent.displayName = DialogPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@ -110,4 +100,4 @@ const SheetTitle = React.forwardRef<
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export { Sheet, SheetTrigger, SheetClose, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetTitle };
export { Sheet, SheetContent, SheetTitle };

View File

@ -81,6 +81,14 @@
}
@layer utilities {
.host-drag-region {
-webkit-app-region: drag;
}
.host-no-drag {
-webkit-app-region: no-drag;
}
.shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
}

View File

@ -16,9 +16,11 @@ import type {
OutboundMcpPresetMention,
OutboundMedia,
GoalStateWsPayload,
ToolProgressEvent,
UIImage,
UIFileEdit,
UIMessage,
WorkspaceScopePayload,
} from "@/lib/types";
interface StreamBuffer {
@ -35,6 +37,8 @@ type PendingStreamEvent =
| { kind: "delta"; text: string }
| { kind: "reasoning"; text: string };
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
/** 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. */
@ -194,15 +198,6 @@ function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMess
return prev;
}
function findLatestAssistantAnswerIndex(prev: UIMessage[]): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.role === "assistant" && m.kind !== "trace") return i;
if (m.role === "user") break;
}
return null;
}
function absorbCompleteAssistantMessage(
prev: UIMessage[],
message: Omit<UIMessage, "id" | "role" | "createdAt">,
@ -235,6 +230,101 @@ function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): strin
return `${edit.tool}|${edit.path}`;
}
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) => fileEditKey(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(fileEditKey));
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 demoteInterruptedAssistantToActivity(
prev: UIMessage[],
segmentId: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const message = prev[i];
if (message.role === "user") break;
if (
message.role !== "assistant"
|| message.kind === "trace"
|| !message.isStreaming
|| !message.content.trim()
|| message.media?.length
) {
continue;
}
const reasoning = [message.reasoning, message.content]
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
.join("\n\n");
const demoted: UIMessage = {
...message,
content: "",
reasoning,
reasoningStreaming: false,
isStreaming: false,
activitySegmentId: message.activitySegmentId ?? segmentId,
};
return replaceMessageAt(prev, i, demoted);
}
return prev;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
@ -285,11 +375,15 @@ function findFileEditTraceIndex(
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace" || !candidate.fileEdits?.length) continue;
if (candidate.kind !== "trace") continue;
if (segmentId && candidate.activitySegmentId === segmentId) return i;
for (const existing of candidate.fileEdits) {
for (const existing of candidate.fileEdits ?? []) {
if (incomingKeys.has(fileEditKey(existing))) return i;
}
for (const event of candidate.toolEvents ?? []) {
const key = toolEventFileEditKey(event);
if (key && incomingKeys.has(key)) return i;
}
}
return null;
}
@ -315,6 +409,7 @@ export interface SendOptions {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
}
export function useNanobotStream(
@ -422,7 +517,12 @@ export function useNanobotStream(
const cursor = activeAssistantRef.current;
if (!cursor) return null;
const indexed = prev[cursor.index];
if (indexed?.id === cursor.id && indexed.role === "assistant" && indexed.kind !== "trace") {
if (
indexed?.id === cursor.id
&& indexed.role === "assistant"
&& indexed.kind !== "trace"
&& indexed.isStreaming
) {
return cursor.index;
}
const idx = prev.findIndex((m) => m.id === cursor.id);
@ -431,7 +531,7 @@ export function useNanobotStream(
return null;
}
const found = prev[idx];
if (found.role !== "assistant" || found.kind === "trace") {
if (found.role !== "assistant" || found.kind === "trace" || !found.isStreaming) {
activeAssistantRef.current = null;
return null;
}
@ -520,8 +620,7 @@ export function useNanobotStream(
if (finalAnswerText !== undefined) {
const targetIndex =
resolveActiveAssistantIndex(next)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current)
?? findLatestAssistantAnswerIndex(next);
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
@ -662,6 +761,7 @@ export function useNanobotStream(
if ("goal_state" in ev && ev.goal_state != null && typeof ev.goal_state === "object") {
setGoalState(ev.goal_state);
}
setRunStartedAt(null);
// Definitive signal that the turn is fully complete. Cancel any
// pending debounce timer and stop the loading indicator immediately.
if (streamEndTimerRef.current !== null) {
@ -710,16 +810,20 @@ export function useNanobotStream(
// so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") {
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
const structuredLines = toolTraceLinesFromEvents(ev.tool_events);
const lines = structuredLines.length > 0
? structuredLines
: ev.text
? [ev.text]
: [];
if (lines.length === 0) return;
setMessages((prev) => {
const segmentId = ensureActivitySegmentId();
const last = prev[prev.length - 1];
const base = demoteInterruptedAssistantToActivity(prev, segmentId);
const visibleStructuredEvents = filterCoveredFileEditToolEvents(base, structuredEvents);
const structuredLines = toolTraceLinesFromEvents(visibleStructuredEvents);
const lines = structuredLines.length > 0
? structuredLines
: structuredEvents.length > 0
? []
: ev.text
? [ev.text]
: [];
if (lines.length === 0) return base;
const last = base[base.length - 1];
if (
last
&& last.kind === "trace"
@ -731,7 +835,7 @@ export function useNanobotStream(
: last.content
? [last.content]
: [];
const mergedLines = structuredLines.length > 0
const mergedLines = visibleStructuredEvents.length > 0
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
: null;
const merged: UIMessage = {
@ -740,22 +844,22 @@ export function useNanobotStream(
content: mergedLines
? mergedLines.traces[mergedLines.traces.length - 1]
: lines[lines.length - 1],
toolEvents: structuredEvents.length
? mergeToolProgressEvents(last.toolEvents, structuredEvents)
toolEvents: visibleStructuredEvents.length
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];
return [...base.slice(0, -1), merged];
}
return [
...prev,
...base,
{
id: crypto.randomUUID(),
role: "tool",
kind: "trace",
content: lines[lines.length - 1],
traces: lines,
...(structuredEvents.length ? { toolEvents: structuredEvents } : {}),
...(visibleStructuredEvents.length ? { toolEvents: visibleStructuredEvents } : {}),
activitySegmentId: segmentId,
createdAt: Date.now(),
},
@ -810,22 +914,24 @@ export function useNanobotStream(
}
setMessages((prev) => {
let segmentId = eventSegmentId;
const targetIndex = findFileEditTraceIndex(prev, segmentId, normalized);
const base = segmentId ? demoteInterruptedAssistantToActivity(prev, segmentId) : prev;
const targetIndex = findFileEditTraceIndex(base, segmentId, normalized);
if (targetIndex !== null) {
const target = prev[targetIndex];
const target = base[targetIndex];
segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
const cleanedTarget = stripCoveredFileEditToolHints(target, normalized);
const merged: UIMessage = {
...target,
fileEdits: mergeFileEdits(target.fileEdits, normalized),
...cleanedTarget,
fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized),
activitySegmentId: segmentId,
};
return replaceMessageAt(prev, targetIndex, merged);
return replaceMessageAt(base, targetIndex, merged);
}
segmentId = segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
return [
...prev,
...base,
{
id: crypto.randomUUID(),
role: "tool",

View File

@ -9,7 +9,7 @@ import {
listSessions,
} from "@/lib/api";
import { deriveTitle } from "@/lib/format";
import type { ChatSummary, UIMessage } from "@/lib/types";
import type { ChatSummary, UIMessage, WorkspaceScopePayload } from "@/lib/types";
const EMPTY_MESSAGES: UIMessage[] = [];
@ -19,7 +19,7 @@ export function useSessions(): {
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
createChat: () => Promise<string>;
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string>;
deleteChat: (key: string) => Promise<void>;
} {
const { client, token } = useClient();
@ -66,8 +66,8 @@ export function useSessions(): {
});
}, [client, refresh]);
const createChat = useCallback(async (): Promise<string> => {
const chatId = await client.newChat();
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => {
const chatId = await client.newChat(5_000, workspaceScope);
const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key);
// Optimistic insert; a subsequent refresh will replace it with the
@ -81,6 +81,7 @@ export function useSessions(): {
updatedAt: new Date().toISOString(),
title: "",
preview: "",
workspaceScope: workspaceScope ?? null,
},
...prev.filter((s) => s.key !== key),
]);

View File

@ -12,6 +12,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
pinned_keys: [],
archived_keys: [],
title_overrides: {},
project_name_overrides: {},
tags_by_key: {},
collapsed_groups: {},
view: {
@ -90,6 +91,7 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
pinned_keys: uniqueStrings(value.pinned_keys),
archived_keys: uniqueStrings(value.archived_keys),
title_overrides: stringMap(value.title_overrides),
project_name_overrides: stringMap(value.project_name_overrides),
tags_by_key: tagsMap(value.tags_by_key),
collapsed_groups: boolMap(value.collapsed_groups),
view: {

View File

@ -71,7 +71,7 @@ export function detectNavigatorLocale(): SupportedLocale {
}
export function resolveInitialLocale(): SupportedLocale {
return readStoredLocale() ?? detectNavigatorLocale();
return readStoredLocale() ?? defaultLocale;
}
export function persistLocale(locale: SupportedLocale): void {

View File

@ -25,7 +25,9 @@
"section": "System",
"restartHint": "Restart nanobot to apply runtime changes.",
"restart": "Restart nanobot",
"restarting": "Restarting..."
"restarting": "Restarting...",
"restartEngine": "Restart engine",
"restartingEngine": "Restarting engine..."
},
"restart": {
"completed": "Restart completed in {{seconds}}s."
@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Sidebar navigation",
"globalActions": "Global actions",
"collapse": "Collapse sidebar",
"toggleTheme": "Toggle theme",
"home": "Home",
"newChat": "New chat",
"searchAria": "Search",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Search",
"searchResults": "Results",
"noSearchResults": "No matching chats.",
"recent": "Recent",
"refreshSessions": "Refresh sessions",
"settings": "Settings",
"language": {
"label": "Language",
@ -80,11 +70,11 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"browser": "Web",
"cliApps": "CLI Apps",
"mcp": "MCP",
"runtime": "Runtime",
"advanced": "Advanced",
"runtime": "System",
"advanced": "Security",
"apps": "Apps"
},
"sections": {
@ -101,10 +91,11 @@
"cliApps": "CLI apps",
"mcp": "MCP services",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Capabilities",
"integrations": "Integrations",
"apps": "Apps"
"apps": "Apps",
"nativeHost": "Native host",
"hostSafety": "App safety"
},
"models": {
"selectModel": "Select model",
@ -112,6 +103,7 @@
"newConfiguration": "New model configuration",
"newConfigurationHelp": "Save a provider and model as a one-click option.",
"configurationName": "Name",
"configurationNameHelp": "Rename this saved model configuration.",
"configurationNamePlaceholder": "Fast writing"
},
"rows": {
@ -147,20 +139,14 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"workspacePath": "Default workspace",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"cliAppsCatalog": "Catalog",
"cliAppsFilter": "Filter",
"configurationDocs": "Configuration docs"
"engine": "Engine",
"logs": "Logs",
"diagnostics": "Diagnostics"
},
"help": {
"theme": "Switch between light and dark appearance.",
@ -187,13 +173,18 @@
"defaultAspectRatio": "Used when the prompt does not choose an aspect ratio.",
"defaultImageSize": "Size hint sent to providers that support it.",
"maxImagesPerTurn": "Upper bound for one generate_image request.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; desktop apps stay untouched.",
"botName": "Shown wherever nanobot uses a display name.",
"botIcon": "Short emoji or text shown with the bot name.",
"timezone": "Used for schedules and time-aware replies.",
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; native apps stay untouched.",
"cliAppsFilter": "Search by app, category, or capability.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"logs": "Open the native engine log folder.",
"diagnostics": "Export a small runtime report for support.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"timezone": {
"select": "Select timezone",
@ -298,8 +289,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "Pending",
"restartingEngine": "Restarting"
},
"status": {
"loading": "Loading settings...",
@ -309,14 +304,24 @@
"savedRestart": "Saved. Restart nanobot to apply.",
"restartAfterSaving": "Save changes, then restart when ready.",
"savedRestartApply": "Saved. Restart when ready.",
"imageProviderRestart": "Image provider changes saved. Restart when ready."
"imageProviderRestart": "Image provider changes saved. Restart when ready.",
"hostRestartAfterSaving": "Save changes and nanobot will restart its engine.",
"hostRestartPending": "Saved. Restarting engine when ready.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Save",
"saving": "Saving",
"edit": "Edit",
"cancel": "Cancel",
"openDocs": "Open docs"
"open": "Open",
"export": "Export",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.",
@ -397,6 +402,19 @@
"featured": "Featured",
"loading": "Loading Apps...",
"empty": "No apps match this filter."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@ -404,7 +422,10 @@
"loading": "Loading…",
"noSessions": "No sessions yet.",
"showMore": "Show {{count}} more",
"collapsed": "{{count}} hidden chats",
"showLess": "Show less",
"actions": "Chat actions for {{title}}",
"newInProject": "Start a new chat in {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Loading conversation…",
"empty": {
"greeting": "What can I do for you?",
"greetings": {
"workOn": "What should we work on?",
"start": "Where should we start?",
"build": "What are we building today?",
"tackle": "What should we tackle together?"
},
"quickActions": {
"plan": {
"title": "Create a project plan",
@ -632,6 +662,13 @@
"decode_failed": "Couldn't decode this image",
"too_large": "Image is too large — try a smaller one",
"io": "Couldn't read this file"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Choose project",
"projectPlaceholder": "Select project",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Scroll to bottom",
@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Message too large",
"body": "The server rejected your last message because it exceeded the size limit. Remove some images or try smaller files, then send again."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Paste path",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}

View File

@ -25,7 +25,9 @@
"section": "Sistema",
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
"restart": "Reiniciar nanobot",
"restarting": "Reiniciando..."
"restarting": "Reiniciando...",
"restartEngine": "Reiniciar motor",
"restartingEngine": "Reiniciando motor..."
},
"restart": {
"completed": "Reinicio completado en {{seconds}} s."
@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Navegación de la barra lateral",
"globalActions": "Acciones globales",
"collapse": "Contraer barra lateral",
"toggleTheme": "Cambiar tema",
"home": "Inicio",
"newChat": "Nuevo chat",
"searchAria": "Buscar",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Buscar",
"searchResults": "Resultados",
"noSearchResults": "No hay chats coincidentes.",
"recent": "Recientes",
"refreshSessions": "Actualizar sesiones",
"settings": "Configuración",
"language": {
"label": "Idioma",
@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "Sistema",
"advanced": "Security",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Apps"
@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Capacidades",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "Servicios MCP",
"apps": "Apps"
"apps": "Apps",
"nativeHost": "App",
"hostSafety": "App safety"
},
"rows": {
"theme": "Tema",
@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "Workspace predeterminado",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "Modelo actual",
"brandLogos": "Logotipos de marca",
"cliAppsCatalog": "Catálogo de apps CLI",
"cliAppsFilter": "Filtro de apps CLI"
"cliAppsFilter": "Filtro de apps CLI",
"engine": "Motor",
"logs": "Registros",
"diagnostics": "Diagnóstico"
},
"help": {
"theme": "Cambia entre apariencia clara y oscura.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "Se usa cuando el prompt no elige una relación de aspecto.",
"defaultImageSize": "Sugerencia de tamaño enviada a los proveedores que la admiten.",
"maxImagesPerTurn": "Límite superior para una solicitud generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "Se muestra donde nanobot usa un nombre visible.",
"botIcon": "Emoji o texto corto mostrado junto al nombre del bot.",
"timezone": "Se usa para programaciones y respuestas sensibles al tiempo.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Elige el modelo que nanobot usará para las próximas respuestas.",
"selectedModelProvider": "Lo define el modelo seleccionado.",
"selectedModelValue": "Lo define el modelo seleccionado.",
"brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
"cliAppsCatalog": "Explora CLIs de apps que nanobot puede ejecutar localmente.",
"cliAppsFilter": "Busca por app, categoría o capacidad."
"cliAppsFilter": "Busca por app, categoría o capacidad.",
"logs": "Abre la carpeta de registros del motor de escritorio.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "Claro",
@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "Pendiente",
"restartingEngine": "Reiniciando"
},
"status": {
"loading": "Cargando configuración...",
@ -212,14 +206,24 @@
"savedRestart": "Guardado. Reinicia nanobot para aplicar.",
"restartAfterSaving": "Guarda los cambios y reinicia cuando estés listo.",
"savedRestartApply": "Guardado. Reinicia cuando estés listo.",
"imageProviderRestart": "Cambios del proveedor de imágenes guardados. Reinicia cuando estés listo."
"imageProviderRestart": "Cambios del proveedor de imágenes guardados. Reinicia cuando estés listo.",
"hostRestartAfterSaving": "Guarda los cambios y nanobot reiniciará su motor.",
"hostRestartPending": "Guardado. Reiniciando el motor cuando esté listo.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Guardar",
"saving": "Guardando",
"edit": "Editar",
"cancel": "Cancelar",
"openDocs": "Open docs"
"open": "Abrir",
"export": "Exportar",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.",
@ -290,6 +294,7 @@
"newConfiguration": "Nueva configuración de modelo",
"newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
"configurationName": "Nombre",
"configurationNameHelp": "Cambia el nombre de esta configuración de modelo guardada.",
"configurationNamePlaceholder": "Escritura rápida"
},
"timezone": {
@ -397,6 +402,19 @@
"featured": "Destacadas",
"loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@ -404,7 +422,10 @@
"loading": "Cargando…",
"noSessions": "Todavía no hay sesiones.",
"showMore": "Mostrar {{count}} más",
"collapsed": "{{count}} chats ocultos",
"showLess": "Mostrar menos",
"actions": "Acciones del chat {{title}}",
"newInProject": "Iniciar un chat nuevo en {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Cargando conversación…",
"empty": {
"greeting": "¿Qué puedo hacer por ti?",
"greetings": {
"workOn": "¿En qué trabajamos juntos?",
"start": "¿Por dónde empezamos?",
"build": "¿Qué construimos hoy?",
"tackle": "¿Qué resolvemos juntos?"
},
"quickActions": {
"plan": {
"title": "Crear un plan de proyecto",
@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "Usar @{{name}} como app CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Elegir proyecto",
"projectPlaceholder": "Seleccionar proyecto",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Desplazarse al final",
@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Mensaje demasiado grande",
"body": "El servidor rechazó tu último mensaje por superar el tamaño permitido. Quita algunas imágenes o usa archivos más pequeños y vuelve a enviarlo."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Pegar ruta",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}

View File

@ -25,7 +25,9 @@
"section": "Système",
"restartHint": "Redémarrez nanobot pour appliquer les changements dexécution.",
"restart": "Redémarrer nanobot",
"restarting": "Redémarrage..."
"restarting": "Redémarrage...",
"restartEngine": "Redémarrer le moteur",
"restartingEngine": "Redémarrage du moteur..."
},
"restart": {
"completed": "Redémarrage terminé en {{seconds}} s."
@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Navigation de la barre latérale",
"globalActions": "Actions globales",
"collapse": "Réduire la barre latérale",
"toggleTheme": "Changer de thème",
"home": "Accueil",
"newChat": "Nouvelle discussion",
"searchAria": "Rechercher",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Rechercher",
"searchResults": "Résultats",
"noSearchResults": "Aucun chat correspondant.",
"recent": "Récentes",
"refreshSessions": "Actualiser les sessions",
"settings": "Paramètres",
"language": {
"label": "Langue",
@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "Système",
"advanced": "Security",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Apps"
@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Capacités",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "Services MCP",
"apps": "Apps"
"apps": "Apps",
"nativeHost": "App",
"hostSafety": "App safety"
},
"rows": {
"theme": "Thème",
@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "Espace de travail par défaut",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "Modèle actuel",
"brandLogos": "Logos de marque",
"cliAppsCatalog": "Catalogue d'apps CLI",
"cliAppsFilter": "Filtre des apps CLI"
"cliAppsFilter": "Filtre des apps CLI",
"engine": "Moteur",
"logs": "Journaux",
"diagnostics": "Diagnostics"
},
"help": {
"theme": "Basculer entre les apparences claire et sombre.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de format.",
"defaultImageSize": "Indication de taille envoyée aux fournisseurs qui la prennent en charge.",
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "Affiché partout où nanobot utilise un nom visible.",
"botIcon": "Emoji ou texte court affiché avec le nom du bot.",
"timezone": "Utilisé pour les planifications et les réponses sensibles à lheure.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Choisissez le modèle que nanobot utilisera pour les prochaines réponses.",
"selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "Défini par le modèle sélectionné.",
"brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
"cliAppsCatalog": "Parcourez les CLIs d'apps que nanobot peut exécuter localement.",
"cliAppsFilter": "Recherchez par app, catégorie ou capacité."
"cliAppsFilter": "Recherchez par app, catégorie ou capacité.",
"logs": "Ouvrir le dossier des journaux du moteur natif.",
"diagnostics": "Exporter un petit rapport runtime pour le support.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "Clair",
@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "En attente",
"restartingEngine": "Redémarrage"
},
"status": {
"loading": "Chargement des paramètres...",
@ -212,14 +206,24 @@
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer.",
"restartAfterSaving": "Enregistrez les modifications, puis redémarrez lorsque vous êtes prêt.",
"savedRestartApply": "Enregistré. Redémarrez lorsque vous êtes prêt.",
"imageProviderRestart": "Modifications du fournisseur dimages enregistrées. Redémarrez lorsque vous êtes prêt."
"imageProviderRestart": "Modifications du fournisseur dimages enregistrées. Redémarrez lorsque vous êtes prêt.",
"hostRestartAfterSaving": "Enregistrez les changements et nanobot redémarrera son moteur.",
"hostRestartPending": "Enregistré. Redémarrage du moteur quand il sera prêt.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Enregistrer",
"saving": "Enregistrement",
"edit": "Modifier",
"cancel": "Annuler",
"openDocs": "Open docs"
"open": "Ouvrir",
"export": "Exporter",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.",
@ -290,6 +294,7 @@
"newConfiguration": "Nouvelle configuration de modèle",
"newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
"configurationName": "Nom",
"configurationNameHelp": "Renommez cette configuration de modèle enregistrée.",
"configurationNamePlaceholder": "Rédaction rapide"
},
"timezone": {
@ -397,6 +402,19 @@
"featured": "En vedette",
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond à ce filtre."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@ -404,7 +422,10 @@
"loading": "Chargement…",
"noSessions": "Aucune session pour le moment.",
"showMore": "Afficher {{count}} de plus",
"collapsed": "{{count}} discussions masquées",
"showLess": "Afficher moins",
"actions": "Actions de la discussion {{title}}",
"newInProject": "Démarrer une nouvelle discussion dans {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Chargement de la conversation…",
"empty": {
"greeting": "Que puis-je faire pour vous ?",
"greetings": {
"workOn": "Sur quoi travaillons-nous ensemble ?",
"start": "Par où commence-t-on ?",
"build": "Que construisons-nous aujourd'hui ?",
"tackle": "Que résout-on ensemble ?"
},
"quickActions": {
"plan": {
"title": "Créer un plan de projet",
@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "Utiliser @{{name}} comme app CLI locale",
"mcpDescription": "Utiliser @{{name}} comme serveur MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Choisir un projet",
"projectPlaceholder": "Sélectionner un projet",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Faire défiler vers le bas",
@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Message trop volumineux",
"body": "Le serveur a rejeté votre dernier message car il dépasse la taille autorisée. Retirez des images ou choisissez des fichiers plus légers, puis renvoyez-le."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Coller un chemin",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}

View File

@ -25,7 +25,9 @@
"section": "Sistem",
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
"restart": "Mulai ulang nanobot",
"restarting": "Memulai ulang..."
"restarting": "Memulai ulang...",
"restartEngine": "Mulai ulang engine",
"restartingEngine": "Memulai ulang engine..."
},
"restart": {
"completed": "Mulai ulang selesai dalam {{seconds}} dtk."
@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Navigasi bilah samping",
"globalActions": "Aksi global",
"collapse": "Ciutkan sidebar",
"toggleTheme": "Ganti tema",
"home": "Beranda",
"newChat": "Obrolan baru",
"searchAria": "Cari",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Cari",
"searchResults": "Hasil",
"noSearchResults": "Tidak ada chat yang cocok.",
"recent": "Terbaru",
"refreshSessions": "Segarkan sesi",
"settings": "Pengaturan",
"language": {
"label": "Bahasa",
@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "Sistem",
"advanced": "Security",
"cliApps": "Aplikasi CLI",
"mcp": "MCP",
"apps": "Aplikasi"
@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Kapabilitas",
"integrations": "Integrations",
"cliApps": "App CLI",
"mcp": "Layanan MCP",
"apps": "Aplikasi"
"apps": "Aplikasi",
"nativeHost": "Native host",
"hostSafety": "App safety"
},
"rows": {
"theme": "Tema",
@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "Workspace default",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "Model saat ini",
"brandLogos": "Logo merek",
"cliAppsCatalog": "Katalog aplikasi CLI",
"cliAppsFilter": "Filter aplikasi CLI"
"cliAppsFilter": "Filter aplikasi CLI",
"engine": "Engine",
"logs": "Log",
"diagnostics": "Diagnostik"
},
"help": {
"theme": "Beralih antara tampilan terang dan gelap.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "Digunakan saat prompt tidak memilih rasio aspek.",
"defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.",
"maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "Ditampilkan di tempat nanobot memakai nama tampilan.",
"botIcon": "Emoji atau teks pendek yang tampil bersama nama bot.",
"timezone": "Dipakai untuk jadwal dan balasan yang peka waktu.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Pilih model yang digunakan nanobot untuk balasan berikutnya.",
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
"cliAppsCatalog": "Jelajahi CLI aplikasi yang dapat dijalankan nanobot secara lokal.",
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan."
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
"logs": "Buka folder log native engine.",
"diagnostics": "Ekspor laporan runtime kecil untuk dukungan.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "Terang",
@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "Tertunda",
"restartingEngine": "Memulai ulang"
},
"status": {
"loading": "Memuat pengaturan...",
@ -212,14 +206,24 @@
"savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.",
"restartAfterSaving": "Simpan perubahan, lalu restart saat siap.",
"savedRestartApply": "Tersimpan. Restart saat siap.",
"imageProviderRestart": "Perubahan penyedia gambar tersimpan. Restart saat siap."
"imageProviderRestart": "Perubahan penyedia gambar tersimpan. Restart saat siap.",
"hostRestartAfterSaving": "Simpan perubahan dan nanobot akan memulai ulang engine.",
"hostRestartPending": "Tersimpan. Engine akan dimulai ulang saat siap.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Simpan",
"saving": "Menyimpan",
"edit": "Edit",
"cancel": "Batal",
"openDocs": "Open docs"
"open": "Buka",
"export": "Ekspor",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.",
@ -290,6 +294,7 @@
"newConfiguration": "Konfigurasi model baru",
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
"configurationName": "Nama",
"configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.",
"configurationNamePlaceholder": "Penulisan cepat"
},
"timezone": {
@ -397,6 +402,19 @@
"featured": "Unggulan",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok dengan filter ini."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@ -404,7 +422,10 @@
"loading": "Memuat…",
"noSessions": "Belum ada sesi.",
"showMore": "Tampilkan {{count}} lagi",
"collapsed": "{{count}} obrolan diciutkan",
"showLess": "Tampilkan lebih sedikit",
"actions": "Aksi obrolan untuk {{title}}",
"newInProject": "Mulai obrolan baru di {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Memuat percakapan…",
"empty": {
"greeting": "Apa yang bisa saya bantu?",
"greetings": {
"workOn": "Apa yang kita kerjakan bersama?",
"start": "Kita mulai dari mana?",
"build": "Apa yang kita bangun hari ini?",
"tackle": "Apa yang kita selesaikan bersama?"
},
"quickActions": {
"plan": {
"title": "Buat rencana proyek",
@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
"mcpDescription": "Gunakan @{{name}} sebagai server MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Pilih proyek",
"projectPlaceholder": "Pilih proyek",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Gulir ke bawah",
@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Pesan terlalu besar",
"body": "Server menolak pesan terakhir karena melebihi batas ukuran. Hapus beberapa gambar atau gunakan berkas yang lebih kecil, lalu coba lagi."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Tempel path",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}

View File

@ -25,7 +25,9 @@
"section": "システム",
"restartHint": "実行時の変更を適用するには nanobot を再起動します。",
"restart": "nanobot を再起動",
"restarting": "再起動中..."
"restarting": "再起動中...",
"restartEngine": "エンジンを再起動",
"restartingEngine": "エンジンを再起動中..."
},
"restart": {
"completed": "{{seconds}} 秒で再起動が完了しました。"
@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "サイドバーのナビゲーション",
"globalActions": "グローバル操作",
"collapse": "サイドバーを閉じる",
"toggleTheme": "テーマを切り替える",
"home": "ホーム",
"newChat": "新しいチャット",
"searchAria": "検索",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "検索",
"searchResults": "検索結果",
"noSearchResults": "一致するチャットはありません。",
"recent": "最近のチャット",
"refreshSessions": "セッションを更新",
"settings": "設定",
"language": {
"label": "言語",
@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "システム",
"advanced": "Security",
"cliApps": "CLI アプリ",
"mcp": "MCP",
"apps": "アプリ"
@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "機能",
"integrations": "Integrations",
"cliApps": "CLI アプリ",
"mcp": "MCP サービス",
"apps": "アプリ"
"apps": "アプリ",
"nativeHost": "App",
"hostSafety": "App safety"
},
"rows": {
"theme": "テーマ",
@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "デフォルトワークスペース",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "現在のモデル",
"brandLogos": "ブランドロゴ",
"cliAppsCatalog": "CLI アプリカタログ",
"cliAppsFilter": "CLI アプリフィルター"
"cliAppsFilter": "CLI アプリフィルター",
"engine": "エンジン",
"logs": "ログ",
"diagnostics": "診断"
},
"help": {
"theme": "ライト表示とダーク表示を切り替えます。",
@ -175,17 +160,22 @@
"defaultAspectRatio": "プロンプトでアスペクト比が指定されていない場合に使用します。",
"defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。",
"maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "nanobot が表示名を使う場所に表示されます。",
"botIcon": "Bot 名の横に表示する短い emoji またはテキストです。",
"timezone": "スケジュールと時刻を考慮する返信に使用します。",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "今後の返信で nanobot が使用するモデルを選択します。",
"selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。"
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
"logs": "Appエンジンのログフォルダを開きます。",
"diagnostics": "サポート用の小さなランタイムレポートを書き出します。",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "ライト",
@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "保留中",
"restartingEngine": "再起動中"
},
"status": {
"loading": "設定を読み込んでいます...",
@ -212,14 +206,24 @@
"savedRestart": "保存しました。反映するには nanobot を再起動してください。",
"restartAfterSaving": "変更を保存してから、準備ができたら再起動してください。",
"savedRestartApply": "保存しました。準備ができたら再起動してください。",
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。"
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。",
"hostRestartAfterSaving": "保存すると nanobot がエンジンを再起動します。",
"hostRestartPending": "保存しました。準備ができたらエンジンを再起動します。",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "保存",
"saving": "保存中",
"edit": "編集",
"cancel": "キャンセル",
"openDocs": "Open docs"
"open": "開く",
"export": "書き出す",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
@ -290,6 +294,7 @@
"newConfiguration": "新しいモデル設定",
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
"configurationName": "名前",
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
"configurationNamePlaceholder": "高速ライティング"
},
"timezone": {
@ -397,6 +402,19 @@
"featured": "おすすめ",
"loading": "アプリを読み込み中...",
"empty": "このフィルターに一致するアプリはありません。"
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@ -404,7 +422,10 @@
"loading": "読み込み中…",
"noSessions": "まだセッションがありません。",
"showMore": "さらに {{count}} 件表示",
"collapsed": "{{count}} 件のチャットを折りたたみ中",
"showLess": "折りたたむ",
"actions": "「{{title}}」のチャット操作",
"newInProject": "「{{project}}」で新しいチャットを開始",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "会話を読み込み中…",
"empty": {
"greeting": "何をお手伝いしましょうか?",
"greetings": {
"workOn": "一緒に何に取り組みましょうか?",
"start": "今日はどこから始めましょう?",
"build": "今日は何を作りましょうか?",
"tackle": "一緒に何を解決しましょう?"
},
"quickActions": {
"plan": {
"title": "プロジェクト計画を作成",
@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
"mcpDescription": "@{{name}} を MCP サーバーとして使用"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "プロジェクトを選択",
"projectPlaceholder": "プロジェクトを選択",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "一番下へスクロール",
@ -690,6 +727,19 @@
"messageTooBig": {
"title": "メッセージが大きすぎます",
"body": "サイズ上限を超えたため、直前のメッセージはサーバーに拒否されました。画像を減らすか、より小さいファイルに差し替えて再送してください。"
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "パスを貼り付け",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}

View File

@ -25,7 +25,9 @@
"section": "시스템",
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
"restart": "nanobot 다시 시작",
"restarting": "다시 시작 중..."
"restarting": "다시 시작 중...",
"restartEngine": "엔진 다시 시작",
"restartingEngine": "엔진 다시 시작 중..."
},
"restart": {
"completed": "{{seconds}}초 만에 다시 시작되었습니다."
@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "사이드바 탐색",
"globalActions": "전역 작업",
"collapse": "사이드바 접기",
"toggleTheme": "테마 전환",
"home": "홈",
"newChat": "새 채팅",
"searchAria": "검색",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "검색",
"searchResults": "결과",
"noSearchResults": "일치하는 채팅이 없습니다.",
"recent": "최근 대화",
"refreshSessions": "세션 새로고침",
"settings": "설정",
"language": {
"label": "언어",
@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "시스템",
"advanced": "Security",
"cliApps": "CLI 앱",
"mcp": "MCP",
"apps": "앱"
@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "기능",
"integrations": "Integrations",
"cliApps": "CLI 앱",
"mcp": "MCP 서비스",
"apps": "앱"
"apps": "앱",
"nativeHost": "App",
"hostSafety": "App safety"
},
"rows": {
"theme": "테마",
@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "기본 작업공간",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "현재 모델",
"brandLogos": "브랜드 로고",
"cliAppsCatalog": "CLI 앱 카탈로그",
"cliAppsFilter": "CLI 앱 필터"
"cliAppsFilter": "CLI 앱 필터",
"engine": "엔진",
"logs": "로그",
"diagnostics": "진단"
},
"help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "프롬프트에서 가로세로 비율을 선택하지 않았을 때 사용됩니다.",
"defaultImageSize": "지원하는 제공자에게 보내는 크기 힌트입니다.",
"maxImagesPerTurn": "한 번의 generate_image 요청에 대한 상한입니다.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "nanobot이 표시 이름을 쓰는 곳에 표시됩니다.",
"botIcon": "Bot 이름 옆에 표시할 짧은 emoji 또는 텍스트입니다.",
"timezone": "예약과 시간 인식 답변에 사용됩니다.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.",
"selectedModelProvider": "선택한 모델에서 설정됩니다.",
"selectedModelValue": "선택한 모델에서 설정됩니다.",
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다."
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
"logs": "App 엔진 로그 폴더를 엽니다.",
"diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "라이트",
@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "대기 중",
"restartingEngine": "다시 시작 중"
},
"status": {
"loading": "설정을 불러오는 중...",
@ -212,14 +206,24 @@
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요.",
"restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.",
"savedRestartApply": "저장되었습니다. 준비되면 재시작하세요.",
"imageProviderRestart": "이미지 제공자 변경 사항이 저장되었습니다. 준비되면 재시작하세요."
"imageProviderRestart": "이미지 제공자 변경 사항이 저장되었습니다. 준비되면 재시작하세요.",
"hostRestartAfterSaving": "저장하면 nanobot이 엔진을 다시 시작합니다.",
"hostRestartPending": "저장되었습니다. 준비되면 엔진을 다시 시작합니다.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "저장",
"saving": "저장 중",
"edit": "편집",
"cancel": "취소",
"openDocs": "Open docs"
"open": "열기",
"export": "내보내기",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
@ -290,6 +294,7 @@
"newConfiguration": "새 모델 구성",
"newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
"configurationName": "이름",
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
"configurationNamePlaceholder": "빠른 글쓰기"
},
"timezone": {
@ -397,6 +402,19 @@
"featured": "추천",
"loading": "앱 불러오는 중...",
"empty": "이 필터와 일치하는 앱이 없습니다."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@ -404,7 +422,10 @@
"loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.",
"showMore": "{{count}}개 더 보기",
"collapsed": "{{count}}개 채팅 접힘",
"showLess": "접기",
"actions": "{{title}} 채팅 작업",
"newInProject": "{{project}}에서 새 채팅 시작",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "대화 불러오는 중…",
"empty": {
"greeting": "무엇을 도와드릴까요?",
"greetings": {
"workOn": "우리 무엇을 함께 해볼까요?",
"start": "오늘은 어디서 시작할까요?",
"build": "오늘은 무엇을 만들어볼까요?",
"tackle": "함께 무엇을 해결할까요?"
},
"quickActions": {
"plan": {
"title": "프로젝트 계획 만들기",
@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
"mcpDescription": "@{{name}}을 MCP 서버로 사용"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "프로젝트 선택",
"projectPlaceholder": "프로젝트 선택",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "맨 아래로 스크롤",
@ -690,6 +727,19 @@
"messageTooBig": {
"title": "메시지가 너무 큽니다",
"body": "마지막 메시지가 서버의 크기 제한을 초과하여 거부되었습니다. 이미지를 줄이거나 더 작은 파일로 바꿔서 다시 보내 주세요."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "경로 붙여넣기",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}

View File

@ -25,7 +25,9 @@
"section": "Hệ thống",
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
"restart": "Khởi động lại nanobot",
"restarting": "Đang khởi động lại..."
"restarting": "Đang khởi động lại...",
"restartEngine": "Khởi động lại engine",
"restartingEngine": "Đang khởi động lại engine..."
},
"restart": {
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây."
@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Điều hướng thanh bên",
"globalActions": "Hành động toàn cục",
"collapse": "Thu gọn thanh bên",
"toggleTheme": "Chuyển giao diện",
"home": "Trang chủ",
"newChat": "Cuộc trò chuyện mới",
"searchAria": "Tìm kiếm",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Tìm kiếm",
"searchResults": "Kết quả",
"noSearchResults": "Không có cuộc trò chuyện phù hợp.",
"recent": "Gần đây",
"refreshSessions": "Làm mới phiên",
"settings": "Cài đặt",
"language": {
"label": "Ngôn ngữ",
@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "Hệ thống",
"advanced": "Security",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP",
"apps": "Ứng dụng"
@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Khả năng",
"integrations": "Integrations",
"cliApps": "Ứng dụng CLI",
"mcp": "Dịch vụ MCP",
"apps": "Ứng dụng"
"apps": "Ứng dụng",
"nativeHost": "Native host",
"hostSafety": "App safety"
},
"rows": {
"theme": "Giao diện",
@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "Workspace mặc định",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "Mô hình hiện tại",
"brandLogos": "Logo thương hiệu",
"cliAppsCatalog": "Danh mục ứng dụng CLI",
"cliAppsFilter": "Bộ lọc ứng dụng CLI"
"cliAppsFilter": "Bộ lọc ứng dụng CLI",
"engine": "Engine",
"logs": "Nhật ký",
"diagnostics": "Chẩn đoán"
},
"help": {
"theme": "Chuyển giữa giao diện sáng và tối.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "Được dùng khi prompt không chọn tỷ lệ khung hình.",
"defaultImageSize": "Gợi ý kích thước gửi tới các nhà cung cấp hỗ trợ.",
"maxImagesPerTurn": "Giới hạn trên cho một yêu cầu generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "Hiển thị ở nơi nanobot dùng tên hiển thị.",
"botIcon": "Emoji hoặc văn bản ngắn hiển thị cùng tên bot.",
"timezone": "Dùng cho lịch hẹn và câu trả lời có yếu tố thời gian.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Chọn mô hình nanobot dùng cho các câu trả lời mới.",
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
"brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
"cliAppsCatalog": "Duyệt các CLI ứng dụng mà nanobot có thể chạy cục bộ.",
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng."
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
"logs": "Mở thư mục nhật ký native engine.",
"diagnostics": "Xuất báo cáo runtime nhỏ để hỗ trợ.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "Sáng",
@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "Đang chờ",
"restartingEngine": "Đang khởi động lại"
},
"status": {
"loading": "Đang tải cài đặt...",
@ -212,14 +206,24 @@
"savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng.",
"restartAfterSaving": "Lưu thay đổi, rồi khởi động lại khi sẵn sàng.",
"savedRestartApply": "Đã lưu. Khởi động lại khi sẵn sàng.",
"imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng."
"imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng.",
"hostRestartAfterSaving": "Lưu thay đổi và nanobot sẽ khởi động lại engine.",
"hostRestartPending": "Đã lưu. Sẽ khởi động lại engine khi sẵn sàng.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Lưu",
"saving": "Đang lưu",
"edit": "Sửa",
"cancel": "Hủy",
"openDocs": "Open docs"
"open": "Mở",
"export": "Xuất",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.",
@ -290,6 +294,7 @@
"newConfiguration": "Cấu hình mô hình mới",
"newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
"configurationName": "Tên",
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
"configurationNamePlaceholder": "Viết nhanh"
},
"timezone": {
@ -397,6 +402,19 @@
"featured": "Nổi bật",
"loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng nào khớp với bộ lọc này."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@ -404,7 +422,10 @@
"loading": "Đang tải…",
"noSessions": "Chưa có phiên nào.",
"showMore": "Hiển thị thêm {{count}}",
"collapsed": "Đã thu gọn {{count}} cuộc trò chuyện",
"showLess": "Thu gọn",
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
"newInProject": "Bắt đầu cuộc trò chuyện mới trong {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Đang tải cuộc trò chuyện…",
"empty": {
"greeting": "Tôi có thể giúp gì cho bạn?",
"greetings": {
"workOn": "Mình cùng làm gì tiếp?",
"start": "Hôm nay bắt đầu từ đâu?",
"build": "Hôm nay mình xây dựng gì?",
"tackle": "Mình cùng xử lý việc gì?"
},
"quickActions": {
"plan": {
"title": "Tạo kế hoạch dự án",
@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
"mcpDescription": "Dùng @{{name}} như máy chủ MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Chọn dự án",
"projectPlaceholder": "Chọn dự án",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Cuộn xuống cuối",
@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Tin nhắn quá lớn",
"body": "Máy chủ đã từ chối tin nhắn trước vì vượt quá giới hạn kích thước. Hãy bớt ảnh hoặc chọn tệp nhỏ hơn rồi thử lại."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Dán đường dẫn",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}

View File

@ -25,7 +25,9 @@
"section": "系统",
"restartHint": "重启 nanobot 以应用运行时更改。",
"restart": "重启 nanobot",
"restarting": "正在重启..."
"restarting": "正在重启...",
"restartEngine": "重启引擎",
"restartingEngine": "正在重启引擎..."
},
"restart": {
"completed": "重启已完成,用时 {{seconds}} 秒。"
@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "侧边栏导航",
"globalActions": "全局操作",
"collapse": "收起侧边栏",
"toggleTheme": "切换主题",
"home": "首页",
"newChat": "新建对话",
"searchAria": "搜索",
"viewOptions": "视图",
"compactList": "紧凑列表",
"showPreviews": "显示预览",
"showTimestamps": "显示时间",
"sortLabel": "排序",
"sortUpdated": "最近更新",
"sortCreated": "最近创建",
"sortTitle": "标题 A-Z",
"searchPlaceholder": "搜索",
"searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。",
"recent": "最近对话",
"refreshSessions": "刷新会话",
"settings": "设置",
"language": {
"label": "语言",
@ -80,11 +70,11 @@
"models": "模型",
"providers": "提供商",
"image": "图片",
"web": "网页",
"browser": "网页",
"cliApps": "CLI 应用",
"mcp": "MCP",
"runtime": "运行时",
"advanced": "高级",
"runtime": "系统",
"advanced": "安全",
"apps": "应用"
},
"sections": {
@ -101,10 +91,11 @@
"cliApps": "CLI 应用",
"mcp": "MCP 服务",
"identity": "身份",
"safety": "安全",
"webuiSafety": "网页端安全",
"capabilities": "能力",
"integrations": "集成",
"apps": "应用"
"apps": "应用",
"nativeHost": "App",
"hostSafety": "App 安全"
},
"models": {
"selectModel": "选择模型",
@ -112,6 +103,7 @@
"newConfiguration": "新建模型配置",
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
"configurationName": "名称",
"configurationNameHelp": "重命名这个已保存的模型配置。",
"configurationNamePlaceholder": "快速写作"
},
"rows": {
@ -147,20 +139,14 @@
"botName": "Bot 名称",
"botIcon": "Bot 图标",
"timezone": "时区",
"toolHintMaxLength": "工具提示长度",
"workspacePath": "工作区路径",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "统一会话",
"restrictWorkspace": "限制在工作区内",
"execTool": "Exec 工具",
"execSandbox": "Exec 沙箱",
"ssrfWhitelist": "SSRF 白名单",
"mcpServers": "MCP 服务器",
"pathAppend": "PATH 追加",
"workspacePath": "默认工作区",
"localServiceAccess": "本机服务",
"webuiDefaultAccess": "默认权限",
"cliAppsCatalog": "目录",
"cliAppsFilter": "筛选",
"configurationDocs": "配置文档"
"engine": "引擎",
"logs": "日志",
"diagnostics": "诊断"
},
"help": {
"theme": "在浅色和深色外观之间切换。",
@ -187,13 +173,18 @@
"defaultAspectRatio": "当提示词没有选择比例时使用。",
"defaultImageSize": "发送给支持该能力的服务商的尺寸提示。",
"maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。",
"botName": "显示在使用 bot 身份的运行时界面里。",
"botName": "显示在 nanobot 使用名称的地方。",
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
"timezone": "运行时上下文和计划任务使用的 IANA 时区。",
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
"timezone": "用于计划任务和需要时间感知的回复。",
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
"cliAppsFilter": "按应用、分类或能力搜索。",
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
"localServiceAccess": "允许完全访问模式下的 shell 命令访问 localhost 服务。",
"webuiDefaultAccess": "用于没有单独选择权限的网页端对话。",
"securityManagedControls": "网页抓取始终保护本机、内网和元数据服务。核心渠道安全仍由 config.json 管理。",
"logs": "打开App引擎日志文件夹。",
"diagnostics": "导出一份用于支持排查的运行时报告。",
"localServiceAccessNative": "允许完全访问模式下的 shell 命令访问这台 Mac 上的服务。",
"webuiDefaultAccessNative": "用于没有单独选择权限的原生 App 对话。"
},
"timezone": {
"select": "选择时区",
@ -298,8 +289,12 @@
"expanded": "展开",
"on": "开",
"off": "关",
"defaultPermission": "默认权限",
"fullAccess": "完全访问",
"configured": "已配置",
"notConfigured": "未配置"
"notConfigured": "未配置",
"pending": "待应用",
"restartingEngine": "正在重启"
},
"status": {
"loading": "正在加载设置...",
@ -309,14 +304,24 @@
"savedRestart": "已保存。重启 nanobot 后生效。",
"restartAfterSaving": "保存后,可在合适时重启。",
"savedRestartApply": "已保存,可稍后重启。",
"imageProviderRestart": "图片服务商改动已保存,可稍后重启。"
"imageProviderRestart": "图片服务商改动已保存,可稍后重启。",
"hostRestartAfterSaving": "保存后nanobot 会自动重启引擎。",
"hostRestartPending": "已保存,将在合适时重启引擎。",
"hostApiUnavailable": "宿主操作只能在原生 App 内使用。",
"logsOpened": "已打开日志文件夹。",
"logsOpenFailed": "无法打开日志文件夹。",
"diagnosticsExported": "诊断已导出到 {{path}}。",
"diagnosticsExportFailed": "无法导出诊断。"
},
"actions": {
"save": "保存",
"saving": "保存中",
"edit": "编辑",
"cancel": "取消",
"openDocs": "打开文档"
"open": "打开",
"export": "导出",
"opening": "打开中...",
"exporting": "导出中..."
},
"byok": {
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
@ -397,6 +402,19 @@
"featured": "精选",
"loading": "正在加载应用...",
"empty": "没有符合筛选条件的应用。"
},
"oauth": {
"authentication": "OAuth 认证",
"signIn": "登录",
"signingIn": "正在登录…",
"signInAgain": "重新登录",
"signOut": "退出登录",
"signedInAs": "已登录为 {{account}}",
"signInHelp": "在这台设备上登录;不会把 API key 写入配置。",
"signInRequired": "需要登录",
"signInBeforeSaving": "先登录这个 OAuth 提供商,然后再保存为当前模型提供商。",
"signedIn": "已登录",
"notSignedIn": "未登录"
}
},
"chat": {
@ -404,7 +422,10 @@
"loading": "加载中…",
"noSessions": "还没有会话。",
"showMore": "再显示 {{count}} 个",
"collapsed": "已折叠 {{count}} 个对话",
"showLess": "收起",
"actions": "“{{title}}” 的会话操作",
"newInProject": "在 {{project}} 中开始新对话",
"activity": {
"running": "Agent 正在运行",
"complete": "Agent 已完成"
@ -415,6 +436,9 @@
"renameTitle": "重命名对话",
"renameDescription": "为这个对话设置一个仅用于 WebUI 侧边栏的名称。",
"renamePlaceholder": "对话名称",
"renameProjectTitle": "重命名项目",
"renameProjectDescription": "为这个项目设置一个仅用于 WebUI 侧边栏的名称。",
"renameProjectPlaceholder": "项目名称",
"renameSave": "保存",
"archive": "归档",
"unarchive": "取消归档",
@ -425,6 +449,7 @@
"groups": {
"pinned": "置顶",
"all": "对话",
"projects": "项目",
"today": "今天",
"yesterday": "昨天",
"earlier": "更早",
@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "正在加载对话…",
"empty": {
"greeting": "我可以帮你做什么?",
"greetings": {
"workOn": "我们要一起做点什么?",
"start": "今天从哪里开始?",
"build": "今天一起构建什么?",
"tackle": "我们要一起解决什么?"
},
"quickActions": {
"plan": {
"title": "创建项目计划",
@ -632,7 +662,14 @@
"too_large": "图片太大,请换一张小一点的",
"io": "无法读取该文件"
},
"goalStateCloseAria": "关闭目标"
"goalStateCloseAria": "关闭目标",
"workspace": {
"accessAria": "工作区访问权限",
"projectAria": "选择项目",
"projectPlaceholder": "选择项目",
"default": "默认权限",
"full": "完全访问权限"
}
},
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息"
@ -690,6 +727,19 @@
"messageTooBig": {
"title": "消息过大",
"body": "服务端因超过大小限制拒收了上一条消息。可移除部分图片或使用更小的图片后重试。"
},
"workspaceScopeRejected": {
"title": "工作区未更改",
"body": "网关拒绝了请求的项目或访问权限Nanobot 已继续使用之前的工作区。"
}
},
"workspace": {
"dialog": {
"defaultProject": "默认工作区",
"manual": "粘贴路径",
"manualPlaceholder": "/Users/name/project",
"usePath": "使用路径",
"absolutePathRequired": "请输入这台机器上的绝对文件夹路径。"
}
}
}

View File

@ -25,7 +25,9 @@
"section": "系統",
"restartHint": "重新啟動 nanobot 以套用執行階段變更。",
"restart": "重新啟動 nanobot",
"restarting": "正在重新啟動..."
"restarting": "正在重新啟動...",
"restartEngine": "重新啟動引擎",
"restartingEngine": "正在重新啟動引擎..."
},
"restart": {
"completed": "重新啟動已完成,耗時 {{seconds}} 秒。"
@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "側邊欄導覽",
"globalActions": "全域操作",
"collapse": "收合側邊欄",
"toggleTheme": "切換主題",
"home": "首頁",
"newChat": "新增對話",
"searchAria": "搜尋",
"viewOptions": "檢視",
"compactList": "緊湊列表",
"showPreviews": "顯示預覽",
"showTimestamps": "顯示時間",
"sortLabel": "排序",
"sortUpdated": "最近更新",
"sortCreated": "最近建立",
"sortTitle": "標題 A-Z",
"searchPlaceholder": "搜尋",
"searchResults": "搜尋結果",
"noSearchResults": "沒有符合的對話。",
"recent": "最近對話",
"refreshSessions": "重新整理會話",
"settings": "設定",
"language": {
"label": "語言",
@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "系統",
"advanced": "Security",
"cliApps": "CLI 應用",
"mcp": "MCP",
"apps": "應用"
@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "網頁端安全",
"capabilities": "功能",
"integrations": "Integrations",
"cliApps": "CLI 應用",
"mcp": "MCP 服務",
"apps": "應用"
"apps": "應用",
"nativeHost": "App",
"hostSafety": "App 安全"
},
"rows": {
"theme": "主題",
@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "預設工作區",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "目前模型",
"brandLogos": "品牌標誌",
"cliAppsCatalog": "CLI 應用目錄",
"cliAppsFilter": "CLI 應用篩選"
"cliAppsFilter": "CLI 應用篩選",
"engine": "引擎",
"logs": "日誌",
"diagnostics": "診斷"
},
"help": {
"theme": "在淺色與深色外觀之間切換。",
@ -175,17 +160,22 @@
"defaultAspectRatio": "當提示詞未指定長寬比時使用。",
"defaultImageSize": "傳送給支援此功能的服務商的尺寸提示。",
"maxImagesPerTurn": "單次 generate_image 請求的上限。",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "顯示在 nanobot 使用名稱的地方。",
"botIcon": "顯示在 bot 名稱旁的短 emoji 或文字。",
"timezone": "用於排程與需要時間感知的回覆。",
"localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。",
"webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "選擇 nanobot 接下來回覆時使用的模型。",
"selectedModelProvider": "由目前模型決定。",
"selectedModelValue": "由目前模型決定。",
"brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
"cliAppsCatalog": "瀏覽 nanobot 可在本機執行的應用 CLI。",
"cliAppsFilter": "按應用、分類或能力搜尋。"
"cliAppsFilter": "按應用、分類或能力搜尋。",
"logs": "開啟App引擎日誌資料夾。",
"diagnostics": "匯出一份供支援排查用的執行階段報告。",
"localServiceAccessNative": "允許完全存取模式下的 shell 命令存取這台 Mac 上的服務。",
"webuiDefaultAccessNative": "用於沒有單獨選擇權限的原生 App 對話。"
},
"values": {
"light": "淺色",
@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "待套用",
"restartingEngine": "正在重新啟動"
},
"status": {
"loading": "正在載入設定...",
@ -212,14 +206,24 @@
"savedRestart": "已儲存。重新啟動 nanobot 後生效。",
"restartAfterSaving": "儲存變更後,可在準備好時重新啟動。",
"savedRestartApply": "已儲存,可在準備好時重新啟動。",
"imageProviderRestart": "圖片服務商變更已儲存,可在準備好時重新啟動。"
"imageProviderRestart": "圖片服務商變更已儲存,可在準備好時重新啟動。",
"hostRestartAfterSaving": "儲存後nanobot 會自動重新啟動引擎。",
"hostRestartPending": "已儲存,將在適當時重新啟動引擎。",
"hostApiUnavailable": "宿主操作只能在原生 App 內使用。",
"logsOpened": "已開啟日誌資料夾。",
"logsOpenFailed": "無法開啟日誌資料夾。",
"diagnosticsExported": "診斷已匯出到 {{path}}。",
"diagnosticsExportFailed": "無法匯出診斷。"
},
"actions": {
"save": "儲存",
"saving": "儲存中",
"edit": "編輯",
"cancel": "取消",
"openDocs": "Open docs"
"open": "開啟",
"export": "匯出",
"opening": "開啟中...",
"exporting": "匯出中..."
},
"byok": {
"description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。",
@ -290,6 +294,7 @@
"newConfiguration": "新增模型設定",
"newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。",
"configurationName": "名稱",
"configurationNameHelp": "重新命名這個已儲存的模型配置。",
"configurationNamePlaceholder": "快速寫作"
},
"timezone": {
@ -397,6 +402,19 @@
"featured": "精選",
"loading": "正在載入應用...",
"empty": "沒有符合篩選條件的應用。"
},
"oauth": {
"authentication": "OAuth 驗證",
"signIn": "登入",
"signingIn": "正在登入…",
"signInAgain": "重新登入",
"signOut": "登出",
"signedInAs": "已登入為 {{account}}",
"signInHelp": "在這台裝置上登入;不會把 API key 寫入設定。",
"signInRequired": "需要登入",
"signInBeforeSaving": "請先登入這個 OAuth 提供商,再儲存為目前模型提供商。",
"signedIn": "已登入",
"notSignedIn": "未登入"
}
},
"chat": {
@ -404,7 +422,10 @@
"loading": "載入中…",
"noSessions": "目前還沒有會話。",
"showMore": "再顯示 {{count}} 個",
"collapsed": "已折疊 {{count}} 個對話",
"showLess": "收起",
"actions": "「{{title}}」的會話操作",
"newInProject": "在 {{project}} 中開始新對話",
"activity": {
"running": "Agent 正在執行",
"complete": "Agent 已完成"
@ -415,6 +436,9 @@
"renameTitle": "重新命名對話",
"renameDescription": "為這個對話設定僅用於 WebUI 側邊欄的名稱。",
"renamePlaceholder": "對話名稱",
"renameProjectTitle": "重新命名專案",
"renameProjectDescription": "為這個專案設定僅用於 WebUI 側邊欄的名稱。",
"renameProjectPlaceholder": "專案名稱",
"renameSave": "儲存",
"archive": "封存",
"unarchive": "取消封存",
@ -425,6 +449,7 @@
"groups": {
"pinned": "置頂",
"all": "對話",
"projects": "專案",
"today": "今天",
"yesterday": "昨天",
"earlier": "更早",
@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "正在載入對話…",
"empty": {
"greeting": "我可以幫你做什麼?",
"greetings": {
"workOn": "我們要一起做點什麼?",
"start": "今天從哪裡開始?",
"build": "今天一起構建什麼?",
"tackle": "我們要一起解決什麼?"
},
"quickActions": {
"plan": {
"title": "建立專案計畫",
@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "使用 @{{name}} 呼叫本機 CLI",
"mcpDescription": "使用 @{{name}} 呼叫 MCP 服務"
},
"workspace": {
"accessAria": "工作區存取權限",
"projectAria": "選擇專案",
"projectPlaceholder": "選擇專案",
"default": "預設權限",
"full": "完全存取權限"
}
},
"scrollToBottom": "捲動到底部",
@ -690,6 +727,19 @@
"messageTooBig": {
"title": "訊息過大",
"body": "伺服器因超過大小限制拒收了上一則訊息。可移除部分圖片或改用較小的圖片後再試。"
},
"workspaceScopeRejected": {
"title": "工作區未變更",
"body": "閘道拒絕了要求的專案或存取權限Nanobot 已繼續使用先前的工作區。"
}
},
"workspace": {
"dialog": {
"defaultProject": "預設工作區",
"manual": "貼上路徑",
"manualPlaceholder": "/Users/name/project",
"usePath": "使用路徑",
"absolutePathRequired": "請輸入這台機器上的絕對資料夾路徑。"
}
}
}

View File

@ -4,13 +4,17 @@ import type {
ImageGenerationSettingsUpdate,
McpPresetsPayload,
ModelConfigurationCreate,
ModelConfigurationUpdate,
NetworkSafetySettingsUpdate,
ProviderSettingsUpdate,
SettingsPayload,
SettingsUpdate,
SidebarStatePayload,
SlashCommand,
WebSearchSettingsUpdate,
WorkspacesPayload,
WebuiThreadPersistedPayload,
WorkspaceScopePayload,
} from "./types";
export class ApiError extends Error {
@ -38,6 +42,17 @@ async function request<T>(
if (!res.ok) {
throw new ApiError(res.status, `HTTP ${res.status}`);
}
const contentType = res.headers?.get?.("content-type") ?? "";
if (contentType && !contentType.toLowerCase().includes("application/json")) {
const text = typeof res.text === "function" ? await res.text() : "";
const isHtml = text.trimStart().toLowerCase().startsWith("<!doctype");
throw new ApiError(
res.status,
isHtml
? "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again."
: "Gateway returned a non-JSON response.",
);
}
return (await res.json()) as T;
}
@ -73,6 +88,7 @@ export async function listSessions(
title?: string;
preview?: string;
run_started_at?: number | null;
workspace_scope?: WorkspaceScopePayload | null;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
@ -86,6 +102,7 @@ export async function listSessions(
title: s.title ?? "",
preview: s.preview ?? "",
runStartedAt: s.run_started_at ?? null,
workspaceScope: s.workspace_scope ?? null,
}));
}
@ -124,6 +141,13 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token);
}
export async function fetchWorkspaces(
token: string,
base: string = "",
): Promise<WorkspacesPayload> {
return request<WorkspacesPayload>(`${base}/api/workspaces`, token);
}
export async function fetchCliApps(
token: string,
base: string = "",
@ -281,6 +305,22 @@ export async function createModelConfiguration(
);
}
export async function updateModelConfiguration(
token: string,
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);
return request<SettingsPayload>(
`${base}/api/settings/model-configurations/update?${query}`,
token,
);
}
export async function updateProviderSettings(
token: string,
update: ProviderSettingsUpdate,
@ -297,6 +337,32 @@ export async function updateProviderSettings(
);
}
export async function loginProviderOAuth(
token: string,
provider: string,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("provider", provider);
return request<SettingsPayload>(
`${base}/api/settings/provider/oauth-login?${query}`,
token,
);
}
export async function logoutProviderOAuth(
token: string,
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,
);
}
export async function updateWebSearchSettings(
token: string,
update: WebSearchSettingsUpdate,
@ -317,6 +383,20 @@ export async function updateWebSearchSettings(
);
}
export async function updateNetworkSafetySettings(
token: string,
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,
);
}
export async function updateImageGenerationSettings(
token: string,
update: ImageGenerationSettingsUpdate,

View File

@ -64,12 +64,26 @@ export async function fetchBootstrap(
* matters because some WS servers dispatch handshakes based on the literal
* path, not a normalised form.
*/
export function deriveWsUrl(wsPath: string, token: string): string {
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
export function deriveWsUrl(
wsPath: string,
token: string,
wsUrl?: string | null,
): string {
const query = `?token=${encodeURIComponent(token)}`;
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
const join = wsUrl.includes("?") ? "&" : "?";
return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
}
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
if (typeof window === "undefined") {
return `ws://127.0.0.1:8765${path}${query}`;
}
if (window.location.port === "5173") {
const host = window.location.hostname.includes(":")
? `[${window.location.hostname}]`
: window.location.hostname;
return `ws://${host}:8765${path}${query}`;
}
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host;
return `${scheme}://${host}${path}${query}`;

View File

@ -0,0 +1,372 @@
import { deriveTitle } from "@/lib/format";
import type { ChatSummary, SidebarSortMode } from "@/lib/types";
import { normalizeWorkspacePath, projectNameFromPath, sameWorkspacePath } from "@/lib/workspace";
export const COLLAPSED_CHATS_VISIBLE_COUNT = 8;
export interface SessionGroup {
id: string;
label: string;
sessions: ChatSummary[];
kind?: "project";
projectPath?: string;
projectKey?: string;
updatedAt?: string | null;
}
export interface ChatGroupLabels {
pinned: string;
all: string;
today: string;
yesterday: string;
earlier: string;
archived: string;
projects: string;
fallbackTitle: string;
}
export interface ChatGroupingOptions {
pinnedKeys: string[];
archivedKeys: string[];
titleOverrides: Record<string, string>;
projectNameOverrides: Record<string, string>;
showArchived: boolean;
sort: SidebarSortMode;
defaultWorkspacePath?: string | null;
}
export function groupSessions(
sessions: ChatSummary[],
labels: ChatGroupLabels,
options: ChatGroupingOptions,
): SessionGroup[] {
if (sessions.some((session) => session.workspaceScope?.project_path)) {
return groupSessionsByProject(sessions, labels, options);
}
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>();
const pinned = new Set(options.pinnedKeys);
const archived = new Set(options.archivedKeys);
const pinnedSessions: ChatSummary[] = [];
const archivedSessions: ChatSummary[] = [];
const normalSessions: ChatSummary[] = [];
for (const session of sessions) {
if (archived.has(session.key)) {
if (options.showArchived) archivedSessions.push(session);
continue;
}
if (pinned.has(session.key)) {
pinnedSessions.push(session);
continue;
}
if (options.sort === "title_asc") {
normalSessions.push(session);
continue;
}
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
? labels.yesterday
: labels.earlier;
const bucket = buckets.get(label) ?? [];
bucket.push(session);
buckets.set(label, bucket);
}
const groups: SessionGroup[] = [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({
id: `date:${label}`,
label,
sessions: sortSessions(
buckets.get(label) ?? [],
options.sort,
options.titleOverrides,
),
}))
.filter((group) => group.sessions.length > 0);
if (options.sort === "title_asc" && normalSessions.length) {
groups.push({
id: "date:all",
label: labels.all,
sessions: sortSessions(
normalSessions,
options.sort,
options.titleOverrides,
),
});
}
if (pinnedSessions.length) {
groups.unshift({
id: "pinned",
label: labels.pinned,
sessions: sortSessions(
pinnedSessions,
options.sort,
options.titleOverrides,
),
});
}
if (archivedSessions.length) {
groups.push({
id: "archived",
label: labels.archived,
sessions: sortSessions(
archivedSessions,
options.sort,
options.titleOverrides,
),
});
}
return groups;
}
export function limitGroups(
groups: SessionGroup[],
limit: number,
activeKey: string | null,
collapsedGroups: Record<string, boolean>,
): SessionGroup[] {
let remaining = Math.max(0, limit);
let activeVisible = !activeKey;
const out: SessionGroup[] = [];
for (const group of groups) {
if (isCollapsedProject(group, collapsedGroups)) {
out.push({ ...group, sessions: [] });
continue;
}
const visible = remaining > 0
? group.sessions.slice(0, remaining)
: [];
remaining -= visible.length;
if (activeKey && visible.some((session) => session.key === activeKey)) {
activeVisible = true;
}
if (visible.length > 0) {
out.push({ ...group, sessions: visible });
}
}
if (activeVisible || !activeKey) return out;
for (const group of groups) {
if (isCollapsedProject(group, collapsedGroups)) continue;
const active = group.sessions.find((session) => session.key === activeKey);
if (!active) continue;
const existing = out.find((item) => item.id === group.id);
if (existing) {
existing.sessions = [...existing.sessions, active];
} else {
out.push({ ...group, sessions: [active] });
}
return out;
}
return out;
}
export function isCollapsedProject(
group: SessionGroup,
collapsedGroups: Record<string, boolean>,
): boolean {
return group.kind === "project" && Boolean(collapsedGroups[group.id]);
}
export function isFoldableChatsGroup(group: SessionGroup): boolean {
return group.id === "workspace:chats" || group.id === "date:all";
}
export function isFoldedChatsGroup(
group: SessionGroup,
collapsedGroups: Record<string, boolean>,
): boolean {
return (
isFoldableChatsGroup(group)
&& group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT
&& collapsedGroups[group.id] !== false
);
}
export function visibleSessionsForGroup(
group: SessionGroup,
activeKey: string | null,
collapsedGroups: Record<string, boolean>,
): ChatSummary[] {
if (!isFoldedChatsGroup(group, collapsedGroups)) {
return group.sessions;
}
const visible = group.sessions.slice(0, COLLAPSED_CHATS_VISIBLE_COUNT);
if (!activeKey || visible.some((session) => session.key === activeKey)) {
return visible;
}
const active = group.sessions.find((session) => session.key === activeKey);
return active ? [...visible, active] : visible;
}
export function displayTitle(
session: ChatSummary,
titleOverrides: Record<string, string>,
fallbackTitle: string,
): string {
return (
titleOverrides[session.key]?.trim()
|| session.title?.trim()
|| deriveTitle(session.preview, fallbackTitle)
);
}
function groupSessionsByProject(
sessions: ChatSummary[],
labels: Pick<ChatGroupLabels, "all">,
options: ChatGroupingOptions,
): SessionGroup[] {
const archived = new Set(options.archivedKeys);
const conversations: ChatSummary[] = [];
const buckets = new Map<string, {
path?: string;
label: string;
sessions: ChatSummary[];
updatedAt: string | null;
}>();
for (const session of sessions) {
if (archived.has(session.key) && !options.showArchived) {
continue;
}
const scope = session.workspaceScope;
const path = scope?.project_path || "";
if (!path || sameWorkspacePath(path, options.defaultWorkspacePath)) {
conversations.push(session);
continue;
}
const key = normalizeWorkspacePath(path);
const label = options.projectNameOverrides[key]?.trim()
|| scope?.project_name?.trim()
|| projectNameFromPath(path);
const bucket = buckets.get(key) ?? {
path,
label,
sessions: [],
updatedAt: null,
};
bucket.sessions.push(session);
const candidate = session.updatedAt ?? session.createdAt ?? null;
if (isNewerDate(candidate, bucket.updatedAt)) {
bucket.updatedAt = candidate;
}
buckets.set(key, bucket);
}
const pinned = new Set(options.pinnedKeys);
const groups: SessionGroup[] = Array.from(buckets.entries()).map(([key, bucket]) => ({
id: `project:${key}`,
label: bucket.label,
kind: "project" as const,
projectPath: bucket.path,
projectKey: key,
updatedAt: bucket.updatedAt,
sessions: sortProjectSessions(
bucket.sessions,
options.sort,
options.titleOverrides,
pinned,
archived,
),
}));
groups.sort((a, b) => {
const timeOrder = dateToTime(b.updatedAt) - dateToTime(a.updatedAt);
if (timeOrder !== 0) return timeOrder;
return a.label.localeCompare(b.label, "en", {
numeric: true,
sensitivity: "base",
});
});
if (conversations.length) {
groups.push({
id: "workspace:chats",
label: labels.all,
sessions: sortProjectSessions(
conversations,
options.sort,
options.titleOverrides,
pinned,
archived,
),
});
}
return groups;
}
function sortProjectSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
pinned: Set<string>,
archived: Set<string>,
): ChatSummary[] {
return sortSessions(sessions, sort, titleOverrides).sort((a, b) => {
const pinOrder = Number(pinned.has(b.key)) - Number(pinned.has(a.key));
if (pinOrder !== 0) return pinOrder;
const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key));
if (archiveOrder !== 0) return archiveOrder;
return 0;
});
}
function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
): ChatSummary[] {
const copy = [...sessions];
copy.sort((a, b) => {
if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides),
"en",
{ numeric: true, sensitivity: "base" },
);
if (titleOrder !== 0) return titleOrder;
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
}
const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt");
const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt");
return bTime - aTime;
});
return copy;
}
function isNewerDate(a: string | null, b: string | null): boolean {
return dateToTime(a) > dateToTime(b);
}
function dateToTime(value: string | null | undefined): number {
const ts = Date.parse(value ?? "");
return Number.isFinite(ts) ? ts : 0;
}
function titleForSort(
session: ChatSummary,
titleOverrides: Record<string, string>,
): string {
return (
titleOverrides[session.key]?.trim()
|| session.title?.trim()
|| deriveTitle(session.preview, "new chat")
).toLocaleLowerCase("en");
}
function sessionTime(session: ChatSummary, field: "createdAt" | "updatedAt"): number {
const ts = Date.parse(session[field] ?? "");
return Number.isFinite(ts) ? ts : 0;
}

View File

@ -7,6 +7,7 @@ import type {
OutboundMcpPresetMention,
OutboundMedia,
GoalStateWsPayload,
WorkspaceScopePayload,
} from "./types";
/** WebSocket readyState constants, referenced by value to stay portable
@ -57,22 +58,25 @@ type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateScope = "metadata" | "thread" | string;
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
type SessionUpdateHandler = (
chatId: string,
scope?: SessionUpdateScope,
workspaceScope?: WorkspaceScopePayload,
) => void;
type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
/** Structured connection-level errors surfaced to the UI.
/** Structured errors surfaced to the UI.
*
* These are *not* InboundEvent errors from the server application layer
* those arrive as ``{event: "error"}`` messages via ``onChat``. These are
* transport-level or protocol-level faults the UI should make visible so
* the user understands *why* their action failed (as opposed to silently
* reconnecting under the hood).
* Most entries are transport-level or protocol-level faults. Workspace scope
* rejections are server application errors promoted here because they affect
* controls outside the message stream and must be visible immediately.
*/
export type StreamError =
/** Server rejected the inbound frame as too large (WS close code 1009).
* Typically means the user attached images whose base64 size exceeded
* ``maxMessageBytes`` on the server. */
| { kind: "message_too_big" };
| { kind: "message_too_big" }
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: string };
type ErrorHandler = (error: StreamError) => void;
@ -206,6 +210,13 @@ export class NanobotClient {
}
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
if (ev.event === "turn_end") {
if (this.runStartedAtByChatId.has(chatId)) {
this.runStartedAtByChatId.delete(chatId);
this.emitRunStatus(chatId, null);
}
return;
}
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
const previous = this.runStartedAtByChatId.get(chatId);
@ -281,7 +292,7 @@ export class NanobotClient {
}
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
newChat(timeoutMs: number = 5_000): Promise<string> {
newChat(timeoutMs: number = 5_000, workspaceScope?: WorkspaceScopePayload | null): Promise<string> {
if (this.pendingNewChat) {
return Promise.reject(new Error("newChat already in flight"));
}
@ -291,7 +302,10 @@ export class NanobotClient {
reject(new Error("newChat timed out"));
}, timeoutMs);
this.pendingNewChat = { resolve, reject, timer };
this.queueSend({ type: "new_chat" });
this.queueSend({
type: "new_chat",
...(workspaceScope ? { workspace_scope: workspaceScope } : {}),
});
});
}
@ -310,6 +324,7 @@ export class NanobotClient {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
},
): void {
this.knownChats.add(chatId);
@ -321,11 +336,21 @@ export class NanobotClient {
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
webui: true,
};
this.queueSend(frame);
}
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
this.knownChats.add(chatId);
this.queueSend({
type: "set_workspace_scope",
chat_id: chatId,
workspace_scope: workspaceScope,
});
}
// -- internals ---------------------------------------------------------
private setStatus(status: ConnectionStatus): void {
@ -388,10 +413,23 @@ export class NanobotClient {
}
if (parsed.event === "session_updated") {
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
this.emitSessionUpdate(parsed.chat_id, parsed.scope, parsed.workspace_scope);
return;
}
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
this.emitError({
kind: "workspace_scope_rejected",
reason: parsed.reason,
chatId: parsed.chat_id,
});
if (this.pendingNewChat) {
clearTimeout(this.pendingNewChat.timer);
this.pendingNewChat.reject(new Error(`workspace_scope_rejected:${parsed.reason || ""}`));
this.pendingNewChat = null;
}
}
const chatId = (parsed as { chat_id?: string }).chat_id;
if (chatId) {
this.recordGoalStatusForRunStrip(chatId, parsed);
@ -406,9 +444,13 @@ export class NanobotClient {
}
}
private emitSessionUpdate(chatId: string, scope?: SessionUpdateScope): void {
private emitSessionUpdate(
chatId: string,
scope?: SessionUpdateScope,
workspaceScope?: WorkspaceScopePayload,
): void {
for (const handler of this.sessionUpdateHandlers) {
handler(chatId, scope);
handler(chatId, scope, workspaceScope);
}
}

View File

@ -92,9 +92,11 @@ export function logoFallbackUrls(logoUrl: string | null | undefined): string[] {
export const PROVIDER_BRAND_ALIASES: Record<string, string> = {
brave_search: "brave",
byteplus_coding_plan: "byteplus",
mimo: "xiaomi_mimo",
minimaxAnthropic: "minimax",
minimax_anthropic: "minimax",
openai_codex: "openai",
xiaomi: "xiaomi_mimo",
volcengine_coding_plan: "volcengine",
};
@ -127,7 +129,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
jina: brand("jina.ai", "#7C3AED", "J"),
kagi: brand("kagi.com", "#FFB319", "K"),
lm_studio: brand("lmstudio.ai", "#111827", "LM"),
longcat: brand("longcat.chat", "#111827", "LC"),
longcat: brand("longcatai.org", "#4F8CFF", "LC", [
"https://www.longcatai.org/favicon.svg",
]),
minimax: brand("minimax.io", "#111827", "MM"),
mistral: brand("mistral.ai", "#FA520F", "M"),
moonshot: brand("moonshot.ai", "#111827", "MS"),
@ -146,7 +150,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
tavily: brand("tavily.com", "#111827", "T"),
volcengine: brand("volcengine.com", "#1664FF", "VE"),
vllm: brand("vllm.ai", "#2563EB", "VL"),
xiaomi_mimo: brand("xiaomimimo.com", "#FF6900", "MI"),
xiaomi_mimo: brand("mimo.xiaomi.com", "#FF6900", "MI", [
"https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg",
]),
zhipu: brand("z.ai", "#155EEF", "Z", [
"https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
"https://www.google.com/s2/favicons?domain=z.ai&sz=64",

211
webui/src/lib/runtime.ts Normal file
View File

@ -0,0 +1,211 @@
import type { RuntimeCapabilities, RuntimeSurface } from "./types";
export interface RuntimeHost {
surface: RuntimeSurface;
capabilities: RuntimeCapabilities;
socketFactory?: (url: string) => WebSocket;
pickFolder?: () => Promise<string | null>;
restartEngine?: () => Promise<void>;
openLogs?: () => Promise<void>;
exportDiagnostics?: () => Promise<string>;
}
export interface HostRuntimeInfo {
surface: "native";
app_version: string;
engine_status: "starting" | "ready" | "restarting" | "stopped" | "crashed";
data_dir: string;
logs_dir: string;
config_path: string;
workspace_path: string;
python: string;
api_base?: string;
engine_transport?: "unix_socket";
}
export interface NanobotHostApi {
getRuntimeInfo(): Promise<HostRuntimeInfo>;
restartEngine(): Promise<void>;
pickFolder(): Promise<string | null>;
openLogs(): Promise<void>;
exportDiagnostics(): Promise<string>;
openSocket?(url: string): Promise<string>;
sendSocket?(id: string, data: string): Promise<void>;
closeSocket?(id: string): Promise<void>;
onSocketEvent?(
listener: (event: HostSocketEvent) => void,
): () => void;
onRuntimeStatus?(
listener: (status: HostRuntimeInfo["engine_status"]) => void,
): () => void;
}
export type HostSocketEvent =
| { id: string; type: "open" }
| { data: string; id: string; type: "message" }
| { id: string; message: string; type: "error" }
| { code?: number; id: string; reason?: string; type: "close" };
type HostSocketBridge = Required<Pick<
NanobotHostApi,
"closeSocket" | "onSocketEvent" | "openSocket" | "sendSocket"
>>;
declare global {
interface Window {
nanobotHost?: NanobotHostApi;
}
}
export function getHostApi(): NanobotHostApi | null {
if (typeof window === "undefined") return null;
return window.nanobotHost ?? null;
}
export function toRuntimeSurface(surface: string | null | undefined): RuntimeSurface {
return surface === "native" ? "native" : "browser";
}
export function createRuntimeHost(
surface: RuntimeSurface,
capabilities?: Partial<RuntimeCapabilities> | null,
): RuntimeHost {
const api = getHostApi();
const mergedCapabilities = {
can_export_diagnostics: false,
can_open_logs: false,
can_pick_folder: false,
can_restart_engine: false,
...(capabilities ?? {}),
};
const bridge = getHostSocketBridge();
return {
surface,
capabilities: mergedCapabilities,
socketFactory: bridge ? createHostWebSocket : undefined,
pickFolder: api?.pickFolder,
restartEngine: api?.restartEngine,
openLogs: api?.openLogs,
exportDiagnostics: api?.exportDiagnostics,
};
}
export function createHostWebSocket(url: string): WebSocket {
const api = getHostSocketBridge();
if (!api) {
throw new Error("Host WebSocket bridge is not available");
}
return new HostWebSocket(api, url) as unknown as WebSocket;
}
function getHostSocketBridge(): HostSocketBridge | null {
const api = getHostApi();
if (
!api?.openSocket
|| !api.sendSocket
|| !api.closeSocket
|| !api.onSocketEvent
) {
return null;
}
return {
closeSocket: api.closeSocket,
onSocketEvent: api.onSocketEvent,
openSocket: api.openSocket,
sendSocket: api.sendSocket,
};
}
class HostWebSocket {
binaryType: BinaryType = "blob";
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null;
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null;
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null;
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null;
readyState: number = WebSocket.CONNECTING;
readonly url: string;
private id: string | null = null;
private readonly queued: string[] = [];
private readonly unsubscribe: () => void;
constructor(
private readonly api: HostSocketBridge,
url: string,
) {
this.url = url;
this.unsubscribe = api.onSocketEvent((event) => this.handleEvent(event));
void api.openSocket(url).then(
(id) => {
this.id = id;
},
() => {
this.readyState = WebSocket.CLOSED;
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
this.onclose?.call(this as unknown as WebSocket, closeEvent());
this.unsubscribe();
},
);
}
close(): void {
if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) {
return;
}
this.readyState = WebSocket.CLOSING;
if (this.id) {
void this.api.closeSocket(this.id);
} else {
this.readyState = WebSocket.CLOSED;
this.unsubscribe();
}
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
if (typeof data !== "string") {
throw new Error("Host WebSocket bridge only supports text frames");
}
if (this.readyState === WebSocket.OPEN && this.id) {
void this.api.sendSocket(this.id, data);
return;
}
this.queued.push(data);
}
private handleEvent(event: HostSocketEvent): void {
if (!this.id || event.id !== this.id) return;
if (event.type === "open") {
this.readyState = WebSocket.OPEN;
this.onopen?.call(this as unknown as WebSocket, new Event("open"));
while (this.queued.length > 0 && this.id) {
const data = this.queued.shift();
if (data !== undefined) void this.api.sendSocket(this.id, data);
}
return;
}
if (event.type === "message") {
this.onmessage?.call(
this as unknown as WebSocket,
new MessageEvent("message", { data: event.data }),
);
return;
}
if (event.type === "error") {
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
return;
}
this.readyState = WebSocket.CLOSED;
this.onclose?.call(
this as unknown as WebSocket,
closeEvent(event.code, event.reason),
);
this.unsubscribe();
}
}
function closeEvent(code = 1006, reason = ""): CloseEvent {
if (typeof CloseEvent !== "undefined") {
return new CloseEvent("close", { code, reason });
}
return new Event("close") as CloseEvent;
}

View File

@ -122,6 +122,7 @@ export interface UIFileEdit {
deleted: number;
approximate?: boolean;
status: "editing" | "done" | "error";
operation?: "edit" | "delete" | string;
binary?: boolean;
error?: string;
pending?: boolean;
@ -139,6 +140,36 @@ export interface ChatSummary {
preview: string;
/** Unix epoch seconds when this session currently has a turn in flight. */
runStartedAt?: number | null;
workspaceScope?: WorkspaceScopePayload | null;
}
export type WorkspaceAccessMode = "restricted" | "full";
export type WebuiDefaultAccessMode = "default" | "full";
export interface WorkspaceScopePayload {
project_path: string;
project_name?: string;
access_mode: WorkspaceAccessMode;
restrict_to_workspace?: boolean;
sandbox_status?: {
restrict_to_workspace: boolean;
workspace_root: string;
level: string;
enforced: boolean;
provider: string;
provider_label: string;
summary: string;
};
}
export interface WorkspacesPayload {
schema_version: number;
default_access_mode: WebuiDefaultAccessMode;
default_scope: WorkspaceScopePayload;
controls: {
can_change_project: boolean;
can_use_full_access: boolean;
};
}
export type SidebarDensity = "comfortable" | "compact";
@ -157,6 +188,7 @@ export interface SidebarStatePayload {
pinned_keys: string[];
archived_keys: string[];
title_overrides: Record<string, string>;
project_name_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>;
collapsed_groups: Record<string, boolean>;
view: SidebarViewState;
@ -166,11 +198,38 @@ export interface SidebarStatePayload {
export interface BootstrapResponse {
token: string;
ws_path: string;
ws_url?: string | null;
expires_in: number;
model_name?: string | null;
runtime_surface?: RuntimeSurface;
runtime_capabilities?: RuntimeCapabilities;
}
export type RuntimeSurface = "browser" | "native";
export type RestartBehavior = "none" | "nextTurn" | "engineRestart" | "appRestart";
export type SettingsApplyStatus =
| "idle"
| "pending"
| "applying"
| "restarting_engine"
| "requires_app_restart";
export interface RuntimeCapabilities {
can_restart_engine: boolean;
can_pick_folder: boolean;
can_open_logs: boolean;
can_export_diagnostics: boolean;
}
export interface SettingsPayload {
surface?: RuntimeSurface;
runtime_surface?: RuntimeSurface;
runtime_capabilities?: RuntimeCapabilities;
apply_state?: {
status: SettingsApplyStatus;
sections: string[];
};
restart_behavior_by_section?: Record<string, RestartBehavior>;
agent: {
model: string;
provider: string;
@ -202,11 +261,15 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
auth_type?: "api_key" | "oauth";
api_key_required?: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
api_type?: "auto" | "chat_completions" | "responses";
oauth_account?: string | null;
oauth_expires_at?: number | null;
oauth_login_supported?: boolean;
}>;
web_search: {
provider: string;
@ -245,6 +308,7 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
auth_type?: "api_key" | "oauth";
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
@ -270,14 +334,27 @@ export interface SettingsPayload {
};
advanced: {
restrict_to_workspace: boolean;
workspace_sandbox?: {
restrict_to_workspace: boolean;
workspace_root: string;
level: "off" | "application" | "system" | string;
enforced: boolean;
provider: string;
provider_label: string;
summary: string;
};
ssrf_whitelist_count: number;
webui_allow_local_service_access: boolean;
allow_local_preview_access?: boolean;
webui_default_access_mode: WebuiDefaultAccessMode;
private_service_protection_enabled: boolean;
mcp_server_count: number;
exec_enabled: boolean;
exec_sandbox?: string | null;
exec_path_append_set: boolean;
};
requires_restart: boolean;
restart_required_sections?: Array<"runtime" | "web" | "image">;
restart_required_sections?: Array<"runtime" | "browser" | "image">;
}
export interface AppPackageRef {
@ -453,6 +530,13 @@ export interface ModelConfigurationCreate {
model: string;
}
export interface ModelConfigurationUpdate {
name: string;
label?: string;
provider?: string;
model?: string;
}
export interface ProviderSettingsUpdate {
provider: string;
apiKey?: string;
@ -469,6 +553,11 @@ export interface WebSearchSettingsUpdate {
useJinaReader?: boolean;
}
export interface NetworkSafetySettingsUpdate {
webuiAllowLocalServiceAccess: boolean;
webuiDefaultAccessMode: WebuiDefaultAccessMode;
}
export interface ImageGenerationSettingsUpdate {
enabled: boolean;
provider: string;
@ -566,8 +655,13 @@ export type InboundEvent =
chat_id: string;
goal_state: GoalStateWsPayload;
}
| { event: "session_updated"; chat_id: string; scope?: "metadata" | "thread" | string }
| { event: "error"; chat_id?: string; detail?: string };
| {
event: "session_updated";
chat_id: string;
scope?: "metadata" | "thread" | string;
workspace_scope?: WorkspaceScopePayload;
}
| { event: "error"; chat_id?: string; detail?: string; reason?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.
*
@ -613,11 +707,13 @@ export interface WebuiThreadPersistedPayload {
sessionKey?: string;
savedAt?: string;
messages: UIMessage[];
workspace_scope?: WorkspaceScopePayload;
}
export type Outbound =
| { type: "new_chat" }
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| {
type: "message";
chat_id: string;
@ -626,6 +722,7 @@ export type Outbound =
image_generation?: OutboundImageGeneration;
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
workspace_scope?: WorkspaceScopePayload;
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;

View File

@ -0,0 +1,56 @@
import type { WorkspaceAccessMode, WorkspaceScopePayload } from "@/lib/types";
export function scopeWithAccessMode(
scope: WorkspaceScopePayload,
accessMode: WorkspaceAccessMode,
): WorkspaceScopePayload {
return {
...scope,
access_mode: accessMode,
restrict_to_workspace: accessMode === "restricted",
};
}
export function projectNameFromPath(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
return normalized.split("/").filter(Boolean).pop() || path;
}
export function shortWorkspacePath(path: string): string {
const normalized = path.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
if (parts.length <= 3) return path;
return `.../${parts.slice(-3).join("/")}`;
}
export function isAbsoluteWorkspacePath(path: string): boolean {
const trimmed = path.trim();
return (
trimmed === "~"
|| trimmed.startsWith("~/")
|| trimmed.startsWith("~\\")
|| trimmed.startsWith("/")
|| /^[A-Za-z]:[\\/]/.test(trimmed)
);
}
export function selectedProjectScope(
scope: WorkspaceScopePayload | null,
defaultScope: WorkspaceScopePayload | null,
): WorkspaceScopePayload | null {
if (!scope || !defaultScope) return null;
return sameWorkspacePath(scope.project_path, defaultScope.project_path) ? null : scope;
}
export function normalizeWorkspacePath(path: string | null | undefined): string {
const normalized = (path ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
return normalized || "/";
}
export function sameWorkspacePath(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
if (!a || !b) return false;
return normalizeWorkspacePath(a) === normalizeWorkspacePath(b);
}

View File

@ -433,6 +433,72 @@ describe("AgentActivityCluster", () => {
}
});
it("labels whole-file deletes as deleted instead of edited", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t-delete",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [{
call_id: "call-delete",
tool: "apply_patch",
path: "angry-birds.html",
phase: "end",
added: 0,
deleted: 590,
approximate: false,
status: "done",
operation: "delete",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /deleted angry-birds\.html/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /edited angry-birds\.html/i })).not.toBeInTheDocument();
});
it("renders file-only edits without a redundant disclosure", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-file-only",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [{
call_id: "call-patch",
tool: "apply_patch",
path: "src/app.tsx",
absolute_path: "/Users/renxubin/project/src/app.tsx",
phase: "end",
added: 12,
deleted: 3,
approximate: false,
status: "done",
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.queryByRole("button", { name: /edited app\.tsx/i })).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByText("Edited")).toBeInTheDocument();
expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx");
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.getByText("-3")).toBeInTheDocument();
});
it("renders CLI app runs as dedicated activity rows", () => {
const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})';
render(
@ -771,6 +837,38 @@ describe("AgentActivityCluster", () => {
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
});
it("shows the reason when a file edit fails", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [{
call_id: "call-patch",
tool: "apply_patch",
path: "angry-birds.html",
phase: "error",
added: 0,
deleted: 0,
approximate: false,
status: "error",
error: "Error applying patch: old_text not found in angry-birds.html",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /failed angry-birds\.html/i }));
expect(screen.getByText("Target text was not found in angry-birds.html.")).toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {

View File

@ -7,15 +7,20 @@ import {
fetchMcpPresets,
fetchSidebarState,
fetchWebuiThread,
fetchWorkspaces,
importMcpConfig,
listSessions,
listSlashCommands,
loginProviderOAuth,
logoutProviderOAuth,
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
updateSidebarState,
updateImageGenerationSettings,
updateModelConfiguration,
updateMcpServerTools,
updateNetworkSafetySettings,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
@ -89,6 +94,44 @@ describe("webui API helpers", () => {
);
});
it("serializes model configuration updates", async () => {
await updateModelConfiguration("tok", {
name: "codex",
label: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("reports HTML API fallbacks as gateway mismatch errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "text/html; charset=utf-8" }),
text: async () => "<!doctype html><html></html>",
}),
);
await expect(
updateModelConfiguration("tok", {
name: "codex",
model: "openai-codex/gpt-5.5",
}),
).rejects.toMatchObject({
status: 200,
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
});
});
it("serializes provider settings updates without returning secrets", async () => {
await updateProviderSettings("tok", {
provider: "openrouter",
@ -104,6 +147,24 @@ 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 logoutProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes web search settings updates", async () => {
await updateWebSearchSettings("tok", {
provider: "searxng",
@ -121,6 +182,20 @@ describe("webui API helpers", () => {
);
});
it("serializes network safety settings updates", async () => {
await updateNetworkSafetySettings("tok", {
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" },
}),
);
});
it("serializes image generation settings updates", async () => {
await updateImageGenerationSettings("tok", {
enabled: true,
@ -257,6 +332,7 @@ describe("webui API helpers", () => {
pinned_keys: ["websocket:chat-1"],
archived_keys: ["websocket:old"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
tags_by_key: {},
collapsed_groups: {},
view: {
@ -292,9 +368,39 @@ describe("webui API helpers", () => {
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
pinned_keys: ["websocket:chat-1"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
});
});
it("fetches workspace project state", async () => {
const payload = {
schema_version: 1,
default_access_mode: "default" as const,
default_scope: {
project_path: "/tmp/workspace",
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
},
controls: {
can_change_project: true,
can_use_full_access: true,
},
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => payload,
} as Response);
await expect(fetchWorkspaces("tok")).resolves.toEqual(payload);
expect(fetch).toHaveBeenCalledWith(
"/api/workspaces",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,

View File

@ -12,6 +12,8 @@ const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
function jsonResponse(body: unknown): Response {
return {
@ -97,6 +99,9 @@ function baseSettingsPayload() {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@ -412,29 +417,7 @@ describe("App layout", () => {
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
fireEvent.pointerDown(within(sidebar).getByRole("button", { name: "View" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(await screen.findByText("Compact list"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.density).toBe("compact");
});
fireEvent.click(screen.getByText("Title A-Z"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.sort).toBe("title_asc");
});
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
});
it("sorts chats by displayed title when A-Z is persisted", async () => {
@ -785,6 +768,9 @@ describe("App layout", () => {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@ -828,8 +814,8 @@ describe("App layout", () => {
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Apps" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
expect(screen.getByText("Brand logos")).toBeInTheDocument();
@ -906,9 +892,13 @@ describe("App layout", () => {
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Runtime" }));
fireEvent.click(within(settingsNav).getByRole("button", { name: "System" }));
expect(screen.getByText("Bot name")).toBeInTheDocument();
expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument();
expect(screen.queryByText("Heartbeat")).not.toBeInTheDocument();
expect(screen.queryByText("Dream")).not.toBeInTheDocument();
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
expect(screen.getByText("Default workspace")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
@ -1071,6 +1061,9 @@ describe("App layout", () => {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@ -1097,7 +1090,7 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
await waitFor(() => expect(document.title).toBe("nanobot"));
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
});
it("filters sessions in the centered search dialog", async () => {
@ -1266,23 +1259,23 @@ describe("App layout", () => {
expect(toggleThemeSpy).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("56px"));
const sidebarAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(sidebarAside.style.width).toBe("56px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
const rail = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(rail).getByRole("button", { name: "New chat" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "Search" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "View" })).toBeInTheDocument();
expect(within(rail).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
expect(within(rail).queryByText("Existing chat")).not.toBeInTheDocument();
fireEvent.click(within(rail).getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.style.width).toBe("272px"));
await waitFor(() => expect(sidebarAside.style.width).toBe("272px"));
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
expect(createChatSpy).not.toHaveBeenCalled();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Settings" })).toBeInTheDocument();

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { deriveWsUrl } from "@/lib/bootstrap";
describe("bootstrap helpers", () => {
it("prefers the server-provided websocket URL over the current dev host", () => {
expect(deriveWsUrl("/", "tok en", "ws://127.0.0.1:8765/")).toBe(
"ws://127.0.0.1:8765/?token=tok%20en",
);
});
it("preserves the host socket bridge URL", () => {
expect(deriveWsUrl("/", "tok en", "nanobot-host://engine/")).toBe(
"nanobot-host://engine/?token=tok%20en",
);
});
it("falls back to the current window host for legacy bootstrap payloads", () => {
expect(deriveWsUrl("/", "tok")).toBe(
"ws://localhost:3000/?token=tok",
);
});
});

Some files were not shown because too many files have changed in this diff Show More