mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
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:
parent
84428136e6
commit
3a420136bb
2
.gitignore
vendored
2
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,6 +1657,7 @@ 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 [],
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
return await self._process_message(
|
return await self._process_message(
|
||||||
msg,
|
msg,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
@ -1625,3 +1665,8 @@ class AgentLoop:
|
|||||||
on_stream=on_stream,
|
on_stream=on_stream,
|
||||||
on_stream_end=on_stream_end,
|
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)
|
||||||
|
|||||||
@ -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,6 +237,8 @@ class SubagentManager:
|
|||||||
if self._llm_wall_timeout_for_session
|
if self._llm_wall_timeout_for_session
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
|
||||||
|
try:
|
||||||
result = await self.runner.run(AgentRunSpec(
|
result = await self.runner.run(AgentRunSpec(
|
||||||
initial_messages=messages,
|
initial_messages=messages,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
@ -226,8 +252,12 @@ class SubagentManager:
|
|||||||
fail_on_tool_error=True,
|
fail_on_tool_error=True,
|
||||||
checkpoint_callback=_on_checkpoint,
|
checkpoint_callback=_on_checkpoint,
|
||||||
session_key=sess_key,
|
session_key=sess_key,
|
||||||
|
workspace=root,
|
||||||
llm_timeout_s=llm_timeout,
|
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 "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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,63 +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)
|
|
||||||
summaries.append(
|
|
||||||
_PatchSummary(
|
|
||||||
action="update", path=path, added=added, deleted=deleted
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
added, deleted = _line_diff_stats(content, new_norm)
|
||||||
summaries.append(
|
summaries.append(
|
||||||
_PatchSummary(
|
_PatchSummary(
|
||||||
@ -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
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 = []
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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: ...
|
||||||
|
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|||||||
@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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,23 +1533,45 @@ 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
|
||||||
|
if socket_path:
|
||||||
|
path_obj = Path(socket_path)
|
||||||
|
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
path_obj.unlink()
|
||||||
|
server = await unix_serve(
|
||||||
|
handler,
|
||||||
|
socket_path,
|
||||||
|
process_request=process_request,
|
||||||
|
max_size=self.config.max_message_bytes,
|
||||||
|
ping_interval=self.config.ping_interval_s,
|
||||||
|
ping_timeout=self.config.ping_timeout_s,
|
||||||
|
)
|
||||||
|
with suppress(OSError):
|
||||||
|
path_obj.chmod(0o600)
|
||||||
|
else:
|
||||||
|
server = await serve(
|
||||||
handler,
|
handler,
|
||||||
self.config.host,
|
self.config.host,
|
||||||
self.config.port,
|
self.config.port,
|
||||||
@ -1384,9 +1580,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
ping_interval=self.config.ping_interval_s,
|
ping_interval=self.config.ping_interval_s,
|
||||||
ping_timeout=self.config.ping_timeout_s,
|
ping_timeout=self.config.ping_timeout_s,
|
||||||
ssl=ssl_context,
|
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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
430
nanobot/security/workspace_access.py
Normal file
430
nanobot/security/workspace_access.py
Normal 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]
|
||||||
85
nanobot/security/workspace_policy.py
Normal file
85
nanobot/security/workspace_policy.py
Normal 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
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
283
nanobot/webui/workspaces.py
Normal 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)
|
||||||
55
tests/agent/test_loop_direct_websocket_status.py
Normal file
55
tests/agent/test_loop_direct_websocket_status.py
Normal 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]
|
||||||
344
tests/agent/test_workspace_scope.py
Normal file
344
tests/agent/test_workspace_scope.py
Normal 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
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
69
tests/security/test_workspace_policy.py
Normal file
69
tests/security/test_workspace_policy.py
Normal 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()
|
||||||
68
tests/security/test_workspace_sandbox.py
Normal file
68
tests/security/test_workspace_sandbox.py
Normal 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
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
154
tests/utils/test_webui_workspaces.py
Normal file
154
tests/utils/test_webui_workspaces.py
Normal 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"
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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,27 +1017,54 @@ 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(
|
||||||
|
"relative h-full w-full overflow-hidden",
|
||||||
|
showHostChrome && "bg-sidebar",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showHostChrome ? (
|
||||||
|
<HostChrome
|
||||||
|
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggle}
|
||||||
|
showThemeButton={view !== "chat"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-full w-full overflow-hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Host sidebar: in normal flow, so the thread area width stays honest. */}
|
||||||
{showMainSidebar ? (
|
{showMainSidebar ? (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -770,17 +1072,21 @@ function Shell({
|
|||||||
"transition-[width] duration-300 ease-out",
|
"transition-[width] duration-300 ease-out",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: desktopSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
|
width: hostSidebarOpen ? 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-y-0 left-0 h-full w-full overflow-hidden bg-sidebar",
|
||||||
|
!showHostChrome && "shadow-inner-right",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
{...sidebarProps}
|
{...sidebarProps}
|
||||||
collapsed={!desktopSidebarOpen}
|
collapsed={!hostSidebarOpen}
|
||||||
onCollapse={closeDesktopSidebar}
|
hostChromeInset={showHostChrome}
|
||||||
onExpand={openDesktopSidebar}
|
onCollapse={closeHostSidebar}
|
||||||
|
onExpand={openHostSidebar}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -817,8 +1123,13 @@ function Shell({
|
|||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
onSelect={onSelectSearchResult}
|
onSelect={onSelectSearchResult}
|
||||||
/>
|
/>
|
||||||
|
<main
|
||||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
className={cn(
|
||||||
|
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
|
||||||
|
showHostChrome &&
|
||||||
|
"rounded-l-[28px] shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.45)] dark:shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.85)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 flex flex-col",
|
"absolute inset-0 flex flex-col",
|
||||||
@ -834,7 +1145,15 @@ function Shell({
|
|||||||
onTurnEnd={onTurnEnd}
|
onTurnEnd={onTurnEnd}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
hideSidebarToggleOnDesktop
|
hideSidebarToggleForHostChrome
|
||||||
|
hideHeader={false}
|
||||||
|
workspaceScope={activeWorkspaceScope}
|
||||||
|
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||||
|
workspaceControls={workspaces?.controls ?? null}
|
||||||
|
workspaceScopeDisabled={activeChatRunning}
|
||||||
|
workspaceError={workspaceError}
|
||||||
|
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||||
|
settingsSnapshot={settingsSnapshot}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view !== "chat" && (
|
{view !== "chat" && (
|
||||||
@ -846,13 +1165,17 @@ function Shell({
|
|||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
onBackToChat={onBackToChat}
|
onBackToChat={onBackToChat}
|
||||||
onModelNameChange={onModelNameChange}
|
onModelNameChange={onModelNameChange}
|
||||||
|
onSettingsChange={setSettingsSnapshot}
|
||||||
|
onWorkspaceSettingsChange={refreshWorkspaces}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onRestart={onRestart}
|
onRestart={onRestart}
|
||||||
isRestarting={isRestarting}
|
isRestarting={isRestarting}
|
||||||
|
hostChromeInset={showHostChrome}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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"
|
||||||
|
|||||||
@ -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,15 +177,52 @@ 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);
|
||||||
|
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
|
||||||
|
const visibleSessions = visibleSessionsForGroup(
|
||||||
|
group,
|
||||||
|
activeKey,
|
||||||
|
collapsedGroups,
|
||||||
|
);
|
||||||
|
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
|
||||||
|
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={group.id} aria-label={group.label}>
|
||||||
|
{group.kind === "project"
|
||||||
|
&& limitedGroups[index - 1]?.kind !== "project" ? (
|
||||||
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
||||||
{group.label}
|
{labels.projects}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{group.kind === "project" ? (
|
||||||
|
<ProjectGroupHeader
|
||||||
|
label={group.label}
|
||||||
|
path={group.projectPath}
|
||||||
|
collapsed={Boolean(collapsedGroups[group.id])}
|
||||||
|
onToggle={() => onToggleGroup?.(group.id)}
|
||||||
|
onRequestRename={
|
||||||
|
group.projectKey && onRequestRenameProject
|
||||||
|
? () => onRequestRenameProject(group.projectKey ?? "", group.label)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onNewChat={
|
||||||
|
group.projectPath && onNewChatInProject
|
||||||
|
? () => onNewChatInProject(group.projectPath ?? "", group.label)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
actionMenuPortalContainer={actionMenuPortalContainer}
|
||||||
|
updatedAt={showTimestamps ? group.updatedAt : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChatsGroupHeader label={group.label} />
|
||||||
|
)}
|
||||||
|
{group.kind === "project" && collapsedGroups[group.id] ? null : (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{group.sessions.map((s) => {
|
{visibleSessions.map((s) => {
|
||||||
const active = s.key === activeKey;
|
const active = s.key === activeKey;
|
||||||
const fallbackTitle = t("chat.fallbackTitle", {
|
const fallbackTitle = t("chat.fallbackTitle", {
|
||||||
id: s.chatId.slice(0, 6),
|
id: s.chatId.slice(0, 6),
|
||||||
@ -169,9 +240,10 @@ export const ChatList = memo(function ChatList({
|
|||||||
const timestamp = showTimestamps
|
const timestamp = showTimestamps
|
||||||
? relativeTime(s.updatedAt ?? s.createdAt)
|
? relativeTime(s.updatedAt ?? s.createdAt)
|
||||||
: "";
|
: "";
|
||||||
|
const projectMode = group.kind === "project";
|
||||||
const activityState = running.has(s.chatId)
|
const activityState = running.has(s.chatId)
|
||||||
? "running"
|
? "running"
|
||||||
: completed.has(s.chatId)
|
: completed.has(s.chatId) && !active
|
||||||
? "complete"
|
? "complete"
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
@ -192,15 +264,31 @@ export const ChatList = memo(function ChatList({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1 overflow-hidden text-left",
|
"min-w-0 flex-1 overflow-hidden text-left",
|
||||||
compact ? "py-1" : "py-1.5",
|
compact ? "py-1" : "py-1.5",
|
||||||
|
projectMode && "pl-7",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="block w-full truncate font-medium leading-5">{title}</span>
|
{projectMode ? (
|
||||||
|
<span className="flex w-full min-w-0 items-baseline gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{timestamp ? (
|
||||||
|
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
|
||||||
|
{timestamp}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="block w-full truncate font-medium leading-5">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
|
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
|
||||||
{preview}
|
{preview}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{timestamp ? (
|
{timestamp && !projectMode ? (
|
||||||
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
|
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
|
||||||
{timestamp}
|
{timestamp}
|
||||||
</span>
|
</span>
|
||||||
@ -266,8 +354,17 @@ export const ChatList = memo(function ChatList({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
{foldableChatsGroup && canToggleFold ? (
|
||||||
|
<ChatsFoldFooter
|
||||||
|
folded={foldedChatsGroup}
|
||||||
|
hiddenCount={hiddenInGroup}
|
||||||
|
onToggle={() => onToggleGroup?.(group.id)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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
@ -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>
|
||||||
|
|||||||
@ -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: "" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
return [
|
const stopCommand = slashCommands.find((command) => command.command === "/stop") ?? {
|
||||||
{
|
|
||||||
command: "/stop",
|
command: "/stop",
|
||||||
title: "Stop current task",
|
title: "Stop current task",
|
||||||
description: "Cancel the active agent turn for this chat.",
|
description: "Cancel the active agent turn for this chat.",
|
||||||
icon: "square",
|
icon: "square",
|
||||||
},
|
};
|
||||||
...slashCommands,
|
return [
|
||||||
|
stopCommand,
|
||||||
|
...baseCommands,
|
||||||
];
|
];
|
||||||
}, [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,12 +1247,22 @@ 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>
|
||||||
|
{workspaceScope ? (
|
||||||
|
<WorkspaceAccessMenu
|
||||||
|
scope={workspaceScope}
|
||||||
|
disabled={disabled || workspaceScopeDisabled}
|
||||||
|
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
||||||
|
isHero={isHero}
|
||||||
|
onChange={onWorkspaceScopeChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{imageGenerationEnabled ? (
|
||||||
<div ref={aspectControlRef} className="relative flex items-center gap-1">
|
<div ref={aspectControlRef} className="relative flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1226,15 +1276,15 @@ export function ThreadComposer({
|
|||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
|
"max-w-[11rem] rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
|
||||||
"h-9 text-[12px]",
|
isHero ? "h-8 text-[11.5px]" : "h-9 text-[12px]",
|
||||||
imageMode
|
imageMode
|
||||||
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
|
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
|
||||||
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
|
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ImageIcon className={cn("mr-1.5", isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
<ImageIcon className={cn("mr-1.5", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
|
||||||
{t("thread.composer.imageMode.label")}
|
<span className="truncate">{t("thread.composer.imageMode.label")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
{imageMode ? (
|
{imageMode ? (
|
||||||
<Button
|
<Button
|
||||||
@ -1247,7 +1297,7 @@ 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>
|
||||||
@ -1266,6 +1316,9 @@ export function ThreadComposer({
|
|||||||
/>
|
/>
|
||||||
) : 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,13 +1327,6 @@ export function ThreadComposer({
|
|||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isHero ? (
|
|
||||||
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
|
|
||||||
{t("thread.composer.sendHint")}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
|
|
||||||
<Button
|
<Button
|
||||||
type={showStopButton ? "button" : "submit"}
|
type={showStopButton ? "button" : "submit"}
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -1294,20 +1340,30 @@ export function ThreadComposer({
|
|||||||
: isHero
|
: 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_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",
|
: "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",
|
isHero ? "h-8 w-8" : "h-9 w-9",
|
||||||
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showStopButton ? (
|
{showStopButton ? (
|
||||||
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-2.5 w-2.5")} />
|
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-3.5 w-3.5")} />
|
||||||
) : isStreaming ? (
|
) : isStreaming ? (
|
||||||
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
|
<Loader2 className={cn(isHero ? "h-4 w-4" : "h-4 w-4", "animate-spin")} />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
|
<ArrowUp className={cn(isHero ? "h-4 w-4" : "h-4 w-4")} />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<WorkspaceProjectPicker
|
||||||
|
isHero={isHero}
|
||||||
|
disabled={disabled || workspaceScopeDisabled}
|
||||||
|
scope={workspaceScope}
|
||||||
|
defaultScope={workspaceDefaultScope}
|
||||||
|
controls={workspaceControls}
|
||||||
|
error={workspaceError}
|
||||||
|
onChange={onWorkspaceScopeChange}
|
||||||
|
/>
|
||||||
|
</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) => {
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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">
|
||||||
|
{!hideHeader ? (
|
||||||
<ThreadHeader
|
<ThreadHeader
|
||||||
title={title}
|
title={title}
|
||||||
onToggleSidebar={onToggleSidebar}
|
onToggleSidebar={onToggleSidebar}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={onToggleTheme}
|
onToggleTheme={onToggleTheme}
|
||||||
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
|
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||||
minimal={!session && !loading}
|
minimal={!session && !loading}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<ThreadViewport
|
<ThreadViewport
|
||||||
messages={displayMessages}
|
messages={displayMessages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
|||||||
@ -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]">
|
||||||
|
<div className="absolute inset-x-0 bottom-[calc(100%+1.5rem)] flex justify-center">
|
||||||
{emptyState}
|
{emptyState}
|
||||||
|
</div>
|
||||||
<div className="w-full">{composer}</div>
|
<div className="w-full">{composer}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
326
webui/src/components/thread/WorkspaceControls.tsx
Normal file
326
webui/src/components/thread/WorkspaceControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
setMessages((prev) => {
|
||||||
|
const segmentId = ensureActivitySegmentId();
|
||||||
|
const base = demoteInterruptedAssistantToActivity(prev, segmentId);
|
||||||
|
const visibleStructuredEvents = filterCoveredFileEditToolEvents(base, structuredEvents);
|
||||||
|
const structuredLines = toolTraceLinesFromEvents(visibleStructuredEvents);
|
||||||
const lines = structuredLines.length > 0
|
const lines = structuredLines.length > 0
|
||||||
? structuredLines
|
? structuredLines
|
||||||
|
: structuredEvents.length > 0
|
||||||
|
? []
|
||||||
: ev.text
|
: ev.text
|
||||||
? [ev.text]
|
? [ev.text]
|
||||||
: [];
|
: [];
|
||||||
if (lines.length === 0) return;
|
if (lines.length === 0) return base;
|
||||||
setMessages((prev) => {
|
const last = base[base.length - 1];
|
||||||
const segmentId = ensureActivitySegmentId();
|
|
||||||
const last = prev[prev.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",
|
||||||
|
|||||||
@ -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),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,9 @@
|
|||||||
"section": "Système",
|
"section": "Système",
|
||||||
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exécution.",
|
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exé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 à l’heure.",
|
||||||
"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 d’images enregistrées. Redémarrez lorsque vous êtes prêt."
|
"imageProviderRestart": "Modifications du fournisseur d’images 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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "请输入这台机器上的绝对文件夹路径。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "請輸入這台機器上的絕對資料夾路徑。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}`;
|
||||||
|
|||||||
372
webui/src/lib/chat-groups.ts
Normal file
372
webui/src/lib/chat-groups.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
211
webui/src/lib/runtime.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
56
webui/src/lib/workspace.ts
Normal file
56
webui/src/lib/workspace.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
23
webui/src/tests/bootstrap.test.ts
Normal file
23
webui/src/tests/bootstrap.test.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user