mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1250f0d16 | ||
|
|
376d82fec7 | ||
|
|
8dd35c373a | ||
|
|
418cb23da2 | ||
|
|
179acfe104 | ||
|
|
cfabc29f74 | ||
|
|
92f2ff3a33 | ||
|
|
c433d60681 | ||
|
|
d472595417 | ||
|
|
92915ea424 | ||
|
|
3f0098839e | ||
|
|
c4e2fcaf0c | ||
|
|
8fedee276b | ||
|
|
547f81e4aa | ||
|
|
00a6e720dc | ||
|
|
6ea7a6a2ac | ||
|
|
704ac558f6 | ||
|
|
8be258212e | ||
|
|
c9ff64fc0f | ||
|
|
9efdce276f | ||
|
|
7a6cc657db | ||
|
|
ec99232208 | ||
|
|
43a1784c5f | ||
|
|
3d3ef586e7 | ||
|
|
ef2ef4f789 | ||
|
|
5b71f61f55 | ||
|
|
5937236f9d | ||
|
|
192d2af19d | ||
|
|
3e6f9907fe |
@@ -98,3 +98,4 @@ tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
exp/
|
||||
.playwright-mcp/
|
||||
|
||||
+104
-2
@@ -126,8 +126,10 @@ 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 |
|
||||
|----------|---------|-------------|
|
||||
@@ -166,6 +168,43 @@ 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>
|
||||
|
||||
@@ -477,6 +516,68 @@ 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>
|
||||
|
||||
@@ -956,7 +1057,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 is auto-resolved from the matching provider config. |
|
||||
| `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. |
|
||||
| `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
|
||||
@@ -1195,7 +1296,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 provider credentials from `providers.openrouter` or `providers.aihubmix`.
|
||||
Image generation is configured under `tools.imageGeneration` and uses credentials from the selected provider's `providers.<name>` block.
|
||||
|
||||
See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting.
|
||||
|
||||
@@ -1288,6 +1389,7 @@ 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. |
|
||||
|
||||
|
||||
@@ -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, and StepFun configuration examples.
|
||||
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu 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` |
|
||||
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
|
||||
| `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,6 +245,31 @@ 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:
|
||||
@@ -299,8 +324,7 @@ 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`, or `stepfun` |
|
||||
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
|
||||
| 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 |
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ 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,
|
||||
@@ -19,6 +23,32 @@ 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."""
|
||||
|
||||
|
||||
+278
-27
@@ -8,17 +8,25 @@ 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 Consolidator, Dream
|
||||
from nanobot.agent.memory import (
|
||||
_STALE_THRESHOLD_DAYS,
|
||||
Consolidator,
|
||||
Dream,
|
||||
_estimate_tokens,
|
||||
_strip_skip_lines,
|
||||
)
|
||||
from nanobot.agent.progress_hook import AgentProgressHook
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
@@ -28,13 +36,15 @@ 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 (
|
||||
@@ -47,7 +57,11 @@ 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.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
from nanobot.utils.prompt_templates import _TEMPLATES_ROOT, render_template
|
||||
from nanobot.utils.runtime import (
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||
SUSTAINED_GOAL_CONTINUE_PROMPT,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.config.schema import (
|
||||
@@ -164,6 +178,7 @@ 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,
|
||||
@@ -190,6 +205,7 @@ 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
|
||||
|
||||
@@ -202,6 +218,7 @@ 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()
|
||||
@@ -262,6 +279,7 @@ 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
|
||||
@@ -308,6 +326,7 @@ 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
|
||||
@@ -347,6 +366,7 @@ 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,
|
||||
@@ -366,6 +386,7 @@ 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,
|
||||
)
|
||||
|
||||
@@ -391,7 +412,7 @@ class AgentLoop:
|
||||
self.runner.provider = provider
|
||||
self.subagents.set_provider(provider, model)
|
||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||
self.dream.set_provider(provider, model)
|
||||
self._configure_dream()
|
||||
self._provider_signature = snapshot.signature
|
||||
if publish_update and self._runtime_model_publisher is not None:
|
||||
self._runtime_model_publisher(
|
||||
@@ -400,6 +421,20 @@ 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
|
||||
@@ -476,26 +511,8 @@ class AgentLoop:
|
||||
logger.info("Registered {} tools: {}", len(registered), registered)
|
||||
|
||||
async def _connect_mcp(self) -> None:
|
||||
"""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
|
||||
"""Connect configured MCP servers."""
|
||||
await agent_context.connect_mcp(self, self.tools)
|
||||
|
||||
def _set_tool_context(
|
||||
self, channel: str, chat_id: str,
|
||||
@@ -568,7 +585,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 {}) | cli_app_utils.session_extra(msg.metadata)
|
||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
||||
extra.update(kwargs)
|
||||
text = msg.content if isinstance(msg.content, str) else ""
|
||||
session.add_message("user", text, **extra)
|
||||
@@ -593,7 +610,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=cli_app_utils.runtime_lines(msg, self.context.workspace),
|
||||
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace),
|
||||
)
|
||||
|
||||
async def _dispatch_command_inline(
|
||||
@@ -744,6 +761,15 @@ 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,
|
||||
@@ -771,6 +797,8 @@ 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)
|
||||
@@ -811,6 +839,8 @@ 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(
|
||||
@@ -1019,6 +1049,28 @@ 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):
|
||||
@@ -1058,7 +1110,7 @@ class AgentLoop:
|
||||
current_role=current_role,
|
||||
sender_id=msg.sender_id,
|
||||
session_summary=pending,
|
||||
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
|
||||
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace, skip=is_subagent),
|
||||
)
|
||||
t_wall = time.time()
|
||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||
@@ -1095,6 +1147,205 @@ 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,
|
||||
|
||||
+168
-191
@@ -6,6 +6,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import weakref
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
@@ -15,7 +16,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
|
||||
import tiktoken
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.utils.gitstore import GitStore
|
||||
@@ -33,6 +34,20 @@ 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
|
||||
@@ -400,6 +415,26 @@ 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
|
||||
@@ -618,19 +653,21 @@ 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) -> str:
|
||||
"""Truncate text so it fits within the consolidation LLM's token budget."""
|
||||
budget = self._input_token_budget
|
||||
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
|
||||
if budget <= 0:
|
||||
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
||||
try:
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
tokens = enc.encode(text)
|
||||
if _TIKTOKEN_ENC is not None:
|
||||
tokens = _TIKTOKEN_ENC.encode(text)
|
||||
if len(tokens) <= budget:
|
||||
return text
|
||||
return enc.decode(tokens[:budget]) + "\n... (truncated)"
|
||||
except Exception:
|
||||
return truncate_text(text, budget * 4)
|
||||
return _TIKTOKEN_ENC.decode(tokens[:budget]) + "\n... (truncated)"
|
||||
return truncate_text(text, budget * 4)
|
||||
|
||||
async def archive(self, messages: list[dict]) -> str | None:
|
||||
"""Summarize messages via LLM and append to history.jsonl.
|
||||
@@ -639,9 +676,53 @@ class Consolidator:
|
||||
"""
|
||||
if not messages:
|
||||
return None
|
||||
t_start = time.perf_counter()
|
||||
try:
|
||||
formatted = MemoryStore._format_messages(messages)
|
||||
formatted = self._truncate_to_token_budget(formatted)
|
||||
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),
|
||||
)
|
||||
|
||||
response = await self.provider.chat_with_retry(
|
||||
model=self.model,
|
||||
messages=[
|
||||
@@ -652,18 +733,31 @@ class Consolidator:
|
||||
strip=True,
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": formatted},
|
||||
{"role": "user", "content": formatted + dedup_context},
|
||||
],
|
||||
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:
|
||||
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
||||
elapsed = time.perf_counter() - t_start
|
||||
logger.warning(
|
||||
"Consolidation LLM call failed after {:.1f}s, raw-dumping to history",
|
||||
elapsed,
|
||||
)
|
||||
self.store.raw_archive(messages)
|
||||
return None
|
||||
|
||||
@@ -851,38 +945,48 @@ class Consolidator:
|
||||
|
||||
|
||||
# Single source of truth for the staleness threshold used in _annotate_with_ages
|
||||
# *and* in the Phase 1 prompt template (passed as `stale_threshold_days`).
|
||||
# *and* in the system 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:
|
||||
"""Two-phase memory processor: analyze history.jsonl, then edit files via AgentRunner.
|
||||
"""Single-phase memory processor: analyze history.jsonl and edit files via AgentRunner.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
|
||||
# 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 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
|
||||
# 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
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: MemoryStore,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
max_batch_size: int = 20,
|
||||
max_batch_size: int = 5,
|
||||
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
|
||||
@@ -890,10 +994,13 @@ 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 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).
|
||||
# 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).
|
||||
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()
|
||||
|
||||
@@ -907,6 +1014,7 @@ 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
|
||||
|
||||
@@ -924,6 +1032,7 @@ 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"
|
||||
@@ -933,15 +1042,25 @@ class Dream:
|
||||
|
||||
# -- skill listing --------------------------------------------------------
|
||||
|
||||
def _list_existing_skills(self) -> list[str]:
|
||||
"""List existing skills as 'name — description' for dedup context."""
|
||||
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.
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
desc_re = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
|
||||
entries: dict[str, str] = {}
|
||||
for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
|
||||
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):
|
||||
if not base.exists():
|
||||
continue
|
||||
for d in base.iterdir():
|
||||
@@ -951,18 +1070,31 @@ class Dream:
|
||||
if not skill_md.exists():
|
||||
continue
|
||||
# Prefer workspace skills over builtin (same name)
|
||||
if d.name in entries and base == BUILTIN_SKILLS_DIR:
|
||||
if d.name in entries and base == builtin_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)"
|
||||
entries[d.name] = desc
|
||||
return [f"{name} — {desc}" for name, desc in sorted(entries.items())]
|
||||
|
||||
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())]
|
||||
|
||||
# -- main entry ----------------------------------------------------------
|
||||
|
||||
def _annotate_with_ages(self, content: str) -> str:
|
||||
"""Append per-line age suffixes to MEMORY.md content.
|
||||
def _annotate_with_ages(self, content: str, file_path: str = "memory/MEMORY.md") -> str:
|
||||
"""Append per-line age suffixes to file content.
|
||||
|
||||
Each non-blank line whose age exceeds ``_STALE_THRESHOLD_DAYS`` gets a
|
||||
suffix like ``← 30d`` indicating days since last modification.
|
||||
@@ -970,9 +1102,7 @@ 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:
|
||||
@@ -1007,156 +1137,3 @@ 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
|
||||
|
||||
+10
-1
@@ -8,7 +8,7 @@ import os
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -42,6 +42,7 @@ 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,
|
||||
@@ -97,6 +98,8 @@ 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)
|
||||
@@ -167,6 +170,7 @@ 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).
|
||||
|
||||
@@ -178,6 +182,10 @@ 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
|
||||
@@ -475,6 +483,7 @@ class AgentRunner:
|
||||
spec, messages, assistant_message, injection_cycles,
|
||||
phase="after final response",
|
||||
iteration=iteration,
|
||||
allow_goal_continue=True,
|
||||
)
|
||||
if should_continue:
|
||||
had_injections = True
|
||||
|
||||
@@ -79,6 +79,7 @@ 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()
|
||||
@@ -95,7 +96,11 @@ class SubagentManager:
|
||||
if max_iterations is not None
|
||||
else defaults.max_tool_iterations
|
||||
)
|
||||
self.max_concurrent_subagents = defaults.max_concurrent_subagents
|
||||
self.max_concurrent_subagents = (
|
||||
max_concurrent_subagents
|
||||
if max_concurrent_subagents is not None
|
||||
else 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]] = {}
|
||||
@@ -140,6 +145,7 @@ 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]
|
||||
@@ -155,7 +161,9 @@ 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)
|
||||
self._run_subagent(
|
||||
task_id, task, display_label, origin, status, origin_message_id, temperature
|
||||
)
|
||||
)
|
||||
self._running_tasks[task_id] = bg_task
|
||||
if session_key:
|
||||
@@ -182,6 +190,7 @@ 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)
|
||||
@@ -208,6 +217,7 @@ 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),
|
||||
|
||||
@@ -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.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
|
||||
|
||||
@@ -53,14 +53,15 @@ class _ExecSession:
|
||||
process: asyncio.subprocess.Process,
|
||||
command: str,
|
||||
cwd: str,
|
||||
timeout: int,
|
||||
timeout: int | None,
|
||||
) -> None:
|
||||
self.session_id = session_id
|
||||
self.process = process
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.started_at = time.monotonic()
|
||||
self.deadline = time.monotonic() + timeout
|
||||
# timeout None/0 means no limit; an infinite deadline is never reached.
|
||||
self.deadline = time.monotonic() + timeout if timeout else float("inf")
|
||||
self.last_access = time.monotonic()
|
||||
self._chunks: list[str] = []
|
||||
self._lock = asyncio.Lock()
|
||||
@@ -169,7 +170,7 @@ class ExecSessionManager:
|
||||
command: str,
|
||||
cwd: str,
|
||||
env: dict[str, str],
|
||||
timeout: int,
|
||||
timeout: int | None,
|
||||
shell_program: str | None,
|
||||
login: bool,
|
||||
yield_time_ms: int,
|
||||
|
||||
+279
-1
@@ -6,13 +6,20 @@ import re
|
||||
import shutil
|
||||
import urllib.parse
|
||||
from contextlib import AsyncExitStack, suppress
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
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
|
||||
@@ -33,6 +40,7 @@ _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:
|
||||
@@ -503,6 +511,7 @@ 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":
|
||||
@@ -662,3 +671,272 @@ 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)
|
||||
|
||||
@@ -46,7 +46,7 @@ _WORKSPACE_BOUNDARY_NOTE = (
|
||||
class ExecToolConfig(Base):
|
||||
"""Shell exec tool configuration."""
|
||||
enable: bool = True
|
||||
timeout: int = 60
|
||||
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
|
||||
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
|
||||
timeout: int | None
|
||||
shell_program: str | None
|
||||
login: bool
|
||||
|
||||
@@ -324,6 +324,20 @@ 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,
|
||||
@@ -369,7 +383,7 @@ class ExecTool(Tool):
|
||||
command = wrap_command(self.sandbox, command, workspace, cwd)
|
||||
cwd = str(Path(workspace).resolve())
|
||||
|
||||
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
|
||||
effective_timeout = self._resolve_timeout(timeout)
|
||||
env = self._build_env()
|
||||
|
||||
if self.path_append:
|
||||
|
||||
@@ -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 StringSchema, tool_parameters_schema
|
||||
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
@@ -17,6 +17,15 @@ 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"],
|
||||
)
|
||||
)
|
||||
@@ -58,7 +67,13 @@ class SpawnTool(Tool, ContextAware):
|
||||
"and use a dedicated subdirectory when helpful."
|
||||
)
|
||||
|
||||
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
||||
async def execute(
|
||||
self,
|
||||
task: str,
|
||||
label: str | None = None,
|
||||
temperature: float | 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
|
||||
@@ -75,4 +90,5 @@ 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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Shared app protocol helpers."""
|
||||
|
||||
from nanobot.apps.protocol import APP_PROTOCOL_SCHEMA, app_manifest
|
||||
|
||||
__all__ = ["APP_PROTOCOL_SCHEMA", "app_manifest"]
|
||||
@@ -1,6 +1,6 @@
|
||||
"""CLI Apps integration helpers."""
|
||||
"""CLI app adapter for the unified Apps domain."""
|
||||
|
||||
from nanobot.cli_apps.service import (
|
||||
from nanobot.apps.cli.service import (
|
||||
CliAppError,
|
||||
CliAppManager,
|
||||
CliAppsRuntimeConfig,
|
||||
@@ -11,12 +11,14 @@ 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"
|
||||
@@ -139,7 +141,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
|
||||
|
||||
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
||||
"3mf": ("3mf.io", "#00A1DE"),
|
||||
"anygen": ("anygen.com", "#111827"),
|
||||
"anygen": ("anygen.io", "#111827"),
|
||||
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
||||
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
||||
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
||||
@@ -244,6 +246,29 @@ 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("-")
|
||||
|
||||
@@ -540,8 +565,86 @@ 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()
|
||||
@@ -581,7 +684,14 @@ class CliAppManager:
|
||||
prefix.extend(["--upgrade", "--force-reinstall"])
|
||||
return prefix + args
|
||||
|
||||
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
|
||||
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]
|
||||
uninstall_cmd = str(app.get("uninstall_cmd") or "")
|
||||
packages = _pip_uninstall_args_from_command(uninstall_cmd)
|
||||
if packages:
|
||||
@@ -619,14 +729,19 @@ class CliAppManager:
|
||||
raise CliAppError(f"unsupported {expected} command")
|
||||
return argv
|
||||
|
||||
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
|
||||
def _argv_for_action(
|
||||
self,
|
||||
app: dict[str, Any],
|
||||
action: str,
|
||||
installed_entry: dict[str, Any] | None = None,
|
||||
) -> 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)
|
||||
return self._pip_uninstall_argv(app, installed_entry=installed_entry)
|
||||
if strategy == "npm":
|
||||
return self._npm_argv(app, action)
|
||||
if strategy == "brew":
|
||||
@@ -648,13 +763,23 @@ class CliAppManager:
|
||||
)
|
||||
|
||||
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
entry_point = str(app.get("entry_point") or "")
|
||||
strategy = self._strategy(app)
|
||||
entry: dict[str, Any] = {
|
||||
"version": app.get("version") or "unknown",
|
||||
"entry_point": app.get("entry_point") or "",
|
||||
"entry_point": entry_point,
|
||||
"source": app.get("_source") or "harness",
|
||||
"strategy": self._strategy(app),
|
||||
"strategy": strategy,
|
||||
"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()
|
||||
@@ -730,11 +855,13 @@ 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]) -> None:
|
||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||
installed = self._load_installed()
|
||||
installed[str(app["name"])] = self._installed_entry(app)
|
||||
entry = self._installed_entry(app)
|
||||
installed[str(app["name"])] = entry
|
||||
self._save_installed(installed)
|
||||
self.install_skill(app)
|
||||
return entry
|
||||
|
||||
def install(self, name: str) -> dict[str, Any]:
|
||||
app = self.get_app(name)
|
||||
@@ -745,7 +872,14 @@ 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."}}
|
||||
return self.payload() | {
|
||||
"last_action": {
|
||||
"ok": True,
|
||||
"message": f"CLI for {app['display_name']} is available.",
|
||||
"installed": True,
|
||||
"verification": ["entry_point_available", "state_recorded"],
|
||||
}
|
||||
}
|
||||
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")
|
||||
@@ -754,7 +888,14 @@ 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']}."}}
|
||||
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"],
|
||||
}
|
||||
}
|
||||
|
||||
def update(self, name: str) -> dict[str, Any]:
|
||||
app = self.get_app(name, force_refresh=True)
|
||||
@@ -762,30 +903,94 @@ 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']}."}}
|
||||
return self.payload() | {
|
||||
"last_action": {
|
||||
"ok": True,
|
||||
"message": f"Checked {app['display_name']}.",
|
||||
"installed": True,
|
||||
"verification": ["state_recorded"],
|
||||
}
|
||||
}
|
||||
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']}."}}
|
||||
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"],
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
if self._strategy(app) != "bundled":
|
||||
argv = self._argv_for_action(app, "uninstall")
|
||||
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)
|
||||
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"]))
|
||||
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_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"],
|
||||
}
|
||||
}
|
||||
|
||||
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.cli_apps import CliAppManager
|
||||
from nanobot.apps.cli import CliAppManager
|
||||
|
||||
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
|
||||
except Exception:
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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,
|
||||
})
|
||||
@@ -9,6 +9,12 @@ 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:
|
||||
@@ -45,4 +51,3 @@ 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)
|
||||
|
||||
|
||||
+117
-75
@@ -30,6 +30,7 @@ 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
|
||||
@@ -46,6 +47,7 @@ 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,
|
||||
@@ -57,12 +59,32 @@ 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
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
@@ -233,6 +255,34 @@ 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)
|
||||
@@ -425,18 +475,6 @@ _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:
|
||||
@@ -477,10 +515,6 @@ 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.
|
||||
@@ -670,6 +704,9 @@ 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)
|
||||
|
||||
@@ -694,6 +731,13 @@ 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))
|
||||
@@ -885,6 +929,16 @@ 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")
|
||||
@@ -941,6 +995,31 @@ 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."""
|
||||
@@ -1032,6 +1111,9 @@ 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,
|
||||
@@ -1121,45 +1203,12 @@ 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:
|
||||
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""
|
||||
|
||||
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
|
||||
return rewrite_local_markdown_images(
|
||||
text,
|
||||
workspace_path=self._workspace_path,
|
||||
sign_path=self._sign_or_stage_media_path,
|
||||
)
|
||||
|
||||
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
|
||||
"""Serve a single media file previously signed via
|
||||
@@ -1532,10 +1581,12 @@ 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")
|
||||
@@ -1569,7 +1620,6 @@ 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()
|
||||
|
||||
@@ -1648,8 +1698,7 @@ class WebSocketChannel(BaseChannel):
|
||||
await self._safe_send_to(connection, raw, label=" ")
|
||||
return
|
||||
text = msg.content
|
||||
should_rewrite_images = msg.chat_id in self._webui_chats
|
||||
wire_text = self._rewrite_local_markdown_images(text) if should_rewrite_images else text
|
||||
wire_text = self._rewrite_local_markdown_images(text)
|
||||
payload: dict[str, Any] = {
|
||||
"event": "message",
|
||||
"chat_id": msg.chat_id,
|
||||
@@ -1749,32 +1798,25 @@ 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}
|
||||
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}
|
||||
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
|
||||
else:
|
||||
body = {
|
||||
"event": "delta",
|
||||
"chat_id": chat_id,
|
||||
"text": delta,
|
||||
}
|
||||
if should_rewrite_images:
|
||||
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
||||
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
||||
if meta.get("_stream_id") is not None:
|
||||
body["stream_id"] = meta["_stream_id"]
|
||||
if transcript_body is not None:
|
||||
transcript_body["stream_id"] = meta["_stream_id"]
|
||||
self._try_append_webui_transcript(chat_id, transcript_body or body)
|
||||
self._try_append_webui_transcript(chat_id, body)
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" stream ")
|
||||
|
||||
@@ -762,7 +762,7 @@ def _run_gateway(
|
||||
)
|
||||
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.events import InboundMessage, 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":
|
||||
try:
|
||||
await agent.dream.run()
|
||||
logger.info("Dream cron job completed")
|
||||
except Exception:
|
||||
logger.exception("Dream cron job failed")
|
||||
await bus.publish_inbound(InboundMessage(
|
||||
channel="system",
|
||||
sender_id="dream",
|
||||
chat_id="dream",
|
||||
content="",
|
||||
))
|
||||
return None
|
||||
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
@@ -1027,11 +1027,10 @@ 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",
|
||||
|
||||
+33
-24
@@ -299,30 +299,22 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Manually trigger a Dream consolidation run."""
|
||||
import time
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
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())
|
||||
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,
|
||||
},
|
||||
))
|
||||
return OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content="Dreaming...",
|
||||
channel=ctx.msg.channel,
|
||||
chat_id=ctx.msg.chat_id,
|
||||
content="Dream started. It will process memory backlog and report when done.",
|
||||
)
|
||||
|
||||
|
||||
@@ -355,6 +347,18 @@ 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",
|
||||
"",
|
||||
@@ -362,8 +366,12 @@ 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([
|
||||
"",
|
||||
@@ -389,7 +397,8 @@ def _format_dream_restore_list(commits: list) -> str:
|
||||
"",
|
||||
]
|
||||
for c in commits:
|
||||
lines.append(f"- `{c.sha}` {c.timestamp} - {c.message.splitlines()[0]}")
|
||||
summary = c.message.splitlines()[0] if c.message else "(no message)"
|
||||
lines.append(f"- `{c.sha}` {c.timestamp} - {summary}")
|
||||
lines.extend([
|
||||
"",
|
||||
"Preview a version with `/dream-log <sha>` before restoring it.",
|
||||
|
||||
@@ -10,10 +10,11 @@ import pydantic
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||
|
||||
# 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:
|
||||
@@ -39,6 +40,11 @@ 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()
|
||||
|
||||
@@ -52,14 +52,17 @@ class DreamConfig(Base):
|
||||
model_override: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
||||
) # 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.
|
||||
) # 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.
|
||||
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."""
|
||||
@@ -92,6 +95,7 @@ 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
|
||||
@@ -170,8 +174,9 @@ 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 fields merged into every request body
|
||||
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
|
||||
|
||||
|
||||
class BedrockProviderConfig(ProviderConfig):
|
||||
@@ -222,6 +227,16 @@ 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."""
|
||||
@@ -254,6 +269,7 @@ 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
|
||||
|
||||
@@ -98,6 +98,7 @@ 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()
|
||||
@@ -183,6 +184,7 @@ 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,
|
||||
@@ -199,6 +201,7 @@ 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,
|
||||
|
||||
@@ -1445,6 +1445,149 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1457,3 +1600,4 @@ 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)
|
||||
|
||||
@@ -274,6 +274,47 @@ 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.
|
||||
|
||||
@@ -289,12 +330,14 @@ 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)
|
||||
@@ -428,6 +471,10 @@ 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."""
|
||||
@@ -466,10 +513,13 @@ 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:
|
||||
@@ -685,8 +735,14 @@ 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
|
||||
@@ -700,7 +756,14 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if not wants:
|
||||
return False
|
||||
|
||||
# Circuit breaker: skip after repeated failures, probe periodically.
|
||||
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."""
|
||||
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
|
||||
failures = self._responses_failures.get(key, 0)
|
||||
if failures >= _RESPONSES_FAILURE_THRESHOLD:
|
||||
@@ -792,6 +855,10 @@ 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -956,7 +1023,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
args = json_repair.loads(args)
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
parsed_tool_calls.append(ToolCallRequest(
|
||||
id=_short_tool_id(),
|
||||
id=str(tc_map.get("id") or _short_tool_id()),
|
||||
name=str(fn.get("name") or ""),
|
||||
arguments=args if isinstance(args, dict) else {},
|
||||
extra_content=ec,
|
||||
@@ -999,7 +1066,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
args = json_repair.loads(args)
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=_short_tool_id(),
|
||||
id=str(getattr(tc, "id", None) or _short_tool_id()),
|
||||
name=tc.function.name,
|
||||
arguments=args,
|
||||
extra_content=ec,
|
||||
@@ -1262,6 +1329,8 @@ 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)
|
||||
@@ -1335,6 +1404,8 @@ 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)
|
||||
|
||||
@@ -7,6 +7,25 @@ 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.
|
||||
@@ -127,12 +146,12 @@ class OpenAITranscriptionProvider:
|
||||
language: str | None = None,
|
||||
):
|
||||
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
||||
self.api_url = (
|
||||
api_base
|
||||
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
|
||||
or "https://api.openai.com/v1/audio/transcriptions"
|
||||
self.api_url = _resolve_transcription_url(
|
||||
api_base or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL"),
|
||||
"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:
|
||||
@@ -166,12 +185,12 @@ class GroqTranscriptionProvider:
|
||||
language: str | None = None,
|
||||
):
|
||||
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
||||
self.api_url = (
|
||||
api_base
|
||||
or os.environ.get("GROQ_BASE_URL")
|
||||
or "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||
self.api_url = _resolve_transcription_url(
|
||||
api_base or os.environ.get("GROQ_BASE_URL"),
|
||||
"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:
|
||||
"""
|
||||
|
||||
@@ -27,6 +27,8 @@ _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:
|
||||
@@ -182,6 +184,28 @@ 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():
|
||||
@@ -621,9 +645,18 @@ 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
|
||||
|
||||
@@ -34,3 +34,5 @@ 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,13 +1,27 @@
|
||||
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
|
||||
Extract key facts from this conversation. For each fact, annotate its memory attributes.
|
||||
|
||||
Priority: user corrections and preferences > solutions > decisions > events > environment facts. The most valuable memory prevents the user from having to repeat themselves.
|
||||
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
|
||||
|
||||
Skip: code patterns derivable from source, git history, or anything already captured in existing memory.
|
||||
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.
|
||||
|
||||
Output as concise bullet points, one fact per line. No preamble, no commentary.
|
||||
If nothing noteworthy happened, output: (nothing)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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.
|
||||
@@ -1,40 +0,0 @@
|
||||
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.
|
||||
@@ -1,37 +0,0 @@
|
||||
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)"
|
||||
@@ -19,7 +19,8 @@ class CommitInfo:
|
||||
|
||||
def format(self, diff: str = "") -> str:
|
||||
"""Format this commit for display, optionally with a diff."""
|
||||
header = f"## {self.message.splitlines()[0]}\n`{self.sha}` — {self.timestamp}\n"
|
||||
summary = self.message.splitlines()[0] if self.message else "(no message)"
|
||||
header = f"## {summary}\n`{self.sha}` — {self.timestamp}\n"
|
||||
if diff:
|
||||
return f"{header}\n```diff\n{diff}\n```"
|
||||
return f"{header}\n(no file changes)"
|
||||
|
||||
@@ -29,6 +29,11 @@ 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."""
|
||||
@@ -65,6 +70,11 @@ 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":
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.apps.cli 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
@@ -0,0 +1,5 @@
|
||||
"""Compatibility exports for WebUI-attached MCP preset annotations."""
|
||||
|
||||
from nanobot.agent.tools.mcp import runtime_lines, session_extra
|
||||
|
||||
__all__ = ["runtime_lines", "session_extra"]
|
||||
@@ -6,10 +6,12 @@ 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,
|
||||
@@ -41,6 +43,7 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||
"2:3",
|
||||
"21:9",
|
||||
}
|
||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
|
||||
|
||||
class WebUISettingsError(ValueError):
|
||||
@@ -100,6 +103,32 @@ 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():
|
||||
@@ -152,17 +181,18 @@ 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
|
||||
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,
|
||||
}
|
||||
)
|
||||
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)
|
||||
|
||||
search_config = config.tools.web.search
|
||||
image_config = config.tools.image_generation
|
||||
@@ -198,7 +228,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
|
||||
model_presets.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": name,
|
||||
"label": preset.label or name,
|
||||
"active": active_preset_name == name,
|
||||
"is_default": False,
|
||||
"model": preset.model,
|
||||
@@ -277,6 +307,7 @@ 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,
|
||||
},
|
||||
@@ -321,15 +352,7 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||
provider = provider.strip()
|
||||
if not provider:
|
||||
raise WebUISettingsError("provider is required")
|
||||
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")
|
||||
_validate_configured_provider(config, provider)
|
||||
if defaults.provider != provider:
|
||||
defaults.provider = provider
|
||||
changed = True
|
||||
@@ -388,6 +411,40 @@ 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:
|
||||
@@ -416,6 +473,17 @@ 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
|
||||
|
||||
@@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -16,6 +18,61 @@ 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""
|
||||
|
||||
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
|
||||
|
||||
|
||||
def webui_transcript_path(session_key: str) -> Path:
|
||||
@@ -458,6 +515,11 @@ 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
|
||||
|
||||
|
||||
+397
-216
@@ -1,19 +1,32 @@
|
||||
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
|
||||
"""Tests for Dream driven through AgentLoop._process_system_message."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.agent.memory import Dream, MemoryStore
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
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")
|
||||
@@ -23,9 +36,7 @@ def store(tmp_path):
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
p = MagicMock()
|
||||
p.chat_with_retry = AsyncMock()
|
||||
return p
|
||||
return _provider("test-model")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -34,10 +45,16 @@ def mock_runner():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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 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 _make_run_result(
|
||||
@@ -56,254 +73,418 @@ def _make_run_result(
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
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
|
||||
mock_runner.run.assert_not_called()
|
||||
|
||||
async def test_calls_runner_for_unprocessed_entries(self, dream, mock_provider, mock_runner, store):
|
||||
async def test_calls_runner_for_unprocessed_entries(self, loop, mock_runner, store):
|
||||
"""Dream should call AgentRunner when there are unprocessed history entries."""
|
||||
store.append_history("User prefers dark mode")
|
||||
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 = 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_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, dream, mock_provider, mock_runner, store):
|
||||
async def test_advances_dream_cursor(self, loop, 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())
|
||||
await dream.run()
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
assert store.get_last_dream_cursor() == 2
|
||||
|
||||
async def test_compacts_processed_history(self, dream, mock_provider, mock_runner, store):
|
||||
async def test_compacts_processed_history(self, loop, 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())
|
||||
await dream.run()
|
||||
# After Dream, cursor is advanced and 3, compact keeps last max_history_entries
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert all(e["cursor"] > 0 for e in entries)
|
||||
|
||||
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."""
|
||||
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):
|
||||
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())
|
||||
|
||||
await dream.run()
|
||||
|
||||
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"]
|
||||
expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
|
||||
assert expected in system_prompt
|
||||
|
||||
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",
|
||||
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=""
|
||||
)
|
||||
|
||||
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"
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
system_msg = spec.initial_messages[0]["content"]
|
||||
assert "N>14" in system_msg
|
||||
|
||||
|
||||
class TestDreamPromptCaps:
|
||||
"""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_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))
|
||||
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_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"]
|
||||
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
|
||||
|
||||
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.
|
||||
async def test_caps_huge_history_entry(self, loop, mock_runner, store):
|
||||
store.history_file.write_text(
|
||||
json.dumps({
|
||||
"cursor": 1,
|
||||
"timestamp": "2026-04-01 10:00",
|
||||
"content": "H" * (dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
||||
}) + "\n",
|
||||
json.dumps(
|
||||
{
|
||||
"cursor": 1,
|
||||
"timestamp": "2026-04-01 10:00",
|
||||
"content": "H" * (loop.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()
|
||||
|
||||
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 TestDreamTools:
|
||||
def test_apply_patch_tool_registered(self, loop):
|
||||
tool = loop.dream._tools.get("apply_patch")
|
||||
assert tool is not None
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -2,12 +2,39 @@
|
||||
|
||||
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:
|
||||
@@ -42,3 +69,152 @@ 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()
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""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
|
||||
@@ -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][11] is None
|
||||
assert fallback_signatures[0][12] is None
|
||||
|
||||
|
||||
# -- FallbackProvider tests --
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""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)
|
||||
@@ -292,3 +292,95 @@ 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,6 +56,20 @@ 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():
|
||||
|
||||
@@ -94,6 +94,39 @@ 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."""
|
||||
|
||||
@@ -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
|
||||
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
||||
|
||||
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
||||
|
||||
@@ -501,7 +501,6 @@ 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", "
|
||||
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
|
||||
@@ -534,7 +533,6 @@ 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",
|
||||
@@ -548,31 +546,6 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
|
||||
assert final["text"].startswith("
|
||||
|
||||
|
||||
@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",
|
||||
"",
|
||||
{"_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()
|
||||
@@ -1215,6 +1188,30 @@ 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"
|
||||
@@ -1282,7 +1279,10 @@ 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 == "deep"
|
||||
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.timezone == "Asia/Shanghai"
|
||||
assert saved.agents.defaults.bot_name == "Nano"
|
||||
assert saved.agents.defaults.bot_icon == "N"
|
||||
@@ -1351,6 +1351,37 @@ 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,6 +209,156 @@ 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
|
||||
|
||||
@@ -5,10 +5,11 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
|
||||
|
||||
def _write_cache(path: Path, registry: dict) -> None:
|
||||
@@ -146,6 +147,15 @@ 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"
|
||||
@@ -156,6 +166,33 @@ 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,
|
||||
@@ -179,6 +216,8 @@ 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"
|
||||
@@ -186,6 +225,49 @@ 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)
|
||||
|
||||
@@ -206,7 +288,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.cli_apps.service.httpx.get", fail_get)
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
|
||||
|
||||
assert manager._fetch_skill_content({
|
||||
"name": "evil",
|
||||
@@ -236,7 +318,7 @@ def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
|
||||
seen.append(url)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get)
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get)
|
||||
|
||||
content = manager._fetch_skill_content({
|
||||
"name": "gimp",
|
||||
@@ -293,6 +375,91 @@ 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(
|
||||
@@ -341,7 +508,7 @@ def test_run_installed_cli_uses_argv_without_shell(
|
||||
_seed_catalog(manager)
|
||||
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli_apps.service.shutil.which",
|
||||
"nanobot.apps.cli.service.shutil.which",
|
||||
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||
)
|
||||
|
||||
@@ -354,7 +521,7 @@ def test_run_installed_cli_uses_argv_without_shell(
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
||||
manager._save_installed(
|
||||
{
|
||||
"gimp": {
|
||||
@@ -380,7 +547,7 @@ def test_run_reports_created_artifacts(
|
||||
_seed_catalog(manager)
|
||||
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli_apps.service.shutil.which",
|
||||
"nanobot.apps.cli.service.shutil.which",
|
||||
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||
)
|
||||
|
||||
@@ -389,7 +556,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.cli_apps.service.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||
|
||||
result = manager.run("gimp", ["render"])
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.agent.tools.cli_apps import CliAppsTool
|
||||
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.apps.cli.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.cli_apps.service.shutil.which",
|
||||
"nanobot.apps.cli.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.cli_apps.service.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("nanobot.apps.cli.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.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
monkeypatch.setattr("nanobot.apps.cli.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.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
|
||||
tool = CliAppsTool(workspace=workspace)
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.cli_apps.service import CliAppManager
|
||||
from nanobot.cli_apps.utils import runtime_lines, session_extra
|
||||
from nanobot.apps.cli.service import CliAppManager
|
||||
from nanobot.apps.cli.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.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
manager = CliAppManager(workspace=tmp_path)
|
||||
manager._save_installed(
|
||||
{
|
||||
|
||||
@@ -113,6 +113,52 @@ 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")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
@@ -12,6 +14,40 @@ 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": {
|
||||
|
||||
@@ -9,6 +9,7 @@ from nanobot.providers.openai_compat_provider import (
|
||||
OpenAICompatProvider,
|
||||
_deep_merge,
|
||||
)
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _deep_merge unit tests
|
||||
@@ -185,6 +186,86 @@ 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,6 +20,7 @@ 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
|
||||
|
||||
@@ -18,6 +18,7 @@ from nanobot.providers.image_generation import (
|
||||
OpenAIImageGenerationClient,
|
||||
OpenRouterImageGenerationClient,
|
||||
StepFunImageGenerationClient,
|
||||
ZhipuImageGenerationClient,
|
||||
)
|
||||
|
||||
PNG_BYTES = (
|
||||
@@ -1027,3 +1028,102 @@ 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"],
|
||||
)
|
||||
|
||||
@@ -602,6 +602,7 @@ 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"}
|
||||
|
||||
@@ -994,7 +995,7 @@ def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None:
|
||||
assert kwargs["messages"][2]["role"] == "tool"
|
||||
|
||||
|
||||
def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
|
||||
def test_openai_compat_preserves_tool_call_ids_after_consecutive_assistant_messages() -> None:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
@@ -1016,6 +1017,34 @@ def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -
|
||||
{"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,6 +18,7 @@ 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
|
||||
@@ -27,6 +28,33 @@ 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)
|
||||
|
||||
@@ -8,7 +8,11 @@ from unittest.mock import AsyncMock, patch
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.providers.transcription import GroqTranscriptionProvider, OpenAITranscriptionProvider
|
||||
from nanobot.providers.transcription import (
|
||||
GroqTranscriptionProvider,
|
||||
OpenAITranscriptionProvider,
|
||||
_resolve_transcription_url,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -290,3 +294,37 @@ 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"
|
||||
|
||||
@@ -64,6 +64,7 @@ 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}"
|
||||
@@ -176,6 +177,7 @@ 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}"
|
||||
@@ -208,6 +210,7 @@ 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"
|
||||
|
||||
@@ -171,6 +171,23 @@ 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")
|
||||
|
||||
@@ -52,10 +52,17 @@ def _fake_mcp_module(
|
||||
)
|
||||
|
||||
class _FakeStdioServerParameters:
|
||||
def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
command: str,
|
||||
args: list[str],
|
||||
env: dict | None = None,
|
||||
cwd: str | 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:
|
||||
@@ -561,6 +568,32 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,6 +4,7 @@ import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from nanobot.agent.tools import (
|
||||
ArraySchema,
|
||||
@@ -16,7 +17,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
|
||||
from nanobot.agent.tools.shell import ExecTool, ExecToolConfig
|
||||
from nanobot.security.network import configure_ssrf_whitelist
|
||||
|
||||
|
||||
@@ -663,6 +664,26 @@ 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 ---
|
||||
|
||||
|
||||
|
||||
+10
-213
@@ -1,216 +1,13 @@
|
||||
"""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
|
||||
from nanobot.utils.gitstore import CommitInfo
|
||||
|
||||
|
||||
@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
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
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"}]
|
||||
@@ -0,0 +1,80 @@
|
||||
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"}]}
|
||||
@@ -0,0 +1,67 @@
|
||||
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"],
|
||||
}
|
||||
)
|
||||
@@ -6,10 +6,8 @@
|
||||
"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",
|
||||
@@ -165,16 +163,12 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -207,8 +201,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -223,8 +215,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -891,10 +881,6 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -13,10 +13,8 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
+24
-5
@@ -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 } from "@/components/settings/SettingsView";
|
||||
import { SettingsView, type SettingsSectionKey } 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";
|
||||
type ShellView = "chat" | "settings" | "apps";
|
||||
|
||||
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
|
||||
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
|
||||
@@ -325,6 +325,7 @@ 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);
|
||||
@@ -588,12 +589,20 @@ function Shell({
|
||||
[onSelectChat],
|
||||
);
|
||||
|
||||
const onOpenSettings = useCallback(() => {
|
||||
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
|
||||
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);
|
||||
@@ -711,6 +720,12 @@ 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");
|
||||
@@ -728,7 +743,9 @@ function Shell({
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
onOpenSettings,
|
||||
onOpenApps,
|
||||
onOpenSearch: onOpenSessionSearch,
|
||||
activeUtility: view === "apps" ? "apps" as const : null,
|
||||
onToggleArchived,
|
||||
onUpdateView: onUpdateSidebarView,
|
||||
pinnedKeys: sidebarState.pinned_keys,
|
||||
@@ -805,7 +822,7 @@ function Shell({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col",
|
||||
view === "settings" && "invisible pointer-events-none",
|
||||
view !== "chat" && "invisible pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<ThreadShell
|
||||
@@ -820,10 +837,12 @@ function Shell({
|
||||
hideSidebarToggleOnDesktop
|
||||
/>
|
||||
</div>
|
||||
{view === "settings" && (
|
||||
{view !== "chat" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<SettingsView
|
||||
theme={theme}
|
||||
initialSection={settingsInitialSection}
|
||||
showSidebar={view === "settings"}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
onModelNameChange={onModelNameChange}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { CliAppInfo } from "@/lib/types";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type { CliAppInfo, McpPresetInfo } 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 (
|
||||
@@ -19,6 +24,18 @@ 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[],
|
||||
@@ -55,22 +72,76 @@ 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 = splitCliAppMentionSegments(text, cliApps);
|
||||
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
|
||||
const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets);
|
||||
if (!segments.some((segment) => segment.kind === "cli" || segment.kind === "mcp")) return <>{text}</>;
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.kind === "text") {
|
||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||
}
|
||||
return (
|
||||
if (segment.kind === "cli") return (
|
||||
<CliAppMentionToken
|
||||
key={`cli-${segment.app.name}-${index}`}
|
||||
app={segment.app}
|
||||
@@ -78,6 +149,14 @@ export function CliAppMentionText({
|
||||
variant="message"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<McpPresetMentionToken
|
||||
key={`mcp-${segment.preset.name}-${index}`}
|
||||
preset={segment.preset}
|
||||
label={segment.text}
|
||||
variant="message"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
@@ -94,15 +173,20 @@ export function CliAppMentionToken({
|
||||
variant: "composer" | "message";
|
||||
isHero?: boolean;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const color = app.brand_color || "hsl(var(--primary))";
|
||||
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||
const showLogo = Boolean(app.logo_url) && !failed;
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
const showLogo = Boolean(logoUrl);
|
||||
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,
|
||||
@@ -124,10 +208,69 @@ export function CliAppMentionToken({
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={app.logo_url ?? ""}
|
||||
src={logoUrl ?? ""}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
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)}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function FileReferenceChip({
|
||||
data-testid={testId}
|
||||
aria-label={fullPath}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1 font-medium leading-[inherit]",
|
||||
"inline-flex max-w-full items-baseline gap-[0.28em] 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.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] 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.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] text-sky-500 dark:text-sky-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -195,16 +195,36 @@ function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
||||
}
|
||||
const label = fileKindLabel(kind);
|
||||
return (
|
||||
<span
|
||||
<svg
|
||||
aria-hidden
|
||||
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",
|
||||
)}
|
||||
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"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,22 @@ import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTurnLatency } from "@/lib/format";
|
||||
import type { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
UICliAppAttachment,
|
||||
UIMcpPresetAttachment,
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,6 +45,7 @@ export function MessageBubble({
|
||||
message,
|
||||
showAssistantCopyAction = true,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
}: MessageBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -45,6 +55,10 @@ export function MessageBubble({
|
||||
() => mergeCliMentionApps(cliApps, message.cliApps),
|
||||
[cliApps, message.cliApps],
|
||||
);
|
||||
const mentionMcpPresets = useMemo(
|
||||
() => mergeMcpMentionPresets(mcpPresets, message.mcpPresets),
|
||||
[mcpPresets, message.mcpPresets],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -96,7 +110,11 @@ export function MessageBubble({
|
||||
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
|
||||
)}
|
||||
>
|
||||
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
|
||||
<CliAppMentionText
|
||||
text={message.content}
|
||||
cliApps={mentionCliApps}
|
||||
mcpPresets={mentionMcpPresets}
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -166,6 +184,39 @@ 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,
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ 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);
|
||||
|
||||
@@ -46,7 +47,6 @@ export function SessionSearchDialog({
|
||||
);
|
||||
}, [normalizedQuery, open, sessions, titleOverrides]);
|
||||
const itemCount = sessionResults.length;
|
||||
const shortcutLabel = useMemo(getSearchShortcutLabel, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -65,6 +65,18 @@ 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);
|
||||
@@ -107,18 +119,18 @@ export function SessionSearchDialog({
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"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",
|
||||
"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]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t("sidebar.searchPlaceholder")}
|
||||
</DialogDescription>
|
||||
<div className="flex h-14 items-center gap-3 border-b border-border/60 px-5">
|
||||
<div className="flex h-[62px] shrink-0 items-center gap-3 border-b border-border px-[18px]">
|
||||
<Search
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
@@ -128,16 +140,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-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-[19px] font-normal leading-none text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<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 className="min-h-0 overflow-y-auto overscroll-contain p-2">
|
||||
<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"
|
||||
>
|
||||
<section>
|
||||
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
|
||||
<div className="px-2.5 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground">
|
||||
{sectionLabel}
|
||||
</div>
|
||||
|
||||
@@ -150,7 +162,7 @@ export function SessionSearchDialog({
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
<ul className="space-y-0.5">
|
||||
{sessionResults.map((session, index) => {
|
||||
const title = titleOverrides[session.key]?.trim() ||
|
||||
session.title?.trim() ||
|
||||
@@ -164,15 +176,18 @@ 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(
|
||||
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
|
||||
"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",
|
||||
highlighted
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
|
||||
? "bg-muted text-foreground"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -181,17 +196,17 @@ export function SessionSearchDialog({
|
||||
</span>
|
||||
{showPreview ? (
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[12px] leading-4",
|
||||
highlighted
|
||||
? "text-accent-foreground/70"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
className="block truncate text-[12px] leading-4 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>
|
||||
);
|
||||
@@ -221,13 +236,3 @@ 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";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
SquarePen,
|
||||
Blocks,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -41,7 +42,9 @@ 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;
|
||||
@@ -129,6 +132,13 @@ 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}
|
||||
@@ -201,12 +211,14 @@ function SidebarActionButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
active = false,
|
||||
className,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -214,14 +226,16 @@ 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
@@ -11,15 +11,17 @@ import {
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import {
|
||||
CliAppMentionToken,
|
||||
McpPresetMentionToken,
|
||||
cliAppInitials,
|
||||
splitCliAppMentionSegments,
|
||||
type CliAppMentionSegment,
|
||||
mcpPresetInitials,
|
||||
splitCapabilityMentionSegments,
|
||||
type CapabilityMentionSegment,
|
||||
} from "@/components/CliAppMentionText";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Square,
|
||||
SquarePen,
|
||||
@@ -48,7 +51,19 @@ import {
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
GoalStateWsPayload,
|
||||
McpPresetInfo,
|
||||
OutboundCliAppMention,
|
||||
OutboundMcpPresetMention,
|
||||
SlashCommand,
|
||||
} from "@/lib/types";
|
||||
import {
|
||||
inferProviderFromModelName,
|
||||
logoFallbackUrls,
|
||||
providerBrand,
|
||||
} from "@/lib/provider-brand";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||
@@ -67,9 +82,12 @@ 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;
|
||||
@@ -82,9 +100,11 @@ 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,
|
||||
@@ -97,7 +117,9 @@ 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 = 64;
|
||||
const SLASH_PALETTE_CHROME_PX = 12;
|
||||
const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents";
|
||||
const SLASH_RECENTS_LIMIT = 5;
|
||||
|
||||
type SlashPalettePlacement = "above" | "below";
|
||||
|
||||
@@ -112,10 +134,45 @@ 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;
|
||||
@@ -192,6 +249,19 @@ 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,
|
||||
@@ -394,9 +464,12 @@ export function ThreadComposer({
|
||||
placeholder,
|
||||
isStreaming = false,
|
||||
modelLabel = null,
|
||||
modelProvider = null,
|
||||
modelProviderLabel = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
imageMode: controlledImageMode,
|
||||
onImageModeChange,
|
||||
onStop,
|
||||
@@ -414,6 +487,7 @@ 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);
|
||||
@@ -498,26 +572,86 @@ export function ThreadComposer({
|
||||
return commandToken.toLowerCase();
|
||||
}, [disabled, slashMenuDismissed, value]);
|
||||
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
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[]>(() => {
|
||||
if (slashQuery === null) return [];
|
||||
return slashCommands
|
||||
const withDetails = visibleSlashCommands
|
||||
.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 ?? "",
|
||||
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
|
||||
defaultValue: "",
|
||||
}),
|
||||
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, {
|
||||
defaultValue: "",
|
||||
}),
|
||||
title,
|
||||
description,
|
||||
].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);
|
||||
}, [slashCommands, slashQuery, t]);
|
||||
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
|
||||
|
||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||
@@ -534,9 +668,9 @@ export function ThreadComposer({
|
||||
};
|
||||
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
||||
|
||||
const filteredCliApps = useMemo(() => {
|
||||
const filteredMentionCandidates = useMemo<MentionCandidate[]>(() => {
|
||||
if (!cliAppMention) return [];
|
||||
return cliApps
|
||||
const cliCandidates: MentionCandidate[] = cliApps
|
||||
.filter((app) => app.installed)
|
||||
.filter((app) => {
|
||||
const haystack = [
|
||||
@@ -548,16 +682,32 @@ export function ThreadComposer({
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(cliAppMention.query);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [cliAppMention, cliApps]);
|
||||
.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]);
|
||||
|
||||
const showCliAppMenu = filteredCliApps.length > 0;
|
||||
const showCliAppMenu = filteredMentionCandidates.length > 0;
|
||||
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
||||
const mentionSegments = useMemo(
|
||||
() => splitCliAppMentionSegments(value, cliApps),
|
||||
[cliApps, value],
|
||||
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets),
|
||||
[cliApps, mcpPresets, value],
|
||||
);
|
||||
const hasMentionDecorations = mentionSegments.some(
|
||||
(segment) => segment.kind === "cli" || segment.kind === "mcp",
|
||||
);
|
||||
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
|
||||
const activeCliMentionApps = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return mentionSegments.flatMap((segment) => {
|
||||
@@ -566,6 +716,14 @@ 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,
|
||||
@@ -586,10 +744,10 @@ export function ThreadComposer({
|
||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCliAppIndex >= filteredCliApps.length) {
|
||||
if (selectedCliAppIndex >= filteredMentionCandidates.length) {
|
||||
setSelectedCliAppIndex(0);
|
||||
}
|
||||
}, [filteredCliApps.length, selectedCliAppIndex]);
|
||||
}, [filteredMentionCandidates.length, selectedCliAppIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAnyPalette) return;
|
||||
@@ -640,7 +798,7 @@ export function ThreadComposer({
|
||||
window.removeEventListener("resize", updateLayout);
|
||||
document.removeEventListener("scroll", updateLayout, true);
|
||||
};
|
||||
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
|
||||
}, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aspectMenuOpen) return;
|
||||
@@ -686,20 +844,37 @@ 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();
|
||||
},
|
||||
[resizeTextarea],
|
||||
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
|
||||
);
|
||||
|
||||
const chooseCliApp = useCallback(
|
||||
(app: CliAppInfo) => {
|
||||
const chooseMentionCandidate = useCallback(
|
||||
(candidate: MentionCandidate) => {
|
||||
if (!cliAppMention) return;
|
||||
const suffix = value.slice(cliAppMention.end);
|
||||
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
|
||||
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
|
||||
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
||||
const nextCursor = cliAppMention.start + mention.length;
|
||||
setValue(next);
|
||||
@@ -736,8 +911,9 @@ export function ThreadComposer({
|
||||
}))
|
||||
: undefined;
|
||||
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
|
||||
const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload);
|
||||
const options: SendOptions | undefined =
|
||||
imageMode || attachedCliApps.length > 0
|
||||
imageMode || attachedCliApps.length > 0 || attachedMcpPresets.length > 0
|
||||
? {
|
||||
...(imageMode
|
||||
? {
|
||||
@@ -748,6 +924,7 @@ export function ThreadComposer({
|
||||
}
|
||||
: {}),
|
||||
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
|
||||
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
|
||||
}
|
||||
: undefined;
|
||||
onSend(trimmed, payload, options);
|
||||
@@ -760,25 +937,36 @@ export function ThreadComposer({
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(0);
|
||||
resizeTextarea();
|
||||
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
|
||||
}, [
|
||||
activeCliMentionApps,
|
||||
activeMcpPresetMentions,
|
||||
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) % filteredCliApps.length);
|
||||
setSelectedCliAppIndex((idx) => (idx + 1) % filteredMentionCandidates.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedCliAppIndex(
|
||||
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
|
||||
(idx) => (idx - 1 + filteredMentionCandidates.length) % filteredMentionCandidates.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
|
||||
chooseMentionCandidate(filteredMentionCandidates[selectedCliAppIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
@@ -894,12 +1082,12 @@ export function ThreadComposer({
|
||||
) : null}
|
||||
{showCliAppMenu ? (
|
||||
<CliAppMentionPalette
|
||||
apps={filteredCliApps}
|
||||
candidates={filteredMentionCandidates}
|
||||
selectedIndex={selectedCliAppIndex}
|
||||
layout={slashPaletteLayout}
|
||||
isHero={isHero}
|
||||
onHover={setSelectedCliAppIndex}
|
||||
onChoose={chooseCliApp}
|
||||
onChoose={chooseMentionCandidate}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
@@ -947,7 +1135,7 @@ export function ThreadComposer({
|
||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||
) : null}
|
||||
<div className="relative">
|
||||
{hasCliMentionDecorations ? (
|
||||
{hasMentionDecorations ? (
|
||||
<ComposerCliMentionOverlay
|
||||
segments={mentionSegments}
|
||||
isHero={isHero}
|
||||
@@ -978,7 +1166,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",
|
||||
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
|
||||
hasMentionDecorations && "text-transparent selection:bg-primary/20",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1019,7 +1207,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-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
||||
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
||||
)}
|
||||
>
|
||||
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
|
||||
@@ -1038,7 +1226,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)]",
|
||||
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
|
||||
"h-9 text-[12px]",
|
||||
imageMode
|
||||
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
|
||||
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
|
||||
@@ -1058,7 +1246,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",
|
||||
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
|
||||
"h-9 text-[12px]",
|
||||
)}
|
||||
>
|
||||
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
|
||||
@@ -1078,22 +1266,12 @@ export function ThreadComposer({
|
||||
) : null}
|
||||
</div>
|
||||
{modelLabel ? (
|
||||
<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>
|
||||
<ComposerModelBadge
|
||||
label={modelLabel}
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
isHero={isHero}
|
||||
/>
|
||||
) : null}
|
||||
{!isHero ? (
|
||||
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
|
||||
@@ -1115,7 +1293,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",
|
||||
isHero ? "" : "h-7.5 w-7.5",
|
||||
"h-9 w-9",
|
||||
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
||||
)}
|
||||
>
|
||||
@@ -1133,12 +1311,79 @@ 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: CliAppMentionSegment[];
|
||||
segments: CapabilityMentionSegment[];
|
||||
isHero: boolean;
|
||||
className: string;
|
||||
}) {
|
||||
@@ -1154,7 +1399,7 @@ function ComposerCliMentionOverlay({
|
||||
if (segment.kind === "text") {
|
||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||
}
|
||||
return (
|
||||
if (segment.kind === "cli") return (
|
||||
<CliAppMentionToken
|
||||
key={`cli-${segment.app.name}-${index}`}
|
||||
app={segment.app}
|
||||
@@ -1163,26 +1408,35 @@ 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: SlashCommand[];
|
||||
commands: SlashPaletteCommand[];
|
||||
selectedIndex: number;
|
||||
layout: SlashPaletteLayout;
|
||||
isHero: boolean;
|
||||
onHover: (index: number) => void;
|
||||
onChoose: (command: SlashCommand) => void;
|
||||
onChoose: (command: SlashPaletteCommand) => void;
|
||||
}
|
||||
|
||||
interface CliAppMentionPaletteProps {
|
||||
apps: CliAppInfo[];
|
||||
candidates: MentionCandidate[];
|
||||
selectedIndex: number;
|
||||
layout: SlashPaletteLayout;
|
||||
isHero: boolean;
|
||||
onHover: (index: number) => void;
|
||||
onChoose: (app: CliAppInfo) => void;
|
||||
onChoose: (candidate: MentionCandidate) => void;
|
||||
}
|
||||
|
||||
function ImageAspectMenu({
|
||||
@@ -1239,7 +1493,7 @@ function ImageAspectMenu({
|
||||
}
|
||||
|
||||
function CliAppMentionPalette({
|
||||
apps,
|
||||
candidates,
|
||||
selectedIndex,
|
||||
layout,
|
||||
isHero,
|
||||
@@ -1257,98 +1511,117 @@ 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-[18px] border",
|
||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[22px] 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.18)]",
|
||||
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||
"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)]",
|
||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||
)}
|
||||
>
|
||||
<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 className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
|
||||
{t("thread.composer.mentions.label")}
|
||||
</div>
|
||||
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||
{apps.map((app, index) => {
|
||||
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
|
||||
{candidates.map((candidate, 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={app.name}
|
||||
key={`${candidate.kind}-${name}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChoose(app);
|
||||
onChoose(candidate);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||
"flex h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 text-left transition-colors",
|
||||
selected
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-foreground/86 hover:bg-accent/55",
|
||||
? "bg-foreground/[0.055] text-foreground"
|
||||
: "text-foreground/90 hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<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}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||
{app.category}
|
||||
{app.entry_point ? ` · ${app.entry_point}` : ""}
|
||||
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
|
||||
@{name}
|
||||
</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 CliAppMentionLogo({
|
||||
app,
|
||||
function MentionCandidateLogo({
|
||||
candidate,
|
||||
selected,
|
||||
}: {
|
||||
app: CliAppInfo;
|
||||
candidate: MentionCandidate;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const color = app.brand_color || "hsl(var(--primary))";
|
||||
if (app.logo_url && !failed) {
|
||||
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) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
|
||||
selected ? "border-primary/25" : "border-border/65",
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px]",
|
||||
selected ? "bg-background/55" : "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={app.logo_url}
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
className="h-4.5 w-4.5 object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
className="h-5 w-5 object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{cliAppInitials(app)}
|
||||
{candidate.kind === "cli"
|
||||
? cliAppInitials(candidate.app)
|
||||
: mcpPresetInitials(candidate.preset)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1374,14 +1647,11 @@ 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.18)]",
|
||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
|
||||
"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;
|
||||
@@ -1405,49 +1675,42 @@ function SlashCommandPalette({
|
||||
onChoose(command);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||
"flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors",
|
||||
selected
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-foreground/86 hover:bg-accent/55",
|
||||
? "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]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"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",
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors",
|
||||
selected && "text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<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>
|
||||
{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 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="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||
{description}
|
||||
<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>
|
||||
) : null}
|
||||
<span className="font-mono text-[12px] text-muted-foreground/60">
|
||||
{command.argHint ? `${command.command} ${command.argHint}` : command.command}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
AgentActivityCluster,
|
||||
isAgentActivityMember,
|
||||
} from "@/components/thread/AgentActivityCluster";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
@@ -15,6 +15,7 @@ interface ThreadMessagesProps {
|
||||
hiddenMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
}
|
||||
|
||||
export type DisplayUnit =
|
||||
@@ -133,6 +134,7 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
||||
reasoningStreaming: message.reasoningStreaming,
|
||||
isStreaming: message.reasoningStreaming,
|
||||
activitySegmentId: message.activitySegmentId,
|
||||
latencyMs: message.latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,6 +168,7 @@ export function ThreadMessages({
|
||||
hiddenMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
@@ -202,6 +205,8 @@ 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}>
|
||||
@@ -210,7 +215,9 @@ export function ThreadMessages({
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={index === liveActivityClusterIndex}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
turnLatencyMs={turnLatencyMs}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
@@ -221,6 +228,7 @@ export function ThreadMessages({
|
||||
: true
|
||||
}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -230,6 +238,28 @@ 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;
|
||||
|
||||
@@ -19,13 +19,19 @@ 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, listSlashCommands } from "@/lib/api";
|
||||
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
|
||||
import {
|
||||
CLI_APPS_CHANGED_EVENT,
|
||||
installedCliAppsFromPayload,
|
||||
isCliAppsPayload,
|
||||
} from "@/lib/cli-app-events";
|
||||
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
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 { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
|
||||
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
@@ -34,6 +40,20 @@ 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;
|
||||
@@ -55,6 +75,41 @@ 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]" },
|
||||
@@ -103,6 +158,8 @@ 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);
|
||||
@@ -141,6 +198,28 @@ 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;
|
||||
@@ -154,19 +233,28 @@ 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);
|
||||
const normalized = projectWebuiThreadMessages(historical);
|
||||
messageCacheRef.current.set(chatId, normalized);
|
||||
return normalized;
|
||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
}
|
||||
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
|
||||
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
|
||||
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);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
const next = projectWebuiThreadMessages(historical);
|
||||
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
|
||||
return next;
|
||||
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, chatId, historical, historyVersion]);
|
||||
@@ -262,6 +350,15 @@ 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 () => {
|
||||
@@ -297,6 +394,41 @@ 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;
|
||||
@@ -379,10 +511,13 @@ export function ThreadShell({
|
||||
? t("thread.composer.placeholderHero")
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
modelLabel={modelBadge.label}
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
||||
onStop={stop}
|
||||
@@ -399,10 +534,13 @@ export function ThreadShell({
|
||||
? t("thread.composer.placeholderOpening")
|
||||
: t("thread.composer.placeholderHero")
|
||||
}
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
modelLabel={modelBadge.label}
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
imageMode={heroImageMode}
|
||||
onImageModeChange={setHeroImageMode}
|
||||
runStartedAt={runStartedAt}
|
||||
@@ -444,6 +582,7 @@ 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, UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
@@ -25,6 +25,7 @@ interface ThreadViewportProps {
|
||||
conversationKey?: string | null;
|
||||
showScrollToBottomButton?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
@@ -55,6 +56,7 @@ export function ThreadViewport({
|
||||
conversationKey = null,
|
||||
showScrollToBottomButton = true,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -252,6 +254,7 @@ export function ThreadViewport({
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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 };
|
||||
@@ -1,46 +0,0 @@
|
||||
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 };
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
InboundEvent,
|
||||
OutboundCliAppMention,
|
||||
OutboundImageGeneration,
|
||||
OutboundMcpPresetMention,
|
||||
OutboundMedia,
|
||||
GoalStateWsPayload,
|
||||
UIImage,
|
||||
@@ -313,6 +314,7 @@ export interface SendImage {
|
||||
export interface SendOptions {
|
||||
imageGeneration?: OutboundImageGeneration;
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
mcpPresets?: OutboundMcpPresetMention[];
|
||||
}
|
||||
|
||||
export function useNanobotStream(
|
||||
@@ -891,6 +893,7 @@ export function useNanobotStream(
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
||||
...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"ariaLabel": "Change language"
|
||||
}
|
||||
},
|
||||
"apps": "Apps"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Back to chat",
|
||||
@@ -81,8 +82,10 @@
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"cliApps": "CLI Apps",
|
||||
"mcp": "MCP",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Interface",
|
||||
@@ -95,11 +98,21 @@
|
||||
"imageDefaults": "Defaults",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"cliApps": "CLI Apps",
|
||||
"cliApps": "CLI apps",
|
||||
"mcp": "MCP services",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Capabilities",
|
||||
"integrations": "Integrations"
|
||||
"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"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Theme",
|
||||
@@ -112,6 +125,7 @@
|
||||
"gateway": "Gateway",
|
||||
"restartState": "Restart state",
|
||||
"pendingChanges": "Pending changes",
|
||||
"currentModel": "Current model",
|
||||
"selectedPreset": "Selected preset",
|
||||
"presetModel": "Preset model",
|
||||
"density": "Density",
|
||||
@@ -154,6 +168,9 @@
|
||||
"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.",
|
||||
@@ -178,6 +195,11 @@
|
||||
"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",
|
||||
@@ -209,6 +231,59 @@
|
||||
"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",
|
||||
@@ -296,7 +371,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "Save provider"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
||||
@@ -307,6 +383,20 @@
|
||||
"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": {
|
||||
@@ -461,6 +551,16 @@
|
||||
"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",
|
||||
@@ -478,6 +578,10 @@
|
||||
"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."
|
||||
@@ -501,12 +605,22 @@
|
||||
"help": {
|
||||
"title": "Show help",
|
||||
"description": "List available slash commands."
|
||||
},
|
||||
"pairing": {
|
||||
"title": "Pairing",
|
||||
"description": "Manage pairing requests."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "CLI Apps",
|
||||
"label": "CLI APPS"
|
||||
"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"
|
||||
},
|
||||
"encoding": "Encoding…",
|
||||
"remove": "Remove attachment",
|
||||
@@ -539,15 +653,18 @@
|
||||
"agentActivityToolsOnly": "{{tools}} tool calls",
|
||||
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
|
||||
"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",
|
||||
"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",
|
||||
"imageAttachment": "Image attachment",
|
||||
"copyReply": "Copy reply",
|
||||
"copiedReply": "Copied reply",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Idioma",
|
||||
"ariaLabel": "Cambiar idioma"
|
||||
}
|
||||
},
|
||||
"apps": "Apps"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Volver al chat",
|
||||
@@ -81,7 +82,10 @@
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Interfaz",
|
||||
@@ -97,7 +101,10 @@
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Capacidades",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "Servicios MCP",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Tema",
|
||||
@@ -141,7 +148,11 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"configurationDocs": "Configuration docs"
|
||||
"configurationDocs": "Configuration docs",
|
||||
"currentModel": "Modelo actual",
|
||||
"brandLogos": "Logotipos de marca",
|
||||
"cliAppsCatalog": "Catálogo de apps CLI",
|
||||
"cliAppsFilter": "Filtro de apps CLI"
|
||||
},
|
||||
"help": {
|
||||
"theme": "Cambia entre apariencia clara y oscura.",
|
||||
@@ -168,7 +179,13 @@
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"values": {
|
||||
"light": "Claro",
|
||||
@@ -185,9 +202,7 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando configuración...",
|
||||
@@ -259,7 +274,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "Guardar proveedor"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Seleccionar proveedor",
|
||||
@@ -267,6 +283,120 @@
|
||||
"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": {
|
||||
@@ -370,8 +500,7 @@
|
||||
"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",
|
||||
@@ -439,6 +568,10 @@
|
||||
"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."
|
||||
@@ -462,7 +595,21 @@
|
||||
"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…",
|
||||
@@ -475,6 +622,16 @@
|
||||
"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",
|
||||
@@ -499,7 +656,19 @@
|
||||
"imageAttachment": "Imagen adjunta",
|
||||
"copyReply": "Copiar respuesta",
|
||||
"copiedReply": "Respuesta copiada",
|
||||
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)"
|
||||
"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ó"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Vista previa de imagen",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"ariaLabel": "Changer de langue"
|
||||
}
|
||||
},
|
||||
"apps": "Apps"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Retour à la discussion",
|
||||
@@ -81,7 +82,10 @@
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Interface",
|
||||
@@ -97,7 +101,10 @@
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Capacités",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "Services MCP",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Thème",
|
||||
@@ -141,7 +148,11 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"configurationDocs": "Configuration docs"
|
||||
"configurationDocs": "Configuration docs",
|
||||
"currentModel": "Modèle actuel",
|
||||
"brandLogos": "Logos de marque",
|
||||
"cliAppsCatalog": "Catalogue d'apps CLI",
|
||||
"cliAppsFilter": "Filtre des apps CLI"
|
||||
},
|
||||
"help": {
|
||||
"theme": "Basculer entre les apparences claire et sombre.",
|
||||
@@ -168,7 +179,13 @@
|
||||
"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."
|
||||
"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é."
|
||||
},
|
||||
"values": {
|
||||
"light": "Clair",
|
||||
@@ -185,9 +202,7 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement des paramètres...",
|
||||
@@ -259,7 +274,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "Enregistrer le fournisseur"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Sélectionner un fournisseur",
|
||||
@@ -267,6 +283,120 @@
|
||||
"selectSize": "Sélectionner une taille",
|
||||
"configureProvider": "Configurer le fournisseur",
|
||||
"missingCredential": "Configurez ce fournisseur avant d’activer la génération d’images."
|
||||
},
|
||||
"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 d’apps 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": {
|
||||
@@ -370,8 +500,7 @@
|
||||
"title": "Modifier une image",
|
||||
"prompt": "Aidez-moi à modifier une image. Demandez-moi d’abord de téléverser ou d’indiquer l’image, 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",
|
||||
@@ -439,6 +568,10 @@
|
||||
"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 l’historique",
|
||||
"description": "Afficher les N derniers messages persistés de la conversation."
|
||||
@@ -462,7 +595,21 @@
|
||||
"help": {
|
||||
"title": "Afficher l’aide",
|
||||
"description": "Lister les commandes slash disponibles."
|
||||
},
|
||||
"pairing": {
|
||||
"title": "Appairage",
|
||||
"description": "Gérer les demandes d’appairage."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "Actuel",
|
||||
"recent": "Récent"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "L’objectif est en cours",
|
||||
"goalReady": "Démarrer un objectif durable",
|
||||
"history": "Afficher les messages récents",
|
||||
"stopRunning": "En cours"
|
||||
}
|
||||
},
|
||||
"encoding": "Traitement…",
|
||||
@@ -475,6 +622,16 @@
|
||||
"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",
|
||||
@@ -499,7 +656,19 @@
|
||||
"imageAttachment": "Pièce jointe image",
|
||||
"copyReply": "Copier la réponse",
|
||||
"copiedReply": "Réponse copiée",
|
||||
"turnLatencyTitle": "Temps de réponse (de bout en bout)"
|
||||
"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"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Aperçu de l’image",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Bahasa",
|
||||
"ariaLabel": "Ganti bahasa"
|
||||
}
|
||||
},
|
||||
"apps": "Aplikasi"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Kembali ke obrolan",
|
||||
@@ -81,7 +82,10 @@
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "Aplikasi CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Aplikasi"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Antarmuka",
|
||||
@@ -97,7 +101,10 @@
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Kapabilitas",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "App CLI",
|
||||
"mcp": "Layanan MCP",
|
||||
"apps": "Aplikasi"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Tema",
|
||||
@@ -141,7 +148,11 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"configurationDocs": "Configuration docs"
|
||||
"configurationDocs": "Configuration docs",
|
||||
"currentModel": "Model saat ini",
|
||||
"brandLogos": "Logo merek",
|
||||
"cliAppsCatalog": "Katalog aplikasi CLI",
|
||||
"cliAppsFilter": "Filter aplikasi CLI"
|
||||
},
|
||||
"help": {
|
||||
"theme": "Beralih antara tampilan terang dan gelap.",
|
||||
@@ -168,7 +179,13 @@
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"values": {
|
||||
"light": "Terang",
|
||||
@@ -185,9 +202,7 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Memuat pengaturan...",
|
||||
@@ -259,7 +274,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "Simpan penyedia"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Pilih penyedia",
|
||||
@@ -267,6 +283,120 @@
|
||||
"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": {
|
||||
@@ -370,8 +500,7 @@
|
||||
"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",
|
||||
@@ -439,6 +568,10 @@
|
||||
"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."
|
||||
@@ -462,7 +595,21 @@
|
||||
"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…",
|
||||
@@ -475,6 +622,16 @@
|
||||
"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",
|
||||
@@ -499,7 +656,19 @@
|
||||
"imageAttachment": "Lampiran gambar",
|
||||
"copyReply": "Salin balasan",
|
||||
"copiedReply": "Balasan disalin",
|
||||
"turnLatencyTitle": "Waktu respons (ujung ke ujung)"
|
||||
"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"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Pratinjau gambar",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "言語",
|
||||
"ariaLabel": "言語を変更"
|
||||
}
|
||||
},
|
||||
"apps": "アプリ"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "チャットに戻る",
|
||||
@@ -81,7 +82,10 @@
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "CLI アプリ",
|
||||
"mcp": "MCP",
|
||||
"apps": "アプリ"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "インターフェース",
|
||||
@@ -97,7 +101,10 @@
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "機能",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "CLI アプリ",
|
||||
"mcp": "MCP サービス",
|
||||
"apps": "アプリ"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "テーマ",
|
||||
@@ -141,7 +148,11 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"configurationDocs": "Configuration docs"
|
||||
"configurationDocs": "Configuration docs",
|
||||
"currentModel": "現在のモデル",
|
||||
"brandLogos": "ブランドロゴ",
|
||||
"cliAppsCatalog": "CLI アプリカタログ",
|
||||
"cliAppsFilter": "CLI アプリフィルター"
|
||||
},
|
||||
"help": {
|
||||
"theme": "ライト表示とダーク表示を切り替えます。",
|
||||
@@ -168,7 +179,13 @@
|
||||
"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."
|
||||
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
|
||||
"currentModel": "今後の返信で nanobot が使用するモデルを選択します。",
|
||||
"selectedModelProvider": "選択したモデルによって設定されます。",
|
||||
"selectedModelValue": "選択したモデルによって設定されます。",
|
||||
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
|
||||
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。",
|
||||
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。"
|
||||
},
|
||||
"values": {
|
||||
"light": "ライト",
|
||||
@@ -185,9 +202,7 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"status": {
|
||||
"loading": "設定を読み込んでいます...",
|
||||
@@ -259,7 +274,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "プロバイダーを保存"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "プロバイダーを選択",
|
||||
@@ -267,6 +283,120 @@
|
||||
"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": {
|
||||
@@ -370,8 +500,7 @@
|
||||
"title": "画像を編集",
|
||||
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
|
||||
}
|
||||
},
|
||||
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "サイドバーを切り替える",
|
||||
@@ -439,6 +568,10 @@
|
||||
"title": "ステータスを表示",
|
||||
"description": "ランタイム、provider、channel の状態を表示します。"
|
||||
},
|
||||
"model": {
|
||||
"title": "モデル",
|
||||
"description": "有効なモデルプリセットを表示または切り替えます。"
|
||||
},
|
||||
"history": {
|
||||
"title": "会話履歴を表示",
|
||||
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
|
||||
@@ -462,7 +595,21 @@
|
||||
"help": {
|
||||
"title": "ヘルプを表示",
|
||||
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
||||
},
|
||||
"pairing": {
|
||||
"title": "ペアリング",
|
||||
"description": "ペアリングリクエストを管理します。"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "現在",
|
||||
"recent": "最近"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "目標が実行中",
|
||||
"goalReady": "継続的な目標を開始",
|
||||
"history": "最近のメッセージを表示",
|
||||
"stopRunning": "実行中"
|
||||
}
|
||||
},
|
||||
"encoding": "処理中…",
|
||||
@@ -475,6 +622,16 @@
|
||||
"decode_failed": "この画像をデコードできません",
|
||||
"too_large": "画像が大きすぎます。小さいものを選んでください",
|
||||
"io": "このファイルを読み込めません"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "アプリ",
|
||||
"label": "アプリ",
|
||||
"cliGroup": "CLI アプリ",
|
||||
"mcpGroup": "MCP サービス",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
|
||||
"mcpDescription": "@{{name}} を MCP サーバーとして使用"
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "一番下へスクロール",
|
||||
@@ -499,7 +656,19 @@
|
||||
"imageAttachment": "画像の添付",
|
||||
"copyReply": "返信をコピー",
|
||||
"copiedReply": "返信をコピーしました",
|
||||
"turnLatencyTitle": "応答時間(全行程)"
|
||||
"turnLatencyTitle": "応答時間(全行程)",
|
||||
"activityThinkingFor": "{{duration}}考えています",
|
||||
"activityThought": "思考しました",
|
||||
"activityThoughtFor": "{{duration}}考えました",
|
||||
"cliActivityRunningOne": "@{{name}} を使用中",
|
||||
"cliActivityRanOne": "@{{name}} を使用しました",
|
||||
"cliActivityFailedOne": "@{{name}} が失敗しました",
|
||||
"cliActivityRunningMany": "{{count}} 個の CLI アプリを使用中",
|
||||
"cliActivityRanMany": "{{count}} 個の CLI アプリを使用しました",
|
||||
"cliActivityFailedMany": "{{count}} 個の CLI アプリが失敗しました",
|
||||
"cliRunRunning": "使用中",
|
||||
"cliRunRan": "使用済み",
|
||||
"cliRunFailed": "失敗"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "画像プレビュー",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "언어",
|
||||
"ariaLabel": "언어 변경"
|
||||
}
|
||||
},
|
||||
"apps": "앱"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "채팅으로 돌아가기",
|
||||
@@ -81,7 +82,10 @@
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "CLI 앱",
|
||||
"mcp": "MCP",
|
||||
"apps": "앱"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "인터페이스",
|
||||
@@ -97,7 +101,10 @@
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "기능",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "CLI 앱",
|
||||
"mcp": "MCP 서비스",
|
||||
"apps": "앱"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "테마",
|
||||
@@ -141,7 +148,11 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"configurationDocs": "Configuration docs"
|
||||
"configurationDocs": "Configuration docs",
|
||||
"currentModel": "현재 모델",
|
||||
"brandLogos": "브랜드 로고",
|
||||
"cliAppsCatalog": "CLI 앱 카탈로그",
|
||||
"cliAppsFilter": "CLI 앱 필터"
|
||||
},
|
||||
"help": {
|
||||
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
|
||||
@@ -168,7 +179,13 @@
|
||||
"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."
|
||||
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
|
||||
"currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.",
|
||||
"selectedModelProvider": "선택한 모델에서 설정됩니다.",
|
||||
"selectedModelValue": "선택한 모델에서 설정됩니다.",
|
||||
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
|
||||
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.",
|
||||
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다."
|
||||
},
|
||||
"values": {
|
||||
"light": "라이트",
|
||||
@@ -185,9 +202,7 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"status": {
|
||||
"loading": "설정을 불러오는 중...",
|
||||
@@ -259,7 +274,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "제공자 저장"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "제공자 선택",
|
||||
@@ -267,6 +283,120 @@
|
||||
"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": {
|
||||
@@ -370,8 +500,7 @@
|
||||
"title": "이미지 편집",
|
||||
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
|
||||
}
|
||||
},
|
||||
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "사이드바 전환",
|
||||
@@ -439,6 +568,10 @@
|
||||
"title": "상태 보기",
|
||||
"description": "런타임, provider, channel 상태를 표시합니다."
|
||||
},
|
||||
"model": {
|
||||
"title": "모델",
|
||||
"description": "활성 모델 프리셋을 보거나 전환합니다."
|
||||
},
|
||||
"history": {
|
||||
"title": "대화 기록 보기",
|
||||
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
|
||||
@@ -462,7 +595,21 @@
|
||||
"help": {
|
||||
"title": "도움말 보기",
|
||||
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
||||
},
|
||||
"pairing": {
|
||||
"title": "페어링",
|
||||
"description": "페어링 요청을 관리합니다."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "현재",
|
||||
"recent": "최근"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "목표 실행 중",
|
||||
"goalReady": "지속 목표 시작",
|
||||
"history": "최근 메시지 보기",
|
||||
"stopRunning": "실행 중"
|
||||
}
|
||||
},
|
||||
"encoding": "처리 중…",
|
||||
@@ -475,6 +622,16 @@
|
||||
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
|
||||
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
|
||||
"io": "이 파일을 읽을 수 없습니다"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "앱",
|
||||
"label": "앱",
|
||||
"cliGroup": "CLI 앱",
|
||||
"mcpGroup": "MCP 서비스",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
|
||||
"mcpDescription": "@{{name}}을 MCP 서버로 사용"
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "맨 아래로 스크롤",
|
||||
@@ -499,7 +656,19 @@
|
||||
"imageAttachment": "이미지 첨부",
|
||||
"copyReply": "답변 복사",
|
||||
"copiedReply": "답변이 복사됨",
|
||||
"turnLatencyTitle": "응답 시간(엔드투엔드)"
|
||||
"turnLatencyTitle": "응답 시간(엔드투엔드)",
|
||||
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
||||
"activityThought": "생각함",
|
||||
"activityThoughtFor": "{{duration}} 동안 생각함",
|
||||
"cliActivityRunningOne": "@{{name}} 사용 중",
|
||||
"cliActivityRanOne": "@{{name}} 사용함",
|
||||
"cliActivityFailedOne": "@{{name}} 실패",
|
||||
"cliActivityRunningMany": "CLI 앱 {{count}}개 사용 중",
|
||||
"cliActivityRanMany": "CLI 앱 {{count}}개 사용함",
|
||||
"cliActivityFailedMany": "CLI 앱 {{count}}개 실패",
|
||||
"cliRunRunning": "사용 중",
|
||||
"cliRunRan": "사용함",
|
||||
"cliRunFailed": "실패"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "이미지 미리보기",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Ngôn ngữ",
|
||||
"ariaLabel": "Đổi ngôn ngữ"
|
||||
}
|
||||
},
|
||||
"apps": "Ứng dụng"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Quay lại trò chuyện",
|
||||
@@ -81,7 +82,10 @@
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "Ứng dụng CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Ứng dụng"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Giao diện",
|
||||
@@ -97,7 +101,10 @@
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Khả năng",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "Ứng dụng CLI",
|
||||
"mcp": "Dịch vụ MCP",
|
||||
"apps": "Ứng dụng"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Giao diện",
|
||||
@@ -141,7 +148,11 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"configurationDocs": "Configuration docs"
|
||||
"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"
|
||||
},
|
||||
"help": {
|
||||
"theme": "Chuyển giữa giao diện sáng và tối.",
|
||||
@@ -168,7 +179,13 @@
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"values": {
|
||||
"light": "Sáng",
|
||||
@@ -185,9 +202,7 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Đang tải cài đặt...",
|
||||
@@ -259,7 +274,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "Lưu nhà cung cấp"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Chọn nhà cung cấp",
|
||||
@@ -267,6 +283,120 @@
|
||||
"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": {
|
||||
@@ -370,8 +500,7 @@
|
||||
"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",
|
||||
@@ -439,6 +568,10 @@
|
||||
"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."
|
||||
@@ -462,7 +595,21 @@
|
||||
"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ý…",
|
||||
@@ -475,6 +622,16 @@
|
||||
"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",
|
||||
@@ -499,7 +656,19 @@
|
||||
"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)"
|
||||
"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"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Xem trước ảnh",
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
"title": "无法连接到 nanobot",
|
||||
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
|
||||
},
|
||||
"auth": {
|
||||
"title": "需要验证",
|
||||
"hint": "请输入 gateway 配置中的 tokenIssueSecret。",
|
||||
"placeholder": "密码",
|
||||
"submit": "连接",
|
||||
"invalid": "密码无效,请重试。"
|
||||
},
|
||||
"account": {
|
||||
"section": "账户",
|
||||
"logoutHint": "断开此浏览器与 gateway 的连接。",
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"system": {
|
||||
"section": "系统",
|
||||
"restartHint": "重启 nanobot 以应用运行时更改。",
|
||||
@@ -51,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"ariaLabel": "切换语言"
|
||||
}
|
||||
},
|
||||
"apps": "应用"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回对话",
|
||||
@@ -69,8 +82,10 @@
|
||||
"image": "图片",
|
||||
"web": "网页",
|
||||
"cliApps": "CLI 应用",
|
||||
"mcp": "MCP",
|
||||
"runtime": "运行时",
|
||||
"advanced": "高级"
|
||||
"advanced": "高级",
|
||||
"apps": "应用"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "界面",
|
||||
@@ -84,10 +99,20 @@
|
||||
"webSearch": "网页搜索",
|
||||
"webBehavior": "行为",
|
||||
"cliApps": "CLI 应用",
|
||||
"mcp": "MCP 服务",
|
||||
"identity": "身份",
|
||||
"safety": "安全",
|
||||
"capabilities": "能力",
|
||||
"integrations": "集成"
|
||||
"integrations": "集成",
|
||||
"apps": "应用"
|
||||
},
|
||||
"models": {
|
||||
"selectModel": "选择模型",
|
||||
"addConfiguration": "添加配置",
|
||||
"newConfiguration": "新建模型配置",
|
||||
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
|
||||
"configurationName": "名称",
|
||||
"configurationNamePlaceholder": "快速写作"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "主题",
|
||||
@@ -100,6 +125,7 @@
|
||||
"gateway": "网关",
|
||||
"restartState": "重启状态",
|
||||
"pendingChanges": "待处理更改",
|
||||
"currentModel": "当前模型",
|
||||
"selectedPreset": "选中的预设",
|
||||
"presetModel": "预设模型",
|
||||
"density": "密度",
|
||||
@@ -142,6 +168,9 @@
|
||||
"provider": "选择新模型请求使用的服务商。",
|
||||
"model": "设置 nanobot 默认使用的模型名称。",
|
||||
"configPath": "当前网关正在使用的配置文件。",
|
||||
"currentModel": "选择 nanobot 接下来回复时使用的模型。",
|
||||
"selectedModelProvider": "由当前模型决定。",
|
||||
"selectedModelValue": "由当前模型决定。",
|
||||
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
|
||||
"presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。",
|
||||
"density": "仅保存在当前浏览器。",
|
||||
@@ -166,6 +195,11 @@
|
||||
"cliAppsFilter": "按应用、分类或能力搜索。",
|
||||
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
|
||||
},
|
||||
"timezone": {
|
||||
"select": "选择时区",
|
||||
"search": "搜索时区",
|
||||
"empty": "没有匹配的时区。"
|
||||
},
|
||||
"cliApps": {
|
||||
"allCategories": "全部分类",
|
||||
"availableCount": "{{count}} 个应用",
|
||||
@@ -197,6 +231,59 @@
|
||||
"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": "深色",
|
||||
@@ -284,7 +371,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "搜索服务商",
|
||||
"noMatches": "没有匹配的服务商。"
|
||||
"noMatches": "没有匹配的服务商。",
|
||||
"saveProvider": "保存服务商"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||
@@ -295,6 +383,20 @@
|
||||
"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": {
|
||||
@@ -448,6 +550,16 @@
|
||||
"navigateHint": "↑↓ 选择",
|
||||
"selectHint": "Enter/Tab 填入",
|
||||
"closeHint": "Esc 关闭",
|
||||
"badges": {
|
||||
"current": "当前",
|
||||
"recent": "最近"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "目标正在运行",
|
||||
"goalReady": "开始一个持续目标",
|
||||
"history": "查看最近消息",
|
||||
"stopRunning": "正在运行"
|
||||
},
|
||||
"commands": {
|
||||
"new": {
|
||||
"title": "新建对话",
|
||||
@@ -465,6 +577,10 @@
|
||||
"title": "查看状态",
|
||||
"description": "显示运行时、服务商和通道状态。"
|
||||
},
|
||||
"model": {
|
||||
"title": "模型",
|
||||
"description": "查看或切换当前模型预设。"
|
||||
},
|
||||
"history": {
|
||||
"title": "查看对话历史",
|
||||
"description": "打印最近 N 条已持久化的对话消息。"
|
||||
@@ -488,12 +604,22 @@
|
||||
"help": {
|
||||
"title": "查看帮助",
|
||||
"description": "列出可用的斜杠命令。"
|
||||
},
|
||||
"pairing": {
|
||||
"title": "配对",
|
||||
"description": "管理配对请求。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "CLI 应用",
|
||||
"label": "CLI 应用"
|
||||
"ariaLabel": "应用",
|
||||
"label": "应用",
|
||||
"cliGroup": "CLI 应用",
|
||||
"mcpGroup": "MCP 服务",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "使用 @{{name}} 调用本地 CLI",
|
||||
"mcpDescription": "使用 @{{name}} 调用 MCP 服务"
|
||||
},
|
||||
"encoding": "处理中…",
|
||||
"remove": "移除附件",
|
||||
@@ -527,15 +653,18 @@
|
||||
"agentActivityToolsOnly": "{{tools}} 次工具调用",
|
||||
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
|
||||
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
|
||||
"cliActivityRunningOne": "正在运行 CLI @{{name}}",
|
||||
"cliActivityRanOne": "已运行 CLI @{{name}}",
|
||||
"cliActivityFailedOne": "CLI 调用失败 @{{name}}",
|
||||
"cliActivityRunningMany": "正在运行 {{count}} 个 CLI",
|
||||
"cliActivityRanMany": "已运行 {{count}} 个 CLI",
|
||||
"cliActivityFailedMany": "{{count}} 个 CLI 调用失败",
|
||||
"cliRunRunning": "正在运行 CLI",
|
||||
"cliRunRan": "已运行 CLI",
|
||||
"cliRunFailed": "CLI 调用失败",
|
||||
"activityThinkingFor": "思考中 {{duration}}",
|
||||
"activityThought": "已思考",
|
||||
"activityThoughtFor": "思考了 {{duration}}",
|
||||
"cliActivityRunningOne": "正在使用 @{{name}}",
|
||||
"cliActivityRanOne": "已使用 @{{name}}",
|
||||
"cliActivityFailedOne": "使用 @{{name}} 失败",
|
||||
"cliActivityRunningMany": "正在使用 {{count}} 个 CLI 应用",
|
||||
"cliActivityRanMany": "已使用 {{count}} 个 CLI 应用",
|
||||
"cliActivityFailedMany": "{{count}} 个 CLI 应用失败",
|
||||
"cliRunRunning": "正在使用",
|
||||
"cliRunRan": "已使用",
|
||||
"cliRunFailed": "失败",
|
||||
"imageAttachment": "图片附件",
|
||||
"copyReply": "复制回复",
|
||||
"copiedReply": "已复制回复",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "語言",
|
||||
"ariaLabel": "切換語言"
|
||||
}
|
||||
},
|
||||
"apps": "應用"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回對話",
|
||||
@@ -81,7 +82,10 @@
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "CLI 應用",
|
||||
"mcp": "MCP",
|
||||
"apps": "應用"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "介面",
|
||||
@@ -97,7 +101,10 @@
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "功能",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "CLI 應用",
|
||||
"mcp": "MCP 服務",
|
||||
"apps": "應用"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "主題",
|
||||
@@ -141,7 +148,11 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"configurationDocs": "Configuration docs"
|
||||
"configurationDocs": "Configuration docs",
|
||||
"currentModel": "目前模型",
|
||||
"brandLogos": "品牌標誌",
|
||||
"cliAppsCatalog": "CLI 應用目錄",
|
||||
"cliAppsFilter": "CLI 應用篩選"
|
||||
},
|
||||
"help": {
|
||||
"theme": "在淺色與深色外觀之間切換。",
|
||||
@@ -168,7 +179,13 @@
|
||||
"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."
|
||||
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
|
||||
"currentModel": "選擇 nanobot 接下來回覆時使用的模型。",
|
||||
"selectedModelProvider": "由目前模型決定。",
|
||||
"selectedModelValue": "由目前模型決定。",
|
||||
"brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
|
||||
"cliAppsCatalog": "瀏覽 nanobot 可在本機執行的應用 CLI。",
|
||||
"cliAppsFilter": "按應用、分類或能力搜尋。"
|
||||
},
|
||||
"values": {
|
||||
"light": "淺色",
|
||||
@@ -185,9 +202,7 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"status": {
|
||||
"loading": "正在載入設定...",
|
||||
@@ -259,7 +274,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "儲存服務商"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "選擇服務商",
|
||||
@@ -267,6 +283,120 @@
|
||||
"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": {
|
||||
@@ -370,8 +500,7 @@
|
||||
"title": "編輯圖片",
|
||||
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
|
||||
}
|
||||
},
|
||||
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "切換側邊欄",
|
||||
@@ -439,6 +568,10 @@
|
||||
"title": "查看狀態",
|
||||
"description": "顯示執行環境、provider 和 channel 狀態。"
|
||||
},
|
||||
"model": {
|
||||
"title": "模型",
|
||||
"description": "查看或切換目前模型預設。"
|
||||
},
|
||||
"history": {
|
||||
"title": "查看對話歷史",
|
||||
"description": "列印最近 N 則已持久化的對話訊息。"
|
||||
@@ -462,7 +595,21 @@
|
||||
"help": {
|
||||
"title": "查看說明",
|
||||
"description": "列出可用的斜線命令。"
|
||||
},
|
||||
"pairing": {
|
||||
"title": "配對",
|
||||
"description": "管理配對請求。"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "目前",
|
||||
"recent": "最近"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "目標正在執行",
|
||||
"goalReady": "開始一個持續目標",
|
||||
"history": "查看最近訊息",
|
||||
"stopRunning": "正在執行"
|
||||
}
|
||||
},
|
||||
"encoding": "處理中…",
|
||||
@@ -475,6 +622,16 @@
|
||||
"decode_failed": "無法解碼這張圖片",
|
||||
"too_large": "圖片太大,請換一張小一點的",
|
||||
"io": "無法讀取這個檔案"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "應用",
|
||||
"label": "應用",
|
||||
"cliGroup": "CLI 應用",
|
||||
"mcpGroup": "MCP 服務",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "使用 @{{name}} 呼叫本機 CLI",
|
||||
"mcpDescription": "使用 @{{name}} 呼叫 MCP 服務"
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "捲動到底部",
|
||||
@@ -499,7 +656,19 @@
|
||||
"imageAttachment": "圖片附件",
|
||||
"copyReply": "複製回覆",
|
||||
"copiedReply": "已複製回覆",
|
||||
"turnLatencyTitle": "本輪耗時(端到端)"
|
||||
"turnLatencyTitle": "本輪耗時(端到端)",
|
||||
"activityThinkingFor": "思考中,已 {{duration}}",
|
||||
"activityThought": "已思考",
|
||||
"activityThoughtFor": "已思考 {{duration}}",
|
||||
"cliActivityRunningOne": "正在使用 @{{name}}",
|
||||
"cliActivityRanOne": "已使用 @{{name}}",
|
||||
"cliActivityFailedOne": "@{{name}} 失敗",
|
||||
"cliActivityRunningMany": "正在使用 {{count}} 個 CLI 應用",
|
||||
"cliActivityRanMany": "已使用 {{count}} 個 CLI 應用",
|
||||
"cliActivityFailedMany": "{{count}} 個 CLI 應用失敗",
|
||||
"cliRunRunning": "使用中",
|
||||
"cliRunRan": "已使用",
|
||||
"cliRunFailed": "失敗"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "圖片預覽",
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
McpPresetsPayload,
|
||||
ModelConfigurationCreate,
|
||||
ProviderSettingsUpdate,
|
||||
SettingsPayload,
|
||||
SettingsUpdate,
|
||||
@@ -39,6 +41,21 @@ 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 };
|
||||
@@ -125,6 +142,66 @@ 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 = "",
|
||||
@@ -188,6 +265,22 @@ 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,
|
||||
@@ -197,6 +290,7 @@ 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
Reference in New Issue
Block a user