Compare commits

..
116 changed files with 2300 additions and 13080 deletions
-1
View File
@@ -98,4 +98,3 @@ tmp/
temp/
*.tmp
exp/
.playwright-mcp/
+2 -104
View File
@@ -126,10 +126,8 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.com/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
@@ -168,43 +166,6 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
<details>
<summary><b>OpenAI</b></summary>
By default, OpenAI uses `apiType: "auto"`: nanobot calls Chat Completions normally and routes GPT-5/o-series or explicit `reasoningEffort` requests through the Responses API when useful. You can force a specific API surface:
```json
{
"providers": {
"openai": {
"apiKey": "${OPENAI_API_KEY}",
"apiType": "chat_completions"
}
}
}
```
Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes it through as the SDK `extra_body` value. With Responses, configure it in Responses API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends `extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
```json
{
"providers": {
"openai": {
"apiKey": "${OPENAI_API_KEY}",
"apiType": "responses",
"extraBody": {
"tools": [{ "type": "web_search" }],
"include": ["web_search_call.action.sources"]
}
}
}
}
```
</details>
<details>
<summary><b>Skywork / APIFree</b></summary>
@@ -516,68 +477,6 @@ Official model names include `LongCat-Flash-Chat`, `LongCat-Flash-Thinking`,
</details>
<details>
<summary><b>Xiaomi MiMo</b></summary>
Xiaomi MiMo models are automatically detected by the `xiaomi_mimo` provider when
the model name contains `mimo`. The default API base is
`https://api.xiaomimimo.com/v1`.
> **Token Plan**: If you're using MiMo's token plan, override `apiBase` with the
> dedicated endpoint:
>
> ```json
> {
> "providers": {
> "xiaomi_mimo": {
> "apiKey": "${XIAOMIMIMO_API_KEY}",
> "apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"
> }
> },
> "agents": {
> "defaults": {
> "model": "xiaomi/mimo-v2.5-pro"
> }
> }
> }
> ```
>
> No need to set `provider` explicitly — the model name contains `mimo`, which
> auto-matches to the `xiaomi_mimo` provider spec. Use an API key from the MiMo
> token plan console and check the MiMo platform for the latest supported model
> names.
</details>
<details>
<summary><b>StepFun Step Plan (subscription)</b></summary>
Step Plan is StepFun's subscription-based service for high-frequency AI developers.
If you're on a Step Plan subscription, override `apiBase` in the existing `stepfun`
provider config to point to the dedicated Step Plan endpoint.
```json
{
"providers": {
"stepfun": {
"apiKey": "${STEPFUN_API_KEY}",
"apiBase": "https://api.stepfun.com/step_plan/v1"
}
},
"agents": {
"defaults": {
"provider": "stepfun",
"model": "step-3.5-flash"
}
}
}
```
Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and
`step-router-v1`.
</details>
<details>
<summary><b>Ant Ling (OpenAI-compatible)</b></summary>
@@ -1057,7 +956,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key and optional `apiBase` are auto-resolved from the matching provider config. Chat-style bases such as `https://api.groq.com/openai/v1` are normalized to the audio transcription endpoint. |
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
`sendProgress` and `sendToolHints` can also be overridden per channel. The
@@ -1296,7 +1195,7 @@ If you want to always use the local conversion, you can force it using:
## Image Generation
Image generation is configured under `tools.imageGeneration` and uses credentials from the selected provider's `providers.<name>` block.
Image generation is configured under `tools.imageGeneration` and uses provider credentials from `providers.openrouter` or `providers.aihubmix`.
See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting.
@@ -1389,7 +1288,6 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
| `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
+4 -28
View File
@@ -23,7 +23,7 @@ The feature is disabled by default. Enable it in `~/.nanobot/config.json`, confi
}
```
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples.
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, and StepFun configuration examples.
> [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
@@ -46,7 +46,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun` |
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
@@ -245,31 +245,6 @@ StepPlan is StepFun's subscription tier and uses a different API base URL. The i
`apiBase` takes precedence over the registry default, so with the StepPlan base URL configured, image requests are sent to `https://api.stepfun.com/step_plan/v1/images/generations` — the same path prefix used for LLM calls. The API key is shared with the standard StepFun provider.
### Zhipu
Zhipu (智谱) `glm-image` model supports text-to-image generation. The API returns temporary image URLs (valid for 30 days); nanobot downloads and re-encodes them as base64 data URLs.
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be specified as `WIDTHxHEIGHT` (e.g. `1280x1280`, `1728x960`) or using aspect ratio presets.
```json
{
"providers": {
"zhipu": {
"apiKey": "${ZAI_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "zhipu",
"model": "glm-image"
}
}
}
```
Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration.
## Artifacts
Generated images are stored under the active nanobot instance's media directory:
@@ -324,7 +299,8 @@ Use the reference image. Keep the same robot and composition, change the palette
|---------|-------|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, or `stepfun` |
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
-30
View File
@@ -10,10 +10,6 @@ from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
from nanobot.agent.tools import mcp as mcp_tools
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage
from nanobot.apps.cli import utils as cli_app_utils
from nanobot.session.goal_state import goal_state_runtime_lines
from nanobot.utils.helpers import (
current_time_str,
@@ -23,32 +19,6 @@ from nanobot.utils.helpers import (
from nanobot.utils.prompt_templates import render_template
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted kwargs for turn-attached capabilities."""
return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata)
def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]:
"""Return model-visible runtime annotations for turn-attached capabilities."""
return [
*cli_app_utils.runtime_lines(msg, workspace, skip=skip),
*mcp_tools.runtime_lines(
msg,
configured_server_names=set(state._mcp_servers),
connected_server_names=set(state._mcp_stacks),
skip=skip,
),
]
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
await mcp_tools.connect_missing_servers(state, tools)
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
return await mcp_tools.handle_runtime_control(state, msg, tools)
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
+27 -278
View File
@@ -8,25 +8,17 @@ import os
import time
from contextlib import AsyncExitStack, nullcontext, suppress
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum, auto
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
from nanobot.agent import context as agent_context
from nanobot.agent import model_presets as preset_helpers
from nanobot.agent.autocompact import AutoCompact
from nanobot.agent.context import ContextBuilder
from nanobot.agent.hook import AgentHook, CompositeHook
from nanobot.agent.memory import (
_STALE_THRESHOLD_DAYS,
Consolidator,
Dream,
_estimate_tokens,
_strip_skip_lines,
)
from nanobot.agent.memory import Consolidator, Dream
from nanobot.agent.progress_hook import AgentProgressHook
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
from nanobot.agent.subagent import SubagentManager
@@ -36,15 +28,13 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.cli_apps import utils as cli_app_utils
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.goal_state import (
GOAL_STATE_KEY,
goal_state_runtime_lines,
runner_wall_llm_timeout_s,
sustained_goal_active,
)
from nanobot.session.manager import Session, SessionManager
from nanobot.session.webui_turns import (
@@ -57,11 +47,7 @@ from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import _TEMPLATES_ROOT, render_template
from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE,
SUSTAINED_GOAL_CONTINUE_PROMPT,
)
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
if TYPE_CHECKING:
from nanobot.config.schema import (
@@ -178,7 +164,6 @@ class AgentLoop:
workspace: Path,
model: str | None = None,
max_iterations: int | None = None,
max_concurrent_subagents: int | None = None,
context_window_tokens: int | None = None,
context_block_limit: int | None = None,
max_tool_result_chars: int | None = None,
@@ -205,7 +190,6 @@ class AgentLoop:
model_preset: str | None = None,
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
dream_model_override: str | None = None,
):
from nanobot.config.schema import ToolsConfig
@@ -218,7 +202,6 @@ class AgentLoop:
self._preset_snapshot_loader = preset_snapshot_loader
self._runtime_model_publisher = runtime_model_publisher
self._provider_signature = provider_signature
self._dream_model_override = dream_model_override
self._default_selection_signature = preset_helpers.default_selection_signature(provider_signature)
self.workspace = workspace
self.model = model or provider.get_default_model()
@@ -279,7 +262,6 @@ class AgentLoop:
restrict_to_workspace=restrict_to_workspace,
disabled_skills=disabled_skills,
max_iterations=self.max_iterations,
max_concurrent_subagents=max_concurrent_subagents,
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
)
self._unified_session = unified_session
@@ -326,7 +308,6 @@ class AgentLoop:
self._active_preset: str | None = None
if model_preset:
self.set_model_preset(model_preset, publish_update=False)
self._configure_dream()
self._register_default_tools()
self._runtime_vars: dict[str, Any] = {}
self._current_iteration: int = 0
@@ -366,7 +347,6 @@ class AgentLoop:
workspace=config.workspace_path,
model=model,
max_iterations=defaults.max_tool_iterations,
max_concurrent_subagents=defaults.max_concurrent_subagents,
context_window_tokens=context_window_tokens,
context_block_limit=defaults.context_block_limit,
max_tool_result_chars=defaults.max_tool_result_chars,
@@ -386,7 +366,6 @@ class AgentLoop:
model_preset=defaults.model_preset,
provider_snapshot_loader=provider_snapshot_loader,
preset_snapshot_loader=preset_snapshot_loader,
dream_model_override=config.agents.defaults.dream.model_override,
**extra,
)
@@ -412,7 +391,7 @@ class AgentLoop:
self.runner.provider = provider
self.subagents.set_provider(provider, model)
self.consolidator.set_provider(provider, model, context_window_tokens)
self._configure_dream()
self.dream.set_provider(provider, model)
self._provider_signature = snapshot.signature
if publish_update and self._runtime_model_publisher is not None:
self._runtime_model_publisher(
@@ -421,20 +400,6 @@ class AgentLoop:
)
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
def _configure_dream(self) -> None:
"""Apply dream.model_override, resolving preset names if needed."""
if not self._dream_model_override:
self.dream.set_provider(self.provider, self.model)
return
if self._dream_model_override in self.model_presets:
snapshot = self._build_model_preset_snapshot(self._dream_model_override)
self.dream.set_provider(snapshot.provider, snapshot.model)
return
# Raw model name fallback — same provider, different model
self.dream.set_provider(self.provider, self._dream_model_override)
def _refresh_provider_snapshot(self) -> None:
if self._provider_snapshot_loader is None:
return
@@ -511,8 +476,26 @@ class AgentLoop:
logger.info("Registered {} tools: {}", len(registered), registered)
async def _connect_mcp(self) -> None:
"""Connect configured MCP servers."""
await agent_context.connect_mcp(self, self.tools)
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
return
self._mcp_connecting = True
from nanobot.agent.tools.mcp import connect_mcp_servers
try:
self._mcp_stacks = await connect_mcp_servers(self._mcp_servers, self.tools)
if self._mcp_stacks:
self._mcp_connected = True
else:
logger.warning("No MCP servers connected successfully (will retry next message)")
except asyncio.CancelledError:
logger.warning("MCP connection cancelled (will retry next message)")
self._mcp_stacks.clear()
except BaseException as e:
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
self._mcp_stacks.clear()
finally:
self._mcp_connecting = False
def _set_tool_context(
self, channel: str, chat_id: str,
@@ -585,7 +568,7 @@ class AgentLoop:
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths:
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
session.add_message("user", text, **extra)
@@ -610,7 +593,7 @@ class AgentLoop:
chat_id=self._runtime_chat_id(msg),
sender_id=msg.sender_id,
session_summary=pending_summary,
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace),
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace),
)
async def _dispatch_command_inline(
@@ -761,15 +744,6 @@ class AgentLoop:
active_session_key = session.key if session else session_key
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
# Build continuation message that embeds the active goal objective so
# the LLM can see it even if earlier Runtime Context was truncated.
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
_goal_continue = (
"You have an active sustained goal:\n\n"
+ "\n".join(_goal_lines)
+ "\n\nPlease continue working toward the objective using your tools, "
"or call complete_goal if the work is truly finished."
) if _goal_lines else SUSTAINED_GOAL_CONTINUE_PROMPT
try:
result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages,
@@ -797,8 +771,6 @@ class AgentLoop:
session.key if session is not None else session_key,
metadata=(session.metadata if session is not None else None),
),
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
goal_continue_message=_goal_continue,
))
finally:
reset_file_states(file_state_token)
@@ -839,8 +811,6 @@ class AgentLoop:
logger.warning("Error consuming inbound message: {}, continuing...", e)
continue
if await agent_context.handle_runtime_control(self, msg, self.tools):
continue
raw = msg.content.strip()
if self.commands.is_priority(raw):
await self._dispatch_command_inline(
@@ -1049,28 +1019,6 @@ class AgentLoop:
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
)
logger.info("Processing system message from {}", msg.sender_id)
if msg.sender_id == "dream":
session_key = "system:dream"
session = self.sessions.get_or_create(session_key)
session.metadata["is_dream"] = True
# Capture trigger source on first batch so _dream_finalize_commit
# can notify the user who ran /dream (cron-triggered runs have no trigger).
if "_dream_trigger_channel" not in session.metadata:
trigger_ch = msg.metadata.get("trigger_channel")
trigger_ci = msg.metadata.get("trigger_chat_id")
if trigger_ch and trigger_ci:
session.metadata["_dream_trigger_channel"] = trigger_ch
session.metadata["_dream_trigger_chat_id"] = trigger_ci
if not sustained_goal_active(session.metadata):
session.metadata[GOAL_STATE_KEY] = {
"status": "active",
"objective": "Dream: consolidate unprocessed memory backlog into MEMORY.md, SOUL.md, USER.md",
"started_at": datetime.now().isoformat(),
}
self.sessions.save(session)
await self._process_dream_batch(session, msg)
await self._dream_finalize_commit(session)
return None
key = msg.session_key_override or f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key)
if self._restore_runtime_checkpoint(session):
@@ -1110,7 +1058,7 @@ class AgentLoop:
current_role=current_role,
sender_id=msg.sender_id,
session_summary=pending,
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace, skip=is_subagent),
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
@@ -1147,205 +1095,6 @@ class AgentLoop:
metadata=outbound_metadata,
)
async def _process_dream_batch(self, session: Session, msg: InboundMessage) -> None:
"""Process the full Dream backlog in batches within a single invocation."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
# System prompt caching with mtime invalidation
template_path = _TEMPLATES_ROOT / "agent" / "dream.md"
cached_prompt = session.metadata.get("_dream_system_prompt")
cached_mtime = session.metadata.get("_dream_system_prompt_mtime")
current_mtime = template_path.stat().st_mtime if template_path.exists() else None
if cached_prompt is None or cached_mtime != current_mtime:
skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"
workspace = self.dream.store.workspace
cached_prompt = render_template(
"agent/dream.md",
strip=True,
skill_creator_path=str(skill_creator_path),
soul_path=str(workspace / "SOUL.md"),
user_path=str(workspace / "USER.md"),
memory_path=str(workspace / "memory" / "MEMORY.md"),
stale_threshold_days=_STALE_THRESHOLD_DAYS,
dream_edit_user_skills=self.dream.edit_user_skills,
)
session.metadata["_dream_system_prompt"] = cached_prompt
session.metadata["_dream_system_prompt_mtime"] = current_mtime
while True:
last_cursor = self.dream.store.get_last_dream_cursor()
entries = self.dream.store.read_unprocessed_history(since_cursor=last_cursor)
if not entries:
return
batch = entries[: self.dream.max_batch_size]
logger.info(
"Dream: processing {}/{} entries (cursor {}{})",
len(batch), len(entries), last_cursor, batch[-1]["cursor"],
)
# Build history text — cap each entry and strip [skip] lines
history_text = "\n".join(
f"[{e['timestamp']}] "
f"{truncate_text_fn(_strip_skip_lines(e['content']), self.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS)}"
for e in batch
)
# Current file contents + per-line age annotations
current_date = datetime.now().strftime("%Y-%m-%d")
annotate = self.dream.annotate_line_ages
raw_memory = self.dream.store.read_memory() or "(empty)"
raw_soul = self.dream.store.read_soul() or "(empty)"
raw_user = self.dream.store.read_user() or "(empty)"
annotated_memory = (
self.dream._annotate_with_ages(raw_memory, "memory/MEMORY.md")
if annotate else raw_memory
)
annotated_soul = (
self.dream._annotate_with_ages(raw_soul, "SOUL.md")
if annotate else raw_soul
)
annotated_user = (
self.dream._annotate_with_ages(raw_user, "USER.md")
if annotate else raw_user
)
current_memory = truncate_text_fn(annotated_memory, self.dream._MEMORY_FILE_MAX_CHARS)
current_soul = truncate_text_fn(annotated_soul, self.dream._SOUL_FILE_MAX_CHARS)
current_user = truncate_text_fn(annotated_user, self.dream._USER_FILE_MAX_CHARS)
file_context = (
f"## Current Date\n{current_date}\n\n"
f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
f"## Current SOUL.md ({len(current_soul)} chars)\n{current_soul}\n\n"
f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
)
existing_skills = self.dream._list_existing_skills(tag_origin=True)
skills_section = ""
if existing_skills:
skills_section = (
"\n\n## Existing Skills\n"
+ "\n".join(f"- {s}" for s in existing_skills)
)
user_prompt = f"## Conversation History\n{history_text}\n\n{file_context}{skills_section}"
logger.info("Dream prompt: {} chars, ~{} tokens", len(user_prompt), _estimate_tokens(user_prompt))
messages: list[dict[str, Any]] = [
{"role": "system", "content": cached_prompt},
{"role": "user", "content": user_prompt},
]
t_start = time.perf_counter()
try:
result = await self.dream._runner.run(AgentRunSpec(
initial_messages=messages,
tools=self.dream._tools,
model=self.dream.model,
max_iterations=self.dream.max_iterations,
max_tool_result_chars=self.dream.max_tool_result_chars,
context_window_tokens=self.context_window_tokens,
fail_on_tool_error=False,
))
elapsed = time.perf_counter() - t_start
logger.info(
"Dream run complete in {:.1f}s: stop_reason={}, tool_events={}",
elapsed, result.stop_reason, len(result.tool_events),
)
except Exception:
elapsed = time.perf_counter() - t_start
logger.exception("Dream run failed after {:.1f}s", elapsed)
result = None
# Build changelog from tool events
changelog: list[str] = []
if result and result.tool_events:
for event in result.tool_events:
if event.get("status") == "ok":
changelog.append(f"{event['name']}: {event['detail']}")
success = result is not None and result.stop_reason == "completed"
if success:
new_cursor = batch[-1]["cursor"]
self.dream.store.set_last_dream_cursor(new_cursor)
session.metadata.setdefault("_dream_changelog", []).extend(changelog)
self.sessions.save(session)
logger.info(
"Dream done: {} change(s), cursor advanced to {}",
len(changelog), new_cursor,
)
else:
reason = result.stop_reason if result else "exception"
logger.warning(
"Dream incomplete ({}): cursor NOT advanced, stopping",
reason,
)
return
self.dream.store.compact_history()
# Persist session record for debugging / visualization
record = {
"timestamp": datetime.now().isoformat(),
"batch": {
"from_cursor": last_cursor,
"to_cursor": batch[-1]["cursor"],
"count": len(batch),
},
"prompt_chars": len(user_prompt),
"elapsed_seconds": elapsed,
"stop_reason": result.stop_reason,
"usage": result.usage,
"tool_events": result.tool_events,
"changelog": changelog,
"commit_sha": None,
"messages": result.messages,
}
self.dream.store.write_dream_session(record)
session.metadata["_dream_last_record"] = record
async def _dream_finalize_commit(self, session: Session) -> None:
"""Collapse accumulated changelog into a single git commit, clear caches, and complete the goal."""
changelog = session.metadata.pop("_dream_changelog", [])
sha = None
if changelog and self.dream.store.git.is_initialized():
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
summary = f"dream: {ts}, {len(changelog)} change(s)"
commit_msg = f"{summary}\n\n" + "\n".join(changelog)
sha = self.dream.store.git.auto_commit(commit_msg)
if sha:
logger.info("Dream commit: {}", sha)
record = session.metadata.pop("_dream_last_record", None)
if record and sha:
record["commit_sha"] = sha
self.dream.store.write_dream_session(record)
session.metadata.pop("_dream_system_prompt", None)
session.metadata.pop("_dream_system_prompt_mtime", None)
trigger_channel = session.metadata.pop("_dream_trigger_channel", None)
trigger_chat_id = session.metadata.pop("_dream_trigger_chat_id", None)
goal = session.metadata.get(GOAL_STATE_KEY)
if isinstance(goal, dict) and goal.get("status") == "active":
session.metadata[GOAL_STATE_KEY] = {
**goal,
"status": "completed",
"completed_at": datetime.now().isoformat(),
"recap": f"Memory backlog consolidated ({len(changelog)} change(s)).",
}
self.sessions.save(session)
session.metadata["_dream_finalized"] = True
# Notify the user who triggered /dream
if trigger_channel and trigger_chat_id:
content = f"Dream completed: {len(changelog)} change(s) committed."
if not changelog:
content = "Dream: nothing to process."
await self.bus.publish_outbound(OutboundMessage(
channel=trigger_channel,
chat_id=trigger_chat_id,
content=content,
))
async def _process_message(
self,
msg: InboundMessage,
+191 -168
View File
@@ -6,7 +6,6 @@ import asyncio
import json
import os
import re
import time
import weakref
from contextlib import suppress
from datetime import datetime
@@ -16,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
import tiktoken
from loguru import logger
from nanobot.agent.runner import AgentRunner
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.session.manager import Session
from nanobot.utils.gitstore import GitStore
@@ -34,20 +33,6 @@ if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import SessionManager
# Cache the tiktoken encoding to avoid repeated instantiation on every
# truncate/encode call. Encoding objects are thread-safe and reusable.
try:
_TIKTOKEN_ENC = tiktoken.get_encoding("cl100k_base")
except Exception: # pragma: no cover
_TIKTOKEN_ENC = None
def _estimate_tokens(text: str) -> int:
"""Approximate token count for a text string."""
if _TIKTOKEN_ENC is not None:
return len(_TIKTOKEN_ENC.encode(text))
return len(text) // 4
# ---------------------------------------------------------------------------
# MemoryStore — pure file I/O layer
@@ -415,26 +400,6 @@ class MemoryStore:
def set_last_dream_cursor(self, cursor: int) -> None:
self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
def write_dream_session(self, data: dict[str, Any]) -> None:
"""Atomic overwrite of the latest Dream run record."""
path = self.memory_dir / ".dream_session.json"
tmp_path = path.with_suffix(".tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
# -- message formatting utility ------------------------------------------
@staticmethod
@@ -653,21 +618,19 @@ class Consolidator:
"""Available input token budget for consolidation LLM."""
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
def _truncate_to_token_budget(self, text: str, reserve_tokens: int = 0) -> str:
"""Truncate text so it fits within the consolidation LLM's token budget.
reserve_tokens: additional tokens to reserve for dedup context or other
overhead that will be appended after truncation.
"""
budget = self._input_token_budget - reserve_tokens
def _truncate_to_token_budget(self, text: str) -> str:
"""Truncate text so it fits within the consolidation LLM's token budget."""
budget = self._input_token_budget
if budget <= 0:
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
if _TIKTOKEN_ENC is not None:
tokens = _TIKTOKEN_ENC.encode(text)
try:
enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode(text)
if len(tokens) <= budget:
return text
return _TIKTOKEN_ENC.decode(tokens[:budget]) + "\n... (truncated)"
return truncate_text(text, budget * 4)
return enc.decode(tokens[:budget]) + "\n... (truncated)"
except Exception:
return truncate_text(text, budget * 4)
async def archive(self, messages: list[dict]) -> str | None:
"""Summarize messages via LLM and append to history.jsonl.
@@ -676,53 +639,9 @@ class Consolidator:
"""
if not messages:
return None
t_start = time.perf_counter()
try:
formatted = MemoryStore._format_messages(messages)
logger.debug(
"Consolidator: {} messages, formatted={} chars",
len(messages), len(formatted),
)
# Inject current memory context for dedup-aware summarization.
memory_preview = self.store.read_memory()[:4000]
user_preview = self.store.read_user()[:2000]
dedup_context = ""
if memory_preview:
dedup_context += f"\n\n## Current MEMORY.md (for dedup)\n{memory_preview}"
if user_preview:
dedup_context += f"\n\n## Current USER.md (for dedup)\n{user_preview}"
reserve_tokens = 0
if dedup_context:
if _TIKTOKEN_ENC is not None:
reserve_tokens = len(_TIKTOKEN_ENC.encode(dedup_context)) + 100
else:
reserve_tokens = len(dedup_context) // 4 + 100
if self._input_token_budget <= reserve_tokens:
logger.warning(
"Consolidator: dedup_context ({} tokens) exceeds budget ({}), dropping it",
reserve_tokens, self._input_token_budget,
)
dedup_context = ""
reserve_tokens = 0
else:
logger.debug(
"Consolidator: dedup_context={} chars, reserve_tokens={}",
len(dedup_context), reserve_tokens,
)
formatted_before = len(formatted)
formatted = self._truncate_to_token_budget(
formatted, reserve_tokens=reserve_tokens
)
if len(formatted) < formatted_before:
logger.warning(
"Consolidator: truncated formatted messages from {} to {} chars",
formatted_before, len(formatted),
)
formatted = self._truncate_to_token_budget(formatted)
response = await self.provider.chat_with_retry(
model=self.model,
messages=[
@@ -733,31 +652,18 @@ class Consolidator:
strip=True,
),
},
{"role": "user", "content": formatted + dedup_context},
{"role": "user", "content": formatted},
],
tools=None,
tool_choice=None,
)
elapsed = time.perf_counter() - t_start
if response.finish_reason == "error":
logger.warning(
"Consolidator LLM error after {:.1f}s: {}",
elapsed, response.content,
)
raise RuntimeError(f"LLM returned error: {response.content}")
summary = response.content or "[no summary]"
logger.info(
"Consolidator: {} entries -> {} chars summary in {:.1f}s",
len(messages), len(summary), elapsed,
)
self.store.append_history(summary, max_chars=_ARCHIVE_SUMMARY_MAX_CHARS)
return summary
except Exception:
elapsed = time.perf_counter() - t_start
logger.warning(
"Consolidation LLM call failed after {:.1f}s, raw-dumping to history",
elapsed,
)
logger.warning("Consolidation LLM call failed, raw-dumping to history")
self.store.raw_archive(messages)
return None
@@ -945,48 +851,38 @@ class Consolidator:
# Single source of truth for the staleness threshold used in _annotate_with_ages
# *and* in the system prompt template (passed as `stale_threshold_days`).
# *and* in the Phase 1 prompt template (passed as `stale_threshold_days`).
# Keep code and prompt aligned — if you bump this, the LLM's instruction string
# updates automatically.
_STALE_THRESHOLD_DAYS = 14
_SKIP_LINE_RE = re.compile(r"^\s*-\s*\[skip\]\s*.*$", re.MULTILINE | re.IGNORECASE)
def _strip_skip_lines(text: str) -> str:
"""Remove lines marked [skip] from history content."""
lines = text.splitlines()
kept = [line for line in lines if not _SKIP_LINE_RE.match(line)]
return "\n".join(kept)
class Dream:
"""Single-phase memory processor: analyze history.jsonl and edit files via AgentRunner.
"""Two-phase memory processor: analyze history.jsonl, then edit files via AgentRunner.
Delegates to AgentRunner with read_file / edit_file tools so the LLM can
analyze conversation history, extract facts, deduplicate, and make targeted
incremental edits — all in a single agent run.
Phase 1 produces an analysis summary (plain LLM call).
Phase 2 delegates to AgentRunner with read_file / edit_file tools so the
LLM can make targeted, incremental edits instead of replacing entire files.
"""
# Caps on prompt-bound inputs so Dream's LLM calls never exceed the model's
# context window just because a file (or a legacy large history entry) grew
# unexpectedly. Each file still appears in full via read_file when the agent
# needs it — these caps only bound the prompt preview.
_MEMORY_FILE_MAX_CHARS = 16_000
_SOUL_FILE_MAX_CHARS = 4_000
_USER_FILE_MAX_CHARS = 4_000
_HISTORY_ENTRY_PREVIEW_MAX_CHARS = 2_000
# needs it in Phase 2 — these caps only bound the Phase 1/2 prompt preview.
_MEMORY_FILE_MAX_CHARS = 32_000
_SOUL_FILE_MAX_CHARS = 16_000
_USER_FILE_MAX_CHARS = 16_000
_HISTORY_ENTRY_PREVIEW_MAX_CHARS = 4_000
def __init__(
self,
store: MemoryStore,
provider: LLMProvider,
model: str,
max_batch_size: int = 5,
max_batch_size: int = 20,
max_iterations: int = 10,
max_tool_result_chars: int = 16_000,
annotate_line_ages: bool = True,
edit_user_skills: bool = False,
):
self.store = store
self.provider = provider
@@ -994,13 +890,10 @@ class Dream:
self.max_batch_size = max_batch_size
self.max_iterations = max_iterations
self.max_tool_result_chars = max_tool_result_chars
# Kill switch for the git-blame-based per-line age annotation in the prompt.
# Default True keeps the #3212 behavior; set False to feed all memory
# files raw (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
# Kill switch for the git-blame-based per-line age annotation in Phase 1.
# Default True keeps the #3212 behavior; set False to feed MEMORY.md raw
# (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
self.annotate_line_ages = annotate_line_ages
# When True, Dream may edit/delete user-created workspace skills.
# When False, only skills with dream_managed: true in frontmatter are editable.
self.edit_user_skills = edit_user_skills
self._runner = AgentRunner(provider)
self._tools = self._build_tools()
@@ -1014,7 +907,6 @@ class Dream:
def _build_tools(self) -> ToolRegistry:
"""Build a minimal tool registry for the Dream agent."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.agent.tools.apply_patch import ApplyPatchTool
from nanobot.agent.tools.file_state import FileStates
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
@@ -1032,7 +924,6 @@ class Dream:
file_states=file_states,
))
tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace, file_states=file_states))
tools.register(ApplyPatchTool(workspace=workspace, allowed_dir=workspace, file_states=file_states))
# write_file resolves relative paths from workspace root, but can only
# write under skills/ so the prompt can safely use skills/<name>/SKILL.md.
skills_dir = workspace / "skills"
@@ -1042,25 +933,15 @@ class Dream:
# -- skill listing --------------------------------------------------------
def _list_existing_skills(self, tag_origin: bool = False) -> list[str]:
"""List existing skills as 'name — description [origin]' for dedup context.
When *tag_origin* is True each entry gets an origin tag:
``[dream]`` for skills with ``dream_managed: true`` in frontmatter,
``[user]`` for other workspace skills, ``[builtin]`` for bundled skills.
"""
def _list_existing_skills(self) -> list[str]:
"""List existing skills as 'name — description' for dedup context."""
import re as _re
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
desc_re = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
managed_re = _re.compile(r"^dream_managed:\s*true$", _re.MULTILINE | _re.IGNORECASE)
entries: dict[str, tuple[str, str]] = {} # name -> (desc, tag)
builtin_dir = BUILTIN_SKILLS_DIR
ws_skills_dir = self.store.workspace / "skills"
for base in (ws_skills_dir, builtin_dir):
entries: dict[str, str] = {}
for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
if not base.exists():
continue
for d in base.iterdir():
@@ -1070,31 +951,18 @@ class Dream:
if not skill_md.exists():
continue
# Prefer workspace skills over builtin (same name)
if d.name in entries and base == builtin_dir:
if d.name in entries and base == BUILTIN_SKILLS_DIR:
continue
content = skill_md.read_text(encoding="utf-8")[:500]
m = desc_re.search(content)
desc = m.group(1).strip() if m else "(no description)"
if tag_origin:
if base == builtin_dir:
tag = "[builtin]"
elif managed_re.search(content):
tag = "[dream]"
else:
tag = "[user]"
entries[d.name] = (desc, tag)
else:
entries[d.name] = (desc, "")
if tag_origin:
return [f"{name}{desc} {tag}" for name, (desc, tag) in sorted(entries.items())]
return [f"{name}{desc}" for name, (desc, _) in sorted(entries.items())]
entries[d.name] = desc
return [f"{name}{desc}" for name, desc in sorted(entries.items())]
# -- main entry ----------------------------------------------------------
def _annotate_with_ages(self, content: str, file_path: str = "memory/MEMORY.md") -> str:
"""Append per-line age suffixes to file content.
def _annotate_with_ages(self, content: str) -> str:
"""Append per-line age suffixes to MEMORY.md content.
Each non-blank line whose age exceeds ``_STALE_THRESHOLD_DAYS`` gets a
suffix like ``← 30d`` indicating days since last modification.
@@ -1102,7 +970,9 @@ class Dream:
annotate fails, or the line count doesn't match the age count
(which can happen with an uncommitted working-tree edit — better to
skip annotation than to tag the wrong line).
SOUL.md and USER.md are never annotated.
"""
file_path = "memory/MEMORY.md"
try:
ages = self.store.git.line_ages(file_path)
except Exception:
@@ -1137,3 +1007,156 @@ class Dream:
result += "\n"
return result
async def run(self) -> bool:
"""Process unprocessed history entries. Returns True if work was done."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
last_cursor = self.store.get_last_dream_cursor()
entries = self.store.read_unprocessed_history(since_cursor=last_cursor)
if not entries:
return False
batch = entries[: self.max_batch_size]
logger.info(
"Dream: processing {} entries (cursor {}{}), batch={}",
len(entries), last_cursor, batch[-1]["cursor"], len(batch),
)
# Build history text for LLM — cap each entry so a legacy oversized
# record (e.g. pre-#3412 raw_archive dump) can't blow up the prompt.
history_text = "\n".join(
f"[{e['timestamp']}] "
f"{truncate_text(e['content'], self._HISTORY_ENTRY_PREVIEW_MAX_CHARS)}"
for e in batch
)
# Current file contents + per-line age annotations (MEMORY.md only).
# Each file is capped in the *prompt preview* only; Phase 2 still sees
# the full file via the read_file tool.
current_date = datetime.now().strftime("%Y-%m-%d")
raw_memory = self.store.read_memory() or "(empty)"
annotated_memory = (
self._annotate_with_ages(raw_memory)
if self.annotate_line_ages
else raw_memory
)
current_memory = truncate_text(annotated_memory, self._MEMORY_FILE_MAX_CHARS)
current_soul = truncate_text(
self.store.read_soul() or "(empty)", self._SOUL_FILE_MAX_CHARS,
)
current_user = truncate_text(
self.store.read_user() or "(empty)", self._USER_FILE_MAX_CHARS,
)
file_context = (
f"## Current Date\n{current_date}\n\n"
f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
f"## Current SOUL.md ({len(current_soul)} chars)\n{current_soul}\n\n"
f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
)
# Phase 1: Analyze (no skills list — dedup is Phase 2's job)
phase1_prompt = (
f"## Conversation History\n{history_text}\n\n{file_context}"
)
try:
phase1_response = await self.provider.chat_with_retry(
model=self.model,
messages=[
{
"role": "system",
"content": render_template(
"agent/dream_phase1.md",
strip=True,
stale_threshold_days=_STALE_THRESHOLD_DAYS,
),
},
{"role": "user", "content": phase1_prompt},
],
tools=None,
tool_choice=None,
)
analysis = phase1_response.content or ""
logger.debug("Dream Phase 1 analysis ({} chars): {}", len(analysis), analysis[:500])
except Exception:
logger.exception("Dream Phase 1 failed")
return False
# Phase 2: Delegate to AgentRunner with read_file / edit_file
existing_skills = self._list_existing_skills()
skills_section = ""
if existing_skills:
skills_section = (
"\n\n## Existing Skills\n"
+ "\n".join(f"- {s}" for s in existing_skills)
)
phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}{skills_section}"
tools = self._tools
skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"
messages: list[dict[str, Any]] = [
{
"role": "system",
"content": render_template(
"agent/dream_phase2.md",
strip=True,
skill_creator_path=str(skill_creator_path),
),
},
{"role": "user", "content": phase2_prompt},
]
try:
result = await self._runner.run(AgentRunSpec(
initial_messages=messages,
tools=tools,
model=self.model,
max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars,
fail_on_tool_error=False,
))
logger.debug(
"Dream Phase 2 complete: stop_reason={}, tool_events={}",
result.stop_reason, len(result.tool_events),
)
for ev in (result.tool_events or []):
logger.info("Dream tool_event: name={}, status={}, detail={}", ev.get("name"), ev.get("status"), ev.get("detail", "")[:200])
except Exception:
logger.exception("Dream Phase 2 failed")
result = None
# Build changelog from tool events
changelog: list[str] = []
if result and result.tool_events:
for event in result.tool_events:
if event["status"] == "ok":
changelog.append(f"{event['name']}: {event['detail']}")
# Only advance cursor on successful completion to prevent silent loss
if result and result.stop_reason == "completed":
new_cursor = batch[-1]["cursor"]
self.store.set_last_dream_cursor(new_cursor)
logger.info(
"Dream done: {} change(s), cursor advanced to {}",
len(changelog), new_cursor,
)
else:
reason = result.stop_reason if result else "exception"
logger.warning(
"Dream incomplete ({}): cursor NOT advanced, will retry next cron cycle",
reason,
)
self.store.compact_history()
# Git auto-commit (only when there are actual changes)
if changelog and self.store.git.is_initialized():
ts = batch[-1]["timestamp"]
summary = f"dream: {ts}, {len(changelog)} change(s)"
commit_msg = f"{summary}\n\n{analysis.strip()}"
sha = self.store.git.auto_commit(commit_msg)
if sha:
logger.info("Dream commit: {}", sha)
return True
+1 -10
View File
@@ -8,7 +8,7 @@ import os
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from typing import Any
from loguru import logger
@@ -42,7 +42,6 @@ from nanobot.utils.prompt_templates import render_template
from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE,
build_finalization_retry_message,
build_goal_continue_message,
build_length_recovery_message,
ensure_nonempty_tool_result,
is_blank_text,
@@ -98,8 +97,6 @@ class AgentRunSpec:
checkpoint_callback: Any | None = None
injection_callback: Any | None = None
llm_timeout_s: float | None = None
goal_active_predicate: Callable[[], bool] | None = None
goal_continue_message: str | None = None
@dataclass(slots=True)
@@ -170,7 +167,6 @@ class AgentRunner:
*,
phase: str = "after error",
iteration: int | None = None,
allow_goal_continue: bool = False,
) -> tuple[bool, int]:
"""Drain pending injections. Returns (should_continue, updated_cycles).
@@ -182,10 +178,6 @@ class AgentRunner:
if injection_cycles >= _MAX_INJECTION_CYCLES:
return False, injection_cycles
injections = await self._drain_injections(spec)
if not injections and allow_goal_continue and assistant_message is not None:
predicate = spec.goal_active_predicate
if predicate is not None and predicate():
injections = [build_goal_continue_message(spec.goal_continue_message)]
if not injections:
return False, injection_cycles
injection_cycles += 1
@@ -483,7 +475,6 @@ class AgentRunner:
spec, messages, assistant_message, injection_cycles,
phase="after final response",
iteration=iteration,
allow_goal_continue=True,
)
if should_continue:
had_injections = True
+2 -12
View File
@@ -79,7 +79,6 @@ class SubagentManager:
restrict_to_workspace: bool = False,
disabled_skills: list[str] | None = None,
max_iterations: int | None = None,
max_concurrent_subagents: int | None = None,
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
):
defaults = AgentDefaults()
@@ -96,11 +95,7 @@ class SubagentManager:
if max_iterations is not None
else defaults.max_tool_iterations
)
self.max_concurrent_subagents = (
max_concurrent_subagents
if max_concurrent_subagents is not None
else defaults.max_concurrent_subagents
)
self.max_concurrent_subagents = defaults.max_concurrent_subagents
self.runner = AgentRunner(provider)
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
self._running_tasks: dict[str, asyncio.Task[None]] = {}
@@ -145,7 +140,6 @@ class SubagentManager:
origin_chat_id: str = "direct",
session_key: str | None = None,
origin_message_id: str | None = None,
temperature: float | None = None,
) -> str:
"""Spawn a subagent to execute a task in the background."""
task_id = str(uuid.uuid4())[:8]
@@ -161,9 +155,7 @@ class SubagentManager:
self._task_statuses[task_id] = status
bg_task = asyncio.create_task(
self._run_subagent(
task_id, task, display_label, origin, status, origin_message_id, temperature
)
self._run_subagent(task_id, task, display_label, origin, status, origin_message_id)
)
self._running_tasks[task_id] = bg_task
if session_key:
@@ -190,7 +182,6 @@ class SubagentManager:
origin: dict[str, str],
status: SubagentStatus,
origin_message_id: str | None = None,
temperature: float | None = None,
) -> None:
"""Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label)
@@ -217,7 +208,6 @@ class SubagentManager:
initial_messages=messages,
tools=tools,
model=self.model,
temperature=temperature,
max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars,
hook=_SubagentHook(task_id, status),
+1 -1
View File
@@ -9,7 +9,7 @@ from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import Base
+3 -4
View File
@@ -53,15 +53,14 @@ class _ExecSession:
process: asyncio.subprocess.Process,
command: str,
cwd: str,
timeout: int | None,
timeout: int,
) -> None:
self.session_id = session_id
self.process = process
self.command = command
self.cwd = cwd
self.started_at = time.monotonic()
# timeout None/0 means no limit; an infinite deadline is never reached.
self.deadline = time.monotonic() + timeout if timeout else float("inf")
self.deadline = time.monotonic() + timeout
self.last_access = time.monotonic()
self._chunks: list[str] = []
self._lock = asyncio.Lock()
@@ -170,7 +169,7 @@ class ExecSessionManager:
command: str,
cwd: str,
env: dict[str, str],
timeout: int | None,
timeout: int,
shell_program: str | None,
login: bool,
yield_time_ms: int,
+1 -279
View File
@@ -6,20 +6,13 @@ import re
import shutil
import urllib.parse
from contextlib import AsyncExitStack, suppress
from typing import Any, Mapping
from weakref import WeakKeyDictionary
from typing import Any
import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_ACK,
RUNTIME_CONTROL_MCP_RELOAD,
InboundMessage,
)
# Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network
@@ -40,7 +33,6 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
_SANITIZE_RE = re.compile(r"_+")
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
def _sanitize_name(name: str) -> str:
@@ -511,7 +503,6 @@ async def connect_mcp_servers(
command=command,
args=args,
env=env,
cwd=cfg.cwd or None,
)
read, write = await server_stack.enter_async_context(stdio_client(params))
elif transport_type == "sse":
@@ -671,272 +662,3 @@ async def connect_mcp_servers(
server_stacks[result[0]] = result[1]
return server_stacks
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted session kwargs for MCP preset attachments."""
mcp_presets = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
def runtime_lines(
message: Any,
*,
available_server_names: set[str] | None = None,
configured_server_names: set[str] | None = None,
connected_server_names: set[str] | None = None,
skip: bool = False,
) -> list[str]:
"""Return model-visible MCP preset annotations for the current turn."""
if skip:
return []
if configured_server_names is None:
configured_server_names = available_server_names
if connected_server_names is None:
connected_server_names = available_server_names
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
structured = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
if not isinstance(structured, list):
return []
lines: list[str] = []
for item in structured[:8]:
if not isinstance(item, Mapping):
continue
raw_name = str(item.get("name") or "").strip().lower()
if not raw_name:
continue
display = str(item.get("display_name") or raw_name).strip() or raw_name
transport = str(item.get("transport") or "mcp").strip() or "mcp"
prefix = f"mcp_{raw_name}_"
if configured_server_names is not None and raw_name not in configured_server_names:
lines.append(
"MCP Preset Attachment: "
f"@{raw_name} ({display}; transport={transport}) is configured in WebUI Settings, "
"but this gateway has not loaded the latest MCP settings yet. "
f"Tools with prefix `{prefix}` may not be available yet; if they are missing, "
"tell the user to restart nanobot."
)
continue
if connected_server_names is not None and raw_name not in connected_server_names:
lines.append(
"MCP Preset Attachment: "
f"@{raw_name} ({display}; transport={transport}) is configured, "
"but its MCP connection is not currently live. "
f"Tools with prefix `{prefix}` may be unavailable; tell the user to open Settings, "
"run the preset test, and restart nanobot only if hot reload is unavailable."
)
continue
lines.append(
"MCP Preset Attachment: "
f"@{raw_name} ({display}; transport={transport}; tool_prefix={prefix}). "
f"Prefer available tools whose names start with `{prefix}` for this request; "
"do not substitute shell commands for this MCP integration unless the user asks."
)
return lines
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
"""Connect configured MCP servers that are not currently live."""
missing_servers = {
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
}
if state._mcp_connecting or not missing_servers:
return
state._mcp_connecting = True
try:
connected = await connect_mcp_servers(missing_servers, registry)
state._mcp_stacks.update(connected)
state._mcp_connected = bool(state._mcp_stacks)
if connected:
logger.info("MCP connected servers: {}", sorted(connected))
else:
logger.warning("No MCP servers connected successfully (will retry next message)")
except asyncio.CancelledError:
logger.warning("MCP connection cancelled (will retry next message)")
state._mcp_connected = bool(state._mcp_stacks)
except BaseException as e:
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
state._mcp_connected = bool(state._mcp_stacks)
finally:
state._mcp_connecting = False
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"""Reconcile live MCP connections with the current config file."""
async with _reload_lock(state):
try:
from nanobot.config.loader import (load_config,
resolve_config_env_vars)
config = resolve_config_env_vars(load_config())
next_servers = dict(config.tools.mcp_servers)
except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc)
return {
"ok": False,
"message": "Could not reload MCP config. Restart nanobot to pick up changes.",
"requires_restart": True,
"error": str(exc),
}
current_servers = dict(state._mcp_servers)
current_names = set(current_servers)
next_names = set(next_servers)
removed = sorted(current_names - next_names)
added = sorted(next_names - current_names)
changed = sorted(
name
for name in current_names & next_names
if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
)
tools_removed = 0
for name in [*removed, *changed]:
tools_removed += _unregister_server_tools(state, registry, name)
await _close_server(state, name)
state._mcp_servers = next_servers
retry_missing = sorted(
name
for name in next_names
if name not in state._mcp_stacks and name not in set(added) | set(changed)
)
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, AsyncExitStack] = {}
if to_connect:
connected = await connect_mcp_servers(to_connect, registry)
state._mcp_stacks.update(connected)
state._mcp_connected = bool(state._mcp_stacks)
failed = sorted(set(to_connect) - set(connected))
unchanged = not removed and not added and not changed and not retry_missing
ok = not failed
if failed:
message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed)
elif unchanged:
message = "MCP config is already live."
elif retry_missing and not added and not changed and not removed:
message = "MCP connections refreshed without restarting nanobot."
else:
message = "MCP config reloaded without restarting nanobot."
logger.info(
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
added,
changed,
removed,
retry_missing,
sorted(connected),
failed,
tools_removed,
)
return {
"ok": ok,
"message": message,
"added": added,
"changed": changed,
"removed": removed,
"retried": retry_missing,
"connected": sorted(state._mcp_stacks),
"configured": sorted(state._mcp_servers),
"failed": failed,
"tools_removed": tools_removed,
"requires_restart": False,
}
async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]:
"""Ask the running agent loop to reconcile live MCP connections."""
loop = asyncio.get_running_loop()
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
await bus.publish_inbound(
InboundMessage(
channel="system",
sender_id="webui-settings",
chat_id="runtime",
content=RUNTIME_CONTROL_MCP_RELOAD,
metadata={
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
RUNTIME_CONTROL_ACK: ack,
},
)
)
try:
result = await asyncio.wait_for(ack, timeout=timeout)
except asyncio.TimeoutError:
return {
"ok": False,
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
"requires_restart": True,
}
return result if isinstance(result, dict) else {
"ok": False,
"message": "MCP hot reload returned an unexpected response.",
"requires_restart": True,
}
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
if control != RUNTIME_CONTROL_MCP_RELOAD:
return False
ack = metadata.get(RUNTIME_CONTROL_ACK)
try:
result = await reload_servers(state, registry)
except Exception as exc:
logger.exception("MCP hot reload failed")
result = {
"ok": False,
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
"requires_restart": True,
"error": str(exc),
}
if isinstance(ack, asyncio.Future) and not ack.done():
ack.set_result(result)
return True
def _reload_lock(state: Any) -> asyncio.Lock:
try:
return _RELOAD_LOCKS[state]
except KeyError:
lock = asyncio.Lock()
_RELOAD_LOCKS[state] = lock
return lock
def _server_signature(cfg: Any) -> Any:
if hasattr(cfg, "model_dump"):
return cfg.model_dump(mode="json")
return cfg
def _tool_prefix(server_name: str) -> str:
safe_name = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in server_name)
while "__" in safe_name:
safe_name = safe_name.replace("__", "_")
return f"mcp_{safe_name}_"
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
prefix = _tool_prefix(server_name)
removed = 0
for tool_name in list(registry.tool_names):
if tool_name.startswith(prefix):
registry.unregister(tool_name)
removed += 1
return removed
async def _close_server(state: Any, server_name: str) -> None:
stack = state._mcp_stacks.pop(server_name, None)
if stack is None:
return
try:
await stack.aclose()
except (RuntimeError, BaseExceptionGroup):
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
+3 -17
View File
@@ -46,7 +46,7 @@ _WORKSPACE_BOUNDARY_NOTE = (
class ExecToolConfig(Base):
"""Shell exec tool configuration."""
enable: bool = True
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
timeout: int = 60
path_append: str = ""
sandbox: str = ""
allowed_env_keys: list[str] = Field(default_factory=list)
@@ -59,7 +59,7 @@ class _PreparedCommand:
command: str
cwd: str
env: dict[str, str]
timeout: int | None
timeout: int
shell_program: str | None
login: bool
@@ -324,20 +324,6 @@ class ExecTool(Tool):
except Exception as exc:
return f"Error executing command: {exc}"
def _resolve_timeout(self, timeout: int | None) -> int | None:
"""Resolve the effective hard timeout in seconds (None = no limit).
A per-call timeout supplied by the model stays capped at _MAX_TIMEOUT so
the LLM cannot request unbounded execution. The config-level default
(self.timeout) may exceed that cap, and 0 disables the limit entirely
for trusted long-running tasks (#3595).
"""
if timeout:
return min(timeout, self._MAX_TIMEOUT)
if self.timeout and self.timeout > 0:
return self.timeout
return None
def _prepare_command(
self,
command: str,
@@ -383,7 +369,7 @@ class ExecTool(Tool):
command = wrap_command(self.sandbox, command, workspace, cwd)
cwd = str(Path(workspace).resolve())
effective_timeout = self._resolve_timeout(timeout)
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
env = self._build_env()
if self.path_append:
+2 -18
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
@@ -17,15 +17,6 @@ if TYPE_CHECKING:
tool_parameters_schema(
task=StringSchema("The task for the subagent to complete"),
label=StringSchema("Optional short label for the task (for display)"),
temperature=NumberSchema(
description=(
"Optional sampling temperature for the subagent "
"(0.0 = deterministic, higher = more creative). "
"Defaults to the provider's configured temperature."
),
minimum=0.0,
maximum=2.0,
),
required=["task"],
)
)
@@ -67,13 +58,7 @@ class SpawnTool(Tool, ContextAware):
"and use a dedicated subdirectory when helpful."
)
async def execute(
self,
task: str,
label: str | None = None,
temperature: float | None = None,
**kwargs: Any,
) -> str:
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
"""Spawn a subagent to execute the given task."""
running = self._manager.get_running_count()
limit = self._manager.max_concurrent_subagents
@@ -90,5 +75,4 @@ class SpawnTool(Tool, ContextAware):
origin_chat_id=self._origin_chat_id.get(),
session_key=self._session_key.get(),
origin_message_id=self._origin_message_id.get(),
temperature=temperature,
)
-5
View File
@@ -1,5 +0,0 @@
"""Shared app protocol helpers."""
from nanobot.apps.protocol import APP_PROTOCOL_SCHEMA, app_manifest
__all__ = ["APP_PROTOCOL_SCHEMA", "app_manifest"]
-56
View File
@@ -1,56 +0,0 @@
"""Neutral manifest shape for settings-managed agent apps.
The manifest is intentionally descriptive. Installers still live in their
own adapters, while this protocol gives the WebUI and future registries one
small vocabulary for capabilities, trust, and verified install/remove plans.
"""
from __future__ import annotations
from typing import Any
APP_PROTOCOL_SCHEMA = "agent-app.v1"
def compact_dict(values: dict[str, Any]) -> dict[str, Any]:
"""Drop empty optional values while preserving explicit booleans and zeros."""
return {
key: value
for key, value in values.items()
if value is not None and value != "" and value != [] and value != {}
}
def app_manifest(
*,
app_id: str,
display_name: str,
description: str,
category: str,
source: str,
capabilities: list[dict[str, Any]],
install: dict[str, Any],
remove: dict[str, Any],
trust: dict[str, Any],
version: str | None = None,
logo_url: str | None = None,
brand_color: str | None = None,
docs_url: str | None = None,
) -> dict[str, Any]:
"""Build a stable app manifest dictionary."""
return compact_dict({
"schema": APP_PROTOCOL_SCHEMA,
"id": app_id,
"display_name": display_name,
"version": version,
"description": description,
"category": category,
"source": source,
"logo_url": logo_url,
"brand_color": brand_color,
"docs_url": docs_url,
"capabilities": capabilities,
"install": install,
"remove": remove,
"trust": trust,
})
+1 -6
View File
@@ -9,12 +9,6 @@ from typing import Any
# render it and other channels may ignore unknown keys.
OUTBOUND_META_AGENT_UI = "_agent_ui"
# Internal-only inbound metadata used by in-process channels to ask the agent
# loop to update runtime state without going through a user session.
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
@dataclass
class InboundMessage:
@@ -51,3 +45,4 @@ class OutboundMessage:
media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
buttons: list[list[str]] = field(default_factory=list)
+75 -117
View File
@@ -30,7 +30,6 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
@@ -47,7 +46,6 @@ from nanobot.utils.media_decode import (
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.settings_api import (
WebUISettingsError,
create_model_configuration,
settings_payload,
update_agent_settings,
update_image_generation_settings,
@@ -59,32 +57,12 @@ from nanobot.webui.cli_apps_api import (
cli_apps_payload,
normalize_cli_app_mentions,
)
from nanobot.webui.mcp_presets_api import (
mcp_presets_settings_action,
normalize_mcp_preset_mentions,
)
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
)
from nanobot.webui.thread_disk import delete_webui_thread
from nanobot.webui.transcript import (
append_transcript_object,
build_webui_thread_response,
rewrite_local_markdown_images,
)
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom",
"/api/settings/mcp-presets/import": "import",
"/api/settings/mcp-presets/import-cursor": "import-cursor",
"/api/settings/mcp-presets/tools": "tools",
}
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
from nanobot.webui.transcript import append_transcript_object, build_webui_thread_response
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -255,34 +233,6 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
return _parse_request_path(path_with_query)[1]
def _parse_mcp_settings_query(request: WsRequest) -> dict[str, list[str]]:
query = _parse_query(request.path)
raw = request.headers.get(_MCP_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("MCP settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid MCP settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("MCP settings payload must be a JSON object")
merged = {key: list(values) for key, values in query.items()}
for key, value in payload.items():
if not isinstance(key, str) or not key:
raise WebUISettingsError("MCP settings payload contains an invalid key")
if value is None:
continue
if isinstance(value, str):
text = value.strip()
else:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if text:
merged[key] = [text]
return merged
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
"""Return the first value for *key*, or None."""
values = query.get(key)
@@ -475,6 +425,18 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
"video/webm",
"video/quicktime",
})
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
)
_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
".png",
".jpg",
".jpeg",
".webp",
".gif",
})
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
if not configured_secret:
@@ -515,6 +477,10 @@ class WebSocketChannel(BaseChannel):
self._conn_chats: dict[Any, set[str]] = {}
# connection -> default chat_id for legacy frames that omit routing.
self._conn_default: dict[Any, str] = {}
# Chat IDs that opted into WebUI-specific rendering by sending a typed
# envelope with ``webui: true``. Raw WebSocket clients keep the legacy
# wire shape.
self._webui_chats: set[str] = set()
# Single-use tokens consumed at WebSocket handshake.
self._issued_tokens: dict[str, float] = {}
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
@@ -704,9 +670,6 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/update":
return self._handle_settings_update(request)
if got == "/api/settings/model-configurations/create":
return self._handle_settings_model_configuration_create(request)
if got == "/api/settings/provider/update":
return self._handle_settings_provider_update(request)
@@ -731,13 +694,6 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
if got == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(got)
if mcp_action is not None:
return await self._handle_settings_mcp_presets(request, mcp_action)
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
@@ -929,16 +885,6 @@ class WebSocketChannel(BaseChannel):
self._with_settings_restart_state(payload, section="runtime")
)
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = create_model_configuration(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@@ -995,31 +941,6 @@ class WebSocketChannel(BaseChannel):
return _http_error(status, message)
return _http_json_response(payload)
async def _handle_settings_mcp_presets(
self,
request: WsRequest,
action: str | None = None,
) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
try:
payload = await mcp_presets_settings_action(
action,
_parse_mcp_settings_query(request),
reload_mcp=lambda: request_mcp_reload(self.bus),
)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("MCP preset action '{}' failed", action or "list")
return _http_error(status, message)
if action is None:
return _http_json_response(payload)
return _http_json_response(
self._with_settings_restart_state(payload, section="runtime")
)
@staticmethod
def _is_websocket_channel_session_key(key: str) -> bool:
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
@@ -1111,9 +1032,6 @@ class WebSocketChannel(BaseChannel):
cli_apps = meta.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
user_obj["cli_apps"] = cli_apps
mcp_presets = meta.get("mcp_presets")
if isinstance(mcp_presets, list) and mcp_presets:
user_obj["mcp_presets"] = mcp_presets
self._try_append_webui_transcript(chat_id, user_obj)
await super()._handle_message(
sender_id,
@@ -1203,12 +1121,45 @@ class WebSocketChannel(BaseChannel):
return None
return {"url": signed, "name": path.name}
def _markdown_image_url_for_local_path(self, raw_url: str) -> str | None:
url = raw_url.strip()
if url.startswith("<") and url.endswith(">"):
url = url[1:-1].strip()
if not url or url.startswith(("/api/media/", "#")):
return None
parsed = urlparse(url)
if parsed.scheme or parsed.netloc:
return None
if parsed.query or parsed.fragment:
return None
path_text = unquote(url)
if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
return None
candidate = Path(path_text).expanduser()
if not candidate.is_absolute():
candidate = self._workspace_path / candidate
try:
resolved = candidate.resolve(strict=False)
resolved.relative_to(self._workspace_path)
except (OSError, ValueError):
return None
if not resolved.is_file():
return None
signed = self._sign_or_stage_media_path(resolved)
return signed["url"] if signed else None
def _rewrite_local_markdown_images(self, text: str) -> str:
return rewrite_local_markdown_images(
text,
workspace_path=self._workspace_path,
sign_path=self._sign_or_stage_media_path,
)
if "![" not in text:
return text
def replace(match: re.Match[str]) -> str:
signed_url = self._markdown_image_url_for_local_path(match.group(2))
if not signed_url:
return match.group(0)
title = match.group(3) or ""
return f"![{match.group(1)}]({signed_url}{title})"
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
"""Serve a single media file previously signed via
@@ -1581,12 +1532,10 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
if envelope.get("webui") is True:
metadata["webui"] = True
self._webui_chats.add(cid)
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps:
metadata["cli_apps"] = cli_apps
mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets"))
if mcp_presets:
metadata["mcp_presets"] = mcp_presets
image_generation = envelope.get("image_generation")
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
aspect_ratio = image_generation.get("aspect_ratio")
@@ -1620,6 +1569,7 @@ class WebSocketChannel(BaseChannel):
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
self._webui_chats.clear()
self._issued_tokens.clear()
self._api_tokens.clear()
@@ -1698,7 +1648,8 @@ class WebSocketChannel(BaseChannel):
await self._safe_send_to(connection, raw, label=" ")
return
text = msg.content
wire_text = self._rewrite_local_markdown_images(text)
should_rewrite_images = msg.chat_id in self._webui_chats
wire_text = self._rewrite_local_markdown_images(text) if should_rewrite_images else text
payload: dict[str, Any] = {
"event": "message",
"chat_id": msg.chat_id,
@@ -1798,25 +1749,32 @@ class WebSocketChannel(BaseChannel):
return
meta = metadata or {}
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
should_rewrite_images = chat_id in self._webui_chats
transcript_body: dict[str, Any] | None = None
if meta.get("_stream_end"):
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = self._stream_text_buffers.pop(stream_key, [])
if delta:
buffered.append(delta)
full_text = "".join(buffered)
rewritten = self._rewrite_local_markdown_images(full_text)
if rewritten != full_text:
body["text"] = rewritten
if should_rewrite_images:
buffered = self._stream_text_buffers.pop(stream_key, [])
if delta:
buffered.append(delta)
full_text = "".join(buffered)
rewritten = self._rewrite_local_markdown_images(full_text)
if rewritten != full_text or delta:
body["text"] = rewritten
transcript_body = {**body, "text": full_text}
else:
body = {
"event": "delta",
"chat_id": chat_id,
"text": delta,
}
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if should_rewrite_images:
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"]
self._try_append_webui_transcript(chat_id, body)
if transcript_body is not None:
transcript_body["stream_id"] = meta["_stream_id"]
self._try_append_webui_transcript(chat_id, transcript_body or body)
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ")
+9 -8
View File
@@ -762,7 +762,7 @@ def _run_gateway(
)
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.events import OutboundMessage
def _channel_session_key(channel: str, chat_id: str) -> str:
return (
@@ -810,13 +810,13 @@ def _run_gateway(
# Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent."""
# Dream is an internal job — run directly, not through the agent loop.
if job.name == "dream":
await bus.publish_inbound(InboundMessage(
channel="system",
sender_id="dream",
chat_id="dream",
content="",
))
try:
await agent.dream.run()
logger.info("Dream cron job completed")
except Exception:
logger.exception("Dream cron job failed")
return None
from nanobot.utils.evaluator import evaluate_response
@@ -1027,10 +1027,11 @@ def _run_gateway(
await server.serve_forever()
# Register Dream system job (always-on, idempotent on restart)
dream_cfg = config.agents.defaults.dream
if dream_cfg.model_override:
agent.dream.model = dream_cfg.model_override
agent.dream.max_batch_size = dream_cfg.max_batch_size
agent.dream.max_iterations = dream_cfg.max_iterations
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
agent.dream.edit_user_skills = dream_cfg.dream_edit_user_skills
from nanobot.cron.types import CronJob, CronPayload
cron.register_system_job(CronJob(
id="dream",
@@ -1,6 +1,6 @@
"""CLI app adapter for the unified Apps domain."""
"""CLI Apps integration helpers."""
from nanobot.apps.cli.service import (
from nanobot.cli_apps.service import (
CliAppError,
CliAppManager,
CliAppsRuntimeConfig,
@@ -11,14 +11,12 @@ import subprocess
import sys
import time
from dataclasses import dataclass
from importlib import metadata as importlib_metadata
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.paths import get_runtime_subdir
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
@@ -141,7 +139,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
"3mf": ("3mf.io", "#00A1DE"),
"anygen": ("anygen.io", "#111827"),
"anygen": ("anygen.com", "#111827"),
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
@@ -246,29 +244,6 @@ def _pip_uninstall_args_from_command(command: str) -> list[str] | None:
return packages
def _console_script_distribution(entry_point: str) -> str | None:
if not entry_point:
return None
try:
distributions = importlib_metadata.distributions()
except Exception:
return None
for distribution in distributions:
try:
entry_points = distribution.entry_points
except Exception:
continue
for item in entry_points:
if item.group != "console_scripts" or item.name != entry_point:
continue
try:
name = distribution.metadata.get("Name")
except Exception:
name = None
return str(name or getattr(distribution, "name", "") or "").strip() or None
return None
def _brand_key(value: str) -> str:
return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-")
@@ -565,86 +540,8 @@ class CliAppManager:
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": self._skill_path(name).is_file(),
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
}
def _package_ref(self, app: dict[str, Any]) -> dict[str, Any] | None:
strategy = self._strategy(app)
name = ""
if strategy == "pip":
try:
uninstall = self._pip_uninstall_argv(app)
except CliAppError:
uninstall = None
name = uninstall[-1] if uninstall else ""
elif strategy == "npm":
name = str(app.get("npm_package") or "").strip()
elif strategy in {"brew", "uv"}:
try:
uninstall = self._argv_for_action(app, "uninstall")
except CliAppError:
uninstall = None
if uninstall:
name = uninstall[-1]
if not strategy or strategy in {"unsupported", "bundled"}:
return None
return compact_dict({"manager": strategy, "name": name})
def _manifest_payload(
self,
app: dict[str, Any],
*,
logo_url: str | None,
brand_color: str | None,
) -> dict[str, Any]:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
capabilities = [
compact_dict({
"type": "cli",
"entry_point": entry_point,
"package": self._package_ref(app),
}),
{"type": "skill", "path": skill_path},
]
install_supported = self._install_supported(app)
install = compact_dict({
"supported": install_supported,
"strategy": strategy,
"managed_paths": [skill_path],
"verification": ["entry_point_available"] if entry_point else [],
})
remove = compact_dict({
"supported": strategy != "unsupported",
"strategy": strategy,
"managed_paths": [skill_path],
"verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"}
else ["nanobot_state_absent", "managed_paths_absent"]
),
})
return app_manifest(
app_id=name,
display_name=str(app.get("display_name") or name),
version=str(app.get("version") or ""),
description=str(app.get("description") or ""),
category=str(app.get("category") or "uncategorized"),
source=f"cli-anything:{app.get('_source') or 'harness'}",
logo_url=logo_url,
brand_color=brand_color,
capabilities=capabilities,
install=install,
remove=remove,
trust={
"registry": "cli-anything",
"level": "catalog",
"review_status": "catalog_entry",
},
)
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]:
apps, updated = self.catalog(force_refresh=force_refresh)
installed = self._load_installed()
@@ -684,14 +581,7 @@ class CliAppManager:
prefix.extend(["--upgrade", "--force-reinstall"])
return prefix + args
def _pip_uninstall_argv(
self,
app: dict[str, Any],
installed_entry: dict[str, Any] | None = None,
) -> list[str]:
distribution = str((installed_entry or {}).get("pip_distribution") or "").strip()
if distribution:
return [sys.executable, "-m", "pip", "uninstall", "-y", distribution]
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
uninstall_cmd = str(app.get("uninstall_cmd") or "")
packages = _pip_uninstall_args_from_command(uninstall_cmd)
if packages:
@@ -729,19 +619,14 @@ class CliAppManager:
raise CliAppError(f"unsupported {expected} command")
return argv
def _argv_for_action(
self,
app: dict[str, Any],
action: str,
installed_entry: dict[str, Any] | None = None,
) -> list[str] | None:
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
strategy = self._strategy(app)
if strategy == "pip":
if action == "install":
return self._pip_install_argv(app)
if action == "update":
return self._pip_install_argv(app, update=True)
return self._pip_uninstall_argv(app, installed_entry=installed_entry)
return self._pip_uninstall_argv(app)
if strategy == "npm":
return self._npm_argv(app, action)
if strategy == "brew":
@@ -763,23 +648,13 @@ class CliAppManager:
)
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
entry: dict[str, Any] = {
return {
"version": app.get("version") or "unknown",
"entry_point": entry_point,
"entry_point": app.get("entry_point") or "",
"source": app.get("_source") or "harness",
"strategy": strategy,
"strategy": self._strategy(app),
"installed_at": int(_now()),
}
resolved = shutil.which(entry_point) if entry_point else None
if resolved:
entry["entry_point_path"] = resolved
if strategy == "pip":
distribution = _console_script_distribution(entry_point)
if distribution:
entry["pip_distribution"] = distribution
return entry
def _fetch_skill_content(self, app: dict[str, Any]) -> str | None:
skill_md = str(app.get("skill_md") or "").strip()
@@ -855,13 +730,11 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
def _record_installed(self, app: dict[str, Any]) -> None:
installed = self._load_installed()
entry = self._installed_entry(app)
installed[str(app["name"])] = entry
installed[str(app["name"])] = self._installed_entry(app)
self._save_installed(installed)
self.install_skill(app)
return entry
def install(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
@@ -872,14 +745,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
if detect_cmd and _command_exists(detect_cmd):
self._record_installed(app)
return self.payload() | {
"last_action": {
"ok": True,
"message": f"CLI for {app['display_name']} is available.",
"installed": True,
"verification": ["entry_point_available", "state_recorded"],
}
}
return self.payload() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}}
note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app."
raise CliAppError(str(note))
argv = self._argv_for_action(app, "install")
@@ -888,14 +754,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500)
self._record_installed(app)
return self.payload() | {
"last_action": {
"ok": True,
"message": f"Installed CLI for {app['display_name']}.",
"installed": True,
"verification": ["package_manager_ok", "state_recorded", "managed_paths_present"],
}
}
return self.payload() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}}
def update(self, name: str) -> dict[str, Any]:
app = self.get_app(name, force_refresh=True)
@@ -903,94 +762,30 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
raise CliAppError("CLI app is not installed")
if self._strategy(app) == "bundled":
self._record_installed(app)
return self.payload() | {
"last_action": {
"ok": True,
"message": f"Checked {app['display_name']}.",
"installed": True,
"verification": ["state_recorded"],
}
}
return self.payload() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}}
argv = self._argv_for_action(app, "update")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500)
self._record_installed(app)
return self.payload() | {
"last_action": {
"ok": True,
"message": f"Updated CLI for {app['display_name']}.",
"installed": True,
"verification": ["package_manager_ok", "state_recorded", "managed_paths_present"],
}
}
return self.payload() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}}
def uninstall(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
installed = self._load_installed()
if str(app["name"]) not in installed:
raise CliAppError("CLI app is not installed")
raw_installed_entry = installed.get(str(app["name"]))
installed_entry = raw_installed_entry if isinstance(raw_installed_entry, dict) else {}
strategy = self._strategy(app)
entry_point = str(app.get("entry_point") or "").strip()
managed_entry_path = str(installed_entry.get("entry_point_path") or "").strip()
if strategy != "bundled":
argv = self._argv_for_action(app, "uninstall", installed_entry=installed_entry)
if self._strategy(app) != "bundled":
argv = self._argv_for_action(app, "uninstall")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "uninstall failed"), status=500)
still_managed = bool(managed_entry_path and Path(managed_entry_path).exists())
still_available = bool(entry_point and shutil.which(entry_point))
if still_managed or (not managed_entry_path and still_available):
reason = (
f"the recorded entry point at {managed_entry_path} still exists"
if still_managed
else f"{entry_point} is still available on PATH"
)
message = (
f"Uninstall for {app['display_name']} completed, but {reason}, "
"so nanobot kept it installed."
)
return self.payload() | {
"last_action": {
"ok": False,
"message": message,
"removed": False,
"still_available": True,
"verification_failed": ["entry_point_absent"],
}
}
else:
still_available = bool(entry_point and shutil.which(entry_point))
installed.pop(str(app["name"]), None)
self._save_installed(installed)
self.remove_skill(str(app["name"]))
if strategy == "bundled" and still_available:
message = (
f"Removed {app['display_name']} from nanobot. {entry_point} "
"is still available because it is managed outside nanobot."
)
elif still_available:
message = (
f"Uninstalled CLI for {app['display_name']}, but another {entry_point} "
"is still available on PATH."
)
else:
message = f"Uninstalled CLI for {app['display_name']}."
return self.payload() | {
"last_action": {
"ok": True,
"message": message,
"removed": True,
"still_available": still_available,
"verification": ["state_absent", "managed_paths_absent"]
if still_available
else ["entry_point_absent", "state_absent", "managed_paths_absent"],
}
}
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}}
def test(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
@@ -46,7 +46,7 @@ def _cli_app_runtime_lines(
if "@" not in text:
return []
try:
from nanobot.apps.cli import CliAppManager
from nanobot.cli_apps import CliAppManager
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
except Exception:
+24 -33
View File
@@ -299,22 +299,30 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
"""Manually trigger a Dream consolidation run."""
from nanobot.bus.events import InboundMessage
import time
await ctx.loop.bus.publish_inbound(InboundMessage(
channel="system",
sender_id="dream",
chat_id="dream",
content="",
metadata={
"trigger_channel": ctx.msg.channel,
"trigger_chat_id": ctx.msg.chat_id,
},
))
loop = ctx.loop
msg = ctx.msg
async def _run_dream():
t0 = time.monotonic()
try:
did_work = await loop.dream.run()
elapsed = time.monotonic() - t0
if did_work:
content = f"Dream completed in {elapsed:.1f}s."
else:
content = "Dream: nothing to process."
except Exception as e:
elapsed = time.monotonic() - t0
content = f"Dream failed after {elapsed:.1f}s: {e}"
await loop.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
))
asyncio.create_task(_run_dream())
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content="Dream started. It will process memory backlog and report when done.",
channel=msg.channel, chat_id=msg.chat_id, content="Dreaming...",
)
@@ -347,18 +355,6 @@ def _format_changed_files(diff: str) -> str:
def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str:
files_line = _format_changed_files(diff)
msg_lines = commit.message.splitlines() if commit.message else []
msg_summary = msg_lines[0] if msg_lines else ""
msg_body = []
in_body = False
for line in msg_lines[1:]:
if not in_body:
if not line:
in_body = True
continue
msg_body.append(line)
body_text = "\n".join(msg_body).strip()
lines = [
"## Dream Update",
"",
@@ -366,12 +362,8 @@ def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None =
"",
f"- Commit: `{commit.sha}`",
f"- Time: {commit.timestamp}",
f"- Changed files: {files_line}",
]
if msg_summary:
lines.append(f"- Summary: {msg_summary}")
lines.append(f"- Changed files: {files_line}")
if body_text:
lines.extend(["", "### Analysis", "", body_text])
if diff:
lines.extend([
"",
@@ -397,8 +389,7 @@ def _format_dream_restore_list(commits: list) -> str:
"",
]
for c in commits:
summary = c.message.splitlines()[0] if c.message else "(no message)"
lines.append(f"- `{c.sha}` {c.timestamp} - {summary}")
lines.append(f"- `{c.sha}` {c.timestamp} - {c.message.splitlines()[0]}")
lines.extend([
"",
"Preview a version with `/dream-log <sha>` before restoring it.",
+1 -7
View File
@@ -10,11 +10,10 @@ import pydantic
from loguru import logger
from pydantic import BaseModel
from nanobot.config.schema import Config, _resolve_tool_config_refs
from nanobot.config.schema import Config
# Global variable to store current config path (for multi-instance support)
_current_config_path: Path | None = None
_schema_refs_ready = False
def set_config_path(path: Path) -> None:
@@ -40,11 +39,6 @@ def load_config(config_path: Path | None = None) -> Config:
Returns:
Loaded configuration object.
"""
global _schema_refs_ready
if not _schema_refs_ready:
_resolve_tool_config_refs()
_schema_refs_ready = True
path = config_path or get_config_path()
config = Config()
+8 -24
View File
@@ -52,17 +52,14 @@ class DreamConfig(Base):
model_override: str | None = Field(
default=None,
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
) # Optional Dream-specific model override. Supports preset names (resolved against model_presets) or raw model identifiers.
max_batch_size: int = Field(default=5, ge=1) # Max history entries per run
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Dream run
# Per-line git-blame age annotation in the Dream prompt (see #3212). Default
# on — set to False to feed all memory files raw if a specific LLM reacts
# poorly to the `← Nd` suffix or you want deterministic, git-independent prompts.
) # Optional Dream-specific model override
max_batch_size: int = Field(default=20, ge=1) # Max history entries per run
# Bumped from 10 to 15 in #3212 (exp002: +30% dedup, no accuracy loss; >15 plateaus).
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Phase 2
# Per-line git-blame age annotation in Phase 1 prompt (see #3212). Default
# on — set to False to feed MEMORY.md raw if a specific LLM reacts poorly
# to the `← Nd` suffix or you want deterministic, git-independent prompts.
annotate_line_ages: bool = True
# When False (default), Dream may only modify skills it created (marked
# dream_managed in frontmatter). When True, Dream may also edit user-created
# workspace skills. Builtin skills are never editable.
dream_edit_user_skills: bool = False
def build_schedule(self, timezone: str) -> CronSchedule:
"""Build the runtime schedule, preferring the legacy cron override if present."""
@@ -95,7 +92,6 @@ FallbackCandidate = str | InlineFallbackConfig
class ModelPresetConfig(Base):
"""A named set of model + generation parameters for quick switching."""
label: str | None = None
model: str
provider: str = "auto"
max_tokens: int = 8192
@@ -174,9 +170,8 @@ class ProviderConfig(Base):
api_key: str | None = None
api_base: str | None = None
api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
extra_body: dict[str, Any] | None = None # Extra fields merged into every request body
class BedrockProviderConfig(ProviderConfig):
@@ -227,16 +222,6 @@ class ProvidersConfig(Base):
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys)
@model_validator(mode="after")
def _validate_api_type_scope(self) -> "ProvidersConfig":
for name in self.__class__.model_fields:
if name == "openai":
continue
provider = getattr(self, name, None)
if isinstance(provider, ProviderConfig) and provider.api_type != "auto":
raise ValueError("providers.<name>.api_type is only supported for providers.openai")
return self
class HeartbeatConfig(Base):
"""Heartbeat service configuration."""
@@ -269,7 +254,6 @@ class MCPServerConfig(Base):
command: str = "" # Stdio: command to run (e.g. "npx")
args: list[str] = Field(default_factory=list) # Stdio: command arguments
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
cwd: str = "" # Stdio: working directory for MCP server runtime artifacts
url: str = "" # HTTP/SSE: endpoint URL
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
tool_timeout: int = 30 # seconds before a tool call is cancelled
-3
View File
@@ -98,7 +98,6 @@ def _make_provider_core(
extra_headers=p.extra_headers if p else None,
spec=spec,
extra_body=p.extra_body if p else None,
api_type=p.api_type if p and provider_name == "openai" else "auto",
)
provider.generation = resolved.to_generation_settings()
@@ -184,7 +183,6 @@ def provider_signature(
config.get_api_base(fallback.model, preset=fallback),
fp.extra_headers if fp else None,
fp.extra_body if fp else None,
fp.api_type if fp else "auto",
getattr(fp, "region", None) if fp else None,
getattr(fp, "profile", None) if fp else None,
fallback.max_tokens,
@@ -201,7 +199,6 @@ def provider_signature(
config.get_api_base(resolved.model, preset=resolved),
p.extra_headers if p else None,
p.extra_body if p else None,
p.api_type if p else "auto",
getattr(p, "region", None) if p else None,
getattr(p, "profile", None) if p else None,
resolved.max_tokens,
-144
View File
@@ -1445,149 +1445,6 @@ def _stepfun_images_from_payload(payload: dict[str, Any]) -> list[str]:
return images
# ---------------------------------------------------------------------------
# Zhipu (智谱) image generation
# ---------------------------------------------------------------------------
_ZHIPU_TIMEOUT_S = 300.0
_ZHIPU_ASPECT_RATIO_SIZES = {
"1:1": "1280x1280",
"16:9": "1728x960",
"9:16": "960x1728",
"3:4": "1088x1472",
"4:3": "1472x1088",
}
class ZhipuImageGenerationClient(ImageGenerationProvider):
"""Async client for Zhipu (智谱) image generation API.
Supports:
- Text-to-image via glm-image, cogview-4, cogview-3-flash, etc.
- Aspect ratio selection
- Watermark control
"""
provider_name = "zhipu"
missing_key_message = "Zhipu API key is not configured. Set providers.zhipu.apiKey."
default_timeout = _ZHIPU_TIMEOUT_S
def _default_base_url(self) -> str:
return "https://open.bigmodel.cn/api/paas/v4"
async def generate(
self,
*,
prompt: str,
model: str,
reference_images: list[str] | None = None,
aspect_ratio: str | None = None,
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
raise ImageGenerationError(self.missing_key_message)
if reference_images:
raise ImageGenerationError(
"Zhipu image generation does not support reference images"
)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
**self.extra_headers,
}
body: dict[str, Any] = {
"model": model,
"prompt": prompt,
}
size = _zhipu_size(aspect_ratio, image_size)
if size:
body["size"] = size
body.update(self.extra_body)
url = f"{self.api_base}/images/generations"
client = self._client or httpx.AsyncClient(timeout=self.timeout)
try:
return await self._generate_with_client(
client,
headers=headers,
body=body,
url=url,
)
finally:
if self._client is None:
await client.aclose()
async def _generate_with_client(
self,
client: httpx.AsyncClient,
*,
headers: dict[str, str],
body: dict[str, Any],
url: str,
) -> GeneratedImageResponse:
try:
response = await self._http_post(url, headers=headers, body=body, client=client)
except httpx.TimeoutException as exc:
raise ImageGenerationError("Zhipu image generation timed out") from exc
except httpx.RequestError as exc:
raise ImageGenerationError(f"Zhipu image generation request failed: {exc}") from exc
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = response.text[:500]
raise ImageGenerationError(f"Zhipu image generation failed: {detail}") from exc
payload = response.json()
images = await _zhipu_images_from_payload(client, payload)
self._require_images(images, payload)
return GeneratedImageResponse(images=images, content="", raw=payload)
def _zhipu_size(
aspect_ratio: str | None,
image_size: str | None,
) -> str:
"""Resolve aspect ratio / image_size to Zhipu size string.
Zhipu glm-image model supports: 1280x1280 (default), 1568x1056,
1056x1568, 1472x1088, 1088x1472, 1728x960, 960x1728.
"""
if image_size and "x" in image_size.lower():
return image_size
if aspect_ratio and aspect_ratio in _ZHIPU_ASPECT_RATIO_SIZES:
return _ZHIPU_ASPECT_RATIO_SIZES[aspect_ratio]
return "1280x1280"
async def _zhipu_images_from_payload(
client: httpx.AsyncClient,
payload: dict[str, Any],
) -> list[str]:
"""Extract image data URLs from Zhipu API response.
Zhipu returns images as temporary URLs that expire after 30 days.
We download and re-encode as base64 data URLs.
"""
images: list[str] = []
for item in payload.get("data") or []:
if not isinstance(item, dict):
continue
url = item.get("url")
if isinstance(url, str) and url:
images.append(await _download_image_data_url(client, url))
return images
# ---------------------------------------------------------------------------
# Provider registration
# ---------------------------------------------------------------------------
@@ -1600,4 +1457,3 @@ register_image_gen_provider(MiniMaxImageGenerationClient)
register_image_gen_provider(OpenAIImageGenerationClient)
register_image_gen_provider(OpenRouterImageGenerationClient)
register_image_gen_provider(StepFunImageGenerationClient)
register_image_gen_provider(ZhipuImageGenerationClient)
+3 -74
View File
@@ -274,47 +274,6 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
return merged
def _merge_unique_list(base: Any, override: Any) -> Any:
"""Append list values while preserving order and removing duplicates."""
if not isinstance(base, list) or not isinstance(override, list):
return override
result: list[Any] = []
seen: set[str] = set()
for value in [*base, *override]:
try:
key = json.dumps(value, sort_keys=True, ensure_ascii=False)
except Exception:
key = repr(value)
if key in seen:
continue
seen.add(key)
result.append(value)
return result
def _merge_responses_extra_body(
body: dict[str, Any],
extra_body: dict[str, Any],
) -> dict[str, Any]:
"""Merge configured Responses API body fields without clobbering tools."""
reserved = {"include", "tools"}
regular_extra = {key: value for key, value in extra_body.items() if key not in reserved}
merged = _deep_merge(body, regular_extra)
if "include" in extra_body:
merged["include"] = _merge_unique_list(body.get("include"), extra_body["include"])
if "tools" in extra_body:
current_tools = body.get("tools")
configured_tools = extra_body["tools"]
if isinstance(current_tools, list) and isinstance(configured_tools, list):
merged["tools"] = [*current_tools, *configured_tools]
else:
merged["tools"] = configured_tools
return merged
class OpenAICompatProvider(LLMProvider):
"""Unified provider for all OpenAI-compatible APIs.
@@ -330,14 +289,12 @@ class OpenAICompatProvider(LLMProvider):
extra_headers: dict[str, str] | None = None,
spec: ProviderSpec | None = None,
extra_body: dict[str, Any] | None = None,
api_type: str = "auto",
):
super().__init__(api_key, api_base)
self.default_model = default_model
self.extra_headers = extra_headers or {}
self._spec = spec
self._extra_body = extra_body or {}
self._api_type = api_type if spec and spec.name == "openai" else "auto"
if api_key and spec and spec.env_key:
self._setup_env(api_key, api_base)
@@ -471,10 +428,6 @@ class OpenAICompatProvider(LLMProvider):
return tool_call_id
return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9]
def _should_normalize_tool_call_ids(self) -> bool:
"""Return True for providers that reject normal OpenAI tool call IDs."""
return bool(self._spec and self._spec.name == "mistral")
@staticmethod
def _normalize_tool_call_arguments(arguments: Any) -> str:
"""Force function.arguments into a valid JSON object string."""
@@ -513,13 +466,10 @@ class OpenAICompatProvider(LLMProvider):
id_map: dict[str, str] = {}
pending_tool_ids: dict[str, deque[str]] = {}
force_string_content = bool(self._spec and self._spec.name == "deepseek")
normalize_tool_ids = self._should_normalize_tool_call_ids()
def map_id(value: Any) -> Any:
if not isinstance(value, str):
return value
if not normalize_tool_ids:
return value
return id_map.setdefault(value, self._normalize_tool_call_id(value))
def unique_tool_id(value: Any, used_ids: set[str], idx: int) -> str:
@@ -735,14 +685,8 @@ class OpenAICompatProvider(LLMProvider):
reasoning_effort: str | None,
) -> bool:
"""Use Responses API only for direct OpenAI requests that benefit from it."""
if self._api_type == "chat_completions":
return False
if self._spec and self._spec.name not in ("openai", "github_copilot"):
return False
if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not
# consult the circuit breaker or fall back to Chat Completions.
return True
if self._spec is None or self._spec.name != "github_copilot":
if not _is_direct_openai_base(self._effective_base):
return False
@@ -756,14 +700,7 @@ class OpenAICompatProvider(LLMProvider):
if not wants:
return False
return self._responses_circuit_allows_probe(model, reasoning_effort)
def _responses_circuit_allows_probe(
self,
model: str | None,
reasoning_effort: str | None,
) -> bool:
"""Return False when the Responses API circuit breaker is open."""
# Circuit breaker: skip after repeated failures, probe periodically.
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
failures = self._responses_failures.get(key, 0)
if failures >= _RESPONSES_FAILURE_THRESHOLD:
@@ -855,10 +792,6 @@ class OpenAICompatProvider(LLMProvider):
body["tools"] = convert_tools(tools)
body["tool_choice"] = tool_choice or "auto"
extra_body = getattr(self, "_extra_body", {})
if extra_body:
body = _merge_responses_extra_body(body, extra_body)
return body
# ------------------------------------------------------------------
@@ -1023,7 +956,7 @@ class OpenAICompatProvider(LLMProvider):
args = json_repair.loads(args)
ec, prov, fn_prov = _extract_tc_extras(tc)
parsed_tool_calls.append(ToolCallRequest(
id=str(tc_map.get("id") or _short_tool_id()),
id=_short_tool_id(),
name=str(fn.get("name") or ""),
arguments=args if isinstance(args, dict) else {},
extra_content=ec,
@@ -1066,7 +999,7 @@ class OpenAICompatProvider(LLMProvider):
args = json_repair.loads(args)
ec, prov, fn_prov = _extract_tc_extras(tc)
tool_calls.append(ToolCallRequest(
id=str(getattr(tc, "id", None) or _short_tool_id()),
id=_short_tool_id(),
name=tc.function.name,
arguments=args,
extra_content=ec,
@@ -1329,8 +1262,6 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would
# hide the real error.
raise
if self._api_type == "responses":
raise
if not self._should_fallback_from_responses_error(responses_error):
raise
self._record_responses_failure(model, reasoning_effort)
@@ -1404,8 +1335,6 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would
# hide the real error.
raise
if self._api_type == "responses":
raise
if not self._should_fallback_from_responses_error(responses_error):
raise
self._record_responses_failure(model, reasoning_effort)
+8 -27
View File
@@ -7,25 +7,6 @@ from pathlib import Path
import httpx
from loguru import logger
_TRANSCRIPTIONS_PATH = "audio/transcriptions"
def _resolve_transcription_url(api_base: str | None, default_url: str) -> str:
"""Resolve the full transcription endpoint URL.
Accepts either a chat-style base (e.g. ``https://api.groq.com/openai/v1``)
or a complete URL already ending in ``/audio/transcriptions``. A chat-style
base the form users naturally copy from their LLM provider config gets
the path appended instead of being POSTed verbatim and 404ing (#3637).
"""
if not api_base:
return default_url
base = api_base.rstrip("/")
if base.endswith(_TRANSCRIPTIONS_PATH):
return base
return f"{base}/{_TRANSCRIPTIONS_PATH}"
# Up to 3 retries (4 attempts total) with exponential backoff on transient
# failures. Whisper endpoints occasionally return 502/503 under load, and
# mobile-network transcription callers hit sporadic connect/read errors.
@@ -146,12 +127,12 @@ class OpenAITranscriptionProvider:
language: str | None = None,
):
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
self.api_url = _resolve_transcription_url(
api_base or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL"),
"https://api.openai.com/v1/audio/transcriptions",
self.api_url = (
api_base
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
or "https://api.openai.com/v1/audio/transcriptions"
)
self.language = language or None
logger.debug("OpenAI transcription endpoint: {}", self.api_url)
async def transcribe(self, file_path: str | Path) -> str:
if not self.api_key:
@@ -185,12 +166,12 @@ class GroqTranscriptionProvider:
language: str | None = None,
):
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
self.api_url = _resolve_transcription_url(
api_base or os.environ.get("GROQ_BASE_URL"),
"https://api.groq.com/openai/v1/audio/transcriptions",
self.api_url = (
api_base
or os.environ.get("GROQ_BASE_URL")
or "https://api.groq.com/openai/v1/audio/transcriptions"
)
self.language = language or None
logger.debug("Groq transcription endpoint: {}", self.api_url)
async def transcribe(self, file_path: str | Path) -> str:
"""
-33
View File
@@ -27,8 +27,6 @@ _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
_SESSION_PREVIEW_MAX_CHARS = 120
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
def _sanitize_assistant_replay_text(content: str) -> str:
@@ -184,28 +182,6 @@ class Session:
if cli_lines:
breadcrumbs = "\n".join(cli_lines)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
mcp_presets = message.get("mcp_presets")
if (
role == "user"
and isinstance(mcp_presets, list)
and mcp_presets
and isinstance(content, str)
):
mcp_lines: list[str] = []
for item in mcp_presets[:8]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip().lower()
if not name:
continue
transport = str(item.get("transport") or "mcp").strip() or "mcp"
mcp_lines.append(
f"[MCP Preset Attachment: @{name}; tool_prefix=mcp_{name}_; "
f"transport={transport}]"
)
if mcp_lines:
breadcrumbs = "\n".join(mcp_lines)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
if include_timestamps:
content = self._annotate_message_time(message, content)
if role == "assistant" and isinstance(content, str) and not content.strip():
@@ -645,18 +621,9 @@ class SessionManager:
title = metadata.get("title") if isinstance(metadata, dict) else None
preview = ""
fallback_preview = ""
scanned_records = 0
scanned_chars = 0
for line in f:
if not line.strip():
continue
scanned_records += 1
scanned_chars += len(line)
if (
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
):
break
item = json.loads(line)
if item.get("_type") == "metadata":
continue
-2
View File
@@ -34,5 +34,3 @@ Examples (replace `keyword`):
- **Do NOT edit SOUL.md, USER.md, or MEMORY.md.** They are automatically managed by Dream.
- If you notice outdated information, it will be corrected when Dream runs next.
- Users can view Dream's activity with the `/dream-log` command.
- Dream runs as a `system` session inside the AgentLoop, triggered by the `/dream` command or cron. Each turn processes one batch; if backlog remains, Dream automatically chains additional turns until complete. All changes are committed in a single git commit.
- Dream can use a different model than the main agent via `agents.defaults.dream.modelOverride`. Supports preset names or raw model identifiers.
@@ -1,27 +1,13 @@
Extract key facts from this conversation. For each fact, annotate its memory attributes.
Extract key facts from this conversation. Only output items matching these categories, skip everything else:
- User facts: personal info, preferences, stated opinions, habits
- Decisions: choices made, conclusions reached
- Solutions: working approaches discovered through trial and error, especially non-obvious methods that succeeded after failed attempts
- Events: plans, deadlines, notable occurrences
- Preferences: communication style, tool preferences
Only SNIP facts deserve a non-[skip] mark:
- Signal: would the user need to repeat this if forgotten?
- Novel: not already in MEMORY.md or USER.md (check context below)
- Important: prevents rework or captures preferences / rules
- Persistent: still relevant after 2 weeks
Priority: user corrections and preferences > solutions > decisions > events > environment facts. The most valuable memory prevents the user from having to repeat themselves.
Output one fact per line in this format:
- [mark] fact content
Marks (choose the best match):
- [permanent] Core preferences, personal traits, habits — never becomes stale
- [durable] Technical discoveries, project knowledge, config details — valid for months
- [ephemeral] Active task state, temporary decisions — may change in weeks
- [correction] Correction to a previous memory — must state what it replaces
- [skip] Does not meet SNIP criteria — still written to history.jsonl for audit, but Dream will ignore it
Categories to capture: people/roles, decisions/rationale, solutions, events/dates, preferences.
Decisions must include their motivation.
Write densely. Prefer 'X=A, Y=B' over separate bullets for tightly coupled facts.
Priority: user corrections > decisions with rationale > solutions > specific events > general context.
Output in the same language as the input conversation.
CRITICAL: Never drop person names, team names, or project names.
Skip: code patterns derivable from source, git history, or anything already in existing memory.
Skip: code patterns derivable from source, git history, or anything already captured in existing memory.
Output as concise bullet points, one fact per line. No preamble, no commentary.
If nothing noteworthy happened, output: (nothing)
-65
View File
@@ -1,65 +0,0 @@
Update memory files by analyzing conversation history and editing files directly.
Prune before adding — removing stale content is as important as adding new facts.
## File routing
Do NOT guess paths. Route each fact to its canonical file:
| File | Full path | Content |
|------|------|---------|
| SOUL.md | `{{ soul_path }}` | Agent behavior, guardrails, tone, interaction patterns |
| USER.md | `{{ user_path }}` | Personal info, preferences, habits, work context, communication style |
| MEMORY.md | `{{ memory_path }}` | Technical knowledge, project context, infrastructure, accounts |
| SKILL.md | `skills/<name>/SKILL.md` | Reusable workflow templates ([SKILL] entries only) |
Cross-boundary rule: no technical configs in USER.md, no user facts in SOUL.md, no preferences in MEMORY.md. If a fact fits multiple files, keep the most specific copy and remove the rest.
## Delete-or-keep
**Always delete:**
- Same fact at multiple locations — keep canonical copy only
- Merged/closed PR notes, resolved incidents, superseded info
- Verbose entries restatable in fewer words
- Overlapping or nested sections covering the same topic
**Likely delete** (apply judgment):
- Same fact at different detail levels — keep most complete version only
- Debugging steps unlikely to recur
- Ephemeral facts past their useful life
- Tool/service details documented upstream
- Lines with ``← Nd`` where N>{{ stale_threshold_days }} — closer review, not automatic removal
**Never delete:**
- User preferences and personality traits (permanent regardless of age)
- Active project context still referenced in conversations
- Behavioral rules in SOUL.md
When removing: prefer deleting individual items over entire sections.
## Fact extraction
- Atomic facts: "has a cat named Luna" not "discussed pet care"
- Corrections: edit the existing entry, don't append a new one
- Capture confirmed approaches the user validated
## Skill discovery & creation
Flag [SKILL] only when ALL are true: repeatable workflow appeared 2+ times, involves clear steps (not vague preferences), substantial enough for its own instruction set. Check existing skills to avoid redundancy.
For [SKILL] entries:
- Use write_file to create skills/<name>/SKILL.md; read_file `{{ skill_creator_path }}` for format reference
- YAML frontmatter must include name, description, **and `dream_managed: true`** (marks this skill as Dream-created)
- Under 2000 words: when to use, steps, output format, example
- Do NOT overwrite existing skills — if overlapping, merge delta into the existing skill
- Skills are instruction sets, not code. Keep concrete values in MEMORY.md; skills use placeholders
## Skill edit policy
Each skill in the Existing Skills list is tagged with an origin:
- **[dream]** — Dream-created (has `dream_managed: true` in frontmatter). You MAY edit these.
- **[user]** — User-created workspace skill. {% if dream_edit_user_skills %}You MAY edit these.{% else %}You MUST NOT modify, rename, or delete these — you can only read them for context.{% endif %}
- **[builtin]** — Bundled with nanobot. You MUST NEVER modify these.
## Editing
- Default tool: apply_patch. Use edit_file only for small exact replacements.
- File contents provided below — no read_file needed for initial edits.
- Batch all changes into a single apply_patch call. Surgical edits only.
- dry_run=true to preview. If nothing to update, stop without calling tools.
Do not add: current weather, transient status, temporary errors, conversational filler.
+40
View File
@@ -0,0 +1,40 @@
You have TWO equally important tasks:
1. Extract new facts from conversation history
2. Deduplicate existing memory files — find and flag redundant, overlapping, or stale content even if NOT mentioned in history
Output one line per finding:
[FILE] atomic fact (not already in memory)
[FILE-REMOVE] reason for removal
[SKILL] kebab-case-name: one-line description of the reusable pattern
Files: USER (identity, preferences), SOUL (bot behavior, tone), MEMORY (knowledge, project context)
Rules:
- Atomic facts: "has a cat named Luna" not "discussed pet care"
- Corrections: [USER] location is Tokyo, not Osaka
- Capture confirmed approaches the user validated
Deduplication — scan ALL memory files for these redundancy patterns:
- Same fact stated in multiple places (e.g., "communicates in Chinese" in both USER.md and multiple MEMORY.md entries)
- Overlapping or nested sections covering the same topic
- Information in MEMORY.md that is already captured in USER.md or SOUL.md (MEMORY.md should not duplicate permanent-file content)
- Verbose entries that can be condensed without losing information
For each duplicate found, output [FILE-REMOVE] for the less authoritative copy (prefer keeping facts in their canonical location)
Staleness — MEMORY.md lines may have a ``← Nd`` suffix showing days since last modification:
- SOUL.md and USER.md have no age annotations — they are permanent, only update with corrections
- Age only indicates when content was last touched, not whether it should be removed
- Use content judgment: user habits/preferences/personality traits are permanent regardless of age
- Only prune content that is objectively outdated: passed events, resolved tracking, superseded approaches
- Lines with ``← Nd`` (N>{{ stale_threshold_days }}) deserve closer review but are NOT automatically removable
- When removing: prefer deleting individual items over entire sections
Skill discovery — flag [SKILL] when ALL of these are true:
- A specific, repeatable workflow appeared 2+ times in the conversation history
- It involves clear steps (not vague preferences like "likes concise answers")
- It is substantial enough to warrant its own instruction set (not trivial like "read a file")
- Do not worry about duplicates — the next phase will check against existing skills
Do not add: current weather, transient status, temporary errors, conversational filler.
[SKIP] if nothing needs updating.
+37
View File
@@ -0,0 +1,37 @@
Update memory files based on the analysis below.
- [FILE] entries: add the described content to the appropriate file
- [FILE-REMOVE] entries: delete the corresponding content from memory files
- [SKILL] entries: create a new skill under skills/<name>/SKILL.md using write_file
## File paths (relative to workspace root)
- SOUL.md
- USER.md
- memory/MEMORY.md
- skills/<name>/SKILL.md (for [SKILL] entries only)
Do NOT guess paths.
## Editing rules
- Edit directly — file contents provided below, no read_file needed
- Use exact text as old_text, include surrounding blank lines for unique match
- Batch changes to the same file into one edit_file call
- For deletions: section header + all bullets as old_text, new_text empty
- Surgical edits only — never rewrite entire files
- If nothing to update, stop without calling tools
## Skill creation rules (for [SKILL] entries)
- Use write_file to create skills/<name>/SKILL.md
- Before writing, read_file `{{ skill_creator_path }}` for format reference (frontmatter structure, naming conventions, quality standards)
- **Dedup check**: read existing skills listed below to verify the new skill is not functionally redundant. Skip creation if an existing skill already covers the same workflow.
- Include YAML frontmatter with name and description fields
- Keep SKILL.md under 2000 words — concise and actionable
- Include: when to use, steps, output format, at least one example
- Do NOT overwrite existing skills — skip if the skill directory already exists
- Reference specific tools the agent has access to (read_file, write_file, exec, web_search, etc.)
- Skills are instruction sets, not code — do not include implementation code
## Quality
- Every line must carry standalone value
- Concise bullets under clear headers
- When reducing (not deleting): keep essential facts, drop verbose details
- If uncertain whether to delete, keep but add "(verify currency)"
+1 -2
View File
@@ -19,8 +19,7 @@ class CommitInfo:
def format(self, diff: str = "") -> str:
"""Format this commit for display, optionally with a diff."""
summary = self.message.splitlines()[0] if self.message else "(no message)"
header = f"## {summary}\n`{self.sha}` — {self.timestamp}\n"
header = f"## {self.message.splitlines()[0]}\n`{self.sha}` — {self.timestamp}\n"
if diff:
return f"{header}\n```diff\n{diff}\n```"
return f"{header}\n(no file changes)"
-10
View File
@@ -29,11 +29,6 @@ LENGTH_RECOVERY_PROMPT = (
"— no recap, no apology. Break remaining work into smaller steps if needed."
)
SUSTAINED_GOAL_CONTINUE_PROMPT = (
"You have an active sustained goal. Please continue working toward the "
"objective using your tools, or call complete_goal if the work is truly finished."
)
def empty_tool_result_message(tool_name: str) -> str:
"""Short prompt-safe marker for tools that completed without visible output."""
@@ -70,11 +65,6 @@ def build_length_recovery_message() -> dict[str, str]:
return {"role": "user", "content": LENGTH_RECOVERY_PROMPT}
def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
"""Prompt the model to continue when a sustained goal is still active."""
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str | None:
"""Stable signature for repeated external lookups we want to throttle."""
if tool_name == "web_fetch":
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import re
from typing import Any
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.loader import load_config
QueryParams = dict[str, list[str]]
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
"""Compatibility exports for WebUI-attached MCP preset annotations."""
from nanobot.agent.tools.mcp import runtime_lines, session_extra
__all__ = ["runtime_lines", "session_extra"]
+21 -89
View File
@@ -6,12 +6,10 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
from __future__ import annotations
import re
from typing import Any
from zoneinfo import ZoneInfo
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.image_generation import (
get_image_gen_provider,
image_gen_provider_names,
@@ -43,7 +41,6 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
"2:3",
"21:9",
}
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
class WebUISettingsError(ValueError):
@@ -103,32 +100,6 @@ def _parse_bool(value: str, field: str) -> bool:
return normalized in {"1", "true", "yes"}
def _model_configuration_slug(label: str) -> str:
normalized = _MODEL_CONFIGURATION_SLUG_RE.sub("-", label.strip().lower())
normalized = normalized.strip("-_")
if not normalized:
raise WebUISettingsError("configuration name is required")
if normalized == "default":
raise WebUISettingsError("configuration name is reserved")
if len(normalized) > 48:
normalized = normalized[:48].rstrip("-_")
return normalized
def _validate_configured_provider(config: Any, provider: str) -> None:
if provider == "auto":
return
spec = find_by_name(provider)
if spec is None:
raise WebUISettingsError("unknown provider")
provider_config = getattr(config.providers, provider, None)
if (
provider_config is None
or not _provider_configured_for_settings(spec, provider_config)
):
raise WebUISettingsError("provider is not configured")
def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for name in image_gen_provider_names():
@@ -181,18 +152,17 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth:
continue
row = {
"name": spec.name,
"label": spec.label,
"configured": _provider_configured_for_settings(spec, provider_config),
"api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
}
if spec.name == "openai":
row["api_type"] = provider_config.api_type
providers.append(row)
providers.append(
{
"name": spec.name,
"label": spec.label,
"configured": _provider_configured_for_settings(spec, provider_config),
"api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
}
)
search_config = config.tools.web.search
image_config = config.tools.image_generation
@@ -228,7 +198,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
model_presets.append(
{
"name": name,
"label": preset.label or name,
"label": name,
"active": active_preset_name == name,
"is_default": False,
"model": preset.model,
@@ -307,7 +277,6 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
"max_batch_size": defaults.dream.max_batch_size,
"max_iterations": defaults.dream.max_iterations,
"annotate_line_ages": defaults.dream.annotate_line_ages,
"dream_edit_user_skills": defaults.dream.dream_edit_user_skills,
},
"unified_session": defaults.unified_session,
},
@@ -352,7 +321,15 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
provider = provider.strip()
if not provider:
raise WebUISettingsError("provider is required")
_validate_configured_provider(config, provider)
spec = find_by_name(provider)
if spec is None:
raise WebUISettingsError("unknown provider")
provider_config = getattr(config.providers, provider, None)
if (
provider_config is None
or not _provider_configured_for_settings(spec, provider_config)
):
raise WebUISettingsError("provider is not configured")
if defaults.provider != provider:
defaults.provider = provider
changed = True
@@ -411,40 +388,6 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
return settings_payload(requires_restart=restart_required)
def create_model_configuration(query: QueryParams) -> dict[str, Any]:
label = (_query_first_alias(query, "label", "displayName") or "").strip()
raw_name = (_query_first(query, "name") or label).strip()
model = (_query_first(query, "model") or "").strip()
provider = (_query_first(query, "provider") or "").strip()
if not label:
label = raw_name
if not model:
raise WebUISettingsError("model is required")
if not provider:
raise WebUISettingsError("provider is required")
name = _model_configuration_slug(raw_name or label)
config = load_config()
if name in config.model_presets:
raise WebUISettingsError("configuration already exists", status=409)
_validate_configured_provider(config, provider)
base = config.resolve_default_preset()
config.model_presets[name] = ModelPresetConfig(
label=label,
model=model,
provider=provider,
max_tokens=base.max_tokens,
context_window_tokens=base.context_window_tokens,
temperature=base.temperature,
reasoning_effort=base.reasoning_effort,
)
config.agents.defaults.model_preset = name
save_config(config)
return settings_payload()
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
@@ -473,17 +416,6 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_config.api_base = api_base
changed = True
if "api_type" in query:
if spec.name == "openai":
api_type = (_query_first(query, "api_type") or "").strip()
try:
parsed_api_type = type(provider_config)(api_type=api_type).api_type
except Exception:
raise WebUISettingsError("api_type must be auto, chat_completions, or responses") from None
if provider_config.api_type != parsed_api_type:
provider_config.api_type = parsed_api_type
changed = True
if changed:
save_config(config)
image_config = config.tools.image_generation
+1 -63
View File
@@ -4,12 +4,10 @@ from __future__ import annotations
import json
import os
import re
import time
import uuid
from pathlib import Path
from typing import Any, Callable, Mapping
from urllib.parse import unquote, urlparse
from typing import Any, Callable
from loguru import logger
@@ -18,61 +16,6 @@ from nanobot.session.manager import SessionManager
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
)
_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
".png",
".jpg",
".jpeg",
".webp",
".gif",
})
def rewrite_local_markdown_images(
text: str,
*,
workspace_path: Path,
sign_path: Callable[[Path], Mapping[str, Any] | None],
) -> str:
"""Rewrite markdown image paths inside the workspace to signed WebUI media URLs."""
if "![" not in text:
return text
def resolve_url(raw_url: str) -> str | None:
url = raw_url.strip()
if url.startswith("<") and url.endswith(">"):
url = url[1:-1].strip()
if not url or url.startswith(("/api/media/", "#")):
return None
parsed = urlparse(url)
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
return None
path_text = unquote(url)
if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
return None
candidate = Path(path_text).expanduser()
if not candidate.is_absolute():
candidate = workspace_path / candidate
try:
resolved = candidate.resolve(strict=False)
resolved.relative_to(workspace_path)
except (OSError, ValueError):
return None
if not resolved.is_file():
return None
signed = sign_path(resolved)
return str(signed.get("url")) if signed and signed.get("url") else None
def replace(match: re.Match[str]) -> str:
signed_url = resolve_url(match.group(2))
if not signed_url:
return match.group(0)
title = match.group(3) or ""
return f"![{match.group(1)}]({signed_url}{title})"
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
def webui_transcript_path(session_key: str) -> Path:
@@ -515,11 +458,6 @@ def replay_transcript_to_ui_messages(
cli_apps = rec.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)]
mcp_presets = rec.get("mcp_presets")
if isinstance(mcp_presets, list) and mcp_presets:
row["mcpPresets"] = [
dict(preset) for preset in mcp_presets if isinstance(preset, dict)
]
messages.append(row)
continue
+218 -399
View File
@@ -1,32 +1,19 @@
"""Tests for Dream driven through AgentLoop._process_system_message."""
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.agent.loop import AgentLoop
from unittest.mock import AsyncMock, MagicMock, patch
from nanobot.agent.memory import Dream, MemoryStore
from nanobot.agent.runner import AgentRunResult
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.utils.gitstore import LineAge
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
provider = MagicMock()
provider.get_default_model.return_value = default_model
provider.generation = SimpleNamespace(
max_tokens=max_tokens, temperature=0.1, reasoning_effort=None
)
return provider
@pytest.fixture
def store(tmp_path):
from nanobot.agent.memory import MemoryStore
s = MemoryStore(tmp_path)
s.write_soul("# Soul\n- Helpful")
s.write_user("# User\n- Developer")
@@ -36,7 +23,9 @@ def store(tmp_path):
@pytest.fixture
def mock_provider():
return _provider("test-model")
p = MagicMock()
p.chat_with_retry = AsyncMock()
return p
@pytest.fixture
@@ -45,16 +34,10 @@ def mock_runner():
@pytest.fixture
def loop(tmp_path, mock_provider, mock_runner):
loop = AgentLoop(
bus=MessageBus(),
provider=mock_provider,
workspace=tmp_path,
model="test-model",
context_window_tokens=1000,
)
loop.dream._runner = mock_runner
return loop
def dream(store, mock_provider, mock_runner):
d = Dream(store=store, provider=mock_provider, model="test-model", max_batch_size=5)
d._runner = mock_runner
return d
def _make_run_result(
@@ -73,418 +56,254 @@ def _make_run_result(
)
class TestDreamAgentLoopIntegration:
async def test_completes_goal_state_after_full_backlog(self, loop, mock_runner, store):
"""Goal should be completed after processing all backlog in internal loop."""
for i in range(6):
store.append_history(f"event {i}")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
session = loop.sessions.get_or_create("system:dream")
goal = session.metadata.get("goal_state")
assert isinstance(goal, dict)
assert goal["status"] == "completed"
assert store.get_last_dream_cursor() == 6
async def test_completes_goal_state_on_finish(self, loop, mock_runner, store):
"""Goal should be marked completed when backlog is fully processed."""
store.append_history("event 1")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
session = loop.sessions.get_or_create("system:dream")
goal = session.metadata.get("goal_state")
assert goal["status"] == "completed"
assert "completed_at" in goal
assert "recap" in goal
async def test_noop_when_no_unprocessed_history(self, loop, mock_runner):
"""Dream should not call runner when there's nothing to process."""
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
result = await loop._process_system_message(msg)
assert result is None
class TestDreamRun:
async def test_noop_when_no_unprocessed_history(self, dream, mock_provider, mock_runner, store):
"""Dream should not call LLM when there's nothing to process."""
result = await dream.run()
assert result is False
mock_provider.chat_with_retry.assert_not_called()
mock_runner.run.assert_not_called()
async def test_calls_runner_for_unprocessed_entries(self, loop, mock_runner, store):
async def test_calls_runner_for_unprocessed_entries(self, dream, mock_provider, mock_runner, store):
"""Dream should call AgentRunner when there are unprocessed history entries."""
store.append_history("User prefers dark mode")
mock_runner.run = AsyncMock(
return_value=_make_run_result(
tool_events=[
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
],
)
)
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
mock_provider.chat_with_retry.return_value = MagicMock(content="New fact")
mock_runner.run = AsyncMock(return_value=_make_run_result(
tool_events=[{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}],
))
result = await dream.run()
assert result is True
mock_runner.run.assert_called_once()
spec = mock_runner.run.call_args[0][0]
assert spec.max_iterations == 10
assert spec.fail_on_tool_error is False
async def test_advances_dream_cursor(self, loop, mock_runner, store):
async def test_advances_dream_cursor(self, dream, mock_provider, mock_runner, store):
"""Dream should advance the cursor after processing."""
store.append_history("event 1")
store.append_history("event 2")
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
await dream.run()
assert store.get_last_dream_cursor() == 2
async def test_compacts_processed_history(self, loop, mock_runner, store):
async def test_compacts_processed_history(self, dream, mock_provider, mock_runner, store):
"""Dream should compact history after processing."""
store.append_history("event 1")
store.append_history("event 2")
store.append_history("event 3")
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
await dream.run()
# After Dream, cursor is advanced and 3, compact keeps last max_history_entries
entries = store.read_unprocessed_history(since_cursor=0)
assert all(e["cursor"] > 0 for e in entries)
async def test_processes_full_backlog_in_one_call(self, loop, mock_runner, store):
"""Backlog larger than max_batch_size should be fully processed in one call."""
for i in range(12):
store.append_history(f"event {i}")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
assert store.get_last_dream_cursor() == 12
assert mock_runner.run.call_count == 3 # 5 + 5 + 2
async def test_single_git_commit_for_multi_batch(self, loop, mock_runner, store):
"""Multi-batch run should collapse into exactly one git commit."""
store.git.init()
store.git.auto_commit("initial")
for i in range(12):
store.append_history(f"event {i}")
mock_runner.run = AsyncMock(
return_value=_make_run_result(
tool_events=[
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
],
)
)
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
commits = store.git.log()
dream_commits = [c for c in commits if c.message.startswith("dream:")]
assert len(dream_commits) == 1
async def test_system_prompt_cached(self, loop, mock_runner, store):
"""Batches within one run should reuse cached system prompt when template mtime unchanged."""
for i in range(6):
store.append_history(f"event {i}")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
# Two batches (5 + 1), both should use the same cached prompt
assert mock_runner.run.call_count == 2
first_prompt = mock_runner.run.call_args_list[0][0][0].initial_messages[0]["content"]
second_prompt = mock_runner.run.call_args_list[1][0][0].initial_messages[0]["content"]
assert second_prompt is first_prompt
async def test_noop_when_empty_backlog(self, loop, mock_runner, store):
"""Empty backlog should not advance cursor or create a commit."""
store.git.init()
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
assert store.get_last_dream_cursor() == 0
commits = store.git.log()
assert len([c for c in commits if c.message.startswith("dream:")]) == 0
class TestDreamPrompt:
async def test_prompt_contains_mece_rules(self, loop, mock_runner, store):
store.append_history("some event")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
spec = mock_runner.run.call_args[0][0]
system_prompt = spec.initial_messages[0]["content"]
assert "Do NOT guess paths" in system_prompt
assert "SOUL.md" in system_prompt
assert "USER.md" in system_prompt
assert "MEMORY.md" in system_prompt
async def test_skill_phase_uses_builtin_skill_creator_path(self, loop, mock_runner, store):
async def test_skill_phase_uses_builtin_skill_creator_path(self, dream, mock_provider, mock_runner, store):
"""Dream should point skill creation guidance at the builtin skill-creator template."""
store.append_history("Repeated workflow one")
store.append_history("Repeated workflow two")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKILL] test-skill: test description")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
await dream.run()
spec = mock_runner.run.call_args[0][0]
system_prompt = spec.initial_messages[0]["content"]
expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
assert expected in system_prompt
async def test_system_prompt_uses_threshold_from_template_var(self, loop, mock_runner, store):
store.append_history("some event")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
async def test_skill_write_tool_accepts_workspace_relative_skill_path(self, dream, store):
"""Dream skill creation should allow skills/<name>/SKILL.md relative to workspace root."""
write_tool = dream._tools.get("write_file")
assert write_tool is not None
result = await write_tool.execute(
path="skills/test-skill/SKILL.md",
content="---\nname: test-skill\ndescription: Test\n---\n",
)
await loop._process_system_message(msg)
spec = mock_runner.run.call_args[0][0]
system_msg = spec.initial_messages[0]["content"]
assert "Successfully wrote" in result
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
async def test_phase1_prompt_includes_line_age_annotations(self, dream, mock_provider, mock_runner, store):
"""Phase 1 prompt should have per-line age suffixes in MEMORY.md when git is available."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
# Init git so line_ages works
store.git.init()
store.git.auto_commit("initial memory state")
await dream.run()
# The MEMORY.md section should not crash and should contain the memory content
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
assert "## Current MEMORY.md" in user_msg
async def test_phase1_annotates_only_memory_not_soul_or_user(self, dream, mock_provider, mock_runner, store):
"""SOUL.md and USER.md should never have age annotations — they are permanent."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
store.git.init()
store.git.auto_commit("initial state")
await dream.run()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
# The ← suffix should only appear in MEMORY.md section
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
soul_section = user_msg.split("## Current SOUL.md")[1].split("## Current USER.md")[0]
user_section = user_msg.split("## Current USER.md")[1]
# SOUL and USER should not contain age arrows
assert "\u2190" not in soul_section
assert "\u2190" not in user_section
async def test_phase1_prompt_works_without_git(self, dream, mock_provider, mock_runner, store):
"""Phase 1 should work fine even if git is not initialized (no age annotations)."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
# Should still succeed — just without age annotations
mock_provider.chat_with_retry.assert_called_once()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
assert "## Current MEMORY.md" in user_msg
async def test_phase1_prompt_carries_age_suffix_for_stale_lines(
self, dream, mock_provider, mock_runner, store,
):
"""End-to-end: ages >14d must appear verbatim in the LLM prompt, ages ≤14d must not."""
# MEMORY.md fixture has 2 non-blank lines ("# Memory" and "- Project X active").
# Inject four ages to cover threshold boundaries: >14 suffix, ==14 no suffix, <14 no suffix.
store.write_memory("# Memory\n- Project X active\n- fresh item\n- edge case line")
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
fake_ages = [
LineAge(age_days=30), # "# Memory" → should get ← 30d
LineAge(age_days=20), # "- Project X..." → should get ← 20d
LineAge(age_days=14), # "- fresh item" → ==14, threshold is strictly >14, no suffix
LineAge(age_days=5), # "- edge case..." → no suffix
]
with patch.object(store.git, "line_ages", return_value=fake_ages):
await dream.run()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
assert "\u2190 30d" in memory_section
assert "\u2190 20d" in memory_section
assert "\u2190 14d" not in memory_section
assert "\u2190 5d" not in memory_section
async def test_phase1_skips_annotation_when_disabled(
self, dream, mock_provider, mock_runner, store,
):
"""`annotate_line_ages=False` must bypass the git lookup entirely and keep MEMORY.md raw."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
dream.annotate_line_ages = False
# line_ages must be bypassed entirely — verify with a spy rather than a
# raising side_effect, because _annotate_with_ages catches Exception
# (which swallows AssertionError) and would hide an accidental call.
with patch.object(store.git, "line_ages") as mock_line_ages:
await dream.run()
mock_line_ages.assert_not_called()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
assert "\u2190" not in user_msg
async def test_phase1_skips_annotation_on_line_ages_length_mismatch(
self, dream, mock_provider, mock_runner, store,
):
"""If ages length != lines length (dirty working tree), skip annotation instead of mis-tagging."""
# MEMORY.md has 2 non-blank lines but we hand back only 1 age → mismatch.
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
with patch.object(store.git, "line_ages", return_value=[LineAge(age_days=999)]):
await dream.run()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
# No age arrow at all — we refused to annotate rather than tag the wrong line.
assert "\u2190" not in memory_section
async def test_phase1_prompt_uses_threshold_from_template_var(
self, dream, mock_provider, mock_runner, store,
):
"""System prompt should reference the stale-threshold constant, not a hardcoded 14."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
system_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][0]["content"]
# The template renders with stale_threshold_days=14 → LLM must see "N>14"
assert "N>14" in system_msg
class TestDreamPromptCaps:
async def test_caps_huge_memory_file(self, loop, mock_runner, store):
store.write_memory("M" * (loop.dream._MEMORY_FILE_MAX_CHARS * 5))
store.append_history("some event")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
spec = mock_runner.run.call_args[0][0]
user_msg = spec.initial_messages[1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
"## Current SOUL.md"
)[0]
assert len(memory_section) < loop.dream._MEMORY_FILE_MAX_CHARS + 500
"""Dream's Phase 1/2 prompt must not be poisoned by a legacy oversized
history entry or a runaway MEMORY.md. Without caps, a single pre-#3412
raw_archive dump in history.jsonl would make every subsequent Dream run
exceed the context window and silently advance the cursor past real work.
"""
async def test_caps_huge_history_entry(self, loop, mock_runner, store):
async def test_phase1_caps_huge_memory_file(
self, dream, mock_provider, mock_runner, store,
):
"""A MEMORY.md much larger than _MEMORY_FILE_MAX_CHARS must be truncated
in the prompt preview (full content is still reachable via read_file)."""
store.write_memory("M" * (dream._MEMORY_FILE_MAX_CHARS * 5))
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
assert len(memory_section) < dream._MEMORY_FILE_MAX_CHARS + 500
async def test_phase1_caps_huge_history_entry(
self, dream, mock_provider, mock_runner, store,
):
"""A legacy oversized history entry (e.g. pre-#3412 raw_archive dump)
must not explode the Phase 1 prompt each entry is capped in the
preview, even though the JSONL record itself stays full-size."""
# Bypass the append_history cap by writing directly, simulating a
# record that was written by an older nanobot build before any caps.
store.history_file.write_text(
json.dumps(
{
"cursor": 1,
"timestamp": "2026-04-01 10:00",
"content": "H" * (loop.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
}
)
+ "\n",
json.dumps({
"cursor": 1,
"timestamp": "2026-04-01 10:00",
"content": "H" * (dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
}) + "\n",
encoding="utf-8",
)
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
spec = mock_runner.run.call_args[0][0]
user_msg = spec.initial_messages[1]["content"]
history_section = user_msg.split("## Conversation History\n")[1].split(
"\n\n## Current Date"
)[0]
assert len(history_section) < loop.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
await dream.run()
class TestDreamTools:
def test_apply_patch_tool_registered(self, loop):
tool = loop.dream._tools.get("apply_patch")
assert tool is not None
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
history_section = user_msg.split("## Conversation History\n")[1].split("\n\n## Current Date")[0]
assert len(history_section) < dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
class TestDreamCaps:
def test_batch_size_default_is_5(self):
from nanobot.config.schema import DreamConfig
assert DreamConfig().max_batch_size == 5
def test_memory_cap_is_16k(self, loop):
assert loop.dream._MEMORY_FILE_MAX_CHARS == 16_000
class TestDreamSkipFiltering:
async def test_skip_entries_removed_from_prompt(self, loop, mock_runner, store):
store.append_history("- [skip] greeting\n- [permanent] User prefers dark mode")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
spec = mock_runner.run.call_args[0][0]
user_msg = spec.initial_messages[1]["content"]
assert "User prefers dark mode" in user_msg
assert "[skip]" not in user_msg
assert "greeting" not in user_msg
class TestDreamAgeAnnotations:
async def test_prompt_includes_line_age_annotations(self, loop, mock_runner, store):
store.append_history("some event")
mock_runner.run = AsyncMock(return_value=_make_run_result())
store.git.init()
store.git.auto_commit("initial memory state")
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
spec = mock_runner.run.call_args[0][0]
user_msg = spec.initial_messages[1]["content"]
assert "## Current MEMORY.md" in user_msg
async def test_annotates_only_memory_not_soul_or_user(self, loop, mock_runner, store):
store.append_history("some event")
mock_runner.run = AsyncMock(return_value=_make_run_result())
store.git.init()
store.git.auto_commit("initial state")
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
spec = mock_runner.run.call_args[0][0]
user_msg = spec.initial_messages[1]["content"]
soul_section = user_msg.split("## Current SOUL.md")[1].split(
"## Current USER.md"
)[0]
user_section = user_msg.split("## Current USER.md")[1]
assert "" not in soul_section
assert "" not in user_section
async def test_prompt_works_without_git(self, loop, mock_runner, store):
store.append_history("some event")
mock_runner.run = AsyncMock(return_value=_make_run_result())
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
mock_runner.run.assert_called_once()
spec = mock_runner.run.call_args[0][0]
user_msg = spec.initial_messages[1]["content"]
assert "## Current MEMORY.md" in user_msg
async def test_prompt_carries_age_suffix_for_stale_lines(self, loop, mock_runner, store):
store.write_memory(
"# Memory\n- Project X active\n- fresh item\n- edge case line"
)
store.append_history("some event")
mock_runner.run = AsyncMock(return_value=_make_run_result())
fake_ages = [
LineAge(age_days=30),
LineAge(age_days=20),
LineAge(age_days=14),
LineAge(age_days=5),
]
with patch.object(loop.dream.store.git, "line_ages", return_value=fake_ages):
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
spec = mock_runner.run.call_args[0][0]
user_msg = spec.initial_messages[1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
"## Current SOUL.md"
)[0]
assert "← 30d" in memory_section
assert "← 20d" in memory_section
assert "← 14d" not in memory_section
assert "← 5d" not in memory_section
async def test_skips_annotation_when_disabled(self, loop, mock_runner, store):
store.append_history("some event")
mock_runner.run = AsyncMock(return_value=_make_run_result())
loop.dream.annotate_line_ages = False
with patch.object(loop.dream.store.git, "line_ages") as mock_line_ages:
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
mock_line_ages.assert_not_called()
spec = mock_runner.run.call_args[0][0]
user_msg = spec.initial_messages[1]["content"]
assert "" not in user_msg
async def test_skips_annotation_on_line_ages_length_mismatch(self, loop, mock_runner, store):
store.append_history("some event")
mock_runner.run = AsyncMock(return_value=_make_run_result())
with patch.object(
loop.dream.store.git, "line_ages", return_value=[LineAge(age_days=999)]
):
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
spec = mock_runner.run.call_args[0][0]
user_msg = spec.initial_messages[1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
"## Current SOUL.md"
)[0]
assert "" not in memory_section
class TestDreamSessionPersistence:
async def test_writes_session_on_success(self, loop, mock_runner, store):
store.append_history("event one")
store.append_history("event two")
mock_runner.run = AsyncMock(
return_value=_make_run_result(
tool_events=[
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
],
)
)
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
session_path = store.memory_dir / ".dream_session.json"
assert session_path.exists()
data = json.loads(session_path.read_text(encoding="utf-8"))
assert data["batch"]["from_cursor"] == 0
assert data["batch"]["to_cursor"] == 2
assert data["batch"]["count"] == 2
assert data["stop_reason"] == "completed"
assert data["changelog"] == ["edit_file: memory/MEMORY.md"]
assert "timestamp" in data
assert "elapsed_seconds" in data
assert "messages" in data
async def test_no_session_record_on_failure(self, loop, mock_runner, store):
"""Failed batch should not write a session record (cursor stays put for retry)."""
store.append_history("event one")
mock_runner.run = AsyncMock(side_effect=RuntimeError("LLM error"))
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
session_path = store.memory_dir / ".dream_session.json"
assert not session_path.exists()
assert store.get_last_dream_cursor() == 0
async def test_session_contains_full_messages(self, loop, mock_runner, store):
store.append_history("event one")
messages = [
{"role": "system", "content": "you are a memory bot"},
{"role": "user", "content": "history here"},
{"role": "assistant", "content": "I will edit MEMORY.md"},
]
result = _make_run_result()
result.messages = messages
mock_runner.run = AsyncMock(return_value=result)
msg = InboundMessage(
channel="system", sender_id="dream", chat_id="dream", content=""
)
await loop._process_system_message(msg)
session_path = store.memory_dir / ".dream_session.json"
data = json.loads(session_path.read_text(encoding="utf-8"))
assert data["messages"] == messages
assert data["prompt_chars"] > 0
assert data["commit_sha"] is None
-176
View File
@@ -2,39 +2,12 @@
from __future__ import annotations
import asyncio
from contextlib import AsyncExitStack
from typing import Any
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools import mcp as mcp_runtime
from nanobot.agent.tools.base import Tool
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import MCPServerConfig
class _FakeMcpTool(Tool):
def __init__(self, name: str) -> None:
self._name = name
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return "fake MCP tool"
@property
def parameters(self) -> dict[str, Any]:
return {"type": "object", "properties": {}}
async def execute(self, **_kwargs: Any) -> str:
return "ok"
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop:
@@ -69,152 +42,3 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
assert attempts == 2
assert loop._mcp_connected is False
assert loop._mcp_stacks == {}
@pytest.mark.asyncio
async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
):
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config = load_config()
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
type="stdio",
command="browserbase-mcp",
)
save_config(config)
closed: list[str] = []
async def _mark_closed(name: str) -> None:
closed.append(name)
async def _fake_connect(servers, registry):
stacks = {}
for name in servers:
registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
stack = AsyncExitStack()
await stack.__aenter__()
stack.push_async_callback(_mark_closed, name)
stacks[name] = stack
return stacks
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
loop = _make_loop(tmp_path, mcp_servers={})
added = await mcp_runtime.reload_servers(loop, loop.tools)
assert added["ok"] is True
assert added["added"] == ["browserbase"]
assert loop.tools.has("mcp_browserbase_navigate")
assert "browserbase" in loop._mcp_stacks
config = load_config()
del config.tools.mcp_servers["browserbase"]
save_config(config)
removed = await mcp_runtime.reload_servers(loop, loop.tools)
assert removed["ok"] is True
assert removed["removed"] == ["browserbase"]
assert not loop.tools.has("mcp_browserbase_navigate")
assert "browserbase" not in loop._mcp_stacks
assert closed == ["browserbase"]
@pytest.mark.asyncio
async def test_request_mcp_reload_reaches_runtime_control_without_restart(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
):
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config = load_config()
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
type="stdio",
command="browserbase-mcp",
)
save_config(config)
closed: list[str] = []
async def _mark_closed(name: str) -> None:
closed.append(name)
async def _fake_connect(servers, registry):
stacks = {}
for name in servers:
registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
stack = AsyncExitStack()
await stack.__aenter__()
stack.push_async_callback(_mark_closed, name)
stacks[name] = stack
return stacks
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
loop = _make_loop(tmp_path, mcp_servers={})
async def _handle_one_runtime_control() -> None:
msg = await loop.bus.consume_inbound()
handled = await mcp_runtime.handle_runtime_control(loop, msg, loop.tools)
assert handled is True
consumer = asyncio.create_task(_handle_one_runtime_control())
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
await consumer
assert result["ok"] is True
assert result["added"] == ["browserbase"]
assert result["requires_restart"] is False
assert loop.tools.has("mcp_browserbase_navigate")
config = load_config()
del config.tools.mcp_servers["browserbase"]
save_config(config)
consumer = asyncio.create_task(_handle_one_runtime_control())
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
await consumer
assert result["ok"] is True
assert result["removed"] == ["browserbase"]
assert result["requires_restart"] is False
assert not loop.tools.has("mcp_browserbase_navigate")
assert closed == ["browserbase"]
@pytest.mark.asyncio
async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
):
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config = load_config()
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
type="stdio",
command="browserbase-mcp",
)
save_config(config)
async def _fake_connect(servers, registry):
stacks = {}
for name in servers:
registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
stack = AsyncExitStack()
await stack.__aenter__()
stacks[name] = stack
return stacks
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
loop = _make_loop(tmp_path, mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]})
result = await mcp_runtime.reload_servers(loop, loop.tools)
assert result["ok"] is True
assert result["added"] == []
assert result["changed"] == []
assert result["retried"] == ["browserbase"]
assert loop.tools.has("mcp_browserbase_navigate")
await loop.close_mcp()
-125
View File
@@ -1,125 +0,0 @@
"""Tests for memory system: Consolidator, token estimation, truncation."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.memory import _TIKTOKEN_ENC, Consolidator, MemoryStore, _estimate_tokens
@pytest.fixture
def store(tmp_path):
s = MemoryStore(tmp_path)
s.write_soul("# Soul\n- Helpful")
s.write_user("# User\n- Developer")
s.write_memory("# Memory\n- Project X active")
return s
@pytest.fixture
def mock_provider():
p = MagicMock()
p.chat_with_retry = AsyncMock()
p.generation.max_tokens = 4096
return p
@pytest.fixture
def mock_sessions():
return MagicMock()
@pytest.fixture
def mock_build_messages():
return MagicMock(return_value=[])
@pytest.fixture
def mock_get_tool_definitions():
return MagicMock(return_value=[])
@pytest.fixture
def consolidator(store, mock_provider, mock_sessions, mock_build_messages, mock_get_tool_definitions):
return Consolidator(
store=store,
provider=mock_provider,
model="test-model",
sessions=mock_sessions,
context_window_tokens=128_000,
build_messages=mock_build_messages,
get_tool_definitions=mock_get_tool_definitions,
)
class TestEstimateTokens:
def test_estimate_tokens_returns_positive(self):
assert _estimate_tokens("hello world") > 0
def test_estimate_tokens_english_approximate(self):
# English is roughly 1 token per 4 chars as fallback
text = "a " * 100
if _TIKTOKEN_ENC is not None:
expected = len(_TIKTOKEN_ENC.encode(text))
else:
expected = len(text) // 4
assert _estimate_tokens(text) == expected
class TestTruncateToTokenBudget:
def test_reserve_tokens_reduces_budget(self, consolidator):
long_text = "word " * 200_000
# Without reserve, more text survives
no_reserve = consolidator._truncate_to_token_budget(long_text, reserve_tokens=0)
with_reserve = consolidator._truncate_to_token_budget(long_text, reserve_tokens=500)
assert len(with_reserve) < len(no_reserve)
def test_reserve_tokens_zero_default(self, consolidator):
text = "hello world"
result = consolidator._truncate_to_token_budget(text)
assert result == text
class TestConsolidatorPrompt:
def test_prompt_contains_snip(self):
from nanobot.utils.prompt_templates import render_template
text = render_template("agent/consolidator_archive.md", strip=True)
assert "SNIP" in text
assert "[permanent]" in text
assert "[skip]" in text
class TestConsolidatorArchive:
async def test_archive_injects_dedup_context(self, consolidator, mock_provider, store):
store.write_memory("- User prefers dark mode")
store.write_user("- Developer")
messages = [{"role": "user", "content": "hello", "timestamp": "2026-01-01 10:00"}]
mock_provider.chat_with_retry.return_value = MagicMock(
content="(nothing)", finish_reason="stop"
)
await consolidator.archive(messages)
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs["messages"][1]["content"]
assert "## Current MEMORY.md (for dedup)" in user_msg
assert "User prefers dark mode" in user_msg
assert "## Current USER.md (for dedup)" in user_msg
assert "Developer" in user_msg
async def test_archive_skips_dedup_when_budget_exhausted(self, consolidator, mock_provider, store):
# Shrink token budget so dedup context (always capped at ~6000 chars)
# exceeds the available room.
consolidator.context_window_tokens = 6_000
store.write_memory("word " * 10_000)
messages = [{"role": "user", "content": "hello", "timestamp": "2026-01-01 10:00"}]
mock_provider.chat_with_retry.return_value = MagicMock(
content="(nothing)", finish_reason="stop"
)
await consolidator.archive(messages)
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs["messages"][1]["content"]
# Should not contain dedup context when budget is exhausted
assert "## Current MEMORY.md (for dedup)" not in user_msg
+1 -1
View File
@@ -241,7 +241,7 @@ def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
signature = provider_signature(config)
fallback_signatures = signature[-1]
assert fallback_signatures[0][12] is None
assert fallback_signatures[0][11] is None
# -- FallbackProvider tests --
-184
View File
@@ -1,184 +0,0 @@
"""Tests for sustained-goal continuation in AgentRunner.
When a goal_active_predicate returns True, the runner must not exit with
stop_reason="completed" after a plain-text final response. Instead it should
inject a continuation message and keep looping (similar to mid-turn injection).
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMProvider, LLMResponse
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio
async def test_runner_exits_normally_without_predicate():
"""Baseline: no predicate, runner exits with completed on final text."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="all done", tool_calls=[], usage={},
))
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert result.stop_reason == "completed"
assert result.final_content == "all done"
@pytest.mark.asyncio
async def test_runner_exits_normally_with_inactive_goal():
"""Predicate returns False, runner should exit normally."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="all done", tool_calls=[], usage={},
))
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: False,
))
assert result.stop_reason == "completed"
assert result.final_content == "all done"
@pytest.mark.asyncio
async def test_runner_forces_continue_when_goal_active():
"""Predicate returns True on final text → runner injects continuation and loops.
We set max_iterations=3 and let the provider return final text every time.
Without the fix this would exit on the first iteration with stop_reason
"completed". With the fix the runner is forced to continue until
max_iterations is hit.
"""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="still working", tool_calls=[], usage={},
))
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
))
# Because the predicate keeps returning True, the runner should never
# naturally complete. It loops until max_iterations is exhausted.
assert result.stop_reason == "max_iterations"
# The injected continuation message should be present in the message list.
user_msgs = [m for m in result.messages if m.get("role") == "user"]
assert any("active sustained goal" in str(m.get("content", "")) for m in user_msgs)
@pytest.mark.asyncio
async def test_runner_respects_max_iterations_even_with_active_goal():
"""A single iteration with active goal still hits max_iterations."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="still working", tool_calls=[], usage={},
))
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
))
assert result.stop_reason == "max_iterations"
@pytest.mark.asyncio
async def test_runner_does_not_force_continue_on_error():
"""Even with active goal, an LLM error should exit with stop_reason="error"."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content=None, tool_calls=[], usage={},
finish_reason="error",
))
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
))
assert result.stop_reason == "error"
@pytest.mark.asyncio
async def test_runner_uses_custom_goal_continue_message():
"""Custom goal_continue_message should be injected instead of the default."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="still working", tool_calls=[], usage={},
))
tools = MagicMock()
tools.get_definitions.return_value = []
custom_msg = "CUSTOM_CONTINUE_PLEASE"
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
goal_continue_message=custom_msg,
))
user_msgs = [m for m in result.messages if m.get("role") == "user"]
assert any(custom_msg in str(m.get("content", "")) for m in user_msgs)
-92
View File
@@ -292,95 +292,3 @@ def test_from_config_static_preset_loader_does_not_enable_hot_reload(tmp_path) -
loop = AgentLoop.from_config(config)
assert loop._provider_snapshot_loader is None
assert loop._preset_snapshot_loader is not None
class TestDreamModelOverride:
def test_dream_follows_main_when_no_override(self, tmp_path) -> None:
provider = _provider("base-model")
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="base-model",
context_window_tokens=1000,
)
assert loop.dream.model == "base-model"
assert loop.dream.provider is provider
def test_dream_raw_model_override(self, tmp_path) -> None:
provider = _provider("base-model")
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="base-model",
context_window_tokens=1000,
dream_model_override="custom-model-v2",
)
assert loop.dream.model == "custom-model-v2"
assert loop.dream.provider is provider
def test_dream_preset_override(self, tmp_path) -> None:
cheap_provider = _provider("openai/gpt-4.1-mini", max_tokens=2048)
preset = ModelPresetConfig(
model="openai/gpt-4.1-mini",
provider="openai",
max_tokens=2048,
context_window_tokens=128_000,
)
loop = AgentLoop(
bus=MessageBus(),
provider=_provider("base-model"),
workspace=tmp_path,
model="base-model",
context_window_tokens=1000,
model_presets={"cheap": preset},
dream_model_override="cheap",
preset_snapshot_loader=lambda _name: ProviderSnapshot(
provider=cheap_provider,
model=preset.model,
context_window_tokens=preset.context_window_tokens,
signature=("cheap", preset.model),
),
)
assert loop.dream.model == "openai/gpt-4.1-mini"
assert loop.dream.provider is cheap_provider
assert loop.dream._runner.provider is cheap_provider
def test_dream_override_survives_main_preset_switch(self, tmp_path) -> None:
base_provider = _provider("base-model")
fast_provider = _provider("openai/gpt-4.1", max_tokens=4096)
cheap_provider = _provider("openai/gpt-4.1-mini", max_tokens=2048)
loop = AgentLoop(
bus=MessageBus(),
provider=base_provider,
workspace=tmp_path,
model="base-model",
context_window_tokens=1000,
model_presets={
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
"cheap": ModelPresetConfig(model="openai/gpt-4.1-mini"),
},
dream_model_override="cheap",
preset_snapshot_loader=lambda name: ProviderSnapshot(
provider=fast_provider if name == "fast" else cheap_provider,
model="openai/gpt-4.1" if name == "fast" else "openai/gpt-4.1-mini",
context_window_tokens=32_768 if name == "fast" else 128_000,
signature=(name, "model"),
),
)
# Initially dream is on cheap
assert loop.dream.model == "openai/gpt-4.1-mini"
assert loop.dream.provider is cheap_provider
# Switch main preset to fast
loop.set_model_preset("fast")
# Main agent should be on fast
assert loop.model == "openai/gpt-4.1"
assert loop.provider is fast_provider
# Dream should still be on cheap override
assert loop.dream.model == "openai/gpt-4.1-mini"
assert loop.dream.provider is cheap_provider
assert loop.dream._runner.provider is cheap_provider
@@ -56,20 +56,6 @@ def test_list_sessions_includes_user_preview(tmp_path):
assert rows[0]["preview"] == "帮我总结一下 OpenAI 的最新硬件计划"
def test_list_sessions_bounds_preview_scan(tmp_path):
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:chat-long-preview")
for index in range(220):
session.add_message("assistant", f"assistant trace {index}")
session.add_message("user", "this should not force a full sidebar scan")
manager.save(session)
rows = manager.list_sessions()
assert rows[0]["key"] == "websocket:chat-long-preview"
assert rows[0]["preview"] == "assistant trace 0"
# --- Original regression test (from PR 2075) ---
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
-33
View File
@@ -94,39 +94,6 @@ async def test_subagent_uses_configured_max_iterations(tmp_path):
mgr.runner.run.assert_awaited_once()
@pytest.mark.asyncio
async def test_spawn_forwards_temperature_to_run_spec(tmp_path):
"""A temperature passed to spawn() should reach the AgentRunSpec."""
from nanobot.agent.subagent import SubagentManager
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
mgr = SubagentManager(
provider=provider,
workspace=tmp_path,
bus=bus,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
)
mgr._announce_result = AsyncMock()
seen = {}
async def fake_run(spec):
seen["temperature"] = spec.temperature
return SimpleNamespace(
stop_reason="done", final_content="done", error=None, tool_events=[],
)
mgr.runner.run = AsyncMock(side_effect=fake_run)
await mgr.spawn(task="do task", temperature=0.9)
await asyncio.gather(*mgr._running_tasks.values(), return_exceptions=True)
assert seen["temperature"] == 0.9
@pytest.mark.asyncio
async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path):
"""SpawnTool should return an error string when the concurrency limit is reached."""
+29 -60
View File
@@ -30,7 +30,7 @@ from nanobot.channels.websocket import (
)
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.webui.settings_api import settings_payload, update_provider_settings
from nanobot.webui.settings_api import settings_payload
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
@@ -501,6 +501,7 @@ async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch,
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
channel._webui_chats.add("chat-1")
await channel.send_delta("chat-1", "![Diagram](", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
@@ -533,6 +534,7 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
channel._webui_chats.add("chat-1")
await channel.send_delta(
"chat-1",
@@ -546,6 +548,31 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
assert final["text"].startswith("![Diagram](/api/media/")
@pytest.mark.asyncio
async def test_send_delta_stream_end_leaves_non_webui_payload_unchanged(tmp_path) -> None:
bus = MagicMock()
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
workspace_path=workspace,
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_delta(
"chat-1",
"![Diagram](diagram.png)",
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
)
mock_ws.send.assert_awaited_once()
final = json.loads(mock_ws.send.await_args.args[0])
assert final == {"event": "stream_end", "chat_id": "chat-1", "stream_id": "sid"}
@pytest.mark.asyncio
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
bus = MagicMock()
@@ -1188,30 +1215,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
)
assert bad_preset.status_code == 400
created_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create"
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
headers={"Authorization": "Bearer tok"},
)
assert created_preset.status_code == 200
created_body = created_preset.json()
assert created_body["agent"]["model_preset"] == "fast-writing"
assert created_body["agent"]["model"] == "openai/gpt-4.1-mini"
created_presets = {
preset["name"]: preset for preset in created_body["model_presets"]
}
assert created_presets["fast-writing"]["label"] == "Fast writing"
assert created_presets["fast-writing"]["provider"] == "openai"
duplicate_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create"
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
headers={"Authorization": "Bearer tok"},
)
assert duplicate_preset.status_code == 409
search_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=searxng"
@@ -1279,10 +1282,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
saved = load_config(config_path)
assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat"
assert saved.agents.defaults.model_preset == "fast-writing"
assert saved.model_presets["fast-writing"].label == "Fast writing"
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
assert saved.model_presets["fast-writing"].provider == "openai"
assert saved.agents.defaults.model_preset == "deep"
assert saved.agents.defaults.timezone == "Asia/Shanghai"
assert saved.agents.defaults.bot_name == "Nano"
assert saved.agents.defaults.bot_icon == "N"
@@ -1351,37 +1351,6 @@ def test_settings_payload_normalizes_camel_case_provider(
assert body["agent"]["provider"] == "minimax_anthropic"
def test_settings_payload_exposes_api_type_only_for_openai(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.openai.api_type = "responses"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
body = settings_payload()
providers = {provider["name"]: provider for provider in body["providers"]}
assert providers["openai"]["api_type"] == "responses"
assert "api_type" not in providers["custom"]
def test_update_provider_settings_ignores_api_type_for_non_openai(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
body = update_provider_settings({
"provider": ["custom"],
"api_base": ["https://example.test/v1"],
"api_type": ["responses"],
})
assert body["providers"]
config = load_config(config_path)
assert config.providers.custom.api_base == "https://example.test/v1"
assert config.providers.custom.api_type == "auto"
@pytest.mark.asyncio
async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMock) -> None:
port = 29880
@@ -209,156 +209,6 @@ async def test_cli_apps_routes_require_token_and_return_payload(
await server_task
@pytest.mark.asyncio
async def test_mcp_presets_routes_require_token_and_return_payload(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.webui.mcp_presets_api.mcp_presets_payload",
lambda: {
"presets": [
{
"name": "browserbase",
"display_name": "Browserbase",
"category": "browser",
"description": "Cloud browser automation",
"docs_url": "https://docs.browserbase.com/integrations/mcp/configuration",
"transport": "streamableHttp",
"requires": "Browserbase API key",
"note": "",
"install_supported": True,
"installed": False,
"configured": False,
"available": False,
"status": "not_installed",
"logo_url": None,
"brand_color": "#111827",
"required_fields": [],
"connection_summary": "",
}
],
"installed_count": 0,
},
)
preset_queries: list[tuple[str, dict[str, list[str]]]] = []
custom_queries: list[tuple[str, dict[str, list[str]]]] = []
def _mcp_preset_action(action: str, query: dict[str, list[str]]) -> dict[str, Any]:
preset_queries.append((action, query))
return {
"presets": [],
"installed_count": 1,
"requires_restart": action != "test",
"last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"},
}
def _custom_action(action: str, query: dict[str, list[str]]) -> dict[str, Any]:
custom_queries.append((action, query))
return {
"presets": [],
"installed_count": 1,
"requires_restart": True,
"last_action": {
"ok": True,
"message": f"{action}:{query.get('name', ['config'])[0]}",
},
}
monkeypatch.setattr(
"nanobot.webui.mcp_presets_api.mcp_presets_action",
_mcp_preset_action,
)
monkeypatch.setattr(
"nanobot.webui.mcp_presets_api.custom_mcp_action",
_custom_action,
)
async def _hot_reload(_bus):
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
monkeypatch.setattr(
"nanobot.channels.websocket.request_mcp_reload",
_hot_reload,
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
assert deny.status_code == 401
boot = await _http_get("http://127.0.0.1:29913/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
catalog = await _http_get(
"http://127.0.0.1:29913/api/settings/mcp-presets",
headers=auth,
)
assert catalog.status_code == 200
assert catalog.json()["presets"][0]["name"] == "browserbase"
enabled = await _http_get(
"http://127.0.0.1:29913/api/settings/mcp-presets/enable?name=browserbase",
headers={
**auth,
"X-Nanobot-MCP-Values": json.dumps(
{"browserbase_api_key": "bb_live_secret"}
),
},
)
assert enabled.status_code == 200
assert preset_queries[-1][1]["browserbase_api_key"] == ["bb_live_secret"]
body = enabled.json()
assert "bb_live_secret" not in enabled.text
assert body["last_action"]["message"] == "enable:browserbase MCP config reloaded."
assert body["hot_reload"]["ok"] is True
assert body["restart_required_sections"] == []
bad_header = await _http_get(
"http://127.0.0.1:29913/api/settings/mcp-presets/enable?name=browserbase",
headers={**auth, "X-Nanobot-MCP-Values": "[]"},
)
assert bad_header.status_code == 400
custom = await _http_get(
"http://127.0.0.1:29913/api/settings/mcp-presets/custom",
headers={
**auth,
"X-Nanobot-MCP-Values": json.dumps(
{"name": "docs", "command": "npx"}
),
},
)
assert custom.status_code == 200
assert custom_queries[-1][1]["command"] == ["npx"]
assert custom.json()["last_action"]["message"] == "custom:docs MCP config reloaded."
imported = await _http_get(
"http://127.0.0.1:29913/api/settings/mcp-presets/import",
headers={**auth, "X-Nanobot-MCP-Values": json.dumps({"config": "{}"})},
)
assert imported.status_code == 200
assert imported.json()["last_action"]["message"] == "import:config MCP config reloaded."
tools = await _http_get(
"http://127.0.0.1:29913/api/settings/mcp-presets/tools",
headers={
**auth,
"X-Nanobot-MCP-Values": json.dumps(
{"name": "docs", "enabled_tools": []}
),
},
)
assert tools.status_code == 200
assert tools.json()["last_action"]["message"] == "tools:docs MCP config reloaded."
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_sessions_list_only_returns_websocket_sessions_by_default(
bus: MagicMock, tmp_path: Path
+7 -174
View File
@@ -5,11 +5,10 @@ import subprocess
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
@@ -147,15 +146,6 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
assert apps["jimeng"]["install_supported"] is False
assert apps["suno"]["install_supported"] is True
assert apps["gimp"]["logo_url"]
gimp_manifest = apps["gimp"]["manifest"]
assert gimp_manifest["schema"] == "agent-app.v1"
assert gimp_manifest["id"] == "gimp"
assert gimp_manifest["source"] == "cli-anything:harness+public"
assert gimp_manifest["capabilities"][0]["type"] == "cli"
assert gimp_manifest["capabilities"][0]["entry_point"] == "cli-anything-gimp"
assert gimp_manifest["install"]["verification"] == ["entry_point_available"]
assert "entry_point_absent" in gimp_manifest["remove"]["verification"]
assert gimp_manifest["trust"]["review_status"] == "catalog_entry"
assert apps["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF"
assert apps["feishu"]["logo_url"] == (
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
@@ -166,33 +156,6 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
)
def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
_write_cache(
manager._cache_path("public"),
{
"meta": {"updated": "2026-04-18"},
"clis": [
{
"name": "anygen",
"display_name": "AnyGen",
"description": "Generate docs, slides, websites and more via AnyGen cloud API",
"category": "generation",
"install_cmd": "pip install cli-anything-anygen",
"entry_point": "cli-anything-anygen",
}
],
},
)
payload = manager.payload()
app = payload["apps"][0]
assert app["name"] == "anygen"
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
def test_install_dispatches_safe_pip_and_installs_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -216,8 +179,6 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["installed"] is True
assert "state_recorded" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
@@ -225,49 +186,6 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
def test_install_records_entry_point_path_and_pip_distribution(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = tmp_path / "bin" / "cli-anything-gimp"
resolved.parent.mkdir()
resolved.write_text("#!/bin/sh\n", encoding="utf-8")
monkeypatch.setattr(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
monkeypatch.setattr(
manager,
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: str(resolved) if command == "cli-anything-gimp" else None,
)
monkeypatch.setattr(
"nanobot.apps.cli.service.importlib_metadata.distributions",
lambda: [
SimpleNamespace(
entry_points=[
SimpleNamespace(group="console_scripts", name="cli-anything-gimp"),
],
metadata={"Name": "cli-anything-gimp"},
)
],
)
manager.install("gimp")
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point_path"] == str(resolved)
assert installed["gimp"]["pip_distribution"] == "cli-anything-gimp"
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
manager = _manager(tmp_path)
@@ -288,7 +206,7 @@ def test_fetch_skill_content_rejects_untrusted_urls(
def fail_get(*args, **kwargs):
raise AssertionError("untrusted skill URL should not be fetched")
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fail_get)
assert manager._fetch_skill_content({
"name": "evil",
@@ -318,7 +236,7 @@ def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
seen.append(url)
return Response()
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get)
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get)
content = manager._fetch_skill_content({
"name": "gimp",
@@ -375,91 +293,6 @@ def test_uninstall_uses_safe_python_m_pip_uninstall_command(
assert payload["last_action"]["ok"] is True
def test_uninstall_uses_recorded_pip_distribution(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({
"gimp": {
"entry_point": "cli-anything-gimp",
"pip_distribution": "actual-dist-name",
"entry_point_path": str(tmp_path / "bin" / "cli-anything-gimp"),
}
})
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
payload = manager.uninstall("gimp")
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "actual-dist-name"]]
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["removed"] is True
assert "entry_point_absent" in payload["last_action"]["verification"]
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
def test_uninstall_keeps_state_when_entry_point_still_available(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
monkeypatch.setattr(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: "/usr/local/bin/cli-anything-gimp" if command == "cli-anything-gimp" else None,
)
payload = manager.uninstall("gimp")
assert payload["last_action"]["ok"] is False
assert payload["last_action"]["removed"] is False
assert payload["last_action"]["still_available"] is True
assert payload["last_action"]["verification_failed"] == ["entry_point_absent"]
assert "kept it installed" in payload["last_action"]["message"]
assert "gimp" in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
def test_uninstall_keeps_state_when_recorded_entry_point_still_exists(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = tmp_path / "bin" / "cli-anything-gimp"
resolved.parent.mkdir()
resolved.write_text("#!/bin/sh\n", encoding="utf-8")
manager._save_installed({
"gimp": {
"entry_point": "cli-anything-gimp",
"entry_point_path": str(resolved),
}
})
monkeypatch.setattr(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
payload = manager.uninstall("gimp")
assert payload["last_action"]["ok"] is False
assert str(resolved) in payload["last_action"]["message"]
assert "gimp" in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None:
manager = _manager(tmp_path)
manager._save_installed(
@@ -508,7 +341,7 @@ def test_run_installed_cli_uses_argv_without_shell(
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
"nanobot.cli_apps.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
@@ -521,7 +354,7 @@ def test_run_installed_cli_uses_argv_without_shell(
stderr="",
)
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
manager._save_installed(
{
"gimp": {
@@ -547,7 +380,7 @@ def test_run_reports_created_artifacts(
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
"nanobot.cli_apps.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
@@ -556,7 +389,7 @@ def test_run_reports_created_artifacts(
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="")
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
result = manager.run("gimp", ["render"])
+6 -6
View File
@@ -7,7 +7,7 @@ import time
from pathlib import Path
from nanobot.agent.tools.cli_apps import CliAppsTool
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
@@ -46,7 +46,7 @@ def test_run_cli_app_uses_installed_registry_app(
)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
"nanobot.cli_apps.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
@@ -59,8 +59,8 @@ def test_run_cli_app_uses_installed_registry_app(
stderr="",
)
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(
workspace=workspace,
@@ -102,7 +102,7 @@ def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> Non
}
_write_cache(data_dir / "harness_registry_cache.json", registry)
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
result = asyncio.run(tool.execute(name="gimp"))
@@ -117,7 +117,7 @@ def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Pa
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
{"drawio": {"entry_point": "cli-anything-drawio"}}
)
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace)
+3 -3
View File
@@ -2,8 +2,8 @@
from types import SimpleNamespace
from nanobot.apps.cli.service import CliAppManager
from nanobot.apps.cli.utils import runtime_lines, session_extra
from nanobot.cli_apps.service import CliAppManager
from nanobot.cli_apps.utils import runtime_lines, session_extra
def test_session_extra_returns_cli_apps_only_when_present() -> None:
@@ -15,7 +15,7 @@ def test_session_extra_returns_cli_apps_only_when_present() -> None:
def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
data_dir = tmp_path / "data"
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
manager = CliAppManager(workspace=tmp_path)
manager._save_installed(
{
-46
View File
@@ -113,52 +113,6 @@ async def test_dream_restore_lists_versions_with_next_steps() -> None:
assert "Restore a version with `/dream-restore <sha>`." in out.content
@pytest.mark.asyncio
async def test_dream_log_shows_summary_and_analysis() -> None:
commit = CommitInfo(
sha="abcd1234",
message="dream: 2026-04-04, 2 change(s)\n\n[ADD] fact A →USER\n[REMOVE] old fact",
timestamp="2026-04-04 12:00",
)
diff = (
"diff --git a/SOUL.md b/SOUL.md\n"
"--- a/SOUL.md\n"
"+++ b/SOUL.md\n"
"@@ -1 +1 @@\n"
"-old\n"
"+new\n"
)
git = _FakeGit(commits=[commit], diff_map={commit.sha: (commit, diff)})
out = await cmd_dream_log(_make_ctx("/dream-log", git))
assert "## Dream Update" in out.content
assert "- Summary: dream: 2026-04-04, 2 change(s)" in out.content
assert "### Analysis" in out.content
assert "[ADD] fact A →USER" in out.content
assert "[REMOVE] old fact" in out.content
@pytest.mark.asyncio
async def test_dream_log_with_empty_commit_message() -> None:
commit = CommitInfo(sha="abcd1234", message="", timestamp="2026-04-04 12:00")
diff = (
"diff --git a/SOUL.md b/SOUL.md\n"
"--- a/SOUL.md\n"
"+++ b/SOUL.md\n"
"@@ -1 +1 @@\n"
"-old\n"
"+new\n"
)
git = _FakeGit(commits=[commit], diff_map={commit.sha: (commit, diff)})
out = await cmd_dream_log(_make_ctx("/dream-log", git))
assert "## Dream Update" in out.content
assert "- Summary:" not in out.content
assert "### Analysis" not in out.content
@pytest.mark.asyncio
async def test_dream_restore_success_mentions_files_and_followup() -> None:
commit = CommitInfo(sha="abcd1234", message="dream: latest", timestamp="2026-04-04 12:00")
-36
View File
@@ -1,5 +1,3 @@
import pytest
from nanobot.config.schema import Config
@@ -14,40 +12,6 @@ def test_resolve_preset_returns_defaults_when_no_preset() -> None:
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
def test_provider_api_type_accepts_exact_values_only() -> None:
config = Config.model_validate({
"providers": {
"openai": {
"apiKey": "sk-test",
"apiType": "responses",
}
}
})
assert config.providers.openai.api_type == "responses"
with pytest.raises(ValueError):
Config.model_validate({
"providers": {
"openai": {
"apiKey": "sk-test",
"apiType": "response",
}
}
})
def test_provider_api_type_is_openai_only() -> None:
with pytest.raises(ValueError, match="only supported"):
Config.model_validate({
"providers": {
"custom": {
"apiBase": "https://example.test/v1",
"apiType": "responses",
}
}
})
def test_legacy_defaults_config_without_presets_still_resolves() -> None:
config = Config.model_validate({
"agents": {
-81
View File
@@ -9,7 +9,6 @@ from nanobot.providers.openai_compat_provider import (
OpenAICompatProvider,
_deep_merge,
)
from nanobot.providers.registry import find_by_name
# ---------------------------------------------------------------------------
# _deep_merge unit tests
@@ -186,86 +185,6 @@ class TestBuildKwargsExtraBody:
assert kwargs["extra_body"]["repetition_penalty"] == 1.15
class TestBuildResponsesBodyExtraBody:
"""Verify extra_body flows into Responses API request bodies."""
def test_responses_extra_body_merges_top_level_fields(self) -> None:
provider = OpenAICompatProvider(
api_key="test-key",
default_model="gpt-5",
spec=find_by_name("openai"),
extra_body={
"metadata": {"source": "test"},
"parallel_tool_calls": False,
},
)
body = provider._build_responses_body(
messages=_simple_messages(),
tools=None, model=None, max_tokens=100,
temperature=0.1, reasoning_effort=None, tool_choice=None,
)
assert body["metadata"] == {"source": "test"}
assert body["parallel_tool_calls"] is False
def test_responses_extra_body_appends_tools(self) -> None:
provider = OpenAICompatProvider(
api_key="test-key",
default_model="gpt-5",
spec=find_by_name("openai"),
extra_body={"tools": [{"type": "web_search"}]},
)
body = provider._build_responses_body(
messages=_simple_messages(),
tools=[{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object"},
},
}],
model=None, max_tokens=100, temperature=0.1,
reasoning_effort=None, tool_choice=None,
)
assert body["tools"] == [
{
"type": "function",
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object"},
},
{"type": "web_search"},
]
def test_responses_extra_body_merges_include_without_duplicates(self) -> None:
provider = OpenAICompatProvider(
api_key="test-key",
default_model="gpt-5",
spec=find_by_name("openai"),
extra_body={
"include": [
"reasoning.encrypted_content",
"web_search_call.action.sources",
],
},
)
body = provider._build_responses_body(
messages=_simple_messages(),
tools=None, model=None, max_tokens=100,
temperature=0.1, reasoning_effort="high", tool_choice=None,
)
assert body["include"] == [
"reasoning.encrypted_content",
"web_search_call.action.sources",
]
# ---------------------------------------------------------------------------
# Schema validation
# ---------------------------------------------------------------------------
@@ -20,7 +20,6 @@ def _make_copilot_provider() -> OpenAICompatProvider:
p.default_model = "github_copilot/gpt-5.4-mini"
p._spec = find_by_name("github_copilot")
p._effective_base = "https://api.githubcopilot.com"
p._api_type = "auto"
p._responses_failures = {}
p._responses_tripped_at = {}
return p
-100
View File
@@ -18,7 +18,6 @@ from nanobot.providers.image_generation import (
OpenAIImageGenerationClient,
OpenRouterImageGenerationClient,
StepFunImageGenerationClient,
ZhipuImageGenerationClient,
)
PNG_BYTES = (
@@ -1028,102 +1027,3 @@ async def test_openai_no_images_raises() -> None:
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="dall-e-3")
# ---------------------------------------------------------------------------
# Zhipu
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_zhipu_image_generation_payload_and_response() -> None:
fake = FakeClient(FakeResponse({"data": [{"url": "https://cdn.example/image.png"}]}))
fake.get_response = FakeResponse({}, content=PNG_BYTES)
client = ZhipuImageGenerationClient(
api_key="sk-zhipu-test",
api_base="https://open.bigmodel.cn/api/paas/v4",
extra_headers={"X-Test": "1"},
extra_body={"watermark_enabled": False},
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="a sunset over the ocean",
model="glm-image",
aspect_ratio="16:9",
image_size="2K",
)
assert response.images[0].startswith("data:image/png;base64,")
call = fake.calls[0]
assert call["url"] == "https://open.bigmodel.cn/api/paas/v4/images/generations"
assert call["headers"]["Authorization"] == "Bearer sk-zhipu-test"
assert call["headers"]["X-Test"] == "1"
body = call["json"]
assert body["model"] == "glm-image"
assert body["prompt"] == "a sunset over the ocean"
assert body["size"] == "1728x960"
assert body["watermark_enabled"] is False
@pytest.mark.asyncio
async def test_zhipu_image_generation_with_explicit_size() -> None:
fake = FakeClient(FakeResponse({"data": [{"url": "https://cdn.example/image.png"}]}))
fake.get_response = FakeResponse({}, content=PNG_BYTES)
client = ZhipuImageGenerationClient(
api_key="sk-zhipu-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(
prompt="a cat",
model="cogview-4",
image_size="1024x1024",
)
body = fake.calls[0]["json"]
assert body["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_zhipu_image_generation_downloads_url_response() -> None:
fake = FakeClient(FakeResponse({"data": [{"url": "https://cdn.example/image.png"}]}))
fake.get_response = FakeResponse({}, content=PNG_BYTES)
client = ZhipuImageGenerationClient(
api_key="sk-zhipu-test",
client=fake, # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="glm-image")
assert response.images[0].startswith("data:image/png;base64,")
assert fake.get_calls[0]["url"] == "https://cdn.example/image.png"
@pytest.mark.asyncio
async def test_zhipu_image_generation_requires_api_key() -> None:
client = ZhipuImageGenerationClient(api_key=None)
with pytest.raises(ImageGenerationError, match="API key"):
await client.generate(prompt="draw", model="glm-image")
@pytest.mark.asyncio
async def test_zhipu_image_generation_no_images_raises() -> None:
fake = FakeClient(FakeResponse({"data": [{"text": "sorry"}]}))
client = ZhipuImageGenerationClient(api_key="sk-zhipu-test", client=fake) # type: ignore[arg-type]
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="glm-image")
@pytest.mark.asyncio
async def test_zhipu_image_generation_rejects_reference_images() -> None:
client = ZhipuImageGenerationClient(api_key="sk-zhipu-test")
with pytest.raises(ImageGenerationError, match="reference images"):
await client.generate(
prompt="edit this",
model="glm-image",
reference_images=["ref.png"],
)
+1 -30
View File
@@ -602,7 +602,6 @@ async def test_openai_compat_preserves_extra_content_on_tool_calls() -> None:
assert len(result.tool_calls) == 1
tool_call = result.tool_calls[0]
assert tool_call.id == "call_123"
assert tool_call.extra_content == {"google": {"thought_signature": "signed-token"}}
assert tool_call.function_provider_specific_fields == {"inner": "value"}
@@ -995,7 +994,7 @@ def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None:
assert kwargs["messages"][2]["role"] == "tool"
def test_openai_compat_preserves_tool_call_ids_after_consecutive_assistant_messages() -> None:
def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
@@ -1017,34 +1016,6 @@ def test_openai_compat_preserves_tool_call_ids_after_consecutive_assistant_messa
{"role": "user", "content": "多少star了呢"},
])
assert sanitized[1]["role"] == "assistant"
assert sanitized[1]["content"] is None
assert sanitized[1]["tool_calls"][0]["id"] == "call_function_akxp3wqzn7ph_1"
assert sanitized[2]["tool_call_id"] == "call_function_akxp3wqzn7ph_1"
def test_mistral_normalizes_tool_call_ids_after_consecutive_assistant_messages() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider(spec=find_by_name("mistral"))
sanitized = provider._sanitize_messages([
{"role": "user", "content": "不错"},
{"role": "assistant", "content": "对,破 4 万指日可待"},
{
"role": "assistant",
"content": "<think>我再查一下</think>",
"tool_calls": [
{
"id": "call_function_akxp3wqzn7ph_1",
"type": "function",
"function": {"name": "exec", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_function_akxp3wqzn7ph_1", "name": "exec", "content": "ok"},
{"role": "user", "content": "多少star了呢"},
])
assert sanitized[1]["role"] == "assistant"
assert sanitized[1]["content"] is None
assert sanitized[1]["tool_calls"][0]["id"] == "3ec83c30d"
@@ -18,7 +18,6 @@ def provider():
p.default_model = "gpt-5"
p._spec = type("Spec", (), {"name": "openai"})()
p._effective_base = "https://api.openai.com/v1"
p._api_type = "auto"
p._responses_failures = {}
p._responses_tripped_at = {}
return p
@@ -28,33 +27,6 @@ def test_responses_api_available_by_default(provider):
assert provider._should_use_responses_api("gpt-5", None) is True
def test_api_type_chat_completions_disables_responses(provider):
provider._api_type = "chat_completions"
assert provider._should_use_responses_api("gpt-5", None) is False
def test_api_type_responses_forces_responses_for_openai(provider):
provider.default_model = "gpt-4o"
provider._api_type = "responses"
assert provider._should_use_responses_api("gpt-4o", None) is True
def test_api_type_responses_ignores_circuit_breaker(provider):
provider.default_model = "gpt-4o"
provider._api_type = "responses"
provider._responses_failures = {"gpt-4o|gpt-4o|": _RESPONSES_FAILURE_THRESHOLD}
provider._responses_tripped_at = {"gpt-4o|gpt-4o|": 0.0}
assert provider._should_use_responses_api("gpt-4o", None) is True
def test_api_type_responses_does_not_force_non_openai(provider):
provider._spec = type("Spec", (), {"name": "custom"})()
provider._api_type = "responses"
assert provider._should_use_responses_api("gpt-4o", None) is False
def test_circuit_opens_after_threshold(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None)
+1 -39
View File
@@ -8,11 +8,7 @@ from unittest.mock import AsyncMock, patch
import httpx
import pytest
from nanobot.providers.transcription import (
GroqTranscriptionProvider,
OpenAITranscriptionProvider,
_resolve_transcription_url,
)
from nanobot.providers.transcription import GroqTranscriptionProvider, OpenAITranscriptionProvider
@pytest.fixture
@@ -294,37 +290,3 @@ async def test_retries_on_every_advertised_transient_exception(
result = await provider.transcribe(audio_file)
assert result == "recovered"
assert post.await_count == 2
# ---------------------------------------------------------------------------
# apiBase normalization (#3637): a chat-style base must not be POSTed verbatim
# ---------------------------------------------------------------------------
def test_resolve_transcription_url_falls_back_to_default() -> None:
default = "https://api.openai.com/v1/audio/transcriptions"
assert _resolve_transcription_url(None, default) == default
assert _resolve_transcription_url("", default) == default
def test_resolve_transcription_url_appends_path_to_chat_style_base() -> None:
assert (
_resolve_transcription_url("https://api.groq.com/openai/v1", "https://x/audio/transcriptions")
== "https://api.groq.com/openai/v1/audio/transcriptions"
)
# Trailing slash must not produce a doubled separator.
assert (
_resolve_transcription_url("https://api.groq.com/openai/v1/", "https://x/audio/transcriptions")
== "https://api.groq.com/openai/v1/audio/transcriptions"
)
def test_resolve_transcription_url_keeps_full_endpoint() -> None:
full = "https://api.groq.com/openai/v1/audio/transcriptions"
assert _resolve_transcription_url(full, "https://x/audio/transcriptions") == full
def test_groq_provider_normalizes_chat_style_api_base() -> None:
"""Regression for #3637: apiBase set to the v1 base resolves to the audio endpoint."""
provider = GroqTranscriptionProvider(api_key="gsk-test", api_base="https://api.groq.com/openai/v1")
assert provider.api_url == "https://api.groq.com/openai/v1/audio/transcriptions"
-3
View File
@@ -64,7 +64,6 @@ async def test_spawn_tool_keeps_task_local_context() -> None:
origin_chat_id: str,
session_key: str,
origin_message_id: str | None = None,
temperature: float | None = None,
) -> str:
seen.append((origin_channel, origin_chat_id, session_key))
return f"{origin_channel}:{origin_chat_id}:{task}"
@@ -177,7 +176,6 @@ async def test_spawn_tool_basic_set_context_and_execute() -> None:
origin_chat_id,
session_key,
origin_message_id=None,
temperature=None,
):
seen.append((origin_channel, origin_chat_id, session_key))
return f"ok: {task}"
@@ -210,7 +208,6 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
origin_chat_id,
session_key,
origin_message_id=None,
temperature=None,
):
seen.append((origin_channel, origin_chat_id, session_key))
return "ok"
-17
View File
@@ -171,23 +171,6 @@ async def test_generate_image_tool_allows_ollama_without_api_key(
assert fake.calls[0]["image_size"] == "1K"
@pytest.mark.asyncio
async def test_generate_image_tool_reports_missing_zhipu_key(tmp_path: Path) -> None:
tool = ImageGenerationTool(
workspace=tmp_path,
config=ImageGenerationToolConfig(
enabled=True,
provider="zhipu",
model="glm-image",
),
provider_configs={"zhipu": ProviderConfig(api_base="https://open.bigmodel.cn/api/paas/v4")},
)
result = await tool.execute(prompt="draw a cat")
assert result.startswith("Error: Zhipu API key is not configured")
@pytest.mark.asyncio
async def test_generate_image_tool_rejects_reference_outside_workspace(tmp_path: Path) -> None:
set_config_path(tmp_path / "config.json")
+1 -34
View File
@@ -52,17 +52,10 @@ def _fake_mcp_module(
)
class _FakeStdioServerParameters:
def __init__(
self,
command: str,
args: list[str],
env: dict | None = None,
cwd: str | None = None,
) -> None:
def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
self.command = command
self.args = args
self.env = env
self.cwd = cwd
class _FakeClientSession:
def __init__(self, _read: object, _write: object) -> None:
@@ -568,32 +561,6 @@ async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
assert captured["env"] is None
@pytest.mark.asyncio
async def test_connect_mcp_servers_passes_stdio_cwd(
fake_mcp_runtime: dict[str, object | None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_mcp_runtime["session"] = _make_fake_session(["demo"])
captured: dict[str, object] = {}
@asynccontextmanager
async def _capturing_stdio_client(params: object):
captured["cwd"] = params.cwd
yield object(), object()
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _capturing_stdio_client)
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{"test": MCPServerConfig(command="fake", cwd="/tmp/nanobot-mcp-test")},
registry,
)
for stack in stacks.values():
await stack.aclose()
assert captured["cwd"] == "/tmp/nanobot-mcp-test"
# ---------------------------------------------------------------------------
# MCPResourceWrapper tests
# ---------------------------------------------------------------------------
+1 -22
View File
@@ -4,7 +4,6 @@ import sys
from typing import Any
import pytest
from pydantic import ValidationError
from nanobot.agent.tools import (
ArraySchema,
@@ -17,7 +16,7 @@ from nanobot.agent.tools import (
)
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool, ExecToolConfig
from nanobot.agent.tools.shell import ExecTool
from nanobot.security.network import configure_ssrf_whitelist
@@ -664,26 +663,6 @@ async def test_exec_timeout_capped_at_max() -> None:
assert "Exit code: 0" in result
def test_exec_config_timeout_uncapped_and_zero() -> None:
"""Config timeout is no longer capped at 600 and accepts 0 = no limit (#3595)."""
assert ExecToolConfig(timeout=0).timeout == 0
assert ExecToolConfig(timeout=3600).timeout == 3600
with pytest.raises(ValidationError):
ExecToolConfig(timeout=-1)
def test_resolve_timeout_config_uncapped_and_unlimited() -> None:
"""Config timeout drives the hard timeout uncapped; 0 means no limit (#3595)."""
assert ExecTool(timeout=3600)._resolve_timeout(None) == 3600
assert ExecTool(timeout=0)._resolve_timeout(None) is None
def test_resolve_timeout_per_call_still_capped() -> None:
"""Per-call (LLM) timeout stays capped at _MAX_TIMEOUT even with unlimited config."""
assert ExecTool(timeout=0)._resolve_timeout(9999) == ExecTool._MAX_TIMEOUT
assert ExecTool(timeout=60)._resolve_timeout(120) == 120
# --- _resolve_type and nullable param tests ---
+213 -10
View File
@@ -1,13 +1,216 @@
from nanobot.utils.gitstore import CommitInfo
"""Tests for GitStore — line_ages() and core git operations."""
import subprocess
import time
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from nanobot.utils.gitstore import GitStore
class TestCommitInfo:
def test_format_with_empty_message(self):
commit = CommitInfo(sha="abcd1234", message="", timestamp="2026-04-04 12:00")
result = commit.format()
assert "(no message)" in result or "## " in result
@pytest.fixture
def git(tmp_path):
"""Create an initialized GitStore with tracked MEMORY.md."""
g = GitStore(tmp_path, tracked_files=["MEMORY.md", "SOUL.md"])
g.init()
return g
def test_format_with_message(self):
commit = CommitInfo(sha="abcd1234", message="dream: update", timestamp="2026-04-04 12:00")
result = commit.format()
assert "dream: update" in result
class TestLineAges:
def test_returns_empty_when_not_initialized(self, tmp_path):
"""line_ages should return [] if the git repo is not initialized."""
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
assert git.line_ages("MEMORY.md") == []
def test_returns_empty_for_missing_file(self, git):
"""line_ages should return [] for a file that doesn't exist."""
assert git.line_ages("SOUL.md") == []
def test_returns_empty_for_empty_file(self, git, tmp_path):
"""line_ages should return [] for an empty tracked file."""
(tmp_path / "SOUL.md").write_text("", encoding="utf-8")
git.auto_commit("empty soul")
assert git.line_ages("SOUL.md") == []
def test_one_age_per_line(self, git, tmp_path):
"""line_ages should return one entry per line in the file."""
content = "# Memory\n\n## Section A\n- item 1\n"
(tmp_path / "MEMORY.md").write_text(content, encoding="utf-8")
git.auto_commit("initial")
ages = git.line_ages("MEMORY.md")
assert len(ages) == len(content.splitlines())
def test_fresh_lines_have_age_zero(self, git, tmp_path):
"""Lines committed today should have age_days=0."""
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
git.auto_commit("initial")
ages = git.line_ages("MEMORY.md")
assert all(a.age_days == 0 for a in ages)
def test_age_differentiates_across_days(self, git, tmp_path):
"""Lines committed today should show correct age when 'now' is mocked forward."""
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
git.auto_commit("initial")
future_now = datetime.now(tz=timezone.utc) + timedelta(days=30)
with patch("nanobot.utils.gitstore.datetime") as mock_dt:
mock_dt.now.return_value = future_now
mock_dt.fromtimestamp = datetime.fromtimestamp
ages = git.line_ages("MEMORY.md")
assert len(ages) == 2
assert all(a.age_days == 30 for a in ages)
def test_annotate_failure_returns_empty(self, tmp_path):
"""If annotate fails, line_ages should return [] gracefully."""
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
# Don't init — annotate will fail
assert git.line_ages("MEMORY.md") == []
def test_partial_edit_only_updates_changed_lines(self, git, tmp_path):
"""Only modified lines should reflect the new commit's timestamp."""
(tmp_path / "MEMORY.md").write_text(
"# Memory\n\n## A\n- old\n\n## B\n- keep\n", encoding="utf-8"
)
git.auto_commit("commit1")
time.sleep(1.1)
# Only modify section A
(tmp_path / "MEMORY.md").write_text(
"# Memory\n\n## A\n- new\n\n## B\n- keep\n", encoding="utf-8"
)
git.auto_commit("commit2")
ages = git.line_ages("MEMORY.md")
lines = (tmp_path / "MEMORY.md").read_text(encoding="utf-8").splitlines()
# All lines are from today, but verify line-level tracking works
assert len(ages) == len(lines)
# "- new" line and "- keep" line both age=0 (same day), but
# the key point is we get per-line results
assert len(ages) == 7
class TestNestedRepoProtection:
"""Regression tests for GitHub issue #2980: nested repo protection."""
def test_init_refuses_inside_git_repo(self, tmp_path):
"""init() should detect it's inside an existing git repo and refuse."""
project = tmp_path / "project"
project.mkdir()
(project / ".git").mkdir()
workspace = project / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is False
assert not (workspace / ".git").is_dir()
def test_init_preserves_existing_gitignore(self, tmp_path):
"""init() should preserve existing .gitignore entries and append new ones."""
workspace = tmp_path / "workspace"
workspace.mkdir()
existing = "*.pyc\n__pycache__/\n"
(workspace / ".gitignore").write_text(existing, encoding="utf-8")
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
gitignore = (workspace / ".gitignore").read_text(encoding="utf-8")
assert "*.pyc" in gitignore
assert "__pycache__/" in gitignore
assert "!MEMORY.md" in gitignore
assert "!.gitignore" in gitignore
def test_init_no_gitignore_creates_new(self, tmp_path):
"""init() should create .gitignore with Dream content when none exists."""
workspace = tmp_path / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
gitignore = (workspace / ".gitignore").read_text(encoding="utf-8")
expected = g._build_gitignore()
assert gitignore == expected
def test_init_gitignore_merge_idempotent(self, tmp_path):
"""init() should not duplicate Dream entries already in .gitignore."""
workspace = tmp_path / "workspace"
workspace.mkdir()
# Pre-existing .gitignore that already has some Dream entries
existing = "*.pyc\n/*\n!MEMORY.md\n"
(workspace / ".gitignore").write_text(existing, encoding="utf-8")
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
gitignore = (workspace / ".gitignore").read_text(encoding="utf-8")
# No duplicate lines
lines = gitignore.splitlines()
assert lines.count("/*") == 1
assert lines.count("!MEMORY.md") == 1
# Existing entry preserved, new Dream entries appended
assert "*.pyc" in gitignore
assert "!.gitignore" in gitignore
def test_init_outside_git_repo_works_normally(self, tmp_path):
"""init() should succeed and create .git when not inside a git repo."""
workspace = tmp_path / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
assert (workspace / ".git").is_dir()
def test_init_refuses_inside_git_worktree(self, tmp_path):
"""init() should refuse when the parent checkout is a git worktree."""
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
(repo / "README.md").write_text("x\n", encoding="utf-8")
subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True)
subprocess.run(
[
"git",
"-C",
str(repo),
"-c",
"user.name=test",
"-c",
"user.email=test@example.com",
"commit",
"-q",
"-m",
"init",
],
check=True,
)
subprocess.run(["git", "-C", str(repo), "branch", "wt-branch"], check=True)
worktree = tmp_path / "worktree"
subprocess.run(
["git", "-C", str(repo), "worktree", "add", "-q", str(worktree), "wt-branch"],
check=True,
)
assert (worktree / ".git").is_file()
workspace = worktree / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is False
assert not (workspace / ".git").exists()
-407
View File
@@ -1,407 +0,0 @@
from __future__ import annotations
import asyncio
import pytest
from nanobot.config.loader import load_config
from nanobot.webui.mcp_presets_api import (
McpPresetError,
custom_mcp_action,
mcp_presets_action,
mcp_presets_payload,
mcp_presets_test_action,
normalize_mcp_preset_mentions,
)
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
payload = mcp_presets_payload()
names = {preset["name"] for preset in payload["presets"]}
assert {
"browserbase",
"playwright",
"github",
"figma",
"context7",
"firecrawl",
"exa",
"microsoft-learn",
"aws-docs",
"brave-search",
"postman",
}.issubset(names)
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
assert browserbase["installed"] is False
assert browserbase["install_supported"] is True
assert browserbase["required_fields"][0]["configured"] is False
assert "browserbaseApiKey" not in browserbase["connection_summary"]
manifest = browserbase["manifest"]
assert manifest["schema"] == "agent-app.v1"
assert manifest["id"] == "browserbase"
assert manifest["source"] == "mcp-preset"
assert manifest["capabilities"][0]["type"] == "mcp"
assert manifest["capabilities"][0]["transport"] == "streamableHttp"
assert manifest["install"]["strategy"] == "config"
assert manifest["remove"]["verification"] == ["config_absent"]
assert manifest["trust"]["review_status"] == "builtin_preset"
def test_enable_browserbase_writes_scrubbed_config_payload(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
payload = mcp_presets_action(
"enable",
{
"name": ["browserbase"],
"browserbase_api_key": ["bb_live_secret"],
},
)
assert payload["requires_restart"] is True
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["installed"] is True
assert payload["last_action"]["verification"] == ["config_present"]
preset = next(row for row in payload["presets"] if row["name"] == "browserbase")
assert preset["installed"] is True
assert preset["configured"] is True
assert "bb_live_secret" not in str(payload)
config = load_config()
assert "browserbaseApiKey=bb_live_secret" in config.tools.mcp_servers["browserbase"].url
def test_enable_requires_missing_secret(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
with pytest.raises(McpPresetError) as exc:
mcp_presets_action("enable", {"name": ["browserbase"]})
assert exc.value.status == 400
assert "Browserbase API key" in exc.value.message
def test_enable_context7_optional_api_key_appends_arg(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
payload = mcp_presets_action(
"enable",
{
"name": ["context7"],
"context7_api_key": ["ctx7_secret"],
},
)
assert "ctx7_secret" not in str(payload)
row = next(item for item in payload["presets"] if item["name"] == "context7")
assert row["configured"] is True
config = load_config()
assert config.tools.mcp_servers["context7"].args == [
"-y",
"@upstash/context7-mcp@latest",
"--api-key",
"ctx7_secret",
]
def test_enable_stdio_preset_uses_config_scoped_cwd(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
mcp_presets_action("enable", {"name": ["playwright"]})
config = load_config()
cwd = config.tools.mcp_servers["playwright"].cwd
assert cwd == str(tmp_path / "mcp" / "playwright")
assert (tmp_path / "mcp" / "playwright").is_dir()
def test_enable_no_auth_remote_presets_write_url(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
mcp_presets_action("enable", {"name": ["microsoft-learn"]})
mcp_presets_action("enable", {"name": ["exa"]})
config = load_config()
assert config.tools.mcp_servers["microsoft-learn"].url == "https://learn.microsoft.com/api/mcp"
assert config.tools.mcp_servers["exa"].url == "https://mcp.exa.ai/mcp"
def test_enable_firecrawl_writes_scrubbed_env(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
payload = mcp_presets_action(
"enable",
{
"name": ["firecrawl"],
"firecrawl_api_key": ["fc-secret"],
},
)
assert "fc-secret" not in str(payload)
config = load_config()
assert config.tools.mcp_servers["firecrawl"].env["FIRECRAWL_API_KEY"] == "fc-secret"
def test_remove_mcp_preset_updates_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
mcp_presets_action("enable", {"name": ["playwright"]})
managed_cwd = tmp_path / "mcp" / "playwright"
(managed_cwd / "cache.txt").write_text("managed runtime data", encoding="utf-8")
payload = mcp_presets_action("remove", {"name": ["playwright"]})
assert payload["requires_restart"] is True
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["removed"] is True
assert payload["last_action"]["managed_paths_removed"] == ["runtime:mcp/playwright"]
assert not managed_cwd.exists()
config = load_config()
assert "playwright" not in config.tools.mcp_servers
def test_remove_custom_mcp_server_preserves_user_cwd(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
user_cwd = tmp_path / "user-cwd"
user_cwd.mkdir()
custom_mcp_action(
"custom",
{
"name": ["internal-docs"],
"transport": ["stdio"],
"command": ["node"],
"args": ['["server.js"]'],
"cwd": [str(user_cwd)],
},
)
payload = mcp_presets_action("remove", {"name": ["internal-docs"]})
assert payload["last_action"]["ok"] is True
assert user_cwd.exists()
config = load_config()
assert "internal-docs" not in config.tools.mcp_servers
def test_test_mcp_preset_reports_missing_dependency(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
mcp_presets_action("enable", {"name": ["playwright"]})
monkeypatch.setattr("nanobot.webui.mcp_presets_api.shutil.which", lambda _command: None)
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
assert payload["last_action"]["ok"] is False
assert "npx" in payload["last_action"]["message"]
def test_test_mcp_preset_connects_and_reports_tools(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
mcp_presets_action("enable", {"name": ["playwright"]})
class FakeStack:
async def aclose(self) -> None:
return None
async def fake_connect(servers, registry):
assert list(servers) == ["playwright"]
class FakeTool:
name = "mcp_playwright_browser_navigate"
def to_schema(self):
return {"name": self.name, "description": "", "parameters": {}}
registry.register(FakeTool())
return {"playwright": FakeStack()}
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["tool_count"] == 1
assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"]
def test_test_mcp_preset_scrubs_connection_errors(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
mcp_presets_action(
"enable",
{
"name": ["browserbase"],
"browserbase_api_key": ["bb_live_secret"],
},
)
async def fake_connect(_servers, _registry):
raise RuntimeError("failed https://mcp.browserbase.com/mcp?browserbaseApiKey=bb_live_secret")
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
payload = asyncio.run(mcp_presets_test_action({"name": ["browserbase"]}))
assert payload["last_action"]["ok"] is False
assert "bb_live_secret" not in str(payload)
assert "<redacted>" in payload["last_action"]["error"]
def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
with pytest.raises(McpPresetError) as exc:
mcp_presets_action("enable", {"name": ["linear"]})
assert exc.value.status == 404
def test_normalize_mcp_preset_mentions_keeps_known_presets_only() -> None:
payload = normalize_mcp_preset_mentions([
{
"name": "browserbase",
"display_name": "Browserbase",
"transport": "streamableHttp",
"configured": True,
"logo_url": "https://example.invalid/logo.svg",
},
{"name": "totally-unknown"},
"bad",
])
assert payload == [{
"name": "browserbase",
"display_name": "Browserbase",
"transport": "streamableHttp",
"configured": True,
"logo_url": "https://example.invalid/logo.svg",
}]
def test_custom_mcp_server_writes_config_and_catalog_row(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
payload = custom_mcp_action(
"custom",
{
"name": ["internal-docs"],
"transport": ["stdio"],
"command": ["node"],
"args": ['["server.js"]'],
"env": ['{"DOCS_TOKEN":"docs-secret-value"}'],
"tool_timeout": ["45"],
},
)
assert payload["requires_restart"] is True
row = next(item for item in payload["presets"] if item["name"] == "internal-docs")
assert row["source"] == "custom"
assert row["transport"] == "stdio"
assert row["connection_summary"] == "node server.js"
assert row["manifest"]["schema"] == "agent-app.v1"
assert row["manifest"]["source"] == "mcp-custom"
assert row["manifest"]["capabilities"][0]["command"] == "node"
assert "server.js" not in str(row["manifest"])
assert "docs-secret-value" not in str(payload)
config = load_config()
assert config.tools.mcp_servers["internal-docs"].args == ["server.js"]
assert config.tools.mcp_servers["internal-docs"].env["DOCS_TOKEN"] == "docs-secret-value"
def test_import_mcp_config_and_tool_allowlist(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
payload = custom_mcp_action(
"import",
{
"config": [
(
'{"mcpServers":{'
'"docs":{"command":"npx","args":["-y","docs-mcp"],"env":{"API_KEY":"config-secret-value"}},'
'"remote-docs":{"transport":"sse","url":"https://example.com/sse"}'
'}}'
)
],
},
)
assert payload["last_action"]["message"] == "Imported 2 MCP server(s)."
config = load_config()
assert config.tools.mcp_servers["docs"].command == "npx"
assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"]
assert config.tools.mcp_servers["remote-docs"].type == "sse"
assert config.tools.mcp_servers["remote-docs"].url == "https://example.com/sse"
assert config.tools.mcp_servers["docs"].env["API_KEY"] == "config-secret-value"
assert "config-secret-value" not in str(payload)
payload = custom_mcp_action(
"tools",
{
"name": ["docs"],
"enabled_tools": ['["mcp_docs_search"]'],
},
)
row = next(item for item in payload["presets"] if item["name"] == "docs")
assert row["enabled_tools"] == ["mcp_docs_search"]
assert load_config().tools.mcp_servers["docs"].enabled_tools == ["mcp_docs_search"]
payload = custom_mcp_action(
"tools",
{
"name": ["docs"],
"enabled_tools": ["[]"],
},
)
row = next(item for item in payload["presets"] if item["name"] == "docs")
assert row["enabled_tools"] == []
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
custom_mcp_action(
"custom",
{
"name": ["docs"],
"transport": ["streamableHttp"],
"url": ["https://example.com/mcp"],
},
)
payload = normalize_mcp_preset_mentions([
{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"},
])
assert payload == [{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"}]
-80
View File
@@ -1,80 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from nanobot.webui import mcp_presets_runtime
def test_mcp_preset_runtime_lines_describe_tool_prefix() -> None:
msg = SimpleNamespace(
content="use @browserbase",
metadata={
"mcp_presets": [{
"name": "browserbase",
"display_name": "Browserbase",
"transport": "streamableHttp",
}],
},
)
lines = mcp_presets_runtime.runtime_lines(
msg,
configured_server_names={"browserbase"},
connected_server_names={"browserbase"},
)
assert lines
assert "@browserbase" in lines[0]
assert "mcp_browserbase_" in lines[0]
assert "shell commands" in lines[0]
def test_mcp_preset_runtime_lines_warn_when_restart_needed() -> None:
msg = SimpleNamespace(
content="use @browserbase",
metadata={
"mcp_presets": [{
"name": "browserbase",
"display_name": "Browserbase",
"transport": "streamableHttp",
}],
},
)
lines = mcp_presets_runtime.runtime_lines(
msg,
configured_server_names=set(),
connected_server_names=set(),
)
assert lines
assert "has not loaded the latest MCP settings" in lines[0]
def test_mcp_preset_runtime_lines_warn_when_connection_not_live() -> None:
msg = SimpleNamespace(
content="use @browserbase",
metadata={
"mcp_presets": [{
"name": "browserbase",
"display_name": "Browserbase",
"transport": "streamableHttp",
}],
},
)
lines = mcp_presets_runtime.runtime_lines(
msg,
configured_server_names={"browserbase"},
connected_server_names=set(),
)
assert lines
assert "connection is not currently live" in lines[0]
def test_mcp_preset_session_extra_only_persists_structured_mentions() -> None:
assert mcp_presets_runtime.session_extra({}) == {}
assert mcp_presets_runtime.session_extra({
"mcp_presets": [{"name": "browserbase"}],
}) == {"mcp_presets": [{"name": "browserbase"}]}
-67
View File
@@ -1,67 +0,0 @@
from __future__ import annotations
import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
from nanobot.webui.settings_api import WebUISettingsError, create_model_configuration
def test_create_model_configuration_writes_label_and_selects(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.agents.defaults.model = "openai/gpt-4o"
config.agents.defaults.provider = "openai"
config.providers.openai.api_key = "sk-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = create_model_configuration(
{
"label": ["Fast writing"],
"provider": ["openai"],
"model": ["openai/gpt-4.1-mini"],
}
)
assert payload["agent"]["model_preset"] == "fast-writing"
assert payload["agent"]["model"] == "openai/gpt-4.1-mini"
rows = {row["name"]: row for row in payload["model_presets"]}
assert rows["fast-writing"]["label"] == "Fast writing"
saved = load_config(config_path)
assert saved.agents.defaults.model_preset == "fast-writing"
assert saved.model_presets["fast-writing"].label == "Fast writing"
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
assert saved.model_presets["fast-writing"].provider == "openai"
with pytest.raises(WebUISettingsError) as duplicate:
create_model_configuration(
{
"label": ["Fast writing"],
"provider": ["openai"],
"model": ["openai/gpt-4.1-mini"],
}
)
assert duplicate.value.status == 409
def test_create_model_configuration_rejects_unconfigured_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)
with pytest.raises(WebUISettingsError, match="provider is not configured"):
create_model_configuration(
{
"label": ["Deep"],
"provider": ["openai"],
"model": ["openai/gpt-4.1"],
}
)
+14
View File
@@ -6,8 +6,10 @@
"name": "nanobot-webui",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
@@ -163,12 +165,16 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@@ -201,6 +207,8 @@
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
@@ -215,6 +223,8 @@
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
@@ -881,6 +891,10 @@
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+2
View File
@@ -13,8 +13,10 @@
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
+5 -24
View File
@@ -4,7 +4,7 @@ import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
import { Sidebar } from "@/components/Sidebar";
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
import { SettingsView, type SettingsSectionKey } from "@/components/settings/SettingsView";
import { SettingsView } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
@@ -46,7 +46,7 @@ const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings" | "apps";
type ShellView = "chat" | "settings";
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
@@ -325,7 +325,6 @@ function Shell({
useSidebarState(sessions, !loading);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat");
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview");
const [desktopSidebarOpen, setDesktopSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@@ -589,20 +588,12 @@ function Shell({
[onSelectChat],
);
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
const onOpenSettings = useCallback(() => {
setSessionSearchOpen(false);
setSettingsInitialSection(section);
setView("settings");
setMobileSidebarOpen(false);
}, []);
const onOpenApps = useCallback(() => {
setSessionSearchOpen(false);
setSettingsInitialSection("apps");
setView("apps");
setMobileSidebarOpen(false);
}, []);
const onBackToChat = useCallback(() => {
setView("chat");
setMobileSidebarOpen(false);
@@ -720,12 +711,6 @@ function Shell({
});
return;
}
if (view === "apps") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.apps", { defaultValue: "Apps" }),
});
return;
}
document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base");
@@ -743,9 +728,7 @@ function Shell({
onRequestRename,
onToggleArchive,
onOpenSettings,
onOpenApps,
onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" ? "apps" as const : null,
onToggleArchived,
onUpdateView: onUpdateSidebarView,
pinnedKeys: sidebarState.pinned_keys,
@@ -822,7 +805,7 @@ function Shell({
<div
className={cn(
"absolute inset-0 flex flex-col",
view !== "chat" && "invisible pointer-events-none",
view === "settings" && "invisible pointer-events-none",
)}
>
<ThreadShell
@@ -837,12 +820,10 @@ function Shell({
hideSidebarToggleOnDesktop
/>
</div>
{view !== "chat" && (
{view === "settings" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
+9 -152
View File
@@ -1,17 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useState } from "react";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
import type { CliAppInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export type CliAppMentionSegment =
| { kind: "text"; text: string }
| { kind: "cli"; text: string; app: CliAppInfo };
export type CapabilityMentionSegment =
| CliAppMentionSegment
| { kind: "mcp"; text: string; preset: McpPresetInfo };
export function cliAppInitials(app: CliAppInfo): string {
const value = app.display_name || app.name;
return (
@@ -24,18 +19,6 @@ export function cliAppInitials(app: CliAppInfo): string {
);
}
export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_name">): string {
const value = preset.display_name || preset.name;
return (
value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || preset.name.slice(0, 2).toUpperCase()
);
}
export function splitCliAppMentionSegments(
value: string,
cliApps: CliAppInfo[],
@@ -72,76 +55,22 @@ export function splitCliAppMentionSegments(
return segments.length ? segments : [{ kind: "text", text: value }];
}
export function splitCapabilityMentionSegments(
value: string,
cliApps: CliAppInfo[],
mcpPresets: McpPresetInfo[] = [],
): CapabilityMentionSegment[] {
if (!value || (cliApps.length === 0 && mcpPresets.length === 0)) {
return value ? [{ kind: "text", text: value }] : [];
}
const cliAppsByName = new Map(
cliApps
.filter((app) => app.installed)
.map((app) => [app.name.toLowerCase(), app]),
);
const mcpPresetsByName = new Map(
mcpPresets
.filter((preset) => preset.installed && preset.configured)
.map((preset) => [preset.name.toLowerCase(), preset]),
);
if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0) {
return [{ kind: "text", text: value }];
}
const segments: CapabilityMentionSegment[] = [];
const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(value)) !== null) {
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const key = name.toLowerCase();
const app = cliAppsByName.get(key);
const preset = app ? null : mcpPresetsByName.get(key);
if (!app && !preset) continue;
const mentionStart = match.index + prefix.length;
const mentionEnd = mentionStart + name.length + 1;
if (mentionStart > cursor) {
segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
}
if (app) {
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
} else if (preset) {
segments.push({ kind: "mcp", text: value.slice(mentionStart, mentionEnd), preset });
}
cursor = mentionEnd;
}
if (cursor < value.length) {
segments.push({ kind: "text", text: value.slice(cursor) });
}
return segments.length ? segments : [{ kind: "text", text: value }];
}
export function CliAppMentionText({
text,
cliApps,
mcpPresets = [],
}: {
text: string;
cliApps: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
}) {
const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets);
if (!segments.some((segment) => segment.kind === "cli" || segment.kind === "mcp")) return <>{text}</>;
const segments = splitCliAppMentionSegments(text, cliApps);
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
return (
<>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
if (segment.kind === "cli") return (
return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
@@ -149,14 +78,6 @@ export function CliAppMentionText({
variant="message"
/>
);
return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="message"
/>
);
})}
</>
);
@@ -173,20 +94,15 @@ export function CliAppMentionToken({
variant: "composer" | "message";
isHero?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
const logoUrl = logoUrls[logoIndex];
const showLogo = Boolean(logoUrl);
const showLogo = Boolean(app.logo_url) && !failed;
const testIdPrefix = variant === "composer" ? "composer" : "message";
useEffect(() => setLogoIndex(0), [app.logo_url]);
return (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
title={`CLI app: ${app.display_name || app.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
@@ -208,69 +124,10 @@ export function CliAppMentionToken({
)}
>
<img
src={logoUrl ?? ""}
src={app.logo_url ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
) : null}
</span>
{mentionName}
</span>
);
}
export function McpPresetMentionToken({
preset,
label,
variant,
isHero = false,
}: {
preset: McpPresetInfo;
label: string;
variant: "composer" | "message";
isHero?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = preset.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
const logoUrl = logoUrls[logoIndex];
const showLogo = Boolean(logoUrl);
const testIdPrefix = variant === "composer" ? "composer" : "message";
useEffect(() => setLogoIndex(0), [preset.logo_url]);
return (
<span
data-testid={`${testIdPrefix}-mcp-mention-${preset.name}`}
title={`MCP server: ${preset.display_name || preset.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
style={{ lineHeight: "inherit" }}
>
@
{showLogo ? (
<span
data-testid={`${testIdPrefix}-mcp-mention-logo-${preset.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
>
<img
src={logoUrl ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoIndex((index) => index + 1)}
onError={() => setFailed(true)}
/>
</span>
) : null}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ComposerProps {
onSend: (content: string) => void;
disabled?: boolean;
placeholder?: string;
/** Visually collapse the outer padding when embedded inside a welcome screen. */
compact?: boolean;
}
/**
* Rounded, shadowed composer with an embedded send button modeled after the
* agent-chat-ui input: a single surface that looks like one interactive unit
* rather than a textarea + button pair.
*/
export function Composer({
onSend,
disabled,
placeholder = "Type your message…",
compact = false,
}: ComposerProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Autofocus on mount — coming back to a chat, switching sessions, or
// opening the welcome screen should always land the caret in the box.
useEffect(() => {
if (disabled) return;
const el = textareaRef.current;
if (!el) return;
// Defer so layout settles first (important during enter animations).
const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id);
}, [disabled]);
const submit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.focus();
}
});
}, [disabled, onSend, value]);
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
};
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
className={cn(
"w-full",
compact ? "px-0" : "bg-background/95 px-4 pb-4 pt-2 backdrop-blur",
)}
>
<div
className={cn(
"relative mx-auto flex w-full max-w-[64rem] flex-col overflow-hidden rounded-3xl",
"border bg-muted/60 shadow-sm transition-all duration-200",
"focus-within:bg-muted focus-within:shadow-md focus-within:ring-1 focus-within:ring-foreground/10",
disabled && "opacity-60",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onInput={onInput}
onKeyDown={onKeyDown}
rows={1}
placeholder={placeholder}
disabled={disabled}
aria-label="Message input"
className={cn(
"min-h-[56px] w-full resize-none bg-transparent px-5 pt-4 pb-2 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
/>
<div className="flex items-center justify-between gap-2 px-3 pb-2">
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline">
Enter to send · Shift+Enter for newline
</span>
<span className="sm:hidden" aria-hidden />
<Button
type="submit"
size="icon"
disabled={disabled || !value.trim()}
aria-label="Send message"
className={cn(
"h-9 w-9 rounded-full shadow-sm transition-transform",
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
</div>
</form>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { MessageSquarePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
export function EmptyState({
onNewChat,
}: {
onNewChat: () => void;
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
<MessageSquarePlus
className="h-10 w-10 text-muted-foreground"
aria-hidden
/>
<div className="space-y-1">
<p className="text-lg font-medium">No chats yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Start a conversation your sessions are stored locally on the nanobot
workspace and stay available across reloads.
</p>
</div>
<Button onClick={onNewChat}>New chat</Button>
</div>
);
}
+11 -31
View File
@@ -51,7 +51,7 @@ export function FileReferenceChip({
data-testid={testId}
aria-label={fullPath}
className={cn(
"inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]",
"inline-flex max-w-full items-center gap-1 font-medium leading-[inherit]",
"text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200",
)}
@@ -161,7 +161,7 @@ function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
return (
<svg
aria-hidden
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] text-sky-500 dark:text-sky-300"
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -180,7 +180,7 @@ function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
return (
<svg
aria-hidden
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] text-sky-500 dark:text-sky-300"
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -195,36 +195,16 @@ function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
}
const label = fileKindLabel(kind);
return (
<svg
<span
aria-hidden
className="h-[0.96em] w-[0.96em] shrink-0 translate-y-[0.12em] text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
className={cn(
"inline-flex h-[1.05em] min-w-[1.05em] shrink-0 items-center justify-center",
"rounded-[4px] bg-sky-500/12 px-[0.22em] text-[0.58em] font-bold uppercase leading-none",
"text-sky-600 dark:bg-sky-400/15 dark:text-sky-300",
)}
>
<path
d="M7 3.5h6.6L18 7.9V19a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 19V5a1.5 1.5 0 0 1 1.5-1.5Z"
fill="currentColor"
opacity="0.12"
/>
<path
d="M13.5 3.75V8h4.25M7 3.5h6.6L18 7.9V19a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 19V5a1.5 1.5 0 0 1 1.5-1.5Z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<text
x="12"
y="15.7"
textAnchor="middle"
fill="currentColor"
fontSize={label.length > 1 ? "5.8" : "7.2"}
fontWeight="800"
letterSpacing="-0.2"
>
{label}
</text>
</svg>
{label}
</span>
);
}
+2 -53
View File
@@ -14,22 +14,13 @@ import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { formatTurnLatency } from "@/lib/format";
import type {
CliAppInfo,
McpPresetInfo,
UICliAppAttachment,
UIMcpPresetAttachment,
UIImage,
UIMediaAttachment,
UIMessage,
} from "@/lib/types";
import type { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
}
/**
@@ -45,7 +36,6 @@ export function MessageBubble({
message,
showAssistantCopyAction = true,
cliApps = [],
mcpPresets = [],
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
@@ -55,10 +45,6 @@ export function MessageBubble({
() => mergeCliMentionApps(cliApps, message.cliApps),
[cliApps, message.cliApps],
);
const mentionMcpPresets = useMemo(
() => mergeMcpMentionPresets(mcpPresets, message.mcpPresets),
[mcpPresets, message.mcpPresets],
);
useEffect(() => {
return () => {
@@ -110,11 +96,7 @@ export function MessageBubble({
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
)}
>
<CliAppMentionText
text={message.content}
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
/>
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
</p>
) : null}
</div>
@@ -184,39 +166,6 @@ export function MessageBubble({
);
}
function mergeMcpMentionPresets(
presets: McpPresetInfo[],
attachments: UIMcpPresetAttachment[] | undefined,
): McpPresetInfo[] {
if (!attachments?.length) return presets;
const byName = new Map(presets.map((preset) => [preset.name.toLowerCase(), preset]));
for (const attachment of attachments) {
const name = attachment.name?.trim();
if (!name) continue;
const existing = byName.get(name.toLowerCase());
byName.set(name.toLowerCase(), {
name,
display_name: attachment.display_name || existing?.display_name || name,
category: attachment.category || existing?.category || "mcp",
description: existing?.description || "",
docs_url: existing?.docs_url || "",
transport: attachment.transport || existing?.transport || "mcp",
requires: existing?.requires || "",
note: existing?.note || "",
install_supported: existing?.install_supported ?? true,
installed: true,
configured: attachment.configured ?? existing?.configured ?? true,
available: existing?.available ?? true,
status: attachment.status || existing?.status || "configured",
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
required_fields: existing?.required_fields || [],
connection_summary: existing?.connection_summary || "",
});
}
return Array.from(byName.values());
}
function mergeCliMentionApps(
cliApps: CliAppInfo[],
attachments: UICliAppAttachment[] | undefined,
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import { MessageBubble } from "@/components/MessageBubble";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
interface MessageListProps {
messages: UIMessage[];
isStreaming: boolean;
}
const NEAR_BOTTOM_PX = 48;
/**
* Scrollable message log. Auto-sticks to the bottom as new content arrives,
* but only when the user was already at the bottom preserving scroll
* position when they've scrolled up to read earlier turns. A floating
* "scroll to bottom" button appears whenever we're detached from the bottom.
*/
export function MessageList({ messages, isStreaming }: MessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [atBottom, setAtBottom] = useState(true);
const scrollToBottom = useCallback((smooth = false) => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
}, []);
// Keep the viewport pinned to the bottom as long as the user hasn't
// scrolled up. During streaming we do instant jumps (smooth scrolling each
// token fights the incoming animations); on settled updates we animate.
useEffect(() => {
if (!atBottom) return;
scrollToBottom(!isStreaming);
}, [messages, isStreaming, atBottom, scrollToBottom]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setAtBottom(distance < NEAR_BOTTOM_PX);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Say hi to get started.
</div>
);
}
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
className={cn(
"h-full overflow-y-auto scroll-smooth",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
"[&::-webkit-scrollbar-track]:bg-transparent",
)}
>
<div className="mx-auto flex w-full max-w-[64rem] flex-col gap-6 px-4 pt-4 pb-8">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
))}
</div>
</div>
{/* Top fade so messages slide under the header gracefully. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
{/* Bottom fade so messages fade out behind the composer. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent"
/>
{!atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true)}
className={cn(
"absolute bottom-2 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
)}
</div>
);
}
+32 -37
View File
@@ -33,7 +33,6 @@ export function SessionSearchDialog({
}: SessionSearchDialogProps) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
@@ -47,6 +46,7 @@ export function SessionSearchDialog({
);
}, [normalizedQuery, open, sessions, titleOverrides]);
const itemCount = sessionResults.length;
const shortcutLabel = useMemo(getSearchShortcutLabel, []);
useEffect(() => {
if (!open) return;
@@ -65,18 +65,6 @@ export function SessionSearchDialog({
);
}, [itemCount]);
useEffect(() => {
itemRefs.current = itemRefs.current.slice(0, itemCount);
}, [itemCount]);
useEffect(() => {
if (!open) return;
itemRefs.current[highlightedIndex]?.scrollIntoView({
block: "nearest",
inline: "nearest",
});
}, [highlightedIndex, open]);
const handleSelect = (key: string) => {
onOpenChange(false);
onSelect(key);
@@ -119,18 +107,18 @@ export function SessionSearchDialog({
<DialogContent
showCloseButton={false}
className={cn(
"flex max-h-[min(40rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
"rounded-[22px] border border-border bg-background text-foreground shadow-[0_22px_70px_rgba(0,0,0,0.22)]",
"dark:border-white/14 dark:bg-[#2b2b2b] dark:shadow-[0_26px_90px_rgba(0,0,0,0.44)] sm:rounded-[22px]",
"max-h-[min(34rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] gap-0 overflow-hidden p-0",
"rounded-2xl border border-border/70 bg-popover/95 text-popover-foreground shadow-2xl backdrop-blur-xl",
"sm:rounded-2xl",
)}
>
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
<DialogDescription className="sr-only">
{t("sidebar.searchPlaceholder")}
</DialogDescription>
<div className="flex h-[62px] shrink-0 items-center gap-3 border-b border-border px-[18px]">
<div className="flex h-14 items-center gap-3 border-b border-border/60 px-5">
<Search
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<input
@@ -140,16 +128,16 @@ export function SessionSearchDialog({
onKeyDown={handleKeyDown}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className="h-full min-w-0 flex-1 bg-transparent text-[19px] font-normal leading-none text-foreground outline-none placeholder:text-muted-foreground"
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
/>
<kbd className="hidden h-6 shrink-0 items-center rounded-md border border-border/70 bg-muted/60 px-2 text-[11px] font-medium text-muted-foreground sm:inline-flex">
{shortcutLabel}
</kbd>
</div>
<div
data-testid="session-search-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2.5 scrollbar-thin scrollbar-track-transparent"
>
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
<section>
<div className="px-2.5 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground">
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
{sectionLabel}
</div>
@@ -162,7 +150,7 @@ export function SessionSearchDialog({
{emptyLabel}
</div>
) : (
<ul className="space-y-0.5">
<ul className="space-y-1">
{sessionResults.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
@@ -176,18 +164,15 @@ export function SessionSearchDialog({
return (
<li key={session.key}>
<button
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
onClick={() => handleSelect(session.key)}
onMouseEnter={() => setHighlightedIndex(index)}
aria-current={active ? "page" : undefined}
className={cn(
"grid min-h-[54px] w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[11px] px-3 py-2 text-left transition-colors",
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
highlighted
? "bg-muted text-foreground"
: "text-foreground hover:bg-muted",
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
)}
>
<span className="min-w-0 flex-1">
@@ -196,17 +181,17 @@ export function SessionSearchDialog({
</span>
{showPreview ? (
<span
className="block truncate text-[12px] leading-4 text-muted-foreground"
className={cn(
"block truncate text-[12px] leading-4",
highlighted
? "text-accent-foreground/70"
: "text-muted-foreground",
)}
>
{preview}
</span>
) : null}
</span>
{active ? (
<span className="shrink-0 rounded-full bg-muted-foreground/10 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{t("common.current", { defaultValue: "Current" })}
</span>
) : null}
</button>
</li>
);
@@ -236,3 +221,13 @@ function sessionMatchesTerms(
return terms.every((term) => haystack.includes(term));
}
function getSearchShortcutLabel() {
if (typeof navigator === "undefined") return "Ctrl K";
const platform = navigator.platform.toLowerCase();
const apple =
platform.includes("mac") ||
platform.includes("iphone") ||
platform.includes("ipad");
return apple ? "⌘K" : "Ctrl K";
}
+1 -15
View File
@@ -6,7 +6,6 @@ import {
Search,
Settings,
SquarePen,
Blocks,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -42,9 +41,7 @@ interface SidebarProps {
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | null;
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void;
@@ -132,13 +129,6 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenSearch}
icon={<Search className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.apps")}
onClick={props.onOpenApps}
active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarViewMenu
compact={collapsed}
view={props.viewState}
@@ -211,14 +201,12 @@ function SidebarActionButton({
label,
icon,
onClick,
active = false,
className,
}: {
collapsed: boolean;
label: string;
icon: ReactNode;
onClick: () => void;
active?: boolean;
className?: string;
}) {
return (
@@ -226,16 +214,14 @@ function SidebarActionButton({
type="button"
variant="ghost"
aria-label={label}
aria-current={active ? "page" : undefined}
title={collapsed ? label : undefined}
onClick={() => onClick()}
onClick={onClick}
className={cn(
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
className,
)}
>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+139 -402
View File
@@ -11,17 +11,15 @@ import {
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
CliAppMentionToken,
McpPresetMentionToken,
cliAppInitials,
mcpPresetInitials,
splitCapabilityMentionSegments,
type CapabilityMentionSegment,
splitCliAppMentionSegments,
type CliAppMentionSegment,
} from "@/components/CliAppMentionText";
import {
Activity,
ArrowUp,
AtSign,
BookOpen,
Brain,
Check,
ChevronDown,
ChevronUp,
@@ -31,7 +29,6 @@ import {
Loader2,
Plus,
RotateCw,
Shield,
Sparkles,
Square,
SquarePen,
@@ -51,19 +48,7 @@ import {
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import type {
CliAppInfo,
GoalStateWsPayload,
McpPresetInfo,
OutboundCliAppMention,
OutboundMcpPresetMention,
SlashCommand,
} from "@/lib/types";
import {
inferProviderFromModelName,
logoFallbackUrls,
providerBrand,
} from "@/lib/provider-brand";
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@@ -82,12 +67,9 @@ interface ThreadComposerProps {
placeholder?: string;
isStreaming?: boolean;
modelLabel?: string | null;
modelProvider?: string | null;
modelProviderLabel?: string | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
@@ -100,11 +82,9 @@ interface ThreadComposerProps {
const COMMAND_ICONS: Record<string, LucideIcon> = {
activity: Activity,
"book-open": BookOpen,
brain: Brain,
"circle-help": CircleHelp,
history: History,
"rotate-cw": RotateCw,
shield: Shield,
sparkles: Sparkles,
square: Square,
"square-pen": SquarePen,
@@ -117,9 +97,7 @@ const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "
const SLASH_PALETTE_GAP_PX = 8;
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
const SLASH_PALETTE_CHROME_PX = 12;
const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents";
const SLASH_RECENTS_LIMIT = 5;
const SLASH_PALETTE_CHROME_PX = 64;
type SlashPalettePlacement = "above" | "below";
@@ -134,45 +112,10 @@ interface CliAppMentionQuery {
end: number;
}
type MentionCandidate =
| { kind: "cli"; name: string; app: CliAppInfo }
| { kind: "mcp"; name: string; preset: McpPresetInfo };
interface SlashPaletteCommand extends SlashCommand {
detail: string;
badge?: string;
recent: boolean;
}
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
}
function readSlashRecents(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(SLASH_RECENTS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string").slice(0, SLASH_RECENTS_LIMIT)
: [];
} catch {
return [];
}
}
function storeSlashRecents(commands: string[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
SLASH_RECENTS_STORAGE_KEY,
JSON.stringify(commands.slice(0, SLASH_RECENTS_LIMIT)),
);
} catch {
// localStorage may be unavailable in private contexts; command insertion still works.
}
}
function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) {
if (!(target instanceof Element) || deltaY === 0) return;
let el: HTMLElement | null = target.parentElement;
@@ -249,19 +192,6 @@ function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
};
}
function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMention {
return {
name: preset.name,
display_name: preset.display_name,
category: preset.category,
transport: preset.transport,
status: preset.status,
configured: preset.configured,
logo_url: preset.logo_url ?? null,
brand_color: preset.brand_color ?? null,
};
}
function RunElapsedStrip({
startedAt,
goalState,
@@ -464,12 +394,9 @@ export function ThreadComposer({
placeholder,
isStreaming = false,
modelLabel = null,
modelProvider = null,
modelProviderLabel = null,
variant = "thread",
slashCommands = [],
cliApps = [],
mcpPresets = [],
imageMode: controlledImageMode,
onImageModeChange,
onStop,
@@ -487,7 +414,6 @@ export function ThreadComposer({
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents());
const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -572,86 +498,26 @@ export function ThreadComposer({
return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => {
if (!(isStreaming && onStop)) return slashCommands;
if (slashCommands.some((command) => command.command === "/stop")) return slashCommands;
return [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn for this chat.",
icon: "square",
},
...slashCommands,
];
}, [isStreaming, onStop, slashCommands]);
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
const filteredSlashCommands = useMemo(() => {
if (slashQuery === null) return [];
const withDetails = visibleSlashCommands
return slashCommands
.filter((command) => {
const commandKey = slashCommandI18nKey(command.command);
const title = t(`thread.composer.slash.commands.${commandKey}.title`, {
defaultValue: command.title,
});
const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
defaultValue: command.description,
});
const haystack = [
command.command,
command.title,
command.description,
command.argHint ?? "",
title,
description,
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
defaultValue: "",
}),
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, {
defaultValue: "",
}),
].join(" ").toLowerCase();
return haystack.includes(slashQuery);
})
.map((command) => {
const commandKey = slashCommandI18nKey(command.command);
const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
defaultValue: command.description,
});
let detail = description;
let badge: string | undefined;
if (command.command === "/model" && modelLabel) {
detail = modelLabel;
badge = t("thread.composer.slash.badges.current");
} else if (command.command === "/goal") {
detail = goalState?.active
? t("thread.composer.slash.details.goalActive")
: t("thread.composer.slash.details.goalReady");
} else if (command.command === "/stop" && isStreaming) {
detail = t("thread.composer.slash.details.stopRunning");
} else if (command.command === "/history") {
detail = t("thread.composer.slash.details.history");
}
return {
...command,
detail,
badge,
recent: recentSlashCommands.includes(command.command),
};
})
.sort((a, b) => {
if (isStreaming) {
if (a.command === "/stop") return -1;
if (b.command === "/stop") return 1;
}
if (slashQuery !== "") return 0;
const aRecent = recentSlashCommands.indexOf(a.command);
const bRecent = recentSlashCommands.indexOf(b.command);
if (aRecent !== -1 || bRecent !== -1) {
if (aRecent === -1) return 1;
if (bRecent === -1) return -1;
return aRecent - bRecent;
}
return 0;
});
return withDetails
.slice(0, 8);
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
}, [slashCommands, slashQuery, t]);
const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
@@ -668,9 +534,9 @@ export function ThreadComposer({
};
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
const filteredMentionCandidates = useMemo<MentionCandidate[]>(() => {
const filteredCliApps = useMemo(() => {
if (!cliAppMention) return [];
const cliCandidates: MentionCandidate[] = cliApps
return cliApps
.filter((app) => app.installed)
.filter((app) => {
const haystack = [
@@ -682,32 +548,16 @@ export function ThreadComposer({
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.map((app) => ({ kind: "cli", name: app.name, app }));
const mcpCandidates: MentionCandidate[] = mcpPresets
.filter((preset) => preset.installed && preset.configured)
.filter((preset) => {
const haystack = [
preset.name,
preset.display_name,
preset.category,
preset.description,
preset.transport,
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.map((preset) => ({ kind: "mcp", name: preset.name, preset }));
return [...cliCandidates, ...mcpCandidates].slice(0, 8);
}, [cliAppMention, cliApps, mcpPresets]);
.slice(0, 8);
}, [cliAppMention, cliApps]);
const showCliAppMenu = filteredMentionCandidates.length > 0;
const showCliAppMenu = filteredCliApps.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
const mentionSegments = useMemo(
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets),
[cliApps, mcpPresets, value],
);
const hasMentionDecorations = mentionSegments.some(
(segment) => segment.kind === "cli" || segment.kind === "mcp",
() => splitCliAppMentionSegments(value, cliApps),
[cliApps, value],
);
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
const activeCliMentionApps = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
@@ -716,14 +566,6 @@ export function ThreadComposer({
return [segment.app];
});
}, [mentionSegments]);
const activeMcpPresetMentions = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
if (segment.kind !== "mcp" || seen.has(segment.preset.name)) return [];
seen.add(segment.preset.name);
return [segment.preset];
});
}, [mentionSegments]);
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
placement: "above",
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
@@ -744,10 +586,10 @@ export function ThreadComposer({
}, [filteredSlashCommands.length, selectedCommandIndex]);
useEffect(() => {
if (selectedCliAppIndex >= filteredMentionCandidates.length) {
if (selectedCliAppIndex >= filteredCliApps.length) {
setSelectedCliAppIndex(0);
}
}, [filteredMentionCandidates.length, selectedCliAppIndex]);
}, [filteredCliApps.length, selectedCliAppIndex]);
useEffect(() => {
if (!showAnyPalette) return;
@@ -798,7 +640,7 @@ export function ThreadComposer({
window.removeEventListener("resize", updateLayout);
document.removeEventListener("scroll", updateLayout, true);
};
}, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]);
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
useEffect(() => {
if (!aspectMenuOpen) return;
@@ -844,37 +686,20 @@ export function ThreadComposer({
const chooseSlashCommand = useCallback(
(command: SlashCommand) => {
const nextRecents = [
command.command,
...recentSlashCommands.filter((item) => item !== command.command),
].slice(0, SLASH_RECENTS_LIMIT);
setRecentSlashCommands(nextRecents);
storeSlashRecents(nextRecents);
if (command.command === "/stop" && isStreaming && onStop) {
onStop();
setValue("");
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
return;
}
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
},
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
[resizeTextarea],
);
const chooseMentionCandidate = useCallback(
(candidate: MentionCandidate) => {
const chooseCliApp = useCallback(
(app: CliAppInfo) => {
if (!cliAppMention) return;
const suffix = value.slice(cliAppMention.end);
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
const nextCursor = cliAppMention.start + mention.length;
setValue(next);
@@ -911,9 +736,8 @@ export function ThreadComposer({
}))
: undefined;
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload);
const options: SendOptions | undefined =
imageMode || attachedCliApps.length > 0 || attachedMcpPresets.length > 0
imageMode || attachedCliApps.length > 0
? {
...(imageMode
? {
@@ -924,7 +748,6 @@ export function ThreadComposer({
}
: {}),
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
}
: undefined;
onSend(trimmed, payload, options);
@@ -937,36 +760,25 @@ export function ThreadComposer({
setCliAppMenuDismissed(false);
setCursorPosition(0);
resizeTextarea();
}, [
activeCliMentionApps,
activeMcpPresetMentions,
canSend,
clear,
imageAspectRatio,
imageMode,
onSend,
readyImages,
resizeTextarea,
value,
]);
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showCliAppMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCliAppIndex((idx) => (idx + 1) % filteredMentionCandidates.length);
setSelectedCliAppIndex((idx) => (idx + 1) % filteredCliApps.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCliAppIndex(
(idx) => (idx - 1 + filteredMentionCandidates.length) % filteredMentionCandidates.length,
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
chooseMentionCandidate(filteredMentionCandidates[selectedCliAppIndex]);
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
return;
}
if (e.key === "Escape") {
@@ -1082,12 +894,12 @@ export function ThreadComposer({
) : null}
{showCliAppMenu ? (
<CliAppMentionPalette
candidates={filteredMentionCandidates}
apps={filteredCliApps}
selectedIndex={selectedCliAppIndex}
layout={slashPaletteLayout}
isHero={isHero}
onHover={setSelectedCliAppIndex}
onChoose={chooseMentionCandidate}
onChoose={chooseCliApp}
/>
) : null}
<div
@@ -1135,7 +947,7 @@ export function ThreadComposer({
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
) : null}
<div className="relative">
{hasMentionDecorations ? (
{hasCliMentionDecorations ? (
<ComposerCliMentionOverlay
segments={mentionSegments}
isHero={isHero}
@@ -1166,7 +978,7 @@ export function ThreadComposer({
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
hasMentionDecorations && "text-transparent selection:bg-primary/20",
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
)}
/>
</div>
@@ -1207,7 +1019,7 @@ export function ThreadComposer({
"rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
: "h-7.5 w-7.5 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")} />
@@ -1226,7 +1038,7 @@ export function ThreadComposer({
}}
className={cn(
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
"h-9 text-[12px]",
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
@@ -1246,7 +1058,7 @@ export function ThreadComposer({
onClick={() => setAspectMenuOpen((open) => !open)}
className={cn(
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
"h-9 text-[12px]",
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
)}
>
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
@@ -1266,12 +1078,22 @@ export function ThreadComposer({
) : null}
</div>
{modelLabel ? (
<ComposerModelBadge
label={modelLabel}
provider={modelProvider}
providerLabel={modelProviderLabel}
isHero={isHero}
/>
<span
title={modelLabel}
className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
isHero
? "max-w-[13rem] text-[12px] shadow-[0_2px_8px_rgba(15,23,42,0.04)]"
: "max-w-[10rem] text-[10.5px] shadow-[0_2px_8px_rgba(15,23,42,0.035)]",
)}
>
<span
aria-hidden
className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500/80"
/>
<span className="truncate">{modelLabel}</span>
</span>
) : null}
{!isHero ? (
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
@@ -1293,7 +1115,7 @@ export function ThreadComposer({
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
"h-9 w-9",
isHero ? "" : "h-7.5 w-7.5",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
@@ -1311,79 +1133,12 @@ export function ThreadComposer({
);
}
function ComposerModelBadge({
label,
provider,
providerLabel,
isHero,
}: {
label: string;
provider?: string | null;
providerLabel?: string | null;
isHero: boolean;
}) {
const inferredProvider = provider || inferProviderFromModelName(label);
const brand = providerBrand(inferredProvider);
const [logoIndex, setLogoIndex] = useState(0);
const logoUrl = brand?.logoUrls[logoIndex];
const showLogo = !!logoUrl;
const title = providerLabel ? `${label} · ${providerLabel}` : label;
useEffect(() => setLogoIndex(0), [inferredProvider]);
return (
<span
title={title}
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
isHero ? "h-9 max-w-[13.5rem] gap-2 px-2.5 text-[12px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)}
>
<span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
"h-5 w-5",
)}
style={{
borderColor: brand ? `${brand.color}28` : undefined,
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
}}
aria-hidden
>
{showLogo ? (
<img
src={logoUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : brand ? (
<span
className={cn(
"grid h-full w-full place-items-center rounded-full text-white",
"text-[8px]",
)}
style={{ backgroundColor: brand.color }}
>
{brand.initials.slice(0, 2)}
</span>
) : (
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
)}
</span>
<span className="truncate">{label}</span>
</span>
);
}
function ComposerCliMentionOverlay({
segments,
isHero,
className,
}: {
segments: CapabilityMentionSegment[];
segments: CliAppMentionSegment[];
isHero: boolean;
className: string;
}) {
@@ -1399,7 +1154,7 @@ function ComposerCliMentionOverlay({
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
if (segment.kind === "cli") return (
return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
@@ -1408,35 +1163,26 @@ function ComposerCliMentionOverlay({
isHero={isHero}
/>
);
return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="composer"
isHero={isHero}
/>
);
})}
</div>
);
}
interface SlashCommandPaletteProps {
commands: SlashPaletteCommand[];
commands: SlashCommand[];
selectedIndex: number;
layout: SlashPaletteLayout;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (command: SlashPaletteCommand) => void;
onChoose: (command: SlashCommand) => void;
}
interface CliAppMentionPaletteProps {
candidates: MentionCandidate[];
apps: CliAppInfo[];
selectedIndex: number;
layout: SlashPaletteLayout;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (candidate: MentionCandidate) => void;
onChoose: (app: CliAppInfo) => void;
}
function ImageAspectMenu({
@@ -1493,7 +1239,7 @@ function ImageAspectMenu({
}
function CliAppMentionPalette({
candidates,
apps,
selectedIndex,
layout,
isHero,
@@ -1511,117 +1257,98 @@ function CliAppMentionPalette({
aria-label={t("thread.composer.mentions.ariaLabel")}
style={{ maxHeight: layout.maxHeight }}
className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[22px] border",
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/70 bg-popover p-2 text-popover-foreground shadow-[0_20px_60px_rgba(15,23,42,0.12)]",
"dark:border-white/10 dark:shadow-[0_24px_60px_rgba(0,0,0,0.42)]",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
{t("thread.composer.mentions.label")}
<div className="flex items-center gap-1.5 px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
<AtSign className="h-3 w-3" aria-hidden />
<span>{t("thread.composer.mentions.label")}</span>
</div>
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
{candidates.map((candidate, index) => {
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{apps.map((app, index) => {
const selected = index === selectedIndex;
const name = candidate.name;
const displayName = candidate.kind === "cli"
? candidate.app.display_name
: candidate.preset.display_name;
const typeLabel = candidate.kind === "cli"
? t("thread.composer.mentions.cliBadge")
: t("thread.composer.mentions.mcpBadge");
const ariaDescription = candidate.kind === "cli"
? t("thread.composer.mentions.cliDescription", { name })
: t("thread.composer.mentions.mcpDescription", { name });
return (
<button
key={`${candidate.kind}-${name}`}
key={app.name}
type="button"
role="option"
aria-selected={selected}
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(candidate);
onChoose(app);
}}
className={cn(
"flex h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 text-left transition-colors",
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
selected
? "bg-foreground/[0.055] text-foreground"
: "text-foreground/90 hover:bg-foreground/[0.04]",
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<MentionCandidateLogo candidate={candidate} selected={selected} />
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="shrink-0 text-[15px] font-medium tracking-normal text-foreground">
{displayName}
<CliAppMentionLogo app={app} selected={selected} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
@{app.name}
</span>
<span className="truncate text-[13px] font-medium">
{app.display_name}
</span>
</span>
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
@{name}
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{app.category}
{app.entry_point ? ` · ${app.entry_point}` : ""}
</span>
</span>
<span
className={cn(
"ml-2 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-normal",
candidate.kind === "cli"
? "bg-orange-500/10 text-orange-600 dark:text-orange-300"
: "bg-sky-500/10 text-sky-600 dark:text-sky-300",
)}
>
{typeLabel}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
function MentionCandidateLogo({
candidate,
function CliAppMentionLogo({
app,
selected,
}: {
candidate: MentionCandidate;
app: CliAppInfo;
selected: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = (candidate.kind === "cli"
? candidate.app.brand_color
: candidate.preset.brand_color) || "hsl(var(--primary))";
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
const logoUrl = logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [rawLogoUrl]);
if (logoUrl) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
if (app.logo_url && !failed) {
return (
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px]",
selected ? "bg-background/55" : "bg-transparent",
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
selected ? "border-primary/25" : "border-border/65",
)}
>
<img
src={logoUrl}
src={app.logo_url}
alt=""
className="h-5 w-5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
className="h-4.5 w-4.5 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
return (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{candidate.kind === "cli"
? cliAppInitials(candidate.app)
: mcpPresetInitials(candidate.preset)}
{cliAppInitials(app)}
</span>
);
}
@@ -1647,11 +1374,14 @@ function SlashCommandPalette({
className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
{t("thread.composer.slash.label")}
</div>
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{commands.map((command, index) => {
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
@@ -1675,42 +1405,49 @@ function SlashCommandPalette({
onChoose(command);
}}
className={cn(
"flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors",
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
selected
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<span
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors",
selected && "text-foreground",
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border",
selected
? "border-primary/25 bg-primary/12 text-primary"
: "border-border/65 bg-muted/45 text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate text-[13.5px] font-semibold tracking-normal text-foreground">
{title}
</span>
<span className="min-w-0 truncate text-[13px] text-muted-foreground">
{command.detail || description}
</span>
</span>
<span className="ml-2 flex shrink-0 items-center gap-1.5">
{command.badge || command.recent ? (
<span className="hidden rounded-full bg-foreground/[0.055] px-2 py-1 text-[11px] font-medium text-muted-foreground sm:inline-flex">
{command.badge ?? t("thread.composer.slash.badges.recent")}
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
{command.command}
</span>
) : null}
<span className="font-mono text-[12px] text-muted-foreground/60">
{command.argHint ? `${command.command} ${command.argHint}` : command.command}
{command.argHint ? (
<span className="font-mono text-[12px] text-muted-foreground">
{command.argHint}
</span>
) : null}
<span className="truncate text-[13px] font-medium">
{title}
</span>
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{description}
</span>
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
+1 -31
View File
@@ -6,7 +6,7 @@ import {
AgentActivityCluster,
isAgentActivityMember,
} from "@/components/thread/AgentActivityCluster";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@@ -15,7 +15,6 @@ interface ThreadMessagesProps {
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
}
export type DisplayUnit =
@@ -134,7 +133,6 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
reasoningStreaming: message.reasoningStreaming,
isStreaming: message.reasoningStreaming,
activitySegmentId: message.activitySegmentId,
latencyMs: message.latencyMs,
};
}
@@ -168,7 +166,6 @@ export function ThreadMessages({
hiddenMessageCount = 0,
onLoadEarlier,
cliApps = [],
mcpPresets = [],
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
@@ -205,8 +202,6 @@ export function ThreadMessages({
unit.type === "cluster"
&& next?.type === "single"
&& next.message.role === "assistant";
const turnLatencyMs =
unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined;
return (
<div key={unitKey(unit, index)} className={marginTop}>
@@ -215,9 +210,7 @@ export function ThreadMessages({
messages={unit.messages}
isTurnStreaming={index === liveActivityClusterIndex}
hasBodyBelow={hasBodyBelow}
turnLatencyMs={turnLatencyMs}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
) : (
<MessageBubble
@@ -228,7 +221,6 @@ export function ThreadMessages({
: true
}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
)}
</div>
@@ -238,28 +230,6 @@ export function ThreadMessages({
);
}
function activityClusterTurnLatencyMs(
messages: UIMessage[],
next: DisplayUnit | undefined,
): number | undefined {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const latency = messages[i].latencyMs;
if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) {
return latency;
}
}
if (
next?.type === "single"
&& next.message.role === "assistant"
&& typeof next.message.latencyMs === "number"
&& Number.isFinite(next.message.latencyMs)
&& next.message.latencyMs >= 0
) {
return next.message.latencyMs;
}
return undefined;
}
function currentActivityClusterIndex(units: DisplayUnit[]): number {
const last = units.length - 1;
return units[last]?.type === "cluster" ? last : -1;
+12 -151
View File
@@ -19,19 +19,13 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
import { fetchCliApps, listSlashCommands } from "@/lib/api";
import {
CLI_APPS_CHANGED_EVENT,
installedCliAppsFromPayload,
isCliAppsPayload,
} from "@/lib/cli-app-events";
import {
MCP_PRESETS_CHANGED_EVENT,
installedMcpPresetsFromPayload,
isMcpPresetsPayload,
} from "@/lib/mcp-preset-events";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type { ChatSummary, CliAppInfo, McpPresetInfo, SettingsPayload, SlashCommand, UIMessage } from "@/lib/types";
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
@@ -40,20 +34,6 @@ function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
}
function sameMessageShape(a: UIMessage, b: UIMessage): boolean {
return (
a.role === b.role
&& (a.kind ?? "") === (b.kind ?? "")
&& a.content === b.content
);
}
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
if (current.length === 0 || snapshot.length >= current.length) return false;
if (snapshot.length === 0) return true;
return snapshot.every((message, index) => sameMessageShape(current[index], message));
}
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
@@ -75,41 +55,6 @@ function toModelBadgeLabel(modelName: string | null): string | null {
return leaf || trimmed;
}
interface ModelBadgeInfo {
label: string | null;
provider: string | null;
providerLabel: string | null;
}
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
if (!settings) return null;
const configured = settings.agent.model_preset || "default";
return (
settings.model_presets.find((preset) => preset.name === configured)
?? settings.model_presets.find((preset) => preset.active)
?? null
);
}
function resolvedModelProvider(settings: SettingsPayload | null, modelName: string | null): string | null {
const preset = activeModelPreset(settings);
const rawProvider = preset?.provider || settings?.agent.provider || null;
if (rawProvider === "auto") {
return settings?.agent.resolved_provider || inferProviderFromModelName(modelName) || null;
}
return rawProvider || inferProviderFromModelName(modelName);
}
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
const label = toModelBadgeLabel(modelName || settings?.agent.model || null);
const provider = resolvedModelProvider(settings, modelName || settings?.agent.model || null);
return {
label,
provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
};
}
const QUICK_ACTION_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
@@ -158,8 +103,6 @@ export function ThreadShell({
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [heroImageMode, setHeroImageMode] = useState(false);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@@ -198,28 +141,6 @@ export function ThreadShell({
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
const modelBadge = useMemo(
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const refreshModelSettings = useCallback(async () => {
try {
setSettings(await fetchSettings(token));
} catch {
setSettings(null);
}
}, [token]);
useEffect(() => {
void refreshModelSettings();
}, [refreshModelSettings]);
useEffect(() => {
return client.onRuntimeModelUpdate(() => {
void refreshModelSettings();
});
}, [client, refreshModelSettings]);
useEffect(() => {
if (!chatId || loading) return;
@@ -233,28 +154,19 @@ export function ThreadShell({
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
// so rendering converges to the same shape as a manual refresh.
setMessages((prev) => {
const normalizedHistory = projectWebuiThreadMessages(historical);
const keepLiveMessages = (messagesToKeep: UIMessage[]) => {
const projected = projectWebuiThreadMessages(messagesToKeep);
messageCacheRef.current.set(chatId, projected);
return projected;
};
if (hasNewCanonicalHistory && historical.length > 0) {
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
pendingCanonicalHydrateRef.current.delete(chatId);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
messageCacheRef.current.set(chatId, normalizedHistory);
return normalizedHistory;
const normalized = projectWebuiThreadMessages(historical);
messageCacheRef.current.set(chatId, normalized);
return normalized;
}
if (cached && cached.length > 0) {
const normalizedCached = projectWebuiThreadMessages(cached);
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
return normalizedCached;
}
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
return normalizedHistory;
const next = projectWebuiThreadMessages(historical);
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical, historyVersion]);
@@ -350,15 +262,6 @@ export function ThreadShell({
}
}, [token]);
const refreshMcpPresets = useCallback(async () => {
try {
const payload = await fetchMcpPresets(token);
setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
setMcpPresets([]);
}
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
@@ -394,41 +297,6 @@ export function ThreadShell({
};
}, [refreshCliApps, token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchMcpPresets(token);
if (!cancelled) setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
if (!cancelled) setMcpPresets([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshMcpPresets();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnMcpPresetsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isMcpPresetsPayload(payload)) {
setMcpPresets(installedMcpPresetsFromPayload(payload));
return;
}
void refreshMcpPresets();
};
window.addEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
};
}, [refreshMcpPresets, token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
@@ -511,13 +379,10 @@ export function ThreadShell({
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={modelBadge.label}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
@@ -534,13 +399,10 @@ export function ThreadShell({
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={modelBadge.label}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
@@ -582,7 +444,6 @@ export function ThreadShell({
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</section>
);
@@ -14,7 +14,7 @@ import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
interface ThreadViewportProps {
messages: UIMessage[];
@@ -25,7 +25,6 @@ interface ThreadViewportProps {
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
}
const NEAR_BOTTOM_PX = 48;
@@ -56,7 +55,6 @@ export function ThreadViewport({
conversationKey = null,
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
@@ -254,7 +252,6 @@ export function ThreadViewport({
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</div>
</div>
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarFallback, AvatarImage };
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full min-w-0 rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
-3
View File
@@ -13,7 +13,6 @@ import type {
InboundEvent,
OutboundCliAppMention,
OutboundImageGeneration,
OutboundMcpPresetMention,
OutboundMedia,
GoalStateWsPayload,
UIImage,
@@ -314,7 +313,6 @@ export interface SendImage {
export interface SendOptions {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
}
export function useNanobotStream(
@@ -893,7 +891,6 @@ export function useNanobotStream(
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}),
},
];
});
+16 -133
View File
@@ -63,8 +63,7 @@
"language": {
"label": "Language",
"ariaLabel": "Change language"
},
"apps": "Apps"
}
},
"settings": {
"backToChat": "Back to chat",
@@ -82,10 +81,8 @@
"image": "Image",
"web": "Web",
"cliApps": "CLI Apps",
"mcp": "MCP",
"runtime": "Runtime",
"advanced": "Advanced",
"apps": "Apps"
"advanced": "Advanced"
},
"sections": {
"interface": "Interface",
@@ -98,21 +95,11 @@
"imageDefaults": "Defaults",
"webSearch": "Web search",
"webBehavior": "Behavior",
"cliApps": "CLI apps",
"mcp": "MCP services",
"cliApps": "CLI Apps",
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capabilities",
"integrations": "Integrations",
"apps": "Apps"
},
"models": {
"selectModel": "Select model",
"addConfiguration": "Add configuration",
"newConfiguration": "New model configuration",
"newConfigurationHelp": "Save a provider and model as a one-click option.",
"configurationName": "Name",
"configurationNamePlaceholder": "Fast writing"
"integrations": "Integrations"
},
"rows": {
"theme": "Theme",
@@ -125,7 +112,6 @@
"gateway": "Gateway",
"restartState": "Restart state",
"pendingChanges": "Pending changes",
"currentModel": "Current model",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
@@ -168,9 +154,6 @@
"provider": "Select the provider that should serve new model requests.",
"model": "Set the default model name used by nanobot.",
"configPath": "The gateway configuration file currently in use.",
"currentModel": "Choose the model nanobot uses for new replies.",
"selectedModelProvider": "Set by the selected model.",
"selectedModelValue": "Set by the selected model.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
@@ -195,11 +178,6 @@
"cliAppsFilter": "Search by app, category, or capability.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"timezone": {
"select": "Select timezone",
"search": "Search timezone",
"empty": "No matching timezones."
},
"cliApps": {
"allCategories": "All categories",
"availableCount": "{{count}} apps",
@@ -231,59 +209,6 @@
"unavailable": "Unavailable",
"noDescription": "No description available."
},
"mcp": {
"allCategories": "All categories",
"summary": "{{installed}} of {{total}} presets enabled",
"filterAll": "All",
"filterInstalled": "Enabled",
"filterNotInstalled": "Not enabled",
"searchPlaceholder": "Search MCP presets",
"moreOptions": "More MCP options",
"moreOptionsSubtitle": "Add a custom server or import mcp.json.",
"customTitle": "Custom MCP",
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
"customAction": "Custom",
"importAction": "Import",
"serverName": "Server name",
"serverUrl": "URL",
"transport": "Transport",
"command": "Command",
"args": "Args JSON",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "Tool timeout",
"advancedOptions": "Advanced options",
"hideAdvanced": "Hide advanced",
"saveCustom": "Save MCP",
"configImport": "Import mcp.json",
"importConfig": "Import",
"restartRequired": "Restart nanobot to connect updated MCP tools.",
"toolsFound": "{{count}} tools",
"loading": "Loading MCP presets...",
"empty": "No MCP presets match this filter.",
"openDocs": "Open docs",
"test": "Test",
"remove": "Remove",
"enable": "Enable",
"enabled": "Enabled",
"setup": "Connect",
"configure": "Connect",
"connectTitle": "Connect {{name}}",
"connectHint": "Add the key from your account settings.",
"saveAndEnable": "Save and enable",
"updateSetup": "Update setup",
"configured": "configured",
"keepExisting": "Leave blank to keep existing",
"statusConfigured": "Configured",
"statusMissingCredentials": "Needs key",
"statusMissingDependency": "Needs dependency",
"statusComingSoon": "Coming soon",
"statusNotInstalled": "Not enabled",
"toolScope": "Tools",
"allTools": "All",
"noTools": "None",
"testForTools": "Run Test to inspect and choose individual tools."
},
"values": {
"light": "Light",
"dark": "Dark",
@@ -371,8 +296,7 @@
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"saveProvider": "Save provider"
"noMatches": "No providers match this search."
},
"legal": {
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
@@ -383,20 +307,6 @@
"selectSize": "Select size",
"configureProvider": "Configure provider",
"missingCredential": "Configure this provider before enabling image generation."
},
"apps": {
"description": "Add app CLIs and MCP services nanobot can use from chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "All",
"filterCli": "CLI apps",
"filterMcp": "MCP services",
"enabledSummary": "{{count}} enabled",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Search Apps",
"featured": "Featured",
"loading": "Loading Apps...",
"empty": "No apps match this filter."
}
},
"chat": {
@@ -551,16 +461,6 @@
"navigateHint": "↑↓ Navigate",
"selectHint": "Enter/Tab Select",
"closeHint": "Esc Close",
"badges": {
"current": "Current",
"recent": "Recent"
},
"details": {
"goalActive": "Goal is running",
"goalReady": "Start a sustained objective",
"history": "Show recent messages",
"stopRunning": "Running now"
},
"commands": {
"new": {
"title": "New chat",
@@ -578,10 +478,6 @@
"title": "Show status",
"description": "Display runtime, provider, and channel status."
},
"model": {
"title": "Model",
"description": "Show or switch the active model preset."
},
"history": {
"title": "Show conversation history",
"description": "Print the last N persisted conversation messages."
@@ -605,22 +501,12 @@
"help": {
"title": "Show help",
"description": "List available slash commands."
},
"pairing": {
"title": "Pairing",
"description": "Manage pairing requests."
}
}
},
"mentions": {
"ariaLabel": "Apps",
"label": "Apps",
"cliGroup": "CLI apps",
"mcpGroup": "MCP services",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Use @{{name}} as a local CLI app",
"mcpDescription": "Use @{{name}} as an MCP server"
"ariaLabel": "CLI Apps",
"label": "CLI APPS"
},
"encoding": "Encoding…",
"remove": "Remove attachment",
@@ -653,18 +539,15 @@
"agentActivityToolsOnly": "{{tools}} tool calls",
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
"activityThinkingFor": "Thinking for {{duration}}",
"activityThought": "Thought",
"activityThoughtFor": "Thought for {{duration}}",
"cliActivityRunningOne": "Using @{{name}}",
"cliActivityRanOne": "Used @{{name}}",
"cliActivityFailedOne": "Failed @{{name}}",
"cliActivityRunningMany": "Using {{count}} CLI apps",
"cliActivityRanMany": "Used {{count}} CLI apps",
"cliActivityFailedMany": "{{count}} CLI apps failed",
"cliRunRunning": "Using",
"cliRunRan": "Used",
"cliRunFailed": "Failed",
"cliActivityRunningOne": "Running CLI @{{name}}",
"cliActivityRanOne": "Ran CLI @{{name}}",
"cliActivityFailedOne": "CLI failed @{{name}}",
"cliActivityRunningMany": "Running {{count}} CLIs",
"cliActivityRanMany": "Ran {{count}} CLIs",
"cliActivityFailedMany": "{{count}} CLI failed",
"cliRunRunning": "Running CLI",
"cliRunRan": "Ran CLI",
"cliRunFailed": "CLI failed",
"imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply",
+12 -181
View File
@@ -63,8 +63,7 @@
"language": {
"label": "Idioma",
"ariaLabel": "Cambiar idioma"
},
"apps": "Apps"
}
},
"settings": {
"backToChat": "Volver al chat",
@@ -82,10 +81,7 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Apps"
"advanced": "Advanced"
},
"sections": {
"interface": "Interfaz",
@@ -101,10 +97,7 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capacidades",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "Servicios MCP",
"apps": "Apps"
"integrations": "Integrations"
},
"rows": {
"theme": "Tema",
@@ -148,11 +141,7 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"currentModel": "Modelo actual",
"brandLogos": "Logotipos de marca",
"cliAppsCatalog": "Catálogo de apps CLI",
"cliAppsFilter": "Filtro de apps CLI"
"configurationDocs": "Configuration docs"
},
"help": {
"theme": "Cambia entre apariencia clara y oscura.",
@@ -179,13 +168,7 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"currentModel": "Elige el modelo que nanobot usará para las próximas respuestas.",
"selectedModelProvider": "Lo define el modelo seleccionado.",
"selectedModelValue": "Lo define el modelo seleccionado.",
"brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
"cliAppsCatalog": "Explora CLIs de apps que nanobot puede ejecutar localmente.",
"cliAppsFilter": "Busca por app, categoría o capacidad."
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "Claro",
@@ -202,7 +185,9 @@
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"restartRequired": "Restart required",
"liveReload": "Live reload ready"
},
"status": {
"loading": "Cargando configuración...",
@@ -274,8 +259,7 @@
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"saveProvider": "Guardar proveedor"
"noMatches": "No providers match this search."
},
"image": {
"selectProvider": "Seleccionar proveedor",
@@ -283,120 +267,6 @@
"selectSize": "Seleccionar tamaño",
"configureProvider": "Configurar proveedor",
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
},
"models": {
"selectModel": "Seleccionar modelo",
"addConfiguration": "Añadir configuración",
"newConfiguration": "Nueva configuración de modelo",
"newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
"configurationName": "Nombre",
"configurationNamePlaceholder": "Escritura rápida"
},
"timezone": {
"select": "Seleccionar zona horaria",
"search": "Buscar zona horaria",
"empty": "No hay zonas horarias coincidentes."
},
"cliApps": {
"allCategories": "Todas las categorías",
"availableCount": "{{count}} apps",
"installedCount": "{{count}} instaladas",
"summary": "{{installed}} de {{total}} CLIs instaladas",
"filterAll": "Todas",
"filterInstalled": "CLIs instaladas",
"filterNotInstalled": "No instaladas",
"searchPlaceholder": "Buscar CLIs",
"statusInstalled": "Instalada",
"statusAvailable": "Disponible",
"statusMissing": "Falta dependencia",
"statusUnsupported": "No compatible",
"statusNotInstalled": "No instalada",
"unsupported": "No compatible",
"loading": "Cargando apps CLI...",
"empty": "Ninguna app CLI coincide con este filtro.",
"readyTitle": "@{{name}} está listo",
"readyStatus": "Listo",
"readyPrompt": "Usa @{{name}} para ver qué puede hacer este CLI.",
"readyTry": "Probar @{{name}}",
"readyCopied": "Copiado",
"openChat": "Abrir chat",
"requires": "Requiere",
"test": "Probar CLI",
"update": "Actualizar CLI",
"uninstall": "Desinstalar CLI",
"install": "Instalar CLI",
"unavailable": "No disponible",
"noDescription": "Sin descripción disponible."
},
"mcp": {
"allCategories": "Todas las categorías",
"summary": "{{installed}} de {{total}} presets habilitados",
"filterAll": "Todos",
"filterInstalled": "Habilitados",
"filterNotInstalled": "No habilitados",
"searchPlaceholder": "Buscar presets MCP",
"moreOptions": "Más opciones de MCP",
"moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.",
"customTitle": "MCP personalizado",
"customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.",
"customAction": "Personalizado",
"importAction": "Importar",
"serverName": "Nombre del servidor",
"serverUrl": "URL",
"transport": "Transporte",
"command": "Comando",
"args": "Args JSON",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "Tiempo límite de herramienta",
"advancedOptions": "Opciones avanzadas",
"hideAdvanced": "Ocultar avanzado",
"saveCustom": "Guardar MCP",
"configImport": "Importar mcp.json",
"importConfig": "Importar",
"restartRequired": "Reinicia nanobot para conectar las herramientas MCP actualizadas.",
"toolsFound": "{{count}} herramientas",
"loading": "Cargando presets MCP...",
"empty": "Ningún preset MCP coincide con este filtro.",
"openDocs": "Abrir docs",
"test": "Probar",
"remove": "Eliminar",
"enable": "Habilitar",
"enabled": "Habilitado",
"setup": "Conectar",
"configure": "Conectar",
"connectTitle": "Conectar {{name}}",
"connectHint": "Añade la clave desde la configuración de tu cuenta.",
"saveAndEnable": "Guardar y habilitar",
"updateSetup": "Actualizar configuración",
"configured": "configurado",
"keepExisting": "Déjalo en blanco para conservar el valor actual",
"statusConfigured": "Configurado",
"statusMissingCredentials": "Necesita clave",
"statusMissingDependency": "Necesita dependencia",
"statusComingSoon": "Próximamente",
"statusNotInstalled": "No habilitado",
"toolScope": "Herramientas",
"allTools": "Todas",
"noTools": "Ninguna",
"testForTools": "Ejecuta Probar para inspeccionar y elegir herramientas individuales."
},
"legal": {
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
},
"apps": {
"description": "Añade CLI de apps y servicios MCP que nanobot puede usar desde el chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Todo",
"filterCli": "Apps CLI",
"filterMcp": "Servicios MCP",
"enabledSummary": "{{count}} activados",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Buscar apps",
"featured": "Destacadas",
"loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro."
}
},
"chat": {
@@ -500,7 +370,8 @@
"title": "Editar una imagen",
"prompt": "Ayúdame a editar una imagen. Primero pídeme que suba o indique la imagen, y luego genera el resultado editado."
}
}
},
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
},
"header": {
"toggleSidebar": "Mostrar u ocultar la barra lateral",
@@ -568,10 +439,6 @@
"title": "Mostrar estado",
"description": "Muestra el estado del runtime, provider y channels."
},
"model": {
"title": "Modelo",
"description": "Muestra o cambia el preset de modelo activo."
},
"history": {
"title": "Mostrar historial",
"description": "Imprime los últimos N mensajes persistidos de la conversación."
@@ -595,21 +462,7 @@
"help": {
"title": "Mostrar ayuda",
"description": "Lista los comandos slash disponibles."
},
"pairing": {
"title": "Emparejamiento",
"description": "Gestiona solicitudes de emparejamiento."
}
},
"badges": {
"current": "Actual",
"recent": "Reciente"
},
"details": {
"goalActive": "El objetivo está en curso",
"goalReady": "Iniciar un objetivo sostenido",
"history": "Mostrar mensajes recientes",
"stopRunning": "En ejecución"
}
},
"encoding": "Procesando…",
@@ -622,16 +475,6 @@
"decode_failed": "No se pudo decodificar esta imagen",
"too_large": "Imagen demasiado grande — prueba una más pequeña",
"io": "No se pudo leer este archivo"
},
"mentions": {
"ariaLabel": "Apps",
"label": "Apps",
"cliGroup": "Apps CLI",
"mcpGroup": "Servicios MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Usar @{{name}} como app CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP"
}
},
"scrollToBottom": "Desplazarse al final",
@@ -656,19 +499,7 @@
"imageAttachment": "Imagen adjunta",
"copyReply": "Copiar respuesta",
"copiedReply": "Respuesta copiada",
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
"activityThinkingFor": "Pensando durante {{duration}}",
"activityThought": "Pensamiento completado",
"activityThoughtFor": "Pensó durante {{duration}}",
"cliActivityRunningOne": "Usando @{{name}}",
"cliActivityRanOne": "Usó @{{name}}",
"cliActivityFailedOne": "Falló @{{name}}",
"cliActivityRunningMany": "Usando {{count}} apps CLI",
"cliActivityRanMany": "Usó {{count}} apps CLI",
"cliActivityFailedMany": "Fallaron {{count}} apps CLI",
"cliRunRunning": "Usando",
"cliRunRan": "Usado",
"cliRunFailed": "Falló"
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)"
},
"lightbox": {
"title": "Vista previa de imagen",
+12 -181
View File
@@ -63,8 +63,7 @@
"language": {
"label": "Langue",
"ariaLabel": "Changer de langue"
},
"apps": "Apps"
}
},
"settings": {
"backToChat": "Retour à la discussion",
@@ -82,10 +81,7 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Apps"
"advanced": "Advanced"
},
"sections": {
"interface": "Interface",
@@ -101,10 +97,7 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capacités",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "Services MCP",
"apps": "Apps"
"integrations": "Integrations"
},
"rows": {
"theme": "Thème",
@@ -148,11 +141,7 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"currentModel": "Modèle actuel",
"brandLogos": "Logos de marque",
"cliAppsCatalog": "Catalogue d'apps CLI",
"cliAppsFilter": "Filtre des apps CLI"
"configurationDocs": "Configuration docs"
},
"help": {
"theme": "Basculer entre les apparences claire et sombre.",
@@ -179,13 +168,7 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"currentModel": "Choisissez le modèle que nanobot utilisera pour les prochaines réponses.",
"selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "Défini par le modèle sélectionné.",
"brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
"cliAppsCatalog": "Parcourez les CLIs d'apps que nanobot peut exécuter localement.",
"cliAppsFilter": "Recherchez par app, catégorie ou capacité."
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "Clair",
@@ -202,7 +185,9 @@
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"restartRequired": "Restart required",
"liveReload": "Live reload ready"
},
"status": {
"loading": "Chargement des paramètres...",
@@ -274,8 +259,7 @@
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"saveProvider": "Enregistrer le fournisseur"
"noMatches": "No providers match this search."
},
"image": {
"selectProvider": "Sélectionner un fournisseur",
@@ -283,120 +267,6 @@
"selectSize": "Sélectionner une taille",
"configureProvider": "Configurer le fournisseur",
"missingCredential": "Configurez ce fournisseur avant dactiver la génération dimages."
},
"models": {
"selectModel": "Sélectionner un modèle",
"addConfiguration": "Ajouter une configuration",
"newConfiguration": "Nouvelle configuration de modèle",
"newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
"configurationName": "Nom",
"configurationNamePlaceholder": "Rédaction rapide"
},
"timezone": {
"select": "Sélectionner un fuseau horaire",
"search": "Rechercher un fuseau horaire",
"empty": "Aucun fuseau horaire correspondant."
},
"cliApps": {
"allCategories": "Toutes les catégories",
"availableCount": "{{count}} apps",
"installedCount": "{{count}} installées",
"summary": "{{installed}} CLIs installées sur {{total}}",
"filterAll": "Tout",
"filterInstalled": "CLIs installées",
"filterNotInstalled": "Non installées",
"searchPlaceholder": "Rechercher des CLIs",
"statusInstalled": "Installée",
"statusAvailable": "Disponible",
"statusMissing": "Dépendance manquante",
"statusUnsupported": "Non compatible",
"statusNotInstalled": "Non installée",
"unsupported": "Non compatible",
"loading": "Chargement des apps CLI...",
"empty": "Aucune app CLI ne correspond à ce filtre.",
"readyTitle": "@{{name}} est prêt",
"readyStatus": "Prêt",
"readyPrompt": "Utilisez @{{name}} pour voir ce que ce CLI peut faire.",
"readyTry": "Essayer @{{name}}",
"readyCopied": "Copié",
"openChat": "Ouvrir le chat",
"requires": "Requiert",
"test": "Tester le CLI",
"update": "Mettre à jour le CLI",
"uninstall": "Désinstaller le CLI",
"install": "Installer le CLI",
"unavailable": "Indisponible",
"noDescription": "Aucune description disponible."
},
"mcp": {
"allCategories": "Toutes les catégories",
"summary": "{{installed}} presets activés sur {{total}}",
"filterAll": "Tout",
"filterInstalled": "Activés",
"filterNotInstalled": "Non activés",
"searchPlaceholder": "Rechercher des presets MCP",
"moreOptions": "Plus d'options MCP",
"moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
"customTitle": "MCP personnalisé",
"customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
"customAction": "Personnalisé",
"importAction": "Importer",
"serverName": "Nom du serveur",
"serverUrl": "URL",
"transport": "Transport",
"command": "Commande",
"args": "Args JSON",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "Délai d'outil",
"advancedOptions": "Options avancées",
"hideAdvanced": "Masquer les options avancées",
"saveCustom": "Enregistrer MCP",
"configImport": "Importer mcp.json",
"importConfig": "Importer",
"restartRequired": "Redémarrez nanobot pour connecter les outils MCP mis à jour.",
"toolsFound": "{{count}} outils",
"loading": "Chargement des presets MCP...",
"empty": "Aucun preset MCP ne correspond à ce filtre.",
"openDocs": "Ouvrir la doc",
"test": "Tester",
"remove": "Supprimer",
"enable": "Activer",
"enabled": "Activé",
"setup": "Connecter",
"configure": "Connecter",
"connectTitle": "Connecter {{name}}",
"connectHint": "Ajoutez la clé depuis les paramètres de votre compte.",
"saveAndEnable": "Enregistrer et activer",
"updateSetup": "Mettre à jour la configuration",
"configured": "configuré",
"keepExisting": "Laissez vide pour conserver la valeur actuelle",
"statusConfigured": "Configuré",
"statusMissingCredentials": "Clé requise",
"statusMissingDependency": "Dépendance requise",
"statusComingSoon": "Bientôt disponible",
"statusNotInstalled": "Non activé",
"toolScope": "Outils",
"allTools": "Tous",
"noTools": "Aucun",
"testForTools": "Exécutez Tester pour inspecter et choisir des outils individuels."
},
"legal": {
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
},
"apps": {
"description": "Ajoutez des CLI dapps et des services MCP que nanobot peut utiliser dans le chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tout",
"filterCli": "Apps CLI",
"filterMcp": "Services MCP",
"enabledSummary": "{{count}} activés",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Rechercher des apps",
"featured": "En vedette",
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond à ce filtre."
}
},
"chat": {
@@ -500,7 +370,8 @@
"title": "Modifier une image",
"prompt": "Aidez-moi à modifier une image. Demandez-moi dabord de téléverser ou dindiquer limage, puis générez le résultat modifié."
}
}
},
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
},
"header": {
"toggleSidebar": "Afficher ou masquer la barre latérale",
@@ -568,10 +439,6 @@
"title": "Afficher l’état",
"description": "Afficher l’état du runtime, du provider et des channels."
},
"model": {
"title": "Modèle",
"description": "Afficher ou changer le préréglage de modèle actif."
},
"history": {
"title": "Afficher lhistorique",
"description": "Afficher les N derniers messages persistés de la conversation."
@@ -595,21 +462,7 @@
"help": {
"title": "Afficher laide",
"description": "Lister les commandes slash disponibles."
},
"pairing": {
"title": "Appairage",
"description": "Gérer les demandes dappairage."
}
},
"badges": {
"current": "Actuel",
"recent": "Récent"
},
"details": {
"goalActive": "Lobjectif est en cours",
"goalReady": "Démarrer un objectif durable",
"history": "Afficher les messages récents",
"stopRunning": "En cours"
}
},
"encoding": "Traitement…",
@@ -622,16 +475,6 @@
"decode_failed": "Impossible de décoder cette image",
"too_large": "Image trop grande — essayez-en une plus petite",
"io": "Impossible de lire ce fichier"
},
"mentions": {
"ariaLabel": "Apps",
"label": "Apps",
"cliGroup": "Apps CLI",
"mcpGroup": "Services MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Utiliser @{{name}} comme app CLI locale",
"mcpDescription": "Utiliser @{{name}} comme serveur MCP"
}
},
"scrollToBottom": "Faire défiler vers le bas",
@@ -656,19 +499,7 @@
"imageAttachment": "Pièce jointe image",
"copyReply": "Copier la réponse",
"copiedReply": "Réponse copiée",
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
"activityThinkingFor": "Réflexion pendant {{duration}}",
"activityThought": "Réflexion terminée",
"activityThoughtFor": "Réflexion terminée en {{duration}}",
"cliActivityRunningOne": "Utilisation de @{{name}}",
"cliActivityRanOne": "@{{name}} utilisé",
"cliActivityFailedOne": "Échec de @{{name}}",
"cliActivityRunningMany": "Utilisation de {{count}} apps CLI",
"cliActivityRanMany": "{{count}} apps CLI utilisées",
"cliActivityFailedMany": "Échec de {{count}} apps CLI",
"cliRunRunning": "Utilisation",
"cliRunRan": "Utilisé",
"cliRunFailed": "Échec"
"turnLatencyTitle": "Temps de réponse (de bout en bout)"
},
"lightbox": {
"title": "Aperçu de limage",
+12 -181
View File
@@ -63,8 +63,7 @@
"language": {
"label": "Bahasa",
"ariaLabel": "Ganti bahasa"
},
"apps": "Aplikasi"
}
},
"settings": {
"backToChat": "Kembali ke obrolan",
@@ -82,10 +81,7 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Aplikasi CLI",
"mcp": "MCP",
"apps": "Aplikasi"
"advanced": "Advanced"
},
"sections": {
"interface": "Antarmuka",
@@ -101,10 +97,7 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "Kapabilitas",
"integrations": "Integrations",
"cliApps": "App CLI",
"mcp": "Layanan MCP",
"apps": "Aplikasi"
"integrations": "Integrations"
},
"rows": {
"theme": "Tema",
@@ -148,11 +141,7 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"currentModel": "Model saat ini",
"brandLogos": "Logo merek",
"cliAppsCatalog": "Katalog aplikasi CLI",
"cliAppsFilter": "Filter aplikasi CLI"
"configurationDocs": "Configuration docs"
},
"help": {
"theme": "Beralih antara tampilan terang dan gelap.",
@@ -179,13 +168,7 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"currentModel": "Pilih model yang digunakan nanobot untuk balasan berikutnya.",
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
"cliAppsCatalog": "Jelajahi CLI aplikasi yang dapat dijalankan nanobot secara lokal.",
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan."
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "Terang",
@@ -202,7 +185,9 @@
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"restartRequired": "Restart required",
"liveReload": "Live reload ready"
},
"status": {
"loading": "Memuat pengaturan...",
@@ -274,8 +259,7 @@
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"saveProvider": "Simpan penyedia"
"noMatches": "No providers match this search."
},
"image": {
"selectProvider": "Pilih penyedia",
@@ -283,120 +267,6 @@
"selectSize": "Pilih ukuran",
"configureProvider": "Konfigurasi penyedia",
"missingCredential": "Konfigurasikan penyedia ini sebelum mengaktifkan pembuatan gambar."
},
"models": {
"selectModel": "Pilih model",
"addConfiguration": "Tambah konfigurasi",
"newConfiguration": "Konfigurasi model baru",
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
"configurationName": "Nama",
"configurationNamePlaceholder": "Penulisan cepat"
},
"timezone": {
"select": "Pilih zona waktu",
"search": "Cari zona waktu",
"empty": "Tidak ada zona waktu yang cocok."
},
"cliApps": {
"allCategories": "Semua kategori",
"availableCount": "{{count}} aplikasi",
"installedCount": "{{count}} terpasang",
"summary": "{{installed}} dari {{total}} CLI terpasang",
"filterAll": "Semua",
"filterInstalled": "CLI terpasang",
"filterNotInstalled": "Belum terpasang",
"searchPlaceholder": "Cari CLI",
"statusInstalled": "Terpasang",
"statusAvailable": "Tersedia",
"statusMissing": "Dependensi hilang",
"statusUnsupported": "Tidak didukung",
"statusNotInstalled": "Belum terpasang",
"unsupported": "Tidak didukung",
"loading": "Memuat aplikasi CLI...",
"empty": "Tidak ada aplikasi CLI yang cocok dengan filter ini.",
"readyTitle": "@{{name}} siap",
"readyStatus": "Siap",
"readyPrompt": "Gunakan @{{name}} untuk melihat kemampuan CLI ini.",
"readyTry": "Coba @{{name}}",
"readyCopied": "Disalin",
"openChat": "Buka chat",
"requires": "Membutuhkan",
"test": "Uji CLI",
"update": "Perbarui CLI",
"uninstall": "Copot CLI",
"install": "Pasang CLI",
"unavailable": "Tidak tersedia",
"noDescription": "Tidak ada deskripsi."
},
"mcp": {
"allCategories": "Semua kategori",
"summary": "{{installed}} dari {{total}} preset diaktifkan",
"filterAll": "Semua",
"filterInstalled": "Aktif",
"filterNotInstalled": "Tidak aktif",
"searchPlaceholder": "Cari preset MCP",
"moreOptions": "Opsi MCP lainnya",
"moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.",
"customTitle": "MCP khusus",
"customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.",
"customAction": "Khusus",
"importAction": "Impor",
"serverName": "Nama server",
"serverUrl": "URL",
"transport": "Transport",
"command": "Perintah",
"args": "Args JSON",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "Batas waktu alat",
"advancedOptions": "Opsi lanjutan",
"hideAdvanced": "Sembunyikan lanjutan",
"saveCustom": "Simpan MCP",
"configImport": "Impor mcp.json",
"importConfig": "Impor",
"restartRequired": "Mulai ulang nanobot untuk menyambungkan alat MCP yang diperbarui.",
"toolsFound": "{{count}} alat",
"loading": "Memuat preset MCP...",
"empty": "Tidak ada preset MCP yang cocok dengan filter ini.",
"openDocs": "Buka dokumentasi",
"test": "Uji",
"remove": "Hapus",
"enable": "Aktifkan",
"enabled": "Aktif",
"setup": "Hubungkan",
"configure": "Hubungkan",
"connectTitle": "Hubungkan {{name}}",
"connectHint": "Tambahkan kunci dari pengaturan akun Anda.",
"saveAndEnable": "Simpan dan aktifkan",
"updateSetup": "Perbarui konfigurasi",
"configured": "terkonfigurasi",
"keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini",
"statusConfigured": "Terkonfigurasi",
"statusMissingCredentials": "Butuh kunci",
"statusMissingDependency": "Butuh dependensi",
"statusComingSoon": "Segera hadir",
"statusNotInstalled": "Tidak aktif",
"toolScope": "Alat",
"allTools": "Semua",
"noTools": "Tidak ada",
"testForTools": "Jalankan Uji untuk memeriksa dan memilih alat individual."
},
"legal": {
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
},
"apps": {
"description": "Tambahkan CLI app dan layanan MCP yang dapat digunakan nanobot dari chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Semua",
"filterCli": "App CLI",
"filterMcp": "Layanan MCP",
"enabledSummary": "{{count}} aktif",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Cari aplikasi",
"featured": "Unggulan",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok dengan filter ini."
}
},
"chat": {
@@ -500,7 +370,8 @@
"title": "Edit gambar",
"prompt": "Bantu saya mengedit gambar. Minta saya mengunggah atau menyebutkan gambar terlebih dahulu, lalu buat hasil editnya."
}
}
},
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
},
"header": {
"toggleSidebar": "Tampilkan atau sembunyikan sidebar",
@@ -568,10 +439,6 @@
"title": "Tampilkan status",
"description": "Tampilkan status runtime, provider, dan channel."
},
"model": {
"title": "Model",
"description": "Tampilkan atau ganti preset model aktif."
},
"history": {
"title": "Tampilkan riwayat",
"description": "Cetak N pesan percakapan tersimpan terbaru."
@@ -595,21 +462,7 @@
"help": {
"title": "Tampilkan bantuan",
"description": "Daftar perintah slash yang tersedia."
},
"pairing": {
"title": "Pemasangan",
"description": "Kelola permintaan pemasangan."
}
},
"badges": {
"current": "Saat ini",
"recent": "Terbaru"
},
"details": {
"goalActive": "Tujuan sedang berjalan",
"goalReady": "Mulai tujuan berkelanjutan",
"history": "Tampilkan pesan terbaru",
"stopRunning": "Sedang berjalan"
}
},
"encoding": "Memproses…",
@@ -622,16 +475,6 @@
"decode_failed": "Tidak dapat mendekode gambar ini",
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
"io": "Tidak dapat membaca file ini"
},
"mentions": {
"ariaLabel": "Aplikasi",
"label": "Aplikasi",
"cliGroup": "App CLI",
"mcpGroup": "Layanan MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
"mcpDescription": "Gunakan @{{name}} sebagai server MCP"
}
},
"scrollToBottom": "Gulir ke bawah",
@@ -656,19 +499,7 @@
"imageAttachment": "Lampiran gambar",
"copyReply": "Salin balasan",
"copiedReply": "Balasan disalin",
"turnLatencyTitle": "Waktu respons (ujung ke ujung)",
"activityThinkingFor": "Berpikir selama {{duration}}",
"activityThought": "Selesai berpikir",
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
"cliActivityRunningOne": "Menggunakan @{{name}}",
"cliActivityRanOne": "Menggunakan @{{name}} selesai",
"cliActivityFailedOne": "@{{name}} gagal",
"cliActivityRunningMany": "Menggunakan {{count}} aplikasi CLI",
"cliActivityRanMany": "{{count}} aplikasi CLI digunakan",
"cliActivityFailedMany": "{{count}} aplikasi CLI gagal",
"cliRunRunning": "Menggunakan",
"cliRunRan": "Digunakan",
"cliRunFailed": "Gagal"
"turnLatencyTitle": "Waktu respons (ujung ke ujung)"
},
"lightbox": {
"title": "Pratinjau gambar",
+12 -181
View File
@@ -63,8 +63,7 @@
"language": {
"label": "言語",
"ariaLabel": "言語を変更"
},
"apps": "アプリ"
}
},
"settings": {
"backToChat": "チャットに戻る",
@@ -82,10 +81,7 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "CLI アプリ",
"mcp": "MCP",
"apps": "アプリ"
"advanced": "Advanced"
},
"sections": {
"interface": "インターフェース",
@@ -101,10 +97,7 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "機能",
"integrations": "Integrations",
"cliApps": "CLI アプリ",
"mcp": "MCP サービス",
"apps": "アプリ"
"integrations": "Integrations"
},
"rows": {
"theme": "テーマ",
@@ -148,11 +141,7 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"currentModel": "現在のモデル",
"brandLogos": "ブランドロゴ",
"cliAppsCatalog": "CLI アプリカタログ",
"cliAppsFilter": "CLI アプリフィルター"
"configurationDocs": "Configuration docs"
},
"help": {
"theme": "ライト表示とダーク表示を切り替えます。",
@@ -179,13 +168,7 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"currentModel": "今後の返信で nanobot が使用するモデルを選択します。",
"selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。"
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "ライト",
@@ -202,7 +185,9 @@
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"restartRequired": "Restart required",
"liveReload": "Live reload ready"
},
"status": {
"loading": "設定を読み込んでいます...",
@@ -274,8 +259,7 @@
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"saveProvider": "プロバイダーを保存"
"noMatches": "No providers match this search."
},
"image": {
"selectProvider": "プロバイダーを選択",
@@ -283,120 +267,6 @@
"selectSize": "サイズを選択",
"configureProvider": "プロバイダーを設定",
"missingCredential": "画像生成を有効にする前に、このプロバイダーを設定してください。"
},
"models": {
"selectModel": "モデルを選択",
"addConfiguration": "設定を追加",
"newConfiguration": "新しいモデル設定",
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
"configurationName": "名前",
"configurationNamePlaceholder": "高速ライティング"
},
"timezone": {
"select": "タイムゾーンを選択",
"search": "タイムゾーンを検索",
"empty": "一致するタイムゾーンはありません。"
},
"cliApps": {
"allCategories": "すべてのカテゴリ",
"availableCount": "{{count}} 個のアプリ",
"installedCount": "{{count}} 個インストール済み",
"summary": "{{total}} 個中 {{installed}} 個の CLI がインストール済み",
"filterAll": "すべて",
"filterInstalled": "インストール済み CLI",
"filterNotInstalled": "未インストール",
"searchPlaceholder": "CLI を検索",
"statusInstalled": "インストール済み",
"statusAvailable": "利用可能",
"statusMissing": "依存関係が不足",
"statusUnsupported": "未対応",
"statusNotInstalled": "未インストール",
"unsupported": "未対応",
"loading": "CLI アプリを読み込み中...",
"empty": "この条件に一致する CLI アプリはありません。",
"readyTitle": "@{{name}} の準備ができました",
"readyStatus": "準備完了",
"readyPrompt": "@{{name}} を使って、この CLI でできることを確認します。",
"readyTry": "@{{name}} を試す",
"readyCopied": "コピーしました",
"openChat": "チャットを開く",
"requires": "必要条件",
"test": "CLI をテスト",
"update": "CLI を更新",
"uninstall": "CLI をアンインストール",
"install": "CLI をインストール",
"unavailable": "利用不可",
"noDescription": "説明はありません。"
},
"mcp": {
"allCategories": "すべてのカテゴリ",
"summary": "{{total}} 個中 {{installed}} 個のプリセットが有効",
"filterAll": "すべて",
"filterInstalled": "有効",
"filterNotInstalled": "未有効",
"searchPlaceholder": "MCP プリセットを検索",
"moreOptions": "その他の MCP オプション",
"moreOptionsSubtitle": "カスタムサーバーを追加するか mcp.json をインポートします。",
"customTitle": "カスタム MCP",
"customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。",
"customAction": "カスタム",
"importAction": "インポート",
"serverName": "サーバー名",
"serverUrl": "URL",
"transport": "トランスポート",
"command": "コマンド",
"args": "Args JSON",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "ツールのタイムアウト",
"advancedOptions": "詳細オプション",
"hideAdvanced": "詳細を隠す",
"saveCustom": "MCP を保存",
"configImport": "mcp.json をインポート",
"importConfig": "インポート",
"restartRequired": "更新された MCP ツールに接続するには nanobot を再起動してください。",
"toolsFound": "{{count}} 個のツール",
"loading": "MCP プリセットを読み込み中...",
"empty": "この条件に一致する MCP プリセットはありません。",
"openDocs": "ドキュメントを開く",
"test": "テスト",
"remove": "削除",
"enable": "有効化",
"enabled": "有効",
"setup": "接続",
"configure": "接続",
"connectTitle": "{{name}} に接続",
"connectHint": "アカウント設定からキーを追加します。",
"saveAndEnable": "保存して有効化",
"updateSetup": "設定を更新",
"configured": "設定済み",
"keepExisting": "既存の値を維持するには空欄のままにします",
"statusConfigured": "設定済み",
"statusMissingCredentials": "キーが必要",
"statusMissingDependency": "依存関係が必要",
"statusComingSoon": "近日公開",
"statusNotInstalled": "未有効",
"toolScope": "ツール",
"allTools": "すべて",
"noTools": "なし",
"testForTools": "テストを実行して個別のツールを確認・選択します。"
},
"legal": {
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
},
"apps": {
"description": "チャットから nanobot が使えるアプリ CLI と MCP サービスを追加します。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "すべて",
"filterCli": "CLI アプリ",
"filterMcp": "MCP サービス",
"enabledSummary": "{{count}} 件有効",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "アプリを検索",
"featured": "おすすめ",
"loading": "アプリを読み込み中...",
"empty": "このフィルターに一致するアプリはありません。"
}
},
"chat": {
@@ -500,7 +370,8 @@
"title": "画像を編集",
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
}
}
},
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
},
"header": {
"toggleSidebar": "サイドバーを切り替える",
@@ -568,10 +439,6 @@
"title": "ステータスを表示",
"description": "ランタイム、provider、channel の状態を表示します。"
},
"model": {
"title": "モデル",
"description": "有効なモデルプリセットを表示または切り替えます。"
},
"history": {
"title": "会話履歴を表示",
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
@@ -595,21 +462,7 @@
"help": {
"title": "ヘルプを表示",
"description": "利用可能なスラッシュコマンドを一覧表示します。"
},
"pairing": {
"title": "ペアリング",
"description": "ペアリングリクエストを管理します。"
}
},
"badges": {
"current": "現在",
"recent": "最近"
},
"details": {
"goalActive": "目標が実行中",
"goalReady": "継続的な目標を開始",
"history": "最近のメッセージを表示",
"stopRunning": "実行中"
}
},
"encoding": "処理中…",
@@ -622,16 +475,6 @@
"decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください",
"io": "このファイルを読み込めません"
},
"mentions": {
"ariaLabel": "アプリ",
"label": "アプリ",
"cliGroup": "CLI アプリ",
"mcpGroup": "MCP サービス",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
"mcpDescription": "@{{name}} を MCP サーバーとして使用"
}
},
"scrollToBottom": "一番下へスクロール",
@@ -656,19 +499,7 @@
"imageAttachment": "画像の添付",
"copyReply": "返信をコピー",
"copiedReply": "返信をコピーしました",
"turnLatencyTitle": "応答時間(全行程)",
"activityThinkingFor": "{{duration}}考えています",
"activityThought": "思考しました",
"activityThoughtFor": "{{duration}}考えました",
"cliActivityRunningOne": "@{{name}} を使用中",
"cliActivityRanOne": "@{{name}} を使用しました",
"cliActivityFailedOne": "@{{name}} が失敗しました",
"cliActivityRunningMany": "{{count}} 個の CLI アプリを使用中",
"cliActivityRanMany": "{{count}} 個の CLI アプリを使用しました",
"cliActivityFailedMany": "{{count}} 個の CLI アプリが失敗しました",
"cliRunRunning": "使用中",
"cliRunRan": "使用済み",
"cliRunFailed": "失敗"
"turnLatencyTitle": "応答時間(全行程)"
},
"lightbox": {
"title": "画像プレビュー",
+12 -181
View File
@@ -63,8 +63,7 @@
"language": {
"label": "언어",
"ariaLabel": "언어 변경"
},
"apps": "앱"
}
},
"settings": {
"backToChat": "채팅으로 돌아가기",
@@ -82,10 +81,7 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "CLI 앱",
"mcp": "MCP",
"apps": "앱"
"advanced": "Advanced"
},
"sections": {
"interface": "인터페이스",
@@ -101,10 +97,7 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "기능",
"integrations": "Integrations",
"cliApps": "CLI 앱",
"mcp": "MCP 서비스",
"apps": "앱"
"integrations": "Integrations"
},
"rows": {
"theme": "테마",
@@ -148,11 +141,7 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"currentModel": "현재 모델",
"brandLogos": "브랜드 로고",
"cliAppsCatalog": "CLI 앱 카탈로그",
"cliAppsFilter": "CLI 앱 필터"
"configurationDocs": "Configuration docs"
},
"help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
@@ -179,13 +168,7 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.",
"selectedModelProvider": "선택한 모델에서 설정됩니다.",
"selectedModelValue": "선택한 모델에서 설정됩니다.",
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다."
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "라이트",
@@ -202,7 +185,9 @@
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"restartRequired": "Restart required",
"liveReload": "Live reload ready"
},
"status": {
"loading": "설정을 불러오는 중...",
@@ -274,8 +259,7 @@
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"saveProvider": "제공자 저장"
"noMatches": "No providers match this search."
},
"image": {
"selectProvider": "제공자 선택",
@@ -283,120 +267,6 @@
"selectSize": "크기 선택",
"configureProvider": "제공자 구성",
"missingCredential": "이미지 생성을 활성화하기 전에 이 제공자를 구성하세요."
},
"models": {
"selectModel": "모델 선택",
"addConfiguration": "구성 추가",
"newConfiguration": "새 모델 구성",
"newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
"configurationName": "이름",
"configurationNamePlaceholder": "빠른 글쓰기"
},
"timezone": {
"select": "시간대 선택",
"search": "시간대 검색",
"empty": "일치하는 시간대가 없습니다."
},
"cliApps": {
"allCategories": "모든 카테고리",
"availableCount": "앱 {{count}}개",
"installedCount": "{{count}}개 설치됨",
"summary": "CLI {{total}}개 중 {{installed}}개 설치됨",
"filterAll": "전체",
"filterInstalled": "설치된 CLI",
"filterNotInstalled": "미설치",
"searchPlaceholder": "CLI 검색",
"statusInstalled": "설치됨",
"statusAvailable": "사용 가능",
"statusMissing": "의존성 필요",
"statusUnsupported": "지원 안 함",
"statusNotInstalled": "미설치",
"unsupported": "지원 안 함",
"loading": "CLI 앱 로드 중...",
"empty": "이 필터와 일치하는 CLI 앱이 없습니다.",
"readyTitle": "@{{name}} 준비됨",
"readyStatus": "준비됨",
"readyPrompt": "@{{name}}을 사용해 이 CLI가 무엇을 할 수 있는지 확인하세요.",
"readyTry": "@{{name}} 사용해 보기",
"readyCopied": "복사됨",
"openChat": "채팅 열기",
"requires": "필요 항목",
"test": "CLI 테스트",
"update": "CLI 업데이트",
"uninstall": "CLI 제거",
"install": "CLI 설치",
"unavailable": "사용 불가",
"noDescription": "설명이 없습니다."
},
"mcp": {
"allCategories": "모든 카테고리",
"summary": "프리셋 {{total}}개 중 {{installed}}개 활성화됨",
"filterAll": "전체",
"filterInstalled": "활성화됨",
"filterNotInstalled": "비활성",
"searchPlaceholder": "MCP 프리셋 검색",
"moreOptions": "추가 MCP 옵션",
"moreOptionsSubtitle": "사용자 지정 서버를 추가하거나 mcp.json을 가져옵니다.",
"customTitle": "사용자 지정 MCP",
"customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.",
"customAction": "사용자 지정",
"importAction": "가져오기",
"serverName": "서버 이름",
"serverUrl": "URL",
"transport": "전송 방식",
"command": "명령",
"args": "Args JSON",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "도구 제한 시간",
"advancedOptions": "고급 옵션",
"hideAdvanced": "고급 숨기기",
"saveCustom": "MCP 저장",
"configImport": "mcp.json 가져오기",
"importConfig": "가져오기",
"restartRequired": "업데이트된 MCP 도구를 연결하려면 nanobot을 다시 시작하세요.",
"toolsFound": "도구 {{count}}개",
"loading": "MCP 프리셋 로드 중...",
"empty": "이 필터와 일치하는 MCP 프리셋이 없습니다.",
"openDocs": "문서 열기",
"test": "테스트",
"remove": "제거",
"enable": "활성화",
"enabled": "활성화됨",
"setup": "연결",
"configure": "연결",
"connectTitle": "{{name}} 연결",
"connectHint": "계정 설정에서 키를 추가하세요.",
"saveAndEnable": "저장 후 활성화",
"updateSetup": "설정 업데이트",
"configured": "구성됨",
"keepExisting": "기존 값을 유지하려면 비워 두세요",
"statusConfigured": "구성됨",
"statusMissingCredentials": "키 필요",
"statusMissingDependency": "의존성 필요",
"statusComingSoon": "곧 제공",
"statusNotInstalled": "비활성",
"toolScope": "도구",
"allTools": "전체",
"noTools": "없음",
"testForTools": "테스트를 실행해 개별 도구를 확인하고 선택하세요."
},
"legal": {
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
},
"apps": {
"description": "채팅에서 nanobot이 사용할 수 있는 앱 CLI와 MCP 서비스를 추가합니다.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "전체",
"filterCli": "CLI 앱",
"filterMcp": "MCP 서비스",
"enabledSummary": "{{count}}개 활성화됨",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "앱 검색",
"featured": "추천",
"loading": "앱 불러오는 중...",
"empty": "이 필터와 일치하는 앱이 없습니다."
}
},
"chat": {
@@ -500,7 +370,8 @@
"title": "이미지 편집",
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
}
}
},
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
},
"header": {
"toggleSidebar": "사이드바 전환",
@@ -568,10 +439,6 @@
"title": "상태 보기",
"description": "런타임, provider, channel 상태를 표시합니다."
},
"model": {
"title": "모델",
"description": "활성 모델 프리셋을 보거나 전환합니다."
},
"history": {
"title": "대화 기록 보기",
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
@@ -595,21 +462,7 @@
"help": {
"title": "도움말 보기",
"description": "사용 가능한 슬래시 명령을 나열합니다."
},
"pairing": {
"title": "페어링",
"description": "페어링 요청을 관리합니다."
}
},
"badges": {
"current": "현재",
"recent": "최근"
},
"details": {
"goalActive": "목표 실행 중",
"goalReady": "지속 목표 시작",
"history": "최근 메시지 보기",
"stopRunning": "실행 중"
}
},
"encoding": "처리 중…",
@@ -622,16 +475,6 @@
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
"io": "이 파일을 읽을 수 없습니다"
},
"mentions": {
"ariaLabel": "앱",
"label": "앱",
"cliGroup": "CLI 앱",
"mcpGroup": "MCP 서비스",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
"mcpDescription": "@{{name}}을 MCP 서버로 사용"
}
},
"scrollToBottom": "맨 아래로 스크롤",
@@ -656,19 +499,7 @@
"imageAttachment": "이미지 첨부",
"copyReply": "답변 복사",
"copiedReply": "답변이 복사됨",
"turnLatencyTitle": "응답 시간(엔드투엔드)",
"activityThinkingFor": "{{duration}} 동안 생각 중",
"activityThought": "생각함",
"activityThoughtFor": "{{duration}} 동안 생각함",
"cliActivityRunningOne": "@{{name}} 사용 중",
"cliActivityRanOne": "@{{name}} 사용함",
"cliActivityFailedOne": "@{{name}} 실패",
"cliActivityRunningMany": "CLI 앱 {{count}}개 사용 중",
"cliActivityRanMany": "CLI 앱 {{count}}개 사용함",
"cliActivityFailedMany": "CLI 앱 {{count}}개 실패",
"cliRunRunning": "사용 중",
"cliRunRan": "사용함",
"cliRunFailed": "실패"
"turnLatencyTitle": "응답 시간(엔드투엔드)"
},
"lightbox": {
"title": "이미지 미리보기",
+12 -181
View File
@@ -63,8 +63,7 @@
"language": {
"label": "Ngôn ngữ",
"ariaLabel": "Đổi ngôn ngữ"
},
"apps": "Ứng dụng"
}
},
"settings": {
"backToChat": "Quay lại trò chuyện",
@@ -82,10 +81,7 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP",
"apps": "Ứng dụng"
"advanced": "Advanced"
},
"sections": {
"interface": "Giao diện",
@@ -101,10 +97,7 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "Khả năng",
"integrations": "Integrations",
"cliApps": "Ứng dụng CLI",
"mcp": "Dịch vụ MCP",
"apps": "Ứng dụng"
"integrations": "Integrations"
},
"rows": {
"theme": "Giao diện",
@@ -148,11 +141,7 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"currentModel": "Mô hình hiện tại",
"brandLogos": "Logo thương hiệu",
"cliAppsCatalog": "Danh mục ứng dụng CLI",
"cliAppsFilter": "Bộ lọc ứng dụng CLI"
"configurationDocs": "Configuration docs"
},
"help": {
"theme": "Chuyển giữa giao diện sáng và tối.",
@@ -179,13 +168,7 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"currentModel": "Chọn mô hình nanobot dùng cho các câu trả lời mới.",
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
"brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
"cliAppsCatalog": "Duyệt các CLI ứng dụng mà nanobot có thể chạy cục bộ.",
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng."
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "Sáng",
@@ -202,7 +185,9 @@
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"restartRequired": "Restart required",
"liveReload": "Live reload ready"
},
"status": {
"loading": "Đang tải cài đặt...",
@@ -274,8 +259,7 @@
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"saveProvider": "Lưu nhà cung cấp"
"noMatches": "No providers match this search."
},
"image": {
"selectProvider": "Chọn nhà cung cấp",
@@ -283,120 +267,6 @@
"selectSize": "Chọn kích thước",
"configureProvider": "Cấu hình nhà cung cấp",
"missingCredential": "Cấu hình nhà cung cấp này trước khi bật tạo ảnh."
},
"models": {
"selectModel": "Chọn mô hình",
"addConfiguration": "Thêm cấu hình",
"newConfiguration": "Cấu hình mô hình mới",
"newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
"configurationName": "Tên",
"configurationNamePlaceholder": "Viết nhanh"
},
"timezone": {
"select": "Chọn múi giờ",
"search": "Tìm múi giờ",
"empty": "Không có múi giờ phù hợp."
},
"cliApps": {
"allCategories": "Tất cả danh mục",
"availableCount": "{{count}} ứng dụng",
"installedCount": "Đã cài {{count}}",
"summary": "Đã cài {{installed}} / {{total}} CLI",
"filterAll": "Tất cả",
"filterInstalled": "CLI đã cài",
"filterNotInstalled": "Chưa cài",
"searchPlaceholder": "Tìm CLI",
"statusInstalled": "Đã cài",
"statusAvailable": "Có sẵn",
"statusMissing": "Thiếu phụ thuộc",
"statusUnsupported": "Không hỗ trợ",
"statusNotInstalled": "Chưa cài",
"unsupported": "Không hỗ trợ",
"loading": "Đang tải ứng dụng CLI...",
"empty": "Không có ứng dụng CLI nào khớp bộ lọc này.",
"readyTitle": "@{{name}} đã sẵn sàng",
"readyStatus": "Sẵn sàng",
"readyPrompt": "Dùng @{{name}} để xem CLI này làm được gì.",
"readyTry": "Thử @{{name}}",
"readyCopied": "Đã sao chép",
"openChat": "Mở chat",
"requires": "Yêu cầu",
"test": "Kiểm tra CLI",
"update": "Cập nhật CLI",
"uninstall": "Gỡ CLI",
"install": "Cài CLI",
"unavailable": "Không khả dụng",
"noDescription": "Không có mô tả."
},
"mcp": {
"allCategories": "Tất cả danh mục",
"summary": "Đã bật {{installed}} / {{total}} preset",
"filterAll": "Tất cả",
"filterInstalled": "Đã bật",
"filterNotInstalled": "Chưa bật",
"searchPlaceholder": "Tìm preset MCP",
"moreOptions": "Tùy chọn MCP khác",
"moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.",
"customTitle": "MCP tùy chỉnh",
"customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.",
"customAction": "Tùy chỉnh",
"importAction": "Nhập",
"serverName": "Tên máy chủ",
"serverUrl": "URL",
"transport": "Giao thức truyền",
"command": "Lệnh",
"args": "Args JSON",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "Thời gian chờ công cụ",
"advancedOptions": "Tùy chọn nâng cao",
"hideAdvanced": "Ẩn nâng cao",
"saveCustom": "Lưu MCP",
"configImport": "Nhập mcp.json",
"importConfig": "Nhập",
"restartRequired": "Khởi động lại nanobot để kết nối các công cụ MCP đã cập nhật.",
"toolsFound": "{{count}} công cụ",
"loading": "Đang tải preset MCP...",
"empty": "Không có preset MCP nào khớp bộ lọc này.",
"openDocs": "Mở tài liệu",
"test": "Kiểm tra",
"remove": "Xóa",
"enable": "Bật",
"enabled": "Đã bật",
"setup": "Kết nối",
"configure": "Kết nối",
"connectTitle": "Kết nối {{name}}",
"connectHint": "Thêm khóa từ phần cài đặt tài khoản của bạn.",
"saveAndEnable": "Lưu và bật",
"updateSetup": "Cập nhật thiết lập",
"configured": "đã cấu hình",
"keepExisting": "Để trống để giữ giá trị hiện tại",
"statusConfigured": "Đã cấu hình",
"statusMissingCredentials": "Cần khóa",
"statusMissingDependency": "Cần phụ thuộc",
"statusComingSoon": "Sắp ra mắt",
"statusNotInstalled": "Chưa bật",
"toolScope": "Công cụ",
"allTools": "Tất cả",
"noTools": "Không có",
"testForTools": "Chạy Kiểm tra để xem và chọn từng công cụ."
},
"legal": {
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
},
"apps": {
"description": "Thêm CLI ứng dụng và dịch vụ MCP để nanobot dùng trong trò chuyện.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tất cả",
"filterCli": "Ứng dụng CLI",
"filterMcp": "Dịch vụ MCP",
"enabledSummary": "{{count}} đã bật",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Nổi bật",
"loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng nào khớp với bộ lọc này."
}
},
"chat": {
@@ -500,7 +370,8 @@
"title": "Chỉnh sửa ảnh",
"prompt": "Giúp tôi chỉnh sửa một ảnh. Trước tiên hãy yêu cầu tôi tải lên hoặc chỉ định ảnh, rồi tạo kết quả đã chỉnh sửa."
}
}
},
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
},
"header": {
"toggleSidebar": "Bật/tắt thanh bên",
@@ -568,10 +439,6 @@
"title": "Hiển thị trạng thái",
"description": "Hiển thị trạng thái runtime, provider và channel."
},
"model": {
"title": "Mô hình",
"description": "Hiển thị hoặc chuyển preset mô hình đang hoạt động."
},
"history": {
"title": "Hiển thị lịch sử",
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
@@ -595,21 +462,7 @@
"help": {
"title": "Hiển thị trợ giúp",
"description": "Liệt kê các lệnh slash có sẵn."
},
"pairing": {
"title": "Ghép nối",
"description": "Quản lý yêu cầu ghép nối."
}
},
"badges": {
"current": "Hiện tại",
"recent": "Gần đây"
},
"details": {
"goalActive": "Mục tiêu đang chạy",
"goalReady": "Bắt đầu mục tiêu duy trì",
"history": "Hiển thị tin nhắn gần đây",
"stopRunning": "Đang chạy"
}
},
"encoding": "Đang xử lý…",
@@ -622,16 +475,6 @@
"decode_failed": "Không thể giải mã ảnh này",
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
"io": "Không thể đọc tệp này"
},
"mentions": {
"ariaLabel": "Ứng dụng",
"label": "Ứng dụng",
"cliGroup": "Ứng dụng CLI",
"mcpGroup": "Dịch vụ MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
"mcpDescription": "Dùng @{{name}} như máy chủ MCP"
}
},
"scrollToBottom": "Cuộn xuống cuối",
@@ -656,19 +499,7 @@
"imageAttachment": "Tệp hình ảnh đính kèm",
"copyReply": "Sao chép trả lời",
"copiedReply": "Đã sao chép trả lời",
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
"activityThought": "Đã suy nghĩ",
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
"cliActivityRunningOne": "Đang dùng @{{name}}",
"cliActivityRanOne": "Đã dùng @{{name}}",
"cliActivityFailedOne": "@{{name}} thất bại",
"cliActivityRunningMany": "Đang dùng {{count}} ứng dụng CLI",
"cliActivityRanMany": "Đã dùng {{count}} ứng dụng CLI",
"cliActivityFailedMany": "{{count}} ứng dụng CLI thất bại",
"cliRunRunning": "Đang dùng",
"cliRunRan": "Đã dùng",
"cliRunFailed": "Thất bại"
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)"
},
"lightbox": {
"title": "Xem trước ảnh",
+15 -144
View File
@@ -9,18 +9,6 @@
"title": "无法连接到 nanobot",
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
},
"auth": {
"title": "需要验证",
"hint": "请输入 gateway 配置中的 tokenIssueSecret。",
"placeholder": "密码",
"submit": "连接",
"invalid": "密码无效,请重试。"
},
"account": {
"section": "账户",
"logoutHint": "断开此浏览器与 gateway 的连接。",
"logout": "退出登录"
},
"system": {
"section": "系统",
"restartHint": "重启 nanobot 以应用运行时更改。",
@@ -63,8 +51,7 @@
"language": {
"label": "语言",
"ariaLabel": "切换语言"
},
"apps": "应用"
}
},
"settings": {
"backToChat": "返回对话",
@@ -82,10 +69,8 @@
"image": "图片",
"web": "网页",
"cliApps": "CLI 应用",
"mcp": "MCP",
"runtime": "运行时",
"advanced": "高级",
"apps": "应用"
"advanced": "高级"
},
"sections": {
"interface": "界面",
@@ -99,20 +84,10 @@
"webSearch": "网页搜索",
"webBehavior": "行为",
"cliApps": "CLI 应用",
"mcp": "MCP 服务",
"identity": "身份",
"safety": "安全",
"capabilities": "能力",
"integrations": "集成",
"apps": "应用"
},
"models": {
"selectModel": "选择模型",
"addConfiguration": "添加配置",
"newConfiguration": "新建模型配置",
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
"configurationName": "名称",
"configurationNamePlaceholder": "快速写作"
"integrations": "集成"
},
"rows": {
"theme": "主题",
@@ -125,7 +100,6 @@
"gateway": "网关",
"restartState": "重启状态",
"pendingChanges": "待处理更改",
"currentModel": "当前模型",
"selectedPreset": "选中的预设",
"presetModel": "预设模型",
"density": "密度",
@@ -168,9 +142,6 @@
"provider": "选择新模型请求使用的服务商。",
"model": "设置 nanobot 默认使用的模型名称。",
"configPath": "当前网关正在使用的配置文件。",
"currentModel": "选择 nanobot 接下来回复时使用的模型。",
"selectedModelProvider": "由当前模型决定。",
"selectedModelValue": "由当前模型决定。",
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
"presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。",
"density": "仅保存在当前浏览器。",
@@ -195,11 +166,6 @@
"cliAppsFilter": "按应用、分类或能力搜索。",
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
},
"timezone": {
"select": "选择时区",
"search": "搜索时区",
"empty": "没有匹配的时区。"
},
"cliApps": {
"allCategories": "全部分类",
"availableCount": "{{count}} 个应用",
@@ -231,59 +197,6 @@
"unavailable": "不可用",
"noDescription": "暂无描述。"
},
"mcp": {
"allCategories": "全部分类",
"summary": "已启用 {{installed}} / {{total}} 个预设",
"filterAll": "全部",
"filterInstalled": "已启用",
"filterNotInstalled": "未启用",
"searchPlaceholder": "搜索 MCP 预设",
"moreOptions": "更多 MCP 选项",
"moreOptionsSubtitle": "添加自定义服务,或导入 mcp.json。",
"customTitle": "自定义 MCP",
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
"customAction": "自定义",
"importAction": "导入",
"serverName": "服务名",
"serverUrl": "URL",
"transport": "传输方式",
"command": "命令",
"args": "参数 JSON",
"headers": "Headers JSON",
"env": "环境变量 JSON",
"timeout": "工具超时",
"advancedOptions": "高级选项",
"hideAdvanced": "收起高级",
"saveCustom": "保存 MCP",
"configImport": "导入 mcp.json",
"importConfig": "导入",
"restartRequired": "重启 nanobot 以连接更新后的 MCP 工具。",
"toolsFound": "{{count}} 个工具",
"loading": "正在加载 MCP 预设...",
"empty": "没有匹配的 MCP 预设。",
"openDocs": "打开文档",
"test": "测试",
"remove": "移除",
"enable": "启用",
"enabled": "已启用",
"setup": "连接",
"configure": "连接",
"connectTitle": "连接 {{name}}",
"connectHint": "填入你账户里的 key。",
"saveAndEnable": "保存并启用",
"updateSetup": "更新配置",
"configured": "已配置",
"keepExisting": "留空则保留当前值",
"statusConfigured": "已配置",
"statusMissingCredentials": "需要 key",
"statusMissingDependency": "缺少依赖",
"statusComingSoon": "暂不支持",
"statusNotInstalled": "未启用",
"toolScope": "工具",
"allTools": "全部",
"noTools": "不暴露",
"testForTools": "运行测试后,可以查看并选择单个工具。"
},
"values": {
"light": "浅色",
"dark": "深色",
@@ -371,8 +284,7 @@
},
"providers": {
"searchPlaceholder": "搜索服务商",
"noMatches": "没有匹配的服务商。",
"saveProvider": "保存服务商"
"noMatches": "没有匹配的服务商。"
},
"legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
@@ -383,20 +295,6 @@
"selectSize": "选择尺寸",
"configureProvider": "配置服务商",
"missingCredential": "启用图片生成前,请先配置这个服务商。"
},
"apps": {
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "全部",
"filterCli": "CLI 应用",
"filterMcp": "MCP 服务",
"enabledSummary": "已启用 {{count}} 个",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜索应用",
"featured": "精选",
"loading": "正在加载应用...",
"empty": "没有符合筛选条件的应用。"
}
},
"chat": {
@@ -550,16 +448,6 @@
"navigateHint": "↑↓ 选择",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 关闭",
"badges": {
"current": "当前",
"recent": "最近"
},
"details": {
"goalActive": "目标正在运行",
"goalReady": "开始一个持续目标",
"history": "查看最近消息",
"stopRunning": "正在运行"
},
"commands": {
"new": {
"title": "新建对话",
@@ -577,10 +465,6 @@
"title": "查看状态",
"description": "显示运行时、服务商和通道状态。"
},
"model": {
"title": "模型",
"description": "查看或切换当前模型预设。"
},
"history": {
"title": "查看对话历史",
"description": "打印最近 N 条已持久化的对话消息。"
@@ -604,22 +488,12 @@
"help": {
"title": "查看帮助",
"description": "列出可用的斜杠命令。"
},
"pairing": {
"title": "配对",
"description": "管理配对请求。"
}
}
},
"mentions": {
"ariaLabel": "应用",
"label": "应用",
"cliGroup": "CLI 应用",
"mcpGroup": "MCP 服务",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "使用 @{{name}} 调用本地 CLI",
"mcpDescription": "使用 @{{name}} 调用 MCP 服务"
"ariaLabel": "CLI 应用",
"label": "CLI 应用"
},
"encoding": "处理中…",
"remove": "移除附件",
@@ -653,18 +527,15 @@
"agentActivityToolsOnly": "{{tools}} 次工具调用",
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
"activityThinkingFor": "思考中 {{duration}}",
"activityThought": "已思考",
"activityThoughtFor": "思考了 {{duration}}",
"cliActivityRunningOne": "正在使用 @{{name}}",
"cliActivityRanOne": "已使用 @{{name}}",
"cliActivityFailedOne": "使用 @{{name}} 失败",
"cliActivityRunningMany": "正在使用 {{count}} 个 CLI 应用",
"cliActivityRanMany": "已使用 {{count}} 个 CLI 应用",
"cliActivityFailedMany": "{{count}} 个 CLI 用失败",
"cliRunRunning": "正在使用",
"cliRunRan": "已使用",
"cliRunFailed": "失败",
"cliActivityRunningOne": "正在运行 CLI @{{name}}",
"cliActivityRanOne": "已运行 CLI @{{name}}",
"cliActivityFailedOne": "CLI 调用失败 @{{name}}",
"cliActivityRunningMany": "正在运行 {{count}} 个 CLI",
"cliActivityRanMany": "已运行 {{count}} 个 CLI",
"cliActivityFailedMany": "{{count}} 个 CLI 调用失败",
"cliRunRunning": "正在运行 CLI",
"cliRunRan": "已运行 CLI",
"cliRunFailed": "CLI 用失败",
"imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复",
+12 -181
View File
@@ -63,8 +63,7 @@
"language": {
"label": "語言",
"ariaLabel": "切換語言"
},
"apps": "應用"
}
},
"settings": {
"backToChat": "返回對話",
@@ -82,10 +81,7 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "CLI 應用",
"mcp": "MCP",
"apps": "應用"
"advanced": "Advanced"
},
"sections": {
"interface": "介面",
@@ -101,10 +97,7 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "功能",
"integrations": "Integrations",
"cliApps": "CLI 應用",
"mcp": "MCP 服務",
"apps": "應用"
"integrations": "Integrations"
},
"rows": {
"theme": "主題",
@@ -148,11 +141,7 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"currentModel": "目前模型",
"brandLogos": "品牌標誌",
"cliAppsCatalog": "CLI 應用目錄",
"cliAppsFilter": "CLI 應用篩選"
"configurationDocs": "Configuration docs"
},
"help": {
"theme": "在淺色與深色外觀之間切換。",
@@ -179,13 +168,7 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"currentModel": "選擇 nanobot 接下來回覆時使用的模型。",
"selectedModelProvider": "由目前模型決定。",
"selectedModelValue": "由目前模型決定。",
"brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
"cliAppsCatalog": "瀏覽 nanobot 可在本機執行的應用 CLI。",
"cliAppsFilter": "按應用、分類或能力搜尋。"
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "淺色",
@@ -202,7 +185,9 @@
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"restartRequired": "Restart required",
"liveReload": "Live reload ready"
},
"status": {
"loading": "正在載入設定...",
@@ -274,8 +259,7 @@
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"saveProvider": "儲存服務商"
"noMatches": "No providers match this search."
},
"image": {
"selectProvider": "選擇服務商",
@@ -283,120 +267,6 @@
"selectSize": "選擇尺寸",
"configureProvider": "設定服務商",
"missingCredential": "啟用圖片生成前,請先設定此服務商。"
},
"models": {
"selectModel": "選擇模型",
"addConfiguration": "新增設定",
"newConfiguration": "新增模型設定",
"newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。",
"configurationName": "名稱",
"configurationNamePlaceholder": "快速寫作"
},
"timezone": {
"select": "選擇時區",
"search": "搜尋時區",
"empty": "沒有符合的時區。"
},
"cliApps": {
"allCategories": "全部分類",
"availableCount": "{{count}} 個應用",
"installedCount": "已安裝 {{count}} 個",
"summary": "已安裝 {{installed}} / {{total}} 個 CLI",
"filterAll": "全部",
"filterInstalled": "已安裝的 CLI",
"filterNotInstalled": "未安裝",
"searchPlaceholder": "搜尋 CLI",
"statusInstalled": "已安裝",
"statusAvailable": "可用",
"statusMissing": "缺少相依項",
"statusUnsupported": "不支援",
"statusNotInstalled": "未安裝",
"unsupported": "不支援",
"loading": "正在載入 CLI 應用...",
"empty": "沒有符合此篩選條件的 CLI 應用。",
"readyTitle": "@{{name}} 已就緒",
"readyStatus": "就緒",
"readyPrompt": "使用 @{{name}} 查看這個 CLI 能做什麼。",
"readyTry": "試用 @{{name}}",
"readyCopied": "已複製",
"openChat": "開啟聊天",
"requires": "需要",
"test": "測試 CLI",
"update": "更新 CLI",
"uninstall": "解除安裝 CLI",
"install": "安裝 CLI",
"unavailable": "不可用",
"noDescription": "暫無描述。"
},
"mcp": {
"allCategories": "全部分類",
"summary": "已啟用 {{installed}} / {{total}} 個預設",
"filterAll": "全部",
"filterInstalled": "已啟用",
"filterNotInstalled": "未啟用",
"searchPlaceholder": "搜尋 MCP 預設",
"moreOptions": "更多 MCP 選項",
"moreOptionsSubtitle": "新增自訂服務,或匯入 mcp.json。",
"customTitle": "自訂 MCP",
"customSubtitle": "新增任意 stdio、HTTP 或 SSE MCP 服務。",
"customAction": "自訂",
"importAction": "匯入",
"serverName": "服務名稱",
"serverUrl": "URL",
"transport": "傳輸方式",
"command": "指令",
"args": "Args JSON",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "工具逾時",
"advancedOptions": "進階選項",
"hideAdvanced": "隱藏進階",
"saveCustom": "儲存 MCP",
"configImport": "匯入 mcp.json",
"importConfig": "匯入",
"restartRequired": "重新啟動 nanobot 以連接更新後的 MCP 工具。",
"toolsFound": "{{count}} 個工具",
"loading": "正在載入 MCP 預設...",
"empty": "沒有符合此篩選條件的 MCP 預設。",
"openDocs": "開啟文件",
"test": "測試",
"remove": "移除",
"enable": "啟用",
"enabled": "已啟用",
"setup": "連接",
"configure": "連接",
"connectTitle": "連接 {{name}}",
"connectHint": "從你的帳號設定中加入金鑰。",
"saveAndEnable": "儲存並啟用",
"updateSetup": "更新設定",
"configured": "已設定",
"keepExisting": "留空以保留目前值",
"statusConfigured": "已設定",
"statusMissingCredentials": "需要金鑰",
"statusMissingDependency": "需要相依項",
"statusComingSoon": "即將推出",
"statusNotInstalled": "未啟用",
"toolScope": "工具",
"allTools": "全部",
"noTools": "無",
"testForTools": "執行測試以檢查並選擇個別工具。"
},
"legal": {
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
},
"apps": {
"description": "新增 nanobot 可在聊天中使用的 App CLI 與 MCP 服務。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "全部",
"filterCli": "CLI 應用",
"filterMcp": "MCP 服務",
"enabledSummary": "已啟用 {{count}} 個",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜尋應用",
"featured": "精選",
"loading": "正在載入應用...",
"empty": "沒有符合篩選條件的應用。"
}
},
"chat": {
@@ -500,7 +370,8 @@
"title": "編輯圖片",
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
}
}
},
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
},
"header": {
"toggleSidebar": "切換側邊欄",
@@ -568,10 +439,6 @@
"title": "查看狀態",
"description": "顯示執行環境、provider 和 channel 狀態。"
},
"model": {
"title": "模型",
"description": "查看或切換目前模型預設。"
},
"history": {
"title": "查看對話歷史",
"description": "列印最近 N 則已持久化的對話訊息。"
@@ -595,21 +462,7 @@
"help": {
"title": "查看說明",
"description": "列出可用的斜線命令。"
},
"pairing": {
"title": "配對",
"description": "管理配對請求。"
}
},
"badges": {
"current": "目前",
"recent": "最近"
},
"details": {
"goalActive": "目標正在執行",
"goalReady": "開始一個持續目標",
"history": "查看最近訊息",
"stopRunning": "正在執行"
}
},
"encoding": "處理中…",
@@ -622,16 +475,6 @@
"decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一張小一點的",
"io": "無法讀取這個檔案"
},
"mentions": {
"ariaLabel": "應用",
"label": "應用",
"cliGroup": "CLI 應用",
"mcpGroup": "MCP 服務",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "使用 @{{name}} 呼叫本機 CLI",
"mcpDescription": "使用 @{{name}} 呼叫 MCP 服務"
}
},
"scrollToBottom": "捲動到底部",
@@ -656,19 +499,7 @@
"imageAttachment": "圖片附件",
"copyReply": "複製回覆",
"copiedReply": "已複製回覆",
"turnLatencyTitle": "本輪耗時(端到端)",
"activityThinkingFor": "思考中,已 {{duration}}",
"activityThought": "已思考",
"activityThoughtFor": "已思考 {{duration}}",
"cliActivityRunningOne": "正在使用 @{{name}}",
"cliActivityRanOne": "已使用 @{{name}}",
"cliActivityFailedOne": "@{{name}} 失敗",
"cliActivityRunningMany": "正在使用 {{count}} 個 CLI 應用",
"cliActivityRanMany": "已使用 {{count}} 個 CLI 應用",
"cliActivityFailedMany": "{{count}} 個 CLI 應用失敗",
"cliRunRunning": "使用中",
"cliRunRan": "已使用",
"cliRunFailed": "失敗"
"turnLatencyTitle": "本輪耗時(端到端)"
},
"lightbox": {
"title": "圖片預覽",
-94
View File
@@ -2,8 +2,6 @@ import type {
ChatSummary,
CliAppsPayload,
ImageGenerationSettingsUpdate,
McpPresetsPayload,
ModelConfigurationCreate,
ProviderSettingsUpdate,
SettingsPayload,
SettingsUpdate,
@@ -41,21 +39,6 @@ async function request<T>(
return (await res.json()) as T;
}
function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefined {
const payload: Record<string, unknown> = {};
Object.entries(values).forEach(([key, value]) => {
if (value === null || value === undefined) return;
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed) payload[key] = trimmed;
return;
}
payload[key] = value;
});
if (!Object.keys(payload).length) return undefined;
return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
}
function splitKey(key: string): { channel: string; chatId: string } {
const idx = key.indexOf(":");
if (idx === -1) return { channel: "", chatId: key };
@@ -142,66 +125,6 @@ export async function runCliAppAction(
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
}
export async function fetchMcpPresets(
token: string,
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(`${base}/api/settings/mcp-presets`, token);
}
export async function runMcpPresetAction(
token: string,
action: "enable" | "remove" | "test",
name: string,
values: Record<string, string> = {},
base: string = "",
): Promise<McpPresetsPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/${action}?${query}`,
token,
{ headers: mcpValuesHeader(values) },
);
}
export async function saveCustomMcpServer(
token: string,
values: Record<string, string>,
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/custom`,
token,
{ headers: mcpValuesHeader(values) },
);
}
export async function importMcpConfig(
token: string,
config: string,
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/import`,
token,
{ headers: mcpValuesHeader({ config }) },
);
}
export async function updateMcpServerTools(
token: string,
name: string,
enabledTools: string[],
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/tools`,
token,
{ headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) },
);
}
export async function listSlashCommands(
token: string,
base: string = "",
@@ -265,22 +188,6 @@ export async function updateSettings(
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
}
export async function createModelConfiguration(
token: string,
configuration: ModelConfigurationCreate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
if (configuration.name !== undefined) query.set("name", configuration.name);
query.set("label", configuration.label);
query.set("provider", configuration.provider);
query.set("model", configuration.model);
return request<SettingsPayload>(
`${base}/api/settings/model-configurations/create?${query}`,
token,
);
}
export async function updateProviderSettings(
token: string,
update: ProviderSettingsUpdate,
@@ -290,7 +197,6 @@ export async function updateProviderSettings(
query.set("provider", update.provider);
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
if (update.apiBase !== undefined) query.set("api_base", update.apiBase);
if (update.apiType !== undefined) query.set("api_type", update.apiType);
return request<SettingsPayload>(
`${base}/api/settings/provider/update?${query}`,
token,

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