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 .env
.web .web
.orion .orion
nanobot-desktop/
desktop/
# Claude / AI assistant artifacts # Claude / AI assistant artifacts
docs/superpowers/ docs/superpowers/

View File

@ -68,11 +68,13 @@ class ContextBuilder:
skill_names: list[str] | None = None, skill_names: list[str] | None = None,
channel: str | None = None, channel: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
workspace: Path | None = None,
) -> str: ) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills.""" """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: if bootstrap:
parts.append(bootstrap) parts.append(bootstrap)
@ -106,9 +108,10 @@ class ContextBuilder:
return "\n\n---\n\n".join(parts) 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.""" """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() system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" 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) 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.""" """Load all bootstrap files from workspace."""
parts = [] parts = []
root = workspace or self.workspace
for filename in self.BOOTSTRAP_FILES: for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename file_path = root / filename
if file_path.exists(): if file_path.exists():
content = file_path.read_text(encoding="utf-8") content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}") parts.append(f"## {filename}\n\n{content}")
@ -185,11 +189,18 @@ class ContextBuilder:
session_summary: str | None = None, session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None, session_metadata: Mapping[str, Any] | None = None,
current_runtime_lines: Sequence[str] | 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]]: ) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call.""" """Build the complete message list for an LLM call."""
root = workspace or self.workspace
extra = [ extra = [
*goal_state_runtime_lines(session_metadata), *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: if current_runtime_lines:
extra.extend(line for line in current_runtime_lines if line) extra.extend(line for line in current_runtime_lines if line)
runtime_ctx = self._build_runtime_context( runtime_ctx = self._build_runtime_context(
@ -210,7 +221,15 @@ class ContextBuilder:
else: else:
merged = user_content + [{"type": "text", "text": runtime_ctx}] merged = user_content + [{"type": "text", "text": runtime_ctx}]
messages = [ 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, *history,
] ]
if messages[-1].get("role") == current_role: 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.subagent import SubagentManager
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states 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.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.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool 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.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
@ -114,7 +120,6 @@ class TurnContext:
pending_queue: asyncio.Queue | None = None pending_queue: asyncio.Queue | None = None
pending_summary: str | None = None pending_summary: str | None = None
turn_wall_started_at: float = field(default_factory=time.time) turn_wall_started_at: float = field(default_factory=time.time)
turn_latency_ms: int | None = None turn_latency_ms: int | None = None
@ -241,6 +246,10 @@ class AgentLoop:
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
self.cron_service = cron_service self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace 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._start_time = time.time()
self._last_usage: dict[str, int] = {} self._last_usage: dict[str, int] = {}
self._pending_turn_latency_ms: dict[str, int] = {} self._pending_turn_latency_ms: dict[str, int] = {}
@ -470,6 +479,7 @@ class AgentLoop:
provider_snapshot_loader=self._provider_snapshot_loader, provider_snapshot_loader=self._provider_snapshot_loader,
image_generation_provider_configs=self._image_generation_provider_configs, image_generation_provider_configs=self._image_generation_provider_configs,
timezone=self.context.timezone or "UTC", timezone=self.context.timezone or "UTC",
workspace_sandbox=self.workspace_scopes.sandbox_status,
) )
loader = ToolLoader() loader = ToolLoader()
registered = loader.load(ctx, self.tools) registered = loader.load(ctx, self.tools)
@ -493,7 +503,7 @@ class AgentLoop:
session_key: str | None = None, session_key: str | None = None,
) -> None: ) -> None:
"""Update context for all tools that need routing info.""" """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: if session_key is not None:
effective_key = session_key effective_key = session_key
@ -575,6 +585,7 @@ class AgentLoop:
pending_summary: str | None, pending_summary: str | None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the initial message list for the LLM turn.""" """Build the initial message list for the LLM turn."""
scope = self.workspace_scopes.for_message(msg, session.metadata)
return self.context.build_messages( return self.context.build_messages(
history=history, history=history,
current_message=image_generation_prompt(msg.content, msg.metadata), current_message=image_generation_prompt(msg.content, msg.metadata),
@ -583,7 +594,10 @@ class AgentLoop:
chat_id=self._runtime_chat_id(msg), chat_id=self._runtime_chat_id(msg),
sender_id=msg.sender_id, sender_id=msg.sender_id,
session_summary=pending_summary, 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( async def _dispatch_command_inline(
@ -733,7 +747,21 @@ class AgentLoop:
return items return items
active_session_key = session.key if session else session_key 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)) 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 # Build continuation message that embeds the active goal objective so
# the LLM can see it even if earlier Runtime Context was truncated. # 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) _goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
@ -753,7 +781,7 @@ class AgentLoop:
hook=hook, hook=hook,
error_message="Sorry, I encountered an error calling the AI model.", error_message="Sorry, I encountered an error calling the AI model.",
concurrent_tools=True, concurrent_tools=True,
workspace=self.workspace, workspace=effective_scope.project_path,
session_key=session.key if session else None, session_key=session.key if session else None,
context_window_tokens=self.context_window_tokens, context_window_tokens=self.context_window_tokens,
context_block_limit=self.context_block_limit, context_block_limit=self.context_block_limit,
@ -774,6 +802,8 @@ class AgentLoop:
goal_continue_message=_goal_continue, goal_continue_message=_goal_continue,
)) ))
finally: finally:
reset_workspace_scope(workspace_token)
reset_request_context(request_token)
reset_file_states(file_state_token) reset_file_states(file_state_token)
self._last_usage = result.usage self._last_usage = result.usage
if result.stop_reason == "max_iterations": if result.stop_reason == "max_iterations":
@ -1063,6 +1093,7 @@ class AgentLoop:
} }
history = session.get_history(**_hist_kwargs) history = session.get_history(**_hist_kwargs)
current_role = "assistant" if is_subagent else "user" current_role = "assistant" if is_subagent else "user"
workspace_scope = self.workspace_scopes.for_message(msg, session.metadata)
messages = self.context.build_messages( messages = self.context.build_messages(
history=history, history=history,
@ -1072,7 +1103,11 @@ class AgentLoop:
current_role=current_role, current_role=current_role,
sender_id=msg.sender_id, sender_id=msg.sender_id,
session_summary=pending, 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() t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop( final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
@ -1248,6 +1283,7 @@ class AgentLoop:
if ctx.session is None: if ctx.session is None:
ctx.session = self.sessions.get_or_create(ctx.session_key) ctx.session = self.sessions.get_or_create(ctx.session_key)
mark_webui_session(ctx.session, msg.metadata) mark_webui_session(ctx.session, msg.metadata)
self.workspace_scopes.persist_message_scope(ctx.session, msg)
if self._restore_runtime_checkpoint(ctx.session): if self._restore_runtime_checkpoint(ctx.session):
self.sessions.save(ctx.session) self.sessions.save(ctx.session)
@ -1315,7 +1351,10 @@ class AgentLoop:
) )
ctx.initial_messages = self._build_initial_messages( 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.user_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session ctx.msg, ctx.session
@ -1618,10 +1657,16 @@ class AgentLoop:
channel=channel, sender_id="user", chat_id=chat_id, channel=channel, sender_id="user", chat_id=chat_id,
content=content, media=media or [], content=content, media=media or [],
) )
return await self._process_message( try:
msg, return await self._process_message(
session_key=session_key, msg,
on_progress=on_progress, session_key=session_key,
on_stream=on_stream, on_progress=on_progress,
on_stream_end=on_stream_end, 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.file_state import FileStates
from nanobot.agent.tools.loader import ToolLoader from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry 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.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults, ToolsConfig from nanobot.config.schema import AgentDefaults, ToolsConfig
@ -128,6 +134,10 @@ class SubagentManager:
config=cfg, config=cfg,
workspace=str(root.resolve()), workspace=str(root.resolve()),
file_state_store=FileStates(), file_state_store=FileStates(),
workspace_sandbox=workspace_sandbox_status(
restrict_to_workspace=cfg.restrict_to_workspace,
workspace=root,
),
) )
ToolLoader().load(ctx, registry, scope="subagent") ToolLoader().load(ctx, registry, scope="subagent")
return registry return registry
@ -146,6 +156,7 @@ class SubagentManager:
session_key: str | None = None, session_key: str | None = None,
origin_message_id: str | None = None, origin_message_id: str | None = None,
temperature: float | None = None, temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> str: ) -> str:
"""Spawn a subagent to execute a task in the background.""" """Spawn a subagent to execute a task in the background."""
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
@ -162,7 +173,14 @@ class SubagentManager:
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._run_subagent( 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 self._running_tasks[task_id] = bg_task
@ -191,6 +209,7 @@ class SubagentManager:
status: SubagentStatus, status: SubagentStatus,
origin_message_id: str | None = None, origin_message_id: str | None = None,
temperature: float | None = None, temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> None: ) -> None:
"""Execute the subagent task and announce the result.""" """Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label) logger.info("Subagent [{}] starting task: {}", task_id, label)
@ -200,8 +219,13 @@ class SubagentManager:
status.iteration = payload.get("iteration", status.iteration) status.iteration = payload.get("iteration", status.iteration)
try: try:
tools = self._build_tools() root = workspace_scope.project_path if workspace_scope is not None else self.workspace
system_prompt = self._build_subagent_prompt() 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]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": task}, {"role": "user", "content": task},
@ -213,21 +237,27 @@ class SubagentManager:
if self._llm_wall_timeout_for_session if self._llm_wall_timeout_for_session
else None else None
) )
result = await self.runner.run(AgentRunSpec( token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
initial_messages=messages, try:
tools=tools, result = await self.runner.run(AgentRunSpec(
model=self.model, initial_messages=messages,
temperature=temperature, tools=tools,
max_iterations=self.max_iterations, model=self.model,
max_tool_result_chars=self.max_tool_result_chars, temperature=temperature,
hook=_SubagentHook(task_id, status), max_iterations=self.max_iterations,
max_iterations_message="Task completed but no final response was generated.", max_tool_result_chars=self.max_tool_result_chars,
error_message=None, hook=_SubagentHook(task_id, status),
fail_on_tool_error=True, max_iterations_message="Task completed but no final response was generated.",
checkpoint_callback=_on_checkpoint, error_message=None,
session_key=sess_key, fail_on_tool_error=True,
llm_timeout_s=llm_timeout, 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.phase = "done"
status.stop_reason = result.stop_reason status.stop_reason = result.stop_reason
@ -321,20 +351,21 @@ class SubagentManager:
lines.append(f"- {result.error}") lines.append(f"- {result.error}")
return "\n".join(lines) or (result.error or "Error: subagent execution failed.") 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.""" """Build a focused system prompt for the subagent."""
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
time_ctx = ContextBuilder._build_runtime_context(None, None) time_ctx = ContextBuilder._build_runtime_context(None, None)
root = workspace or self.workspace
skills_summary = SkillsLoader( skills_summary = SkillsLoader(
self.workspace, root,
disabled_skills=self.disabled_skills, disabled_skills=self.disabled_skills,
).build_skills_summary() ).build_skills_summary()
return render_template( return render_template(
"agent/subagent_system.md", "agent/subagent_system.md",
time_ctx=time_ctx, time_ctx=time_ctx,
workspace=str(self.workspace), workspace=str(root),
skills_summary=skills_summary or "", skills_summary=skills_summary or "",
) )

View File

@ -88,11 +88,11 @@ def _format_summary(summary: _PatchSummary) -> str:
items=ObjectSchema( items=ObjectSchema(
path=StringSchema("Relative path to the file to edit."), path=StringSchema("Relative path to the file to edit."),
action=StringSchema( action=StringSchema(
"Operation type: replace (find and replace text), add (append new content or create file), delete (remove text).", "Operation type: replace or add.",
enum=["replace", "add", "delete"], enum=["replace", "add"],
), ),
old_text=StringSchema( 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, nullable=True,
), ),
new_text=StringSchema( new_text=StringSchema(
@ -124,7 +124,8 @@ class ApplyPatchTool(_FsTool):
def description(self) -> str: def description(self) -> str:
return ( return (
"Default tool for code edits. Supports multi-file changes in a single call. " "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. " "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." "Use edit_file only for small exact replacements on a single file."
) )
@ -140,7 +141,6 @@ class ApplyPatchTool(_FsTool):
raise _PatchError("must provide edits") raise _PatchError("must provide edits")
writes: dict[Path, str] = {} writes: dict[Path, str] = {}
deletes: set[Path] = set()
summaries: list[_PatchSummary] = [] summaries: list[_PatchSummary] = []
for edit in edits: for edit in edits:
@ -183,7 +183,6 @@ class ApplyPatchTool(_FsTool):
if uses_crlf: if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n") new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm) added, deleted = _line_diff_stats(content, new_norm)
action_name = "update" action_name = "update"
else: else:
@ -191,7 +190,6 @@ class ApplyPatchTool(_FsTool):
if new_norm and not new_norm.endswith("\n"): if new_norm and not new_norm.endswith("\n"):
new_norm += "\n" new_norm += "\n"
writes[source] = new_norm writes[source] = new_norm
deletes.discard(source)
added = _text_line_count(new_norm) added = _text_line_count(new_norm)
deleted = 0 deleted = 0
action_name = "add" action_name = "add"
@ -246,7 +244,6 @@ class ApplyPatchTool(_FsTool):
new_norm = new_norm.replace("\n", "\r\n") new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm) added, deleted = _line_diff_stats(content, new_norm)
summaries.append( summaries.append(
_PatchSummary( _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: else:
raise _PatchError(f"unknown action: {action}") raise _PatchError(f"unknown action: {action}")
@ -319,13 +260,10 @@ class ApplyPatchTool(_FsTool):
) )
backups: dict[Path, bytes | None] = {} 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 backups[path] = path.read_bytes() if path.exists() else None
try: try:
for path in deletes:
if path.exists():
path.unlink()
for path, content in writes.items(): for path, content in writes.items():
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="") path.write_text(content, encoding="utf-8", newline="")
@ -339,7 +277,7 @@ class ApplyPatchTool(_FsTool):
path.write_bytes(data) path.write_bytes(data)
raise raise
for path in set(writes) | deletes: for path in writes:
self._file_states.record_write(path) self._file_states.record_write(path)
return "Patch applied:\n" + "\n".join( return "Patch applied:\n" + "\n".join(
_format_summary(summary) for summary in summaries _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.base import Tool, tool_parameters
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema 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.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import Base from nanobot.config.schema import Base
@ -113,7 +114,12 @@ class CliAppsTool(Tool):
working_dir: str | None = None, working_dir: str | None = None,
timeout: int | None = None, timeout: int | None = None,
) -> str: ) -> 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: try:
return manager.run( return manager.run(
name, name,
@ -121,7 +127,7 @@ class CliAppsTool(Tool):
json_output=bool(json), json_output=bool(json),
working_dir=working_dir, working_dir=working_dir,
timeout=timeout, timeout=timeout,
restrict_to_workspace=self.restrict_to_workspace, restrict_to_workspace=access.restrict_to_workspace,
) )
except CliAppError as exc: except CliAppError as exc:
return f"Error: {exc.message}" return f"Error: {exc.message}"

View File

@ -1,9 +1,15 @@
"""Runtime context for tool construction.""" """Runtime context for tool construction."""
from __future__ import annotations from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable, Protocol, runtime_checkable from typing import Any, Callable, Protocol, runtime_checkable
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
"nanobot_tool_request_context",
default=None,
)
@dataclass(frozen=True) @dataclass(frozen=True)
class RequestContext: 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 @dataclass
class ToolContext: class ToolContext:
config: Any config: Any
@ -33,3 +56,4 @@ class ToolContext:
provider_snapshot_loader: Callable[[], Any] | None = None provider_snapshot_loader: Callable[[], Any] | None = None
image_generation_provider_configs: dict[str, Any] | None = None image_generation_provider_configs: dict[str, Any] | None = None
timezone: str = "UTC" timezone: str = "UTC"
workspace_sandbox: Any | None = None

View File

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

View File

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

View File

@ -14,6 +14,7 @@ from nanobot.agent.tools.schema import (
StringSchema, StringSchema,
tool_parameters_schema, tool_parameters_schema,
) )
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.providers.image_generation import ( from nanobot.providers.image_generation import (
@ -21,6 +22,7 @@ from nanobot.providers.image_generation import (
ImageGenerationProvider, ImageGenerationProvider,
get_image_gen_provider, get_image_gen_provider,
) )
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
from nanobot.utils.artifacts import ( from nanobot.utils.artifacts import (
ArtifactError, ArtifactError,
generated_image_tool_result, generated_image_tool_result,
@ -131,18 +133,22 @@ class ImageGenerationTool(Tool):
return cls(**kwargs) return cls(**kwargs)
def _resolve_reference_image(self, value: str) -> str: def _resolve_reference_image(self, value: str) -> str:
raw_path = Path(value).expanduser() access = current_tool_workspace(self.workspace, restrict_to_workspace=True)
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path workspace = access.project_path or self.workspace
try: try:
resolved = path.resolve(strict=True) resolved = resolve_allowed_path(
except OSError as exc: value,
raise ImageGenerationError(f"reference image not found: {value}") from exc workspace=workspace,
allowed_root=access.allowed_root,
allowed_roots = [self.workspace.resolve(), get_media_dir().resolve()] extra_allowed_roots=[get_media_dir()] if access.allowed_root is not None else None,
if not any(_is_relative_to(resolved, root) for root in allowed_roots): strict=True,
)
except WorkspaceBoundaryError as exc:
raise ImageGenerationError( raise ImageGenerationError(
"reference_images must be inside the workspace or nanobot media directory" "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(): if not resolved.is_file():
raise ImageGenerationError(f"reference image is not a file: {value}") raise ImageGenerationError(f"reference image is not a file: {value}")
raw = resolved.read_bytes() raw = resolved.read_bytes()
@ -201,11 +207,3 @@ class ImageGenerationTool(Tool):
return generated_image_tool_result(artifacts) return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc: except (ArtifactError, ImageGenerationError, OSError) as exc:
return f"Error: {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.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema 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.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path 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]: def _resolve_media(self, media: list[str]) -> list[str]:
"""Resolve local media attachments and enforce workspace restriction when enabled.""" """Resolve local media attachments and enforce workspace restriction when enabled."""
resolved: list[str] = [] 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: for p in media:
if p.startswith(("http://", "https://")): if p.startswith(("http://", "https://")):
resolved.append(p) resolved.append(p)
elif not self._restrict_to_workspace: elif not access.restrict_to_workspace:
path = Path(p).expanduser() 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: 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 return resolved
async def execute( async def execute(

View File

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

View File

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

View File

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

View File

@ -3,16 +3,18 @@
from __future__ import annotations from __future__ import annotations
import time import time
from typing import Any from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from nanobot.agent.subagent import SubagentStatus
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.runtime_state import RuntimeState from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config.schema import Base from nanobot.config.schema import Base
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentStatus
class MyToolConfig(Base): class MyToolConfig(Base):
"""Self-inspection tool configuration.""" """Self-inspection tool configuration."""
@ -33,6 +35,12 @@ def _has_real_attr(obj: Any, key: str) -> bool:
return False return False
def _is_subagent_status(value: Any) -> bool:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
class MyTool(Tool, ContextAware): class MyTool(Tool, ContextAware):
"""Check and set the agent loop's runtime configuration.""" """Check and set the agent loop's runtime configuration."""
@ -68,6 +76,7 @@ class MyTool(Tool, ContextAware):
"_current_iteration", # updated by runner only "_current_iteration", # updated by runner only
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked "exec_config", # inspect allowed (e.g. check sandbox), modify blocked
"web_config", # inspect allowed (e.g. check enable), modify blocked "web_config", # inspect allowed (e.g. check enable), modify blocked
"workspace_sandbox", # read-only view of workspace enforcement level
}) })
_DENIED_ATTRS = frozenset({ _DENIED_ATTRS = frozenset({
@ -214,7 +223,7 @@ class MyTool(Tool, ContextAware):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod @staticmethod
def _format_status(st: SubagentStatus, indent: str = " ") -> str: def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
elapsed = time.monotonic() - st.started_at elapsed = time.monotonic() - st.started_at
tool_summary = ", ".join( tool_summary = ", ".join(
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:] f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
@ -232,14 +241,14 @@ class MyTool(Tool, ContextAware):
@staticmethod @staticmethod
def _format_value(val: Any, key: str = "") -> str: 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}'" header = f"Subagent [{val.task_id}] '{val.label}'"
detail = MyTool._format_status(val, " ") detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val.task_description}\n{detail}" return f"{header}\n task: {val.task_description}\n{detail}"
# SubagentManager: delegate to its _task_statuses dict # SubagentManager: delegate to its _task_statuses dict
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict): if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
return MyTool._format_value(val._task_statuses, key) 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 "" prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(val)} subagent(s):"] lines = [f"{prefix}{len(val)} subagent(s):"]
for tid, st in val.items(): 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(getattr(state, k, None), k))
parts.append(self._format_value(state.model_preset, "model_preset")) parts.append(self._format_value(state.model_preset, "model_preset"))
# Other useful top-level keys shown in description # 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): if _has_real_attr(state, k):
parts.append(self._format_value(getattr(state, k, None), k)) parts.append(self._format_value(getattr(state, k, None), k))
# Token usage # Token usage

View File

@ -25,10 +25,13 @@ from nanobot.agent.tools.exec_session import (
clamp_session_int, clamp_session_int,
format_session_poll, 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.sandbox import wrap_command
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema 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.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.security.workspace_policy import is_path_within
_IS_WINDOWS = sys.platform == "win32" _IS_WINDOWS = sys.platform == "win32"
@ -140,6 +143,7 @@ class ExecTool(Tool):
working_dir=ctx.workspace, working_dir=ctx.workspace,
timeout=cfg.timeout, timeout=cfg.timeout,
restrict_to_workspace=ctx.config.restrict_to_workspace, restrict_to_workspace=ctx.config.restrict_to_workspace,
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
sandbox=cfg.sandbox, sandbox=cfg.sandbox,
path_append=cfg.path_append, path_append=cfg.path_append,
allowed_env_keys=cfg.allowed_env_keys, allowed_env_keys=cfg.allowed_env_keys,
@ -154,6 +158,8 @@ class ExecTool(Tool):
deny_patterns: list[str] | None = None, deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None, allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
webui_allow_local_service_access: bool = True,
allow_local_preview_access: bool | None = None,
sandbox: str = "", sandbox: str = "",
path_append: str = "", path_append: str = "",
allowed_env_keys: list[str] | None = None, allowed_env_keys: list[str] | None = None,
@ -183,6 +189,9 @@ class ExecTool(Tool):
] ]
self.allow_patterns = allow_patterns or [] self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace 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.path_append = path_append
self.allowed_env_keys = allowed_env_keys or [] self.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@ -313,6 +322,7 @@ class ExecTool(Tool):
shell_program=prepared.shell_program, shell_program=prepared.shell_program,
login=prepared.login, login=prepared.login,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS), 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=clamp_session_int(
max_output_chars, max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS, DEFAULT_MAX_OUTPUT_CHARS,
@ -346,29 +356,39 @@ class ExecTool(Tool):
shell: str | None = None, shell: str | None = None,
login: bool | None = None, login: bool | None = None,
) -> _PreparedCommand | str: ) -> _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 # Prevent an LLM-supplied working_dir from escaping the configured
# workspace when restrict_to_workspace is enabled (#2826). Without # workspace when restrict_to_workspace is enabled (#2826). Without
# this, a caller can pass working_dir="/etc" and then all absolute # this, a caller can pass working_dir="/etc" and then all absolute
# paths under /etc would pass the _guard_command check that anchors # paths under /etc would pass the _guard_command check that anchors
# on cwd. # on cwd.
if self.restrict_to_workspace and self.working_dir: if access.restrict_to_workspace and workspace_root:
try: try:
requested = Path(cwd).expanduser().resolve() requested = Path(cwd).expanduser().resolve()
workspace_root = Path(self.working_dir).expanduser().resolve() resolved_root = Path(workspace_root).expanduser().resolve()
except Exception: except Exception:
return ( return (
"Error: working_dir could not be resolved" "Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE + _WORKSPACE_BOUNDARY_NOTE
) )
if requested != workspace_root and workspace_root not in requested.parents: if not is_path_within(requested, resolved_root):
return ( return (
"Error: working_dir is outside the configured workspace" "Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE + _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: if guard_error:
return guard_error return guard_error
@ -379,7 +399,7 @@ class ExecTool(Tool):
self.sandbox, self.sandbox,
) )
else: else:
workspace = self.working_dir or cwd workspace = workspace_root or cwd
command = wrap_command(self.sandbox, command, workspace, cwd) command = wrap_command(self.sandbox, command, workspace, cwd)
cwd = str(Path(workspace).resolve()) cwd = str(Path(workspace).resolve())
@ -528,7 +548,13 @@ class ExecTool(Tool):
env[key] = val env[key] = val
return env 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.""" """Best-effort safety guard for potentially destructive commands."""
cmd = command.strip() cmd = command.strip()
lower = cmd.lower() lower = cmd.lower()
@ -548,11 +574,17 @@ class ExecTool(Tool):
return "Error: Command blocked by allowlist filter (not in allowlist)" return "Error: Command blocked by allowlist filter (not in allowlist)"
from nanobot.security.network import contains_internal_url 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. # The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)" 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: if "..\\" in cmd or "../" in cmd:
return ( return (
"Error: Command blocked by safety guard (path traversal detected)" "Error: Command blocked by safety guard (path traversal detected)"
@ -577,11 +609,9 @@ class ExecTool(Tool):
continue continue
media_path = get_media_dir().resolve() media_path = get_media_dir().resolve()
if (p.is_absolute() if p.is_absolute() and not (
and cwd_path not in p.parents is_path_within(p, cwd_path)
and p != cwd_path or is_path_within(p, media_path)
and media_path not in p.parents
and p != media_path
): ):
return ( return (
"Error: Command blocked by safety guard (path outside working dir)" "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.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_workspace_scope
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
@ -91,4 +92,5 @@ class SpawnTool(Tool, ContextAware):
session_key=self._session_key.get(), session_key=self._session_key.get(),
origin_message_id=self._origin_message_id.get(), origin_message_id=self._origin_message_id.get(),
temperature=temperature, 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.apps.protocol import app_manifest, compact_dict
from nanobot.config.paths import get_runtime_subdir 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_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_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_-]+") _SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE) _MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<") _SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
_ARTIFACT_EXTENSIONS = frozenset({ _ARTIFACT_EXTENSIONS = frozenset({
".csv", ".csv",
".drawio", ".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 ..." 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: class CliAppManager:
"""Manage CLI-Anything registry entries and local install state.""" """Manage CLI-Anything registry entries and local install state."""
@ -554,7 +562,7 @@ class CliAppManager:
"name": name, "name": name,
"display_name": app.get("display_name") or name, "display_name": app.get("display_name") or name,
"category": app.get("category") or "uncategorized", "category": app.get("category") or "uncategorized",
"description": app.get("description") or "", "description": _catalog_description(app),
"requires": app.get("requires") or "", "requires": app.get("requires") or "",
"source": app.get("_source") or "harness", "source": app.get("_source") or "harness",
"entry_point": entry_point, "entry_point": entry_point,
@ -630,7 +638,7 @@ class CliAppManager:
app_id=name, app_id=name,
display_name=str(app.get("display_name") or name), display_name=str(app.get("display_name") or name),
version=str(app.get("version") or ""), version=str(app.get("version") or ""),
description=str(app.get("description") or ""), description=_catalog_description(app),
category=str(app.get("category") or "uncategorized"), category=str(app.get("category") or "uncategorized"),
source=f"cli-anything:{app.get('_source') or 'harness'}", source=f"cli-anything:{app.get('_source') or 'harness'}",
logo_url=logo_url, logo_url=logo_url,
@ -802,7 +810,7 @@ class CliAppManager:
name = str(app.get("name") or "unknown") name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name) display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{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"""--- return f"""---
name: {_safe_skill_name(name)} name: {_safe_skill_name(name)}
description: >- 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 = Path(working_dir).expanduser() if working_dir else self.workspace
cwd = cwd.resolve(strict=False) cwd = cwd.resolve(strict=False)
workspace = self.workspace.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") raise CliAppError("working_dir is outside the configured workspace")
return cwd return cwd

View File

@ -57,11 +57,17 @@ class ChannelManager:
*, *,
session_manager: "SessionManager | None" = None, session_manager: "SessionManager | None" = None,
webui_runtime_model_name: Callable[[], str | None] | 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.config = config
self.bus = bus self.bus = bus
self._session_manager = session_manager self._session_manager = session_manager
self._webui_runtime_model_name = webui_runtime_model_name 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.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None self._dispatch_task: asyncio.Task | None = None
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {} self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
@ -107,12 +113,15 @@ class ChannelManager:
if cls.name == "websocket": if cls.name == "websocket":
if self._session_manager is not None: if self._session_manager is not None:
kwargs["session_manager"] = self._session_manager 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: if static_path is not None:
kwargs["static_dist_path"] = static_path kwargs["static_dist_path"] = static_path
kwargs["workspace_path"] = self.config.workspace_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: if self._webui_runtime_model_name is not None:
kwargs["runtime_model_name"] = self._webui_runtime_model_name 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 = cls(section, self.bus, **kwargs)
channel.transcription_provider = transcription_provider channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key channel.transcription_api_key = transcription_key

View File

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

View File

@ -18,19 +18,24 @@ import ssl
import time import time
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Self from typing import TYPE_CHECKING, Any, Self
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from loguru import logger from loguru import logger
from pydantic import Field, field_validator, model_validator 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.datastructures import Headers
from websockets.exceptions import ConnectionClosed from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from websockets.http11 import Response from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload 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.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel 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 ( from nanobot.webui.settings_api import (
WebUISettingsError, WebUISettingsError,
create_model_configuration, create_model_configuration,
decorate_settings_payload,
login_oauth_provider,
logout_oauth_provider,
runtime_capabilities,
settings_payload, settings_payload,
update_agent_settings, update_agent_settings,
update_image_generation_settings, update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings, update_provider_settings,
update_web_search_settings, update_web_search_settings,
) )
@ -73,6 +84,9 @@ from nanobot.webui.transcript import (
build_webui_thread_response, build_webui_thread_response,
rewrite_local_markdown_images, rewrite_local_markdown_images,
) )
from nanobot.webui.workspaces import (
WebUIWorkspaceController,
)
_MCP_PRESET_ACTIONS_BY_PATH = { _MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable", "/api/settings/mcp-presets/enable": "enable",
@ -100,6 +114,41 @@ def _normalize_config_path(path: str) -> str:
return _strip_trailing_slash(path) 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): class WebSocketConfig(Base):
"""WebSocket server channel configuration. """WebSocket server channel configuration.
@ -123,6 +172,7 @@ class WebSocketConfig(Base):
enabled: bool = False enabled: bool = False
host: str = "127.0.0.1" host: str = "127.0.0.1"
port: int = 8765 port: int = 8765
unix_socket_path: str = ""
path: str = "/" path: str = "/"
token: str = "" token: str = ""
token_issue_path: str = "" token_issue_path: str = ""
@ -141,6 +191,19 @@ class WebSocketConfig(Base):
ssl_certfile: str = "" ssl_certfile: str = ""
ssl_keyfile: 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") @field_validator("path")
@classmethod @classmethod
def path_must_start_with_slash(cls, value: str) -> str: def path_must_start_with_slash(cls, value: str) -> str:
@ -503,7 +566,10 @@ class WebSocketChannel(BaseChannel):
session_manager: "SessionManager | None" = None, session_manager: "SessionManager | None" = None,
static_dist_path: Path | None = None, static_dist_path: Path | None = None,
workspace_path: Path | None = None, workspace_path: Path | None = None,
restrict_to_workspace: bool = False,
runtime_model_name: Callable[[], str | None] | None = None, runtime_model_name: Callable[[], str | None] | None = None,
runtime_surface: str = "browser",
runtime_capabilities_overrides: dict[str, Any] | None = None,
): ):
if isinstance(config, dict): if isinstance(config, dict):
config = WebSocketConfig.model_validate(config) config = WebSocketConfig.model_validate(config)
@ -530,7 +596,20 @@ class WebSocketChannel(BaseChannel):
if workspace_path is not None if workspace_path is not None
else get_workspace_path() else get_workspace_path()
).resolve(strict=False) ).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_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._settings_restart_sections: set[str] = set()
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {} self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
# Process-local secret used to HMAC-sign media URLs. The signed URL is # Process-local secret used to HMAC-sign media URLs. The signed URL is
@ -695,6 +774,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/commands": if got == "/api/commands":
return self._handle_commands(request) return self._handle_commands(request)
if got == "/api/workspaces":
return self._handle_workspaces(connection, request)
if got == "/api/webui/sidebar-state": if got == "/api/webui/sidebar-state":
return self._handle_webui_sidebar_state(request) return self._handle_webui_sidebar_state(request)
@ -707,15 +789,27 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/model-configurations/create": if got == "/api/settings/model-configurations/create":
return self._handle_settings_model_configuration_create(request) 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": if got == "/api/settings/provider/update":
return self._handle_settings_provider_update(request) 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": if got == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request) return self._handle_settings_web_search_update(request)
if got == "/api/settings/image-generation/update": if got == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request) 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": if got == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request) return self._handle_settings_cli_apps(request)
@ -773,6 +867,12 @@ class WebSocketChannel(BaseChannel):
return connection.respond(403, "Forbidden") return connection.respond(403, "Forbidden")
return self._authorize_websocket_handshake(connection, query) 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). # 5. Static SPA serving (only if a build directory was wired in).
if self._static_dist_path is not None: if self._static_dist_path is not None:
response = self._serve_static(got) response = self._serve_static(got)
@ -832,15 +932,32 @@ class WebSocketChannel(BaseChannel):
# while the REST surface keeps validating the other until TTL expiry. # while the REST surface keeps validating the other until TTL expiry.
self._issued_tokens[token] = expiry self._issued_tokens[token] = expiry
self._api_tokens[token] = expiry self._api_tokens[token] = expiry
ws_url = self._bootstrap_ws_url(request)
return _http_json_response( return _http_json_response(
{ {
"token": token, "token": token,
"ws_path": self._expected_path(), "ws_path": self._expected_path(),
"ws_url": ws_url,
"expires_in": self.config.token_ttl_s, "expires_in": self.config.token_ttl_s,
"model_name": _resolve_bootstrap_model_name(self._runtime_model_name), "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: def _handle_sessions_list(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
@ -859,13 +976,29 @@ class WebSocketChannel(BaseChannel):
started_at = websocket_turn_wall_started_at(chat_id) started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None: if started_at is not None:
row["run_started_at"] = started_at row["run_started_at"] = started_at
scope = self._webui_workspaces.scope_for_session_key(key)
row["workspace_scope"] = scope.payload()
cleaned.append(row) cleaned.append(row)
return _http_json_response({"sessions": cleaned}) 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: def _handle_settings(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
return _http_json_response(self._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( def _with_settings_restart_state(
self, self,
@ -876,14 +1009,16 @@ class WebSocketChannel(BaseChannel):
"""Keep restart-required state alive for this gateway process.""" """Keep restart-required state alive for this gateway process."""
if section and payload.get("requires_restart"): if section and payload.get("requires_restart"):
self._settings_restart_sections.add(section) self._settings_restart_sections.add(section)
if self._settings_restart_sections: sections = sorted(self._settings_restart_sections)
payload = dict(payload) payload = dict(payload)
if sections:
payload["requires_restart"] = True payload["requires_restart"] = True
payload["restart_required_sections"] = sorted(self._settings_restart_sections) return decorate_settings_payload(
else: payload,
payload = dict(payload) surface=self._runtime_surface,
payload["restart_required_sections"] = [] runtime_capability_overrides=self._runtime_capabilities,
return payload restart_required_sections=sections,
)
def _handle_commands(self, request: WsRequest) -> Response: def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
@ -939,6 +1074,16 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message) return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload)) 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: def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
@ -949,6 +1094,19 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message) return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image")) 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: def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
@ -957,7 +1115,7 @@ class WebSocketChannel(BaseChannel):
payload = update_web_search_settings(query) payload = update_web_search_settings(query)
except WebUISettingsError as e: except WebUISettingsError as e:
return _http_error(e.status, e.message) 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: def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
@ -969,6 +1127,16 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message) return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image")) 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: def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
@ -1058,13 +1226,19 @@ class WebSocketChannel(BaseChannel):
return _http_error(400, "invalid session key") return _http_error(400, "invalid session key")
if not self._is_websocket_channel_session_key(decoded_key): if not self._is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found") return _http_error(404, "session not found")
scope = self._webui_workspaces.scope_for_session_key(decoded_key)
data = build_webui_thread_response( data = build_webui_thread_response(
decoded_key, decoded_key,
augment_user_media=self._augment_transcript_user_media, 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: if data is None:
return _http_error(404, "webui thread not found") return _http_error(404, "webui thread not found")
data["workspace_scope"] = scope.payload()
return _http_json_response(data) return _http_json_response(data)
def _try_append_webui_transcript(self, chat_id: str, wire: dict[str, Any]) -> None: 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) await self._connection_loop(connection)
self.logger.info( self.logger.info(
"WebSocket server listening on {}://{}:{}{}", "WebSocket server listening on {}",
scheme, (
self.config.host, f"unix:{self.config.unix_socket_path}{self.config.path}"
self.config.port, if self.config.unix_socket_path
self.config.path, else f"{scheme}://{self.config.host}:{self.config.port}{self.config.path}"
),
) )
if self.config.token_issue_path: if self.config.token_issue_path:
self.logger.info( self.logger.info(
"WebSocket token issue route: {}://{}:{}{}", "WebSocket token issue route: {}",
scheme, (
self.config.host, f"unix:{self.config.unix_socket_path}{_normalize_config_path(self.config.token_issue_path)}"
self.config.port, if self.config.unix_socket_path
_normalize_config_path(self.config.token_issue_path), else (
f"{scheme}://{self.config.host}:{self.config.port}"
f"{_normalize_config_path(self.config.token_issue_path)}"
)
),
) )
async def runner() -> None: async def runner() -> None:
async with serve( socket_path = self.config.unix_socket_path
handler, if socket_path:
self.config.host, path_obj = Path(socket_path)
self.config.port, path_obj.parent.mkdir(parents=True, exist_ok=True)
process_request=process_request, with suppress(FileNotFoundError):
max_size=self.config.max_message_bytes, path_obj.unlink()
ping_interval=self.config.ping_interval_s, server = await unix_serve(
ping_timeout=self.config.ping_timeout_s, handler,
ssl=ssl_context, 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 assert self._stop_event is not None
await self._stop_event.wait() 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()) self._server_task = asyncio.create_task(runner())
await self._server_task await self._server_task
@ -1530,8 +1733,25 @@ class WebSocketChannel(BaseChannel):
t = envelope.get("type") t = envelope.get("type")
if t == "new_chat": if t == "new_chat":
new_id = str(uuid.uuid4()) 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) self._attach(connection, new_id)
await self._send_event(connection, "attached", chat_id=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) await self._hydrate_after_subscribe(new_id)
return return
if t == "attach": if t == "attach":
@ -1543,6 +1763,32 @@ class WebSocketChannel(BaseChannel):
await self._send_event(connection, "attached", chat_id=cid) await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid) await self._hydrate_after_subscribe(cid)
return 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": if t == "message":
cid = envelope.get("chat_id") cid = envelope.get("chat_id")
content = envelope.get("content") content = envelope.get("content")
@ -1574,6 +1820,18 @@ class WebSocketChannel(BaseChannel):
if not content.strip() and not media_paths: if not content.strip() and not media_paths:
await self._send_event(connection, "error", detail="missing content") await self._send_event(connection, "error", detail="missing content")
return 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. # Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid) self._attach(connection, cid)
@ -1587,6 +1845,8 @@ class WebSocketChannel(BaseChannel):
mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets")) mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets"))
if mcp_presets: if mcp_presets:
metadata["mcp_presets"] = 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") image_generation = envelope.get("image_generation")
if isinstance(image_generation, dict) and image_generation.get("enabled") is True: if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
aspect_ratio = image_generation.get("aspect_ratio") aspect_ratio = image_generation.get("aspect_ratio")
@ -1605,6 +1865,25 @@ class WebSocketChannel(BaseChannel):
return return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}") 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: async def stop(self) -> None:
if not self._running: if not self._running:
return return

View File

@ -720,11 +720,144 @@ def gateway(
_run_gateway(cfg, port=port) _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( def _run_gateway(
config: Config, config: Config,
*, *,
port: int | None = None, port: int | None = None,
open_browser_url: str | 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: ) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up.""" """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
@ -957,6 +1090,9 @@ def _run_gateway(
bus, bus,
session_manager=session_manager, session_manager=session_manager,
webui_runtime_model_name=_webui_runtime_model_name, 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]: def _pick_heartbeat_target() -> tuple[str, str]:
@ -1088,8 +1224,9 @@ def _run_gateway(
tasks = [ tasks = [
agent.run(), agent.run(),
channels.start_all(), 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: if open_browser_url:
tasks.append(_open_browser_when_ready()) tasks.append(_open_browser_when_ready())
await asyncio.gather(*tasks) await asyncio.gather(*tasks)

View File

@ -295,7 +295,16 @@ class ToolsConfig(Base):
image_generation: ImageGenerationToolConfig = Field( image_generation: ImageGenerationToolConfig = Field(
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"), 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) 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) 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"), 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") @model_validator(mode="after")
def _validate_model_preset(self) -> "Config": def _validate_model_preset(self) -> "Config":
if "default" in self.model_presets: 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.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses import ( from nanobot.providers.openai_responses import (
consume_sse, consume_sse_with_reasoning,
convert_messages, convert_messages,
convert_tools, convert_tools,
) )
@ -41,6 +41,7 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort: str | None, reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None, tool_choice: str | dict[str, Any] | None,
on_content_delta: Callable[[str], Awaitable[None]] | None = 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, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
"""Shared request logic for both chat() and chat_stream().""" """Shared request logic for both chat() and chat_stream()."""
@ -62,28 +63,36 @@ class OpenAICodexProvider(LLMProvider):
"tool_choice": tool_choice or "auto", "tool_choice": tool_choice or "auto",
"parallel_tool_calls": True, "parallel_tool_calls": True,
} }
if reasoning_effort and reasoning_effort.lower() != "none": reasoning_options = _build_reasoning_options(reasoning_effort)
body["reasoning"] = {"effort": reasoning_effort} if reasoning_options:
body["reasoning"] = reasoning_options
if tools: if tools:
body["tools"] = convert_tools(tools) body["tools"] = convert_tools(tools)
try: try:
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, DEFAULT_CODEX_URL, headers, body, verify=True,
on_content_delta=on_content_delta, on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
) )
except Exception as e: except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e): if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise raise
logger.warning("SSL verification failed for Codex API; retrying with verify=False") 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, DEFAULT_CODEX_URL, headers, body, verify=False,
on_content_delta=on_content_delta, on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_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: except Exception as e:
response = _codex_error_response(e) response = _codex_error_response(e)
exc_type = "CodexHTTPError" if isinstance(e, _CodexHTTPError) else type(e).__name__ 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_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
_ = on_thinking_delta
return await self._call_codex( return await self._call_codex(
messages, messages,
tools, tools,
@ -126,6 +134,7 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort, reasoning_effort,
tool_choice, tool_choice,
on_content_delta, on_content_delta,
on_thinking_delta,
on_tool_call_delta, on_tool_call_delta,
) )
@ -139,6 +148,16 @@ def _strip_model_prefix(model: str) -> str:
return model 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]: def _build_headers(account_id: str, token: str) -> dict[str, str]:
return { return {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
@ -176,8 +195,9 @@ async def _request_codex(
body: dict[str, Any], body: dict[str, Any],
verify: bool, verify: bool,
on_content_delta: Callable[[str], Awaitable[None]] | None = 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, 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")) 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 httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response: async with client.stream("POST", url, headers=headers, json=body) as response:
@ -194,7 +214,12 @@ async def _request_codex(
error_code=error_code, error_code=error_code,
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw), 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: 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, FINISH_REASON_MAP,
consume_sdk_stream, consume_sdk_stream,
consume_sse, consume_sse,
consume_sse_with_reasoning,
iter_sse, iter_sse,
map_finish_reason, map_finish_reason,
parse_response_output, parse_response_output,
@ -22,6 +23,7 @@ __all__ = [
"split_tool_call_id", "split_tool_call_id",
"iter_sse", "iter_sse",
"consume_sse", "consume_sse",
"consume_sse_with_reasoning",
"consume_sdk_stream", "consume_sdk_stream",
"map_finish_reason", "map_finish_reason",
"parse_response_output", "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, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]: ) -> tuple[str, list[ToolCallRequest], str]:
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``.""" """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 = "" content = ""
tool_calls: list[ToolCallRequest] = [] tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {} tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop" finish_reason = "stop"
reasoning_content: str | None = None
streamed_reasoning = False
async for event in iter_sse(response): async for event in iter_sse(response):
event_type = event.get("type") event_type = event.get("type")
@ -94,6 +112,26 @@ async def consume_sse(
content += delta_text content += delta_text
if on_content_delta and delta_text: if on_content_delta and delta_text:
await on_content_delta(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": elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id") call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers: 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": elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id") call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers: 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": elif event_type == "response.output_item.done":
item = event.get("item") or {} item = event.get("item") or {}
if item.get("type") == "function_call": if item.get("type") == "function_call":
@ -117,6 +163,13 @@ async def consume_sse(
continue continue
buf = tool_call_buffers.get(call_id) or {} buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or item.get("arguments") 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: try:
args = json.loads(args_raw) args = json.loads(args_raw)
except Exception: except Exception:
@ -135,14 +188,44 @@ async def consume_sse(
arguments=args, 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": 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) 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"}: elif event_type in {"error", "response.failed"}:
detail = event.get("error") or event.get("message") or event detail = event.get("error") or event.get("message") or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}") 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: def parse_response_output(response: Any) -> LLMResponse:
@ -230,6 +313,7 @@ async def consume_sdk_stream(
content = "" content = ""
tool_calls: list[ToolCallRequest] = [] tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {} tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop" finish_reason = "stop"
usage: dict[str, int] = {} usage: dict[str, int] = {}
reasoning_content: str | None = None reasoning_content: str | None = None
@ -272,7 +356,15 @@ async def consume_sdk_stream(
elif event_type == "response.function_call_arguments.done": elif event_type == "response.function_call_arguments.done":
call_id = getattr(event, "call_id", None) call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers: 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": elif event_type == "response.output_item.done":
item = getattr(event, "item", None) item = getattr(event, "item", None)
if item and getattr(item, "type", None) == "function_call": if item and getattr(item, "type", None) == "function_call":
@ -281,6 +373,13 @@ async def consume_sdk_stream(
continue continue
buf = tool_call_buffers.get(call_id) or {} buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or getattr(item, "arguments", None) 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: try:
args = json.loads(args_raw) args = json.loads(args_raw)
except Exception: 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) 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. """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. Returns (ok, error_message). When ok is True, error_message is empty.
""" """
try: try:
@ -66,11 +71,16 @@ def validate_url_target(url: str) -> tuple[bool, str]:
except socket.gaierror: except socket.gaierror:
return False, f"Cannot resolve hostname: {hostname}" return False, f"Cannot resolve hostname: {hostname}"
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
for info in infos: for info in infos:
try: try:
addr = ipaddress.ip_address(info[4][0]) addr = ipaddress.ip_address(info[4][0])
except ValueError: except ValueError:
continue continue
addrs.append(addr)
if allow_loopback and _is_allowed_loopback_target(hostname, addrs):
return True, ""
for addr in addrs:
if _is_private(addr): if _is_private(addr):
return False, f"Blocked: {hostname} resolves to private/internal address {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, "" 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.""" """Return True if the command string contains a URL targeting an internal/private address."""
for m in _URL_RE.finditer(command): for m in _URL_RE.finditer(command):
url = m.group(0) url = m.group(0)
ok, _ = validate_url_target(url) ok, _ = validate_url_target(url, allow_loopback=allow_loopback)
if not ok: if not ok:
return True return True
return False 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, deleted=deleted,
approximate=False, approximate=False,
binary=(after.binary or after.oversized or after.unreadable) and not counted, 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, added: int,
deleted: int = 0, deleted: int = 0,
operation: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build an approximate in-progress event while tool-call arguments stream.""" """Build an approximate in-progress event while tool-call arguments stream."""
return _event_payload( return _event_payload(
@ -333,6 +335,7 @@ def build_file_edit_live_event(
added=added, added=added,
deleted=deleted, deleted=deleted,
approximate=True, 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_end = path_matches[i + 1].start() if i + 1 < len(path_matches) else len(state.arguments)
segment = state.arguments[segment_start:segment_end] 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" action = action_match.group(1) if action_match else "replace"
old_text = _extract_json_string_prefix(segment, "old_text") or "" old_text = _extract_json_string_prefix(segment, "old_text") or ""
new_text = _extract_json_string_prefix(segment, "new_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 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 deleted = _text_line_count(old_text) if action == "replace" else 0
delete_file = action == "delete"
file_state = state.patch_files.get(raw_path) file_state = state.patch_files.get(raw_path)
if file_state is None: if file_state is None:
@ -475,8 +477,6 @@ class StreamingFileEditTracker:
) )
file_state = _StreamingPatchFileState(tracker=tracker) file_state = _StreamingPatchFileState(tracker=tracker)
state.patch_files[raw_path] = file_state 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): if not file_state.should_emit(added, deleted, now):
continue continue
file_state.mark_emitted(added, deleted, now) file_state.mark_emitted(added, deleted, now)
@ -916,6 +916,7 @@ def _event_payload(
deleted: int, deleted: int,
approximate: bool, approximate: bool,
binary: bool = False, binary: bool = False,
operation: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"version": 1, "version": 1,
@ -931,6 +932,8 @@ def _event_payload(
} }
if binary: if binary:
payload["binary"] = True payload["binary"] = True
if operation:
payload["operation"] = operation
return payload return payload

View File

@ -124,7 +124,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="playwright", name="playwright",
display_name="Playwright", display_name="Playwright",
category="browser", 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", docs_url="https://playwright.dev/docs/getting-started-mcp",
transport="stdio", transport="stdio",
install_supported=True, install_supported=True,
@ -216,7 +216,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="microsoft-learn", name="microsoft-learn",
display_name="Microsoft Learn", display_name="Microsoft Learn",
category="docs", 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", docs_url="https://learn.microsoft.com/en-us/training/support/mcp",
transport="streamableHttp", transport="streamableHttp",
install_supported=True, install_supported=True,
@ -307,7 +307,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="figma", name="figma",
display_name="Figma", display_name="Figma",
category="design", 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", docs_url="https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server",
transport="streamableHttp", transport="streamableHttp",
install_supported=True, install_supported=True,
@ -325,7 +325,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="github", name="github",
display_name="GitHub", display_name="GitHub",
category="code", 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", docs_url="https://github.com/github/github-mcp-server",
transport="stdio", transport="stdio",
install_supported=True, install_supported=True,

View File

@ -7,7 +7,9 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
from __future__ import annotations from __future__ import annotations
import re import re
from typing import Any import time
from contextlib import suppress
from typing import Any, Literal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from nanobot.config.loader import get_config_path, load_config, save_config 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, image_gen_provider_names,
) )
from nanobot.providers.registry import PROVIDERS, find_by_name 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]] 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], ...] = ( _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"}, {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
@ -55,6 +97,70 @@ class WebUISettingsError(ValueError):
self.status = status 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: def _query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key) values = query.get(key)
return values[0] if values else None return values[0] if values else None
@ -83,9 +189,57 @@ def _provider_requires_api_key(spec: Any) -> bool:
return True 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: def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if spec.is_oauth: if spec.is_oauth:
return True return bool(_oauth_provider_status(spec)["configured"])
if _provider_requires_api_key(spec): if _provider_requires_api_key(spec):
return bool(provider_config.api_key) return bool(provider_config.api_key)
return bool( return bool(
@ -144,6 +298,7 @@ def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
"name": name, "name": name,
"label": spec.label if spec is not None else name, "label": spec.label if spec is not None else name,
"configured": configured, "configured": configured,
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
"api_key_hint": _mask_secret_hint( "api_key_hint": _mask_secret_hint(
getattr(provider_config, "api_key", None) getattr(provider_config, "api_key", None)
), ),
@ -156,7 +311,14 @@ def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
return rows 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() config = load_config()
defaults = config.agents.defaults defaults = config.agents.defaults
active_preset_name = defaults.model_preset or "default" active_preset_name = defaults.model_preset or "default"
@ -179,17 +341,27 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
providers = [] providers = []
for spec in PROVIDERS: for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None) provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth: if provider_config is None:
continue continue
oauth_status = _oauth_provider_status(spec) if spec.is_oauth else None
row = { row = {
"name": spec.name, "name": spec.name,
"label": spec.label, "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_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key), "api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base, "api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None, "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": if spec.name == "openai":
row["api_type"] = provider_config.api_type row["api_type"] = provider_config.api_type
providers.append(row) providers.append(row)
@ -241,7 +413,11 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
) )
exec_config = config.tools.exec 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": { "agent": {
"model": effective_preset.model, "model": effective_preset.model,
"provider": selected_provider, "provider": selected_provider,
@ -312,6 +488,11 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
}, },
"advanced": { "advanced": {
"restrict_to_workspace": config.tools.restrict_to_workspace, "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), "ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
"mcp_server_count": len(config.tools.mcp_servers), "mcp_server_count": len(config.tools.mcp_servers),
"exec_enabled": exec_config.enable, "exec_enabled": exec_config.enable,
@ -320,6 +501,13 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
}, },
"requires_restart": requires_restart, "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]: 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() 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]: def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip() provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name: if not provider_name:
@ -495,6 +731,114 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
return settings_payload(requires_restart=restart_required) 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]: def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip().lower() provider_name = (_query_first(query, "provider") or "").strip().lower()
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name) 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": [], "pinned_keys": [],
"archived_keys": [], "archived_keys": [],
"title_overrides": {}, "title_overrides": {},
"project_name_overrides": {},
"tags_by_key": {}, "tags_by_key": {},
"collapsed_groups": {}, "collapsed_groups": {},
"view": { "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["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
state["archived_keys"] = _clean_string_list(raw.get("archived_keys")) state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides")) 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["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups")) state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
state["view"] = _clean_view(raw.get("view")) state["view"] = _clean_view(raw.get("view"))
@ -190,4 +194,3 @@ def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
finally: finally:
os.close(dir_fd) os.close(dir_fd)
return state return state

View File

@ -28,6 +28,11 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
".webp", ".webp",
".gif", ".gif",
}) })
_FILE_EDIT_TOOL_NAMES: frozenset[str] = frozenset({
"write_file",
"edit_file",
"apply_patch",
})
def rewrite_local_markdown_images( 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) 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]]: def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not isinstance(previous, list) or not previous: if not isinstance(previous, list) or not previous:
return incoming return incoming
@ -222,6 +240,87 @@ def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[di
return merged 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( def _merge_unique_tool_trace_lines(
previous_traces: list[str], previous_traces: list[str],
lines: list[str], lines: list[str],
@ -343,6 +442,40 @@ def replay_transcript_to_ui_messages(
return None return None
return str(last.get("id")) 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: def close_reasoning(prev: list[dict[str, Any]]) -> None:
for i in range(len(prev) - 1, -1, -1): for i in range(len(prev) - 1, -1, -1):
if prev[i].get("reasoningStreaming"): if prev[i].get("reasoningStreaming"):
@ -404,13 +537,6 @@ def replay_transcript_to_ui_messages(
active_activity_segment_id = None active_activity_segment_id = None
active_file_edit_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( def find_file_edit_trace_index(
segment: str | None, segment: str | None,
edits: list[dict[str, Any]], edits: list[dict[str, Any]],
@ -420,16 +546,23 @@ def replay_transcript_to_ui_messages(
candidate = messages[i] candidate = messages[i]
if candidate.get("role") == "user": if candidate.get("role") == "user":
break break
if candidate.get("kind") != "trace" or not candidate.get("fileEdits"): if candidate.get("kind") != "trace":
continue continue
if segment and candidate.get("activitySegmentId") == segment: if segment and candidate.get("activitySegmentId") == segment:
return i return i
existing_edits = candidate.get("fileEdits") existing_edits = candidate.get("fileEdits")
if not isinstance(existing_edits, list): if isinstance(existing_edits, list):
continue for existing in existing_edits:
for existing in existing_edits: if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys: return i
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 return None
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> 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: if not edits:
return return
segment = active_file_edit_segment_id 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) target_index = find_file_edit_trace_index(segment, edits)
if target_index is not None: if target_index is not None:
last = messages[target_index] last = messages[target_index]
segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False)) segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
active_file_edit_segment_id = segment active_file_edit_segment_id = segment
last = _strip_covered_file_edit_tool_hints(last, edits)
else: else:
if not segment: if not segment:
segment = _new_activity_segment(activate=False) segment = _new_activity_segment(activate=False)
@ -620,12 +758,21 @@ def replay_transcript_to_ui_messages(
continue continue
if kind in ("tool_hint", "progress"): if kind in ("tool_hint", "progress"):
structured_events = _normalize_tool_events(rec.get("tool_events")) 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") 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: if not trace_lines:
continue continue
segment = _ensure_activity_segment() segment = _ensure_activity_segment()
demote_interrupted_assistant(segment)
last = messages[-1] if messages else None last = messages[-1] if messages else None
if ( if (
last last
@ -636,7 +783,7 @@ def replay_transcript_to_ui_messages(
prev_traces = list(last.get("traces") or [last.get("content")]) prev_traces = list(last.get("traces") or [last.get("content")])
if structured: if structured:
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, 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 continue
else: else:
merged_traces = prev_traces + trace_lines merged_traces = prev_traces + trace_lines
@ -644,8 +791,8 @@ def replay_transcript_to_ui_messages(
**last, **last,
"traces": merged_traces, "traces": merged_traces,
"content": merged_traces[-1], "content": merged_traces[-1],
"toolEvents": _merge_tool_events(last.get("toolEvents"), structured_events) "toolEvents": _merge_tool_events(last.get("toolEvents"), visible_structured_events)
if structured_events if visible_structured_events
else last.get("toolEvents"), else last.get("toolEvents"),
"activitySegmentId": last.get("activitySegmentId") or segment, "activitySegmentId": last.get("activitySegmentId") or segment,
} }
@ -658,7 +805,7 @@ def replay_transcript_to_ui_messages(
"kind": "trace", "kind": "trace",
"content": trace_lines[-1], "content": trace_lines[-1],
"traces": trace_lines, "traces": trace_lines,
**({"toolEvents": structured_events} if structured_events else {}), **({"toolEvents": visible_structured_events} if visible_structured_events else {}),
"activitySegmentId": segment, "activitySegmentId": segment,
"createdAt": _ts_base + idx, "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.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig 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 from nanobot.webui.settings_api import settings_payload, update_provider_settings
# -- Shared helpers (aligned with test_websocket_integration.py) --------------- # -- Shared helpers (aligned with test_websocket_integration.py) ---------------
@ -57,6 +59,14 @@ def bus() -> MagicMock:
return b 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: 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.""" """Run GET in a thread to avoid blocking the asyncio loop shared with websockets."""
return await asyncio.to_thread( 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: def test_normalize_http_path_strips_trailing_slash_except_root() -> None:
assert _normalize_http_path("/chat/") == "/chat" assert _normalize_http_path("/chat/") == "/chat"
assert _normalize_http_path("/chat?x=1") == "/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("/") == "/" 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: def test_parse_query_extracts_token_and_client_id() -> None:
query = _parse_query("/?token=secret&client_id=u1") query = _parse_query("/?token=secret&client_id=u1")
assert query.get("token") == ["secret"] 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 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 @pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None: async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock() 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" config.tools.web.search.api_key = "brave-secret"
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
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 = _ch(bus, port=port)
channel._api_tokens["tok"] = time.monotonic() + 300 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"]["configured"] is False
assert providers["atomic_chat"]["api_key_required"] is False assert providers["atomic_chat"]["api_key_required"] is False
assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1" 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["agent"]["has_api_key"] is True
assert body["web_search"]["provider"] == "brave" assert body["web_search"]["provider"] == "brave"
assert body["web_search"]["api_key_hint"] == "brav••••cret" assert body["web_search"]["api_key_hint"] == "brav••••cret"
@ -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"]["label"] == "OpenRouter"
assert image_providers["openrouter"]["configured"] is False 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 image_providers["gemini"]["label"] == "Gemini"
assert body["runtime"]["config_path"] == str(config_path) assert body["runtime"]["config_path"] == str(config_path)
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/") workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
assert workspace_path.endswith("/.nanobot/workspace") assert workspace_path.endswith("/.nanobot/workspace")
assert body["runtime"]["gateway_port"] == 18790 assert body["runtime"]["gateway_port"] == 18790
assert body["advanced"]["exec_enabled"] is True 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["advanced"]["mcp_server_count"] == 0
assert body["restart_required_sections"] == [] assert body["restart_required_sections"] == []
assert "secret-key" not in settings.text assert "secret-key" not in settings.text
assert "brave-secret" not in settings.text assert "brave-secret" not in settings.text
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( provider_updated = await _http_get(
"http://127.0.0.1:" "http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=openrouter" 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"]["label"] == "Fast writing"
assert created_presets["fast-writing"]["provider"] == "openai" 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( duplicate_preset = await _http_get(
"http://127.0.0.1:" "http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create" 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 assert search_updated.status_code == 200
search_body = search_updated.json() search_body = search_updated.json()
assert search_body["requires_restart"] is True 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"]["provider"] == "searxng"
assert search_body["web_search"]["api_key_hint"] is None assert search_body["web_search"]["api_key_hint"] is None
assert search_body["web_search"]["base_url"] == "https://search.example.com" assert search_body["web_search"]["base_url"] == "https://search.example.com"
assert search_body["web_search"]["max_results"] == 8 assert search_body["web_search"]["max_results"] == 8
assert search_body["web"]["fetch"]["use_jina_reader"] is False 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( image_updated = await _http_get(
"http://127.0.0.1:" "http://127.0.0.1:"
f"{port}/api/settings/image-generation/update?enabled=true" 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 assert image_updated.status_code == 200
image_body = image_updated.json() image_body = image_updated.json()
assert image_body["requires_restart"] is True 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"]["enabled"] is True
assert image_body["image_generation"]["model"] == "openai/gpt-image-1" assert image_body["image_generation"]["model"] == "openai/gpt-image-1"
assert image_body["image_generation"]["default_aspect_ratio"] == "16:9" assert image_body["image_generation"]["default_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.status_code == 200
assert image_provider_updated.json()["requires_restart"] is True assert image_provider_updated.json()["requires_restart"] is True
assert image_provider_updated.json()["restart_required_sections"] == [ assert image_provider_updated.json()["restart_required_sections"] == [
"browser",
"image", "image",
"runtime", "runtime",
"web",
] ]
assert "sk-or-next" not in image_provider_updated.text 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.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat" assert saved.agents.defaults.provider == "atomic_chat"
assert saved.agents.defaults.model_preset == "fast-writing" assert saved.agents.defaults.model_preset == "fast-writing"
assert saved.model_presets["fast-writing"].label == "Fast writing" assert saved.model_presets["fast-writing"].label == "Codex"
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini" assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
assert saved.model_presets["fast-writing"].provider == "openai" assert saved.model_presets["fast-writing"].provider == "openai"
assert saved.agents.defaults.timezone == "Asia/Shanghai" assert saved.agents.defaults.timezone == "Asia/Shanghai"
assert saved.agents.defaults.bot_name == "Nano" 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.max_results == 8
assert saved.tools.web.search.timeout == 45 assert saved.tools.web.search.timeout == 45
assert saved.tools.web.fetch.use_jina_reader is False 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.enabled is True
assert saved.tools.image_generation.provider == "openrouter" assert saved.tools.image_generation.provider == "openrouter"
assert saved.tools.image_generation.model == "openai/gpt-image-1" 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 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( def test_settings_payload_normalizes_camel_case_provider(
bus: MagicMock, bus: MagicMock,
monkeypatch, 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"] 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: def test_update_provider_settings_ignores_api_type_for_non_openai(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
save_config(Config(), config_path) 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") OutboundMessage(channel="websocket", chat_id=new_chat, content="ok")
) )
reply = json.loads(await client.recv()) reply = json.loads(await client.recv())
if reply["event"] == "session_updated":
reply = json.loads(await client.recv())
assert reply["event"] == "message" assert reply["event"] == "message"
assert reply["chat_id"] == new_chat assert reply["chat_id"] == new_chat
assert reply["text"] == "ok" assert reply["text"] == "ok"
@ -1691,16 +2136,16 @@ async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
await client.recv() # ready await client.recv() # ready
await client.send(json.dumps({"type": "new_chat"})) 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"})) 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 assert chat_a != chat_b
# Push A → client sees A only (FIFO over the single WS). # Push A → client sees A only (FIFO over the single WS).
await channel.send( await channel.send(
OutboundMessage(channel="websocket", chat_id=chat_a, content="for-A") 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["chat_id"] == chat_a
assert msg_a["text"] == "for-A" assert msg_a["text"] == "for-A"
@ -1708,7 +2153,7 @@ async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
await channel.send( await channel.send(
OutboundMessage(channel="websocket", chat_id=chat_b, content="for-B") 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["chat_id"] == chat_b
assert msg_b["text"] == "for-B" assert msg_b["text"] == "for-B"
finally: finally:
@ -1830,6 +2275,9 @@ def test_sessions_list_includes_active_run_started_at() -> None:
assert resp.status_code == 200 assert resp.status_code == 200
body = json.loads(resp.body.decode()) 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"] == [ assert body["sessions"] == [
{ {
"key": "websocket:chat-1", "key": "websocket:chat-1",

View File

@ -95,6 +95,7 @@ async def test_bootstrap_returns_token_for_localhost(
body = resp.json() body = resp.json()
assert body["token"].startswith("nbwt_") assert body["token"].startswith("nbwt_")
assert body["ws_path"] == "/" assert body["ws_path"] == "/"
assert body["ws_url"] == "ws://127.0.0.1:29901/"
assert body["expires_in"] > 0 assert body["expires_in"] > 0
assert isinstance(body.get("model_name"), str) assert isinstance(body.get("model_name"), str)
finally: finally:
@ -734,6 +735,17 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
assert body["token"].startswith("nbwt_") 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: def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1") channel = _ch(bus, host="127.0.0.1")
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS) 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 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( def test_gateway_health_endpoint_binds_and_serves_expected_responses(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> 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"]["install_supported"] is True
assert apps["gimp"]["source"] == "harness+public" assert apps["gimp"]["source"] == "harness+public"
assert apps["gimp"]["description"] == "Public duplicate entry" 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["clibrowser"]["install_supported"] is False
assert apps["jimeng"]["install_supported"] is False assert apps["jimeng"]["install_supported"] is False
assert apps["suno"]["install_supported"] is True 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"])): with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
ok, _ = validate_url_target("http://ts.local/api") ok, _ = validate_url_target("http://ts.local/api")
assert not ok 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 ( from nanobot.providers.openai_codex_provider import (
OpenAICodexProvider, OpenAICodexProvider,
_codex_error_response, _codex_error_response,
_build_reasoning_options,
_CodexHTTPError, _CodexHTTPError,
_friendly_error, _friendly_error,
_request_codex, _request_codex,
@ -128,11 +129,12 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
body, body,
verify, verify,
on_content_delta=None, on_content_delta=None,
on_thinking_delta=None,
on_tool_call_delta=None, on_tool_call_delta=None,
): ):
_ = on_tool_call_delta _ = on_thinking_delta, on_tool_call_delta
bodies.append(body) bodies.append(body)
return "ok", [], "stop" return "ok", [], "stop", None
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request) 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 calls += 1
if calls == 1: if calls == 1:
raise httpx.ReadTimeout("") raise httpx.ReadTimeout("")
return "ok", [], "stop" return "ok", [], "stop", None
async def fake_sleep(delay: float) -> None: async def fake_sleep(delay: float) -> None:
delays.append(delay) 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) 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 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.""" """Tests for the shared openai_responses converters and parsers."""
import json
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses.converters import ( from nanobot.providers.openai_responses.converters import (
convert_messages, convert_messages,
convert_tools, convert_tools,
@ -13,6 +13,8 @@ from nanobot.providers.openai_responses.converters import (
) )
from nanobot.providers.openai_responses.parsing import ( from nanobot.providers.openai_responses.parsing import (
consume_sdk_stream, consume_sdk_stream,
consume_sse,
consume_sse_with_reasoning,
map_finish_reason, map_finish_reason,
parse_response_output, parse_response_output,
) )
@ -434,6 +436,166 @@ class TestParseResponseOutput:
assert result.usage["total_tokens"] == 150 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 # parsing - consume_sdk_stream
# ====================================================================== # ======================================================================
@ -544,6 +706,46 @@ class TestConsumeSdkStream:
"arguments_delta": '{"path":"a.txt","content":"', "arguments_delta": '{"path":"a.txt","content":"',
}, },
{"call_id": "c1", "name": "write_file", "arguments_delta": "hello\\n"}, {"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 @pytest.mark.asyncio

View File

@ -49,7 +49,7 @@ def test_rejects_missing_domain():
]) ])
def test_blocks_private_ipv4(ip: str, label: str): def test_blocks_private_ipv4(ip: str, label: str):
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("evil.com", [ip])): 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 not ok, f"Should block {label} ({ip})"
assert "private" in err.lower() or "blocked" in err.lower() 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") 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(): def test_allows_normal_curl():
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["93.184.216.34"])): 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") 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, session_key: str,
origin_message_id: str | None = None, origin_message_id: str | None = None,
temperature: float | None = None, temperature: float | None = None,
workspace_scope=None,
) -> str: ) -> str:
seen.append((origin_channel, origin_chat_id, session_key)) seen.append((origin_channel, origin_chat_id, session_key))
return f"{origin_channel}:{origin_chat_id}:{task}" 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, session_key,
origin_message_id=None, origin_message_id=None,
temperature=None, temperature=None,
workspace_scope=None,
): ):
seen.append((origin_channel, origin_chat_id, session_key)) seen.append((origin_channel, origin_chat_id, session_key))
return f"ok: {task}" return f"ok: {task}"
@ -211,6 +213,7 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
session_key, session_key,
origin_message_id=None, origin_message_id=None,
temperature=None, temperature=None,
workspace_scope=None,
): ):
seen.append((origin_channel, origin_chat_id, session_key)) seen.append((origin_channel, origin_chat_id, session_key))
return "ok" 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 = tmp_path / "utils.py"
target.write_text("def unused():\n pass\ndef used():\n return 1\n") target.write_text("def unused():\n pass\ndef used():\n return 1\n")
tool = ApplyPatchTool(workspace=tmp_path) tool = ApplyPatchTool(workspace=tmp_path)
@ -106,51 +106,8 @@ def test_apply_patch_edits_delete(tmp_path):
) )
) )
assert "update utils.py" in result assert "unknown action: delete" in result
assert target.read_text() == "def used():\n return 1\n" assert target.read_text() == "def unused():\n pass\ndef 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"
def test_apply_patch_edits_batch_multiple_files(tmp_path): 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", "path": "missing.txt",
"action": "delete", "action": "replace",
"old_text": "remove me", "old_text": "remove me",
"new_text": "removed",
}, },
] ]
) )

View File

@ -9,6 +9,7 @@ from unittest.mock import patch
import pytest import pytest
from nanobot.agent.tools.shell import ExecTool 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): 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 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 @pytest.mark.asyncio
async def test_exec_allows_normal_commands(): async def test_exec_allows_normal_commands():
tool = ExecTool(timeout=5) tool = ExecTool(timeout=5)

View File

@ -5,8 +5,6 @@ from dataclasses import fields
from typing import Any from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
@ -115,6 +113,31 @@ def test_discover_skips_private_classes():
assert not cls.__name__.startswith("_") 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() --- # --- Task 4: _FsTool.create() ---
from pathlib import Path 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 import web as web_module
from nanobot.agent.tools.web import WebFetchTool from nanobot.agent.tools.web import WebFetchTool
from nanobot.config.schema import WebFetchConfig 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 _REAL_GETADDRINFO = socket.getaddrinfo
@ -45,6 +46,24 @@ async def test_web_fetch_blocks_localhost():
assert "error" in data 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 @pytest.mark.asyncio
async def test_web_fetch_result_contains_untrusted_flag(): async def test_web_fetch_result_contains_untrusted_flag():
"""When fetch succeeds, result JSON must include untrusted=True and the banner.""" """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() (tmp_path / "src").mkdir()
existing = tmp_path / "src" / "existing.py" existing = tmp_path / "src" / "existing.py"
existing.write_text("old\nkeep\n", encoding="utf-8") 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 = [ edits = [
{"path": "src/new.py", "action": "add", "new_text": "fresh"}, {"path": "src/new.py", "action": "add", "new_text": "fresh"},
{"path": "src/existing.py", "action": "replace", "old_text": "old", "new_text": "new"}, {"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( 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] == [ assert [tracker.display_path for tracker in trackers] == [
"src/new.py", "src/new.py",
"src/existing.py", "src/existing.py",
"src/delete_me.py",
] ]
(tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8") (tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
existing.write_text("new\nkeep\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] events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers]
by_path = {event["path"]: event for event in events} 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/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/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: 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], "pinned_keys": ["websocket:a", "websocket:a", "", 123],
"archived_keys": ["websocket:b"], "archived_keys": ["websocket:b"],
"title_overrides": {"websocket:a": " Release notes ", "bad": ""}, "title_overrides": {"websocket:a": " Release notes ", "bad": ""},
"project_name_overrides": {"/repo": " Core ", "bad": ""},
"tags_by_key": {"websocket:a": ["work", "work", ""]}, "tags_by_key": {"websocket:a": ["work", "work", ""]},
"collapsed_groups": {"Earlier": 1}, "collapsed_groups": {"Earlier": 1},
"view": {"density": "tiny", "show_archived": True, "sort": "nope"}, "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["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"] assert state["archived_keys"] == ["websocket:b"]
assert state["title_overrides"] == {"websocket:a": "Release notes"} 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["tags_by_key"] == {"websocket:a": ["work"]}
assert state["collapsed_groups"] == {"Earlier": True} assert state["collapsed_groups"] == {"Earlier": True}
assert state["view"] == { 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"], "pinned_keys": ["websocket:a"],
"archived_keys": ["websocket:b"], "archived_keys": ["websocket:b"],
"title_overrides": {"websocket:a": "Release"}, "title_overrides": {"websocket:a": "Release"},
"project_name_overrides": {"/repo": "Core"},
"view": {"density": "compact", "show_previews": True}, "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["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"] assert state["archived_keys"] == ["websocket:b"]
assert state["title_overrides"] == {"websocket:a": "Release"} assert state["title_overrides"] == {"websocket:a": "Release"}
assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["view"]["density"] == "compact" assert state["view"]["density"] == "compact"
assert state["view"]["show_previews"] is True assert state["view"]["show_previews"] is True
assert webui_sidebar_state_path().is_file() 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"] 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: def test_replay_tool_events_dedupes_finish_after_start() -> None:
msgs = replay_transcript_to_ui_messages([ 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 from __future__ import annotations
import json
import pytest import pytest
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.webui.settings_api import WebUISettingsError, create_model_configuration 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( 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"], "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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Menu, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm"; import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog"; import { RenameChatDialog } from "@/components/RenameChatDialog";
@ -23,9 +24,21 @@ import {
import { deriveTitle } from "@/lib/format"; import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client"; import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 = type BootState =
| { status: "loading" } | { status: "loading" }
@ -37,6 +50,7 @@ type BootState =
token: string; token: string;
tokenExpiresAt: number; tokenExpiresAt: number;
modelName: string | null; modelName: string | null;
runtimeSurface: RuntimeSurface;
}; };
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; 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() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" }); const [state, setState] = useState<BootState>({ status: "loading" });
@ -163,13 +238,20 @@ export default function App() {
const boot = await fetchBootstrap("", secret); const boot = await fetchBootstrap("", secret);
if (cancelled) return; if (cancelled) return;
if (secret) saveSecret(secret); 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({ const client = new NanobotClient({
url, url,
socketFactory: runtimeHost.socketFactory,
onReauth: async () => { onReauth: async () => {
try { try {
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current); 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); const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
setState((current) => setState((current) =>
current.status === "ready" && current.client === client current.status === "ready" && current.client === client
@ -178,6 +260,10 @@ export default function App() {
token: refreshed.token, token: refreshed.token,
tokenExpiresAt, tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName, modelName: refreshed.model_name ?? current.modelName,
runtimeSurface:
refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: current.runtimeSurface,
} }
: current, : current,
); );
@ -195,6 +281,7 @@ export default function App() {
token: boot.token, token: boot.token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in), tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null, modelName: boot.model_name ?? null,
runtimeSurface,
}); });
} catch (e) { } catch (e) {
if (cancelled) return; if (cancelled) return;
@ -219,7 +306,7 @@ export default function App() {
const timer = window.setTimeout(async () => { const timer = window.setTimeout(async () => {
try { try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current); 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); const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
client.updateUrl(url); client.updateUrl(url);
setState((current) => setState((current) =>
@ -229,6 +316,9 @@ export default function App() {
token: boot.token, token: boot.token,
tokenExpiresAt, tokenExpiresAt,
modelName: boot.model_name ?? current.modelName, modelName: boot.model_name ?? current.modelName,
runtimeSurface: boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: current.runtimeSurface,
} }
: current, : current,
); );
@ -304,20 +394,26 @@ export default function App() {
token={state.token} token={state.token}
modelName={state.modelName} modelName={state.modelName}
> >
<Shell onModelNameChange={handleModelNameChange} onLogout={handleLogout} /> <Shell
runtimeSurface={state.runtimeSurface}
onModelNameChange={handleModelNameChange}
onLogout={handleLogout}
/>
</ClientProvider> </ClientProvider>
); );
} }
function Shell({ function Shell({
runtimeSurface,
onModelNameChange, onModelNameChange,
onLogout, onLogout,
}: { }: {
runtimeSurface: RuntimeSurface;
onModelNameChange: (modelName: string | null) => void; onModelNameChange: (modelName: string | null) => void;
onLogout: () => void; onLogout: () => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { client } = useClient(); const { client, token } = useClient();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const { state: sidebarState, update: updateSidebarState } = const { state: sidebarState, update: updateSidebarState } =
@ -325,7 +421,7 @@ function Shell({
const [activeKey, setActiveKey] = useState<string | null>(null); const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat"); const [view, setView] = useState<ShellView>("chat");
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview"); const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview");
const [desktopSidebarOpen, setDesktopSidebarOpen] = const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen); useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
@ -337,23 +433,48 @@ function Shell({
key: string; key: string;
label: string; label: string;
} | null>(null); } | null>(null);
const [pendingProjectRename, setPendingProjectRename] = useState<{
key: string;
label: string;
} | null>(null);
const restartSawDisconnectRef = useRef(false); const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null); const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set()); const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds); 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()); 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(() => { useEffect(() => {
try { try {
window.localStorage.setItem( window.localStorage.setItem(
SIDEBAR_STORAGE_KEY, SIDEBAR_STORAGE_KEY,
desktopSidebarOpen ? "1" : "0", hostSidebarOpen ? "1" : "0",
); );
} catch { } catch {
// ignore storage errors (private mode, etc.) // ignore storage errors (private mode, etc.)
} }
}, [desktopSidebarOpen]); }, [hostSidebarOpen]);
useEffect(() => { useEffect(() => {
writeCompletedRunChatIds(completedChatIds); writeCompletedRunChatIds(completedChatIds);
@ -365,6 +486,36 @@ function Shell({
}, [sessions, activeKey]); }, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]); 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(() => { useEffect(() => {
if (loading) return; if (loading) return;
@ -375,8 +526,34 @@ function Shell({
); );
return next.size === current.size ? current : next; 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]); }, [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(() => { useEffect(() => {
if (loading) return; if (loading) return;
const activeRunIds = sessions const activeRunIds = sessions
@ -408,12 +585,12 @@ function Shell({
}); });
}, [client, loading, sessions]); }, [client, loading, sessions]);
const closeDesktopSidebar = useCallback(() => { const closeHostSidebar = useCallback(() => {
setDesktopSidebarOpen(false); setHostSidebarOpen(false);
}, []); }, []);
const openDesktopSidebar = useCallback(() => { const openHostSidebar = useCallback(() => {
setDesktopSidebarOpen(true); setHostSidebarOpen(true);
}, []); }, []);
const closeMobileSidebar = useCallback(() => { const closeMobileSidebar = useCallback(() => {
@ -421,38 +598,88 @@ function Shell({
}, []); }, []);
const toggleSidebar = useCallback(() => { const toggleSidebar = useCallback(() => {
const isDesktop = const isNativeHost =
typeof window !== "undefined" && typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches; window.matchMedia("(min-width: 1024px)").matches;
if (isDesktop) { if (isNativeHost) {
setDesktopSidebarOpen((v) => !v); setHostSidebarOpen((v) => !v);
} else { } else {
setMobileSidebarOpen((v) => !v); 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 { try {
const chatId = await createChat(); const scope = workspaceScope ?? activeWorkspaceScope;
const chatId = await createChat(scope);
setActiveKey(`websocket:${chatId}`); setActiveKey(`websocket:${chatId}`);
setView("chat"); setView("chat");
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
if (scope) {
setWorkspaceOverrides((current) => ({
...current,
[chatId]: normalizeWorkspaceScope(scope),
}));
}
return chatId; return chatId;
} catch (e) { } catch (e) {
console.error("Failed to create chat", 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; return null;
} }
}, [createChat]); }, [activeWorkspaceScope, createChat, t]);
const onNewChat = useCallback(() => { const onNewChat = useCallback(() => {
setActiveKey(null); setActiveKey(null);
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setView("chat"); setView("chat");
setMobileSidebarOpen(false); 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( const onSelectChat = useCallback(
(key: string) => { (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) { if (selectedChatId) {
setCompletedChatIds((current) => { setCompletedChatIds((current) => {
if (!current.has(selectedChatId)) return current; if (!current.has(selectedChatId)) return current;
@ -461,6 +688,12 @@ function Shell({
return next; return next;
}); });
} }
if (selected?.workspaceScope) {
setDraftWorkspaceScope(normalizeWorkspaceScope(selected.workspaceScope));
} else {
setDraftWorkspaceScope(null);
}
setWorkspaceError(null);
setActiveKey(key); setActiveKey(key);
setView("chat"); setView("chat");
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
@ -512,6 +745,61 @@ function Shell({
[pendingRename, updateSidebarState], [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( const onToggleArchive = useCallback(
(key: string) => { (key: string) => {
void updateSidebarState((current) => { void updateSidebarState((current) => {
@ -547,19 +835,6 @@ function Shell({
})); }));
}, [updateSidebarState]); }, [updateSidebarState]);
const onUpdateSidebarView = useCallback(
(viewUpdate: Partial<typeof sidebarState.view>) => {
void updateSidebarState((current) => ({
...current,
view: {
...current.view,
...viewUpdate,
},
}));
},
[updateSidebarState],
);
const onOpenSessionSearch = useCallback(() => { const onOpenSessionSearch = useCallback(() => {
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
setSessionSearchOpen(true); setSessionSearchOpen(true);
@ -742,117 +1017,165 @@ function Shell({
onTogglePin, onTogglePin,
onRequestRename, onRequestRename,
onToggleArchive, onToggleArchive,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
onOpenSettings, onOpenSettings,
onOpenApps, onOpenApps,
onOpenSearch: onOpenSessionSearch, onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" ? "apps" as const : null, activeUtility: view === "apps" ? "apps" as const : null,
onToggleArchived, onToggleArchived,
onUpdateView: onUpdateSidebarView,
pinnedKeys: sidebarState.pinned_keys, pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys, archivedKeys: sidebarState.archived_keys,
titleOverrides: sidebarState.title_overrides, titleOverrides: sidebarState.title_overrides,
projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList, runningChatIds: runningChatIdList,
completedChatIds: completedChatIdList, completedChatIds: completedChatIdList,
viewState: sidebarState.view, viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived, showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length, 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"; const showMainSidebar = view !== "settings";
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<div className="relative flex h-full w-full overflow-hidden"> <div
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */} className={cn(
{showMainSidebar ? ( "relative h-full w-full overflow-hidden",
<aside 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( className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block", "relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
"transition-[width] duration-300 ease-out", 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 <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 <ThreadShell
{...sidebarProps} session={activeSession}
collapsed={!desktopSidebarOpen} title={headerTitle}
onCollapse={closeDesktopSidebar} onToggleSidebar={toggleSidebar}
onExpand={openDesktopSidebar} onNewChat={onNewChat}
/> onCreateChat={onCreateChat}
</div> onTurnEnd={onTurnEnd}
</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
theme={theme} theme={theme}
initialSection={settingsInitialSection}
showSidebar={view === "settings"}
onToggleTheme={toggle} onToggleTheme={toggle}
onBackToChat={onBackToChat} hideSidebarToggleForHostChrome
onModelNameChange={onModelNameChange} hideHeader={false}
onLogout={onLogout} workspaceScope={activeWorkspaceScope}
onRestart={onRestart} workspaceDefaultScope={workspaces?.default_scope ?? null}
isRestarting={isRestarting} workspaceControls={workspaces?.controls ?? null}
workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
/> />
</div> </div>
)} {view !== "chat" && (
</main> <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 <DeleteConfirm
open={!!pendingDelete} open={!!pendingDelete}
@ -866,6 +1189,15 @@ function Shell({
onCancel={() => setPendingRename(null)} onCancel={() => setPendingRename(null)}
onConfirm={onConfirmRename} 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 ? ( {restartToast ? (
<div <div
role="status" role="status"

View File

@ -7,10 +7,12 @@ import {
import { import {
Archive, Archive,
ArchiveRestore, ArchiveRestore,
Folder,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
Pin, Pin,
PinOff, PinOff,
Plus,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -22,6 +24,17 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { deriveTitle, relativeTime } from "@/lib/format"; 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 { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types"; import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
@ -36,9 +49,14 @@ interface ChatListProps {
onTogglePin: (key: string) => void; onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void; onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void; onToggleArchive: (key: string) => void;
onToggleGroup?: (groupId: string) => void;
onRequestRenameProject?: (projectKey: string, label: string) => void;
onNewChatInProject?: (projectPath: string, projectName: string) => void;
pinnedKeys?: string[]; pinnedKeys?: string[];
archivedKeys?: string[]; archivedKeys?: string[];
titleOverrides?: Record<string, string>; titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[]; runningChatIds?: string[];
completedChatIds?: string[]; completedChatIds?: string[];
density?: SidebarDensity; density?: SidebarDensity;
@ -46,6 +64,7 @@ interface ChatListProps {
showTimestamps?: boolean; showTimestamps?: boolean;
sort?: SidebarSortMode; sort?: SidebarSortMode;
showArchived?: boolean; showArchived?: boolean;
defaultWorkspacePath?: string | null;
actionMenuPortalContainer?: HTMLElement | null; actionMenuPortalContainer?: HTMLElement | null;
loading?: boolean; loading?: boolean;
emptyLabel?: string; emptyLabel?: string;
@ -59,9 +78,14 @@ export const ChatList = memo(function ChatList({
onTogglePin, onTogglePin,
onRequestRename, onRequestRename,
onToggleArchive, onToggleArchive,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
pinnedKeys = [], pinnedKeys = [],
archivedKeys = [], archivedKeys = [],
titleOverrides = {}, titleOverrides = {},
projectNameOverrides = {},
collapsedGroups = {},
runningChatIds = [], runningChatIds = [],
completedChatIds = [], completedChatIds = [],
density = "comfortable", density = "comfortable",
@ -69,19 +93,21 @@ export const ChatList = memo(function ChatList({
showTimestamps = false, showTimestamps = false,
sort = "updated_desc", sort = "updated_desc",
showArchived = false, showArchived = false,
defaultWorkspacePath,
actionMenuPortalContainer, actionMenuPortalContainer,
loading, loading,
emptyLabel, emptyLabel,
}: ChatListProps) { }: ChatListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const labels = useMemo(() => ({ const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"), pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"), all: t("chat.groups.all"),
today: t("chat.groups.today"), today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"), yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"), earlier: t("chat.groups.earlier"),
archived: t("chat.groups.archived"), archived: t("chat.groups.archived"),
projects: t("chat.groups.projects"),
fallbackTitle: t("chat.newChat"), fallbackTitle: t("chat.newChat"),
}), [t]); }), [t]);
const groups = useMemo( const groups = useMemo(
@ -89,8 +115,10 @@ export const ChatList = memo(function ChatList({
pinnedKeys, pinnedKeys,
archivedKeys, archivedKeys,
titleOverrides, titleOverrides,
projectNameOverrides,
showArchived, showArchived,
sort, sort,
defaultWorkspacePath,
}), }),
[ [
archivedKeys, archivedKeys,
@ -100,15 +128,21 @@ export const ChatList = memo(function ChatList({
showArchived, showArchived,
sort, sort,
titleOverrides, titleOverrides,
projectNameOverrides,
defaultWorkspacePath,
], ],
); );
const limitedGroups = useMemo( const limitedGroups = useMemo(
() => limitGroups(groups, visibleLimit, activeKey), () => limitGroups(groups, visibleLimit, activeKey, collapsedGroups),
[activeKey, groups, visibleLimit], [activeKey, collapsedGroups, groups, visibleLimit],
); );
const totalSessionCount = useMemo( const totalSessionCount = useMemo(
() => groups.reduce((total, group) => total + group.sessions.length, 0), () => groups.reduce(
[groups], (total, group) =>
total + (isCollapsedProject(group, collapsedGroups) ? 0 : group.sessions.length),
0,
),
[collapsedGroups, groups],
); );
const visibleSessionCount = useMemo( const visibleSessionCount = useMemo(
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0), () => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
@ -143,131 +177,194 @@ export const ChatList = memo(function ChatList({
const compact = density === "compact"; const compact = density === "compact";
return ( return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain"> <div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<div className="min-w-0 space-y-3 px-2 py-1.5"> <div className="min-w-0 space-y-3 px-2 py-1.5">
{limitedGroups.map((group) => ( {limitedGroups.map((group, index) => {
<section key={group.label} aria-label={group.label}> const foldableChatsGroup = isFoldableChatsGroup(group);
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65"> const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
{group.label} const visibleSessions = visibleSessionsForGroup(
</div> group,
<ul className="space-y-0.5"> activeKey,
{group.sessions.map((s) => { collapsedGroups,
const active = s.key === activeKey; );
const fallbackTitle = t("chat.fallbackTitle", { const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
id: s.chatId.slice(0, 6), const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
});
const generatedTitle = s.title?.trim() || ""; return (
const title = displayTitle(s, titleOverrides, t("chat.newChat")); <section key={group.id} aria-label={group.label}>
const tooltipTitle = {group.kind === "project"
titleOverrides[s.key]?.trim() || && limitedGroups[index - 1]?.kind !== "project" ? (
generatedTitle || <div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
deriveTitle(s.preview, fallbackTitle); {labels.projects}
const isPinned = pinned.has(s.key); </div>
const isArchived = archived.has(s.key); ) : null}
const preview = s.preview.trim(); {group.kind === "project" ? (
const showPreview = showPreviews && preview && preview !== title; <ProjectGroupHeader
const timestamp = showTimestamps label={group.label}
? relativeTime(s.updatedAt ?? s.createdAt) path={group.projectPath}
: ""; collapsed={Boolean(collapsedGroups[group.id])}
const activityState = running.has(s.chatId) onToggle={() => onToggleGroup?.(group.id)}
? "running" onRequestRename={
: completed.has(s.chatId) group.projectKey && onRequestRenameProject
? "complete" ? () => onRequestRenameProject(group.projectKey ?? "", group.label)
: null; : undefined
return ( }
<li key={s.key} className="min-w-0"> onNewChat={
<div group.projectPath && onNewChatInProject
className={cn( ? () => onNewChatInProject(group.projectPath ?? "", group.label)
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors", : undefined
compact ? "min-h-7" : "min-h-8", }
active actionMenuPortalContainer={actionMenuPortalContainer}
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]" updatedAt={showTimestamps ? group.updatedAt : null}
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground", />
)} ) : (
> <ChatsGroupHeader label={group.label} />
<button )}
type="button" {group.kind === "project" && collapsedGroups[group.id] ? null : (
onClick={() => onSelect(s.key)} <ul className="space-y-0.5">
title={tooltipTitle} {visibleSessions.map((s) => {
className={cn( const active = s.key === activeKey;
"min-w-0 flex-1 overflow-hidden text-left", const fallbackTitle = t("chat.fallbackTitle", {
compact ? "py-1" : "py-1.5", id: s.chatId.slice(0, 6),
)} });
> const generatedTitle = s.title?.trim() || "";
<span className="block w-full truncate font-medium leading-5">{title}</span> const title = displayTitle(s, titleOverrides, t("chat.newChat"));
{showPreview ? ( const tooltipTitle =
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72"> titleOverrides[s.key]?.trim() ||
{preview} generatedTitle ||
</span> deriveTitle(s.preview, fallbackTitle);
) : null} const isPinned = pinned.has(s.key);
{timestamp ? ( const isArchived = archived.has(s.key);
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58"> const preview = s.preview.trim();
{timestamp} const showPreview = showPreviews && preview && preview !== title;
</span> const timestamp = showTimestamps
) : null} ? relativeTime(s.updatedAt ?? s.createdAt)
</button> : "";
<SessionActivityIndicator state={activityState} /> const projectMode = group.kind === "project";
<DropdownMenu modal={false}> const activityState = running.has(s.chatId)
<DropdownMenuTrigger ? "running"
: completed.has(s.chatId) && !active
? "complete"
: null;
return (
<li key={s.key} className="min-w-0">
<div
className={cn( className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity", "group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100", compact ? "min-h-7" : "min-h-8",
"focus-visible:opacity-100", active
active && "opacity-100", ? "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" /> <button
</DropdownMenuTrigger> type="button"
<DropdownMenuContent onClick={() => onSelect(s.key)}
align="end" title={tooltipTitle}
portalContainer={actionMenuPortalContainer} className={cn(
onCloseAutoFocus={(event) => event.preventDefault()} "min-w-0 flex-1 overflow-hidden text-left",
> compact ? "py-1" : "py-1.5",
<DropdownMenuItem projectMode && "pl-7",
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" /> {projectMode ? (
{t("chat.rename")} <span className="flex w-full min-w-0 items-baseline gap-2">
</DropdownMenuItem> <span className="min-w-0 flex-1 truncate font-medium leading-5">
<DropdownMenuItem {title}
onSelect={() => onToggleArchive(s.key)} </span>
> {timestamp ? (
{isArchived ? ( <span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
<ArchiveRestore className="mr-2 h-4 w-4" /> {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")} {showPreview ? (
</DropdownMenuItem> <span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
<DropdownMenuItem {preview}
onSelect={() => { </span>
window.setTimeout(() => onRequestDelete(s.key, title), 0); ) : null}
}} {timestamp && !projectMode ? (
className="text-destructive focus:text-destructive" <span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
> {timestamp}
<Trash2 className="mr-2 h-4 w-4" /> </span>
{t("chat.delete")} ) : null}
</DropdownMenuItem> </button>
</DropdownMenuContent> <SessionActivityIndicator state={activityState} />
</DropdownMenu> <DropdownMenu modal={false}>
</div> <DropdownMenuTrigger
</li> 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",
</ul> "focus-visible:opacity-100",
</section> 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 ? ( {hiddenSessionCount > 0 ? (
<div className="px-2 pb-2 pt-1"> <div className="px-2 pb-2 pt-1">
<button <button
@ -277,7 +374,7 @@ export const ChatList = memo(function ChatList({
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT), 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 })} {t("chat.showMore", { count: hiddenSessionCount })}
</button> </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({ function SessionActivityIndicator({
state, state,
}: { }: {
@ -316,202 +540,10 @@ function SessionActivityIndicator({
title={label} title={label}
className="grid h-4 w-4 shrink-0 place-items-center" 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> </span>
); );
} }
return <span className="h-4 w-4 shrink-0" aria-hidden="true" />; 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 { interface RenameChatDialogProps {
open: boolean; open: boolean;
title: string; title: string;
dialogTitle?: string;
description?: string;
placeholder?: string;
onCancel: () => void; onCancel: () => void;
onConfirm: (title: string) => void; onConfirm: (title: string) => void;
} }
@ -22,6 +25,9 @@ interface RenameChatDialogProps {
export function RenameChatDialog({ export function RenameChatDialog({
open, open,
title, title,
dialogTitle,
description,
placeholder,
onCancel, onCancel,
onConfirm, onConfirm,
}: RenameChatDialogProps) { }: RenameChatDialogProps) {
@ -48,15 +54,15 @@ export function RenameChatDialog({
}} }}
> >
<DialogHeader className="text-left"> <DialogHeader className="text-left">
<DialogTitle>{t("chat.renameTitle")}</DialogTitle> <DialogTitle>{dialogTitle ?? t("chat.renameTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>
{t("chat.renameDescription")} {description ?? t("chat.renameDescription")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Input <Input
value={value} value={value}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
placeholder={t("chat.renamePlaceholder")} placeholder={placeholder ?? t("chat.renamePlaceholder")}
autoFocus autoFocus
maxLength={160} maxLength={160}
/> />

View File

@ -1,7 +1,6 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { import {
Archive, Archive,
ListFilter,
Menu, Menu,
Search, Search,
Settings, Settings,
@ -13,20 +12,9 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList"; import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge"; import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import type { import type {
ChatSummary, ChatSummary,
SidebarSortMode,
SidebarViewState, SidebarViewState,
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -41,12 +29,14 @@ interface SidebarProps {
onTogglePin: (key: string) => void; onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void; onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void; onToggleArchive: (key: string) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void;
onOpenSettings: () => void; onOpenSettings: () => void;
onOpenApps: () => void; onOpenApps: () => void;
onOpenSearch: () => void; onOpenSearch: () => void;
activeUtility?: "apps" | null; activeUtility?: "apps" | null;
onToggleArchived: () => void; onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void; onCollapse: () => void;
onExpand?: () => void; onExpand?: () => void;
containActionMenus?: boolean; containActionMenus?: boolean;
@ -54,11 +44,15 @@ interface SidebarProps {
pinnedKeys?: string[]; pinnedKeys?: string[];
archivedKeys?: string[]; archivedKeys?: string[];
titleOverrides?: Record<string, string>; titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[]; runningChatIds?: string[];
completedChatIds?: string[]; completedChatIds?: string[];
viewState?: SidebarViewState; viewState?: SidebarViewState;
showArchived?: boolean; showArchived?: boolean;
archivedCount?: number; archivedCount?: number;
defaultWorkspacePath?: string | null;
hostChromeInset?: boolean;
} }
export function Sidebar(props: SidebarProps) { export function Sidebar(props: SidebarProps) {
@ -72,11 +66,15 @@ export function Sidebar(props: SidebarProps) {
<nav <nav
ref={props.containActionMenus ? setMenuPortalContainer : undefined} ref={props.containActionMenus ? setMenuPortalContainer : undefined}
aria-label={t("sidebar.navigation")} aria-label={t("sidebar.navigation")}
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground" className={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 <div
className={cn( 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", collapsed ? "w-14 justify-start" : "justify-between",
)} )}
> >
@ -101,7 +99,7 @@ export function Sidebar(props: SidebarProps) {
draggable={false} draggable={false}
/> />
</button> </button>
{!collapsed && ( {!collapsed && !props.hostChromeInset && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -139,11 +137,6 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "apps"} active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />} icon={<Blocks className="h-4 w-4" />}
/> />
<SidebarViewMenu
compact={collapsed}
view={props.viewState}
onUpdateView={props.onUpdateView}
/>
{props.archivedCount ? ( {props.archivedCount ? (
<SidebarActionButton <SidebarActionButton
collapsed={collapsed} collapsed={collapsed}
@ -170,9 +163,14 @@ export function Sidebar(props: SidebarProps) {
onTogglePin={props.onTogglePin} onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename} onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive} onToggleArchive={props.onToggleArchive}
onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject}
pinnedKeys={props.pinnedKeys} pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys} archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides} titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds} runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds} completedChatIds={props.completedChatIds}
density={props.viewState?.density} density={props.viewState?.density}
@ -180,6 +178,7 @@ export function Sidebar(props: SidebarProps) {
showTimestamps={props.viewState?.show_timestamps} showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort} sort={props.viewState?.sort}
showArchived={props.showArchived} showArchived={props.showArchived}
defaultWorkspacePath={props.defaultWorkspacePath}
actionMenuPortalContainer={ actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined props.containActionMenus ? menuPortalContainer : undefined
} }
@ -261,102 +260,3 @@ function SidebarActionButton({
</Button> </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; hasDiffStats: boolean;
hasEditingFiles: boolean; hasEditingFiles: boolean;
hasFailedFiles: boolean; hasFailedFiles: boolean;
hasDeletedFiles: boolean;
primaryFilePath?: string; primaryFilePath?: string;
primaryFileTooltipPath?: string; primaryFileTooltipPath?: string;
primaryCliName?: string; primaryCliName?: string;
@ -66,6 +67,7 @@ interface FileEditSummary {
approximate: boolean; approximate: boolean;
binary: boolean; binary: boolean;
status: UIFileEdit["status"]; status: UIFileEdit["status"];
operation?: UIFileEdit["operation"];
pending: boolean; pending: boolean;
error?: string; error?: string;
} }
@ -126,6 +128,7 @@ function countActivity(
let hasDiffStats = false; let hasDiffStats = false;
let hasEditingFiles = false; let hasEditingFiles = false;
let failedFileCount = 0; let failedFileCount = 0;
let deletedFileCount = 0;
let primaryFilePath: string | undefined; let primaryFilePath: string | undefined;
let primaryFileTooltipPath: string | undefined; let primaryFileTooltipPath: string | undefined;
for (const edit of fileEdits) { for (const edit of fileEdits) {
@ -137,6 +140,9 @@ function countActivity(
if (edit.status === "error") { if (edit.status === "error") {
failedFileCount += 1; failedFileCount += 1;
} }
if (edit.operation === "delete") {
deletedFileCount += 1;
}
if (edit.status === "error" || edit.binary) { if (edit.status === "error" || edit.binary) {
continue; continue;
} }
@ -158,6 +164,7 @@ function countActivity(
hasDiffStats, hasDiffStats,
hasEditingFiles, hasEditingFiles,
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length, hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
hasDeletedFiles: fileEdits.length > 0 && deletedFileCount === fileEdits.length,
primaryFilePath, primaryFilePath,
primaryFileTooltipPath, primaryFileTooltipPath,
primaryCliName, primaryCliName,
@ -217,6 +224,7 @@ export function AgentActivityCluster({
hasDiffStats, hasDiffStats,
hasEditingFiles, hasEditingFiles,
hasFailedFiles, hasFailedFiles,
hasDeletedFiles,
primaryFilePath, primaryFilePath,
primaryFileTooltipPath, primaryFileTooltipPath,
primaryCliName, primaryCliName,
@ -245,6 +253,7 @@ export function AgentActivityCluster({
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined; const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined; const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0; 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 durationMs = activityDurationMs(messages, isTurnStreaming, now, turnLatencyMs);
const activityDuration = formatActivityDuration(durationMs); const activityDuration = formatActivityDuration(durationMs);
const thoughtLabel = isTurnStreaming const thoughtLabel = isTurnStreaming
@ -263,13 +272,13 @@ export function AgentActivityCluster({
? hasPendingFileEdit && !singleFilePath ? hasPendingFileEdit && !singleFilePath
? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" }) ? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" })
: singleFilePath : singleFilePath
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), { ? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
file: shortFileName(singleFilePath), 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, 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 (!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 ( return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}> <div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button <button
@ -426,7 +454,7 @@ export function AgentActivityCluster({
active={isTurnStreaming} active={isTurnStreaming}
className="min-w-0" className="min-w-0"
> >
{singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles) : thoughtLabel} {singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles) : thoughtLabel}
</StreamingLabelSheen> </StreamingLabelSheen>
{singleFilePath ? ( {singleFilePath ? (
<FileReferenceChip <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 { function shortFileName(path: string): string {
return path.split(/[\\/]/).pop() || path; 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] ?? ""); 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 { function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim()); const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
if (!match) return null; if (!match) return null;
@ -1365,18 +1468,21 @@ function mcpRunLabelDefault(run: McpRunSummary, active: boolean): string {
return active && run.status === "running" ? "Using" : "Used"; 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 (failed) return "Failed";
if (deleted) return editing ? "Deleting" : "Deleted";
return editing ? "Editing" : "Edited"; 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 (failed) return "message.fileActivityFailedOne";
if (deleted) return editing ? "message.fileActivityDeletingOne" : "message.fileActivityDeletedOne";
return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne"; 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 (failed) return "message.fileActivityFailedMany";
if (deleted) return editing ? "message.fileActivityDeletingMany" : "message.fileActivityDeletedMany";
return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany"; return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
} }
@ -1419,6 +1525,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
hasSuccessfulChange: boolean; hasSuccessfulChange: boolean;
hasActiveEditing: boolean; hasActiveEditing: boolean;
hasFailed: boolean; hasFailed: boolean;
operation?: UIFileEdit["operation"];
error?: string; error?: string;
} }
@ -1440,6 +1547,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
hasSuccessfulChange: false, hasSuccessfulChange: false,
hasActiveEditing: false, hasActiveEditing: false,
hasFailed: false, hasFailed: false,
operation: undefined,
}; };
byPath.set(key, summary); byPath.set(key, summary);
order.push(key); order.push(key);
@ -1451,6 +1559,9 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
if (edit.absolute_path) { if (edit.absolute_path) {
summary.absolute_path = edit.absolute_path; summary.absolute_path = edit.absolute_path;
} }
if (edit.operation === "delete") {
summary.operation = "delete";
}
summary.pending = summary.pending || !!edit.pending || !edit.path; summary.pending = summary.pending || !!edit.pending || !edit.path;
if (!edit.path && edit.pending) { if (!edit.path && edit.pending) {
if (active && edit.status === "editing") { if (active && edit.status === "editing") {
@ -1515,6 +1626,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
approximate: summary.approximate, approximate: summary.approximate,
binary: summary.binary, binary: summary.binary,
status, status,
operation: summary.operation,
pending: summary.pending && !summary.path, pending: summary.pending && !summary.path,
error: summary.error, error: summary.error,
}]; }];
@ -1525,6 +1637,23 @@ function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">):
return edit.added > 0 || edit.deleted > 0; 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({ function CliRunGroup({
runs, runs,
active, active,
@ -1758,8 +1887,15 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
const editing = edit.status === "editing"; const editing = edit.status === "editing";
const failed = edit.status === "error"; const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit); const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
: "";
return ( 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"> <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"> <span className="grid h-5 w-5 shrink-0 place-items-center text-muted-foreground/50">
{failed ? ( {failed ? (
@ -1789,13 +1925,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
/> />
)} )}
{failed ? ( {failed ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75"> <span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
{t("message.fileEditFailed", { defaultValue: "Failed" })} {failureDetail}
</span>
) : null}
{edit.approximate && !failed ? (
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
</span> </span>
) : null} ) : null}
</div> </div>

View File

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

View File

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

View File

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

View File

@ -59,7 +59,7 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
cluster.push(current); cluster.push(current);
i += 1; i += 1;
} }
out.push({ type: "cluster", messages: cluster }); pushActivityCluster(out, cluster);
continue; continue;
} }
const previous = out[out.length - 1]; const previous = out[out.length - 1];
@ -85,6 +85,42 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
return out; 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 { function clusterSegmentId(messages: UIMessage[]): string | undefined {
return messages.find((message) => message.activitySegmentId)?.activitySegmentId; return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
} }
@ -115,6 +151,19 @@ function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boole
return segmentId === message.activitySegmentId; 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 { function assistantHasInlineReasoning(message: UIMessage): boolean {
return ( return (
message.role === "assistant" message.role === "assistant"
@ -261,8 +310,14 @@ function activityClusterTurnLatencyMs(
} }
function currentActivityClusterIndex(units: DisplayUnit[]): number { function currentActivityClusterIndex(units: DisplayUnit[]): number {
const last = units.length - 1; for (let i = units.length - 1; i >= 0; i -= 1) {
return units[last]?.type === "cluster" ? last : -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 { function unitKey(unit: DisplayUnit, index: number): string {

View File

@ -1,16 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 { useTranslation } from "react-i18next";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
@ -31,7 +19,16 @@ import {
isMcpPresetsPayload, isMcpPresetsPayload,
} from "@/lib/mcp-preset-events"; } from "@/lib/mcp-preset-events";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand"; 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 { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display"; import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
@ -60,11 +57,19 @@ interface ThreadShellProps {
onToggleSidebar: () => void; onToggleSidebar: () => void;
onGoHome?: () => void; onGoHome?: () => void;
onNewChat?: () => void; onNewChat?: () => void;
onCreateChat?: () => Promise<string | null>; onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
onTurnEnd?: () => void; onTurnEnd?: () => void;
theme?: "light" | "dark"; theme?: "light" | "dark";
onToggleTheme?: () => void; 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 { function toModelBadgeLabel(modelName: string | null): string | null {
@ -110,23 +115,17 @@ function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload |
}; };
} }
const QUICK_ACTION_KEYS = [ const HERO_GREETING_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" }, "thread.empty.greetings.workOn",
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" }, "thread.empty.greetings.start",
{ key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" }, "thread.empty.greetings.build",
{ key: "code", icon: Code2, tone: "text-[#eba45d]" }, "thread.empty.greetings.tackle",
{ key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" },
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
] as const; ] as const;
const IMAGE_QUICK_ACTION_KEYS = [ function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
{ key: "icon", icon: ImageIcon, tone: "text-[#4f9de8]" }, const index = Math.floor(Math.random() * HERO_GREETING_KEYS.length);
{ key: "sticker", icon: Sparkles, tone: "text-[#f25b8f]" }, return HERO_GREETING_KEYS[index] ?? HERO_GREETING_KEYS[0];
{ 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;
interface PendingFirstMessage { interface PendingFirstMessage {
content: string; content: string;
@ -142,7 +141,15 @@ export function ThreadShell({
onTurnEnd, onTurnEnd,
theme = "light", theme = "light",
onToggleTheme = () => {}, onToggleTheme = () => {},
hideSidebarToggleOnDesktop = false, hideSidebarToggleForHostChrome = false,
hideHeader = false,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onWorkspaceScopeChange,
settingsSnapshot = null,
}: ThreadShellProps) { }: ThreadShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
@ -159,8 +166,9 @@ export function ThreadShell({
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]); const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]); 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 [heroImageMode, setHeroImageMode] = useState(false);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0); const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null); const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map()); const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
@ -198,22 +206,46 @@ export function ThreadShell({
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]); const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading; const showHeroComposer = messages.length === 0 && !loading;
const wasShowingHeroComposerRef = useRef(showHeroComposer);
const modelBadge = useMemo( const modelBadge = useMemo(
() => toModelBadgeInfo(modelName, settings), () => toModelBadgeInfo(modelName, settings),
[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 () => { const refreshModelSettings = useCallback(async () => {
try { try {
setSettings(await fetchSettings(token)); setSettings(await fetchSettings(token));
} catch { } catch {
setSettings(null); if (!settingsSnapshot) setSettings(null);
} }
}, [token]); }, [settingsSnapshot, token]);
useEffect(() => { useEffect(() => {
if (settingsSnapshot) {
setSettings(settingsSnapshot);
return;
}
void refreshModelSettings(); void refreshModelSettings();
}, [refreshModelSettings]); }, [refreshModelSettings, settingsSnapshot]);
useEffect(() => { useEffect(() => {
return client.onRuntimeModelUpdate(() => { return client.onRuntimeModelUpdate(() => {
@ -433,64 +465,22 @@ export function ThreadShell({
async (content: string, images?: SendImage[], options?: SendOptions) => { async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return; if (booting) return;
setBooting(true); setBooting(true);
pendingFirstRef.current = { content, images, options }; pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
const newId = await onCreateChat?.(); const newId = await onCreateChat?.(workspaceScope);
if (!newId) { if (!newId) {
pendingFirstRef.current = null; pendingFirstRef.current = null;
setBooting(false); setBooting(false);
} }
}, },
[booting, onCreateChat], [booting, onCreateChat, withWorkspaceScope, workspaceScope],
); );
const handleThreadSend = useCallback( const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => { (content: string, images?: SendImage[], options?: SendOptions) => {
setScrollToBottomSignal((value) => value + 1); setScrollToBottomSignal((value) => value + 1);
send(content, images, options); send(content, images, withWorkspaceScope(options));
}, },
[send], [send, withWorkspaceScope],
);
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>
); );
const composer = ( const composer = (
@ -518,11 +508,18 @@ export function ThreadShell({
slashCommands={slashCommands} slashCommands={slashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={showHeroComposer ? heroImageMode : undefined} imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined} onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop} onStop={stop}
runStartedAt={runStartedAt} runStartedAt={runStartedAt}
goalState={goalState} goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/> />
) : ( ) : (
<ThreadComposer <ThreadComposer
@ -541,13 +538,19 @@ export function ThreadShell({
slashCommands={slashCommands} slashCommands={slashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={heroImageMode} imageMode={heroImageMode}
onImageModeChange={setHeroImageMode} onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt} runStartedAt={runStartedAt}
goalState={goalState} 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"> <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]"> <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> </h1>
</div> </div>
); );
return ( return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden"> <section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<ThreadHeader {!hideHeader ? (
title={title} <ThreadHeader
onToggleSidebar={onToggleSidebar} title={title}
theme={theme} onToggleSidebar={onToggleSidebar}
onToggleTheme={onToggleTheme} theme={theme}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop} onToggleTheme={onToggleTheme}
minimal={!session && !loading} hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
/> minimal={!session && !loading}
/>
) : null}
<ThreadViewport <ThreadViewport
messages={displayMessages} messages={displayMessages}
isStreaming={isStreaming} isStreaming={isStreaming}

View File

@ -271,9 +271,11 @@ export function ThreadViewport({
</div> </div>
) : ( ) : (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4"> <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 flex-1 items-center justify-center py-10 sm:py-12">
<div className="flex w-full max-w-[58rem] flex-col gap-6"> <div className="relative w-full max-w-[58rem]">
{emptyState} <div className="absolute inset-x-0 bottom-[calc(100%+1.5rem)] flex justify-center">
{emptyState}
</div>
<div className="w-full">{composer}</div> <div className="w-full">{composer}</div>
</div> </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"; import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root; const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal; const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef< const AlertDialogOverlay = React.forwardRef<
@ -128,8 +127,5 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger,
}; };

View File

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

View File

@ -11,6 +11,12 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 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< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
@ -20,14 +26,15 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( 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", inset && "pl-8",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto" /> <ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)); ));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
@ -39,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg", menuContentClassName,
className, className,
)} )}
{...props} {...props}
@ -61,7 +68,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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, className,
)} )}
{...props} {...props}
@ -79,7 +87,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( 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", inset && "pl-8",
className, className,
)} )}
@ -95,15 +103,16 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
checked={checked} checked={checked}
{...props} {...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> <DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-3.5 w-3.5" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
@ -119,12 +128,13 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...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> <DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" /> <Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
@ -143,7 +153,7 @@ const DropdownMenuLabel = React.forwardRef<
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( 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", inset && "pl-8",
className, className,
)} )}
@ -158,7 +168,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} 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} {...props}
/> />
)); ));

View File

@ -6,8 +6,6 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const Sheet = DialogPrimitive.Root; const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
const SheetPortal = DialogPrimitive.Portal; const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = React.forwardRef< const SheetOverlay = React.forwardRef<
@ -90,14 +88,6 @@ const SheetContent = React.forwardRef<
)); ));
SheetContent.displayName = DialogPrimitive.Content.displayName; 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< const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@ -110,4 +100,4 @@ const SheetTitle = React.forwardRef<
)); ));
SheetTitle.displayName = DialogPrimitive.Title.displayName; 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 { @layer utilities {
.host-drag-region {
-webkit-app-region: drag;
}
.host-no-drag {
-webkit-app-region: no-drag;
}
.shadow-inner-right { .shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02); box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
} }

View File

@ -16,9 +16,11 @@ import type {
OutboundMcpPresetMention, OutboundMcpPresetMention,
OutboundMedia, OutboundMedia,
GoalStateWsPayload, GoalStateWsPayload,
ToolProgressEvent,
UIImage, UIImage,
UIFileEdit, UIFileEdit,
UIMessage, UIMessage,
WorkspaceScopePayload,
} from "@/lib/types"; } from "@/lib/types";
interface StreamBuffer { interface StreamBuffer {
@ -35,6 +37,8 @@ type PendingStreamEvent =
| { kind: "delta"; text: string } | { kind: "delta"; text: string }
| { kind: "reasoning"; 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 /** Find a still-open streamed assistant turn. Closed stream segments stay visible
* as streaming until ``turn_end`` for visual continuity, but they must not * as streaming until ``turn_end`` for visual continuity, but they must not
* receive later delta segments. */ * receive later delta segments. */
@ -194,15 +198,6 @@ function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMess
return prev; 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( function absorbCompleteAssistantMessage(
prev: UIMessage[], prev: UIMessage[],
message: Omit<UIMessage, "id" | "role" | "createdAt">, 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}`; 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 { function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null; if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus = const inferredStatus =
@ -285,11 +375,15 @@ function findFileEditTraceIndex(
for (let i = prev.length - 1; i >= 0; i -= 1) { for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i]; const candidate = prev[i];
if (candidate.role === "user") break; 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; 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; 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; return null;
} }
@ -315,6 +409,7 @@ export interface SendOptions {
imageGeneration?: OutboundImageGeneration; imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[]; cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[]; mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
} }
export function useNanobotStream( export function useNanobotStream(
@ -422,7 +517,12 @@ export function useNanobotStream(
const cursor = activeAssistantRef.current; const cursor = activeAssistantRef.current;
if (!cursor) return null; if (!cursor) return null;
const indexed = prev[cursor.index]; 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; return cursor.index;
} }
const idx = prev.findIndex((m) => m.id === cursor.id); const idx = prev.findIndex((m) => m.id === cursor.id);
@ -431,7 +531,7 @@ export function useNanobotStream(
return null; return null;
} }
const found = prev[idx]; const found = prev[idx];
if (found.role !== "assistant" || found.kind === "trace") { if (found.role !== "assistant" || found.kind === "trace" || !found.isStreaming) {
activeAssistantRef.current = null; activeAssistantRef.current = null;
return null; return null;
} }
@ -520,8 +620,7 @@ export function useNanobotStream(
if (finalAnswerText !== undefined) { if (finalAnswerText !== undefined) {
const targetIndex = const targetIndex =
resolveActiveAssistantIndex(next) resolveActiveAssistantIndex(next)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current) ?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
?? findLatestAssistantAnswerIndex(next);
if (targetIndex !== null) { if (targetIndex !== null) {
const target = next[targetIndex]; const target = next[targetIndex];
next = replaceMessageAt(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") { if ("goal_state" in ev && ev.goal_state != null && typeof ev.goal_state === "object") {
setGoalState(ev.goal_state); setGoalState(ev.goal_state);
} }
setRunStartedAt(null);
// Definitive signal that the turn is fully complete. Cancel any // Definitive signal that the turn is fully complete. Cancel any
// pending debounce timer and stop the loading indicator immediately. // pending debounce timer and stop the loading indicator immediately.
if (streamEndTimerRef.current !== null) { if (streamEndTimerRef.current !== null) {
@ -710,16 +810,20 @@ export function useNanobotStream(
// so a sequence of calls collapses into one compact trace group. // so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") { if (ev.kind === "tool_hint" || ev.kind === "progress") {
const structuredEvents = normalizeToolProgressEvents(ev.tool_events); 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) => { setMessages((prev) => {
const segmentId = ensureActivitySegmentId(); 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 ( if (
last last
&& last.kind === "trace" && last.kind === "trace"
@ -731,7 +835,7 @@ export function useNanobotStream(
: last.content : last.content
? [last.content] ? [last.content]
: []; : [];
const mergedLines = structuredLines.length > 0 const mergedLines = visibleStructuredEvents.length > 0
? mergeUniqueToolTraceLines(previousTraces, structuredLines) ? mergeUniqueToolTraceLines(previousTraces, structuredLines)
: null; : null;
const merged: UIMessage = { const merged: UIMessage = {
@ -740,22 +844,22 @@ export function useNanobotStream(
content: mergedLines content: mergedLines
? mergedLines.traces[mergedLines.traces.length - 1] ? mergedLines.traces[mergedLines.traces.length - 1]
: lines[lines.length - 1], : lines[lines.length - 1],
toolEvents: structuredEvents.length toolEvents: visibleStructuredEvents.length
? mergeToolProgressEvents(last.toolEvents, structuredEvents) ? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents, : last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId, activitySegmentId: last.activitySegmentId ?? segmentId,
}; };
return [...prev.slice(0, -1), merged]; return [...base.slice(0, -1), merged];
} }
return [ return [
...prev, ...base,
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
role: "tool", role: "tool",
kind: "trace", kind: "trace",
content: lines[lines.length - 1], content: lines[lines.length - 1],
traces: lines, traces: lines,
...(structuredEvents.length ? { toolEvents: structuredEvents } : {}), ...(visibleStructuredEvents.length ? { toolEvents: visibleStructuredEvents } : {}),
activitySegmentId: segmentId, activitySegmentId: segmentId,
createdAt: Date.now(), createdAt: Date.now(),
}, },
@ -810,22 +914,24 @@ export function useNanobotStream(
} }
setMessages((prev) => { setMessages((prev) => {
let segmentId = eventSegmentId; 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) { if (targetIndex !== null) {
const target = prev[targetIndex]; const target = base[targetIndex];
segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId(); segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId; if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
const cleanedTarget = stripCoveredFileEditToolHints(target, normalized);
const merged: UIMessage = { const merged: UIMessage = {
...target, ...cleanedTarget,
fileEdits: mergeFileEdits(target.fileEdits, normalized), fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized),
activitySegmentId: segmentId, activitySegmentId: segmentId,
}; };
return replaceMessageAt(prev, targetIndex, merged); return replaceMessageAt(base, targetIndex, merged);
} }
segmentId = segmentId ?? detachedActivitySegmentId(); segmentId = segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId; if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
return [ return [
...prev, ...base,
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
role: "tool", role: "tool",

View File

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

View File

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

View File

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

View File

@ -25,7 +25,9 @@
"section": "System", "section": "System",
"restartHint": "Restart nanobot to apply runtime changes.", "restartHint": "Restart nanobot to apply runtime changes.",
"restart": "Restart nanobot", "restart": "Restart nanobot",
"restarting": "Restarting..." "restarting": "Restarting...",
"restartEngine": "Restart engine",
"restartingEngine": "Restarting engine..."
}, },
"restart": { "restart": {
"completed": "Restart completed in {{seconds}}s." "completed": "Restart completed in {{seconds}}s."
@ -40,25 +42,13 @@
}, },
"sidebar": { "sidebar": {
"navigation": "Sidebar navigation", "navigation": "Sidebar navigation",
"globalActions": "Global actions",
"collapse": "Collapse sidebar", "collapse": "Collapse sidebar",
"toggleTheme": "Toggle theme",
"home": "Home",
"newChat": "New chat", "newChat": "New chat",
"searchAria": "Search", "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", "searchPlaceholder": "Search",
"searchResults": "Results", "searchResults": "Results",
"noSearchResults": "No matching chats.", "noSearchResults": "No matching chats.",
"recent": "Recent", "recent": "Recent",
"refreshSessions": "Refresh sessions",
"settings": "Settings", "settings": "Settings",
"language": { "language": {
"label": "Language", "label": "Language",
@ -80,11 +70,11 @@
"models": "Models", "models": "Models",
"providers": "Providers", "providers": "Providers",
"image": "Image", "image": "Image",
"web": "Web", "browser": "Web",
"cliApps": "CLI Apps", "cliApps": "CLI Apps",
"mcp": "MCP", "mcp": "MCP",
"runtime": "Runtime", "runtime": "System",
"advanced": "Advanced", "advanced": "Security",
"apps": "Apps" "apps": "Apps"
}, },
"sections": { "sections": {
@ -101,10 +91,11 @@
"cliApps": "CLI apps", "cliApps": "CLI apps",
"mcp": "MCP services", "mcp": "MCP services",
"identity": "Identity", "identity": "Identity",
"safety": "Safety", "webuiSafety": "Web safety",
"capabilities": "Capabilities", "capabilities": "Capabilities",
"integrations": "Integrations", "apps": "Apps",
"apps": "Apps" "nativeHost": "Native host",
"hostSafety": "App safety"
}, },
"models": { "models": {
"selectModel": "Select model", "selectModel": "Select model",
@ -112,6 +103,7 @@
"newConfiguration": "New model configuration", "newConfiguration": "New model configuration",
"newConfigurationHelp": "Save a provider and model as a one-click option.", "newConfigurationHelp": "Save a provider and model as a one-click option.",
"configurationName": "Name", "configurationName": "Name",
"configurationNameHelp": "Rename this saved model configuration.",
"configurationNamePlaceholder": "Fast writing" "configurationNamePlaceholder": "Fast writing"
}, },
"rows": { "rows": {
@ -147,20 +139,14 @@
"botName": "Bot name", "botName": "Bot name",
"botIcon": "Bot icon", "botIcon": "Bot icon",
"timezone": "Timezone", "timezone": "Timezone",
"toolHintMaxLength": "Tool hint length", "workspacePath": "Default workspace",
"workspacePath": "Workspace path", "localServiceAccess": "Local services",
"heartbeat": "Heartbeat", "webuiDefaultAccess": "Default access",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"cliAppsCatalog": "Catalog", "cliAppsCatalog": "Catalog",
"cliAppsFilter": "Filter", "cliAppsFilter": "Filter",
"configurationDocs": "Configuration docs" "engine": "Engine",
"logs": "Logs",
"diagnostics": "Diagnostics"
}, },
"help": { "help": {
"theme": "Switch between light and dark appearance.", "theme": "Switch between light and dark appearance.",
@ -187,13 +173,18 @@
"defaultAspectRatio": "Used when the prompt does not choose an aspect ratio.", "defaultAspectRatio": "Used when the prompt does not choose an aspect ratio.",
"defaultImageSize": "Size hint sent to providers that support it.", "defaultImageSize": "Size hint sent to providers that support it.",
"maxImagesPerTurn": "Upper bound for one generate_image request.", "maxImagesPerTurn": "Upper bound for one generate_image request.",
"botName": "Shown in runtime surfaces that use the configured bot identity.", "botName": "Shown wherever nanobot uses a display name.",
"botIcon": "Short emoji or text shown beside the bot name.", "botIcon": "Short emoji or text shown with the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.", "timezone": "Used for schedules and time-aware replies.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.", "cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; native apps stay untouched.",
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; desktop apps stay untouched.",
"cliAppsFilter": "Search by app, category, or capability.", "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": { "timezone": {
"select": "Select timezone", "select": "Select timezone",
@ -298,8 +289,12 @@
"expanded": "Expanded", "expanded": "Expanded",
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured", "configured": "Configured",
"notConfigured": "Not configured" "notConfigured": "Not configured",
"pending": "Pending",
"restartingEngine": "Restarting"
}, },
"status": { "status": {
"loading": "Loading settings...", "loading": "Loading settings...",
@ -309,14 +304,24 @@
"savedRestart": "Saved. Restart nanobot to apply.", "savedRestart": "Saved. Restart nanobot to apply.",
"restartAfterSaving": "Save changes, then restart when ready.", "restartAfterSaving": "Save changes, then restart when ready.",
"savedRestartApply": "Saved. 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": { "actions": {
"save": "Save", "save": "Save",
"saving": "Saving", "saving": "Saving",
"edit": "Edit", "edit": "Edit",
"cancel": "Cancel", "cancel": "Cancel",
"openDocs": "Open docs" "open": "Open",
"export": "Export",
"opening": "Opening...",
"exporting": "Exporting..."
}, },
"byok": { "byok": {
"description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.", "description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.",
@ -397,6 +402,19 @@
"featured": "Featured", "featured": "Featured",
"loading": "Loading Apps...", "loading": "Loading Apps...",
"empty": "No apps match this filter." "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": { "chat": {
@ -404,7 +422,10 @@
"loading": "Loading…", "loading": "Loading…",
"noSessions": "No sessions yet.", "noSessions": "No sessions yet.",
"showMore": "Show {{count}} more", "showMore": "Show {{count}} more",
"collapsed": "{{count}} hidden chats",
"showLess": "Show less",
"actions": "Chat actions for {{title}}", "actions": "Chat actions for {{title}}",
"newInProject": "Start a new chat in {{project}}",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat", "renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.", "renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name", "renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save", "renameSave": "Save",
"archive": "Archive", "archive": "Archive",
"unarchive": "Unarchive", "unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": { "groups": {
"pinned": "Pinned", "pinned": "Pinned",
"all": "Chats", "all": "Chats",
"projects": "Projects",
"today": "Today", "today": "Today",
"yesterday": "Yesterday", "yesterday": "Yesterday",
"earlier": "Earlier", "earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": { "thread": {
"loadingConversation": "Loading conversation…", "loadingConversation": "Loading conversation…",
"empty": { "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": { "quickActions": {
"plan": { "plan": {
"title": "Create a project plan", "title": "Create a project plan",
@ -632,6 +662,13 @@
"decode_failed": "Couldn't decode this image", "decode_failed": "Couldn't decode this image",
"too_large": "Image is too large — try a smaller one", "too_large": "Image is too large — try a smaller one",
"io": "Couldn't read this file" "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", "scrollToBottom": "Scroll to bottom",
@ -690,6 +727,19 @@
"messageTooBig": { "messageTooBig": {
"title": "Message too large", "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." "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", "section": "Sistema",
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.", "restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
"restart": "Reiniciar nanobot", "restart": "Reiniciar nanobot",
"restarting": "Reiniciando..." "restarting": "Reiniciando...",
"restartEngine": "Reiniciar motor",
"restartingEngine": "Reiniciando motor..."
}, },
"restart": { "restart": {
"completed": "Reinicio completado en {{seconds}} s." "completed": "Reinicio completado en {{seconds}} s."
@ -40,25 +42,13 @@
}, },
"sidebar": { "sidebar": {
"navigation": "Navegación de la barra lateral", "navigation": "Navegación de la barra lateral",
"globalActions": "Acciones globales",
"collapse": "Contraer barra lateral", "collapse": "Contraer barra lateral",
"toggleTheme": "Cambiar tema",
"home": "Inicio",
"newChat": "Nuevo chat", "newChat": "Nuevo chat",
"searchAria": "Buscar", "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", "searchPlaceholder": "Buscar",
"searchResults": "Resultados", "searchResults": "Resultados",
"noSearchResults": "No hay chats coincidentes.", "noSearchResults": "No hay chats coincidentes.",
"recent": "Recientes", "recent": "Recientes",
"refreshSessions": "Actualizar sesiones",
"settings": "Configuración", "settings": "Configuración",
"language": { "language": {
"label": "Idioma", "label": "Idioma",
@ -80,9 +70,9 @@
"models": "Models", "models": "Models",
"providers": "Providers", "providers": "Providers",
"image": "Image", "image": "Image",
"web": "Web", "browser": "Web",
"runtime": "Runtime", "runtime": "Sistema",
"advanced": "Advanced", "advanced": "Security",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Apps" "apps": "Apps"
@ -99,12 +89,13 @@
"webSearch": "Web search", "webSearch": "Web search",
"webBehavior": "Behavior", "webBehavior": "Behavior",
"identity": "Identity", "identity": "Identity",
"safety": "Safety", "webuiSafety": "Web safety",
"capabilities": "Capacidades", "capabilities": "Capacidades",
"integrations": "Integrations",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "Servicios MCP", "mcp": "Servicios MCP",
"apps": "Apps" "apps": "Apps",
"nativeHost": "App",
"hostSafety": "App safety"
}, },
"rows": { "rows": {
"theme": "Tema", "theme": "Tema",
@ -137,22 +128,16 @@
"botName": "Bot name", "botName": "Bot name",
"botIcon": "Bot icon", "botIcon": "Bot icon",
"timezone": "Timezone", "timezone": "Timezone",
"toolHintMaxLength": "Tool hint length", "workspacePath": "Workspace predeterminado",
"workspacePath": "Workspace path", "localServiceAccess": "Local services",
"heartbeat": "Heartbeat", "webuiDefaultAccess": "Default access",
"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",
"currentModel": "Modelo actual", "currentModel": "Modelo actual",
"brandLogos": "Logotipos de marca", "brandLogos": "Logotipos de marca",
"cliAppsCatalog": "Catálogo de apps CLI", "cliAppsCatalog": "Catálogo de apps CLI",
"cliAppsFilter": "Filtro de apps CLI" "cliAppsFilter": "Filtro de apps CLI",
"engine": "Motor",
"logs": "Registros",
"diagnostics": "Diagnóstico"
}, },
"help": { "help": {
"theme": "Cambia entre apariencia clara y oscura.", "theme": "Cambia entre apariencia clara y oscura.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "Se usa cuando el prompt no elige una relación de aspecto.", "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.", "defaultImageSize": "Sugerencia de tamaño enviada a los proveedores que la admiten.",
"maxImagesPerTurn": "Límite superior para una solicitud generate_image.", "maxImagesPerTurn": "Límite superior para una solicitud generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.", "botName": "Se muestra donde nanobot usa un nombre visible.",
"botIcon": "Short emoji or text shown beside the bot name.", "botIcon": "Emoji o texto corto mostrado junto al nombre del bot.",
"timezone": "IANA timezone used by runtime context and schedules.", "timezone": "Se usa para programaciones y respuestas sensibles al tiempo.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Elige el modelo que nanobot usará para las próximas respuestas.", "currentModel": "Elige el modelo que nanobot usará para las próximas respuestas.",
"selectedModelProvider": "Lo define el modelo seleccionado.", "selectedModelProvider": "Lo define el modelo seleccionado.",
"selectedModelValue": "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.", "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.", "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": { "values": {
"light": "Claro", "light": "Claro",
@ -201,8 +191,12 @@
"expanded": "Expanded", "expanded": "Expanded",
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured", "configured": "Configured",
"notConfigured": "Not configured" "notConfigured": "Not configured",
"pending": "Pendiente",
"restartingEngine": "Reiniciando"
}, },
"status": { "status": {
"loading": "Cargando configuración...", "loading": "Cargando configuración...",
@ -212,14 +206,24 @@
"savedRestart": "Guardado. Reinicia nanobot para aplicar.", "savedRestart": "Guardado. Reinicia nanobot para aplicar.",
"restartAfterSaving": "Guarda los cambios y reinicia cuando estés listo.", "restartAfterSaving": "Guarda los cambios y reinicia cuando estés listo.",
"savedRestartApply": "Guardado. 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": { "actions": {
"save": "Guardar", "save": "Guardar",
"saving": "Guardando", "saving": "Guardando",
"edit": "Editar", "edit": "Editar",
"cancel": "Cancelar", "cancel": "Cancelar",
"openDocs": "Open docs" "open": "Abrir",
"export": "Exportar",
"opening": "Opening...",
"exporting": "Exporting..."
}, },
"byok": { "byok": {
"description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.", "description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.",
@ -290,6 +294,7 @@
"newConfiguration": "Nueva configuración de modelo", "newConfiguration": "Nueva configuración de modelo",
"newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.", "newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
"configurationName": "Nombre", "configurationName": "Nombre",
"configurationNameHelp": "Cambia el nombre de esta configuración de modelo guardada.",
"configurationNamePlaceholder": "Escritura rápida" "configurationNamePlaceholder": "Escritura rápida"
}, },
"timezone": { "timezone": {
@ -397,6 +402,19 @@
"featured": "Destacadas", "featured": "Destacadas",
"loading": "Cargando apps...", "loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro." "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": { "chat": {
@ -404,7 +422,10 @@
"loading": "Cargando…", "loading": "Cargando…",
"noSessions": "Todavía no hay sesiones.", "noSessions": "Todavía no hay sesiones.",
"showMore": "Mostrar {{count}} más", "showMore": "Mostrar {{count}} más",
"collapsed": "{{count}} chats ocultos",
"showLess": "Mostrar menos",
"actions": "Acciones del chat {{title}}", "actions": "Acciones del chat {{title}}",
"newInProject": "Iniciar un chat nuevo en {{project}}",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat", "renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.", "renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name", "renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save", "renameSave": "Save",
"archive": "Archive", "archive": "Archive",
"unarchive": "Unarchive", "unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": { "groups": {
"pinned": "Pinned", "pinned": "Pinned",
"all": "Chats", "all": "Chats",
"projects": "Projects",
"today": "Today", "today": "Today",
"yesterday": "Yesterday", "yesterday": "Yesterday",
"earlier": "Earlier", "earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": { "thread": {
"loadingConversation": "Cargando conversación…", "loadingConversation": "Cargando conversación…",
"empty": { "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": { "quickActions": {
"plan": { "plan": {
"title": "Crear un plan de proyecto", "title": "Crear un plan de proyecto",
@ -632,6 +662,13 @@
"mcpBadge": "MCP", "mcpBadge": "MCP",
"cliDescription": "Usar @{{name}} como app CLI local", "cliDescription": "Usar @{{name}} como app CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP" "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", "scrollToBottom": "Desplazarse al final",
@ -690,6 +727,19 @@
"messageTooBig": { "messageTooBig": {
"title": "Mensaje demasiado grande", "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." "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", "section": "Système",
"restartHint": "Redémarrez nanobot pour appliquer les changements dexécution.", "restartHint": "Redémarrez nanobot pour appliquer les changements dexécution.",
"restart": "Redémarrer nanobot", "restart": "Redémarrer nanobot",
"restarting": "Redémarrage..." "restarting": "Redémarrage...",
"restartEngine": "Redémarrer le moteur",
"restartingEngine": "Redémarrage du moteur..."
}, },
"restart": { "restart": {
"completed": "Redémarrage terminé en {{seconds}} s." "completed": "Redémarrage terminé en {{seconds}} s."
@ -40,25 +42,13 @@
}, },
"sidebar": { "sidebar": {
"navigation": "Navigation de la barre latérale", "navigation": "Navigation de la barre latérale",
"globalActions": "Actions globales",
"collapse": "Réduire la barre latérale", "collapse": "Réduire la barre latérale",
"toggleTheme": "Changer de thème",
"home": "Accueil",
"newChat": "Nouvelle discussion", "newChat": "Nouvelle discussion",
"searchAria": "Rechercher", "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", "searchPlaceholder": "Rechercher",
"searchResults": "Résultats", "searchResults": "Résultats",
"noSearchResults": "Aucun chat correspondant.", "noSearchResults": "Aucun chat correspondant.",
"recent": "Récentes", "recent": "Récentes",
"refreshSessions": "Actualiser les sessions",
"settings": "Paramètres", "settings": "Paramètres",
"language": { "language": {
"label": "Langue", "label": "Langue",
@ -80,9 +70,9 @@
"models": "Models", "models": "Models",
"providers": "Providers", "providers": "Providers",
"image": "Image", "image": "Image",
"web": "Web", "browser": "Web",
"runtime": "Runtime", "runtime": "Système",
"advanced": "Advanced", "advanced": "Security",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Apps" "apps": "Apps"
@ -99,12 +89,13 @@
"webSearch": "Web search", "webSearch": "Web search",
"webBehavior": "Behavior", "webBehavior": "Behavior",
"identity": "Identity", "identity": "Identity",
"safety": "Safety", "webuiSafety": "Web safety",
"capabilities": "Capacités", "capabilities": "Capacités",
"integrations": "Integrations",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "Services MCP", "mcp": "Services MCP",
"apps": "Apps" "apps": "Apps",
"nativeHost": "App",
"hostSafety": "App safety"
}, },
"rows": { "rows": {
"theme": "Thème", "theme": "Thème",
@ -137,22 +128,16 @@
"botName": "Bot name", "botName": "Bot name",
"botIcon": "Bot icon", "botIcon": "Bot icon",
"timezone": "Timezone", "timezone": "Timezone",
"toolHintMaxLength": "Tool hint length", "workspacePath": "Espace de travail par défaut",
"workspacePath": "Workspace path", "localServiceAccess": "Local services",
"heartbeat": "Heartbeat", "webuiDefaultAccess": "Default access",
"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",
"currentModel": "Modèle actuel", "currentModel": "Modèle actuel",
"brandLogos": "Logos de marque", "brandLogos": "Logos de marque",
"cliAppsCatalog": "Catalogue d'apps CLI", "cliAppsCatalog": "Catalogue d'apps CLI",
"cliAppsFilter": "Filtre des apps CLI" "cliAppsFilter": "Filtre des apps CLI",
"engine": "Moteur",
"logs": "Journaux",
"diagnostics": "Diagnostics"
}, },
"help": { "help": {
"theme": "Basculer entre les apparences claire et sombre.", "theme": "Basculer entre les apparences claire et sombre.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de format.", "defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de format.",
"defaultImageSize": "Indication de taille envoyée aux fournisseurs qui la prennent en charge.", "defaultImageSize": "Indication de taille envoyée aux fournisseurs qui la prennent en charge.",
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.", "maxImagesPerTurn": "Limite supérieure pour une requête generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.", "botName": "Affiché partout où nanobot utilise un nom visible.",
"botIcon": "Short emoji or text shown beside the bot name.", "botIcon": "Emoji ou texte court affiché avec le nom du bot.",
"timezone": "IANA timezone used by runtime context and schedules.", "timezone": "Utilisé pour les planifications et les réponses sensibles à lheure.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Choisissez le modèle que nanobot utilisera pour les prochaines réponses.", "currentModel": "Choisissez le modèle que nanobot utilisera pour les prochaines réponses.",
"selectedModelProvider": "Défini par le modèle sélectionné.", "selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "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.", "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.", "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": { "values": {
"light": "Clair", "light": "Clair",
@ -201,8 +191,12 @@
"expanded": "Expanded", "expanded": "Expanded",
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured", "configured": "Configured",
"notConfigured": "Not configured" "notConfigured": "Not configured",
"pending": "En attente",
"restartingEngine": "Redémarrage"
}, },
"status": { "status": {
"loading": "Chargement des paramètres...", "loading": "Chargement des paramètres...",
@ -212,14 +206,24 @@
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer.", "savedRestart": "Enregistré. Redémarrez nanobot pour appliquer.",
"restartAfterSaving": "Enregistrez les modifications, puis redémarrez lorsque vous êtes prêt.", "restartAfterSaving": "Enregistrez les modifications, puis redémarrez lorsque vous êtes prêt.",
"savedRestartApply": "Enregistré. 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": { "actions": {
"save": "Enregistrer", "save": "Enregistrer",
"saving": "Enregistrement", "saving": "Enregistrement",
"edit": "Modifier", "edit": "Modifier",
"cancel": "Annuler", "cancel": "Annuler",
"openDocs": "Open docs" "open": "Ouvrir",
"export": "Exporter",
"opening": "Opening...",
"exporting": "Exporting..."
}, },
"byok": { "byok": {
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.", "description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.",
@ -290,6 +294,7 @@
"newConfiguration": "Nouvelle configuration de modèle", "newConfiguration": "Nouvelle configuration de modèle",
"newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.", "newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
"configurationName": "Nom", "configurationName": "Nom",
"configurationNameHelp": "Renommez cette configuration de modèle enregistrée.",
"configurationNamePlaceholder": "Rédaction rapide" "configurationNamePlaceholder": "Rédaction rapide"
}, },
"timezone": { "timezone": {
@ -397,6 +402,19 @@
"featured": "En vedette", "featured": "En vedette",
"loading": "Chargement des apps...", "loading": "Chargement des apps...",
"empty": "Aucune app ne correspond à ce filtre." "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": { "chat": {
@ -404,7 +422,10 @@
"loading": "Chargement…", "loading": "Chargement…",
"noSessions": "Aucune session pour le moment.", "noSessions": "Aucune session pour le moment.",
"showMore": "Afficher {{count}} de plus", "showMore": "Afficher {{count}} de plus",
"collapsed": "{{count}} discussions masquées",
"showLess": "Afficher moins",
"actions": "Actions de la discussion {{title}}", "actions": "Actions de la discussion {{title}}",
"newInProject": "Démarrer une nouvelle discussion dans {{project}}",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat", "renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.", "renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name", "renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save", "renameSave": "Save",
"archive": "Archive", "archive": "Archive",
"unarchive": "Unarchive", "unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": { "groups": {
"pinned": "Pinned", "pinned": "Pinned",
"all": "Chats", "all": "Chats",
"projects": "Projects",
"today": "Today", "today": "Today",
"yesterday": "Yesterday", "yesterday": "Yesterday",
"earlier": "Earlier", "earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": { "thread": {
"loadingConversation": "Chargement de la conversation…", "loadingConversation": "Chargement de la conversation…",
"empty": { "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": { "quickActions": {
"plan": { "plan": {
"title": "Créer un plan de projet", "title": "Créer un plan de projet",
@ -632,6 +662,13 @@
"mcpBadge": "MCP", "mcpBadge": "MCP",
"cliDescription": "Utiliser @{{name}} comme app CLI locale", "cliDescription": "Utiliser @{{name}} comme app CLI locale",
"mcpDescription": "Utiliser @{{name}} comme serveur MCP" "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", "scrollToBottom": "Faire défiler vers le bas",
@ -690,6 +727,19 @@
"messageTooBig": { "messageTooBig": {
"title": "Message trop volumineux", "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." "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", "section": "Sistem",
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.", "restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
"restart": "Mulai ulang nanobot", "restart": "Mulai ulang nanobot",
"restarting": "Memulai ulang..." "restarting": "Memulai ulang...",
"restartEngine": "Mulai ulang engine",
"restartingEngine": "Memulai ulang engine..."
}, },
"restart": { "restart": {
"completed": "Mulai ulang selesai dalam {{seconds}} dtk." "completed": "Mulai ulang selesai dalam {{seconds}} dtk."
@ -40,25 +42,13 @@
}, },
"sidebar": { "sidebar": {
"navigation": "Navigasi bilah samping", "navigation": "Navigasi bilah samping",
"globalActions": "Aksi global",
"collapse": "Ciutkan sidebar", "collapse": "Ciutkan sidebar",
"toggleTheme": "Ganti tema",
"home": "Beranda",
"newChat": "Obrolan baru", "newChat": "Obrolan baru",
"searchAria": "Cari", "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", "searchPlaceholder": "Cari",
"searchResults": "Hasil", "searchResults": "Hasil",
"noSearchResults": "Tidak ada chat yang cocok.", "noSearchResults": "Tidak ada chat yang cocok.",
"recent": "Terbaru", "recent": "Terbaru",
"refreshSessions": "Segarkan sesi",
"settings": "Pengaturan", "settings": "Pengaturan",
"language": { "language": {
"label": "Bahasa", "label": "Bahasa",
@ -80,9 +70,9 @@
"models": "Models", "models": "Models",
"providers": "Providers", "providers": "Providers",
"image": "Image", "image": "Image",
"web": "Web", "browser": "Web",
"runtime": "Runtime", "runtime": "Sistem",
"advanced": "Advanced", "advanced": "Security",
"cliApps": "Aplikasi CLI", "cliApps": "Aplikasi CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Aplikasi" "apps": "Aplikasi"
@ -99,12 +89,13 @@
"webSearch": "Web search", "webSearch": "Web search",
"webBehavior": "Behavior", "webBehavior": "Behavior",
"identity": "Identity", "identity": "Identity",
"safety": "Safety", "webuiSafety": "Web safety",
"capabilities": "Kapabilitas", "capabilities": "Kapabilitas",
"integrations": "Integrations",
"cliApps": "App CLI", "cliApps": "App CLI",
"mcp": "Layanan MCP", "mcp": "Layanan MCP",
"apps": "Aplikasi" "apps": "Aplikasi",
"nativeHost": "Native host",
"hostSafety": "App safety"
}, },
"rows": { "rows": {
"theme": "Tema", "theme": "Tema",
@ -137,22 +128,16 @@
"botName": "Bot name", "botName": "Bot name",
"botIcon": "Bot icon", "botIcon": "Bot icon",
"timezone": "Timezone", "timezone": "Timezone",
"toolHintMaxLength": "Tool hint length", "workspacePath": "Workspace default",
"workspacePath": "Workspace path", "localServiceAccess": "Local services",
"heartbeat": "Heartbeat", "webuiDefaultAccess": "Default access",
"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",
"currentModel": "Model saat ini", "currentModel": "Model saat ini",
"brandLogos": "Logo merek", "brandLogos": "Logo merek",
"cliAppsCatalog": "Katalog aplikasi CLI", "cliAppsCatalog": "Katalog aplikasi CLI",
"cliAppsFilter": "Filter aplikasi CLI" "cliAppsFilter": "Filter aplikasi CLI",
"engine": "Engine",
"logs": "Log",
"diagnostics": "Diagnostik"
}, },
"help": { "help": {
"theme": "Beralih antara tampilan terang dan gelap.", "theme": "Beralih antara tampilan terang dan gelap.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "Digunakan saat prompt tidak memilih rasio aspek.", "defaultAspectRatio": "Digunakan saat prompt tidak memilih rasio aspek.",
"defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.", "defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.",
"maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.", "maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.", "botName": "Ditampilkan di tempat nanobot memakai nama tampilan.",
"botIcon": "Short emoji or text shown beside the bot name.", "botIcon": "Emoji atau teks pendek yang tampil bersama nama bot.",
"timezone": "IANA timezone used by runtime context and schedules.", "timezone": "Dipakai untuk jadwal dan balasan yang peka waktu.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Pilih model yang digunakan nanobot untuk balasan berikutnya.", "currentModel": "Pilih model yang digunakan nanobot untuk balasan berikutnya.",
"selectedModelProvider": "Ditentukan oleh model yang dipilih.", "selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.", "selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.", "brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
"cliAppsCatalog": "Jelajahi CLI aplikasi yang dapat dijalankan nanobot secara lokal.", "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": { "values": {
"light": "Terang", "light": "Terang",
@ -201,8 +191,12 @@
"expanded": "Expanded", "expanded": "Expanded",
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured", "configured": "Configured",
"notConfigured": "Not configured" "notConfigured": "Not configured",
"pending": "Tertunda",
"restartingEngine": "Memulai ulang"
}, },
"status": { "status": {
"loading": "Memuat pengaturan...", "loading": "Memuat pengaturan...",
@ -212,14 +206,24 @@
"savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.", "savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.",
"restartAfterSaving": "Simpan perubahan, lalu restart saat siap.", "restartAfterSaving": "Simpan perubahan, lalu restart saat siap.",
"savedRestartApply": "Tersimpan. 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": { "actions": {
"save": "Simpan", "save": "Simpan",
"saving": "Menyimpan", "saving": "Menyimpan",
"edit": "Edit", "edit": "Edit",
"cancel": "Batal", "cancel": "Batal",
"openDocs": "Open docs" "open": "Buka",
"export": "Ekspor",
"opening": "Opening...",
"exporting": "Exporting..."
}, },
"byok": { "byok": {
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.", "description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.",
@ -290,6 +294,7 @@
"newConfiguration": "Konfigurasi model baru", "newConfiguration": "Konfigurasi model baru",
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.", "newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
"configurationName": "Nama", "configurationName": "Nama",
"configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.",
"configurationNamePlaceholder": "Penulisan cepat" "configurationNamePlaceholder": "Penulisan cepat"
}, },
"timezone": { "timezone": {
@ -397,6 +402,19 @@
"featured": "Unggulan", "featured": "Unggulan",
"loading": "Memuat aplikasi...", "loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok dengan filter ini." "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": { "chat": {
@ -404,7 +422,10 @@
"loading": "Memuat…", "loading": "Memuat…",
"noSessions": "Belum ada sesi.", "noSessions": "Belum ada sesi.",
"showMore": "Tampilkan {{count}} lagi", "showMore": "Tampilkan {{count}} lagi",
"collapsed": "{{count}} obrolan diciutkan",
"showLess": "Tampilkan lebih sedikit",
"actions": "Aksi obrolan untuk {{title}}", "actions": "Aksi obrolan untuk {{title}}",
"newInProject": "Mulai obrolan baru di {{project}}",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat", "renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.", "renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name", "renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save", "renameSave": "Save",
"archive": "Archive", "archive": "Archive",
"unarchive": "Unarchive", "unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": { "groups": {
"pinned": "Pinned", "pinned": "Pinned",
"all": "Chats", "all": "Chats",
"projects": "Projects",
"today": "Today", "today": "Today",
"yesterday": "Yesterday", "yesterday": "Yesterday",
"earlier": "Earlier", "earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": { "thread": {
"loadingConversation": "Memuat percakapan…", "loadingConversation": "Memuat percakapan…",
"empty": { "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": { "quickActions": {
"plan": { "plan": {
"title": "Buat rencana proyek", "title": "Buat rencana proyek",
@ -632,6 +662,13 @@
"mcpBadge": "MCP", "mcpBadge": "MCP",
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal", "cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
"mcpDescription": "Gunakan @{{name}} sebagai server MCP" "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", "scrollToBottom": "Gulir ke bawah",
@ -690,6 +727,19 @@
"messageTooBig": { "messageTooBig": {
"title": "Pesan terlalu besar", "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." "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": "システム", "section": "システム",
"restartHint": "実行時の変更を適用するには nanobot を再起動します。", "restartHint": "実行時の変更を適用するには nanobot を再起動します。",
"restart": "nanobot を再起動", "restart": "nanobot を再起動",
"restarting": "再起動中..." "restarting": "再起動中...",
"restartEngine": "エンジンを再起動",
"restartingEngine": "エンジンを再起動中..."
}, },
"restart": { "restart": {
"completed": "{{seconds}} 秒で再起動が完了しました。" "completed": "{{seconds}} 秒で再起動が完了しました。"
@ -40,25 +42,13 @@
}, },
"sidebar": { "sidebar": {
"navigation": "サイドバーのナビゲーション", "navigation": "サイドバーのナビゲーション",
"globalActions": "グローバル操作",
"collapse": "サイドバーを閉じる", "collapse": "サイドバーを閉じる",
"toggleTheme": "テーマを切り替える",
"home": "ホーム",
"newChat": "新しいチャット", "newChat": "新しいチャット",
"searchAria": "検索", "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": "検索", "searchPlaceholder": "検索",
"searchResults": "検索結果", "searchResults": "検索結果",
"noSearchResults": "一致するチャットはありません。", "noSearchResults": "一致するチャットはありません。",
"recent": "最近のチャット", "recent": "最近のチャット",
"refreshSessions": "セッションを更新",
"settings": "設定", "settings": "設定",
"language": { "language": {
"label": "言語", "label": "言語",
@ -80,9 +70,9 @@
"models": "Models", "models": "Models",
"providers": "Providers", "providers": "Providers",
"image": "Image", "image": "Image",
"web": "Web", "browser": "Web",
"runtime": "Runtime", "runtime": "システム",
"advanced": "Advanced", "advanced": "Security",
"cliApps": "CLI アプリ", "cliApps": "CLI アプリ",
"mcp": "MCP", "mcp": "MCP",
"apps": "アプリ" "apps": "アプリ"
@ -99,12 +89,13 @@
"webSearch": "Web search", "webSearch": "Web search",
"webBehavior": "Behavior", "webBehavior": "Behavior",
"identity": "Identity", "identity": "Identity",
"safety": "Safety", "webuiSafety": "Web safety",
"capabilities": "機能", "capabilities": "機能",
"integrations": "Integrations",
"cliApps": "CLI アプリ", "cliApps": "CLI アプリ",
"mcp": "MCP サービス", "mcp": "MCP サービス",
"apps": "アプリ" "apps": "アプリ",
"nativeHost": "App",
"hostSafety": "App safety"
}, },
"rows": { "rows": {
"theme": "テーマ", "theme": "テーマ",
@ -137,22 +128,16 @@
"botName": "Bot name", "botName": "Bot name",
"botIcon": "Bot icon", "botIcon": "Bot icon",
"timezone": "Timezone", "timezone": "Timezone",
"toolHintMaxLength": "Tool hint length", "workspacePath": "デフォルトワークスペース",
"workspacePath": "Workspace path", "localServiceAccess": "Local services",
"heartbeat": "Heartbeat", "webuiDefaultAccess": "Default access",
"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",
"currentModel": "現在のモデル", "currentModel": "現在のモデル",
"brandLogos": "ブランドロゴ", "brandLogos": "ブランドロゴ",
"cliAppsCatalog": "CLI アプリカタログ", "cliAppsCatalog": "CLI アプリカタログ",
"cliAppsFilter": "CLI アプリフィルター" "cliAppsFilter": "CLI アプリフィルター",
"engine": "エンジン",
"logs": "ログ",
"diagnostics": "診断"
}, },
"help": { "help": {
"theme": "ライト表示とダーク表示を切り替えます。", "theme": "ライト表示とダーク表示を切り替えます。",
@ -175,17 +160,22 @@
"defaultAspectRatio": "プロンプトでアスペクト比が指定されていない場合に使用します。", "defaultAspectRatio": "プロンプトでアスペクト比が指定されていない場合に使用します。",
"defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。", "defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。",
"maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。", "maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。",
"botName": "Shown in runtime surfaces that use the configured bot identity.", "botName": "nanobot が表示名を使う場所に表示されます。",
"botIcon": "Short emoji or text shown beside the bot name.", "botIcon": "Bot 名の横に表示する短い emoji またはテキストです。",
"timezone": "IANA timezone used by runtime context and schedules.", "timezone": "スケジュールと時刻を考慮する返信に使用します。",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "今後の返信で nanobot が使用するモデルを選択します。", "currentModel": "今後の返信で nanobot が使用するモデルを選択します。",
"selectedModelProvider": "選択したモデルによって設定されます。", "selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。", "selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。", "brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。", "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": { "values": {
"light": "ライト", "light": "ライト",
@ -201,8 +191,12 @@
"expanded": "Expanded", "expanded": "Expanded",
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured", "configured": "Configured",
"notConfigured": "Not configured" "notConfigured": "Not configured",
"pending": "保留中",
"restartingEngine": "再起動中"
}, },
"status": { "status": {
"loading": "設定を読み込んでいます...", "loading": "設定を読み込んでいます...",
@ -212,14 +206,24 @@
"savedRestart": "保存しました。反映するには nanobot を再起動してください。", "savedRestart": "保存しました。反映するには nanobot を再起動してください。",
"restartAfterSaving": "変更を保存してから、準備ができたら再起動してください。", "restartAfterSaving": "変更を保存してから、準備ができたら再起動してください。",
"savedRestartApply": "保存しました。準備ができたら再起動してください。", "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": { "actions": {
"save": "保存", "save": "保存",
"saving": "保存中", "saving": "保存中",
"edit": "編集", "edit": "編集",
"cancel": "キャンセル", "cancel": "キャンセル",
"openDocs": "Open docs" "open": "開く",
"export": "書き出す",
"opening": "Opening...",
"exporting": "Exporting..."
}, },
"byok": { "byok": {
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。", "description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
@ -290,6 +294,7 @@
"newConfiguration": "新しいモデル設定", "newConfiguration": "新しいモデル設定",
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。", "newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
"configurationName": "名前", "configurationName": "名前",
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
"configurationNamePlaceholder": "高速ライティング" "configurationNamePlaceholder": "高速ライティング"
}, },
"timezone": { "timezone": {
@ -397,6 +402,19 @@
"featured": "おすすめ", "featured": "おすすめ",
"loading": "アプリを読み込み中...", "loading": "アプリを読み込み中...",
"empty": "このフィルターに一致するアプリはありません。" "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": { "chat": {
@ -404,7 +422,10 @@
"loading": "読み込み中…", "loading": "読み込み中…",
"noSessions": "まだセッションがありません。", "noSessions": "まだセッションがありません。",
"showMore": "さらに {{count}} 件表示", "showMore": "さらに {{count}} 件表示",
"collapsed": "{{count}} 件のチャットを折りたたみ中",
"showLess": "折りたたむ",
"actions": "「{{title}}」のチャット操作", "actions": "「{{title}}」のチャット操作",
"newInProject": "「{{project}}」で新しいチャットを開始",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat", "renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.", "renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name", "renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save", "renameSave": "Save",
"archive": "Archive", "archive": "Archive",
"unarchive": "Unarchive", "unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": { "groups": {
"pinned": "Pinned", "pinned": "Pinned",
"all": "Chats", "all": "Chats",
"projects": "Projects",
"today": "Today", "today": "Today",
"yesterday": "Yesterday", "yesterday": "Yesterday",
"earlier": "Earlier", "earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": { "thread": {
"loadingConversation": "会話を読み込み中…", "loadingConversation": "会話を読み込み中…",
"empty": { "empty": {
"greeting": "何をお手伝いしましょうか?", "greetings": {
"workOn": "一緒に何に取り組みましょうか?",
"start": "今日はどこから始めましょう?",
"build": "今日は何を作りましょうか?",
"tackle": "一緒に何を解決しましょう?"
},
"quickActions": { "quickActions": {
"plan": { "plan": {
"title": "プロジェクト計画を作成", "title": "プロジェクト計画を作成",
@ -632,6 +662,13 @@
"mcpBadge": "MCP", "mcpBadge": "MCP",
"cliDescription": "@{{name}} をローカル CLI アプリとして使用", "cliDescription": "@{{name}} をローカル CLI アプリとして使用",
"mcpDescription": "@{{name}} を MCP サーバーとして使用" "mcpDescription": "@{{name}} を MCP サーバーとして使用"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "プロジェクトを選択",
"projectPlaceholder": "プロジェクトを選択",
"default": "Default Permission",
"full": "Full Access"
} }
}, },
"scrollToBottom": "一番下へスクロール", "scrollToBottom": "一番下へスクロール",
@ -690,6 +727,19 @@
"messageTooBig": { "messageTooBig": {
"title": "メッセージが大きすぎます", "title": "メッセージが大きすぎます",
"body": "サイズ上限を超えたため、直前のメッセージはサーバーに拒否されました。画像を減らすか、より小さいファイルに差し替えて再送してください。" "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": "시스템", "section": "시스템",
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.", "restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
"restart": "nanobot 다시 시작", "restart": "nanobot 다시 시작",
"restarting": "다시 시작 중..." "restarting": "다시 시작 중...",
"restartEngine": "엔진 다시 시작",
"restartingEngine": "엔진 다시 시작 중..."
}, },
"restart": { "restart": {
"completed": "{{seconds}}초 만에 다시 시작되었습니다." "completed": "{{seconds}}초 만에 다시 시작되었습니다."
@ -40,25 +42,13 @@
}, },
"sidebar": { "sidebar": {
"navigation": "사이드바 탐색", "navigation": "사이드바 탐색",
"globalActions": "전역 작업",
"collapse": "사이드바 접기", "collapse": "사이드바 접기",
"toggleTheme": "테마 전환",
"home": "홈",
"newChat": "새 채팅", "newChat": "새 채팅",
"searchAria": "검색", "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": "검색", "searchPlaceholder": "검색",
"searchResults": "결과", "searchResults": "결과",
"noSearchResults": "일치하는 채팅이 없습니다.", "noSearchResults": "일치하는 채팅이 없습니다.",
"recent": "최근 대화", "recent": "최근 대화",
"refreshSessions": "세션 새로고침",
"settings": "설정", "settings": "설정",
"language": { "language": {
"label": "언어", "label": "언어",
@ -80,9 +70,9 @@
"models": "Models", "models": "Models",
"providers": "Providers", "providers": "Providers",
"image": "Image", "image": "Image",
"web": "Web", "browser": "Web",
"runtime": "Runtime", "runtime": "시스템",
"advanced": "Advanced", "advanced": "Security",
"cliApps": "CLI 앱", "cliApps": "CLI 앱",
"mcp": "MCP", "mcp": "MCP",
"apps": "앱" "apps": "앱"
@ -99,12 +89,13 @@
"webSearch": "Web search", "webSearch": "Web search",
"webBehavior": "Behavior", "webBehavior": "Behavior",
"identity": "Identity", "identity": "Identity",
"safety": "Safety", "webuiSafety": "Web safety",
"capabilities": "기능", "capabilities": "기능",
"integrations": "Integrations",
"cliApps": "CLI 앱", "cliApps": "CLI 앱",
"mcp": "MCP 서비스", "mcp": "MCP 서비스",
"apps": "앱" "apps": "앱",
"nativeHost": "App",
"hostSafety": "App safety"
}, },
"rows": { "rows": {
"theme": "테마", "theme": "테마",
@ -137,22 +128,16 @@
"botName": "Bot name", "botName": "Bot name",
"botIcon": "Bot icon", "botIcon": "Bot icon",
"timezone": "Timezone", "timezone": "Timezone",
"toolHintMaxLength": "Tool hint length", "workspacePath": "기본 작업공간",
"workspacePath": "Workspace path", "localServiceAccess": "Local services",
"heartbeat": "Heartbeat", "webuiDefaultAccess": "Default access",
"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",
"currentModel": "현재 모델", "currentModel": "현재 모델",
"brandLogos": "브랜드 로고", "brandLogos": "브랜드 로고",
"cliAppsCatalog": "CLI 앱 카탈로그", "cliAppsCatalog": "CLI 앱 카탈로그",
"cliAppsFilter": "CLI 앱 필터" "cliAppsFilter": "CLI 앱 필터",
"engine": "엔진",
"logs": "로그",
"diagnostics": "진단"
}, },
"help": { "help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.", "theme": "밝은 모드와 어두운 모드를 전환합니다.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "프롬프트에서 가로세로 비율을 선택하지 않았을 때 사용됩니다.", "defaultAspectRatio": "프롬프트에서 가로세로 비율을 선택하지 않았을 때 사용됩니다.",
"defaultImageSize": "지원하는 제공자에게 보내는 크기 힌트입니다.", "defaultImageSize": "지원하는 제공자에게 보내는 크기 힌트입니다.",
"maxImagesPerTurn": "한 번의 generate_image 요청에 대한 상한입니다.", "maxImagesPerTurn": "한 번의 generate_image 요청에 대한 상한입니다.",
"botName": "Shown in runtime surfaces that use the configured bot identity.", "botName": "nanobot이 표시 이름을 쓰는 곳에 표시됩니다.",
"botIcon": "Short emoji or text shown beside the bot name.", "botIcon": "Bot 이름 옆에 표시할 짧은 emoji 또는 텍스트입니다.",
"timezone": "IANA timezone used by runtime context and schedules.", "timezone": "예약과 시간 인식 답변에 사용됩니다.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.", "currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.",
"selectedModelProvider": "선택한 모델에서 설정됩니다.", "selectedModelProvider": "선택한 모델에서 설정됩니다.",
"selectedModelValue": "선택한 모델에서 설정됩니다.", "selectedModelValue": "선택한 모델에서 설정됩니다.",
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.", "brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.", "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": { "values": {
"light": "라이트", "light": "라이트",
@ -201,8 +191,12 @@
"expanded": "Expanded", "expanded": "Expanded",
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured", "configured": "Configured",
"notConfigured": "Not configured" "notConfigured": "Not configured",
"pending": "대기 중",
"restartingEngine": "다시 시작 중"
}, },
"status": { "status": {
"loading": "설정을 불러오는 중...", "loading": "설정을 불러오는 중...",
@ -212,14 +206,24 @@
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요.", "savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요.",
"restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.", "restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.",
"savedRestartApply": "저장되었습니다. 준비되면 재시작하세요.", "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": { "actions": {
"save": "저장", "save": "저장",
"saving": "저장 중", "saving": "저장 중",
"edit": "편집", "edit": "편집",
"cancel": "취소", "cancel": "취소",
"openDocs": "Open docs" "open": "열기",
"export": "내보내기",
"opening": "Opening...",
"exporting": "Exporting..."
}, },
"byok": { "byok": {
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.", "description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
@ -290,6 +294,7 @@
"newConfiguration": "새 모델 구성", "newConfiguration": "새 모델 구성",
"newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.", "newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
"configurationName": "이름", "configurationName": "이름",
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
"configurationNamePlaceholder": "빠른 글쓰기" "configurationNamePlaceholder": "빠른 글쓰기"
}, },
"timezone": { "timezone": {
@ -397,6 +402,19 @@
"featured": "추천", "featured": "추천",
"loading": "앱 불러오는 중...", "loading": "앱 불러오는 중...",
"empty": "이 필터와 일치하는 앱이 없습니다." "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": { "chat": {
@ -404,7 +422,10 @@
"loading": "불러오는 중…", "loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.", "noSessions": "아직 세션이 없습니다.",
"showMore": "{{count}}개 더 보기", "showMore": "{{count}}개 더 보기",
"collapsed": "{{count}}개 채팅 접힘",
"showLess": "접기",
"actions": "{{title}} 채팅 작업", "actions": "{{title}} 채팅 작업",
"newInProject": "{{project}}에서 새 채팅 시작",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat", "renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.", "renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name", "renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save", "renameSave": "Save",
"archive": "Archive", "archive": "Archive",
"unarchive": "Unarchive", "unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": { "groups": {
"pinned": "Pinned", "pinned": "Pinned",
"all": "Chats", "all": "Chats",
"projects": "Projects",
"today": "Today", "today": "Today",
"yesterday": "Yesterday", "yesterday": "Yesterday",
"earlier": "Earlier", "earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": { "thread": {
"loadingConversation": "대화 불러오는 중…", "loadingConversation": "대화 불러오는 중…",
"empty": { "empty": {
"greeting": "무엇을 도와드릴까요?", "greetings": {
"workOn": "우리 무엇을 함께 해볼까요?",
"start": "오늘은 어디서 시작할까요?",
"build": "오늘은 무엇을 만들어볼까요?",
"tackle": "함께 무엇을 해결할까요?"
},
"quickActions": { "quickActions": {
"plan": { "plan": {
"title": "프로젝트 계획 만들기", "title": "프로젝트 계획 만들기",
@ -632,6 +662,13 @@
"mcpBadge": "MCP", "mcpBadge": "MCP",
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용", "cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
"mcpDescription": "@{{name}}을 MCP 서버로 사용" "mcpDescription": "@{{name}}을 MCP 서버로 사용"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "프로젝트 선택",
"projectPlaceholder": "프로젝트 선택",
"default": "Default Permission",
"full": "Full Access"
} }
}, },
"scrollToBottom": "맨 아래로 스크롤", "scrollToBottom": "맨 아래로 스크롤",
@ -690,6 +727,19 @@
"messageTooBig": { "messageTooBig": {
"title": "메시지가 너무 큽니다", "title": "메시지가 너무 큽니다",
"body": "마지막 메시지가 서버의 크기 제한을 초과하여 거부되었습니다. 이미지를 줄이거나 더 작은 파일로 바꿔서 다시 보내 주세요." "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", "section": "Hệ thống",
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.", "restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
"restart": "Khởi động lại nanobot", "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": { "restart": {
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây." "completed": "Khởi động lại hoàn tất sau {{seconds}} giây."
@ -40,25 +42,13 @@
}, },
"sidebar": { "sidebar": {
"navigation": "Điều hướng thanh bên", "navigation": "Điều hướng thanh bên",
"globalActions": "Hành động toàn cục",
"collapse": "Thu gọn thanh bên", "collapse": "Thu gọn thanh bên",
"toggleTheme": "Chuyển giao diện",
"home": "Trang chủ",
"newChat": "Cuộc trò chuyện mới", "newChat": "Cuộc trò chuyện mới",
"searchAria": "Tìm kiếm", "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", "searchPlaceholder": "Tìm kiếm",
"searchResults": "Kết quả", "searchResults": "Kết quả",
"noSearchResults": "Không có cuộc trò chuyện phù hợp.", "noSearchResults": "Không có cuộc trò chuyện phù hợp.",
"recent": "Gần đây", "recent": "Gần đây",
"refreshSessions": "Làm mới phiên",
"settings": "Cài đặt", "settings": "Cài đặt",
"language": { "language": {
"label": "Ngôn ngữ", "label": "Ngôn ngữ",
@ -80,9 +70,9 @@
"models": "Models", "models": "Models",
"providers": "Providers", "providers": "Providers",
"image": "Image", "image": "Image",
"web": "Web", "browser": "Web",
"runtime": "Runtime", "runtime": "Hệ thống",
"advanced": "Advanced", "advanced": "Security",
"cliApps": "Ứng dụng CLI", "cliApps": "Ứng dụng CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Ứng dụng" "apps": "Ứng dụng"
@ -99,12 +89,13 @@
"webSearch": "Web search", "webSearch": "Web search",
"webBehavior": "Behavior", "webBehavior": "Behavior",
"identity": "Identity", "identity": "Identity",
"safety": "Safety", "webuiSafety": "Web safety",
"capabilities": "Khả năng", "capabilities": "Khả năng",
"integrations": "Integrations",
"cliApps": "Ứng dụng CLI", "cliApps": "Ứng dụng CLI",
"mcp": "Dịch vụ MCP", "mcp": "Dịch vụ MCP",
"apps": "Ứng dụng" "apps": "Ứng dụng",
"nativeHost": "Native host",
"hostSafety": "App safety"
}, },
"rows": { "rows": {
"theme": "Giao diện", "theme": "Giao diện",
@ -137,22 +128,16 @@
"botName": "Bot name", "botName": "Bot name",
"botIcon": "Bot icon", "botIcon": "Bot icon",
"timezone": "Timezone", "timezone": "Timezone",
"toolHintMaxLength": "Tool hint length", "workspacePath": "Workspace mặc định",
"workspacePath": "Workspace path", "localServiceAccess": "Local services",
"heartbeat": "Heartbeat", "webuiDefaultAccess": "Default access",
"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",
"currentModel": "Mô hình hiện tại", "currentModel": "Mô hình hiện tại",
"brandLogos": "Logo thương hiệu", "brandLogos": "Logo thương hiệu",
"cliAppsCatalog": "Danh mục ứng dụng CLI", "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": { "help": {
"theme": "Chuyển giữa giao diện sáng và tối.", "theme": "Chuyển giữa giao diện sáng và tối.",
@ -175,17 +160,22 @@
"defaultAspectRatio": "Được dùng khi prompt không chọn tỷ lệ khung hình.", "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ợ.", "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.", "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.", "botName": "Hiển thị ở nơi nanobot dùng tên hiển thị.",
"botIcon": "Short emoji or text shown beside the bot name.", "botIcon": "Emoji hoặc văn bản ngắn hiển thị cùng tên bot.",
"timezone": "IANA timezone used by runtime context and schedules.", "timezone": "Dùng cho lịch hẹn và câu trả lời có yếu tố thời gian.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Chọn mô hình nanobot dùng cho các câu trả lời mới.", "currentModel": "Chọn 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.", "selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Đượ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.", "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ộ.", "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": { "values": {
"light": "Sáng", "light": "Sáng",
@ -201,8 +191,12 @@
"expanded": "Expanded", "expanded": "Expanded",
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured", "configured": "Configured",
"notConfigured": "Not configured" "notConfigured": "Not configured",
"pending": "Đang chờ",
"restartingEngine": "Đang khởi động lại"
}, },
"status": { "status": {
"loading": "Đang tải cài đặt...", "loading": "Đang tải cài đặt...",
@ -212,14 +206,24 @@
"savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng.", "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.", "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.", "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": { "actions": {
"save": "Lưu", "save": "Lưu",
"saving": "Đang lưu", "saving": "Đang lưu",
"edit": "Sửa", "edit": "Sửa",
"cancel": "Hủy", "cancel": "Hủy",
"openDocs": "Open docs" "open": "Mở",
"export": "Xuất",
"opening": "Opening...",
"exporting": "Exporting..."
}, },
"byok": { "byok": {
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.", "description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.",
@ -290,6 +294,7 @@
"newConfiguration": "Cấu hình mô hình mới", "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.", "newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
"configurationName": "Tên", "configurationName": "Tên",
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
"configurationNamePlaceholder": "Viết nhanh" "configurationNamePlaceholder": "Viết nhanh"
}, },
"timezone": { "timezone": {
@ -397,6 +402,19 @@
"featured": "Nổi bật", "featured": "Nổi bật",
"loading": "Đang tải ứng dụng...", "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." "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": { "chat": {
@ -404,7 +422,10 @@
"loading": "Đang tải…", "loading": "Đang tải…",
"noSessions": "Chưa có phiên nào.", "noSessions": "Chưa có phiên nào.",
"showMore": "Hiển thị thêm {{count}}", "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}}", "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": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished"
@ -415,6 +436,9 @@
"renameTitle": "Rename chat", "renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.", "renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name", "renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save", "renameSave": "Save",
"archive": "Archive", "archive": "Archive",
"unarchive": "Unarchive", "unarchive": "Unarchive",
@ -425,6 +449,7 @@
"groups": { "groups": {
"pinned": "Pinned", "pinned": "Pinned",
"all": "Chats", "all": "Chats",
"projects": "Projects",
"today": "Today", "today": "Today",
"yesterday": "Yesterday", "yesterday": "Yesterday",
"earlier": "Earlier", "earlier": "Earlier",
@ -448,7 +473,12 @@
"thread": { "thread": {
"loadingConversation": "Đang tải cuộc trò chuyện…", "loadingConversation": "Đang tải cuộc trò chuyện…",
"empty": { "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": { "quickActions": {
"plan": { "plan": {
"title": "Tạo kế hoạch dự án", "title": "Tạo kế hoạch dự án",
@ -632,6 +662,13 @@
"mcpBadge": "MCP", "mcpBadge": "MCP",
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ", "cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
"mcpDescription": "Dùng @{{name}} như máy chủ MCP" "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", "scrollToBottom": "Cuộn xuống cuối",
@ -690,6 +727,19 @@
"messageTooBig": { "messageTooBig": {
"title": "Tin nhắn quá lớn", "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." "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": "系统", "section": "系统",
"restartHint": "重启 nanobot 以应用运行时更改。", "restartHint": "重启 nanobot 以应用运行时更改。",
"restart": "重启 nanobot", "restart": "重启 nanobot",
"restarting": "正在重启..." "restarting": "正在重启...",
"restartEngine": "重启引擎",
"restartingEngine": "正在重启引擎..."
}, },
"restart": { "restart": {
"completed": "重启已完成,用时 {{seconds}} 秒。" "completed": "重启已完成,用时 {{seconds}} 秒。"
@ -40,25 +42,13 @@
}, },
"sidebar": { "sidebar": {
"navigation": "侧边栏导航", "navigation": "侧边栏导航",
"globalActions": "全局操作",
"collapse": "收起侧边栏", "collapse": "收起侧边栏",
"toggleTheme": "切换主题",
"home": "首页",
"newChat": "新建对话", "newChat": "新建对话",
"searchAria": "搜索", "searchAria": "搜索",
"viewOptions": "视图",
"compactList": "紧凑列表",
"showPreviews": "显示预览",
"showTimestamps": "显示时间",
"sortLabel": "排序",
"sortUpdated": "最近更新",
"sortCreated": "最近创建",
"sortTitle": "标题 A-Z",
"searchPlaceholder": "搜索", "searchPlaceholder": "搜索",
"searchResults": "搜索结果", "searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。", "noSearchResults": "没有匹配的会话。",
"recent": "最近对话", "recent": "最近对话",
"refreshSessions": "刷新会话",
"settings": "设置", "settings": "设置",
"language": { "language": {
"label": "语言", "label": "语言",
@ -80,11 +70,11 @@
"models": "模型", "models": "模型",
"providers": "提供商", "providers": "提供商",
"image": "图片", "image": "图片",
"web": "网页", "browser": "网页",
"cliApps": "CLI 应用", "cliApps": "CLI 应用",
"mcp": "MCP", "mcp": "MCP",
"runtime": "运行时", "runtime": "系统",
"advanced": "高级", "advanced": "安全",
"apps": "应用" "apps": "应用"
}, },
"sections": { "sections": {
@ -101,10 +91,11 @@
"cliApps": "CLI 应用", "cliApps": "CLI 应用",
"mcp": "MCP 服务", "mcp": "MCP 服务",
"identity": "身份", "identity": "身份",
"safety": "安全", "webuiSafety": "网页端安全",
"capabilities": "能力", "capabilities": "能力",
"integrations": "集成", "apps": "应用",
"apps": "应用" "nativeHost": "App",
"hostSafety": "App 安全"
}, },
"models": { "models": {
"selectModel": "选择模型", "selectModel": "选择模型",
@ -112,6 +103,7 @@
"newConfiguration": "新建模型配置", "newConfiguration": "新建模型配置",
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。", "newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
"configurationName": "名称", "configurationName": "名称",
"configurationNameHelp": "重命名这个已保存的模型配置。",
"configurationNamePlaceholder": "快速写作" "configurationNamePlaceholder": "快速写作"
}, },
"rows": { "rows": {
@ -147,20 +139,14 @@
"botName": "Bot 名称", "botName": "Bot 名称",
"botIcon": "Bot 图标", "botIcon": "Bot 图标",
"timezone": "时区", "timezone": "时区",
"toolHintMaxLength": "工具提示长度", "workspacePath": "默认工作区",
"workspacePath": "工作区路径", "localServiceAccess": "本机服务",
"heartbeat": "Heartbeat", "webuiDefaultAccess": "默认权限",
"dream": "Dream",
"unifiedSession": "统一会话",
"restrictWorkspace": "限制在工作区内",
"execTool": "Exec 工具",
"execSandbox": "Exec 沙箱",
"ssrfWhitelist": "SSRF 白名单",
"mcpServers": "MCP 服务器",
"pathAppend": "PATH 追加",
"cliAppsCatalog": "目录", "cliAppsCatalog": "目录",
"cliAppsFilter": "筛选", "cliAppsFilter": "筛选",
"configurationDocs": "配置文档" "engine": "引擎",
"logs": "日志",
"diagnostics": "诊断"
}, },
"help": { "help": {
"theme": "在浅色和深色外观之间切换。", "theme": "在浅色和深色外观之间切换。",
@ -187,13 +173,18 @@
"defaultAspectRatio": "当提示词没有选择比例时使用。", "defaultAspectRatio": "当提示词没有选择比例时使用。",
"defaultImageSize": "发送给支持该能力的服务商的尺寸提示。", "defaultImageSize": "发送给支持该能力的服务商的尺寸提示。",
"maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。", "maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。",
"botName": "显示在使用 bot 身份的运行时界面里。", "botName": "显示在 nanobot 使用名称的地方。",
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。", "botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
"timezone": "运行时上下文和计划任务使用的 IANA 时区。", "timezone": "用于计划任务和需要时间感知的回复。",
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。", "cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
"cliAppsFilter": "按应用、分类或能力搜索。", "cliAppsFilter": "按应用、分类或能力搜索。",
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。" "localServiceAccess": "允许完全访问模式下的 shell 命令访问 localhost 服务。",
"webuiDefaultAccess": "用于没有单独选择权限的网页端对话。",
"securityManagedControls": "网页抓取始终保护本机、内网和元数据服务。核心渠道安全仍由 config.json 管理。",
"logs": "打开App引擎日志文件夹。",
"diagnostics": "导出一份用于支持排查的运行时报告。",
"localServiceAccessNative": "允许完全访问模式下的 shell 命令访问这台 Mac 上的服务。",
"webuiDefaultAccessNative": "用于没有单独选择权限的原生 App 对话。"
}, },
"timezone": { "timezone": {
"select": "选择时区", "select": "选择时区",
@ -298,8 +289,12 @@
"expanded": "展开", "expanded": "展开",
"on": "开", "on": "开",
"off": "关", "off": "关",
"defaultPermission": "默认权限",
"fullAccess": "完全访问",
"configured": "已配置", "configured": "已配置",
"notConfigured": "未配置" "notConfigured": "未配置",
"pending": "待应用",
"restartingEngine": "正在重启"
}, },
"status": { "status": {
"loading": "正在加载设置...", "loading": "正在加载设置...",
@ -309,14 +304,24 @@
"savedRestart": "已保存。重启 nanobot 后生效。", "savedRestart": "已保存。重启 nanobot 后生效。",
"restartAfterSaving": "保存后,可在合适时重启。", "restartAfterSaving": "保存后,可在合适时重启。",
"savedRestartApply": "已保存,可稍后重启。", "savedRestartApply": "已保存,可稍后重启。",
"imageProviderRestart": "图片服务商改动已保存,可稍后重启。" "imageProviderRestart": "图片服务商改动已保存,可稍后重启。",
"hostRestartAfterSaving": "保存后nanobot 会自动重启引擎。",
"hostRestartPending": "已保存,将在合适时重启引擎。",
"hostApiUnavailable": "宿主操作只能在原生 App 内使用。",
"logsOpened": "已打开日志文件夹。",
"logsOpenFailed": "无法打开日志文件夹。",
"diagnosticsExported": "诊断已导出到 {{path}}。",
"diagnosticsExportFailed": "无法导出诊断。"
}, },
"actions": { "actions": {
"save": "保存", "save": "保存",
"saving": "保存中", "saving": "保存中",
"edit": "编辑", "edit": "编辑",
"cancel": "取消", "cancel": "取消",
"openDocs": "打开文档" "open": "打开",
"export": "导出",
"opening": "打开中...",
"exporting": "导出中..."
}, },
"byok": { "byok": {
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。", "description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
@ -397,6 +402,19 @@
"featured": "精选", "featured": "精选",
"loading": "正在加载应用...", "loading": "正在加载应用...",
"empty": "没有符合筛选条件的应用。" "empty": "没有符合筛选条件的应用。"
},
"oauth": {
"authentication": "OAuth 认证",
"signIn": "登录",
"signingIn": "正在登录…",
"signInAgain": "重新登录",
"signOut": "退出登录",
"signedInAs": "已登录为 {{account}}",
"signInHelp": "在这台设备上登录;不会把 API key 写入配置。",
"signInRequired": "需要登录",
"signInBeforeSaving": "先登录这个 OAuth 提供商,然后再保存为当前模型提供商。",
"signedIn": "已登录",
"notSignedIn": "未登录"
} }
}, },
"chat": { "chat": {
@ -404,7 +422,10 @@
"loading": "加载中…", "loading": "加载中…",
"noSessions": "还没有会话。", "noSessions": "还没有会话。",
"showMore": "再显示 {{count}} 个", "showMore": "再显示 {{count}} 个",
"collapsed": "已折叠 {{count}} 个对话",
"showLess": "收起",
"actions": "“{{title}}” 的会话操作", "actions": "“{{title}}” 的会话操作",
"newInProject": "在 {{project}} 中开始新对话",
"activity": { "activity": {
"running": "Agent 正在运行", "running": "Agent 正在运行",
"complete": "Agent 已完成" "complete": "Agent 已完成"
@ -415,6 +436,9 @@
"renameTitle": "重命名对话", "renameTitle": "重命名对话",
"renameDescription": "为这个对话设置一个仅用于 WebUI 侧边栏的名称。", "renameDescription": "为这个对话设置一个仅用于 WebUI 侧边栏的名称。",
"renamePlaceholder": "对话名称", "renamePlaceholder": "对话名称",
"renameProjectTitle": "重命名项目",
"renameProjectDescription": "为这个项目设置一个仅用于 WebUI 侧边栏的名称。",
"renameProjectPlaceholder": "项目名称",
"renameSave": "保存", "renameSave": "保存",
"archive": "归档", "archive": "归档",
"unarchive": "取消归档", "unarchive": "取消归档",
@ -425,6 +449,7 @@
"groups": { "groups": {
"pinned": "置顶", "pinned": "置顶",
"all": "对话", "all": "对话",
"projects": "项目",
"today": "今天", "today": "今天",
"yesterday": "昨天", "yesterday": "昨天",
"earlier": "更早", "earlier": "更早",
@ -448,7 +473,12 @@
"thread": { "thread": {
"loadingConversation": "正在加载对话…", "loadingConversation": "正在加载对话…",
"empty": { "empty": {
"greeting": "我可以帮你做什么?", "greetings": {
"workOn": "我们要一起做点什么?",
"start": "今天从哪里开始?",
"build": "今天一起构建什么?",
"tackle": "我们要一起解决什么?"
},
"quickActions": { "quickActions": {
"plan": { "plan": {
"title": "创建项目计划", "title": "创建项目计划",
@ -632,7 +662,14 @@
"too_large": "图片太大,请换一张小一点的", "too_large": "图片太大,请换一张小一点的",
"io": "无法读取该文件" "io": "无法读取该文件"
}, },
"goalStateCloseAria": "关闭目标" "goalStateCloseAria": "关闭目标",
"workspace": {
"accessAria": "工作区访问权限",
"projectAria": "选择项目",
"projectPlaceholder": "选择项目",
"default": "默认权限",
"full": "完全访问权限"
}
}, },
"scrollToBottom": "滚动到底部", "scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息" "loadEarlier": "加载更早消息"
@ -690,6 +727,19 @@
"messageTooBig": { "messageTooBig": {
"title": "消息过大", "title": "消息过大",
"body": "服务端因超过大小限制拒收了上一条消息。可移除部分图片或使用更小的图片后重试。" "body": "服务端因超过大小限制拒收了上一条消息。可移除部分图片或使用更小的图片后重试。"
},
"workspaceScopeRejected": {
"title": "工作区未更改",
"body": "网关拒绝了请求的项目或访问权限Nanobot 已继续使用之前的工作区。"
}
},
"workspace": {
"dialog": {
"defaultProject": "默认工作区",
"manual": "粘贴路径",
"manualPlaceholder": "/Users/name/project",
"usePath": "使用路径",
"absolutePathRequired": "请输入这台机器上的绝对文件夹路径。"
} }
} }
} }

View File

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

View File

@ -4,13 +4,17 @@ import type {
ImageGenerationSettingsUpdate, ImageGenerationSettingsUpdate,
McpPresetsPayload, McpPresetsPayload,
ModelConfigurationCreate, ModelConfigurationCreate,
ModelConfigurationUpdate,
NetworkSafetySettingsUpdate,
ProviderSettingsUpdate, ProviderSettingsUpdate,
SettingsPayload, SettingsPayload,
SettingsUpdate, SettingsUpdate,
SidebarStatePayload, SidebarStatePayload,
SlashCommand, SlashCommand,
WebSearchSettingsUpdate, WebSearchSettingsUpdate,
WorkspacesPayload,
WebuiThreadPersistedPayload, WebuiThreadPersistedPayload,
WorkspaceScopePayload,
} from "./types"; } from "./types";
export class ApiError extends Error { export class ApiError extends Error {
@ -38,6 +42,17 @@ async function request<T>(
if (!res.ok) { if (!res.ok) {
throw new ApiError(res.status, `HTTP ${res.status}`); 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; return (await res.json()) as T;
} }
@ -73,6 +88,7 @@ export async function listSessions(
title?: string; title?: string;
preview?: string; preview?: string;
run_started_at?: number | null; run_started_at?: number | null;
workspace_scope?: WorkspaceScopePayload | null;
}; };
const body = await request<{ sessions: Row[] }>( const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`, `${base}/api/sessions`,
@ -86,6 +102,7 @@ export async function listSessions(
title: s.title ?? "", title: s.title ?? "",
preview: s.preview ?? "", preview: s.preview ?? "",
runStartedAt: s.run_started_at ?? null, 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); 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( export async function fetchCliApps(
token: string, token: string,
base: 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( export async function updateProviderSettings(
token: string, token: string,
update: ProviderSettingsUpdate, 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( export async function updateWebSearchSettings(
token: string, token: string,
update: WebSearchSettingsUpdate, 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( export async function updateImageGenerationSettings(
token: string, token: string,
update: ImageGenerationSettingsUpdate, update: ImageGenerationSettingsUpdate,

View File

@ -64,12 +64,26 @@ export async function fetchBootstrap(
* matters because some WS servers dispatch handshakes based on the literal * matters because some WS servers dispatch handshakes based on the literal
* path, not a normalised form. * path, not a normalised form.
*/ */
export function deriveWsUrl(wsPath: string, token: string): string { export function deriveWsUrl(
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`; wsPath: string,
token: string,
wsUrl?: string | null,
): string {
const query = `?token=${encodeURIComponent(token)}`; 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") { if (typeof window === "undefined") {
return `ws://127.0.0.1:8765${path}${query}`; 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 scheme = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host; const host = window.location.host;
return `${scheme}://${host}${path}${query}`; 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, OutboundMcpPresetMention,
OutboundMedia, OutboundMedia,
GoalStateWsPayload, GoalStateWsPayload,
WorkspaceScopePayload,
} from "./types"; } from "./types";
/** WebSocket readyState constants, referenced by value to stay portable /** WebSocket readyState constants, referenced by value to stay portable
@ -57,22 +58,25 @@ type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void; type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void; type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateScope = "metadata" | "thread" | string; type SessionUpdateScope = "metadata" | "thread" | string;
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void; type SessionUpdateHandler = (
chatId: string,
scope?: SessionUpdateScope,
workspaceScope?: WorkspaceScopePayload,
) => void;
type RunStatusHandler = (chatId: string, startedAt: number | null) => 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 * Most entries are transport-level or protocol-level faults. Workspace scope
* those arrive as ``{event: "error"}`` messages via ``onChat``. These are * rejections are server application errors promoted here because they affect
* transport-level or protocol-level faults the UI should make visible so * controls outside the message stream and must be visible immediately.
* the user understands *why* their action failed (as opposed to silently
* reconnecting under the hood).
*/ */
export type StreamError = export type StreamError =
/** Server rejected the inbound frame as too large (WS close code 1009). /** Server rejected the inbound frame as too large (WS close code 1009).
* Typically means the user attached images whose base64 size exceeded * Typically means the user attached images whose base64 size exceeded
* ``maxMessageBytes`` on the server. */ * ``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; type ErrorHandler = (error: StreamError) => void;
@ -206,6 +210,13 @@ export class NanobotClient {
} }
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void { 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.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") { if (ev.status === "running" && typeof ev.started_at === "number") {
const previous = this.runStartedAtByChatId.get(chatId); 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. */ /** 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) { if (this.pendingNewChat) {
return Promise.reject(new Error("newChat already in flight")); return Promise.reject(new Error("newChat already in flight"));
} }
@ -291,7 +302,10 @@ export class NanobotClient {
reject(new Error("newChat timed out")); reject(new Error("newChat timed out"));
}, timeoutMs); }, timeoutMs);
this.pendingNewChat = { resolve, reject, timer }; 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; imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[]; cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[]; mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
}, },
): void { ): void {
this.knownChats.add(chatId); this.knownChats.add(chatId);
@ -321,11 +336,21 @@ export class NanobotClient {
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}), ...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}), ...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}), ...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
webui: true, webui: true,
}; };
this.queueSend(frame); 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 --------------------------------------------------------- // -- internals ---------------------------------------------------------
private setStatus(status: ConnectionStatus): void { private setStatus(status: ConnectionStatus): void {
@ -388,10 +413,23 @@ export class NanobotClient {
} }
if (parsed.event === "session_updated") { if (parsed.event === "session_updated") {
this.emitSessionUpdate(parsed.chat_id, parsed.scope); this.emitSessionUpdate(parsed.chat_id, parsed.scope, parsed.workspace_scope);
return; 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; const chatId = (parsed as { chat_id?: string }).chat_id;
if (chatId) { if (chatId) {
this.recordGoalStatusForRunStrip(chatId, parsed); 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) { 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> = { export const PROVIDER_BRAND_ALIASES: Record<string, string> = {
brave_search: "brave", brave_search: "brave",
byteplus_coding_plan: "byteplus", byteplus_coding_plan: "byteplus",
mimo: "xiaomi_mimo",
minimaxAnthropic: "minimax", minimaxAnthropic: "minimax",
minimax_anthropic: "minimax", minimax_anthropic: "minimax",
openai_codex: "openai", openai_codex: "openai",
xiaomi: "xiaomi_mimo",
volcengine_coding_plan: "volcengine", volcengine_coding_plan: "volcengine",
}; };
@ -127,7 +129,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
jina: brand("jina.ai", "#7C3AED", "J"), jina: brand("jina.ai", "#7C3AED", "J"),
kagi: brand("kagi.com", "#FFB319", "K"), kagi: brand("kagi.com", "#FFB319", "K"),
lm_studio: brand("lmstudio.ai", "#111827", "LM"), 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"), minimax: brand("minimax.io", "#111827", "MM"),
mistral: brand("mistral.ai", "#FA520F", "M"), mistral: brand("mistral.ai", "#FA520F", "M"),
moonshot: brand("moonshot.ai", "#111827", "MS"), moonshot: brand("moonshot.ai", "#111827", "MS"),
@ -146,7 +150,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
tavily: brand("tavily.com", "#111827", "T"), tavily: brand("tavily.com", "#111827", "T"),
volcengine: brand("volcengine.com", "#1664FF", "VE"), volcengine: brand("volcengine.com", "#1664FF", "VE"),
vllm: brand("vllm.ai", "#2563EB", "VL"), 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", [ zhipu: brand("z.ai", "#155EEF", "Z", [
"https://z-cdn.chatglm.cn/z-ai/static/logo.svg", "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
"https://www.google.com/s2/favicons?domain=z.ai&sz=64", "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; deleted: number;
approximate?: boolean; approximate?: boolean;
status: "editing" | "done" | "error"; status: "editing" | "done" | "error";
operation?: "edit" | "delete" | string;
binary?: boolean; binary?: boolean;
error?: string; error?: string;
pending?: boolean; pending?: boolean;
@ -139,6 +140,36 @@ export interface ChatSummary {
preview: string; preview: string;
/** Unix epoch seconds when this session currently has a turn in flight. */ /** Unix epoch seconds when this session currently has a turn in flight. */
runStartedAt?: number | null; 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"; export type SidebarDensity = "comfortable" | "compact";
@ -157,6 +188,7 @@ export interface SidebarStatePayload {
pinned_keys: string[]; pinned_keys: string[];
archived_keys: string[]; archived_keys: string[];
title_overrides: Record<string, string>; title_overrides: Record<string, string>;
project_name_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>; tags_by_key: Record<string, string[]>;
collapsed_groups: Record<string, boolean>; collapsed_groups: Record<string, boolean>;
view: SidebarViewState; view: SidebarViewState;
@ -166,11 +198,38 @@ export interface SidebarStatePayload {
export interface BootstrapResponse { export interface BootstrapResponse {
token: string; token: string;
ws_path: string; ws_path: string;
ws_url?: string | null;
expires_in: number; expires_in: number;
model_name?: string | null; 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 { 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: { agent: {
model: string; model: string;
provider: string; provider: string;
@ -202,11 +261,15 @@ export interface SettingsPayload {
name: string; name: string;
label: string; label: string;
configured: boolean; configured: boolean;
auth_type?: "api_key" | "oauth";
api_key_required?: boolean; api_key_required?: boolean;
api_key_hint?: string | null; api_key_hint?: string | null;
api_base?: string | null; api_base?: string | null;
default_api_base?: string | null; default_api_base?: string | null;
api_type?: "auto" | "chat_completions" | "responses"; api_type?: "auto" | "chat_completions" | "responses";
oauth_account?: string | null;
oauth_expires_at?: number | null;
oauth_login_supported?: boolean;
}>; }>;
web_search: { web_search: {
provider: string; provider: string;
@ -245,6 +308,7 @@ export interface SettingsPayload {
name: string; name: string;
label: string; label: string;
configured: boolean; configured: boolean;
auth_type?: "api_key" | "oauth";
api_key_hint?: string | null; api_key_hint?: string | null;
api_base?: string | null; api_base?: string | null;
default_api_base?: string | null; default_api_base?: string | null;
@ -270,14 +334,27 @@ export interface SettingsPayload {
}; };
advanced: { advanced: {
restrict_to_workspace: boolean; 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; 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; mcp_server_count: number;
exec_enabled: boolean; exec_enabled: boolean;
exec_sandbox?: string | null; exec_sandbox?: string | null;
exec_path_append_set: boolean; exec_path_append_set: boolean;
}; };
requires_restart: boolean; requires_restart: boolean;
restart_required_sections?: Array<"runtime" | "web" | "image">; restart_required_sections?: Array<"runtime" | "browser" | "image">;
} }
export interface AppPackageRef { export interface AppPackageRef {
@ -453,6 +530,13 @@ export interface ModelConfigurationCreate {
model: string; model: string;
} }
export interface ModelConfigurationUpdate {
name: string;
label?: string;
provider?: string;
model?: string;
}
export interface ProviderSettingsUpdate { export interface ProviderSettingsUpdate {
provider: string; provider: string;
apiKey?: string; apiKey?: string;
@ -469,6 +553,11 @@ export interface WebSearchSettingsUpdate {
useJinaReader?: boolean; useJinaReader?: boolean;
} }
export interface NetworkSafetySettingsUpdate {
webuiAllowLocalServiceAccess: boolean;
webuiDefaultAccessMode: WebuiDefaultAccessMode;
}
export interface ImageGenerationSettingsUpdate { export interface ImageGenerationSettingsUpdate {
enabled: boolean; enabled: boolean;
provider: string; provider: string;
@ -566,8 +655,13 @@ export type InboundEvent =
chat_id: string; chat_id: string;
goal_state: GoalStateWsPayload; 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. /** Base64-encoded image attached to an outbound ``message`` envelope.
* *
@ -613,11 +707,13 @@ export interface WebuiThreadPersistedPayload {
sessionKey?: string; sessionKey?: string;
savedAt?: string; savedAt?: string;
messages: UIMessage[]; messages: UIMessage[];
workspace_scope?: WorkspaceScopePayload;
} }
export type Outbound = export type Outbound =
| { type: "new_chat" } | { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "attach"; chat_id: string } | { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| { | {
type: "message"; type: "message";
chat_id: string; chat_id: string;
@ -626,6 +722,7 @@ export type Outbound =
image_generation?: OutboundImageGeneration; image_generation?: OutboundImageGeneration;
cli_apps?: OutboundCliAppMention[]; cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[]; mcp_presets?: OutboundMcpPresetMention[];
workspace_scope?: WorkspaceScopePayload;
/** Marks messages sent by the embedded WebUI, without changing the /** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */ * generic websocket protocol for other clients. */
webui?: true; 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", () => { it("renders CLI app runs as dedicated activity rows", () => {
const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})'; const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})';
render( render(
@ -771,6 +837,38 @@ describe("AgentActivityCluster", () => {
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument(); 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 () => { it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion(); const restoreMotion = installReducedMotion();
try { try {

View File

@ -7,15 +7,20 @@ import {
fetchMcpPresets, fetchMcpPresets,
fetchSidebarState, fetchSidebarState,
fetchWebuiThread, fetchWebuiThread,
fetchWorkspaces,
importMcpConfig, importMcpConfig,
listSessions, listSessions,
listSlashCommands, listSlashCommands,
loginProviderOAuth,
logoutProviderOAuth,
runCliAppAction, runCliAppAction,
runMcpPresetAction, runMcpPresetAction,
saveCustomMcpServer, saveCustomMcpServer,
updateSidebarState, updateSidebarState,
updateImageGenerationSettings, updateImageGenerationSettings,
updateModelConfiguration,
updateMcpServerTools, updateMcpServerTools,
updateNetworkSafetySettings,
updateProviderSettings, updateProviderSettings,
updateSettings, updateSettings,
updateWebSearchSettings, 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 () => { it("serializes provider settings updates without returning secrets", async () => {
await updateProviderSettings("tok", { await updateProviderSettings("tok", {
provider: "openrouter", 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 () => { it("serializes web search settings updates", async () => {
await updateWebSearchSettings("tok", { await updateWebSearchSettings("tok", {
provider: "searxng", 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 () => { it("serializes image generation settings updates", async () => {
await updateImageGenerationSettings("tok", { await updateImageGenerationSettings("tok", {
enabled: true, enabled: true,
@ -257,6 +332,7 @@ describe("webui API helpers", () => {
pinned_keys: ["websocket:chat-1"], pinned_keys: ["websocket:chat-1"],
archived_keys: ["websocket:old"], archived_keys: ["websocket:old"],
title_overrides: { "websocket:chat-1": "Release" }, title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
tags_by_key: {}, tags_by_key: {},
collapsed_groups: {}, collapsed_groups: {},
view: { view: {
@ -292,9 +368,39 @@ describe("webui API helpers", () => {
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({ expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
pinned_keys: ["websocket:chat-1"], pinned_keys: ["websocket:chat-1"],
title_overrides: { "websocket:chat-1": "Release" }, 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 () => { it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({ vi.mocked(fetch).mockResolvedValueOnce({
ok: true, ok: true,

View File

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