mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 22:08:38 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4819c27be0 | ||
|
|
56465e7017 | ||
|
|
7033152a2c | ||
|
|
2048c38a22 |
@@ -98,4 +98,3 @@ tmp/
|
|||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
exp/
|
exp/
|
||||||
.playwright-mcp/
|
|
||||||
|
|||||||
+2
-104
@@ -126,10 +126,8 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
|||||||
> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
|
> - **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.
|
> - **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.
|
> - **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.
|
> - **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 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 |
|
| Provider | Purpose | Get API Key |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
@@ -168,43 +166,6 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
|||||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
| `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) |
|
| `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>
|
<details>
|
||||||
<summary><b>Skywork / APIFree</b></summary>
|
<summary><b>Skywork / APIFree</b></summary>
|
||||||
|
|
||||||
@@ -516,68 +477,6 @@ Official model names include `LongCat-Flash-Chat`, `LongCat-Flash-Thinking`,
|
|||||||
|
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary><b>Ant Ling (OpenAI-compatible)</b></summary>
|
<summary><b>Ant Ling (OpenAI-compatible)</b></summary>
|
||||||
|
|
||||||
@@ -1057,7 +956,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
|||||||
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
|
| `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`. |
|
| `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) |
|
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
|
||||||
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key and optional `apiBase` are auto-resolved from the matching provider config. Chat-style bases such as `https://api.groq.com/openai/v1` are normalized to the audio transcription endpoint. |
|
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
|
||||||
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
|
| `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
|
`sendProgress` and `sendToolHints` can also be overridden per channel. The
|
||||||
@@ -1296,7 +1195,7 @@ If you want to always use the local conversion, you can force it using:
|
|||||||
|
|
||||||
## Image Generation
|
## Image Generation
|
||||||
|
|
||||||
Image generation is configured under `tools.imageGeneration` and uses credentials from the selected provider's `providers.<name>` block.
|
Image generation is configured under `tools.imageGeneration` and uses provider credentials from `providers.openrouter` or `providers.aihubmix`.
|
||||||
|
|
||||||
See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting.
|
See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting.
|
||||||
|
|
||||||
@@ -1389,7 +1288,6 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
|
|||||||
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
|
| `tools.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.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.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`). |
|
| `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. |
|
| `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, StepFun, and Zhipu configuration examples.
|
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, and StepFun configuration examples.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
> 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 |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
||||||
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
|
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun` |
|
||||||
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
| `tools.imageGeneration.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.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` |
|
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
|
||||||
@@ -245,31 +245,6 @@ StepPlan is StepFun's subscription tier and uses a different API base URL. The i
|
|||||||
|
|
||||||
`apiBase` takes precedence over the registry default, so with the StepPlan base URL configured, image requests are sent to `https://api.stepfun.com/step_plan/v1/images/generations` — the same path prefix used for LLM calls. The API key is shared with the standard StepFun provider.
|
`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
|
## Artifacts
|
||||||
|
|
||||||
Generated images are stored under the active nanobot instance's media directory:
|
Generated images are stored under the active nanobot instance's media directory:
|
||||||
@@ -324,7 +299,8 @@ Use the reference image. Keep the same robot and composition, change the palette
|
|||||||
|---------|-------|
|
|---------|-------|
|
||||||
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
|
| `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 |
|
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
|
||||||
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
|
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, or `stepfun` |
|
||||||
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
| 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 |
|
| 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 |
|
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ from typing import Any, Mapping, Sequence
|
|||||||
|
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.skills import SkillsLoader
|
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.session.goal_state import goal_state_runtime_lines
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
current_time_str,
|
current_time_str,
|
||||||
@@ -23,32 +19,6 @@ from nanobot.utils.helpers import (
|
|||||||
from nanobot.utils.prompt_templates import render_template
|
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:
|
class ContextBuilder:
|
||||||
"""Builds the context (system prompt + messages) for the agent."""
|
"""Builds the context (system prompt + messages) for the agent."""
|
||||||
|
|
||||||
|
|||||||
+27
-278
@@ -8,25 +8,17 @@ import os
|
|||||||
import time
|
import time
|
||||||
from contextlib import AsyncExitStack, nullcontext, suppress
|
from contextlib import AsyncExitStack, nullcontext, suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent import context as agent_context
|
|
||||||
from nanobot.agent import model_presets as preset_helpers
|
from nanobot.agent import model_presets as preset_helpers
|
||||||
from nanobot.agent.autocompact import AutoCompact
|
from nanobot.agent.autocompact import AutoCompact
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.hook import AgentHook, CompositeHook
|
from nanobot.agent.hook import AgentHook, CompositeHook
|
||||||
from nanobot.agent.memory import (
|
from nanobot.agent.memory import Consolidator, Dream
|
||||||
_STALE_THRESHOLD_DAYS,
|
|
||||||
Consolidator,
|
|
||||||
Dream,
|
|
||||||
_estimate_tokens,
|
|
||||||
_strip_skip_lines,
|
|
||||||
)
|
|
||||||
from nanobot.agent.progress_hook import AgentProgressHook
|
from nanobot.agent.progress_hook import AgentProgressHook
|
||||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
@@ -36,15 +28,13 @@ from nanobot.agent.tools.registry import ToolRegistry
|
|||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.cli_apps import utils as cli_app_utils
|
||||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
GOAL_STATE_KEY,
|
|
||||||
goal_state_runtime_lines,
|
|
||||||
runner_wall_llm_timeout_s,
|
runner_wall_llm_timeout_s,
|
||||||
sustained_goal_active,
|
|
||||||
)
|
)
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.session.webui_turns import (
|
from nanobot.session.webui_turns import (
|
||||||
@@ -57,11 +47,7 @@ from nanobot.utils.helpers import image_placeholder_text
|
|||||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||||
from nanobot.utils.image_generation_intent import image_generation_prompt
|
from nanobot.utils.image_generation_intent import image_generation_prompt
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
from nanobot.utils.prompt_templates import _TEMPLATES_ROOT, render_template
|
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
from nanobot.utils.runtime import (
|
|
||||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
|
||||||
SUSTAINED_GOAL_CONTINUE_PROMPT,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
@@ -178,7 +164,6 @@ class AgentLoop:
|
|||||||
workspace: Path,
|
workspace: Path,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_iterations: int | None = None,
|
max_iterations: int | None = None,
|
||||||
max_concurrent_subagents: int | None = None,
|
|
||||||
context_window_tokens: int | None = None,
|
context_window_tokens: int | None = None,
|
||||||
context_block_limit: int | None = None,
|
context_block_limit: int | None = None,
|
||||||
max_tool_result_chars: int | None = None,
|
max_tool_result_chars: int | None = None,
|
||||||
@@ -205,7 +190,6 @@ class AgentLoop:
|
|||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||||
dream_model_override: str | None = None,
|
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
@@ -218,7 +202,6 @@ class AgentLoop:
|
|||||||
self._preset_snapshot_loader = preset_snapshot_loader
|
self._preset_snapshot_loader = preset_snapshot_loader
|
||||||
self._runtime_model_publisher = runtime_model_publisher
|
self._runtime_model_publisher = runtime_model_publisher
|
||||||
self._provider_signature = provider_signature
|
self._provider_signature = provider_signature
|
||||||
self._dream_model_override = dream_model_override
|
|
||||||
self._default_selection_signature = preset_helpers.default_selection_signature(provider_signature)
|
self._default_selection_signature = preset_helpers.default_selection_signature(provider_signature)
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.model = model or provider.get_default_model()
|
self.model = model or provider.get_default_model()
|
||||||
@@ -279,7 +262,6 @@ class AgentLoop:
|
|||||||
restrict_to_workspace=restrict_to_workspace,
|
restrict_to_workspace=restrict_to_workspace,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
max_iterations=self.max_iterations,
|
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),
|
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
||||||
)
|
)
|
||||||
self._unified_session = unified_session
|
self._unified_session = unified_session
|
||||||
@@ -326,7 +308,6 @@ class AgentLoop:
|
|||||||
self._active_preset: str | None = None
|
self._active_preset: str | None = None
|
||||||
if model_preset:
|
if model_preset:
|
||||||
self.set_model_preset(model_preset, publish_update=False)
|
self.set_model_preset(model_preset, publish_update=False)
|
||||||
self._configure_dream()
|
|
||||||
self._register_default_tools()
|
self._register_default_tools()
|
||||||
self._runtime_vars: dict[str, Any] = {}
|
self._runtime_vars: dict[str, Any] = {}
|
||||||
self._current_iteration: int = 0
|
self._current_iteration: int = 0
|
||||||
@@ -366,7 +347,6 @@ class AgentLoop:
|
|||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
model=model,
|
model=model,
|
||||||
max_iterations=defaults.max_tool_iterations,
|
max_iterations=defaults.max_tool_iterations,
|
||||||
max_concurrent_subagents=defaults.max_concurrent_subagents,
|
|
||||||
context_window_tokens=context_window_tokens,
|
context_window_tokens=context_window_tokens,
|
||||||
context_block_limit=defaults.context_block_limit,
|
context_block_limit=defaults.context_block_limit,
|
||||||
max_tool_result_chars=defaults.max_tool_result_chars,
|
max_tool_result_chars=defaults.max_tool_result_chars,
|
||||||
@@ -386,7 +366,6 @@ class AgentLoop:
|
|||||||
model_preset=defaults.model_preset,
|
model_preset=defaults.model_preset,
|
||||||
provider_snapshot_loader=provider_snapshot_loader,
|
provider_snapshot_loader=provider_snapshot_loader,
|
||||||
preset_snapshot_loader=preset_snapshot_loader,
|
preset_snapshot_loader=preset_snapshot_loader,
|
||||||
dream_model_override=config.agents.defaults.dream.model_override,
|
|
||||||
**extra,
|
**extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -412,7 +391,7 @@ class AgentLoop:
|
|||||||
self.runner.provider = provider
|
self.runner.provider = provider
|
||||||
self.subagents.set_provider(provider, model)
|
self.subagents.set_provider(provider, model)
|
||||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||||
self._configure_dream()
|
self.dream.set_provider(provider, model)
|
||||||
self._provider_signature = snapshot.signature
|
self._provider_signature = snapshot.signature
|
||||||
if publish_update and self._runtime_model_publisher is not None:
|
if publish_update and self._runtime_model_publisher is not None:
|
||||||
self._runtime_model_publisher(
|
self._runtime_model_publisher(
|
||||||
@@ -421,20 +400,6 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
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:
|
def _refresh_provider_snapshot(self) -> None:
|
||||||
if self._provider_snapshot_loader is None:
|
if self._provider_snapshot_loader is None:
|
||||||
return
|
return
|
||||||
@@ -511,8 +476,26 @@ class AgentLoop:
|
|||||||
logger.info("Registered {} tools: {}", len(registered), registered)
|
logger.info("Registered {} tools: {}", len(registered), registered)
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
"""Connect configured MCP servers."""
|
"""Connect to configured MCP servers (one-time, lazy)."""
|
||||||
await agent_context.connect_mcp(self, self.tools)
|
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
|
||||||
|
return
|
||||||
|
self._mcp_connecting = True
|
||||||
|
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._mcp_stacks = await connect_mcp_servers(self._mcp_servers, self.tools)
|
||||||
|
if self._mcp_stacks:
|
||||||
|
self._mcp_connected = True
|
||||||
|
else:
|
||||||
|
logger.warning("No MCP servers connected successfully (will retry next message)")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.warning("MCP connection cancelled (will retry next message)")
|
||||||
|
self._mcp_stacks.clear()
|
||||||
|
except BaseException as e:
|
||||||
|
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||||
|
self._mcp_stacks.clear()
|
||||||
|
finally:
|
||||||
|
self._mcp_connecting = False
|
||||||
|
|
||||||
def _set_tool_context(
|
def _set_tool_context(
|
||||||
self, channel: str, chat_id: str,
|
self, channel: str, chat_id: str,
|
||||||
@@ -585,7 +568,7 @@ class AgentLoop:
|
|||||||
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
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()
|
has_text = isinstance(msg.content, str) and msg.content.strip()
|
||||||
if has_text or media_paths:
|
if has_text or media_paths:
|
||||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata)
|
||||||
extra.update(kwargs)
|
extra.update(kwargs)
|
||||||
text = msg.content if isinstance(msg.content, str) else ""
|
text = msg.content if isinstance(msg.content, str) else ""
|
||||||
session.add_message("user", text, **extra)
|
session.add_message("user", text, **extra)
|
||||||
@@ -610,7 +593,7 @@ class AgentLoop:
|
|||||||
chat_id=self._runtime_chat_id(msg),
|
chat_id=self._runtime_chat_id(msg),
|
||||||
sender_id=msg.sender_id,
|
sender_id=msg.sender_id,
|
||||||
session_summary=pending_summary,
|
session_summary=pending_summary,
|
||||||
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace),
|
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
@@ -761,15 +744,6 @@ class AgentLoop:
|
|||||||
|
|
||||||
active_session_key = session.key if session else session_key
|
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))
|
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:
|
try:
|
||||||
result = await self.runner.run(AgentRunSpec(
|
result = await self.runner.run(AgentRunSpec(
|
||||||
initial_messages=initial_messages,
|
initial_messages=initial_messages,
|
||||||
@@ -797,8 +771,6 @@ class AgentLoop:
|
|||||||
session.key if session is not None else session_key,
|
session.key if session is not None else session_key,
|
||||||
metadata=(session.metadata if session is not None else None),
|
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:
|
finally:
|
||||||
reset_file_states(file_state_token)
|
reset_file_states(file_state_token)
|
||||||
@@ -839,8 +811,6 @@ class AgentLoop:
|
|||||||
logger.warning("Error consuming inbound message: {}, continuing...", e)
|
logger.warning("Error consuming inbound message: {}, continuing...", e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
|
||||||
continue
|
|
||||||
raw = msg.content.strip()
|
raw = msg.content.strip()
|
||||||
if self.commands.is_priority(raw):
|
if self.commands.is_priority(raw):
|
||||||
await self._dispatch_command_inline(
|
await self._dispatch_command_inline(
|
||||||
@@ -1049,28 +1019,6 @@ class AgentLoop:
|
|||||||
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
||||||
)
|
)
|
||||||
logger.info("Processing system message from {}", msg.sender_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}"
|
key = msg.session_key_override or f"{channel}:{chat_id}"
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
if self._restore_runtime_checkpoint(session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
@@ -1110,7 +1058,7 @@ class AgentLoop:
|
|||||||
current_role=current_role,
|
current_role=current_role,
|
||||||
sender_id=msg.sender_id,
|
sender_id=msg.sender_id,
|
||||||
session_summary=pending,
|
session_summary=pending,
|
||||||
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace, skip=is_subagent),
|
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
|
||||||
)
|
)
|
||||||
t_wall = time.time()
|
t_wall = time.time()
|
||||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||||
@@ -1147,205 +1095,6 @@ class AgentLoop:
|
|||||||
metadata=outbound_metadata,
|
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(
|
async def _process_message(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
|
|||||||
+190
-167
@@ -6,7 +6,6 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import weakref
|
import weakref
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -16,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
|
|||||||
import tiktoken
|
import tiktoken
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session
|
||||||
from nanobot.utils.gitstore import GitStore
|
from nanobot.utils.gitstore import GitStore
|
||||||
@@ -34,20 +33,6 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.session.manager import SessionManager
|
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
|
# MemoryStore — pure file I/O layer
|
||||||
@@ -415,26 +400,6 @@ class MemoryStore:
|
|||||||
def set_last_dream_cursor(self, cursor: int) -> None:
|
def set_last_dream_cursor(self, cursor: int) -> None:
|
||||||
self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
|
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 ------------------------------------------
|
# -- message formatting utility ------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -653,20 +618,18 @@ class Consolidator:
|
|||||||
"""Available input token budget for consolidation LLM."""
|
"""Available input token budget for consolidation LLM."""
|
||||||
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
||||||
|
|
||||||
def _truncate_to_token_budget(self, text: str, reserve_tokens: int = 0) -> str:
|
def _truncate_to_token_budget(self, text: str) -> str:
|
||||||
"""Truncate text so it fits within the consolidation LLM's token budget.
|
"""Truncate text so it fits within the consolidation LLM's token budget."""
|
||||||
|
budget = self._input_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:
|
if budget <= 0:
|
||||||
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
||||||
if _TIKTOKEN_ENC is not None:
|
try:
|
||||||
tokens = _TIKTOKEN_ENC.encode(text)
|
enc = tiktoken.get_encoding("cl100k_base")
|
||||||
|
tokens = enc.encode(text)
|
||||||
if len(tokens) <= budget:
|
if len(tokens) <= budget:
|
||||||
return text
|
return text
|
||||||
return _TIKTOKEN_ENC.decode(tokens[:budget]) + "\n... (truncated)"
|
return enc.decode(tokens[:budget]) + "\n... (truncated)"
|
||||||
|
except Exception:
|
||||||
return truncate_text(text, budget * 4)
|
return truncate_text(text, budget * 4)
|
||||||
|
|
||||||
async def archive(self, messages: list[dict]) -> str | None:
|
async def archive(self, messages: list[dict]) -> str | None:
|
||||||
@@ -676,53 +639,9 @@ class Consolidator:
|
|||||||
"""
|
"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
t_start = time.perf_counter()
|
|
||||||
try:
|
try:
|
||||||
formatted = MemoryStore._format_messages(messages)
|
formatted = MemoryStore._format_messages(messages)
|
||||||
logger.debug(
|
formatted = self._truncate_to_token_budget(formatted)
|
||||||
"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(
|
response = await self.provider.chat_with_retry(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
messages=[
|
messages=[
|
||||||
@@ -733,31 +652,18 @@ class Consolidator:
|
|||||||
strip=True,
|
strip=True,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{"role": "user", "content": formatted + dedup_context},
|
{"role": "user", "content": formatted},
|
||||||
],
|
],
|
||||||
tools=None,
|
tools=None,
|
||||||
tool_choice=None,
|
tool_choice=None,
|
||||||
)
|
)
|
||||||
elapsed = time.perf_counter() - t_start
|
|
||||||
if response.finish_reason == "error":
|
if response.finish_reason == "error":
|
||||||
logger.warning(
|
|
||||||
"Consolidator LLM error after {:.1f}s: {}",
|
|
||||||
elapsed, response.content,
|
|
||||||
)
|
|
||||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
raise RuntimeError(f"LLM returned error: {response.content}")
|
||||||
summary = response.content or "[no summary]"
|
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)
|
self.store.append_history(summary, max_chars=_ARCHIVE_SUMMARY_MAX_CHARS)
|
||||||
return summary
|
return summary
|
||||||
except Exception:
|
except Exception:
|
||||||
elapsed = time.perf_counter() - t_start
|
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
||||||
logger.warning(
|
|
||||||
"Consolidation LLM call failed after {:.1f}s, raw-dumping to history",
|
|
||||||
elapsed,
|
|
||||||
)
|
|
||||||
self.store.raw_archive(messages)
|
self.store.raw_archive(messages)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -945,48 +851,38 @@ class Consolidator:
|
|||||||
|
|
||||||
|
|
||||||
# Single source of truth for the staleness threshold used in _annotate_with_ages
|
# Single source of truth for the staleness threshold used in _annotate_with_ages
|
||||||
# *and* in the system prompt template (passed as `stale_threshold_days`).
|
# *and* in the Phase 1 prompt template (passed as `stale_threshold_days`).
|
||||||
# Keep code and prompt aligned — if you bump this, the LLM's instruction string
|
# Keep code and prompt aligned — if you bump this, the LLM's instruction string
|
||||||
# updates automatically.
|
# updates automatically.
|
||||||
_STALE_THRESHOLD_DAYS = 14
|
_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:
|
class Dream:
|
||||||
"""Single-phase memory processor: analyze history.jsonl and edit files via AgentRunner.
|
"""Two-phase memory processor: analyze history.jsonl, then edit files via AgentRunner.
|
||||||
|
|
||||||
Delegates to AgentRunner with read_file / edit_file tools so the LLM can
|
Phase 1 produces an analysis summary (plain LLM call).
|
||||||
analyze conversation history, extract facts, deduplicate, and make targeted
|
Phase 2 delegates to AgentRunner with read_file / edit_file tools so the
|
||||||
incremental edits — all in a single agent run.
|
LLM can make targeted, incremental edits instead of replacing entire files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Caps on prompt-bound inputs so Dream's LLM calls never exceed the model's
|
# 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
|
# 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
|
# unexpectedly. Each file still appears in full via read_file when the agent
|
||||||
# needs it — these caps only bound the prompt preview.
|
# needs it in Phase 2 — these caps only bound the Phase 1/2 prompt preview.
|
||||||
_MEMORY_FILE_MAX_CHARS = 16_000
|
_MEMORY_FILE_MAX_CHARS = 32_000
|
||||||
_SOUL_FILE_MAX_CHARS = 4_000
|
_SOUL_FILE_MAX_CHARS = 16_000
|
||||||
_USER_FILE_MAX_CHARS = 4_000
|
_USER_FILE_MAX_CHARS = 16_000
|
||||||
_HISTORY_ENTRY_PREVIEW_MAX_CHARS = 2_000
|
_HISTORY_ENTRY_PREVIEW_MAX_CHARS = 4_000
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
store: MemoryStore,
|
store: MemoryStore,
|
||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
model: str,
|
model: str,
|
||||||
max_batch_size: int = 5,
|
max_batch_size: int = 20,
|
||||||
max_iterations: int = 10,
|
max_iterations: int = 10,
|
||||||
max_tool_result_chars: int = 16_000,
|
max_tool_result_chars: int = 16_000,
|
||||||
annotate_line_ages: bool = True,
|
annotate_line_ages: bool = True,
|
||||||
edit_user_skills: bool = False,
|
|
||||||
):
|
):
|
||||||
self.store = store
|
self.store = store
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
@@ -994,13 +890,10 @@ class Dream:
|
|||||||
self.max_batch_size = max_batch_size
|
self.max_batch_size = max_batch_size
|
||||||
self.max_iterations = max_iterations
|
self.max_iterations = max_iterations
|
||||||
self.max_tool_result_chars = max_tool_result_chars
|
self.max_tool_result_chars = max_tool_result_chars
|
||||||
# Kill switch for the git-blame-based per-line age annotation in the prompt.
|
# Kill switch for the git-blame-based per-line age annotation in Phase 1.
|
||||||
# Default True keeps the #3212 behavior; set False to feed all memory
|
# Default True keeps the #3212 behavior; set False to feed MEMORY.md raw
|
||||||
# files raw (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
|
# (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
|
||||||
self.annotate_line_ages = annotate_line_ages
|
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._runner = AgentRunner(provider)
|
||||||
self._tools = self._build_tools()
|
self._tools = self._build_tools()
|
||||||
|
|
||||||
@@ -1014,7 +907,6 @@ class Dream:
|
|||||||
def _build_tools(self) -> ToolRegistry:
|
def _build_tools(self) -> ToolRegistry:
|
||||||
"""Build a minimal tool registry for the Dream agent."""
|
"""Build a minimal tool registry for the Dream agent."""
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
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.file_state import FileStates
|
||||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
||||||
|
|
||||||
@@ -1032,7 +924,6 @@ class Dream:
|
|||||||
file_states=file_states,
|
file_states=file_states,
|
||||||
))
|
))
|
||||||
tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace, 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_file resolves relative paths from workspace root, but can only
|
||||||
# write under skills/ so the prompt can safely use skills/<name>/SKILL.md.
|
# write under skills/ so the prompt can safely use skills/<name>/SKILL.md.
|
||||||
skills_dir = workspace / "skills"
|
skills_dir = workspace / "skills"
|
||||||
@@ -1042,25 +933,15 @@ class Dream:
|
|||||||
|
|
||||||
# -- skill listing --------------------------------------------------------
|
# -- skill listing --------------------------------------------------------
|
||||||
|
|
||||||
def _list_existing_skills(self, tag_origin: bool = False) -> list[str]:
|
def _list_existing_skills(self) -> list[str]:
|
||||||
"""List existing skills as 'name — description [origin]' for dedup context.
|
"""List existing skills as 'name — description' 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
|
import re as _re
|
||||||
|
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
|
|
||||||
desc_re = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
|
desc_re = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
|
||||||
managed_re = _re.compile(r"^dream_managed:\s*true$", _re.MULTILINE | _re.IGNORECASE)
|
entries: dict[str, str] = {}
|
||||||
|
for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
|
||||||
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():
|
if not base.exists():
|
||||||
continue
|
continue
|
||||||
for d in base.iterdir():
|
for d in base.iterdir():
|
||||||
@@ -1070,31 +951,18 @@ class Dream:
|
|||||||
if not skill_md.exists():
|
if not skill_md.exists():
|
||||||
continue
|
continue
|
||||||
# Prefer workspace skills over builtin (same name)
|
# Prefer workspace skills over builtin (same name)
|
||||||
if d.name in entries and base == builtin_dir:
|
if d.name in entries and base == BUILTIN_SKILLS_DIR:
|
||||||
continue
|
continue
|
||||||
content = skill_md.read_text(encoding="utf-8")[:500]
|
content = skill_md.read_text(encoding="utf-8")[:500]
|
||||||
m = desc_re.search(content)
|
m = desc_re.search(content)
|
||||||
desc = m.group(1).strip() if m else "(no description)"
|
desc = m.group(1).strip() if m else "(no description)"
|
||||||
|
entries[d.name] = desc
|
||||||
if tag_origin:
|
return [f"{name} — {desc}" for name, desc in sorted(entries.items())]
|
||||||
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 ----------------------------------------------------------
|
# -- main entry ----------------------------------------------------------
|
||||||
|
|
||||||
def _annotate_with_ages(self, content: str, file_path: str = "memory/MEMORY.md") -> str:
|
def _annotate_with_ages(self, content: str) -> str:
|
||||||
"""Append per-line age suffixes to file content.
|
"""Append per-line age suffixes to MEMORY.md content.
|
||||||
|
|
||||||
Each non-blank line whose age exceeds ``_STALE_THRESHOLD_DAYS`` gets a
|
Each non-blank line whose age exceeds ``_STALE_THRESHOLD_DAYS`` gets a
|
||||||
suffix like ``← 30d`` indicating days since last modification.
|
suffix like ``← 30d`` indicating days since last modification.
|
||||||
@@ -1102,7 +970,9 @@ class Dream:
|
|||||||
annotate fails, or the line count doesn't match the age count
|
annotate fails, or the line count doesn't match the age count
|
||||||
(which can happen with an uncommitted working-tree edit — better to
|
(which can happen with an uncommitted working-tree edit — better to
|
||||||
skip annotation than to tag the wrong line).
|
skip annotation than to tag the wrong line).
|
||||||
|
SOUL.md and USER.md are never annotated.
|
||||||
"""
|
"""
|
||||||
|
file_path = "memory/MEMORY.md"
|
||||||
try:
|
try:
|
||||||
ages = self.store.git.line_ages(file_path)
|
ages = self.store.git.line_ages(file_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1137,3 +1007,156 @@ class Dream:
|
|||||||
result += "\n"
|
result += "\n"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def run(self) -> bool:
|
||||||
|
"""Process unprocessed history entries. Returns True if work was done."""
|
||||||
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
|
|
||||||
|
last_cursor = self.store.get_last_dream_cursor()
|
||||||
|
entries = self.store.read_unprocessed_history(since_cursor=last_cursor)
|
||||||
|
if not entries:
|
||||||
|
return False
|
||||||
|
|
||||||
|
batch = entries[: self.max_batch_size]
|
||||||
|
logger.info(
|
||||||
|
"Dream: processing {} entries (cursor {}→{}), batch={}",
|
||||||
|
len(entries), last_cursor, batch[-1]["cursor"], len(batch),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build history text for LLM — cap each entry so a legacy oversized
|
||||||
|
# record (e.g. pre-#3412 raw_archive dump) can't blow up the prompt.
|
||||||
|
history_text = "\n".join(
|
||||||
|
f"[{e['timestamp']}] "
|
||||||
|
f"{truncate_text(e['content'], self._HISTORY_ENTRY_PREVIEW_MAX_CHARS)}"
|
||||||
|
for e in batch
|
||||||
|
)
|
||||||
|
|
||||||
|
# Current file contents + per-line age annotations (MEMORY.md only).
|
||||||
|
# Each file is capped in the *prompt preview* only; Phase 2 still sees
|
||||||
|
# the full file via the read_file tool.
|
||||||
|
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
raw_memory = self.store.read_memory() or "(empty)"
|
||||||
|
annotated_memory = (
|
||||||
|
self._annotate_with_ages(raw_memory)
|
||||||
|
if self.annotate_line_ages
|
||||||
|
else raw_memory
|
||||||
|
)
|
||||||
|
current_memory = truncate_text(annotated_memory, self._MEMORY_FILE_MAX_CHARS)
|
||||||
|
current_soul = truncate_text(
|
||||||
|
self.store.read_soul() or "(empty)", self._SOUL_FILE_MAX_CHARS,
|
||||||
|
)
|
||||||
|
current_user = truncate_text(
|
||||||
|
self.store.read_user() or "(empty)", self._USER_FILE_MAX_CHARS,
|
||||||
|
)
|
||||||
|
|
||||||
|
file_context = (
|
||||||
|
f"## Current Date\n{current_date}\n\n"
|
||||||
|
f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
|
||||||
|
f"## Current SOUL.md ({len(current_soul)} chars)\n{current_soul}\n\n"
|
||||||
|
f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 1: Analyze (no skills list — dedup is Phase 2's job)
|
||||||
|
phase1_prompt = (
|
||||||
|
f"## Conversation History\n{history_text}\n\n{file_context}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
phase1_response = await self.provider.chat_with_retry(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": render_template(
|
||||||
|
"agent/dream_phase1.md",
|
||||||
|
strip=True,
|
||||||
|
stale_threshold_days=_STALE_THRESHOLD_DAYS,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": phase1_prompt},
|
||||||
|
],
|
||||||
|
tools=None,
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
analysis = phase1_response.content or ""
|
||||||
|
logger.debug("Dream Phase 1 analysis ({} chars): {}", len(analysis), analysis[:500])
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Dream Phase 1 failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Phase 2: Delegate to AgentRunner with read_file / edit_file
|
||||||
|
existing_skills = self._list_existing_skills()
|
||||||
|
skills_section = ""
|
||||||
|
if existing_skills:
|
||||||
|
skills_section = (
|
||||||
|
"\n\n## Existing Skills\n"
|
||||||
|
+ "\n".join(f"- {s}" for s in existing_skills)
|
||||||
|
)
|
||||||
|
phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}{skills_section}"
|
||||||
|
|
||||||
|
tools = self._tools
|
||||||
|
skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"
|
||||||
|
messages: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": render_template(
|
||||||
|
"agent/dream_phase2.md",
|
||||||
|
strip=True,
|
||||||
|
skill_creator_path=str(skill_creator_path),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": phase2_prompt},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self._runner.run(AgentRunSpec(
|
||||||
|
initial_messages=messages,
|
||||||
|
tools=tools,
|
||||||
|
model=self.model,
|
||||||
|
max_iterations=self.max_iterations,
|
||||||
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
|
fail_on_tool_error=False,
|
||||||
|
))
|
||||||
|
logger.debug(
|
||||||
|
"Dream Phase 2 complete: stop_reason={}, tool_events={}",
|
||||||
|
result.stop_reason, len(result.tool_events),
|
||||||
|
)
|
||||||
|
for ev in (result.tool_events or []):
|
||||||
|
logger.info("Dream tool_event: name={}, status={}, detail={}", ev.get("name"), ev.get("status"), ev.get("detail", "")[:200])
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Dream Phase 2 failed")
|
||||||
|
result = None
|
||||||
|
|
||||||
|
# Build changelog from tool events
|
||||||
|
changelog: list[str] = []
|
||||||
|
if result and result.tool_events:
|
||||||
|
for event in result.tool_events:
|
||||||
|
if event["status"] == "ok":
|
||||||
|
changelog.append(f"{event['name']}: {event['detail']}")
|
||||||
|
|
||||||
|
# Only advance cursor on successful completion to prevent silent loss
|
||||||
|
if result and result.stop_reason == "completed":
|
||||||
|
new_cursor = batch[-1]["cursor"]
|
||||||
|
self.store.set_last_dream_cursor(new_cursor)
|
||||||
|
logger.info(
|
||||||
|
"Dream done: {} change(s), cursor advanced to {}",
|
||||||
|
len(changelog), new_cursor,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reason = result.stop_reason if result else "exception"
|
||||||
|
logger.warning(
|
||||||
|
"Dream incomplete ({}): cursor NOT advanced, will retry next cron cycle",
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.store.compact_history()
|
||||||
|
|
||||||
|
# Git auto-commit (only when there are actual changes)
|
||||||
|
if changelog and self.store.git.is_initialized():
|
||||||
|
ts = batch[-1]["timestamp"]
|
||||||
|
summary = f"dream: {ts}, {len(changelog)} change(s)"
|
||||||
|
commit_msg = f"{summary}\n\n{analysis.strip()}"
|
||||||
|
sha = self.store.git.auto_commit(commit_msg)
|
||||||
|
if sha:
|
||||||
|
logger.info("Dream commit: {}", sha)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
+1
-10
@@ -8,7 +8,7 @@ import os
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -42,7 +42,6 @@ from nanobot.utils.prompt_templates import render_template
|
|||||||
from nanobot.utils.runtime import (
|
from nanobot.utils.runtime import (
|
||||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||||
build_finalization_retry_message,
|
build_finalization_retry_message,
|
||||||
build_goal_continue_message,
|
|
||||||
build_length_recovery_message,
|
build_length_recovery_message,
|
||||||
ensure_nonempty_tool_result,
|
ensure_nonempty_tool_result,
|
||||||
is_blank_text,
|
is_blank_text,
|
||||||
@@ -98,8 +97,6 @@ class AgentRunSpec:
|
|||||||
checkpoint_callback: Any | None = None
|
checkpoint_callback: Any | None = None
|
||||||
injection_callback: Any | None = None
|
injection_callback: Any | None = None
|
||||||
llm_timeout_s: float | None = None
|
llm_timeout_s: float | None = None
|
||||||
goal_active_predicate: Callable[[], bool] | None = None
|
|
||||||
goal_continue_message: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -170,7 +167,6 @@ class AgentRunner:
|
|||||||
*,
|
*,
|
||||||
phase: str = "after error",
|
phase: str = "after error",
|
||||||
iteration: int | None = None,
|
iteration: int | None = None,
|
||||||
allow_goal_continue: bool = False,
|
|
||||||
) -> tuple[bool, int]:
|
) -> tuple[bool, int]:
|
||||||
"""Drain pending injections. Returns (should_continue, updated_cycles).
|
"""Drain pending injections. Returns (should_continue, updated_cycles).
|
||||||
|
|
||||||
@@ -182,10 +178,6 @@ class AgentRunner:
|
|||||||
if injection_cycles >= _MAX_INJECTION_CYCLES:
|
if injection_cycles >= _MAX_INJECTION_CYCLES:
|
||||||
return False, injection_cycles
|
return False, injection_cycles
|
||||||
injections = await self._drain_injections(spec)
|
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:
|
if not injections:
|
||||||
return False, injection_cycles
|
return False, injection_cycles
|
||||||
injection_cycles += 1
|
injection_cycles += 1
|
||||||
@@ -483,7 +475,6 @@ class AgentRunner:
|
|||||||
spec, messages, assistant_message, injection_cycles,
|
spec, messages, assistant_message, injection_cycles,
|
||||||
phase="after final response",
|
phase="after final response",
|
||||||
iteration=iteration,
|
iteration=iteration,
|
||||||
allow_goal_continue=True,
|
|
||||||
)
|
)
|
||||||
if should_continue:
|
if should_continue:
|
||||||
had_injections = True
|
had_injections = True
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ class SubagentManager:
|
|||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
disabled_skills: list[str] | None = None,
|
disabled_skills: list[str] | None = None,
|
||||||
max_iterations: int | 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,
|
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
|
||||||
):
|
):
|
||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
@@ -96,11 +95,7 @@ class SubagentManager:
|
|||||||
if max_iterations is not None
|
if max_iterations is not None
|
||||||
else defaults.max_tool_iterations
|
else defaults.max_tool_iterations
|
||||||
)
|
)
|
||||||
self.max_concurrent_subagents = (
|
self.max_concurrent_subagents = defaults.max_concurrent_subagents
|
||||||
max_concurrent_subagents
|
|
||||||
if max_concurrent_subagents is not None
|
|
||||||
else defaults.max_concurrent_subagents
|
|
||||||
)
|
|
||||||
self.runner = AgentRunner(provider)
|
self.runner = AgentRunner(provider)
|
||||||
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
@@ -145,7 +140,6 @@ class SubagentManager:
|
|||||||
origin_chat_id: str = "direct",
|
origin_chat_id: str = "direct",
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
temperature: float | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Spawn a subagent to execute a task in the background."""
|
"""Spawn a subagent to execute a task in the background."""
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
@@ -161,9 +155,7 @@ class SubagentManager:
|
|||||||
self._task_statuses[task_id] = status
|
self._task_statuses[task_id] = status
|
||||||
|
|
||||||
bg_task = asyncio.create_task(
|
bg_task = asyncio.create_task(
|
||||||
self._run_subagent(
|
self._run_subagent(task_id, task, display_label, origin, status, origin_message_id)
|
||||||
task_id, task, display_label, origin, status, origin_message_id, temperature
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self._running_tasks[task_id] = bg_task
|
self._running_tasks[task_id] = bg_task
|
||||||
if session_key:
|
if session_key:
|
||||||
@@ -190,7 +182,6 @@ class SubagentManager:
|
|||||||
origin: dict[str, str],
|
origin: dict[str, str],
|
||||||
status: SubagentStatus,
|
status: SubagentStatus,
|
||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
temperature: float | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute the subagent task and announce the result."""
|
"""Execute the subagent task and announce the result."""
|
||||||
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
||||||
@@ -217,7 +208,6 @@ class SubagentManager:
|
|||||||
initial_messages=messages,
|
initial_messages=messages,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
temperature=temperature,
|
|
||||||
max_iterations=self.max_iterations,
|
max_iterations=self.max_iterations,
|
||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
hook=_SubagentHook(task_id, status),
|
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.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,14 @@ class _ExecSession:
|
|||||||
process: asyncio.subprocess.Process,
|
process: asyncio.subprocess.Process,
|
||||||
command: str,
|
command: str,
|
||||||
cwd: str,
|
cwd: str,
|
||||||
timeout: int | None,
|
timeout: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.process = process
|
self.process = process
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.started_at = time.monotonic()
|
self.started_at = time.monotonic()
|
||||||
# timeout None/0 means no limit; an infinite deadline is never reached.
|
self.deadline = time.monotonic() + timeout
|
||||||
self.deadline = time.monotonic() + timeout if timeout else float("inf")
|
|
||||||
self.last_access = time.monotonic()
|
self.last_access = time.monotonic()
|
||||||
self._chunks: list[str] = []
|
self._chunks: list[str] = []
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
@@ -170,7 +169,7 @@ class ExecSessionManager:
|
|||||||
command: str,
|
command: str,
|
||||||
cwd: str,
|
cwd: str,
|
||||||
env: dict[str, str],
|
env: dict[str, str],
|
||||||
timeout: int | None,
|
timeout: int,
|
||||||
shell_program: str | None,
|
shell_program: str | None,
|
||||||
login: bool,
|
login: bool,
|
||||||
yield_time_ms: int,
|
yield_time_ms: int,
|
||||||
|
|||||||
+1
-279
@@ -6,20 +6,13 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from contextlib import AsyncExitStack, suppress
|
from contextlib import AsyncExitStack, suppress
|
||||||
from typing import Any, Mapping
|
from typing import Any
|
||||||
from weakref import WeakKeyDictionary
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
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.
|
# Transient connection errors that warrant a single retry.
|
||||||
# These typically happen when an MCP server restarts or a network
|
# These typically happen when an MCP server restarts or a network
|
||||||
@@ -40,7 +33,6 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
|
|||||||
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
|
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
|
||||||
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
|
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
|
||||||
_SANITIZE_RE = re.compile(r"_+")
|
_SANITIZE_RE = re.compile(r"_+")
|
||||||
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_name(name: str) -> str:
|
def _sanitize_name(name: str) -> str:
|
||||||
@@ -511,7 +503,6 @@ async def connect_mcp_servers(
|
|||||||
command=command,
|
command=command,
|
||||||
args=args,
|
args=args,
|
||||||
env=env,
|
env=env,
|
||||||
cwd=cfg.cwd or None,
|
|
||||||
)
|
)
|
||||||
read, write = await server_stack.enter_async_context(stdio_client(params))
|
read, write = await server_stack.enter_async_context(stdio_client(params))
|
||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
@@ -671,272 +662,3 @@ async def connect_mcp_servers(
|
|||||||
server_stacks[result[0]] = result[1]
|
server_stacks[result[0]] = result[1]
|
||||||
|
|
||||||
return server_stacks
|
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):
|
class ExecToolConfig(Base):
|
||||||
"""Shell exec tool configuration."""
|
"""Shell exec tool configuration."""
|
||||||
enable: bool = True
|
enable: bool = True
|
||||||
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
|
timeout: int = 60
|
||||||
path_append: str = ""
|
path_append: str = ""
|
||||||
sandbox: str = ""
|
sandbox: str = ""
|
||||||
allowed_env_keys: list[str] = Field(default_factory=list)
|
allowed_env_keys: list[str] = Field(default_factory=list)
|
||||||
@@ -59,7 +59,7 @@ class _PreparedCommand:
|
|||||||
command: str
|
command: str
|
||||||
cwd: str
|
cwd: str
|
||||||
env: dict[str, str]
|
env: dict[str, str]
|
||||||
timeout: int | None
|
timeout: int
|
||||||
shell_program: str | None
|
shell_program: str | None
|
||||||
login: bool
|
login: bool
|
||||||
|
|
||||||
@@ -324,20 +324,6 @@ class ExecTool(Tool):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error executing command: {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(
|
def _prepare_command(
|
||||||
self,
|
self,
|
||||||
command: str,
|
command: str,
|
||||||
@@ -383,7 +369,7 @@ class ExecTool(Tool):
|
|||||||
command = wrap_command(self.sandbox, command, workspace, cwd)
|
command = wrap_command(self.sandbox, command, workspace, cwd)
|
||||||
cwd = str(Path(workspace).resolve())
|
cwd = str(Path(workspace).resolve())
|
||||||
|
|
||||||
effective_timeout = self._resolve_timeout(timeout)
|
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
|
||||||
env = self._build_env()
|
env = self._build_env()
|
||||||
|
|
||||||
if self.path_append:
|
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.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||||
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
@@ -17,15 +17,6 @@ if TYPE_CHECKING:
|
|||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
task=StringSchema("The task for the subagent to complete"),
|
task=StringSchema("The task for the subagent to complete"),
|
||||||
label=StringSchema("Optional short label for the task (for display)"),
|
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"],
|
required=["task"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -67,13 +58,7 @@ class SpawnTool(Tool, ContextAware):
|
|||||||
"and use a dedicated subdirectory when helpful."
|
"and use a dedicated subdirectory when helpful."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(
|
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
||||||
self,
|
|
||||||
task: str,
|
|
||||||
label: str | None = None,
|
|
||||||
temperature: float | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
"""Spawn a subagent to execute the given task."""
|
"""Spawn a subagent to execute the given task."""
|
||||||
running = self._manager.get_running_count()
|
running = self._manager.get_running_count()
|
||||||
limit = self._manager.max_concurrent_subagents
|
limit = self._manager.max_concurrent_subagents
|
||||||
@@ -90,5 +75,4 @@ class SpawnTool(Tool, ContextAware):
|
|||||||
origin_chat_id=self._origin_chat_id.get(),
|
origin_chat_id=self._origin_chat_id.get(),
|
||||||
session_key=self._session_key.get(),
|
session_key=self._session_key.get(),
|
||||||
origin_message_id=self._origin_message_id.get(),
|
origin_message_id=self._origin_message_id.get(),
|
||||||
temperature=temperature,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Shared app protocol helpers."""
|
|
||||||
|
|
||||||
from nanobot.apps.protocol import APP_PROTOCOL_SCHEMA, app_manifest
|
|
||||||
|
|
||||||
__all__ = ["APP_PROTOCOL_SCHEMA", "app_manifest"]
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""Neutral manifest shape for settings-managed agent apps.
|
|
||||||
|
|
||||||
The manifest is intentionally descriptive. Installers still live in their
|
|
||||||
own adapters, while this protocol gives the WebUI and future registries one
|
|
||||||
small vocabulary for capabilities, trust, and verified install/remove plans.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
APP_PROTOCOL_SCHEMA = "agent-app.v1"
|
|
||||||
|
|
||||||
|
|
||||||
def compact_dict(values: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Drop empty optional values while preserving explicit booleans and zeros."""
|
|
||||||
return {
|
|
||||||
key: value
|
|
||||||
for key, value in values.items()
|
|
||||||
if value is not None and value != "" and value != [] and value != {}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def app_manifest(
|
|
||||||
*,
|
|
||||||
app_id: str,
|
|
||||||
display_name: str,
|
|
||||||
description: str,
|
|
||||||
category: str,
|
|
||||||
source: str,
|
|
||||||
capabilities: list[dict[str, Any]],
|
|
||||||
install: dict[str, Any],
|
|
||||||
remove: dict[str, Any],
|
|
||||||
trust: dict[str, Any],
|
|
||||||
version: str | None = None,
|
|
||||||
logo_url: str | None = None,
|
|
||||||
brand_color: str | None = None,
|
|
||||||
docs_url: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Build a stable app manifest dictionary."""
|
|
||||||
return compact_dict({
|
|
||||||
"schema": APP_PROTOCOL_SCHEMA,
|
|
||||||
"id": app_id,
|
|
||||||
"display_name": display_name,
|
|
||||||
"version": version,
|
|
||||||
"description": description,
|
|
||||||
"category": category,
|
|
||||||
"source": source,
|
|
||||||
"logo_url": logo_url,
|
|
||||||
"brand_color": brand_color,
|
|
||||||
"docs_url": docs_url,
|
|
||||||
"capabilities": capabilities,
|
|
||||||
"install": install,
|
|
||||||
"remove": remove,
|
|
||||||
"trust": trust,
|
|
||||||
})
|
|
||||||
@@ -9,12 +9,6 @@ from typing import Any
|
|||||||
# render it and other channels may ignore unknown keys.
|
# render it and other channels may ignore unknown keys.
|
||||||
OUTBOUND_META_AGENT_UI = "_agent_ui"
|
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
|
@dataclass
|
||||||
class InboundMessage:
|
class InboundMessage:
|
||||||
@@ -51,3 +45,4 @@ class OutboundMessage:
|
|||||||
media: list[str] = field(default_factory=list)
|
media: list[str] = field(default_factory=list)
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
buttons: list[list[str]] = field(default_factory=list)
|
buttons: list[list[str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
+68
-110
@@ -30,7 +30,6 @@ from websockets.exceptions import ConnectionClosed
|
|||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
|
||||||
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
@@ -47,7 +46,6 @@ from nanobot.utils.media_decode import (
|
|||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
create_model_configuration,
|
|
||||||
settings_payload,
|
settings_payload,
|
||||||
update_agent_settings,
|
update_agent_settings,
|
||||||
update_image_generation_settings,
|
update_image_generation_settings,
|
||||||
@@ -59,32 +57,12 @@ from nanobot.webui.cli_apps_api import (
|
|||||||
cli_apps_payload,
|
cli_apps_payload,
|
||||||
normalize_cli_app_mentions,
|
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 (
|
from nanobot.webui.sidebar_state import (
|
||||||
read_webui_sidebar_state,
|
read_webui_sidebar_state,
|
||||||
write_webui_sidebar_state,
|
write_webui_sidebar_state,
|
||||||
)
|
)
|
||||||
from nanobot.webui.thread_disk import delete_webui_thread
|
from nanobot.webui.thread_disk import delete_webui_thread
|
||||||
from nanobot.webui.transcript import (
|
from nanobot.webui.transcript import append_transcript_object, build_webui_thread_response
|
||||||
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:
|
if TYPE_CHECKING:
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@@ -255,34 +233,6 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
|
|||||||
return _parse_request_path(path_with_query)[1]
|
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:
|
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
|
||||||
"""Return the first value for *key*, or None."""
|
"""Return the first value for *key*, or None."""
|
||||||
values = query.get(key)
|
values = query.get(key)
|
||||||
@@ -475,6 +425,18 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
|||||||
"video/webm",
|
"video/webm",
|
||||||
"video/quicktime",
|
"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:
|
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``."""
|
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||||
if not configured_secret:
|
if not configured_secret:
|
||||||
@@ -515,6 +477,10 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_chats: dict[Any, set[str]] = {}
|
self._conn_chats: dict[Any, set[str]] = {}
|
||||||
# connection -> default chat_id for legacy frames that omit routing.
|
# connection -> default chat_id for legacy frames that omit routing.
|
||||||
self._conn_default: dict[Any, str] = {}
|
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.
|
# Single-use tokens consumed at WebSocket handshake.
|
||||||
self._issued_tokens: dict[str, float] = {}
|
self._issued_tokens: dict[str, float] = {}
|
||||||
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
|
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
|
||||||
@@ -704,9 +670,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if got == "/api/settings/update":
|
if got == "/api/settings/update":
|
||||||
return self._handle_settings_update(request)
|
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":
|
if got == "/api/settings/provider/update":
|
||||||
return self._handle_settings_provider_update(request)
|
return self._handle_settings_provider_update(request)
|
||||||
|
|
||||||
@@ -731,13 +694,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if got == "/api/settings/cli-apps/test":
|
if got == "/api/settings/cli-apps/test":
|
||||||
return await self._handle_settings_cli_apps_action(request, "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)
|
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||||
if m:
|
if m:
|
||||||
return self._handle_session_messages(request, m.group(1))
|
return self._handle_session_messages(request, m.group(1))
|
||||||
@@ -929,16 +885,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._with_settings_restart_state(payload, section="runtime")
|
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:
|
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
||||||
if not self._check_api_token(request):
|
if not self._check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
@@ -995,31 +941,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return _http_error(status, message)
|
return _http_error(status, message)
|
||||||
return _http_json_response(payload)
|
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
|
@staticmethod
|
||||||
def _is_websocket_channel_session_key(key: str) -> bool:
|
def _is_websocket_channel_session_key(key: str) -> bool:
|
||||||
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
|
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
|
||||||
@@ -1111,9 +1032,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
cli_apps = meta.get("cli_apps")
|
cli_apps = meta.get("cli_apps")
|
||||||
if isinstance(cli_apps, list) and cli_apps:
|
if isinstance(cli_apps, list) and cli_apps:
|
||||||
user_obj["cli_apps"] = 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)
|
self._try_append_webui_transcript(chat_id, user_obj)
|
||||||
await super()._handle_message(
|
await super()._handle_message(
|
||||||
sender_id,
|
sender_id,
|
||||||
@@ -1203,12 +1121,45 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return None
|
return None
|
||||||
return {"url": signed, "name": path.name}
|
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:
|
def _rewrite_local_markdown_images(self, text: str) -> str:
|
||||||
return rewrite_local_markdown_images(
|
if "![" not in text:
|
||||||
text,
|
return text
|
||||||
workspace_path=self._workspace_path,
|
|
||||||
sign_path=self._sign_or_stage_media_path,
|
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)
|
||||||
|
|
||||||
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
|
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
|
||||||
"""Serve a single media file previously signed via
|
"""Serve a single media file previously signed via
|
||||||
@@ -1581,12 +1532,10 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
||||||
if envelope.get("webui") is True:
|
if envelope.get("webui") is True:
|
||||||
metadata["webui"] = True
|
metadata["webui"] = True
|
||||||
|
self._webui_chats.add(cid)
|
||||||
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
|
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
|
||||||
if cli_apps:
|
if cli_apps:
|
||||||
metadata["cli_apps"] = 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")
|
image_generation = envelope.get("image_generation")
|
||||||
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
|
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
|
||||||
aspect_ratio = image_generation.get("aspect_ratio")
|
aspect_ratio = image_generation.get("aspect_ratio")
|
||||||
@@ -1620,6 +1569,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._subs.clear()
|
self._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
|
self._webui_chats.clear()
|
||||||
self._issued_tokens.clear()
|
self._issued_tokens.clear()
|
||||||
self._api_tokens.clear()
|
self._api_tokens.clear()
|
||||||
|
|
||||||
@@ -1698,7 +1648,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
await self._safe_send_to(connection, raw, label=" ")
|
await self._safe_send_to(connection, raw, label=" ")
|
||||||
return
|
return
|
||||||
text = msg.content
|
text = msg.content
|
||||||
wire_text = self._rewrite_local_markdown_images(text)
|
should_rewrite_images = msg.chat_id in self._webui_chats
|
||||||
|
wire_text = self._rewrite_local_markdown_images(text) if should_rewrite_images else text
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"chat_id": msg.chat_id,
|
"chat_id": msg.chat_id,
|
||||||
@@ -1798,25 +1749,32 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
stream_key = (chat_id, str(meta.get("_stream_id") 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"):
|
if meta.get("_stream_end"):
|
||||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||||
|
if should_rewrite_images:
|
||||||
buffered = self._stream_text_buffers.pop(stream_key, [])
|
buffered = self._stream_text_buffers.pop(stream_key, [])
|
||||||
if delta:
|
if delta:
|
||||||
buffered.append(delta)
|
buffered.append(delta)
|
||||||
full_text = "".join(buffered)
|
full_text = "".join(buffered)
|
||||||
rewritten = self._rewrite_local_markdown_images(full_text)
|
rewritten = self._rewrite_local_markdown_images(full_text)
|
||||||
if rewritten != full_text:
|
if rewritten != full_text or delta:
|
||||||
body["text"] = rewritten
|
body["text"] = rewritten
|
||||||
|
transcript_body = {**body, "text": full_text}
|
||||||
else:
|
else:
|
||||||
body = {
|
body = {
|
||||||
"event": "delta",
|
"event": "delta",
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"text": delta,
|
"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:
|
if meta.get("_stream_id") is not None:
|
||||||
body["stream_id"] = meta["_stream_id"]
|
body["stream_id"] = meta["_stream_id"]
|
||||||
self._try_append_webui_transcript(chat_id, body)
|
if transcript_body is not None:
|
||||||
|
transcript_body["stream_id"] = meta["_stream_id"]
|
||||||
|
self._try_append_webui_transcript(chat_id, transcript_body or body)
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" stream ")
|
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.agent.loop import UNIFIED_SESSION_KEY
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|
||||||
def _channel_session_key(channel: str, chat_id: str) -> str:
|
def _channel_session_key(channel: str, chat_id: str) -> str:
|
||||||
return (
|
return (
|
||||||
@@ -810,13 +810,13 @@ def _run_gateway(
|
|||||||
# Set cron callback (needs agent)
|
# Set cron callback (needs agent)
|
||||||
async def on_cron_job(job: CronJob) -> str | None:
|
async def on_cron_job(job: CronJob) -> str | None:
|
||||||
"""Execute a cron job through the agent."""
|
"""Execute a cron job through the agent."""
|
||||||
|
# Dream is an internal job — run directly, not through the agent loop.
|
||||||
if job.name == "dream":
|
if job.name == "dream":
|
||||||
await bus.publish_inbound(InboundMessage(
|
try:
|
||||||
channel="system",
|
await agent.dream.run()
|
||||||
sender_id="dream",
|
logger.info("Dream cron job completed")
|
||||||
chat_id="dream",
|
except Exception:
|
||||||
content="",
|
logger.exception("Dream cron job failed")
|
||||||
))
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from nanobot.utils.evaluator import evaluate_response
|
from nanobot.utils.evaluator import evaluate_response
|
||||||
@@ -1027,10 +1027,11 @@ def _run_gateway(
|
|||||||
await server.serve_forever()
|
await server.serve_forever()
|
||||||
# Register Dream system job (always-on, idempotent on restart)
|
# Register Dream system job (always-on, idempotent on restart)
|
||||||
dream_cfg = config.agents.defaults.dream
|
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_batch_size = dream_cfg.max_batch_size
|
||||||
agent.dream.max_iterations = dream_cfg.max_iterations
|
agent.dream.max_iterations = dream_cfg.max_iterations
|
||||||
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
|
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
|
from nanobot.cron.types import CronJob, CronPayload
|
||||||
cron.register_system_job(CronJob(
|
cron.register_system_job(CronJob(
|
||||||
id="dream",
|
id="dream",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""CLI app adapter for the unified Apps domain."""
|
"""CLI Apps integration helpers."""
|
||||||
|
|
||||||
from nanobot.apps.cli.service import (
|
from nanobot.cli_apps.service import (
|
||||||
CliAppError,
|
CliAppError,
|
||||||
CliAppManager,
|
CliAppManager,
|
||||||
CliAppsRuntimeConfig,
|
CliAppsRuntimeConfig,
|
||||||
@@ -11,14 +11,12 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from importlib import metadata as importlib_metadata
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
|
||||||
from nanobot.config.paths import get_runtime_subdir
|
from nanobot.config.paths import get_runtime_subdir
|
||||||
|
|
||||||
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
||||||
@@ -141,7 +139,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
|
|||||||
|
|
||||||
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
||||||
"3mf": ("3mf.io", "#00A1DE"),
|
"3mf": ("3mf.io", "#00A1DE"),
|
||||||
"anygen": ("anygen.io", "#111827"),
|
"anygen": ("anygen.com", "#111827"),
|
||||||
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
||||||
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
||||||
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
||||||
@@ -246,29 +244,6 @@ def _pip_uninstall_args_from_command(command: str) -> list[str] | None:
|
|||||||
return packages
|
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:
|
def _brand_key(value: str) -> str:
|
||||||
return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-")
|
return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-")
|
||||||
|
|
||||||
@@ -565,86 +540,8 @@ class CliAppManager:
|
|||||||
"logo_url": logo_url,
|
"logo_url": logo_url,
|
||||||
"brand_color": brand_color,
|
"brand_color": brand_color,
|
||||||
"skill_installed": self._skill_path(name).is_file(),
|
"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]:
|
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||||
apps, updated = self.catalog(force_refresh=force_refresh)
|
apps, updated = self.catalog(force_refresh=force_refresh)
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
@@ -684,14 +581,7 @@ class CliAppManager:
|
|||||||
prefix.extend(["--upgrade", "--force-reinstall"])
|
prefix.extend(["--upgrade", "--force-reinstall"])
|
||||||
return prefix + args
|
return prefix + args
|
||||||
|
|
||||||
def _pip_uninstall_argv(
|
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
|
||||||
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 "")
|
uninstall_cmd = str(app.get("uninstall_cmd") or "")
|
||||||
packages = _pip_uninstall_args_from_command(uninstall_cmd)
|
packages = _pip_uninstall_args_from_command(uninstall_cmd)
|
||||||
if packages:
|
if packages:
|
||||||
@@ -729,19 +619,14 @@ class CliAppManager:
|
|||||||
raise CliAppError(f"unsupported {expected} command")
|
raise CliAppError(f"unsupported {expected} command")
|
||||||
return argv
|
return argv
|
||||||
|
|
||||||
def _argv_for_action(
|
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
|
||||||
self,
|
|
||||||
app: dict[str, Any],
|
|
||||||
action: str,
|
|
||||||
installed_entry: dict[str, Any] | None = None,
|
|
||||||
) -> list[str] | None:
|
|
||||||
strategy = self._strategy(app)
|
strategy = self._strategy(app)
|
||||||
if strategy == "pip":
|
if strategy == "pip":
|
||||||
if action == "install":
|
if action == "install":
|
||||||
return self._pip_install_argv(app)
|
return self._pip_install_argv(app)
|
||||||
if action == "update":
|
if action == "update":
|
||||||
return self._pip_install_argv(app, update=True)
|
return self._pip_install_argv(app, update=True)
|
||||||
return self._pip_uninstall_argv(app, installed_entry=installed_entry)
|
return self._pip_uninstall_argv(app)
|
||||||
if strategy == "npm":
|
if strategy == "npm":
|
||||||
return self._npm_argv(app, action)
|
return self._npm_argv(app, action)
|
||||||
if strategy == "brew":
|
if strategy == "brew":
|
||||||
@@ -763,23 +648,13 @@ class CliAppManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
|
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
entry_point = str(app.get("entry_point") or "")
|
return {
|
||||||
strategy = self._strategy(app)
|
|
||||||
entry: dict[str, Any] = {
|
|
||||||
"version": app.get("version") or "unknown",
|
"version": app.get("version") or "unknown",
|
||||||
"entry_point": entry_point,
|
"entry_point": app.get("entry_point") or "",
|
||||||
"source": app.get("_source") or "harness",
|
"source": app.get("_source") or "harness",
|
||||||
"strategy": strategy,
|
"strategy": self._strategy(app),
|
||||||
"installed_at": int(_now()),
|
"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:
|
def _fetch_skill_content(self, app: dict[str, Any]) -> str | None:
|
||||||
skill_md = str(app.get("skill_md") or "").strip()
|
skill_md = str(app.get("skill_md") or "").strip()
|
||||||
@@ -855,13 +730,11 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
if skill_dir.is_dir():
|
if skill_dir.is_dir():
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(skill_dir)
|
||||||
|
|
||||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
def _record_installed(self, app: dict[str, Any]) -> None:
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
entry = self._installed_entry(app)
|
installed[str(app["name"])] = self._installed_entry(app)
|
||||||
installed[str(app["name"])] = entry
|
|
||||||
self._save_installed(installed)
|
self._save_installed(installed)
|
||||||
self.install_skill(app)
|
self.install_skill(app)
|
||||||
return entry
|
|
||||||
|
|
||||||
def install(self, name: str) -> dict[str, Any]:
|
def install(self, name: str) -> dict[str, Any]:
|
||||||
app = self.get_app(name)
|
app = self.get_app(name)
|
||||||
@@ -872,14 +745,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
|
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
|
||||||
if detect_cmd and _command_exists(detect_cmd):
|
if detect_cmd and _command_exists(detect_cmd):
|
||||||
self._record_installed(app)
|
self._record_installed(app)
|
||||||
return self.payload() | {
|
return self.payload() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}}
|
||||||
"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."
|
note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app."
|
||||||
raise CliAppError(str(note))
|
raise CliAppError(str(note))
|
||||||
argv = self._argv_for_action(app, "install")
|
argv = self._argv_for_action(app, "install")
|
||||||
@@ -888,14 +754,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500)
|
raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500)
|
||||||
self._record_installed(app)
|
self._record_installed(app)
|
||||||
return self.payload() | {
|
return self.payload() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}}
|
||||||
"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]:
|
def update(self, name: str) -> dict[str, Any]:
|
||||||
app = self.get_app(name, force_refresh=True)
|
app = self.get_app(name, force_refresh=True)
|
||||||
@@ -903,94 +762,30 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
raise CliAppError("CLI app is not installed")
|
raise CliAppError("CLI app is not installed")
|
||||||
if self._strategy(app) == "bundled":
|
if self._strategy(app) == "bundled":
|
||||||
self._record_installed(app)
|
self._record_installed(app)
|
||||||
return self.payload() | {
|
return self.payload() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}}
|
||||||
"last_action": {
|
|
||||||
"ok": True,
|
|
||||||
"message": f"Checked {app['display_name']}.",
|
|
||||||
"installed": True,
|
|
||||||
"verification": ["state_recorded"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
argv = self._argv_for_action(app, "update")
|
argv = self._argv_for_action(app, "update")
|
||||||
assert argv is not None
|
assert argv is not None
|
||||||
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500)
|
raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500)
|
||||||
self._record_installed(app)
|
self._record_installed(app)
|
||||||
return self.payload() | {
|
return self.payload() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}}
|
||||||
"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]:
|
def uninstall(self, name: str) -> dict[str, Any]:
|
||||||
app = self.get_app(name)
|
app = self.get_app(name)
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
if str(app["name"]) not in installed:
|
if str(app["name"]) not in installed:
|
||||||
raise CliAppError("CLI app is not installed")
|
raise CliAppError("CLI app is not installed")
|
||||||
raw_installed_entry = installed.get(str(app["name"]))
|
if self._strategy(app) != "bundled":
|
||||||
installed_entry = raw_installed_entry if isinstance(raw_installed_entry, dict) else {}
|
argv = self._argv_for_action(app, "uninstall")
|
||||||
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
|
assert argv is not None
|
||||||
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise CliAppError(_truncate(result.stderr or result.stdout or "uninstall failed"), status=500)
|
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)
|
installed.pop(str(app["name"]), None)
|
||||||
self._save_installed(installed)
|
self._save_installed(installed)
|
||||||
self.remove_skill(str(app["name"]))
|
self.remove_skill(str(app["name"]))
|
||||||
if strategy == "bundled" and still_available:
|
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}}
|
||||||
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]:
|
def test(self, name: str) -> dict[str, Any]:
|
||||||
app = self.get_app(name)
|
app = self.get_app(name)
|
||||||
@@ -46,7 +46,7 @@ def _cli_app_runtime_lines(
|
|||||||
if "@" not in text:
|
if "@" not in text:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
from nanobot.apps.cli import CliAppManager
|
from nanobot.cli_apps import CliAppManager
|
||||||
|
|
||||||
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
|
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
+23
-32
@@ -299,22 +299,30 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
|
|||||||
|
|
||||||
async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Manually trigger a Dream consolidation run."""
|
"""Manually trigger a Dream consolidation run."""
|
||||||
from nanobot.bus.events import InboundMessage
|
import time
|
||||||
|
|
||||||
await ctx.loop.bus.publish_inbound(InboundMessage(
|
loop = ctx.loop
|
||||||
channel="system",
|
msg = ctx.msg
|
||||||
sender_id="dream",
|
|
||||||
chat_id="dream",
|
async def _run_dream():
|
||||||
content="",
|
t0 = time.monotonic()
|
||||||
metadata={
|
try:
|
||||||
"trigger_channel": ctx.msg.channel,
|
did_work = await loop.dream.run()
|
||||||
"trigger_chat_id": ctx.msg.chat_id,
|
elapsed = time.monotonic() - t0
|
||||||
},
|
if did_work:
|
||||||
|
content = f"Dream completed in {elapsed:.1f}s."
|
||||||
|
else:
|
||||||
|
content = "Dream: nothing to process."
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = time.monotonic() - t0
|
||||||
|
content = f"Dream failed after {elapsed:.1f}s: {e}"
|
||||||
|
await loop.bus.publish_outbound(OutboundMessage(
|
||||||
|
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
asyncio.create_task(_run_dream())
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=ctx.msg.channel,
|
channel=msg.channel, chat_id=msg.chat_id, content="Dreaming...",
|
||||||
chat_id=ctx.msg.chat_id,
|
|
||||||
content="Dream started. It will process memory backlog and report when done.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -347,18 +355,6 @@ def _format_changed_files(diff: str) -> str:
|
|||||||
|
|
||||||
def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str:
|
def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str:
|
||||||
files_line = _format_changed_files(diff)
|
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 = [
|
lines = [
|
||||||
"## Dream Update",
|
"## Dream Update",
|
||||||
"",
|
"",
|
||||||
@@ -366,12 +362,8 @@ def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None =
|
|||||||
"",
|
"",
|
||||||
f"- Commit: `{commit.sha}`",
|
f"- Commit: `{commit.sha}`",
|
||||||
f"- Time: {commit.timestamp}",
|
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:
|
if diff:
|
||||||
lines.extend([
|
lines.extend([
|
||||||
"",
|
"",
|
||||||
@@ -397,8 +389,7 @@ def _format_dream_restore_list(commits: list) -> str:
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for c in commits:
|
for c in commits:
|
||||||
summary = c.message.splitlines()[0] if c.message else "(no message)"
|
lines.append(f"- `{c.sha}` {c.timestamp} - {c.message.splitlines()[0]}")
|
||||||
lines.append(f"- `{c.sha}` {c.timestamp} - {summary}")
|
|
||||||
lines.extend([
|
lines.extend([
|
||||||
"",
|
"",
|
||||||
"Preview a version with `/dream-log <sha>` before restoring it.",
|
"Preview a version with `/dream-log <sha>` before restoring it.",
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import pydantic
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
# Global variable to store current config path (for multi-instance support)
|
# Global variable to store current config path (for multi-instance support)
|
||||||
_current_config_path: Path | None = None
|
_current_config_path: Path | None = None
|
||||||
_schema_refs_ready = False
|
|
||||||
|
|
||||||
|
|
||||||
def set_config_path(path: Path) -> None:
|
def set_config_path(path: Path) -> None:
|
||||||
@@ -40,11 +39,6 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
Returns:
|
Returns:
|
||||||
Loaded configuration object.
|
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()
|
path = config_path or get_config_path()
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|||||||
@@ -52,17 +52,14 @@ class DreamConfig(Base):
|
|||||||
model_override: str | None = Field(
|
model_override: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
||||||
) # Optional Dream-specific model override. Supports preset names (resolved against model_presets) or raw model identifiers.
|
) # Optional Dream-specific model override
|
||||||
max_batch_size: int = Field(default=5, ge=1) # Max history entries per run
|
max_batch_size: int = Field(default=20, ge=1) # Max history entries per run
|
||||||
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Dream run
|
# Bumped from 10 to 15 in #3212 (exp002: +30% dedup, no accuracy loss; >15 plateaus).
|
||||||
# Per-line git-blame age annotation in the Dream prompt (see #3212). Default
|
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Phase 2
|
||||||
# on — set to False to feed all memory files raw if a specific LLM reacts
|
# Per-line git-blame age annotation in Phase 1 prompt (see #3212). Default
|
||||||
# poorly to the `← Nd` suffix or you want deterministic, git-independent prompts.
|
# on — set to False to feed MEMORY.md raw if a specific LLM reacts poorly
|
||||||
|
# to the `← Nd` suffix or you want deterministic, git-independent prompts.
|
||||||
annotate_line_ages: bool = True
|
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:
|
def build_schedule(self, timezone: str) -> CronSchedule:
|
||||||
"""Build the runtime schedule, preferring the legacy cron override if present."""
|
"""Build the runtime schedule, preferring the legacy cron override if present."""
|
||||||
@@ -95,7 +92,6 @@ FallbackCandidate = str | InlineFallbackConfig
|
|||||||
class ModelPresetConfig(Base):
|
class ModelPresetConfig(Base):
|
||||||
"""A named set of model + generation parameters for quick switching."""
|
"""A named set of model + generation parameters for quick switching."""
|
||||||
|
|
||||||
label: str | None = None
|
|
||||||
model: str
|
model: str
|
||||||
provider: str = "auto"
|
provider: str = "auto"
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 8192
|
||||||
@@ -174,9 +170,8 @@ class ProviderConfig(Base):
|
|||||||
|
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
api_base: 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_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||||
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
|
extra_body: dict[str, Any] | None = None # Extra fields merged into every request body
|
||||||
|
|
||||||
|
|
||||||
class BedrockProviderConfig(ProviderConfig):
|
class BedrockProviderConfig(ProviderConfig):
|
||||||
@@ -227,16 +222,6 @@ class ProvidersConfig(Base):
|
|||||||
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
|
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
|
||||||
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys)
|
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):
|
class HeartbeatConfig(Base):
|
||||||
"""Heartbeat service configuration."""
|
"""Heartbeat service configuration."""
|
||||||
@@ -269,7 +254,6 @@ class MCPServerConfig(Base):
|
|||||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
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
|
url: str = "" # HTTP/SSE: endpoint URL
|
||||||
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
|
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
|
||||||
tool_timeout: int = 30 # seconds before a tool call is cancelled
|
tool_timeout: int = 30 # seconds before a tool call is cancelled
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ def _make_provider_core(
|
|||||||
extra_headers=p.extra_headers if p else None,
|
extra_headers=p.extra_headers if p else None,
|
||||||
spec=spec,
|
spec=spec,
|
||||||
extra_body=p.extra_body if p else None,
|
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()
|
provider.generation = resolved.to_generation_settings()
|
||||||
@@ -184,7 +183,6 @@ def provider_signature(
|
|||||||
config.get_api_base(fallback.model, preset=fallback),
|
config.get_api_base(fallback.model, preset=fallback),
|
||||||
fp.extra_headers if fp else None,
|
fp.extra_headers if fp else None,
|
||||||
fp.extra_body 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, "region", None) if fp else None,
|
||||||
getattr(fp, "profile", None) if fp else None,
|
getattr(fp, "profile", None) if fp else None,
|
||||||
fallback.max_tokens,
|
fallback.max_tokens,
|
||||||
@@ -201,7 +199,6 @@ def provider_signature(
|
|||||||
config.get_api_base(resolved.model, preset=resolved),
|
config.get_api_base(resolved.model, preset=resolved),
|
||||||
p.extra_headers if p else None,
|
p.extra_headers if p else None,
|
||||||
p.extra_body 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, "region", None) if p else None,
|
||||||
getattr(p, "profile", None) if p else None,
|
getattr(p, "profile", None) if p else None,
|
||||||
resolved.max_tokens,
|
resolved.max_tokens,
|
||||||
|
|||||||
@@ -1445,149 +1445,6 @@ def _stepfun_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
|||||||
return images
|
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
|
# Provider registration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1600,4 +1457,3 @@ register_image_gen_provider(MiniMaxImageGenerationClient)
|
|||||||
register_image_gen_provider(OpenAIImageGenerationClient)
|
register_image_gen_provider(OpenAIImageGenerationClient)
|
||||||
register_image_gen_provider(OpenRouterImageGenerationClient)
|
register_image_gen_provider(OpenRouterImageGenerationClient)
|
||||||
register_image_gen_provider(StepFunImageGenerationClient)
|
register_image_gen_provider(StepFunImageGenerationClient)
|
||||||
register_image_gen_provider(ZhipuImageGenerationClient)
|
|
||||||
|
|||||||
@@ -274,47 +274,6 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
|
|||||||
return merged
|
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):
|
class OpenAICompatProvider(LLMProvider):
|
||||||
"""Unified provider for all OpenAI-compatible APIs.
|
"""Unified provider for all OpenAI-compatible APIs.
|
||||||
|
|
||||||
@@ -330,14 +289,12 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
extra_headers: dict[str, str] | None = None,
|
extra_headers: dict[str, str] | None = None,
|
||||||
spec: ProviderSpec | None = None,
|
spec: ProviderSpec | None = None,
|
||||||
extra_body: dict[str, Any] | None = None,
|
extra_body: dict[str, Any] | None = None,
|
||||||
api_type: str = "auto",
|
|
||||||
):
|
):
|
||||||
super().__init__(api_key, api_base)
|
super().__init__(api_key, api_base)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self.extra_headers = extra_headers or {}
|
self.extra_headers = extra_headers or {}
|
||||||
self._spec = spec
|
self._spec = spec
|
||||||
self._extra_body = extra_body or {}
|
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:
|
if api_key and spec and spec.env_key:
|
||||||
self._setup_env(api_key, api_base)
|
self._setup_env(api_key, api_base)
|
||||||
@@ -471,10 +428,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
return tool_call_id
|
return tool_call_id
|
||||||
return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9]
|
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
|
@staticmethod
|
||||||
def _normalize_tool_call_arguments(arguments: Any) -> str:
|
def _normalize_tool_call_arguments(arguments: Any) -> str:
|
||||||
"""Force function.arguments into a valid JSON object string."""
|
"""Force function.arguments into a valid JSON object string."""
|
||||||
@@ -513,13 +466,10 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
id_map: dict[str, str] = {}
|
id_map: dict[str, str] = {}
|
||||||
pending_tool_ids: dict[str, deque[str]] = {}
|
pending_tool_ids: dict[str, deque[str]] = {}
|
||||||
force_string_content = bool(self._spec and self._spec.name == "deepseek")
|
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:
|
def map_id(value: Any) -> Any:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
if not normalize_tool_ids:
|
|
||||||
return value
|
|
||||||
return id_map.setdefault(value, self._normalize_tool_call_id(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:
|
def unique_tool_id(value: Any, used_ids: set[str], idx: int) -> str:
|
||||||
@@ -735,14 +685,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None,
|
reasoning_effort: str | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Use Responses API only for direct OpenAI requests that benefit from it."""
|
"""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"):
|
if self._spec and self._spec.name not in ("openai", "github_copilot"):
|
||||||
return False
|
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 self._spec is None or self._spec.name != "github_copilot":
|
||||||
if not _is_direct_openai_base(self._effective_base):
|
if not _is_direct_openai_base(self._effective_base):
|
||||||
return False
|
return False
|
||||||
@@ -756,14 +700,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if not wants:
|
if not wants:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return self._responses_circuit_allows_probe(model, reasoning_effort)
|
# Circuit breaker: skip after repeated failures, probe periodically.
|
||||||
|
|
||||||
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)
|
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
|
||||||
failures = self._responses_failures.get(key, 0)
|
failures = self._responses_failures.get(key, 0)
|
||||||
if failures >= _RESPONSES_FAILURE_THRESHOLD:
|
if failures >= _RESPONSES_FAILURE_THRESHOLD:
|
||||||
@@ -855,10 +792,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
body["tools"] = convert_tools(tools)
|
body["tools"] = convert_tools(tools)
|
||||||
body["tool_choice"] = tool_choice or "auto"
|
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
|
return body
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1023,7 +956,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
args = json_repair.loads(args)
|
args = json_repair.loads(args)
|
||||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||||
parsed_tool_calls.append(ToolCallRequest(
|
parsed_tool_calls.append(ToolCallRequest(
|
||||||
id=str(tc_map.get("id") or _short_tool_id()),
|
id=_short_tool_id(),
|
||||||
name=str(fn.get("name") or ""),
|
name=str(fn.get("name") or ""),
|
||||||
arguments=args if isinstance(args, dict) else {},
|
arguments=args if isinstance(args, dict) else {},
|
||||||
extra_content=ec,
|
extra_content=ec,
|
||||||
@@ -1066,7 +999,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
args = json_repair.loads(args)
|
args = json_repair.loads(args)
|
||||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||||
tool_calls.append(ToolCallRequest(
|
tool_calls.append(ToolCallRequest(
|
||||||
id=str(getattr(tc, "id", None) or _short_tool_id()),
|
id=_short_tool_id(),
|
||||||
name=tc.function.name,
|
name=tc.function.name,
|
||||||
arguments=args,
|
arguments=args,
|
||||||
extra_content=ec,
|
extra_content=ec,
|
||||||
@@ -1329,8 +1262,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# falling back to /chat/completions cannot succeed and would
|
# falling back to /chat/completions cannot succeed and would
|
||||||
# hide the real error.
|
# hide the real error.
|
||||||
raise
|
raise
|
||||||
if self._api_type == "responses":
|
|
||||||
raise
|
|
||||||
if not self._should_fallback_from_responses_error(responses_error):
|
if not self._should_fallback_from_responses_error(responses_error):
|
||||||
raise
|
raise
|
||||||
self._record_responses_failure(model, reasoning_effort)
|
self._record_responses_failure(model, reasoning_effort)
|
||||||
@@ -1404,8 +1335,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# falling back to /chat/completions cannot succeed and would
|
# falling back to /chat/completions cannot succeed and would
|
||||||
# hide the real error.
|
# hide the real error.
|
||||||
raise
|
raise
|
||||||
if self._api_type == "responses":
|
|
||||||
raise
|
|
||||||
if not self._should_fallback_from_responses_error(responses_error):
|
if not self._should_fallback_from_responses_error(responses_error):
|
||||||
raise
|
raise
|
||||||
self._record_responses_failure(model, reasoning_effort)
|
self._record_responses_failure(model, reasoning_effort)
|
||||||
|
|||||||
@@ -7,25 +7,6 @@ from pathlib import Path
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
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
|
# Up to 3 retries (4 attempts total) with exponential backoff on transient
|
||||||
# failures. Whisper endpoints occasionally return 502/503 under load, and
|
# failures. Whisper endpoints occasionally return 502/503 under load, and
|
||||||
# mobile-network transcription callers hit sporadic connect/read errors.
|
# mobile-network transcription callers hit sporadic connect/read errors.
|
||||||
@@ -146,12 +127,12 @@ class OpenAITranscriptionProvider:
|
|||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
):
|
):
|
||||||
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
||||||
self.api_url = _resolve_transcription_url(
|
self.api_url = (
|
||||||
api_base or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL"),
|
api_base
|
||||||
"https://api.openai.com/v1/audio/transcriptions",
|
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
|
||||||
|
or "https://api.openai.com/v1/audio/transcriptions"
|
||||||
)
|
)
|
||||||
self.language = language or None
|
self.language = language or None
|
||||||
logger.debug("OpenAI transcription endpoint: {}", self.api_url)
|
|
||||||
|
|
||||||
async def transcribe(self, file_path: str | Path) -> str:
|
async def transcribe(self, file_path: str | Path) -> str:
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
@@ -185,12 +166,12 @@ class GroqTranscriptionProvider:
|
|||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
):
|
):
|
||||||
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
||||||
self.api_url = _resolve_transcription_url(
|
self.api_url = (
|
||||||
api_base or os.environ.get("GROQ_BASE_URL"),
|
api_base
|
||||||
"https://api.groq.com/openai/v1/audio/transcriptions",
|
or os.environ.get("GROQ_BASE_URL")
|
||||||
|
or "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||||
)
|
)
|
||||||
self.language = language or None
|
self.language = language or None
|
||||||
logger.debug("Groq transcription endpoint: {}", self.api_url)
|
|
||||||
|
|
||||||
async def transcribe(self, file_path: str | Path) -> str:
|
async def transcribe(self, file_path: str | Path) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
|||||||
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
||||||
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
||||||
_SESSION_PREVIEW_MAX_CHARS = 120
|
_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:
|
def _sanitize_assistant_replay_text(content: str) -> str:
|
||||||
@@ -184,28 +182,6 @@ class Session:
|
|||||||
if cli_lines:
|
if cli_lines:
|
||||||
breadcrumbs = "\n".join(cli_lines)
|
breadcrumbs = "\n".join(cli_lines)
|
||||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
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:
|
if include_timestamps:
|
||||||
content = self._annotate_message_time(message, content)
|
content = self._annotate_message_time(message, content)
|
||||||
if role == "assistant" and isinstance(content, str) and not content.strip():
|
if role == "assistant" and isinstance(content, str) and not content.strip():
|
||||||
@@ -645,18 +621,9 @@ class SessionManager:
|
|||||||
title = metadata.get("title") if isinstance(metadata, dict) else None
|
title = metadata.get("title") if isinstance(metadata, dict) else None
|
||||||
preview = ""
|
preview = ""
|
||||||
fallback_preview = ""
|
fallback_preview = ""
|
||||||
scanned_records = 0
|
|
||||||
scanned_chars = 0
|
|
||||||
for line in f:
|
for line in f:
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
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)
|
item = json.loads(line)
|
||||||
if item.get("_type") == "metadata":
|
if item.get("_type") == "metadata":
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -34,5 +34,3 @@ Examples (replace `keyword`):
|
|||||||
- **Do NOT edit SOUL.md, USER.md, or MEMORY.md.** They are automatically managed by Dream.
|
- **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.
|
- If you notice outdated information, it will be corrected when Dream runs next.
|
||||||
- Users can view Dream's activity with the `/dream-log` command.
|
- Users can view Dream's activity with the `/dream-log` command.
|
||||||
- Dream runs as a `system` session inside the AgentLoop, triggered by the `/dream` command or cron. Each turn processes one batch; if backlog remains, Dream automatically chains additional turns until complete. All changes are committed in a single git commit.
|
|
||||||
- Dream can use a different model than the main agent via `agents.defaults.dream.modelOverride`. Supports preset names or raw model identifiers.
|
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
Extract key facts from this conversation. For each fact, annotate its memory attributes.
|
Extract key facts from this conversation. Only output items matching these categories, skip everything else:
|
||||||
|
- User facts: personal info, preferences, stated opinions, habits
|
||||||
|
- Decisions: choices made, conclusions reached
|
||||||
|
- Solutions: working approaches discovered through trial and error, especially non-obvious methods that succeeded after failed attempts
|
||||||
|
- Events: plans, deadlines, notable occurrences
|
||||||
|
- Preferences: communication style, tool preferences
|
||||||
|
|
||||||
Only SNIP facts deserve a non-[skip] mark:
|
Priority: user corrections and preferences > solutions > decisions > events > environment facts. The most valuable memory prevents the user from having to repeat themselves.
|
||||||
- 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
|
|
||||||
|
|
||||||
Output one fact per line in this format:
|
Skip: code patterns derivable from source, git history, or anything already captured in existing memory.
|
||||||
- [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)
|
If nothing noteworthy happened, output: (nothing)
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
Update memory files by analyzing conversation history and editing files directly.
|
|
||||||
Prune before adding — removing stale content is as important as adding new facts.
|
|
||||||
|
|
||||||
## File routing
|
|
||||||
Do NOT guess paths. Route each fact to its canonical file:
|
|
||||||
|
|
||||||
| File | Full path | Content |
|
|
||||||
|------|------|---------|
|
|
||||||
| SOUL.md | `{{ soul_path }}` | Agent behavior, guardrails, tone, interaction patterns |
|
|
||||||
| USER.md | `{{ user_path }}` | Personal info, preferences, habits, work context, communication style |
|
|
||||||
| MEMORY.md | `{{ memory_path }}` | Technical knowledge, project context, infrastructure, accounts |
|
|
||||||
| SKILL.md | `skills/<name>/SKILL.md` | Reusable workflow templates ([SKILL] entries only) |
|
|
||||||
|
|
||||||
Cross-boundary rule: no technical configs in USER.md, no user facts in SOUL.md, no preferences in MEMORY.md. If a fact fits multiple files, keep the most specific copy and remove the rest.
|
|
||||||
|
|
||||||
## Delete-or-keep
|
|
||||||
|
|
||||||
**Always delete:**
|
|
||||||
- Same fact at multiple locations — keep canonical copy only
|
|
||||||
- Merged/closed PR notes, resolved incidents, superseded info
|
|
||||||
- Verbose entries restatable in fewer words
|
|
||||||
- Overlapping or nested sections covering the same topic
|
|
||||||
|
|
||||||
**Likely delete** (apply judgment):
|
|
||||||
- Same fact at different detail levels — keep most complete version only
|
|
||||||
- Debugging steps unlikely to recur
|
|
||||||
- Ephemeral facts past their useful life
|
|
||||||
- Tool/service details documented upstream
|
|
||||||
- Lines with ``← Nd`` where N>{{ stale_threshold_days }} — closer review, not automatic removal
|
|
||||||
|
|
||||||
**Never delete:**
|
|
||||||
- User preferences and personality traits (permanent regardless of age)
|
|
||||||
- Active project context still referenced in conversations
|
|
||||||
- Behavioral rules in SOUL.md
|
|
||||||
|
|
||||||
When removing: prefer deleting individual items over entire sections.
|
|
||||||
|
|
||||||
## Fact extraction
|
|
||||||
- Atomic facts: "has a cat named Luna" not "discussed pet care"
|
|
||||||
- Corrections: edit the existing entry, don't append a new one
|
|
||||||
- Capture confirmed approaches the user validated
|
|
||||||
|
|
||||||
## Skill discovery & creation
|
|
||||||
Flag [SKILL] only when ALL are true: repeatable workflow appeared 2+ times, involves clear steps (not vague preferences), substantial enough for its own instruction set. Check existing skills to avoid redundancy.
|
|
||||||
|
|
||||||
For [SKILL] entries:
|
|
||||||
- Use write_file to create skills/<name>/SKILL.md; read_file `{{ skill_creator_path }}` for format reference
|
|
||||||
- YAML frontmatter must include name, description, **and `dream_managed: true`** (marks this skill as Dream-created)
|
|
||||||
- Under 2000 words: when to use, steps, output format, example
|
|
||||||
- Do NOT overwrite existing skills — if overlapping, merge delta into the existing skill
|
|
||||||
- Skills are instruction sets, not code. Keep concrete values in MEMORY.md; skills use placeholders
|
|
||||||
|
|
||||||
## Skill edit policy
|
|
||||||
Each skill in the Existing Skills list is tagged with an origin:
|
|
||||||
- **[dream]** — Dream-created (has `dream_managed: true` in frontmatter). You MAY edit these.
|
|
||||||
- **[user]** — User-created workspace skill. {% if dream_edit_user_skills %}You MAY edit these.{% else %}You MUST NOT modify, rename, or delete these — you can only read them for context.{% endif %}
|
|
||||||
- **[builtin]** — Bundled with nanobot. You MUST NEVER modify these.
|
|
||||||
|
|
||||||
## Editing
|
|
||||||
- Default tool: apply_patch. Use edit_file only for small exact replacements.
|
|
||||||
- File contents provided below — no read_file needed for initial edits.
|
|
||||||
- Batch all changes into a single apply_patch call. Surgical edits only.
|
|
||||||
- dry_run=true to preview. If nothing to update, stop without calling tools.
|
|
||||||
|
|
||||||
Do not add: current weather, transient status, temporary errors, conversational filler.
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
You have TWO equally important tasks:
|
||||||
|
1. Extract new facts from conversation history
|
||||||
|
2. Deduplicate existing memory files — find and flag redundant, overlapping, or stale content even if NOT mentioned in history
|
||||||
|
|
||||||
|
Output one line per finding:
|
||||||
|
[FILE] atomic fact (not already in memory)
|
||||||
|
[FILE-REMOVE] reason for removal
|
||||||
|
[SKILL] kebab-case-name: one-line description of the reusable pattern
|
||||||
|
|
||||||
|
Files: USER (identity, preferences), SOUL (bot behavior, tone), MEMORY (knowledge, project context)
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Atomic facts: "has a cat named Luna" not "discussed pet care"
|
||||||
|
- Corrections: [USER] location is Tokyo, not Osaka
|
||||||
|
- Capture confirmed approaches the user validated
|
||||||
|
|
||||||
|
Deduplication — scan ALL memory files for these redundancy patterns:
|
||||||
|
- Same fact stated in multiple places (e.g., "communicates in Chinese" in both USER.md and multiple MEMORY.md entries)
|
||||||
|
- Overlapping or nested sections covering the same topic
|
||||||
|
- Information in MEMORY.md that is already captured in USER.md or SOUL.md (MEMORY.md should not duplicate permanent-file content)
|
||||||
|
- Verbose entries that can be condensed without losing information
|
||||||
|
For each duplicate found, output [FILE-REMOVE] for the less authoritative copy (prefer keeping facts in their canonical location)
|
||||||
|
|
||||||
|
Staleness — MEMORY.md lines may have a ``← Nd`` suffix showing days since last modification:
|
||||||
|
- SOUL.md and USER.md have no age annotations — they are permanent, only update with corrections
|
||||||
|
- Age only indicates when content was last touched, not whether it should be removed
|
||||||
|
- Use content judgment: user habits/preferences/personality traits are permanent regardless of age
|
||||||
|
- Only prune content that is objectively outdated: passed events, resolved tracking, superseded approaches
|
||||||
|
- Lines with ``← Nd`` (N>{{ stale_threshold_days }}) deserve closer review but are NOT automatically removable
|
||||||
|
- When removing: prefer deleting individual items over entire sections
|
||||||
|
|
||||||
|
Skill discovery — flag [SKILL] when ALL of these are true:
|
||||||
|
- A specific, repeatable workflow appeared 2+ times in the conversation history
|
||||||
|
- It involves clear steps (not vague preferences like "likes concise answers")
|
||||||
|
- It is substantial enough to warrant its own instruction set (not trivial like "read a file")
|
||||||
|
- Do not worry about duplicates — the next phase will check against existing skills
|
||||||
|
|
||||||
|
Do not add: current weather, transient status, temporary errors, conversational filler.
|
||||||
|
|
||||||
|
[SKIP] if nothing needs updating.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
Update memory files based on the analysis below.
|
||||||
|
- [FILE] entries: add the described content to the appropriate file
|
||||||
|
- [FILE-REMOVE] entries: delete the corresponding content from memory files
|
||||||
|
- [SKILL] entries: create a new skill under skills/<name>/SKILL.md using write_file
|
||||||
|
|
||||||
|
## File paths (relative to workspace root)
|
||||||
|
- SOUL.md
|
||||||
|
- USER.md
|
||||||
|
- memory/MEMORY.md
|
||||||
|
- skills/<name>/SKILL.md (for [SKILL] entries only)
|
||||||
|
|
||||||
|
Do NOT guess paths.
|
||||||
|
|
||||||
|
## Editing rules
|
||||||
|
- Edit directly — file contents provided below, no read_file needed
|
||||||
|
- Use exact text as old_text, include surrounding blank lines for unique match
|
||||||
|
- Batch changes to the same file into one edit_file call
|
||||||
|
- For deletions: section header + all bullets as old_text, new_text empty
|
||||||
|
- Surgical edits only — never rewrite entire files
|
||||||
|
- If nothing to update, stop without calling tools
|
||||||
|
|
||||||
|
## Skill creation rules (for [SKILL] entries)
|
||||||
|
- Use write_file to create skills/<name>/SKILL.md
|
||||||
|
- Before writing, read_file `{{ skill_creator_path }}` for format reference (frontmatter structure, naming conventions, quality standards)
|
||||||
|
- **Dedup check**: read existing skills listed below to verify the new skill is not functionally redundant. Skip creation if an existing skill already covers the same workflow.
|
||||||
|
- Include YAML frontmatter with name and description fields
|
||||||
|
- Keep SKILL.md under 2000 words — concise and actionable
|
||||||
|
- Include: when to use, steps, output format, at least one example
|
||||||
|
- Do NOT overwrite existing skills — skip if the skill directory already exists
|
||||||
|
- Reference specific tools the agent has access to (read_file, write_file, exec, web_search, etc.)
|
||||||
|
- Skills are instruction sets, not code — do not include implementation code
|
||||||
|
|
||||||
|
## Quality
|
||||||
|
- Every line must carry standalone value
|
||||||
|
- Concise bullets under clear headers
|
||||||
|
- When reducing (not deleting): keep essential facts, drop verbose details
|
||||||
|
- If uncertain whether to delete, keep but add "(verify currency)"
|
||||||
@@ -19,8 +19,7 @@ class CommitInfo:
|
|||||||
|
|
||||||
def format(self, diff: str = "") -> str:
|
def format(self, diff: str = "") -> str:
|
||||||
"""Format this commit for display, optionally with a diff."""
|
"""Format this commit for display, optionally with a diff."""
|
||||||
summary = self.message.splitlines()[0] if self.message else "(no message)"
|
header = f"## {self.message.splitlines()[0]}\n`{self.sha}` — {self.timestamp}\n"
|
||||||
header = f"## {summary}\n`{self.sha}` — {self.timestamp}\n"
|
|
||||||
if diff:
|
if diff:
|
||||||
return f"{header}\n```diff\n{diff}\n```"
|
return f"{header}\n```diff\n{diff}\n```"
|
||||||
return f"{header}\n(no file changes)"
|
return f"{header}\n(no file changes)"
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ LENGTH_RECOVERY_PROMPT = (
|
|||||||
"— no recap, no apology. Break remaining work into smaller steps if needed."
|
"— 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:
|
def empty_tool_result_message(tool_name: str) -> str:
|
||||||
"""Short prompt-safe marker for tools that completed without visible output."""
|
"""Short prompt-safe marker for tools that completed without visible output."""
|
||||||
@@ -70,11 +65,6 @@ def build_length_recovery_message() -> dict[str, str]:
|
|||||||
return {"role": "user", "content": LENGTH_RECOVERY_PROMPT}
|
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:
|
def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str | None:
|
||||||
"""Stable signature for repeated external lookups we want to throttle."""
|
"""Stable signature for repeated external lookups we want to throttle."""
|
||||||
if tool_name == "web_fetch":
|
if tool_name == "web_fetch":
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
"""Compatibility exports for WebUI-attached MCP preset annotations."""
|
|
||||||
|
|
||||||
from nanobot.agent.tools.mcp import runtime_lines, session_extra
|
|
||||||
|
|
||||||
__all__ = ["runtime_lines", "session_extra"]
|
|
||||||
@@ -6,12 +6,10 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
get_image_gen_provider,
|
get_image_gen_provider,
|
||||||
image_gen_provider_names,
|
image_gen_provider_names,
|
||||||
@@ -43,7 +41,6 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
|||||||
"2:3",
|
"2:3",
|
||||||
"21:9",
|
"21:9",
|
||||||
}
|
}
|
||||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
|
||||||
|
|
||||||
|
|
||||||
class WebUISettingsError(ValueError):
|
class WebUISettingsError(ValueError):
|
||||||
@@ -103,32 +100,6 @@ def _parse_bool(value: str, field: str) -> bool:
|
|||||||
return normalized in {"1", "true", "yes"}
|
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]]:
|
def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for name in image_gen_provider_names():
|
for name in image_gen_provider_names():
|
||||||
@@ -181,7 +152,8 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
|
|||||||
provider_config = getattr(config.providers, spec.name, None)
|
provider_config = getattr(config.providers, spec.name, None)
|
||||||
if provider_config is None or spec.is_oauth:
|
if provider_config is None or spec.is_oauth:
|
||||||
continue
|
continue
|
||||||
row = {
|
providers.append(
|
||||||
|
{
|
||||||
"name": spec.name,
|
"name": spec.name,
|
||||||
"label": spec.label,
|
"label": spec.label,
|
||||||
"configured": _provider_configured_for_settings(spec, provider_config),
|
"configured": _provider_configured_for_settings(spec, provider_config),
|
||||||
@@ -190,9 +162,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
|
|||||||
"api_base": provider_config.api_base,
|
"api_base": provider_config.api_base,
|
||||||
"default_api_base": spec.default_api_base or None,
|
"default_api_base": spec.default_api_base or None,
|
||||||
}
|
}
|
||||||
if spec.name == "openai":
|
)
|
||||||
row["api_type"] = provider_config.api_type
|
|
||||||
providers.append(row)
|
|
||||||
|
|
||||||
search_config = config.tools.web.search
|
search_config = config.tools.web.search
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
@@ -228,7 +198,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
|
|||||||
model_presets.append(
|
model_presets.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"label": preset.label or name,
|
"label": name,
|
||||||
"active": active_preset_name == name,
|
"active": active_preset_name == name,
|
||||||
"is_default": False,
|
"is_default": False,
|
||||||
"model": preset.model,
|
"model": preset.model,
|
||||||
@@ -307,7 +277,6 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
|
|||||||
"max_batch_size": defaults.dream.max_batch_size,
|
"max_batch_size": defaults.dream.max_batch_size,
|
||||||
"max_iterations": defaults.dream.max_iterations,
|
"max_iterations": defaults.dream.max_iterations,
|
||||||
"annotate_line_ages": defaults.dream.annotate_line_ages,
|
"annotate_line_ages": defaults.dream.annotate_line_ages,
|
||||||
"dream_edit_user_skills": defaults.dream.dream_edit_user_skills,
|
|
||||||
},
|
},
|
||||||
"unified_session": defaults.unified_session,
|
"unified_session": defaults.unified_session,
|
||||||
},
|
},
|
||||||
@@ -352,7 +321,15 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
provider = provider.strip()
|
provider = provider.strip()
|
||||||
if not provider:
|
if not provider:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
_validate_configured_provider(config, provider)
|
spec = find_by_name(provider)
|
||||||
|
if spec is None:
|
||||||
|
raise WebUISettingsError("unknown provider")
|
||||||
|
provider_config = getattr(config.providers, provider, None)
|
||||||
|
if (
|
||||||
|
provider_config is None
|
||||||
|
or not _provider_configured_for_settings(spec, provider_config)
|
||||||
|
):
|
||||||
|
raise WebUISettingsError("provider is not configured")
|
||||||
if defaults.provider != provider:
|
if defaults.provider != provider:
|
||||||
defaults.provider = provider
|
defaults.provider = provider
|
||||||
changed = True
|
changed = True
|
||||||
@@ -411,40 +388,6 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
return settings_payload(requires_restart=restart_required)
|
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]:
|
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
@@ -473,17 +416,6 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
provider_config.api_base = api_base
|
provider_config.api_base = api_base
|
||||||
changed = True
|
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:
|
if changed:
|
||||||
save_config(config)
|
save_config(config)
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Mapping
|
from typing import Any, Callable
|
||||||
from urllib.parse import unquote, urlparse
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -18,61 +16,6 @@ from nanobot.session.manager import SessionManager
|
|||||||
|
|
||||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
|
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
|
||||||
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
|
_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:
|
def webui_transcript_path(session_key: str) -> Path:
|
||||||
@@ -515,11 +458,6 @@ def replay_transcript_to_ui_messages(
|
|||||||
cli_apps = rec.get("cli_apps")
|
cli_apps = rec.get("cli_apps")
|
||||||
if isinstance(cli_apps, list) and cli_apps:
|
if isinstance(cli_apps, list) and cli_apps:
|
||||||
row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)]
|
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)
|
messages.append(row)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
+216
-397
@@ -1,32 +1,19 @@
|
|||||||
"""Tests for Dream driven through AgentLoop._process_system_message."""
|
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from nanobot.agent.memory import Dream, MemoryStore
|
||||||
from nanobot.agent.runner import AgentRunResult
|
from nanobot.agent.runner import AgentRunResult
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
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
|
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
|
@pytest.fixture
|
||||||
def store(tmp_path):
|
def store(tmp_path):
|
||||||
from nanobot.agent.memory import MemoryStore
|
|
||||||
|
|
||||||
s = MemoryStore(tmp_path)
|
s = MemoryStore(tmp_path)
|
||||||
s.write_soul("# Soul\n- Helpful")
|
s.write_soul("# Soul\n- Helpful")
|
||||||
s.write_user("# User\n- Developer")
|
s.write_user("# User\n- Developer")
|
||||||
@@ -36,7 +23,9 @@ def store(tmp_path):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_provider():
|
def mock_provider():
|
||||||
return _provider("test-model")
|
p = MagicMock()
|
||||||
|
p.chat_with_retry = AsyncMock()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -45,16 +34,10 @@ def mock_runner():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def loop(tmp_path, mock_provider, mock_runner):
|
def dream(store, mock_provider, mock_runner):
|
||||||
loop = AgentLoop(
|
d = Dream(store=store, provider=mock_provider, model="test-model", max_batch_size=5)
|
||||||
bus=MessageBus(),
|
d._runner = mock_runner
|
||||||
provider=mock_provider,
|
return d
|
||||||
workspace=tmp_path,
|
|
||||||
model="test-model",
|
|
||||||
context_window_tokens=1000,
|
|
||||||
)
|
|
||||||
loop.dream._runner = mock_runner
|
|
||||||
return loop
|
|
||||||
|
|
||||||
|
|
||||||
def _make_run_result(
|
def _make_run_result(
|
||||||
@@ -73,418 +56,254 @@ def _make_run_result(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDreamAgentLoopIntegration:
|
class TestDreamRun:
|
||||||
async def test_completes_goal_state_after_full_backlog(self, loop, mock_runner, store):
|
async def test_noop_when_no_unprocessed_history(self, dream, mock_provider, mock_runner, store):
|
||||||
"""Goal should be completed after processing all backlog in internal loop."""
|
"""Dream should not call LLM when there's nothing to process."""
|
||||||
for i in range(6):
|
result = await dream.run()
|
||||||
store.append_history(f"event {i}")
|
assert result is False
|
||||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
mock_provider.chat_with_retry.assert_not_called()
|
||||||
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()
|
mock_runner.run.assert_not_called()
|
||||||
|
|
||||||
async def test_calls_runner_for_unprocessed_entries(self, loop, mock_runner, store):
|
async def test_calls_runner_for_unprocessed_entries(self, dream, mock_provider, mock_runner, store):
|
||||||
"""Dream should call AgentRunner when there are unprocessed history entries."""
|
"""Dream should call AgentRunner when there are unprocessed history entries."""
|
||||||
store.append_history("User prefers dark mode")
|
store.append_history("User prefers dark mode")
|
||||||
mock_runner.run = AsyncMock(
|
mock_provider.chat_with_retry.return_value = MagicMock(content="New fact")
|
||||||
return_value=_make_run_result(
|
mock_runner.run = AsyncMock(return_value=_make_run_result(
|
||||||
tool_events=[
|
tool_events=[{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}],
|
||||||
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
|
))
|
||||||
],
|
result = await dream.run()
|
||||||
)
|
assert result is True
|
||||||
)
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
||||||
)
|
|
||||||
await loop._process_system_message(msg)
|
|
||||||
mock_runner.run.assert_called_once()
|
mock_runner.run.assert_called_once()
|
||||||
spec = mock_runner.run.call_args[0][0]
|
spec = mock_runner.run.call_args[0][0]
|
||||||
assert spec.max_iterations == 10
|
assert spec.max_iterations == 10
|
||||||
assert spec.fail_on_tool_error is False
|
assert spec.fail_on_tool_error is False
|
||||||
|
|
||||||
async def test_advances_dream_cursor(self, loop, mock_runner, store):
|
async def test_advances_dream_cursor(self, dream, mock_provider, mock_runner, store):
|
||||||
"""Dream should advance the cursor after processing."""
|
"""Dream should advance the cursor after processing."""
|
||||||
store.append_history("event 1")
|
store.append_history("event 1")
|
||||||
store.append_history("event 2")
|
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())
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
msg = InboundMessage(
|
await dream.run()
|
||||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
||||||
)
|
|
||||||
await loop._process_system_message(msg)
|
|
||||||
assert store.get_last_dream_cursor() == 2
|
assert store.get_last_dream_cursor() == 2
|
||||||
|
|
||||||
async def test_compacts_processed_history(self, loop, mock_runner, store):
|
async def test_compacts_processed_history(self, dream, mock_provider, mock_runner, store):
|
||||||
"""Dream should compact history after processing."""
|
"""Dream should compact history after processing."""
|
||||||
store.append_history("event 1")
|
store.append_history("event 1")
|
||||||
store.append_history("event 2")
|
store.append_history("event 2")
|
||||||
store.append_history("event 3")
|
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())
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
msg = InboundMessage(
|
await dream.run()
|
||||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
# After Dream, cursor is advanced and 3, compact keeps last max_history_entries
|
||||||
)
|
|
||||||
await loop._process_system_message(msg)
|
|
||||||
entries = store.read_unprocessed_history(since_cursor=0)
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
assert all(e["cursor"] > 0 for e in entries)
|
assert all(e["cursor"] > 0 for e in entries)
|
||||||
|
|
||||||
async def test_processes_full_backlog_in_one_call(self, loop, mock_runner, store):
|
async def test_skill_phase_uses_builtin_skill_creator_path(self, dream, mock_provider, mock_runner, store):
|
||||||
"""Backlog larger than max_batch_size should be fully processed in one call."""
|
"""Dream should point skill creation guidance at the builtin skill-creator template."""
|
||||||
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 one")
|
||||||
store.append_history("Repeated workflow two")
|
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())
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
msg = InboundMessage(
|
|
||||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
await dream.run()
|
||||||
)
|
|
||||||
await loop._process_system_message(msg)
|
|
||||||
spec = mock_runner.run.call_args[0][0]
|
spec = mock_runner.run.call_args[0][0]
|
||||||
system_prompt = spec.initial_messages[0]["content"]
|
system_prompt = spec.initial_messages[0]["content"]
|
||||||
expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
|
expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
|
||||||
assert expected in system_prompt
|
assert expected in system_prompt
|
||||||
|
|
||||||
async def test_system_prompt_uses_threshold_from_template_var(self, loop, mock_runner, store):
|
async def test_skill_write_tool_accepts_workspace_relative_skill_path(self, dream, store):
|
||||||
store.append_history("some event")
|
"""Dream skill creation should allow skills/<name>/SKILL.md relative to workspace root."""
|
||||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
write_tool = dream._tools.get("write_file")
|
||||||
msg = InboundMessage(
|
assert write_tool is not None
|
||||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
||||||
|
result = await write_tool.execute(
|
||||||
|
path="skills/test-skill/SKILL.md",
|
||||||
|
content="---\nname: test-skill\ndescription: Test\n---\n",
|
||||||
)
|
)
|
||||||
await loop._process_system_message(msg)
|
|
||||||
spec = mock_runner.run.call_args[0][0]
|
assert "Successfully wrote" in result
|
||||||
system_msg = spec.initial_messages[0]["content"]
|
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
async def test_phase1_prompt_includes_line_age_annotations(self, dream, mock_provider, mock_runner, store):
|
||||||
|
"""Phase 1 prompt should have per-line age suffixes in MEMORY.md when git is available."""
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
|
||||||
|
# Init git so line_ages works
|
||||||
|
store.git.init()
|
||||||
|
store.git.auto_commit("initial memory state")
|
||||||
|
|
||||||
|
await dream.run()
|
||||||
|
|
||||||
|
# The MEMORY.md section should not crash and should contain the memory content
|
||||||
|
call_args = mock_provider.chat_with_retry.call_args
|
||||||
|
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||||
|
assert "## Current MEMORY.md" in user_msg
|
||||||
|
|
||||||
|
async def test_phase1_annotates_only_memory_not_soul_or_user(self, dream, mock_provider, mock_runner, store):
|
||||||
|
"""SOUL.md and USER.md should never have age annotations — they are permanent."""
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
|
||||||
|
store.git.init()
|
||||||
|
store.git.auto_commit("initial state")
|
||||||
|
|
||||||
|
await dream.run()
|
||||||
|
|
||||||
|
call_args = mock_provider.chat_with_retry.call_args
|
||||||
|
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||||
|
# The ← suffix should only appear in MEMORY.md section
|
||||||
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||||
|
soul_section = user_msg.split("## Current SOUL.md")[1].split("## Current USER.md")[0]
|
||||||
|
user_section = user_msg.split("## Current USER.md")[1]
|
||||||
|
# SOUL and USER should not contain age arrows
|
||||||
|
assert "\u2190" not in soul_section
|
||||||
|
assert "\u2190" not in user_section
|
||||||
|
|
||||||
|
async def test_phase1_prompt_works_without_git(self, dream, mock_provider, mock_runner, store):
|
||||||
|
"""Phase 1 should work fine even if git is not initialized (no age annotations)."""
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
|
||||||
|
await dream.run()
|
||||||
|
|
||||||
|
# Should still succeed — just without age annotations
|
||||||
|
mock_provider.chat_with_retry.assert_called_once()
|
||||||
|
call_args = mock_provider.chat_with_retry.call_args
|
||||||
|
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||||
|
assert "## Current MEMORY.md" in user_msg
|
||||||
|
|
||||||
|
async def test_phase1_prompt_carries_age_suffix_for_stale_lines(
|
||||||
|
self, dream, mock_provider, mock_runner, store,
|
||||||
|
):
|
||||||
|
"""End-to-end: ages >14d must appear verbatim in the LLM prompt, ages ≤14d must not."""
|
||||||
|
# MEMORY.md fixture has 2 non-blank lines ("# Memory" and "- Project X active").
|
||||||
|
# Inject four ages to cover threshold boundaries: >14 suffix, ==14 no suffix, <14 no suffix.
|
||||||
|
store.write_memory("# Memory\n- Project X active\n- fresh item\n- edge case line")
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
|
||||||
|
fake_ages = [
|
||||||
|
LineAge(age_days=30), # "# Memory" → should get ← 30d
|
||||||
|
LineAge(age_days=20), # "- Project X..." → should get ← 20d
|
||||||
|
LineAge(age_days=14), # "- fresh item" → ==14, threshold is strictly >14, no suffix
|
||||||
|
LineAge(age_days=5), # "- edge case..." → no suffix
|
||||||
|
]
|
||||||
|
with patch.object(store.git, "line_ages", return_value=fake_ages):
|
||||||
|
await dream.run()
|
||||||
|
|
||||||
|
call_args = mock_provider.chat_with_retry.call_args
|
||||||
|
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||||
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||||
|
assert "\u2190 30d" in memory_section
|
||||||
|
assert "\u2190 20d" in memory_section
|
||||||
|
assert "\u2190 14d" not in memory_section
|
||||||
|
assert "\u2190 5d" not in memory_section
|
||||||
|
|
||||||
|
async def test_phase1_skips_annotation_when_disabled(
|
||||||
|
self, dream, mock_provider, mock_runner, store,
|
||||||
|
):
|
||||||
|
"""`annotate_line_ages=False` must bypass the git lookup entirely and keep MEMORY.md raw."""
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
|
||||||
|
dream.annotate_line_ages = False
|
||||||
|
# line_ages must be bypassed entirely — verify with a spy rather than a
|
||||||
|
# raising side_effect, because _annotate_with_ages catches Exception
|
||||||
|
# (which swallows AssertionError) and would hide an accidental call.
|
||||||
|
with patch.object(store.git, "line_ages") as mock_line_ages:
|
||||||
|
await dream.run()
|
||||||
|
mock_line_ages.assert_not_called()
|
||||||
|
|
||||||
|
call_args = mock_provider.chat_with_retry.call_args
|
||||||
|
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||||
|
assert "\u2190" not in user_msg
|
||||||
|
|
||||||
|
async def test_phase1_skips_annotation_on_line_ages_length_mismatch(
|
||||||
|
self, dream, mock_provider, mock_runner, store,
|
||||||
|
):
|
||||||
|
"""If ages length != lines length (dirty working tree), skip annotation instead of mis-tagging."""
|
||||||
|
# MEMORY.md has 2 non-blank lines but we hand back only 1 age → mismatch.
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
|
||||||
|
with patch.object(store.git, "line_ages", return_value=[LineAge(age_days=999)]):
|
||||||
|
await dream.run()
|
||||||
|
|
||||||
|
call_args = mock_provider.chat_with_retry.call_args
|
||||||
|
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||||
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||||
|
# No age arrow at all — we refused to annotate rather than tag the wrong line.
|
||||||
|
assert "\u2190" not in memory_section
|
||||||
|
|
||||||
|
async def test_phase1_prompt_uses_threshold_from_template_var(
|
||||||
|
self, dream, mock_provider, mock_runner, store,
|
||||||
|
):
|
||||||
|
"""System prompt should reference the stale-threshold constant, not a hardcoded 14."""
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
|
||||||
|
await dream.run()
|
||||||
|
|
||||||
|
system_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][0]["content"]
|
||||||
|
# The template renders with stale_threshold_days=14 → LLM must see "N>14"
|
||||||
assert "N>14" in system_msg
|
assert "N>14" in system_msg
|
||||||
|
|
||||||
|
|
||||||
class TestDreamPromptCaps:
|
class TestDreamPromptCaps:
|
||||||
async def test_caps_huge_memory_file(self, loop, mock_runner, store):
|
"""Dream's Phase 1/2 prompt must not be poisoned by a legacy oversized
|
||||||
store.write_memory("M" * (loop.dream._MEMORY_FILE_MAX_CHARS * 5))
|
history entry or a runaway MEMORY.md. Without caps, a single pre-#3412
|
||||||
store.append_history("some event")
|
raw_archive dump in history.jsonl would make every subsequent Dream run
|
||||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
exceed the context window and silently advance the cursor past real work.
|
||||||
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
|
|
||||||
|
|
||||||
async def test_caps_huge_history_entry(self, loop, mock_runner, store):
|
async def test_phase1_caps_huge_memory_file(
|
||||||
|
self, dream, mock_provider, mock_runner, store,
|
||||||
|
):
|
||||||
|
"""A MEMORY.md much larger than _MEMORY_FILE_MAX_CHARS must be truncated
|
||||||
|
in the prompt preview (full content is still reachable via read_file)."""
|
||||||
|
store.write_memory("M" * (dream._MEMORY_FILE_MAX_CHARS * 5))
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
|
||||||
|
await dream.run()
|
||||||
|
|
||||||
|
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||||
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||||
|
assert len(memory_section) < dream._MEMORY_FILE_MAX_CHARS + 500
|
||||||
|
|
||||||
|
async def test_phase1_caps_huge_history_entry(
|
||||||
|
self, dream, mock_provider, mock_runner, store,
|
||||||
|
):
|
||||||
|
"""A legacy oversized history entry (e.g. pre-#3412 raw_archive dump)
|
||||||
|
must not explode the Phase 1 prompt — each entry is capped in the
|
||||||
|
preview, even though the JSONL record itself stays full-size."""
|
||||||
|
# Bypass the append_history cap by writing directly, simulating a
|
||||||
|
# record that was written by an older nanobot build before any caps.
|
||||||
store.history_file.write_text(
|
store.history_file.write_text(
|
||||||
json.dumps(
|
json.dumps({
|
||||||
{
|
|
||||||
"cursor": 1,
|
"cursor": 1,
|
||||||
"timestamp": "2026-04-01 10:00",
|
"timestamp": "2026-04-01 10:00",
|
||||||
"content": "H" * (loop.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
"content": "H" * (dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
||||||
}
|
}) + "\n",
|
||||||
)
|
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
msg = InboundMessage(
|
|
||||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
||||||
)
|
|
||||||
await loop._process_system_message(msg)
|
|
||||||
spec = mock_runner.run.call_args[0][0]
|
|
||||||
user_msg = spec.initial_messages[1]["content"]
|
|
||||||
history_section = user_msg.split("## Conversation History\n")[1].split(
|
|
||||||
"\n\n## Current Date"
|
|
||||||
)[0]
|
|
||||||
assert len(history_section) < loop.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
|
|
||||||
|
|
||||||
|
await dream.run()
|
||||||
|
|
||||||
class TestDreamTools:
|
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||||
def test_apply_patch_tool_registered(self, loop):
|
history_section = user_msg.split("## Conversation History\n")[1].split("\n\n## Current Date")[0]
|
||||||
tool = loop.dream._tools.get("apply_patch")
|
assert len(history_section) < dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
|
||||||
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,39 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from contextlib import AsyncExitStack
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
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.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:
|
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop:
|
||||||
@@ -69,152 +42,3 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
|
|||||||
assert attempts == 2
|
assert attempts == 2
|
||||||
assert loop._mcp_connected is False
|
assert loop._mcp_connected is False
|
||||||
assert loop._mcp_stacks == {}
|
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()
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
"""Tests for memory system: Consolidator, token estimation, truncation."""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.memory import _TIKTOKEN_ENC, Consolidator, MemoryStore, _estimate_tokens
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def store(tmp_path):
|
|
||||||
s = MemoryStore(tmp_path)
|
|
||||||
s.write_soul("# Soul\n- Helpful")
|
|
||||||
s.write_user("# User\n- Developer")
|
|
||||||
s.write_memory("# Memory\n- Project X active")
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_provider():
|
|
||||||
p = MagicMock()
|
|
||||||
p.chat_with_retry = AsyncMock()
|
|
||||||
p.generation.max_tokens = 4096
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_sessions():
|
|
||||||
return MagicMock()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_build_messages():
|
|
||||||
return MagicMock(return_value=[])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_get_tool_definitions():
|
|
||||||
return MagicMock(return_value=[])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def consolidator(store, mock_provider, mock_sessions, mock_build_messages, mock_get_tool_definitions):
|
|
||||||
return Consolidator(
|
|
||||||
store=store,
|
|
||||||
provider=mock_provider,
|
|
||||||
model="test-model",
|
|
||||||
sessions=mock_sessions,
|
|
||||||
context_window_tokens=128_000,
|
|
||||||
build_messages=mock_build_messages,
|
|
||||||
get_tool_definitions=mock_get_tool_definitions,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEstimateTokens:
|
|
||||||
def test_estimate_tokens_returns_positive(self):
|
|
||||||
assert _estimate_tokens("hello world") > 0
|
|
||||||
|
|
||||||
def test_estimate_tokens_english_approximate(self):
|
|
||||||
# English is roughly 1 token per 4 chars as fallback
|
|
||||||
text = "a " * 100
|
|
||||||
if _TIKTOKEN_ENC is not None:
|
|
||||||
expected = len(_TIKTOKEN_ENC.encode(text))
|
|
||||||
else:
|
|
||||||
expected = len(text) // 4
|
|
||||||
assert _estimate_tokens(text) == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestTruncateToTokenBudget:
|
|
||||||
def test_reserve_tokens_reduces_budget(self, consolidator):
|
|
||||||
long_text = "word " * 200_000
|
|
||||||
# Without reserve, more text survives
|
|
||||||
no_reserve = consolidator._truncate_to_token_budget(long_text, reserve_tokens=0)
|
|
||||||
with_reserve = consolidator._truncate_to_token_budget(long_text, reserve_tokens=500)
|
|
||||||
assert len(with_reserve) < len(no_reserve)
|
|
||||||
|
|
||||||
def test_reserve_tokens_zero_default(self, consolidator):
|
|
||||||
text = "hello world"
|
|
||||||
result = consolidator._truncate_to_token_budget(text)
|
|
||||||
assert result == text
|
|
||||||
|
|
||||||
|
|
||||||
class TestConsolidatorPrompt:
|
|
||||||
def test_prompt_contains_snip(self):
|
|
||||||
from nanobot.utils.prompt_templates import render_template
|
|
||||||
text = render_template("agent/consolidator_archive.md", strip=True)
|
|
||||||
assert "SNIP" in text
|
|
||||||
assert "[permanent]" in text
|
|
||||||
assert "[skip]" in text
|
|
||||||
|
|
||||||
|
|
||||||
class TestConsolidatorArchive:
|
|
||||||
async def test_archive_injects_dedup_context(self, consolidator, mock_provider, store):
|
|
||||||
store.write_memory("- User prefers dark mode")
|
|
||||||
store.write_user("- Developer")
|
|
||||||
messages = [{"role": "user", "content": "hello", "timestamp": "2026-01-01 10:00"}]
|
|
||||||
|
|
||||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
|
||||||
content="(nothing)", finish_reason="stop"
|
|
||||||
)
|
|
||||||
await consolidator.archive(messages)
|
|
||||||
|
|
||||||
call_args = mock_provider.chat_with_retry.call_args
|
|
||||||
user_msg = call_args.kwargs["messages"][1]["content"]
|
|
||||||
assert "## Current MEMORY.md (for dedup)" in user_msg
|
|
||||||
assert "User prefers dark mode" in user_msg
|
|
||||||
assert "## Current USER.md (for dedup)" in user_msg
|
|
||||||
assert "Developer" in user_msg
|
|
||||||
|
|
||||||
async def test_archive_skips_dedup_when_budget_exhausted(self, consolidator, mock_provider, store):
|
|
||||||
# Shrink token budget so dedup context (always capped at ~6000 chars)
|
|
||||||
# exceeds the available room.
|
|
||||||
consolidator.context_window_tokens = 6_000
|
|
||||||
store.write_memory("word " * 10_000)
|
|
||||||
messages = [{"role": "user", "content": "hello", "timestamp": "2026-01-01 10:00"}]
|
|
||||||
|
|
||||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
|
||||||
content="(nothing)", finish_reason="stop"
|
|
||||||
)
|
|
||||||
await consolidator.archive(messages)
|
|
||||||
|
|
||||||
call_args = mock_provider.chat_with_retry.call_args
|
|
||||||
user_msg = call_args.kwargs["messages"][1]["content"]
|
|
||||||
# Should not contain dedup context when budget is exhausted
|
|
||||||
assert "## Current MEMORY.md (for dedup)" not in user_msg
|
|
||||||
@@ -241,7 +241,7 @@ def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
|
|||||||
signature = provider_signature(config)
|
signature = provider_signature(config)
|
||||||
fallback_signatures = signature[-1]
|
fallback_signatures = signature[-1]
|
||||||
|
|
||||||
assert fallback_signatures[0][12] is None
|
assert fallback_signatures[0][11] is None
|
||||||
|
|
||||||
|
|
||||||
# -- FallbackProvider tests --
|
# -- FallbackProvider tests --
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
"""Tests for sustained-goal continuation in AgentRunner.
|
|
||||||
|
|
||||||
When a goal_active_predicate returns True, the runner must not exit with
|
|
||||||
stop_reason="completed" after a plain-text final response. Instead it should
|
|
||||||
inject a continuation message and keep looping (similar to mid-turn injection).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.config.schema import AgentDefaults
|
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse
|
|
||||||
|
|
||||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_exits_normally_without_predicate():
|
|
||||||
"""Baseline: no predicate, runner exits with completed on final text."""
|
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content="all done", tool_calls=[], usage={},
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
|
||||||
result = await runner.run(AgentRunSpec(
|
|
||||||
initial_messages=[{"role": "user", "content": "do task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=2,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.stop_reason == "completed"
|
|
||||||
assert result.final_content == "all done"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_exits_normally_with_inactive_goal():
|
|
||||||
"""Predicate returns False, runner should exit normally."""
|
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content="all done", tool_calls=[], usage={},
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
|
||||||
result = await runner.run(AgentRunSpec(
|
|
||||||
initial_messages=[{"role": "user", "content": "do task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=2,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
goal_active_predicate=lambda: False,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.stop_reason == "completed"
|
|
||||||
assert result.final_content == "all done"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_forces_continue_when_goal_active():
|
|
||||||
"""Predicate returns True on final text → runner injects continuation and loops.
|
|
||||||
|
|
||||||
We set max_iterations=3 and let the provider return final text every time.
|
|
||||||
Without the fix this would exit on the first iteration with stop_reason
|
|
||||||
"completed". With the fix the runner is forced to continue until
|
|
||||||
max_iterations is hit.
|
|
||||||
"""
|
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content="still working", tool_calls=[], usage={},
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
|
||||||
result = await runner.run(AgentRunSpec(
|
|
||||||
initial_messages=[{"role": "user", "content": "do task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=3,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
goal_active_predicate=lambda: True,
|
|
||||||
))
|
|
||||||
|
|
||||||
# Because the predicate keeps returning True, the runner should never
|
|
||||||
# naturally complete. It loops until max_iterations is exhausted.
|
|
||||||
assert result.stop_reason == "max_iterations"
|
|
||||||
# The injected continuation message should be present in the message list.
|
|
||||||
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
|
||||||
assert any("active sustained goal" in str(m.get("content", "")) for m in user_msgs)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_respects_max_iterations_even_with_active_goal():
|
|
||||||
"""A single iteration with active goal still hits max_iterations."""
|
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content="still working", tool_calls=[], usage={},
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
|
||||||
result = await runner.run(AgentRunSpec(
|
|
||||||
initial_messages=[{"role": "user", "content": "do task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=1,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
goal_active_predicate=lambda: True,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.stop_reason == "max_iterations"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_does_not_force_continue_on_error():
|
|
||||||
"""Even with active goal, an LLM error should exit with stop_reason="error"."""
|
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content=None, tool_calls=[], usage={},
|
|
||||||
finish_reason="error",
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
|
||||||
result = await runner.run(AgentRunSpec(
|
|
||||||
initial_messages=[{"role": "user", "content": "do task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=2,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
goal_active_predicate=lambda: True,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.stop_reason == "error"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_uses_custom_goal_continue_message():
|
|
||||||
"""Custom goal_continue_message should be injected instead of the default."""
|
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content="still working", tool_calls=[], usage={},
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
|
|
||||||
custom_msg = "CUSTOM_CONTINUE_PLEASE"
|
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
|
||||||
result = await runner.run(AgentRunSpec(
|
|
||||||
initial_messages=[{"role": "user", "content": "do task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=2,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
goal_active_predicate=lambda: True,
|
|
||||||
goal_continue_message=custom_msg,
|
|
||||||
))
|
|
||||||
|
|
||||||
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
|
||||||
assert any(custom_msg in str(m.get("content", "")) for m in user_msgs)
|
|
||||||
@@ -292,95 +292,3 @@ def test_from_config_static_preset_loader_does_not_enable_hot_reload(tmp_path) -
|
|||||||
loop = AgentLoop.from_config(config)
|
loop = AgentLoop.from_config(config)
|
||||||
assert loop._provider_snapshot_loader is None
|
assert loop._provider_snapshot_loader is None
|
||||||
assert loop._preset_snapshot_loader is not None
|
assert loop._preset_snapshot_loader is not None
|
||||||
|
|
||||||
|
|
||||||
class TestDreamModelOverride:
|
|
||||||
def test_dream_follows_main_when_no_override(self, tmp_path) -> None:
|
|
||||||
provider = _provider("base-model")
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="base-model",
|
|
||||||
context_window_tokens=1000,
|
|
||||||
)
|
|
||||||
assert loop.dream.model == "base-model"
|
|
||||||
assert loop.dream.provider is provider
|
|
||||||
|
|
||||||
def test_dream_raw_model_override(self, tmp_path) -> None:
|
|
||||||
provider = _provider("base-model")
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="base-model",
|
|
||||||
context_window_tokens=1000,
|
|
||||||
dream_model_override="custom-model-v2",
|
|
||||||
)
|
|
||||||
assert loop.dream.model == "custom-model-v2"
|
|
||||||
assert loop.dream.provider is provider
|
|
||||||
|
|
||||||
def test_dream_preset_override(self, tmp_path) -> None:
|
|
||||||
cheap_provider = _provider("openai/gpt-4.1-mini", max_tokens=2048)
|
|
||||||
preset = ModelPresetConfig(
|
|
||||||
model="openai/gpt-4.1-mini",
|
|
||||||
provider="openai",
|
|
||||||
max_tokens=2048,
|
|
||||||
context_window_tokens=128_000,
|
|
||||||
)
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=_provider("base-model"),
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="base-model",
|
|
||||||
context_window_tokens=1000,
|
|
||||||
model_presets={"cheap": preset},
|
|
||||||
dream_model_override="cheap",
|
|
||||||
preset_snapshot_loader=lambda _name: ProviderSnapshot(
|
|
||||||
provider=cheap_provider,
|
|
||||||
model=preset.model,
|
|
||||||
context_window_tokens=preset.context_window_tokens,
|
|
||||||
signature=("cheap", preset.model),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert loop.dream.model == "openai/gpt-4.1-mini"
|
|
||||||
assert loop.dream.provider is cheap_provider
|
|
||||||
assert loop.dream._runner.provider is cheap_provider
|
|
||||||
|
|
||||||
def test_dream_override_survives_main_preset_switch(self, tmp_path) -> None:
|
|
||||||
base_provider = _provider("base-model")
|
|
||||||
fast_provider = _provider("openai/gpt-4.1", max_tokens=4096)
|
|
||||||
cheap_provider = _provider("openai/gpt-4.1-mini", max_tokens=2048)
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=base_provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="base-model",
|
|
||||||
context_window_tokens=1000,
|
|
||||||
model_presets={
|
|
||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
|
||||||
"cheap": ModelPresetConfig(model="openai/gpt-4.1-mini"),
|
|
||||||
},
|
|
||||||
dream_model_override="cheap",
|
|
||||||
preset_snapshot_loader=lambda name: ProviderSnapshot(
|
|
||||||
provider=fast_provider if name == "fast" else cheap_provider,
|
|
||||||
model="openai/gpt-4.1" if name == "fast" else "openai/gpt-4.1-mini",
|
|
||||||
context_window_tokens=32_768 if name == "fast" else 128_000,
|
|
||||||
signature=(name, "model"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
# Initially dream is on cheap
|
|
||||||
assert loop.dream.model == "openai/gpt-4.1-mini"
|
|
||||||
assert loop.dream.provider is cheap_provider
|
|
||||||
|
|
||||||
# Switch main preset to fast
|
|
||||||
loop.set_model_preset("fast")
|
|
||||||
|
|
||||||
# Main agent should be on fast
|
|
||||||
assert loop.model == "openai/gpt-4.1"
|
|
||||||
assert loop.provider is fast_provider
|
|
||||||
|
|
||||||
# Dream should still be on cheap override
|
|
||||||
assert loop.dream.model == "openai/gpt-4.1-mini"
|
|
||||||
assert loop.dream.provider is cheap_provider
|
|
||||||
assert loop.dream._runner.provider is cheap_provider
|
|
||||||
|
|||||||
@@ -56,20 +56,6 @@ def test_list_sessions_includes_user_preview(tmp_path):
|
|||||||
assert rows[0]["preview"] == "帮我总结一下 OpenAI 的最新硬件计划"
|
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) ---
|
# --- Original regression test (from PR 2075) ---
|
||||||
|
|
||||||
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
|
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
|
||||||
|
|||||||
@@ -94,39 +94,6 @@ async def test_subagent_uses_configured_max_iterations(tmp_path):
|
|||||||
mgr.runner.run.assert_awaited_once()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path):
|
async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path):
|
||||||
"""SpawnTool should return an error string when the concurrency limit is reached."""
|
"""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.loader import load_config, save_config
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
from nanobot.webui.settings_api import settings_payload
|
||||||
|
|
||||||
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
||||||
|
|
||||||
@@ -501,6 +501,7 @@ async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch,
|
|||||||
)
|
)
|
||||||
mock_ws = AsyncMock()
|
mock_ws = AsyncMock()
|
||||||
channel._attach(mock_ws, "chat-1")
|
channel._attach(mock_ws, "chat-1")
|
||||||
|
channel._webui_chats.add("chat-1")
|
||||||
|
|
||||||
await channel.send_delta("chat-1", "
|
await channel.send_delta("chat-1", "
|
||||||
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
|
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
|
||||||
@@ -533,6 +534,7 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
|
|||||||
)
|
)
|
||||||
mock_ws = AsyncMock()
|
mock_ws = AsyncMock()
|
||||||
channel._attach(mock_ws, "chat-1")
|
channel._attach(mock_ws, "chat-1")
|
||||||
|
channel._webui_chats.add("chat-1")
|
||||||
|
|
||||||
await channel.send_delta(
|
await channel.send_delta(
|
||||||
"chat-1",
|
"chat-1",
|
||||||
@@ -546,6 +548,31 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
|
|||||||
assert final["text"].startswith("
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
|
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
@@ -1188,30 +1215,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
)
|
)
|
||||||
assert bad_preset.status_code == 400
|
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(
|
search_updated = await _http_get(
|
||||||
"http://127.0.0.1:"
|
"http://127.0.0.1:"
|
||||||
f"{port}/api/settings/web-search/update?provider=searxng"
|
f"{port}/api/settings/web-search/update?provider=searxng"
|
||||||
@@ -1279,10 +1282,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||||
assert saved.agents.defaults.provider == "atomic_chat"
|
assert saved.agents.defaults.provider == "atomic_chat"
|
||||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
assert saved.agents.defaults.model_preset == "deep"
|
||||||
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.timezone == "Asia/Shanghai"
|
||||||
assert saved.agents.defaults.bot_name == "Nano"
|
assert saved.agents.defaults.bot_name == "Nano"
|
||||||
assert saved.agents.defaults.bot_icon == "N"
|
assert saved.agents.defaults.bot_icon == "N"
|
||||||
@@ -1351,37 +1351,6 @@ def test_settings_payload_normalizes_camel_case_provider(
|
|||||||
assert body["agent"]["provider"] == "minimax_anthropic"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMock) -> None:
|
async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMock) -> None:
|
||||||
port = 29880
|
port = 29880
|
||||||
|
|||||||
@@ -209,156 +209,6 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
|||||||
await server_task
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
||||||
bus: MagicMock, tmp_path: Path
|
bus: MagicMock, tmp_path: Path
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
def _write_cache(path: Path, registry: dict) -> None:
|
def _write_cache(path: Path, registry: dict) -> None:
|
||||||
@@ -147,15 +146,6 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
|
|||||||
assert apps["jimeng"]["install_supported"] is False
|
assert apps["jimeng"]["install_supported"] is False
|
||||||
assert apps["suno"]["install_supported"] is True
|
assert apps["suno"]["install_supported"] is True
|
||||||
assert apps["gimp"]["logo_url"]
|
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["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF"
|
||||||
assert apps["feishu"]["logo_url"] == (
|
assert apps["feishu"]["logo_url"] == (
|
||||||
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
|
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
|
||||||
@@ -166,33 +156,6 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None:
|
|
||||||
manager = _manager(tmp_path)
|
|
||||||
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
|
|
||||||
_write_cache(
|
|
||||||
manager._cache_path("public"),
|
|
||||||
{
|
|
||||||
"meta": {"updated": "2026-04-18"},
|
|
||||||
"clis": [
|
|
||||||
{
|
|
||||||
"name": "anygen",
|
|
||||||
"display_name": "AnyGen",
|
|
||||||
"description": "Generate docs, slides, websites and more via AnyGen cloud API",
|
|
||||||
"category": "generation",
|
|
||||||
"install_cmd": "pip install cli-anything-anygen",
|
|
||||||
"entry_point": "cli-anything-anygen",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = manager.payload()
|
|
||||||
|
|
||||||
app = payload["apps"][0]
|
|
||||||
assert app["name"] == "anygen"
|
|
||||||
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_dispatches_safe_pip_and_installs_skill(
|
def test_install_dispatches_safe_pip_and_installs_skill(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -216,8 +179,6 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
|
|
||||||
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
|
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
|
||||||
assert payload["last_action"]["ok"] is True
|
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"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||||
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||||
@@ -225,49 +186,6 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
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:
|
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
|
|
||||||
@@ -288,7 +206,7 @@ def test_fetch_skill_content_rejects_untrusted_urls(
|
|||||||
def fail_get(*args, **kwargs):
|
def fail_get(*args, **kwargs):
|
||||||
raise AssertionError("untrusted skill URL should not be fetched")
|
raise AssertionError("untrusted skill URL should not be fetched")
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
|
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fail_get)
|
||||||
|
|
||||||
assert manager._fetch_skill_content({
|
assert manager._fetch_skill_content({
|
||||||
"name": "evil",
|
"name": "evil",
|
||||||
@@ -318,7 +236,7 @@ def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
|
|||||||
seen.append(url)
|
seen.append(url)
|
||||||
return Response()
|
return Response()
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get)
|
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get)
|
||||||
|
|
||||||
content = manager._fetch_skill_content({
|
content = manager._fetch_skill_content({
|
||||||
"name": "gimp",
|
"name": "gimp",
|
||||||
@@ -375,91 +293,6 @@ def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
|||||||
assert payload["last_action"]["ok"] is True
|
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:
|
def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
manager._save_installed(
|
manager._save_installed(
|
||||||
@@ -508,7 +341,7 @@ def test_run_installed_cli_uses_argv_without_shell(
|
|||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.apps.cli.service.shutil.which",
|
"nanobot.cli_apps.service.shutil.which",
|
||||||
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -521,7 +354,7 @@ def test_run_installed_cli_uses_argv_without_shell(
|
|||||||
stderr="",
|
stderr="",
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
|
||||||
manager._save_installed(
|
manager._save_installed(
|
||||||
{
|
{
|
||||||
"gimp": {
|
"gimp": {
|
||||||
@@ -547,7 +380,7 @@ def test_run_reports_created_artifacts(
|
|||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.apps.cli.service.shutil.which",
|
"nanobot.cli_apps.service.shutil.which",
|
||||||
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -556,7 +389,7 @@ def test_run_reports_created_artifacts(
|
|||||||
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
|
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
|
||||||
return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="")
|
return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="")
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
|
||||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||||
|
|
||||||
result = manager.run("gimp", ["render"])
|
result = manager.run("gimp", ["render"])
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.agent.tools.cli_apps import CliAppsTool
|
from nanobot.agent.tools.cli_apps import CliAppsTool
|
||||||
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
|
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
def _write_cache(path: Path, registry: dict) -> None:
|
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")
|
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.apps.cli.service.shutil.which",
|
"nanobot.cli_apps.service.shutil.which",
|
||||||
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ def test_run_cli_app_uses_installed_registry_app(
|
|||||||
stderr="",
|
stderr="",
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
|
|
||||||
tool = CliAppsTool(
|
tool = CliAppsTool(
|
||||||
workspace=workspace,
|
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 / "harness_registry_cache.json", registry)
|
||||||
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
|
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
|
||||||
|
|
||||||
result = asyncio.run(tool.execute(name="gimp"))
|
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(
|
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
||||||
{"drawio": {"entry_point": "cli-anything-drawio"}}
|
{"drawio": {"entry_point": "cli-anything-drawio"}}
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
|
|
||||||
tool = CliAppsTool(workspace=workspace)
|
tool = CliAppsTool(workspace=workspace)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from nanobot.apps.cli.service import CliAppManager
|
from nanobot.cli_apps.service import CliAppManager
|
||||||
from nanobot.apps.cli.utils import runtime_lines, session_extra
|
from nanobot.cli_apps.utils import runtime_lines, session_extra
|
||||||
|
|
||||||
|
|
||||||
def test_session_extra_returns_cli_apps_only_when_present() -> None:
|
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):
|
def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
||||||
data_dir = tmp_path / "data"
|
data_dir = tmp_path / "data"
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
manager = CliAppManager(workspace=tmp_path)
|
manager = CliAppManager(workspace=tmp_path)
|
||||||
manager._save_installed(
|
manager._save_installed(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,52 +113,6 @@ async def test_dream_restore_lists_versions_with_next_steps() -> None:
|
|||||||
assert "Restore a version with `/dream-restore <sha>`." in out.content
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_dream_restore_success_mentions_files_and_followup() -> None:
|
async def test_dream_restore_success_mentions_files_and_followup() -> None:
|
||||||
commit = CommitInfo(sha="abcd1234", message="dream: latest", timestamp="2026-04-04 12:00")
|
commit = CommitInfo(sha="abcd1234", message="dream: latest", timestamp="2026-04-04 12:00")
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
@@ -14,40 +12,6 @@ def test_resolve_preset_returns_defaults_when_no_preset() -> None:
|
|||||||
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
|
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:
|
def test_legacy_defaults_config_without_presets_still_resolves() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from nanobot.providers.openai_compat_provider import (
|
|||||||
OpenAICompatProvider,
|
OpenAICompatProvider,
|
||||||
_deep_merge,
|
_deep_merge,
|
||||||
)
|
)
|
||||||
from nanobot.providers.registry import find_by_name
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _deep_merge unit tests
|
# _deep_merge unit tests
|
||||||
@@ -186,86 +185,6 @@ class TestBuildKwargsExtraBody:
|
|||||||
assert kwargs["extra_body"]["repetition_penalty"] == 1.15
|
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
|
# Schema validation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ def _make_copilot_provider() -> OpenAICompatProvider:
|
|||||||
p.default_model = "github_copilot/gpt-5.4-mini"
|
p.default_model = "github_copilot/gpt-5.4-mini"
|
||||||
p._spec = find_by_name("github_copilot")
|
p._spec = find_by_name("github_copilot")
|
||||||
p._effective_base = "https://api.githubcopilot.com"
|
p._effective_base = "https://api.githubcopilot.com"
|
||||||
p._api_type = "auto"
|
|
||||||
p._responses_failures = {}
|
p._responses_failures = {}
|
||||||
p._responses_tripped_at = {}
|
p._responses_tripped_at = {}
|
||||||
return p
|
return p
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from nanobot.providers.image_generation import (
|
|||||||
OpenAIImageGenerationClient,
|
OpenAIImageGenerationClient,
|
||||||
OpenRouterImageGenerationClient,
|
OpenRouterImageGenerationClient,
|
||||||
StepFunImageGenerationClient,
|
StepFunImageGenerationClient,
|
||||||
ZhipuImageGenerationClient,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
PNG_BYTES = (
|
PNG_BYTES = (
|
||||||
@@ -1028,102 +1027,3 @@ async def test_openai_no_images_raises() -> None:
|
|||||||
|
|
||||||
with pytest.raises(ImageGenerationError, match="returned no images"):
|
with pytest.raises(ImageGenerationError, match="returned no images"):
|
||||||
await client.generate(prompt="draw", model="dall-e-3")
|
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,7 +602,6 @@ async def test_openai_compat_preserves_extra_content_on_tool_calls() -> None:
|
|||||||
|
|
||||||
assert len(result.tool_calls) == 1
|
assert len(result.tool_calls) == 1
|
||||||
tool_call = result.tool_calls[0]
|
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.extra_content == {"google": {"thought_signature": "signed-token"}}
|
||||||
assert tool_call.function_provider_specific_fields == {"inner": "value"}
|
assert tool_call.function_provider_specific_fields == {"inner": "value"}
|
||||||
|
|
||||||
@@ -995,7 +994,7 @@ def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None:
|
|||||||
assert kwargs["messages"][2]["role"] == "tool"
|
assert kwargs["messages"][2]["role"] == "tool"
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compat_preserves_tool_call_ids_after_consecutive_assistant_messages() -> None:
|
def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = OpenAICompatProvider()
|
provider = OpenAICompatProvider()
|
||||||
|
|
||||||
@@ -1017,34 +1016,6 @@ def test_openai_compat_preserves_tool_call_ids_after_consecutive_assistant_messa
|
|||||||
{"role": "user", "content": "多少star了呢"},
|
{"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]["role"] == "assistant"
|
||||||
assert sanitized[1]["content"] is None
|
assert sanitized[1]["content"] is None
|
||||||
assert sanitized[1]["tool_calls"][0]["id"] == "3ec83c30d"
|
assert sanitized[1]["tool_calls"][0]["id"] == "3ec83c30d"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ def provider():
|
|||||||
p.default_model = "gpt-5"
|
p.default_model = "gpt-5"
|
||||||
p._spec = type("Spec", (), {"name": "openai"})()
|
p._spec = type("Spec", (), {"name": "openai"})()
|
||||||
p._effective_base = "https://api.openai.com/v1"
|
p._effective_base = "https://api.openai.com/v1"
|
||||||
p._api_type = "auto"
|
|
||||||
p._responses_failures = {}
|
p._responses_failures = {}
|
||||||
p._responses_tripped_at = {}
|
p._responses_tripped_at = {}
|
||||||
return p
|
return p
|
||||||
@@ -28,33 +27,6 @@ def test_responses_api_available_by_default(provider):
|
|||||||
assert provider._should_use_responses_api("gpt-5", None) is True
|
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):
|
def test_circuit_opens_after_threshold(provider):
|
||||||
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
||||||
provider._record_responses_failure("gpt-5", None)
|
provider._record_responses_failure("gpt-5", None)
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ from unittest.mock import AsyncMock, patch
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.providers.transcription import (
|
from nanobot.providers.transcription import GroqTranscriptionProvider, OpenAITranscriptionProvider
|
||||||
GroqTranscriptionProvider,
|
|
||||||
OpenAITranscriptionProvider,
|
|
||||||
_resolve_transcription_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -294,37 +290,3 @@ async def test_retries_on_every_advertised_transient_exception(
|
|||||||
result = await provider.transcribe(audio_file)
|
result = await provider.transcribe(audio_file)
|
||||||
assert result == "recovered"
|
assert result == "recovered"
|
||||||
assert post.await_count == 2
|
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,7 +64,6 @@ async def test_spawn_tool_keeps_task_local_context() -> None:
|
|||||||
origin_chat_id: str,
|
origin_chat_id: str,
|
||||||
session_key: str,
|
session_key: str,
|
||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
temperature: float | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
seen.append((origin_channel, origin_chat_id, session_key))
|
seen.append((origin_channel, origin_chat_id, session_key))
|
||||||
return f"{origin_channel}:{origin_chat_id}:{task}"
|
return f"{origin_channel}:{origin_chat_id}:{task}"
|
||||||
@@ -177,7 +176,6 @@ async def test_spawn_tool_basic_set_context_and_execute() -> None:
|
|||||||
origin_chat_id,
|
origin_chat_id,
|
||||||
session_key,
|
session_key,
|
||||||
origin_message_id=None,
|
origin_message_id=None,
|
||||||
temperature=None,
|
|
||||||
):
|
):
|
||||||
seen.append((origin_channel, origin_chat_id, session_key))
|
seen.append((origin_channel, origin_chat_id, session_key))
|
||||||
return f"ok: {task}"
|
return f"ok: {task}"
|
||||||
@@ -210,7 +208,6 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
|
|||||||
origin_chat_id,
|
origin_chat_id,
|
||||||
session_key,
|
session_key,
|
||||||
origin_message_id=None,
|
origin_message_id=None,
|
||||||
temperature=None,
|
|
||||||
):
|
):
|
||||||
seen.append((origin_channel, origin_chat_id, session_key))
|
seen.append((origin_channel, origin_chat_id, session_key))
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|||||||
@@ -171,23 +171,6 @@ async def test_generate_image_tool_allows_ollama_without_api_key(
|
|||||||
assert fake.calls[0]["image_size"] == "1K"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_generate_image_tool_rejects_reference_outside_workspace(tmp_path: Path) -> None:
|
async def test_generate_image_tool_rejects_reference_outside_workspace(tmp_path: Path) -> None:
|
||||||
set_config_path(tmp_path / "config.json")
|
set_config_path(tmp_path / "config.json")
|
||||||
|
|||||||
@@ -52,17 +52,10 @@ def _fake_mcp_module(
|
|||||||
)
|
)
|
||||||
|
|
||||||
class _FakeStdioServerParameters:
|
class _FakeStdioServerParameters:
|
||||||
def __init__(
|
def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
|
||||||
self,
|
|
||||||
command: str,
|
|
||||||
args: list[str],
|
|
||||||
env: dict | None = None,
|
|
||||||
cwd: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.command = command
|
self.command = command
|
||||||
self.args = args
|
self.args = args
|
||||||
self.env = env
|
self.env = env
|
||||||
self.cwd = cwd
|
|
||||||
|
|
||||||
class _FakeClientSession:
|
class _FakeClientSession:
|
||||||
def __init__(self, _read: object, _write: object) -> None:
|
def __init__(self, _read: object, _write: object) -> None:
|
||||||
@@ -568,32 +561,6 @@ async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
|||||||
assert captured["env"] is None
|
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
|
# MCPResourceWrapper tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import sys
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from nanobot.agent.tools import (
|
from nanobot.agent.tools import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
@@ -17,7 +16,7 @@ from nanobot.agent.tools import (
|
|||||||
)
|
)
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.shell import ExecTool, ExecToolConfig
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
from nanobot.security.network import configure_ssrf_whitelist
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
|
|
||||||
@@ -664,26 +663,6 @@ async def test_exec_timeout_capped_at_max() -> None:
|
|||||||
assert "Exit code: 0" in result
|
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 ---
|
# --- _resolve_type and nullable param tests ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+213
-10
@@ -1,13 +1,216 @@
|
|||||||
from nanobot.utils.gitstore import CommitInfo
|
"""Tests for GitStore — line_ages() and core git operations."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.utils.gitstore import GitStore
|
||||||
|
|
||||||
|
|
||||||
class TestCommitInfo:
|
@pytest.fixture
|
||||||
def test_format_with_empty_message(self):
|
def git(tmp_path):
|
||||||
commit = CommitInfo(sha="abcd1234", message="", timestamp="2026-04-04 12:00")
|
"""Create an initialized GitStore with tracked MEMORY.md."""
|
||||||
result = commit.format()
|
g = GitStore(tmp_path, tracked_files=["MEMORY.md", "SOUL.md"])
|
||||||
assert "(no message)" in result or "## " in result
|
g.init()
|
||||||
|
return g
|
||||||
|
|
||||||
def test_format_with_message(self):
|
|
||||||
commit = CommitInfo(sha="abcd1234", message="dream: update", timestamp="2026-04-04 12:00")
|
class TestLineAges:
|
||||||
result = commit.format()
|
def test_returns_empty_when_not_initialized(self, tmp_path):
|
||||||
assert "dream: update" in result
|
"""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()
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.config.loader import load_config
|
|
||||||
from nanobot.webui.mcp_presets_api import (
|
|
||||||
McpPresetError,
|
|
||||||
custom_mcp_action,
|
|
||||||
mcp_presets_action,
|
|
||||||
mcp_presets_payload,
|
|
||||||
mcp_presets_test_action,
|
|
||||||
normalize_mcp_preset_mentions,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
|
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
payload = mcp_presets_payload()
|
|
||||||
names = {preset["name"] for preset in payload["presets"]}
|
|
||||||
|
|
||||||
assert {
|
|
||||||
"browserbase",
|
|
||||||
"playwright",
|
|
||||||
"github",
|
|
||||||
"figma",
|
|
||||||
"context7",
|
|
||||||
"firecrawl",
|
|
||||||
"exa",
|
|
||||||
"microsoft-learn",
|
|
||||||
"aws-docs",
|
|
||||||
"brave-search",
|
|
||||||
"postman",
|
|
||||||
}.issubset(names)
|
|
||||||
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
|
||||||
assert browserbase["installed"] is False
|
|
||||||
assert browserbase["install_supported"] is True
|
|
||||||
assert browserbase["required_fields"][0]["configured"] is False
|
|
||||||
assert "browserbaseApiKey" not in browserbase["connection_summary"]
|
|
||||||
manifest = browserbase["manifest"]
|
|
||||||
assert manifest["schema"] == "agent-app.v1"
|
|
||||||
assert manifest["id"] == "browserbase"
|
|
||||||
assert manifest["source"] == "mcp-preset"
|
|
||||||
assert manifest["capabilities"][0]["type"] == "mcp"
|
|
||||||
assert manifest["capabilities"][0]["transport"] == "streamableHttp"
|
|
||||||
assert manifest["install"]["strategy"] == "config"
|
|
||||||
assert manifest["remove"]["verification"] == ["config_absent"]
|
|
||||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_browserbase_writes_scrubbed_config_payload(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
payload = mcp_presets_action(
|
|
||||||
"enable",
|
|
||||||
{
|
|
||||||
"name": ["browserbase"],
|
|
||||||
"browserbase_api_key": ["bb_live_secret"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload["requires_restart"] is True
|
|
||||||
assert payload["last_action"]["ok"] is True
|
|
||||||
assert payload["last_action"]["installed"] is True
|
|
||||||
assert payload["last_action"]["verification"] == ["config_present"]
|
|
||||||
preset = next(row for row in payload["presets"] if row["name"] == "browserbase")
|
|
||||||
assert preset["installed"] is True
|
|
||||||
assert preset["configured"] is True
|
|
||||||
assert "bb_live_secret" not in str(payload)
|
|
||||||
config = load_config()
|
|
||||||
assert "browserbaseApiKey=bb_live_secret" in config.tools.mcp_servers["browserbase"].url
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_requires_missing_secret(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
with pytest.raises(McpPresetError) as exc:
|
|
||||||
mcp_presets_action("enable", {"name": ["browserbase"]})
|
|
||||||
|
|
||||||
assert exc.value.status == 400
|
|
||||||
assert "Browserbase API key" in exc.value.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_context7_optional_api_key_appends_arg(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
payload = mcp_presets_action(
|
|
||||||
"enable",
|
|
||||||
{
|
|
||||||
"name": ["context7"],
|
|
||||||
"context7_api_key": ["ctx7_secret"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "ctx7_secret" not in str(payload)
|
|
||||||
row = next(item for item in payload["presets"] if item["name"] == "context7")
|
|
||||||
assert row["configured"] is True
|
|
||||||
config = load_config()
|
|
||||||
assert config.tools.mcp_servers["context7"].args == [
|
|
||||||
"-y",
|
|
||||||
"@upstash/context7-mcp@latest",
|
|
||||||
"--api-key",
|
|
||||||
"ctx7_secret",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_stdio_preset_uses_config_scoped_cwd(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
cwd = config.tools.mcp_servers["playwright"].cwd
|
|
||||||
assert cwd == str(tmp_path / "mcp" / "playwright")
|
|
||||||
assert (tmp_path / "mcp" / "playwright").is_dir()
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_no_auth_remote_presets_write_url(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
mcp_presets_action("enable", {"name": ["microsoft-learn"]})
|
|
||||||
mcp_presets_action("enable", {"name": ["exa"]})
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
assert config.tools.mcp_servers["microsoft-learn"].url == "https://learn.microsoft.com/api/mcp"
|
|
||||||
assert config.tools.mcp_servers["exa"].url == "https://mcp.exa.ai/mcp"
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_firecrawl_writes_scrubbed_env(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
payload = mcp_presets_action(
|
|
||||||
"enable",
|
|
||||||
{
|
|
||||||
"name": ["firecrawl"],
|
|
||||||
"firecrawl_api_key": ["fc-secret"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "fc-secret" not in str(payload)
|
|
||||||
config = load_config()
|
|
||||||
assert config.tools.mcp_servers["firecrawl"].env["FIRECRAWL_API_KEY"] == "fc-secret"
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_mcp_preset_updates_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
|
||||||
managed_cwd = tmp_path / "mcp" / "playwright"
|
|
||||||
(managed_cwd / "cache.txt").write_text("managed runtime data", encoding="utf-8")
|
|
||||||
|
|
||||||
payload = mcp_presets_action("remove", {"name": ["playwright"]})
|
|
||||||
|
|
||||||
assert payload["requires_restart"] is True
|
|
||||||
assert payload["last_action"]["ok"] is True
|
|
||||||
assert payload["last_action"]["removed"] is True
|
|
||||||
assert payload["last_action"]["managed_paths_removed"] == ["runtime:mcp/playwright"]
|
|
||||||
assert not managed_cwd.exists()
|
|
||||||
config = load_config()
|
|
||||||
assert "playwright" not in config.tools.mcp_servers
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_custom_mcp_server_preserves_user_cwd(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
user_cwd = tmp_path / "user-cwd"
|
|
||||||
user_cwd.mkdir()
|
|
||||||
custom_mcp_action(
|
|
||||||
"custom",
|
|
||||||
{
|
|
||||||
"name": ["internal-docs"],
|
|
||||||
"transport": ["stdio"],
|
|
||||||
"command": ["node"],
|
|
||||||
"args": ['["server.js"]'],
|
|
||||||
"cwd": [str(user_cwd)],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = mcp_presets_action("remove", {"name": ["internal-docs"]})
|
|
||||||
|
|
||||||
assert payload["last_action"]["ok"] is True
|
|
||||||
assert user_cwd.exists()
|
|
||||||
config = load_config()
|
|
||||||
assert "internal-docs" not in config.tools.mcp_servers
|
|
||||||
|
|
||||||
|
|
||||||
def test_test_mcp_preset_reports_missing_dependency(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
|
||||||
monkeypatch.setattr("nanobot.webui.mcp_presets_api.shutil.which", lambda _command: None)
|
|
||||||
|
|
||||||
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
|
|
||||||
|
|
||||||
assert payload["last_action"]["ok"] is False
|
|
||||||
assert "npx" in payload["last_action"]["message"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_test_mcp_preset_connects_and_reports_tools(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
|
||||||
|
|
||||||
class FakeStack:
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_connect(servers, registry):
|
|
||||||
assert list(servers) == ["playwright"]
|
|
||||||
|
|
||||||
class FakeTool:
|
|
||||||
name = "mcp_playwright_browser_navigate"
|
|
||||||
|
|
||||||
def to_schema(self):
|
|
||||||
return {"name": self.name, "description": "", "parameters": {}}
|
|
||||||
|
|
||||||
registry.register(FakeTool())
|
|
||||||
return {"playwright": FakeStack()}
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
|
|
||||||
|
|
||||||
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
|
|
||||||
|
|
||||||
assert payload["last_action"]["ok"] is True
|
|
||||||
assert payload["last_action"]["tool_count"] == 1
|
|
||||||
assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_test_mcp_preset_scrubs_connection_errors(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
mcp_presets_action(
|
|
||||||
"enable",
|
|
||||||
{
|
|
||||||
"name": ["browserbase"],
|
|
||||||
"browserbase_api_key": ["bb_live_secret"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fake_connect(_servers, _registry):
|
|
||||||
raise RuntimeError("failed https://mcp.browserbase.com/mcp?browserbaseApiKey=bb_live_secret")
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
|
|
||||||
|
|
||||||
payload = asyncio.run(mcp_presets_test_action({"name": ["browserbase"]}))
|
|
||||||
|
|
||||||
assert payload["last_action"]["ok"] is False
|
|
||||||
assert "bb_live_secret" not in str(payload)
|
|
||||||
assert "<redacted>" in payload["last_action"]["error"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
with pytest.raises(McpPresetError) as exc:
|
|
||||||
mcp_presets_action("enable", {"name": ["linear"]})
|
|
||||||
|
|
||||||
assert exc.value.status == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_mcp_preset_mentions_keeps_known_presets_only() -> None:
|
|
||||||
payload = normalize_mcp_preset_mentions([
|
|
||||||
{
|
|
||||||
"name": "browserbase",
|
|
||||||
"display_name": "Browserbase",
|
|
||||||
"transport": "streamableHttp",
|
|
||||||
"configured": True,
|
|
||||||
"logo_url": "https://example.invalid/logo.svg",
|
|
||||||
},
|
|
||||||
{"name": "totally-unknown"},
|
|
||||||
"bad",
|
|
||||||
])
|
|
||||||
|
|
||||||
assert payload == [{
|
|
||||||
"name": "browserbase",
|
|
||||||
"display_name": "Browserbase",
|
|
||||||
"transport": "streamableHttp",
|
|
||||||
"configured": True,
|
|
||||||
"logo_url": "https://example.invalid/logo.svg",
|
|
||||||
}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_mcp_server_writes_config_and_catalog_row(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
payload = custom_mcp_action(
|
|
||||||
"custom",
|
|
||||||
{
|
|
||||||
"name": ["internal-docs"],
|
|
||||||
"transport": ["stdio"],
|
|
||||||
"command": ["node"],
|
|
||||||
"args": ['["server.js"]'],
|
|
||||||
"env": ['{"DOCS_TOKEN":"docs-secret-value"}'],
|
|
||||||
"tool_timeout": ["45"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload["requires_restart"] is True
|
|
||||||
row = next(item for item in payload["presets"] if item["name"] == "internal-docs")
|
|
||||||
assert row["source"] == "custom"
|
|
||||||
assert row["transport"] == "stdio"
|
|
||||||
assert row["connection_summary"] == "node server.js"
|
|
||||||
assert row["manifest"]["schema"] == "agent-app.v1"
|
|
||||||
assert row["manifest"]["source"] == "mcp-custom"
|
|
||||||
assert row["manifest"]["capabilities"][0]["command"] == "node"
|
|
||||||
assert "server.js" not in str(row["manifest"])
|
|
||||||
assert "docs-secret-value" not in str(payload)
|
|
||||||
config = load_config()
|
|
||||||
assert config.tools.mcp_servers["internal-docs"].args == ["server.js"]
|
|
||||||
assert config.tools.mcp_servers["internal-docs"].env["DOCS_TOKEN"] == "docs-secret-value"
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_mcp_config_and_tool_allowlist(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
payload = custom_mcp_action(
|
|
||||||
"import",
|
|
||||||
{
|
|
||||||
"config": [
|
|
||||||
(
|
|
||||||
'{"mcpServers":{'
|
|
||||||
'"docs":{"command":"npx","args":["-y","docs-mcp"],"env":{"API_KEY":"config-secret-value"}},'
|
|
||||||
'"remote-docs":{"transport":"sse","url":"https://example.com/sse"}'
|
|
||||||
'}}'
|
|
||||||
)
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload["last_action"]["message"] == "Imported 2 MCP server(s)."
|
|
||||||
config = load_config()
|
|
||||||
assert config.tools.mcp_servers["docs"].command == "npx"
|
|
||||||
assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"]
|
|
||||||
assert config.tools.mcp_servers["remote-docs"].type == "sse"
|
|
||||||
assert config.tools.mcp_servers["remote-docs"].url == "https://example.com/sse"
|
|
||||||
assert config.tools.mcp_servers["docs"].env["API_KEY"] == "config-secret-value"
|
|
||||||
assert "config-secret-value" not in str(payload)
|
|
||||||
|
|
||||||
payload = custom_mcp_action(
|
|
||||||
"tools",
|
|
||||||
{
|
|
||||||
"name": ["docs"],
|
|
||||||
"enabled_tools": ['["mcp_docs_search"]'],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
|
||||||
assert row["enabled_tools"] == ["mcp_docs_search"]
|
|
||||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == ["mcp_docs_search"]
|
|
||||||
|
|
||||||
payload = custom_mcp_action(
|
|
||||||
"tools",
|
|
||||||
{
|
|
||||||
"name": ["docs"],
|
|
||||||
"enabled_tools": ["[]"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
|
||||||
assert row["enabled_tools"] == []
|
|
||||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
custom_mcp_action(
|
|
||||||
"custom",
|
|
||||||
{
|
|
||||||
"name": ["docs"],
|
|
||||||
"transport": ["streamableHttp"],
|
|
||||||
"url": ["https://example.com/mcp"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = normalize_mcp_preset_mentions([
|
|
||||||
{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"},
|
|
||||||
])
|
|
||||||
|
|
||||||
assert payload == [{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"}]
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from nanobot.webui import mcp_presets_runtime
|
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_preset_runtime_lines_describe_tool_prefix() -> None:
|
|
||||||
msg = SimpleNamespace(
|
|
||||||
content="use @browserbase",
|
|
||||||
metadata={
|
|
||||||
"mcp_presets": [{
|
|
||||||
"name": "browserbase",
|
|
||||||
"display_name": "Browserbase",
|
|
||||||
"transport": "streamableHttp",
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = mcp_presets_runtime.runtime_lines(
|
|
||||||
msg,
|
|
||||||
configured_server_names={"browserbase"},
|
|
||||||
connected_server_names={"browserbase"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert lines
|
|
||||||
assert "@browserbase" in lines[0]
|
|
||||||
assert "mcp_browserbase_" in lines[0]
|
|
||||||
assert "shell commands" in lines[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_preset_runtime_lines_warn_when_restart_needed() -> None:
|
|
||||||
msg = SimpleNamespace(
|
|
||||||
content="use @browserbase",
|
|
||||||
metadata={
|
|
||||||
"mcp_presets": [{
|
|
||||||
"name": "browserbase",
|
|
||||||
"display_name": "Browserbase",
|
|
||||||
"transport": "streamableHttp",
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = mcp_presets_runtime.runtime_lines(
|
|
||||||
msg,
|
|
||||||
configured_server_names=set(),
|
|
||||||
connected_server_names=set(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert lines
|
|
||||||
assert "has not loaded the latest MCP settings" in lines[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_preset_runtime_lines_warn_when_connection_not_live() -> None:
|
|
||||||
msg = SimpleNamespace(
|
|
||||||
content="use @browserbase",
|
|
||||||
metadata={
|
|
||||||
"mcp_presets": [{
|
|
||||||
"name": "browserbase",
|
|
||||||
"display_name": "Browserbase",
|
|
||||||
"transport": "streamableHttp",
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = mcp_presets_runtime.runtime_lines(
|
|
||||||
msg,
|
|
||||||
configured_server_names={"browserbase"},
|
|
||||||
connected_server_names=set(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert lines
|
|
||||||
assert "connection is not currently live" in lines[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_preset_session_extra_only_persists_structured_mentions() -> None:
|
|
||||||
assert mcp_presets_runtime.session_extra({}) == {}
|
|
||||||
assert mcp_presets_runtime.session_extra({
|
|
||||||
"mcp_presets": [{"name": "browserbase"}],
|
|
||||||
}) == {"mcp_presets": [{"name": "browserbase"}]}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.webui.settings_api import WebUISettingsError, create_model_configuration
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_model_configuration_writes_label_and_selects(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
config_path = tmp_path / "config.json"
|
|
||||||
config = Config()
|
|
||||||
config.agents.defaults.model = "openai/gpt-4o"
|
|
||||||
config.agents.defaults.provider = "openai"
|
|
||||||
config.providers.openai.api_key = "sk-test"
|
|
||||||
save_config(config, config_path)
|
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
||||||
|
|
||||||
payload = create_model_configuration(
|
|
||||||
{
|
|
||||||
"label": ["Fast writing"],
|
|
||||||
"provider": ["openai"],
|
|
||||||
"model": ["openai/gpt-4.1-mini"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload["agent"]["model_preset"] == "fast-writing"
|
|
||||||
assert payload["agent"]["model"] == "openai/gpt-4.1-mini"
|
|
||||||
rows = {row["name"]: row for row in payload["model_presets"]}
|
|
||||||
assert rows["fast-writing"]["label"] == "Fast writing"
|
|
||||||
|
|
||||||
saved = load_config(config_path)
|
|
||||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
|
||||||
assert saved.model_presets["fast-writing"].label == "Fast writing"
|
|
||||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
|
||||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as duplicate:
|
|
||||||
create_model_configuration(
|
|
||||||
{
|
|
||||||
"label": ["Fast writing"],
|
|
||||||
"provider": ["openai"],
|
|
||||||
"model": ["openai/gpt-4.1-mini"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert duplicate.value.status == 409
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_model_configuration_rejects_unconfigured_provider(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
config_path = tmp_path / "config.json"
|
|
||||||
save_config(Config(), config_path)
|
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError, match="provider is not configured"):
|
|
||||||
create_model_configuration(
|
|
||||||
{
|
|
||||||
"label": ["Deep"],
|
|
||||||
"provider": ["openai"],
|
|
||||||
"model": ["openai/gpt-4.1"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
"name": "nanobot-webui",
|
"name": "nanobot-webui",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@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-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.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-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
@@ -163,12 +165,16 @@
|
|||||||
|
|
||||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
"@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/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-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-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-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=="],
|
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||||
@@ -201,6 +207,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
"@radix-ui/react-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-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=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
@@ -215,6 +223,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
"@radix-ui/react-use-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-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=="],
|
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||||
@@ -881,6 +891,10 @@
|
|||||||
|
|
||||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-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-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=="],
|
"@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,8 +13,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@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-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.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-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
|
|||||||
+5
-24
@@ -4,7 +4,7 @@ import { DeleteConfirm } from "@/components/DeleteConfirm";
|
|||||||
import { RenameChatDialog } from "@/components/RenameChatDialog";
|
import { RenameChatDialog } from "@/components/RenameChatDialog";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
|
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
|
||||||
import { SettingsView, type SettingsSectionKey } from "@/components/settings/SettingsView";
|
import { SettingsView } from "@/components/settings/SettingsView";
|
||||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ const SIDEBAR_WIDTH = 272;
|
|||||||
const SIDEBAR_RAIL_WIDTH = 56;
|
const SIDEBAR_RAIL_WIDTH = 56;
|
||||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||||
type ShellView = "chat" | "settings" | "apps";
|
type ShellView = "chat" | "settings";
|
||||||
|
|
||||||
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
|
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
|
||||||
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
|
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
|
||||||
@@ -325,7 +325,6 @@ function Shell({
|
|||||||
useSidebarState(sessions, !loading);
|
useSidebarState(sessions, !loading);
|
||||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||||
const [view, setView] = useState<ShellView>("chat");
|
const [view, setView] = useState<ShellView>("chat");
|
||||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview");
|
|
||||||
const [desktopSidebarOpen, setDesktopSidebarOpen] =
|
const [desktopSidebarOpen, setDesktopSidebarOpen] =
|
||||||
useState<boolean>(readSidebarOpen);
|
useState<boolean>(readSidebarOpen);
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
@@ -589,20 +588,12 @@ function Shell({
|
|||||||
[onSelectChat],
|
[onSelectChat],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
|
const onOpenSettings = useCallback(() => {
|
||||||
setSessionSearchOpen(false);
|
setSessionSearchOpen(false);
|
||||||
setSettingsInitialSection(section);
|
|
||||||
setView("settings");
|
setView("settings");
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onOpenApps = useCallback(() => {
|
|
||||||
setSessionSearchOpen(false);
|
|
||||||
setSettingsInitialSection("apps");
|
|
||||||
setView("apps");
|
|
||||||
setMobileSidebarOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onBackToChat = useCallback(() => {
|
const onBackToChat = useCallback(() => {
|
||||||
setView("chat");
|
setView("chat");
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
@@ -720,12 +711,6 @@ function Shell({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (view === "apps") {
|
|
||||||
document.title = t("app.documentTitle.chat", {
|
|
||||||
title: t("settings.nav.apps", { defaultValue: "Apps" }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.title = activeSession
|
document.title = activeSession
|
||||||
? t("app.documentTitle.chat", { title: headerTitle })
|
? t("app.documentTitle.chat", { title: headerTitle })
|
||||||
: t("app.documentTitle.base");
|
: t("app.documentTitle.base");
|
||||||
@@ -743,9 +728,7 @@ function Shell({
|
|||||||
onRequestRename,
|
onRequestRename,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onOpenApps,
|
|
||||||
onOpenSearch: onOpenSessionSearch,
|
onOpenSearch: onOpenSessionSearch,
|
||||||
activeUtility: view === "apps" ? "apps" as const : null,
|
|
||||||
onToggleArchived,
|
onToggleArchived,
|
||||||
onUpdateView: onUpdateSidebarView,
|
onUpdateView: onUpdateSidebarView,
|
||||||
pinnedKeys: sidebarState.pinned_keys,
|
pinnedKeys: sidebarState.pinned_keys,
|
||||||
@@ -822,7 +805,7 @@ function Shell({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 flex flex-col",
|
"absolute inset-0 flex flex-col",
|
||||||
view !== "chat" && "invisible pointer-events-none",
|
view === "settings" && "invisible pointer-events-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ThreadShell
|
<ThreadShell
|
||||||
@@ -837,12 +820,10 @@ function Shell({
|
|||||||
hideSidebarToggleOnDesktop
|
hideSidebarToggleOnDesktop
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view !== "chat" && (
|
{view === "settings" && (
|
||||||
<div className="absolute inset-0 flex flex-col">
|
<div className="absolute inset-0 flex flex-col">
|
||||||
<SettingsView
|
<SettingsView
|
||||||
theme={theme}
|
theme={theme}
|
||||||
initialSection={settingsInitialSection}
|
|
||||||
showSidebar={view === "settings"}
|
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
onBackToChat={onBackToChat}
|
onBackToChat={onBackToChat}
|
||||||
onModelNameChange={onModelNameChange}
|
onModelNameChange={onModelNameChange}
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
import type { CliAppInfo } from "@/lib/types";
|
||||||
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type CliAppMentionSegment =
|
export type CliAppMentionSegment =
|
||||||
| { kind: "text"; text: string }
|
| { kind: "text"; text: string }
|
||||||
| { kind: "cli"; text: string; app: CliAppInfo };
|
| { kind: "cli"; text: string; app: CliAppInfo };
|
||||||
|
|
||||||
export type CapabilityMentionSegment =
|
|
||||||
| CliAppMentionSegment
|
|
||||||
| { kind: "mcp"; text: string; preset: McpPresetInfo };
|
|
||||||
|
|
||||||
export function cliAppInitials(app: CliAppInfo): string {
|
export function cliAppInitials(app: CliAppInfo): string {
|
||||||
const value = app.display_name || app.name;
|
const value = app.display_name || app.name;
|
||||||
return (
|
return (
|
||||||
@@ -24,18 +19,6 @@ export function cliAppInitials(app: CliAppInfo): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_name">): string {
|
|
||||||
const value = preset.display_name || preset.name;
|
|
||||||
return (
|
|
||||||
value
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part[0]?.toUpperCase())
|
|
||||||
.join("") || preset.name.slice(0, 2).toUpperCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitCliAppMentionSegments(
|
export function splitCliAppMentionSegments(
|
||||||
value: string,
|
value: string,
|
||||||
cliApps: CliAppInfo[],
|
cliApps: CliAppInfo[],
|
||||||
@@ -72,76 +55,22 @@ export function splitCliAppMentionSegments(
|
|||||||
return segments.length ? segments : [{ kind: "text", text: value }];
|
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({
|
export function CliAppMentionText({
|
||||||
text,
|
text,
|
||||||
cliApps,
|
cliApps,
|
||||||
mcpPresets = [],
|
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
cliApps: CliAppInfo[];
|
cliApps: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
|
||||||
}) {
|
}) {
|
||||||
const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets);
|
const segments = splitCliAppMentionSegments(text, cliApps);
|
||||||
if (!segments.some((segment) => segment.kind === "cli" || segment.kind === "mcp")) return <>{text}</>;
|
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{segments.map((segment, index) => {
|
{segments.map((segment, index) => {
|
||||||
if (segment.kind === "text") {
|
if (segment.kind === "text") {
|
||||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||||
}
|
}
|
||||||
if (segment.kind === "cli") return (
|
return (
|
||||||
<CliAppMentionToken
|
<CliAppMentionToken
|
||||||
key={`cli-${segment.app.name}-${index}`}
|
key={`cli-${segment.app.name}-${index}`}
|
||||||
app={segment.app}
|
app={segment.app}
|
||||||
@@ -149,14 +78,6 @@ export function CliAppMentionText({
|
|||||||
variant="message"
|
variant="message"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return (
|
|
||||||
<McpPresetMentionToken
|
|
||||||
key={`mcp-${segment.preset.name}-${index}`}
|
|
||||||
preset={segment.preset}
|
|
||||||
label={segment.text}
|
|
||||||
variant="message"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -173,20 +94,15 @@ export function CliAppMentionToken({
|
|||||||
variant: "composer" | "message";
|
variant: "composer" | "message";
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [logoIndex, setLogoIndex] = useState(0);
|
const [failed, setFailed] = useState(false);
|
||||||
const color = app.brand_color || "hsl(var(--primary))";
|
const color = app.brand_color || "hsl(var(--primary))";
|
||||||
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||||
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
|
const showLogo = Boolean(app.logo_url) && !failed;
|
||||||
const logoUrl = logoUrls[logoIndex];
|
|
||||||
const showLogo = Boolean(logoUrl);
|
|
||||||
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||||
|
|
||||||
useEffect(() => setLogoIndex(0), [app.logo_url]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
|
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
|
||||||
title={`CLI app: ${app.display_name || app.name}`}
|
|
||||||
className="relative inline transition-[color,text-shadow] duration-150"
|
className="relative inline transition-[color,text-shadow] duration-150"
|
||||||
style={{
|
style={{
|
||||||
color,
|
color,
|
||||||
@@ -208,69 +124,10 @@ export function CliAppMentionToken({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={logoUrl ?? ""}
|
src={app.logo_url ?? ""}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
onError={() => setLogoIndex((index) => index + 1)}
|
onError={() => setFailed(true)}
|
||||||
/>
|
|
||||||
</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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowUp } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ComposerProps {
|
||||||
|
onSend: (content: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Visually collapse the outer padding when embedded inside a welcome screen. */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounded, shadowed composer with an embedded send button — modeled after the
|
||||||
|
* agent-chat-ui input: a single surface that looks like one interactive unit
|
||||||
|
* rather than a textarea + button pair.
|
||||||
|
*/
|
||||||
|
export function Composer({
|
||||||
|
onSend,
|
||||||
|
disabled,
|
||||||
|
placeholder = "Type your message…",
|
||||||
|
compact = false,
|
||||||
|
}: ComposerProps) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Autofocus on mount — coming back to a chat, switching sessions, or
|
||||||
|
// opening the welcome screen should always land the caret in the box.
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) return;
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
// Defer so layout settles first (important during enter animations).
|
||||||
|
const id = requestAnimationFrame(() => el.focus());
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const submit = useCallback(() => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || disabled) return;
|
||||||
|
onSend(trimmed);
|
||||||
|
setValue("");
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [disabled, onSend, value]);
|
||||||
|
|
||||||
|
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
compact ? "px-0" : "bg-background/95 px-4 pb-4 pt-2 backdrop-blur",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative mx-auto flex w-full max-w-[64rem] flex-col overflow-hidden rounded-3xl",
|
||||||
|
"border bg-muted/60 shadow-sm transition-all duration-200",
|
||||||
|
"focus-within:bg-muted focus-within:shadow-md focus-within:ring-1 focus-within:ring-foreground/10",
|
||||||
|
disabled && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onInput={onInput}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
rows={1}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Message input"
|
||||||
|
className={cn(
|
||||||
|
"min-h-[56px] w-full resize-none bg-transparent px-5 pt-4 pb-2 text-sm",
|
||||||
|
"placeholder:text-muted-foreground",
|
||||||
|
"focus:outline-none focus-visible:outline-none",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 pb-2">
|
||||||
|
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline">
|
||||||
|
Enter to send · Shift+Enter for newline
|
||||||
|
</span>
|
||||||
|
<span className="sm:hidden" aria-hidden />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled || !value.trim()}
|
||||||
|
aria-label="Send message"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9 rounded-full shadow-sm transition-transform",
|
||||||
|
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MessageSquarePlus } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
onNewChat,
|
||||||
|
}: {
|
||||||
|
onNewChat: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<MessageSquarePlus
|
||||||
|
className="h-10 w-10 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-lg font-medium">No chats yet</p>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
Start a conversation — your sessions are stored locally on the nanobot
|
||||||
|
workspace and stay available across reloads.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onNewChat}>New chat</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export function FileReferenceChip({
|
|||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
aria-label={fullPath}
|
aria-label={fullPath}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]",
|
"inline-flex max-w-full items-center gap-1 font-medium leading-[inherit]",
|
||||||
"text-sky-600 transition-colors hover:text-sky-700",
|
"text-sky-600 transition-colors hover:text-sky-700",
|
||||||
"dark:text-sky-300 dark:hover:text-sky-200",
|
"dark:text-sky-300 dark:hover:text-sky-200",
|
||||||
)}
|
)}
|
||||||
@@ -161,7 +161,7 @@ function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] text-sky-500 dark:text-sky-300"
|
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -180,7 +180,7 @@ function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] text-sky-500 dark:text-sky-300"
|
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -195,36 +195,16 @@ function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
|||||||
}
|
}
|
||||||
const label = fileKindLabel(kind);
|
const label = fileKindLabel(kind);
|
||||||
return (
|
return (
|
||||||
<svg
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="h-[0.96em] w-[0.96em] shrink-0 translate-y-[0.12em] text-sky-500 dark:text-sky-300"
|
className={cn(
|
||||||
viewBox="0 0 24 24"
|
"inline-flex h-[1.05em] min-w-[1.05em] shrink-0 items-center justify-center",
|
||||||
fill="none"
|
"rounded-[4px] bg-sky-500/12 px-[0.22em] text-[0.58em] font-bold uppercase leading-none",
|
||||||
>
|
"text-sky-600 dark:bg-sky-400/15 dark:text-sky-300",
|
||||||
<path
|
)}
|
||||||
d="M7 3.5h6.6L18 7.9V19a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 19V5a1.5 1.5 0 0 1 1.5-1.5Z"
|
|
||||||
fill="currentColor"
|
|
||||||
opacity="0.12"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M13.5 3.75V8h4.25M7 3.5h6.6L18 7.9V19a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 19V5a1.5 1.5 0 0 1 1.5-1.5Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.75"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x="12"
|
|
||||||
y="15.7"
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="currentColor"
|
|
||||||
fontSize={label.length > 1 ? "5.8" : "7.2"}
|
|
||||||
fontWeight="800"
|
|
||||||
letterSpacing="-0.2"
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</text>
|
</span>
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,22 +14,13 @@ import { ImageLightbox } from "@/components/ImageLightbox";
|
|||||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatTurnLatency } from "@/lib/format";
|
import { formatTurnLatency } from "@/lib/format";
|
||||||
import type {
|
import type { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||||
CliAppInfo,
|
|
||||||
McpPresetInfo,
|
|
||||||
UICliAppAttachment,
|
|
||||||
UIMcpPresetAttachment,
|
|
||||||
UIImage,
|
|
||||||
UIMediaAttachment,
|
|
||||||
UIMessage,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
|
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
|
||||||
showAssistantCopyAction?: boolean;
|
showAssistantCopyAction?: boolean;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +36,6 @@ export function MessageBubble({
|
|||||||
message,
|
message,
|
||||||
showAssistantCopyAction = true,
|
showAssistantCopyAction = true,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -55,10 +45,6 @@ export function MessageBubble({
|
|||||||
() => mergeCliMentionApps(cliApps, message.cliApps),
|
() => mergeCliMentionApps(cliApps, message.cliApps),
|
||||||
[cliApps, message.cliApps],
|
[cliApps, message.cliApps],
|
||||||
);
|
);
|
||||||
const mentionMcpPresets = useMemo(
|
|
||||||
() => mergeMcpMentionPresets(mcpPresets, message.mcpPresets),
|
|
||||||
[mcpPresets, message.mcpPresets],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -110,11 +96,7 @@ export function MessageBubble({
|
|||||||
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
|
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CliAppMentionText
|
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
|
||||||
text={message.content}
|
|
||||||
cliApps={mentionCliApps}
|
|
||||||
mcpPresets={mentionMcpPresets}
|
|
||||||
/>
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -184,39 +166,6 @@ export function MessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeMcpMentionPresets(
|
|
||||||
presets: McpPresetInfo[],
|
|
||||||
attachments: UIMcpPresetAttachment[] | undefined,
|
|
||||||
): McpPresetInfo[] {
|
|
||||||
if (!attachments?.length) return presets;
|
|
||||||
const byName = new Map(presets.map((preset) => [preset.name.toLowerCase(), preset]));
|
|
||||||
for (const attachment of attachments) {
|
|
||||||
const name = attachment.name?.trim();
|
|
||||||
if (!name) continue;
|
|
||||||
const existing = byName.get(name.toLowerCase());
|
|
||||||
byName.set(name.toLowerCase(), {
|
|
||||||
name,
|
|
||||||
display_name: attachment.display_name || existing?.display_name || name,
|
|
||||||
category: attachment.category || existing?.category || "mcp",
|
|
||||||
description: existing?.description || "",
|
|
||||||
docs_url: existing?.docs_url || "",
|
|
||||||
transport: attachment.transport || existing?.transport || "mcp",
|
|
||||||
requires: existing?.requires || "",
|
|
||||||
note: existing?.note || "",
|
|
||||||
install_supported: existing?.install_supported ?? true,
|
|
||||||
installed: true,
|
|
||||||
configured: attachment.configured ?? existing?.configured ?? true,
|
|
||||||
available: existing?.available ?? true,
|
|
||||||
status: attachment.status || existing?.status || "configured",
|
|
||||||
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
|
|
||||||
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
|
|
||||||
required_fields: existing?.required_fields || [],
|
|
||||||
connection_summary: existing?.connection_summary || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Array.from(byName.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeCliMentionApps(
|
function mergeCliMentionApps(
|
||||||
cliApps: CliAppInfo[],
|
cliApps: CliAppInfo[],
|
||||||
attachments: UICliAppAttachment[] | undefined,
|
attachments: UICliAppAttachment[] | undefined,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
|
import { MessageBubble } from "@/components/MessageBubble";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
messages: UIMessage[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEAR_BOTTOM_PX = 48;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrollable message log. Auto-sticks to the bottom as new content arrives,
|
||||||
|
* but only when the user was already at the bottom — preserving scroll
|
||||||
|
* position when they've scrolled up to read earlier turns. A floating
|
||||||
|
* "scroll to bottom" button appears whenever we're detached from the bottom.
|
||||||
|
*/
|
||||||
|
export function MessageList({ messages, isStreaming }: MessageListProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((smooth = false) => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTo({
|
||||||
|
top: el.scrollHeight,
|
||||||
|
behavior: smooth ? "smooth" : "auto",
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep the viewport pinned to the bottom as long as the user hasn't
|
||||||
|
// scrolled up. During streaming we do instant jumps (smooth scrolling each
|
||||||
|
// token fights the incoming animations); on settled updates we animate.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!atBottom) return;
|
||||||
|
scrollToBottom(!isStreaming);
|
||||||
|
}, [messages, isStreaming, atBottom, scrollToBottom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
setAtBottom(distance < NEAR_BOTTOM_PX);
|
||||||
|
};
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Say hi to get started.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={cn(
|
||||||
|
"h-full overflow-y-auto scroll-smooth",
|
||||||
|
"[&::-webkit-scrollbar]:w-1.5",
|
||||||
|
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||||
|
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
||||||
|
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex w-full max-w-[64rem] flex-col gap-6 px-4 pt-4 pb-8">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<MessageBubble key={m.id} message={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top fade so messages slide under the header gracefully. */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||||
|
/>
|
||||||
|
{/* Bottom fade so messages fade out behind the composer. */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!atBottom && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => scrollToBottom(true)}
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-2 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||||
|
"bg-background/90 backdrop-blur",
|
||||||
|
"animate-in fade-in-0 zoom-in-95",
|
||||||
|
)}
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,7 +33,6 @@ export function SessionSearchDialog({
|
|||||||
}: SessionSearchDialogProps) {
|
}: SessionSearchDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
|
||||||
@@ -47,6 +46,7 @@ export function SessionSearchDialog({
|
|||||||
);
|
);
|
||||||
}, [normalizedQuery, open, sessions, titleOverrides]);
|
}, [normalizedQuery, open, sessions, titleOverrides]);
|
||||||
const itemCount = sessionResults.length;
|
const itemCount = sessionResults.length;
|
||||||
|
const shortcutLabel = useMemo(getSearchShortcutLabel, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -65,18 +65,6 @@ export function SessionSearchDialog({
|
|||||||
);
|
);
|
||||||
}, [itemCount]);
|
}, [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) => {
|
const handleSelect = (key: string) => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onSelect(key);
|
onSelect(key);
|
||||||
@@ -119,18 +107,18 @@ export function SessionSearchDialog({
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-h-[min(40rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
|
"max-h-[min(34rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] 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)]",
|
"rounded-2xl border border-border/70 bg-popover/95 text-popover-foreground shadow-2xl backdrop-blur-xl",
|
||||||
"dark:border-white/14 dark:bg-[#2b2b2b] dark:shadow-[0_26px_90px_rgba(0,0,0,0.44)] sm:rounded-[22px]",
|
"sm:rounded-2xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
|
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
{t("sidebar.searchPlaceholder")}
|
{t("sidebar.searchPlaceholder")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<div className="flex h-[62px] shrink-0 items-center gap-3 border-b border-border px-[18px]">
|
<div className="flex h-14 items-center gap-3 border-b border-border/60 px-5">
|
||||||
<Search
|
<Search
|
||||||
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
|
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -140,16 +128,16 @@ export function SessionSearchDialog({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t("sidebar.searchPlaceholder")}
|
placeholder={t("sidebar.searchPlaceholder")}
|
||||||
aria-label={t("sidebar.searchAria")}
|
aria-label={t("sidebar.searchAria")}
|
||||||
className="h-full min-w-0 flex-1 bg-transparent text-[19px] font-normal leading-none text-foreground outline-none placeholder:text-muted-foreground"
|
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
|
||||||
/>
|
/>
|
||||||
|
<kbd className="hidden h-6 shrink-0 items-center rounded-md border border-border/70 bg-muted/60 px-2 text-[11px] font-medium text-muted-foreground sm:inline-flex">
|
||||||
|
{shortcutLabel}
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
|
||||||
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>
|
<section>
|
||||||
<div className="px-2.5 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground">
|
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
|
||||||
{sectionLabel}
|
{sectionLabel}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,7 +150,7 @@ export function SessionSearchDialog({
|
|||||||
{emptyLabel}
|
{emptyLabel}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-1">
|
||||||
{sessionResults.map((session, index) => {
|
{sessionResults.map((session, index) => {
|
||||||
const title = titleOverrides[session.key]?.trim() ||
|
const title = titleOverrides[session.key]?.trim() ||
|
||||||
session.title?.trim() ||
|
session.title?.trim() ||
|
||||||
@@ -176,18 +164,15 @@ export function SessionSearchDialog({
|
|||||||
return (
|
return (
|
||||||
<li key={session.key}>
|
<li key={session.key}>
|
||||||
<button
|
<button
|
||||||
ref={(node) => {
|
|
||||||
itemRefs.current[index] = node;
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(session.key)}
|
onClick={() => handleSelect(session.key)}
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid min-h-[54px] w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[11px] px-3 py-2 text-left transition-colors",
|
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
|
||||||
highlighted
|
highlighted
|
||||||
? "bg-muted text-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "text-foreground hover:bg-muted",
|
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
@@ -196,17 +181,17 @@ export function SessionSearchDialog({
|
|||||||
</span>
|
</span>
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
<span
|
<span
|
||||||
className="block truncate text-[12px] leading-4 text-muted-foreground"
|
className={cn(
|
||||||
|
"block truncate text-[12px] leading-4",
|
||||||
|
highlighted
|
||||||
|
? "text-accent-foreground/70"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{preview}
|
{preview}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -236,3 +221,13 @@ function sessionMatchesTerms(
|
|||||||
|
|
||||||
return terms.every((term) => haystack.includes(term));
|
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,7 +6,6 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Blocks,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -42,9 +41,7 @@ interface SidebarProps {
|
|||||||
onRequestRename: (key: string, label: string) => void;
|
onRequestRename: (key: string, label: string) => void;
|
||||||
onToggleArchive: (key: string) => void;
|
onToggleArchive: (key: string) => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onOpenApps: () => void;
|
|
||||||
onOpenSearch: () => void;
|
onOpenSearch: () => void;
|
||||||
activeUtility?: "apps" | null;
|
|
||||||
onToggleArchived: () => void;
|
onToggleArchived: () => void;
|
||||||
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
@@ -132,13 +129,6 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onClick={props.onOpenSearch}
|
onClick={props.onOpenSearch}
|
||||||
icon={<Search className="h-4 w-4" />}
|
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
|
<SidebarViewMenu
|
||||||
compact={collapsed}
|
compact={collapsed}
|
||||||
view={props.viewState}
|
view={props.viewState}
|
||||||
@@ -211,14 +201,12 @@ function SidebarActionButton({
|
|||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
onClick,
|
onClick,
|
||||||
active = false,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
active?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -226,16 +214,14 @@ function SidebarActionButton({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
title={collapsed ? label : undefined}
|
title={collapsed ? label : undefined}
|
||||||
onClick={() => onClick()}
|
onClick={onClick}
|
||||||
className={cn(
|
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",
|
"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",
|
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
|
||||||
collapsed
|
collapsed
|
||||||
? "w-9 justify-center gap-0 rounded-xl px-0"
|
? "w-9 justify-center gap-0 rounded-xl px-0"
|
||||||
: "w-full justify-start gap-2 px-3 text-[12.5px]",
|
: "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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,15 @@ import {
|
|||||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
import {
|
import {
|
||||||
CliAppMentionToken,
|
CliAppMentionToken,
|
||||||
McpPresetMentionToken,
|
|
||||||
cliAppInitials,
|
cliAppInitials,
|
||||||
mcpPresetInitials,
|
splitCliAppMentionSegments,
|
||||||
splitCapabilityMentionSegments,
|
type CliAppMentionSegment,
|
||||||
type CapabilityMentionSegment,
|
|
||||||
} from "@/components/CliAppMentionText";
|
} from "@/components/CliAppMentionText";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
AtSign,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Brain,
|
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -31,7 +29,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Shield,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
@@ -51,19 +48,7 @@ import {
|
|||||||
} from "@/hooks/useAttachedImages";
|
} from "@/hooks/useAttachedImages";
|
||||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||||
import type {
|
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
|
||||||
CliAppInfo,
|
|
||||||
GoalStateWsPayload,
|
|
||||||
McpPresetInfo,
|
|
||||||
OutboundCliAppMention,
|
|
||||||
OutboundMcpPresetMention,
|
|
||||||
SlashCommand,
|
|
||||||
} from "@/lib/types";
|
|
||||||
import {
|
|
||||||
inferProviderFromModelName,
|
|
||||||
logoFallbackUrls,
|
|
||||||
providerBrand,
|
|
||||||
} from "@/lib/provider-brand";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||||
@@ -82,12 +67,9 @@ interface ThreadComposerProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
modelLabel?: string | null;
|
modelLabel?: string | null;
|
||||||
modelProvider?: string | null;
|
|
||||||
modelProviderLabel?: string | null;
|
|
||||||
variant?: "thread" | "hero";
|
variant?: "thread" | "hero";
|
||||||
slashCommands?: SlashCommand[];
|
slashCommands?: SlashCommand[];
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
|
||||||
imageMode?: boolean;
|
imageMode?: boolean;
|
||||||
onImageModeChange?: (enabled: boolean) => void;
|
onImageModeChange?: (enabled: boolean) => void;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
@@ -100,11 +82,9 @@ interface ThreadComposerProps {
|
|||||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
"book-open": BookOpen,
|
"book-open": BookOpen,
|
||||||
brain: Brain,
|
|
||||||
"circle-help": CircleHelp,
|
"circle-help": CircleHelp,
|
||||||
history: History,
|
history: History,
|
||||||
"rotate-cw": RotateCw,
|
"rotate-cw": RotateCw,
|
||||||
shield: Shield,
|
|
||||||
sparkles: Sparkles,
|
sparkles: Sparkles,
|
||||||
square: Square,
|
square: Square,
|
||||||
"square-pen": SquarePen,
|
"square-pen": SquarePen,
|
||||||
@@ -117,9 +97,7 @@ const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "
|
|||||||
const SLASH_PALETTE_GAP_PX = 8;
|
const SLASH_PALETTE_GAP_PX = 8;
|
||||||
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
|
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
|
||||||
const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
|
const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
|
||||||
const SLASH_PALETTE_CHROME_PX = 12;
|
const SLASH_PALETTE_CHROME_PX = 64;
|
||||||
const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents";
|
|
||||||
const SLASH_RECENTS_LIMIT = 5;
|
|
||||||
|
|
||||||
type SlashPalettePlacement = "above" | "below";
|
type SlashPalettePlacement = "above" | "below";
|
||||||
|
|
||||||
@@ -134,45 +112,10 @@ interface CliAppMentionQuery {
|
|||||||
end: number;
|
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 {
|
function slashCommandI18nKey(command: string): string {
|
||||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
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) {
|
function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) {
|
||||||
if (!(target instanceof Element) || deltaY === 0) return;
|
if (!(target instanceof Element) || deltaY === 0) return;
|
||||||
let el: HTMLElement | null = target.parentElement;
|
let el: HTMLElement | null = target.parentElement;
|
||||||
@@ -249,19 +192,6 @@ function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMention {
|
|
||||||
return {
|
|
||||||
name: preset.name,
|
|
||||||
display_name: preset.display_name,
|
|
||||||
category: preset.category,
|
|
||||||
transport: preset.transport,
|
|
||||||
status: preset.status,
|
|
||||||
configured: preset.configured,
|
|
||||||
logo_url: preset.logo_url ?? null,
|
|
||||||
brand_color: preset.brand_color ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function RunElapsedStrip({
|
function RunElapsedStrip({
|
||||||
startedAt,
|
startedAt,
|
||||||
goalState,
|
goalState,
|
||||||
@@ -464,12 +394,9 @@ export function ThreadComposer({
|
|||||||
placeholder,
|
placeholder,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
modelLabel = null,
|
modelLabel = null,
|
||||||
modelProvider = null,
|
|
||||||
modelProviderLabel = null,
|
|
||||||
variant = "thread",
|
variant = "thread",
|
||||||
slashCommands = [],
|
slashCommands = [],
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
|
||||||
imageMode: controlledImageMode,
|
imageMode: controlledImageMode,
|
||||||
onImageModeChange,
|
onImageModeChange,
|
||||||
onStop,
|
onStop,
|
||||||
@@ -487,7 +414,6 @@ export function ThreadComposer({
|
|||||||
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
|
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
|
||||||
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
||||||
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
||||||
const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents());
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -572,86 +498,26 @@ export function ThreadComposer({
|
|||||||
return commandToken.toLowerCase();
|
return commandToken.toLowerCase();
|
||||||
}, [disabled, slashMenuDismissed, value]);
|
}, [disabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const visibleSlashCommands = useMemo(() => {
|
const filteredSlashCommands = 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 [];
|
if (slashQuery === null) return [];
|
||||||
const withDetails = visibleSlashCommands
|
return slashCommands
|
||||||
.filter((command) => {
|
.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 = [
|
const haystack = [
|
||||||
command.command,
|
command.command,
|
||||||
command.title,
|
command.title,
|
||||||
command.description,
|
command.description,
|
||||||
command.argHint ?? "",
|
command.argHint ?? "",
|
||||||
title,
|
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
|
||||||
description,
|
defaultValue: "",
|
||||||
|
}),
|
||||||
|
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, {
|
||||||
|
defaultValue: "",
|
||||||
|
}),
|
||||||
].join(" ").toLowerCase();
|
].join(" ").toLowerCase();
|
||||||
return haystack.includes(slashQuery);
|
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);
|
.slice(0, 8);
|
||||||
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
|
}, [slashCommands, slashQuery, t]);
|
||||||
|
|
||||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||||
@@ -668,9 +534,9 @@ export function ThreadComposer({
|
|||||||
};
|
};
|
||||||
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
||||||
|
|
||||||
const filteredMentionCandidates = useMemo<MentionCandidate[]>(() => {
|
const filteredCliApps = useMemo(() => {
|
||||||
if (!cliAppMention) return [];
|
if (!cliAppMention) return [];
|
||||||
const cliCandidates: MentionCandidate[] = cliApps
|
return cliApps
|
||||||
.filter((app) => app.installed)
|
.filter((app) => app.installed)
|
||||||
.filter((app) => {
|
.filter((app) => {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
@@ -682,32 +548,16 @@ export function ThreadComposer({
|
|||||||
].join(" ").toLowerCase();
|
].join(" ").toLowerCase();
|
||||||
return haystack.includes(cliAppMention.query);
|
return haystack.includes(cliAppMention.query);
|
||||||
})
|
})
|
||||||
.map((app) => ({ kind: "cli", name: app.name, app }));
|
.slice(0, 8);
|
||||||
const mcpCandidates: MentionCandidate[] = mcpPresets
|
}, [cliAppMention, cliApps]);
|
||||||
.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 = filteredMentionCandidates.length > 0;
|
const showCliAppMenu = filteredCliApps.length > 0;
|
||||||
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
||||||
const mentionSegments = useMemo(
|
const mentionSegments = useMemo(
|
||||||
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets),
|
() => splitCliAppMentionSegments(value, cliApps),
|
||||||
[cliApps, mcpPresets, value],
|
[cliApps, value],
|
||||||
);
|
|
||||||
const hasMentionDecorations = mentionSegments.some(
|
|
||||||
(segment) => segment.kind === "cli" || segment.kind === "mcp",
|
|
||||||
);
|
);
|
||||||
|
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
|
||||||
const activeCliMentionApps = useMemo(() => {
|
const activeCliMentionApps = useMemo(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return mentionSegments.flatMap((segment) => {
|
return mentionSegments.flatMap((segment) => {
|
||||||
@@ -716,14 +566,6 @@ export function ThreadComposer({
|
|||||||
return [segment.app];
|
return [segment.app];
|
||||||
});
|
});
|
||||||
}, [mentionSegments]);
|
}, [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>({
|
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
|
||||||
placement: "above",
|
placement: "above",
|
||||||
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
|
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
|
||||||
@@ -744,10 +586,10 @@ export function ThreadComposer({
|
|||||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCliAppIndex >= filteredMentionCandidates.length) {
|
if (selectedCliAppIndex >= filteredCliApps.length) {
|
||||||
setSelectedCliAppIndex(0);
|
setSelectedCliAppIndex(0);
|
||||||
}
|
}
|
||||||
}, [filteredMentionCandidates.length, selectedCliAppIndex]);
|
}, [filteredCliApps.length, selectedCliAppIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showAnyPalette) return;
|
if (!showAnyPalette) return;
|
||||||
@@ -798,7 +640,7 @@ export function ThreadComposer({
|
|||||||
window.removeEventListener("resize", updateLayout);
|
window.removeEventListener("resize", updateLayout);
|
||||||
document.removeEventListener("scroll", updateLayout, true);
|
document.removeEventListener("scroll", updateLayout, true);
|
||||||
};
|
};
|
||||||
}, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]);
|
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!aspectMenuOpen) return;
|
if (!aspectMenuOpen) return;
|
||||||
@@ -844,37 +686,20 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const chooseSlashCommand = useCallback(
|
const chooseSlashCommand = useCallback(
|
||||||
(command: SlashCommand) => {
|
(command: SlashCommand) => {
|
||||||
const nextRecents = [
|
|
||||||
command.command,
|
|
||||||
...recentSlashCommands.filter((item) => item !== command.command),
|
|
||||||
].slice(0, SLASH_RECENTS_LIMIT);
|
|
||||||
setRecentSlashCommands(nextRecents);
|
|
||||||
storeSlashRecents(nextRecents);
|
|
||||||
|
|
||||||
if (command.command === "/stop" && isStreaming && onStop) {
|
|
||||||
onStop();
|
|
||||||
setValue("");
|
|
||||||
setSlashMenuDismissed(true);
|
|
||||||
setCliAppMenuDismissed(false);
|
|
||||||
setInlineError(null);
|
|
||||||
resizeTextarea();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setValue(command.argHint ? `${command.command} ` : command.command);
|
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||||
setSlashMenuDismissed(true);
|
setSlashMenuDismissed(true);
|
||||||
setCliAppMenuDismissed(false);
|
setCliAppMenuDismissed(false);
|
||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
resizeTextarea();
|
resizeTextarea();
|
||||||
},
|
},
|
||||||
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
|
[resizeTextarea],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chooseMentionCandidate = useCallback(
|
const chooseCliApp = useCallback(
|
||||||
(candidate: MentionCandidate) => {
|
(app: CliAppInfo) => {
|
||||||
if (!cliAppMention) return;
|
if (!cliAppMention) return;
|
||||||
const suffix = value.slice(cliAppMention.end);
|
const suffix = value.slice(cliAppMention.end);
|
||||||
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
|
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
|
||||||
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
||||||
const nextCursor = cliAppMention.start + mention.length;
|
const nextCursor = cliAppMention.start + mention.length;
|
||||||
setValue(next);
|
setValue(next);
|
||||||
@@ -911,9 +736,8 @@ export function ThreadComposer({
|
|||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
|
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
|
||||||
const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload);
|
|
||||||
const options: SendOptions | undefined =
|
const options: SendOptions | undefined =
|
||||||
imageMode || attachedCliApps.length > 0 || attachedMcpPresets.length > 0
|
imageMode || attachedCliApps.length > 0
|
||||||
? {
|
? {
|
||||||
...(imageMode
|
...(imageMode
|
||||||
? {
|
? {
|
||||||
@@ -924,7 +748,6 @@ export function ThreadComposer({
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
|
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
|
||||||
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
|
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
onSend(trimmed, payload, options);
|
onSend(trimmed, payload, options);
|
||||||
@@ -937,36 +760,25 @@ export function ThreadComposer({
|
|||||||
setCliAppMenuDismissed(false);
|
setCliAppMenuDismissed(false);
|
||||||
setCursorPosition(0);
|
setCursorPosition(0);
|
||||||
resizeTextarea();
|
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>) => {
|
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (showCliAppMenu) {
|
if (showCliAppMenu) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedCliAppIndex((idx) => (idx + 1) % filteredMentionCandidates.length);
|
setSelectedCliAppIndex((idx) => (idx + 1) % filteredCliApps.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowUp") {
|
if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedCliAppIndex(
|
setSelectedCliAppIndex(
|
||||||
(idx) => (idx - 1 + filteredMentionCandidates.length) % filteredMentionCandidates.length,
|
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
chooseMentionCandidate(filteredMentionCandidates[selectedCliAppIndex]);
|
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@@ -1082,12 +894,12 @@ export function ThreadComposer({
|
|||||||
) : null}
|
) : null}
|
||||||
{showCliAppMenu ? (
|
{showCliAppMenu ? (
|
||||||
<CliAppMentionPalette
|
<CliAppMentionPalette
|
||||||
candidates={filteredMentionCandidates}
|
apps={filteredCliApps}
|
||||||
selectedIndex={selectedCliAppIndex}
|
selectedIndex={selectedCliAppIndex}
|
||||||
layout={slashPaletteLayout}
|
layout={slashPaletteLayout}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
onHover={setSelectedCliAppIndex}
|
onHover={setSelectedCliAppIndex}
|
||||||
onChoose={chooseMentionCandidate}
|
onChoose={chooseCliApp}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
@@ -1135,7 +947,7 @@ export function ThreadComposer({
|
|||||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{hasMentionDecorations ? (
|
{hasCliMentionDecorations ? (
|
||||||
<ComposerCliMentionOverlay
|
<ComposerCliMentionOverlay
|
||||||
segments={mentionSegments}
|
segments={mentionSegments}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
@@ -1166,7 +978,7 @@ export function ThreadComposer({
|
|||||||
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||||
"focus:outline-none focus-visible:outline-none",
|
"focus:outline-none focus-visible:outline-none",
|
||||||
"disabled:cursor-not-allowed",
|
"disabled:cursor-not-allowed",
|
||||||
hasMentionDecorations && "text-transparent selection:bg-primary/20",
|
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1207,7 +1019,7 @@ export function ThreadComposer({
|
|||||||
"rounded-full text-muted-foreground hover:text-foreground",
|
"rounded-full text-muted-foreground hover:text-foreground",
|
||||||
isHero
|
isHero
|
||||||
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
|
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
|
||||||
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
: "h-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
|
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
|
||||||
@@ -1226,7 +1038,7 @@ export function ThreadComposer({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
|
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
|
||||||
"h-9 text-[12px]",
|
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
|
||||||
imageMode
|
imageMode
|
||||||
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
|
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
|
||||||
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
|
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
|
||||||
@@ -1246,7 +1058,7 @@ export function ThreadComposer({
|
|||||||
onClick={() => setAspectMenuOpen((open) => !open)}
|
onClick={() => setAspectMenuOpen((open) => !open)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
|
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
|
||||||
"h-9 text-[12px]",
|
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
|
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
|
||||||
@@ -1266,12 +1078,22 @@ export function ThreadComposer({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{modelLabel ? (
|
{modelLabel ? (
|
||||||
<ComposerModelBadge
|
<span
|
||||||
label={modelLabel}
|
title={modelLabel}
|
||||||
provider={modelProvider}
|
className={cn(
|
||||||
providerLabel={modelProviderLabel}
|
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
|
||||||
isHero={isHero}
|
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
|
||||||
|
isHero
|
||||||
|
? "max-w-[13rem] text-[12px] shadow-[0_2px_8px_rgba(15,23,42,0.04)]"
|
||||||
|
: "max-w-[10rem] text-[10.5px] shadow-[0_2px_8px_rgba(15,23,42,0.035)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500/80"
|
||||||
/>
|
/>
|
||||||
|
<span className="truncate">{modelLabel}</span>
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{!isHero ? (
|
{!isHero ? (
|
||||||
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
|
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
|
||||||
@@ -1293,7 +1115,7 @@ export function ThreadComposer({
|
|||||||
: isHero
|
: isHero
|
||||||
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
|
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
|
||||||
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
|
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
|
||||||
"h-9 w-9",
|
isHero ? "" : "h-7.5 w-7.5",
|
||||||
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1311,79 +1133,12 @@ export function ThreadComposer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComposerModelBadge({
|
|
||||||
label,
|
|
||||||
provider,
|
|
||||||
providerLabel,
|
|
||||||
isHero,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
provider?: string | null;
|
|
||||||
providerLabel?: string | null;
|
|
||||||
isHero: boolean;
|
|
||||||
}) {
|
|
||||||
const inferredProvider = provider || inferProviderFromModelName(label);
|
|
||||||
const brand = providerBrand(inferredProvider);
|
|
||||||
const [logoIndex, setLogoIndex] = useState(0);
|
|
||||||
const logoUrl = brand?.logoUrls[logoIndex];
|
|
||||||
const showLogo = !!logoUrl;
|
|
||||||
const title = providerLabel ? `${label} · ${providerLabel}` : label;
|
|
||||||
|
|
||||||
useEffect(() => setLogoIndex(0), [inferredProvider]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
title={title}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
|
|
||||||
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
|
||||||
isHero ? "h-9 max-w-[13.5rem] gap-2 px-2.5 text-[12px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
|
|
||||||
className={cn(
|
|
||||||
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
|
|
||||||
"h-5 w-5",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
borderColor: brand ? `${brand.color}28` : undefined,
|
|
||||||
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
|
||||||
}}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{showLogo ? (
|
|
||||||
<img
|
|
||||||
src={logoUrl}
|
|
||||||
alt=""
|
|
||||||
className="h-3.5 w-3.5 object-contain"
|
|
||||||
onError={() => setLogoIndex((index) => index + 1)}
|
|
||||||
/>
|
|
||||||
) : brand ? (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"grid h-full w-full place-items-center rounded-full text-white",
|
|
||||||
"text-[8px]",
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: brand.color }}
|
|
||||||
>
|
|
||||||
{brand.initials.slice(0, 2)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerCliMentionOverlay({
|
function ComposerCliMentionOverlay({
|
||||||
segments,
|
segments,
|
||||||
isHero,
|
isHero,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
segments: CapabilityMentionSegment[];
|
segments: CliAppMentionSegment[];
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
className: string;
|
className: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -1399,7 +1154,7 @@ function ComposerCliMentionOverlay({
|
|||||||
if (segment.kind === "text") {
|
if (segment.kind === "text") {
|
||||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||||
}
|
}
|
||||||
if (segment.kind === "cli") return (
|
return (
|
||||||
<CliAppMentionToken
|
<CliAppMentionToken
|
||||||
key={`cli-${segment.app.name}-${index}`}
|
key={`cli-${segment.app.name}-${index}`}
|
||||||
app={segment.app}
|
app={segment.app}
|
||||||
@@ -1408,35 +1163,26 @@ function ComposerCliMentionOverlay({
|
|||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return (
|
|
||||||
<McpPresetMentionToken
|
|
||||||
key={`mcp-${segment.preset.name}-${index}`}
|
|
||||||
preset={segment.preset}
|
|
||||||
label={segment.text}
|
|
||||||
variant="composer"
|
|
||||||
isHero={isHero}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
interface SlashCommandPaletteProps {
|
interface SlashCommandPaletteProps {
|
||||||
commands: SlashPaletteCommand[];
|
commands: SlashCommand[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
layout: SlashPaletteLayout;
|
layout: SlashPaletteLayout;
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
onHover: (index: number) => void;
|
onHover: (index: number) => void;
|
||||||
onChoose: (command: SlashPaletteCommand) => void;
|
onChoose: (command: SlashCommand) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CliAppMentionPaletteProps {
|
interface CliAppMentionPaletteProps {
|
||||||
candidates: MentionCandidate[];
|
apps: CliAppInfo[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
layout: SlashPaletteLayout;
|
layout: SlashPaletteLayout;
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
onHover: (index: number) => void;
|
onHover: (index: number) => void;
|
||||||
onChoose: (candidate: MentionCandidate) => void;
|
onChoose: (app: CliAppInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageAspectMenu({
|
function ImageAspectMenu({
|
||||||
@@ -1493,7 +1239,7 @@ function ImageAspectMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CliAppMentionPalette({
|
function CliAppMentionPalette({
|
||||||
candidates,
|
apps,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
layout,
|
layout,
|
||||||
isHero,
|
isHero,
|
||||||
@@ -1511,117 +1257,98 @@ function CliAppMentionPalette({
|
|||||||
aria-label={t("thread.composer.mentions.ariaLabel")}
|
aria-label={t("thread.composer.mentions.ariaLabel")}
|
||||||
style={{ maxHeight: layout.maxHeight }}
|
style={{ maxHeight: layout.maxHeight }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[22px] border",
|
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
||||||
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||||
"border-border/70 bg-popover p-2 text-popover-foreground shadow-[0_20px_60px_rgba(15,23,42,0.12)]",
|
"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_24px_60px_rgba(0,0,0,0.42)]",
|
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
|
<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">
|
||||||
{t("thread.composer.mentions.label")}
|
<AtSign className="h-3 w-3" aria-hidden />
|
||||||
|
<span>{t("thread.composer.mentions.label")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
|
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||||
{candidates.map((candidate, index) => {
|
{apps.map((app, index) => {
|
||||||
const selected = index === selectedIndex;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${candidate.kind}-${name}`}
|
key={app.name}
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
|
|
||||||
onMouseEnter={() => onHover(index)}
|
onMouseEnter={() => onHover(index)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChoose(candidate);
|
onChoose(app);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 text-left transition-colors",
|
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||||
selected
|
selected
|
||||||
? "bg-foreground/[0.055] text-foreground"
|
? "bg-primary/10 text-foreground"
|
||||||
: "text-foreground/90 hover:bg-foreground/[0.04]",
|
: "text-foreground/86 hover:bg-accent/55",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MentionCandidateLogo candidate={candidate} selected={selected} />
|
<CliAppMentionLogo app={app} selected={selected} />
|
||||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="shrink-0 text-[15px] font-medium tracking-normal text-foreground">
|
<span className="flex min-w-0 items-baseline gap-2">
|
||||||
{displayName}
|
<span className="font-mono text-[13px] font-semibold text-foreground">
|
||||||
|
@{app.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
|
<span className="truncate text-[13px] font-medium">
|
||||||
@{name}
|
{app.display_name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||||
className={cn(
|
{app.category}
|
||||||
"ml-2 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-normal",
|
{app.entry_point ? ` · ${app.entry_point}` : ""}
|
||||||
candidate.kind === "cli"
|
</span>
|
||||||
? "bg-orange-500/10 text-orange-600 dark:text-orange-300"
|
|
||||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{typeLabel}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MentionCandidateLogo({
|
function CliAppMentionLogo({
|
||||||
candidate,
|
app,
|
||||||
selected,
|
selected,
|
||||||
}: {
|
}: {
|
||||||
candidate: MentionCandidate;
|
app: CliAppInfo;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [logoIndex, setLogoIndex] = useState(0);
|
const [failed, setFailed] = useState(false);
|
||||||
const color = (candidate.kind === "cli"
|
const color = app.brand_color || "hsl(var(--primary))";
|
||||||
? candidate.app.brand_color
|
if (app.logo_url && !failed) {
|
||||||
: 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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px]",
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
|
||||||
selected ? "bg-background/55" : "bg-transparent",
|
selected ? "border-primary/25" : "border-border/65",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={app.logo_url}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-5 w-5 object-contain"
|
className="h-4.5 w-4.5 object-contain"
|
||||||
onError={() => setLogoIndex((index) => index + 1)}
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white"
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
>
|
>
|
||||||
{candidate.kind === "cli"
|
{cliAppInitials(app)}
|
||||||
? cliAppInitials(candidate.app)
|
|
||||||
: mcpPresetInitials(candidate.preset)}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1647,11 +1374,14 @@ function SlashCommandPalette({
|
|||||||
className={cn(
|
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-[18px] border",
|
||||||
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
|
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
|
||||||
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
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 }}>
|
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||||
{commands.map((command, index) => {
|
{commands.map((command, index) => {
|
||||||
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
|
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
|
||||||
@@ -1675,42 +1405,49 @@ function SlashCommandPalette({
|
|||||||
onChoose(command);
|
onChoose(command);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors",
|
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||||
selected
|
selected
|
||||||
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
|
? "bg-primary/10 text-foreground"
|
||||||
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
|
: "text-foreground/86 hover:bg-accent/55",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors",
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border",
|
||||||
selected && "text-foreground",
|
selected
|
||||||
|
? "border-primary/25 bg-primary/12 text-primary"
|
||||||
|
: "border-border/65 bg-muted/45 text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="min-w-0 truncate text-[13.5px] font-semibold tracking-normal text-foreground">
|
<span className="flex min-w-0 items-baseline gap-2">
|
||||||
{title}
|
<span className="font-mono text-[13px] font-semibold text-foreground">
|
||||||
|
{command.command}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 truncate text-[13px] text-muted-foreground">
|
{command.argHint ? (
|
||||||
{command.detail || description}
|
<span className="font-mono text-[12px] text-muted-foreground">
|
||||||
</span>
|
{command.argHint}
|
||||||
</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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="font-mono text-[12px] text-muted-foreground/60">
|
<span className="truncate text-[13px] font-medium">
|
||||||
{command.argHint ? `${command.command} ${command.argHint}` : command.command}
|
{title}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||||
|
{description}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
AgentActivityCluster,
|
AgentActivityCluster,
|
||||||
isAgentActivityMember,
|
isAgentActivityMember,
|
||||||
} from "@/components/thread/AgentActivityCluster";
|
} from "@/components/thread/AgentActivityCluster";
|
||||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface ThreadMessagesProps {
|
interface ThreadMessagesProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
@@ -15,7 +15,6 @@ interface ThreadMessagesProps {
|
|||||||
hiddenMessageCount?: number;
|
hiddenMessageCount?: number;
|
||||||
onLoadEarlier?: () => void;
|
onLoadEarlier?: () => void;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisplayUnit =
|
export type DisplayUnit =
|
||||||
@@ -134,7 +133,6 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
|||||||
reasoningStreaming: message.reasoningStreaming,
|
reasoningStreaming: message.reasoningStreaming,
|
||||||
isStreaming: message.reasoningStreaming,
|
isStreaming: message.reasoningStreaming,
|
||||||
activitySegmentId: message.activitySegmentId,
|
activitySegmentId: message.activitySegmentId,
|
||||||
latencyMs: message.latencyMs,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +166,6 @@ export function ThreadMessages({
|
|||||||
hiddenMessageCount = 0,
|
hiddenMessageCount = 0,
|
||||||
onLoadEarlier,
|
onLoadEarlier,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
|
||||||
}: ThreadMessagesProps) {
|
}: ThreadMessagesProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||||
@@ -205,8 +202,6 @@ export function ThreadMessages({
|
|||||||
unit.type === "cluster"
|
unit.type === "cluster"
|
||||||
&& next?.type === "single"
|
&& next?.type === "single"
|
||||||
&& next.message.role === "assistant";
|
&& next.message.role === "assistant";
|
||||||
const turnLatencyMs =
|
|
||||||
unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={unitKey(unit, index)} className={marginTop}>
|
<div key={unitKey(unit, index)} className={marginTop}>
|
||||||
@@ -215,9 +210,7 @@ export function ThreadMessages({
|
|||||||
messages={unit.messages}
|
messages={unit.messages}
|
||||||
isTurnStreaming={index === liveActivityClusterIndex}
|
isTurnStreaming={index === liveActivityClusterIndex}
|
||||||
hasBodyBelow={hasBodyBelow}
|
hasBodyBelow={hasBodyBelow}
|
||||||
turnLatencyMs={turnLatencyMs}
|
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
@@ -228,7 +221,6 @@ export function ThreadMessages({
|
|||||||
: true
|
: true
|
||||||
}
|
}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -238,28 +230,6 @@ export function ThreadMessages({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function activityClusterTurnLatencyMs(
|
|
||||||
messages: UIMessage[],
|
|
||||||
next: DisplayUnit | undefined,
|
|
||||||
): number | undefined {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
||||||
const latency = messages[i].latencyMs;
|
|
||||||
if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) {
|
|
||||||
return latency;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
next?.type === "single"
|
|
||||||
&& next.message.role === "assistant"
|
|
||||||
&& typeof next.message.latencyMs === "number"
|
|
||||||
&& Number.isFinite(next.message.latencyMs)
|
|
||||||
&& next.message.latencyMs >= 0
|
|
||||||
) {
|
|
||||||
return next.message.latencyMs;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
||||||
const last = units.length - 1;
|
const last = units.length - 1;
|
||||||
return units[last]?.type === "cluster" ? last : -1;
|
return units[last]?.type === "cluster" ? last : -1;
|
||||||
|
|||||||
@@ -19,19 +19,13 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
|||||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||||
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
||||||
import { useSessionHistory } from "@/hooks/useSessions";
|
import { useSessionHistory } from "@/hooks/useSessions";
|
||||||
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
|
import { fetchCliApps, listSlashCommands } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
CLI_APPS_CHANGED_EVENT,
|
CLI_APPS_CHANGED_EVENT,
|
||||||
installedCliAppsFromPayload,
|
installedCliAppsFromPayload,
|
||||||
isCliAppsPayload,
|
isCliAppsPayload,
|
||||||
} from "@/lib/cli-app-events";
|
} from "@/lib/cli-app-events";
|
||||||
import {
|
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||||
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 { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
|
||||||
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
|
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
@@ -40,20 +34,6 @@ function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
|||||||
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
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 {
|
interface ThreadShellProps {
|
||||||
session: ChatSummary | null;
|
session: ChatSummary | null;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -75,41 +55,6 @@ function toModelBadgeLabel(modelName: string | null): string | null {
|
|||||||
return leaf || trimmed;
|
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 = [
|
const QUICK_ACTION_KEYS = [
|
||||||
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
|
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
|
||||||
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
|
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
|
||||||
@@ -158,8 +103,6 @@ export function ThreadShell({
|
|||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
|
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
|
||||||
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
|
|
||||||
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
|
||||||
const [heroImageMode, setHeroImageMode] = useState(false);
|
const [heroImageMode, setHeroImageMode] = useState(false);
|
||||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||||
@@ -198,28 +141,6 @@ export function ThreadShell({
|
|||||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||||
|
|
||||||
const showHeroComposer = messages.length === 0 && !loading;
|
const showHeroComposer = messages.length === 0 && !loading;
|
||||||
const 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(() => {
|
useEffect(() => {
|
||||||
if (!chatId || loading) return;
|
if (!chatId || loading) return;
|
||||||
@@ -233,28 +154,19 @@ export function ThreadShell({
|
|||||||
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
||||||
// so rendering converges to the same shape as a manual refresh.
|
// so rendering converges to the same shape as a manual refresh.
|
||||||
setMessages((prev) => {
|
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 (hasNewCanonicalHistory && historical.length > 0) {
|
||||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
|
||||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
const normalized = projectWebuiThreadMessages(historical);
|
||||||
return normalizedHistory;
|
messageCacheRef.current.set(chatId, normalized);
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
if (cached && cached.length > 0) {
|
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
|
||||||
const normalizedCached = projectWebuiThreadMessages(cached);
|
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
|
||||||
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
|
|
||||||
return normalizedCached;
|
|
||||||
}
|
|
||||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
|
||||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||||
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
const next = projectWebuiThreadMessages(historical);
|
||||||
return normalizedHistory;
|
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [loading, chatId, historical, historyVersion]);
|
}, [loading, chatId, historical, historyVersion]);
|
||||||
@@ -350,15 +262,6 @@ export function ThreadShell({
|
|||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const refreshMcpPresets = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const payload = await fetchMcpPresets(token);
|
|
||||||
setMcpPresets(installedMcpPresetsFromPayload(payload));
|
|
||||||
} catch {
|
|
||||||
setMcpPresets([]);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -394,41 +297,6 @@ export function ThreadShell({
|
|||||||
};
|
};
|
||||||
}, [refreshCliApps, token]);
|
}, [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(
|
const handleWelcomeSend = useCallback(
|
||||||
async (content: string, images?: SendImage[], options?: SendOptions) => {
|
async (content: string, images?: SendImage[], options?: SendOptions) => {
|
||||||
if (booting) return;
|
if (booting) return;
|
||||||
@@ -511,13 +379,10 @@ export function ThreadShell({
|
|||||||
? t("thread.composer.placeholderHero")
|
? t("thread.composer.placeholderHero")
|
||||||
: t("thread.composer.placeholderThread")
|
: t("thread.composer.placeholderThread")
|
||||||
}
|
}
|
||||||
modelLabel={modelBadge.label}
|
modelLabel={toModelBadgeLabel(modelName)}
|
||||||
modelProvider={modelBadge.provider}
|
|
||||||
modelProviderLabel={modelBadge.providerLabel}
|
|
||||||
variant={showHeroComposer ? "hero" : "thread"}
|
variant={showHeroComposer ? "hero" : "thread"}
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
|
||||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
imageMode={showHeroComposer ? heroImageMode : undefined}
|
||||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
@@ -534,13 +399,10 @@ export function ThreadShell({
|
|||||||
? t("thread.composer.placeholderOpening")
|
? t("thread.composer.placeholderOpening")
|
||||||
: t("thread.composer.placeholderHero")
|
: t("thread.composer.placeholderHero")
|
||||||
}
|
}
|
||||||
modelLabel={modelBadge.label}
|
modelLabel={toModelBadgeLabel(modelName)}
|
||||||
modelProvider={modelBadge.provider}
|
|
||||||
modelProviderLabel={modelBadge.providerLabel}
|
|
||||||
variant="hero"
|
variant="hero"
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
|
||||||
imageMode={heroImageMode}
|
imageMode={heroImageMode}
|
||||||
onImageModeChange={setHeroImageMode}
|
onImageModeChange={setHeroImageMode}
|
||||||
runStartedAt={runStartedAt}
|
runStartedAt={runStartedAt}
|
||||||
@@ -582,7 +444,6 @@ export function ThreadShell({
|
|||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
showScrollToBottomButton={!!session}
|
showScrollToBottomButton={!!session}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
|||||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface ThreadViewportProps {
|
interface ThreadViewportProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
@@ -25,7 +25,6 @@ interface ThreadViewportProps {
|
|||||||
conversationKey?: string | null;
|
conversationKey?: string | null;
|
||||||
showScrollToBottomButton?: boolean;
|
showScrollToBottomButton?: boolean;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
@@ -56,7 +55,6 @@ export function ThreadViewport({
|
|||||||
conversationKey = null,
|
conversationKey = null,
|
||||||
showScrollToBottomButton = true,
|
showScrollToBottomButton = true,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
|
||||||
}: ThreadViewportProps) {
|
}: ThreadViewportProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -254,7 +252,6 @@ export function ThreadViewport({
|
|||||||
hiddenMessageCount={hiddenMessageCount}
|
hiddenMessageCount={hiddenMessageCount}
|
||||||
onLoadEarlier={loadEarlierMessages}
|
onLoadEarlier={loadEarlierMessages}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarFallback, AvatarImage };
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full min-w-0 rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
InboundEvent,
|
InboundEvent,
|
||||||
OutboundCliAppMention,
|
OutboundCliAppMention,
|
||||||
OutboundImageGeneration,
|
OutboundImageGeneration,
|
||||||
OutboundMcpPresetMention,
|
|
||||||
OutboundMedia,
|
OutboundMedia,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
UIImage,
|
UIImage,
|
||||||
@@ -314,7 +313,6 @@ export interface SendImage {
|
|||||||
export interface SendOptions {
|
export interface SendOptions {
|
||||||
imageGeneration?: OutboundImageGeneration;
|
imageGeneration?: OutboundImageGeneration;
|
||||||
cliApps?: OutboundCliAppMention[];
|
cliApps?: OutboundCliAppMention[];
|
||||||
mcpPresets?: OutboundMcpPresetMention[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNanobotStream(
|
export function useNanobotStream(
|
||||||
@@ -893,7 +891,6 @@ export function useNanobotStream(
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
...(previews ? { images: previews } : {}),
|
...(previews ? { images: previews } : {}),
|
||||||
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
||||||
...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,8 +63,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
"ariaLabel": "Change language"
|
"ariaLabel": "Change language"
|
||||||
},
|
}
|
||||||
"apps": "Apps"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Back to chat",
|
"backToChat": "Back to chat",
|
||||||
@@ -82,10 +81,8 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"web": "Web",
|
"web": "Web",
|
||||||
"cliApps": "CLI Apps",
|
"cliApps": "CLI Apps",
|
||||||
"mcp": "MCP",
|
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced"
|
||||||
"apps": "Apps"
|
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "Interface",
|
"interface": "Interface",
|
||||||
@@ -98,21 +95,11 @@
|
|||||||
"imageDefaults": "Defaults",
|
"imageDefaults": "Defaults",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
"cliApps": "CLI apps",
|
"cliApps": "CLI Apps",
|
||||||
"mcp": "MCP services",
|
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "Capabilities",
|
"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": {
|
"rows": {
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
@@ -125,7 +112,6 @@
|
|||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"restartState": "Restart state",
|
"restartState": "Restart state",
|
||||||
"pendingChanges": "Pending changes",
|
"pendingChanges": "Pending changes",
|
||||||
"currentModel": "Current model",
|
|
||||||
"selectedPreset": "Selected preset",
|
"selectedPreset": "Selected preset",
|
||||||
"presetModel": "Preset model",
|
"presetModel": "Preset model",
|
||||||
"density": "Density",
|
"density": "Density",
|
||||||
@@ -168,9 +154,6 @@
|
|||||||
"provider": "Select the provider that should serve new model requests.",
|
"provider": "Select the provider that should serve new model requests.",
|
||||||
"model": "Set the default model name used by nanobot.",
|
"model": "Set the default model name used by nanobot.",
|
||||||
"configPath": "The gateway configuration file currently in use.",
|
"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.",
|
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
|
||||||
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
|
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
|
||||||
"density": "Stored only in this browser.",
|
"density": "Stored only in this browser.",
|
||||||
@@ -195,11 +178,6 @@
|
|||||||
"cliAppsFilter": "Search by app, category, or capability.",
|
"cliAppsFilter": "Search by app, category, or capability.",
|
||||||
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
|
"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": {
|
"cliApps": {
|
||||||
"allCategories": "All categories",
|
"allCategories": "All categories",
|
||||||
"availableCount": "{{count}} apps",
|
"availableCount": "{{count}} apps",
|
||||||
@@ -231,59 +209,6 @@
|
|||||||
"unavailable": "Unavailable",
|
"unavailable": "Unavailable",
|
||||||
"noDescription": "No description available."
|
"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": {
|
"values": {
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
@@ -371,8 +296,7 @@
|
|||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search.",
|
"noMatches": "No providers match this search."
|
||||||
"saveProvider": "Save provider"
|
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
||||||
@@ -383,20 +307,6 @@
|
|||||||
"selectSize": "Select size",
|
"selectSize": "Select size",
|
||||||
"configureProvider": "Configure provider",
|
"configureProvider": "Configure provider",
|
||||||
"missingCredential": "Configure this provider before enabling image generation."
|
"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": {
|
"chat": {
|
||||||
@@ -551,16 +461,6 @@
|
|||||||
"navigateHint": "↑↓ Navigate",
|
"navigateHint": "↑↓ Navigate",
|
||||||
"selectHint": "Enter/Tab Select",
|
"selectHint": "Enter/Tab Select",
|
||||||
"closeHint": "Esc Close",
|
"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": {
|
"commands": {
|
||||||
"new": {
|
"new": {
|
||||||
"title": "New chat",
|
"title": "New chat",
|
||||||
@@ -578,10 +478,6 @@
|
|||||||
"title": "Show status",
|
"title": "Show status",
|
||||||
"description": "Display runtime, provider, and channel status."
|
"description": "Display runtime, provider, and channel status."
|
||||||
},
|
},
|
||||||
"model": {
|
|
||||||
"title": "Model",
|
|
||||||
"description": "Show or switch the active model preset."
|
|
||||||
},
|
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Show conversation history",
|
"title": "Show conversation history",
|
||||||
"description": "Print the last N persisted conversation messages."
|
"description": "Print the last N persisted conversation messages."
|
||||||
@@ -605,22 +501,12 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Show help",
|
"title": "Show help",
|
||||||
"description": "List available slash commands."
|
"description": "List available slash commands."
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "Pairing",
|
|
||||||
"description": "Manage pairing requests."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mentions": {
|
"mentions": {
|
||||||
"ariaLabel": "Apps",
|
"ariaLabel": "CLI Apps",
|
||||||
"label": "Apps",
|
"label": "CLI 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…",
|
"encoding": "Encoding…",
|
||||||
"remove": "Remove attachment",
|
"remove": "Remove attachment",
|
||||||
@@ -653,18 +539,15 @@
|
|||||||
"agentActivityToolsOnly": "{{tools}} tool calls",
|
"agentActivityToolsOnly": "{{tools}} tool calls",
|
||||||
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||||
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
|
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
|
||||||
"activityThinkingFor": "Thinking for {{duration}}",
|
"cliActivityRunningOne": "Running CLI @{{name}}",
|
||||||
"activityThought": "Thought",
|
"cliActivityRanOne": "Ran CLI @{{name}}",
|
||||||
"activityThoughtFor": "Thought for {{duration}}",
|
"cliActivityFailedOne": "CLI failed @{{name}}",
|
||||||
"cliActivityRunningOne": "Using @{{name}}",
|
"cliActivityRunningMany": "Running {{count}} CLIs",
|
||||||
"cliActivityRanOne": "Used @{{name}}",
|
"cliActivityRanMany": "Ran {{count}} CLIs",
|
||||||
"cliActivityFailedOne": "Failed @{{name}}",
|
"cliActivityFailedMany": "{{count}} CLI failed",
|
||||||
"cliActivityRunningMany": "Using {{count}} CLI apps",
|
"cliRunRunning": "Running CLI",
|
||||||
"cliActivityRanMany": "Used {{count}} CLI apps",
|
"cliRunRan": "Ran CLI",
|
||||||
"cliActivityFailedMany": "{{count}} CLI apps failed",
|
"cliRunFailed": "CLI failed",
|
||||||
"cliRunRunning": "Using",
|
|
||||||
"cliRunRan": "Used",
|
|
||||||
"cliRunFailed": "Failed",
|
|
||||||
"imageAttachment": "Image attachment",
|
"imageAttachment": "Image attachment",
|
||||||
"copyReply": "Copy reply",
|
"copyReply": "Copy reply",
|
||||||
"copiedReply": "Copied reply",
|
"copiedReply": "Copied reply",
|
||||||
|
|||||||
@@ -63,8 +63,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "Idioma",
|
"label": "Idioma",
|
||||||
"ariaLabel": "Cambiar idioma"
|
"ariaLabel": "Cambiar idioma"
|
||||||
},
|
}
|
||||||
"apps": "Apps"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Volver al chat",
|
"backToChat": "Volver al chat",
|
||||||
@@ -82,10 +81,7 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"web": "Web",
|
"web": "Web",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced"
|
||||||
"cliApps": "Apps CLI",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"apps": "Apps"
|
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "Interfaz",
|
"interface": "Interfaz",
|
||||||
@@ -101,10 +97,7 @@
|
|||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "Capacidades",
|
"capabilities": "Capacidades",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations"
|
||||||
"cliApps": "Apps CLI",
|
|
||||||
"mcp": "Servicios MCP",
|
|
||||||
"apps": "Apps"
|
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
@@ -148,11 +141,7 @@
|
|||||||
"ssrfWhitelist": "SSRF whitelist",
|
"ssrfWhitelist": "SSRF whitelist",
|
||||||
"mcpServers": "MCP servers",
|
"mcpServers": "MCP servers",
|
||||||
"pathAppend": "PATH append",
|
"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": {
|
"help": {
|
||||||
"theme": "Cambia entre apariencia clara y oscura.",
|
"theme": "Cambia entre apariencia clara y oscura.",
|
||||||
@@ -179,13 +168,7 @@
|
|||||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||||
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
|
"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": {
|
"values": {
|
||||||
"light": "Claro",
|
"light": "Claro",
|
||||||
@@ -202,7 +185,9 @@
|
|||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"notConfigured": "Not configured"
|
"notConfigured": "Not configured",
|
||||||
|
"restartRequired": "Restart required",
|
||||||
|
"liveReload": "Live reload ready"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando configuración...",
|
"loading": "Cargando configuración...",
|
||||||
@@ -274,8 +259,7 @@
|
|||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search.",
|
"noMatches": "No providers match this search."
|
||||||
"saveProvider": "Guardar proveedor"
|
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "Seleccionar proveedor",
|
"selectProvider": "Seleccionar proveedor",
|
||||||
@@ -283,120 +267,6 @@
|
|||||||
"selectSize": "Seleccionar tamaño",
|
"selectSize": "Seleccionar tamaño",
|
||||||
"configureProvider": "Configurar proveedor",
|
"configureProvider": "Configurar proveedor",
|
||||||
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
|
"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": {
|
"chat": {
|
||||||
@@ -500,7 +370,8 @@
|
|||||||
"title": "Editar una imagen",
|
"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."
|
"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": {
|
"header": {
|
||||||
"toggleSidebar": "Mostrar u ocultar la barra lateral",
|
"toggleSidebar": "Mostrar u ocultar la barra lateral",
|
||||||
@@ -568,10 +439,6 @@
|
|||||||
"title": "Mostrar estado",
|
"title": "Mostrar estado",
|
||||||
"description": "Muestra el estado del runtime, provider y channels."
|
"description": "Muestra el estado del runtime, provider y channels."
|
||||||
},
|
},
|
||||||
"model": {
|
|
||||||
"title": "Modelo",
|
|
||||||
"description": "Muestra o cambia el preset de modelo activo."
|
|
||||||
},
|
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Mostrar historial",
|
"title": "Mostrar historial",
|
||||||
"description": "Imprime los últimos N mensajes persistidos de la conversación."
|
"description": "Imprime los últimos N mensajes persistidos de la conversación."
|
||||||
@@ -595,21 +462,7 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Mostrar ayuda",
|
"title": "Mostrar ayuda",
|
||||||
"description": "Lista los comandos slash disponibles."
|
"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…",
|
"encoding": "Procesando…",
|
||||||
@@ -622,16 +475,6 @@
|
|||||||
"decode_failed": "No se pudo decodificar esta imagen",
|
"decode_failed": "No se pudo decodificar esta imagen",
|
||||||
"too_large": "Imagen demasiado grande — prueba una más pequeña",
|
"too_large": "Imagen demasiado grande — prueba una más pequeña",
|
||||||
"io": "No se pudo leer este archivo"
|
"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",
|
"scrollToBottom": "Desplazarse al final",
|
||||||
@@ -656,19 +499,7 @@
|
|||||||
"imageAttachment": "Imagen adjunta",
|
"imageAttachment": "Imagen adjunta",
|
||||||
"copyReply": "Copiar respuesta",
|
"copyReply": "Copiar respuesta",
|
||||||
"copiedReply": "Respuesta copiada",
|
"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": {
|
"lightbox": {
|
||||||
"title": "Vista previa de imagen",
|
"title": "Vista previa de imagen",
|
||||||
|
|||||||
@@ -63,8 +63,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "Langue",
|
"label": "Langue",
|
||||||
"ariaLabel": "Changer de langue"
|
"ariaLabel": "Changer de langue"
|
||||||
},
|
}
|
||||||
"apps": "Apps"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Retour à la discussion",
|
"backToChat": "Retour à la discussion",
|
||||||
@@ -82,10 +81,7 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"web": "Web",
|
"web": "Web",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced"
|
||||||
"cliApps": "Apps CLI",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"apps": "Apps"
|
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "Interface",
|
"interface": "Interface",
|
||||||
@@ -101,10 +97,7 @@
|
|||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "Capacités",
|
"capabilities": "Capacités",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations"
|
||||||
"cliApps": "Apps CLI",
|
|
||||||
"mcp": "Services MCP",
|
|
||||||
"apps": "Apps"
|
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "Thème",
|
"theme": "Thème",
|
||||||
@@ -148,11 +141,7 @@
|
|||||||
"ssrfWhitelist": "SSRF whitelist",
|
"ssrfWhitelist": "SSRF whitelist",
|
||||||
"mcpServers": "MCP servers",
|
"mcpServers": "MCP servers",
|
||||||
"pathAppend": "PATH append",
|
"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": {
|
"help": {
|
||||||
"theme": "Basculer entre les apparences claire et sombre.",
|
"theme": "Basculer entre les apparences claire et sombre.",
|
||||||
@@ -179,13 +168,7 @@
|
|||||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||||
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
|
"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": {
|
"values": {
|
||||||
"light": "Clair",
|
"light": "Clair",
|
||||||
@@ -202,7 +185,9 @@
|
|||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"notConfigured": "Not configured"
|
"notConfigured": "Not configured",
|
||||||
|
"restartRequired": "Restart required",
|
||||||
|
"liveReload": "Live reload ready"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement des paramètres...",
|
"loading": "Chargement des paramètres...",
|
||||||
@@ -274,8 +259,7 @@
|
|||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search.",
|
"noMatches": "No providers match this search."
|
||||||
"saveProvider": "Enregistrer le fournisseur"
|
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "Sélectionner un fournisseur",
|
"selectProvider": "Sélectionner un fournisseur",
|
||||||
@@ -283,120 +267,6 @@
|
|||||||
"selectSize": "Sélectionner une taille",
|
"selectSize": "Sélectionner une taille",
|
||||||
"configureProvider": "Configurer le fournisseur",
|
"configureProvider": "Configurer le fournisseur",
|
||||||
"missingCredential": "Configurez ce fournisseur avant d’activer la génération d’images."
|
"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": {
|
"chat": {
|
||||||
@@ -500,7 +370,8 @@
|
|||||||
"title": "Modifier une image",
|
"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é."
|
"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": {
|
"header": {
|
||||||
"toggleSidebar": "Afficher ou masquer la barre latérale",
|
"toggleSidebar": "Afficher ou masquer la barre latérale",
|
||||||
@@ -568,10 +439,6 @@
|
|||||||
"title": "Afficher l’état",
|
"title": "Afficher l’état",
|
||||||
"description": "Afficher l’état du runtime, du provider et des channels."
|
"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": {
|
"history": {
|
||||||
"title": "Afficher l’historique",
|
"title": "Afficher l’historique",
|
||||||
"description": "Afficher les N derniers messages persistés de la conversation."
|
"description": "Afficher les N derniers messages persistés de la conversation."
|
||||||
@@ -595,21 +462,7 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Afficher l’aide",
|
"title": "Afficher l’aide",
|
||||||
"description": "Lister les commandes slash disponibles."
|
"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…",
|
"encoding": "Traitement…",
|
||||||
@@ -622,16 +475,6 @@
|
|||||||
"decode_failed": "Impossible de décoder cette image",
|
"decode_failed": "Impossible de décoder cette image",
|
||||||
"too_large": "Image trop grande — essayez-en une plus petite",
|
"too_large": "Image trop grande — essayez-en une plus petite",
|
||||||
"io": "Impossible de lire ce fichier"
|
"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",
|
"scrollToBottom": "Faire défiler vers le bas",
|
||||||
@@ -656,19 +499,7 @@
|
|||||||
"imageAttachment": "Pièce jointe image",
|
"imageAttachment": "Pièce jointe image",
|
||||||
"copyReply": "Copier la réponse",
|
"copyReply": "Copier la réponse",
|
||||||
"copiedReply": "Réponse copiée",
|
"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": {
|
"lightbox": {
|
||||||
"title": "Aperçu de l’image",
|
"title": "Aperçu de l’image",
|
||||||
|
|||||||
@@ -63,8 +63,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "Bahasa",
|
"label": "Bahasa",
|
||||||
"ariaLabel": "Ganti bahasa"
|
"ariaLabel": "Ganti bahasa"
|
||||||
},
|
}
|
||||||
"apps": "Aplikasi"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Kembali ke obrolan",
|
"backToChat": "Kembali ke obrolan",
|
||||||
@@ -82,10 +81,7 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"web": "Web",
|
"web": "Web",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced"
|
||||||
"cliApps": "Aplikasi CLI",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"apps": "Aplikasi"
|
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "Antarmuka",
|
"interface": "Antarmuka",
|
||||||
@@ -101,10 +97,7 @@
|
|||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "Kapabilitas",
|
"capabilities": "Kapabilitas",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations"
|
||||||
"cliApps": "App CLI",
|
|
||||||
"mcp": "Layanan MCP",
|
|
||||||
"apps": "Aplikasi"
|
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
@@ -148,11 +141,7 @@
|
|||||||
"ssrfWhitelist": "SSRF whitelist",
|
"ssrfWhitelist": "SSRF whitelist",
|
||||||
"mcpServers": "MCP servers",
|
"mcpServers": "MCP servers",
|
||||||
"pathAppend": "PATH append",
|
"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": {
|
"help": {
|
||||||
"theme": "Beralih antara tampilan terang dan gelap.",
|
"theme": "Beralih antara tampilan terang dan gelap.",
|
||||||
@@ -179,13 +168,7 @@
|
|||||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||||
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
|
"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": {
|
"values": {
|
||||||
"light": "Terang",
|
"light": "Terang",
|
||||||
@@ -202,7 +185,9 @@
|
|||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"notConfigured": "Not configured"
|
"notConfigured": "Not configured",
|
||||||
|
"restartRequired": "Restart required",
|
||||||
|
"liveReload": "Live reload ready"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Memuat pengaturan...",
|
"loading": "Memuat pengaturan...",
|
||||||
@@ -274,8 +259,7 @@
|
|||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search.",
|
"noMatches": "No providers match this search."
|
||||||
"saveProvider": "Simpan penyedia"
|
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "Pilih penyedia",
|
"selectProvider": "Pilih penyedia",
|
||||||
@@ -283,120 +267,6 @@
|
|||||||
"selectSize": "Pilih ukuran",
|
"selectSize": "Pilih ukuran",
|
||||||
"configureProvider": "Konfigurasi penyedia",
|
"configureProvider": "Konfigurasi penyedia",
|
||||||
"missingCredential": "Konfigurasikan penyedia ini sebelum mengaktifkan pembuatan gambar."
|
"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": {
|
"chat": {
|
||||||
@@ -500,7 +370,8 @@
|
|||||||
"title": "Edit gambar",
|
"title": "Edit gambar",
|
||||||
"prompt": "Bantu saya mengedit gambar. Minta saya mengunggah atau menyebutkan gambar terlebih dahulu, lalu buat hasil editnya."
|
"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": {
|
"header": {
|
||||||
"toggleSidebar": "Tampilkan atau sembunyikan sidebar",
|
"toggleSidebar": "Tampilkan atau sembunyikan sidebar",
|
||||||
@@ -568,10 +439,6 @@
|
|||||||
"title": "Tampilkan status",
|
"title": "Tampilkan status",
|
||||||
"description": "Tampilkan status runtime, provider, dan channel."
|
"description": "Tampilkan status runtime, provider, dan channel."
|
||||||
},
|
},
|
||||||
"model": {
|
|
||||||
"title": "Model",
|
|
||||||
"description": "Tampilkan atau ganti preset model aktif."
|
|
||||||
},
|
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Tampilkan riwayat",
|
"title": "Tampilkan riwayat",
|
||||||
"description": "Cetak N pesan percakapan tersimpan terbaru."
|
"description": "Cetak N pesan percakapan tersimpan terbaru."
|
||||||
@@ -595,21 +462,7 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Tampilkan bantuan",
|
"title": "Tampilkan bantuan",
|
||||||
"description": "Daftar perintah slash yang tersedia."
|
"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…",
|
"encoding": "Memproses…",
|
||||||
@@ -622,16 +475,6 @@
|
|||||||
"decode_failed": "Tidak dapat mendekode gambar ini",
|
"decode_failed": "Tidak dapat mendekode gambar ini",
|
||||||
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
|
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
|
||||||
"io": "Tidak dapat membaca file ini"
|
"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",
|
"scrollToBottom": "Gulir ke bawah",
|
||||||
@@ -656,19 +499,7 @@
|
|||||||
"imageAttachment": "Lampiran gambar",
|
"imageAttachment": "Lampiran gambar",
|
||||||
"copyReply": "Salin balasan",
|
"copyReply": "Salin balasan",
|
||||||
"copiedReply": "Balasan disalin",
|
"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": {
|
"lightbox": {
|
||||||
"title": "Pratinjau gambar",
|
"title": "Pratinjau gambar",
|
||||||
|
|||||||
@@ -63,8 +63,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "言語",
|
"label": "言語",
|
||||||
"ariaLabel": "言語を変更"
|
"ariaLabel": "言語を変更"
|
||||||
},
|
}
|
||||||
"apps": "アプリ"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "チャットに戻る",
|
"backToChat": "チャットに戻る",
|
||||||
@@ -82,10 +81,7 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"web": "Web",
|
"web": "Web",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced"
|
||||||
"cliApps": "CLI アプリ",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"apps": "アプリ"
|
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "インターフェース",
|
"interface": "インターフェース",
|
||||||
@@ -101,10 +97,7 @@
|
|||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "機能",
|
"capabilities": "機能",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations"
|
||||||
"cliApps": "CLI アプリ",
|
|
||||||
"mcp": "MCP サービス",
|
|
||||||
"apps": "アプリ"
|
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
@@ -148,11 +141,7 @@
|
|||||||
"ssrfWhitelist": "SSRF whitelist",
|
"ssrfWhitelist": "SSRF whitelist",
|
||||||
"mcpServers": "MCP servers",
|
"mcpServers": "MCP servers",
|
||||||
"pathAppend": "PATH append",
|
"pathAppend": "PATH append",
|
||||||
"configurationDocs": "Configuration docs",
|
"configurationDocs": "Configuration docs"
|
||||||
"currentModel": "現在のモデル",
|
|
||||||
"brandLogos": "ブランドロゴ",
|
|
||||||
"cliAppsCatalog": "CLI アプリカタログ",
|
|
||||||
"cliAppsFilter": "CLI アプリフィルター"
|
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"theme": "ライト表示とダーク表示を切り替えます。",
|
"theme": "ライト表示とダーク表示を切り替えます。",
|
||||||
@@ -179,13 +168,7 @@
|
|||||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||||
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
|
"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": {
|
"values": {
|
||||||
"light": "ライト",
|
"light": "ライト",
|
||||||
@@ -202,7 +185,9 @@
|
|||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"notConfigured": "Not configured"
|
"notConfigured": "Not configured",
|
||||||
|
"restartRequired": "Restart required",
|
||||||
|
"liveReload": "Live reload ready"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "設定を読み込んでいます...",
|
"loading": "設定を読み込んでいます...",
|
||||||
@@ -274,8 +259,7 @@
|
|||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search.",
|
"noMatches": "No providers match this search."
|
||||||
"saveProvider": "プロバイダーを保存"
|
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "プロバイダーを選択",
|
"selectProvider": "プロバイダーを選択",
|
||||||
@@ -283,120 +267,6 @@
|
|||||||
"selectSize": "サイズを選択",
|
"selectSize": "サイズを選択",
|
||||||
"configureProvider": "プロバイダーを設定",
|
"configureProvider": "プロバイダーを設定",
|
||||||
"missingCredential": "画像生成を有効にする前に、このプロバイダーを設定してください。"
|
"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": {
|
"chat": {
|
||||||
@@ -500,7 +370,8 @@
|
|||||||
"title": "画像を編集",
|
"title": "画像を編集",
|
||||||
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
|
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "サイドバーを切り替える",
|
"toggleSidebar": "サイドバーを切り替える",
|
||||||
@@ -568,10 +439,6 @@
|
|||||||
"title": "ステータスを表示",
|
"title": "ステータスを表示",
|
||||||
"description": "ランタイム、provider、channel の状態を表示します。"
|
"description": "ランタイム、provider、channel の状態を表示します。"
|
||||||
},
|
},
|
||||||
"model": {
|
|
||||||
"title": "モデル",
|
|
||||||
"description": "有効なモデルプリセットを表示または切り替えます。"
|
|
||||||
},
|
|
||||||
"history": {
|
"history": {
|
||||||
"title": "会話履歴を表示",
|
"title": "会話履歴を表示",
|
||||||
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
|
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
|
||||||
@@ -595,21 +462,7 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "ヘルプを表示",
|
"title": "ヘルプを表示",
|
||||||
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "ペアリング",
|
|
||||||
"description": "ペアリングリクエストを管理します。"
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"badges": {
|
|
||||||
"current": "現在",
|
|
||||||
"recent": "最近"
|
|
||||||
},
|
|
||||||
"details": {
|
|
||||||
"goalActive": "目標が実行中",
|
|
||||||
"goalReady": "継続的な目標を開始",
|
|
||||||
"history": "最近のメッセージを表示",
|
|
||||||
"stopRunning": "実行中"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "処理中…",
|
"encoding": "処理中…",
|
||||||
@@ -622,16 +475,6 @@
|
|||||||
"decode_failed": "この画像をデコードできません",
|
"decode_failed": "この画像をデコードできません",
|
||||||
"too_large": "画像が大きすぎます。小さいものを選んでください",
|
"too_large": "画像が大きすぎます。小さいものを選んでください",
|
||||||
"io": "このファイルを読み込めません"
|
"io": "このファイルを読み込めません"
|
||||||
},
|
|
||||||
"mentions": {
|
|
||||||
"ariaLabel": "アプリ",
|
|
||||||
"label": "アプリ",
|
|
||||||
"cliGroup": "CLI アプリ",
|
|
||||||
"mcpGroup": "MCP サービス",
|
|
||||||
"cliBadge": "CLI",
|
|
||||||
"mcpBadge": "MCP",
|
|
||||||
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
|
|
||||||
"mcpDescription": "@{{name}} を MCP サーバーとして使用"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "一番下へスクロール",
|
"scrollToBottom": "一番下へスクロール",
|
||||||
@@ -656,19 +499,7 @@
|
|||||||
"imageAttachment": "画像の添付",
|
"imageAttachment": "画像の添付",
|
||||||
"copyReply": "返信をコピー",
|
"copyReply": "返信をコピー",
|
||||||
"copiedReply": "返信をコピーしました",
|
"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": {
|
"lightbox": {
|
||||||
"title": "画像プレビュー",
|
"title": "画像プレビュー",
|
||||||
|
|||||||
@@ -63,8 +63,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "언어",
|
"label": "언어",
|
||||||
"ariaLabel": "언어 변경"
|
"ariaLabel": "언어 변경"
|
||||||
},
|
}
|
||||||
"apps": "앱"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "채팅으로 돌아가기",
|
"backToChat": "채팅으로 돌아가기",
|
||||||
@@ -82,10 +81,7 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"web": "Web",
|
"web": "Web",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced"
|
||||||
"cliApps": "CLI 앱",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"apps": "앱"
|
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "인터페이스",
|
"interface": "인터페이스",
|
||||||
@@ -101,10 +97,7 @@
|
|||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "기능",
|
"capabilities": "기능",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations"
|
||||||
"cliApps": "CLI 앱",
|
|
||||||
"mcp": "MCP 서비스",
|
|
||||||
"apps": "앱"
|
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "테마",
|
"theme": "테마",
|
||||||
@@ -148,11 +141,7 @@
|
|||||||
"ssrfWhitelist": "SSRF whitelist",
|
"ssrfWhitelist": "SSRF whitelist",
|
||||||
"mcpServers": "MCP servers",
|
"mcpServers": "MCP servers",
|
||||||
"pathAppend": "PATH append",
|
"pathAppend": "PATH append",
|
||||||
"configurationDocs": "Configuration docs",
|
"configurationDocs": "Configuration docs"
|
||||||
"currentModel": "현재 모델",
|
|
||||||
"brandLogos": "브랜드 로고",
|
|
||||||
"cliAppsCatalog": "CLI 앱 카탈로그",
|
|
||||||
"cliAppsFilter": "CLI 앱 필터"
|
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
|
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
|
||||||
@@ -179,13 +168,7 @@
|
|||||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||||
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
|
"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": {
|
"values": {
|
||||||
"light": "라이트",
|
"light": "라이트",
|
||||||
@@ -202,7 +185,9 @@
|
|||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"notConfigured": "Not configured"
|
"notConfigured": "Not configured",
|
||||||
|
"restartRequired": "Restart required",
|
||||||
|
"liveReload": "Live reload ready"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "설정을 불러오는 중...",
|
"loading": "설정을 불러오는 중...",
|
||||||
@@ -274,8 +259,7 @@
|
|||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search.",
|
"noMatches": "No providers match this search."
|
||||||
"saveProvider": "제공자 저장"
|
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "제공자 선택",
|
"selectProvider": "제공자 선택",
|
||||||
@@ -283,120 +267,6 @@
|
|||||||
"selectSize": "크기 선택",
|
"selectSize": "크기 선택",
|
||||||
"configureProvider": "제공자 구성",
|
"configureProvider": "제공자 구성",
|
||||||
"missingCredential": "이미지 생성을 활성화하기 전에 이 제공자를 구성하세요."
|
"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": {
|
"chat": {
|
||||||
@@ -500,7 +370,8 @@
|
|||||||
"title": "이미지 편집",
|
"title": "이미지 편집",
|
||||||
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
|
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "사이드바 전환",
|
"toggleSidebar": "사이드바 전환",
|
||||||
@@ -568,10 +439,6 @@
|
|||||||
"title": "상태 보기",
|
"title": "상태 보기",
|
||||||
"description": "런타임, provider, channel 상태를 표시합니다."
|
"description": "런타임, provider, channel 상태를 표시합니다."
|
||||||
},
|
},
|
||||||
"model": {
|
|
||||||
"title": "모델",
|
|
||||||
"description": "활성 모델 프리셋을 보거나 전환합니다."
|
|
||||||
},
|
|
||||||
"history": {
|
"history": {
|
||||||
"title": "대화 기록 보기",
|
"title": "대화 기록 보기",
|
||||||
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
|
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
|
||||||
@@ -595,21 +462,7 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "도움말 보기",
|
"title": "도움말 보기",
|
||||||
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "페어링",
|
|
||||||
"description": "페어링 요청을 관리합니다."
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"badges": {
|
|
||||||
"current": "현재",
|
|
||||||
"recent": "최근"
|
|
||||||
},
|
|
||||||
"details": {
|
|
||||||
"goalActive": "목표 실행 중",
|
|
||||||
"goalReady": "지속 목표 시작",
|
|
||||||
"history": "최근 메시지 보기",
|
|
||||||
"stopRunning": "실행 중"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "처리 중…",
|
"encoding": "처리 중…",
|
||||||
@@ -622,16 +475,6 @@
|
|||||||
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
|
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
|
||||||
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
|
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
|
||||||
"io": "이 파일을 읽을 수 없습니다"
|
"io": "이 파일을 읽을 수 없습니다"
|
||||||
},
|
|
||||||
"mentions": {
|
|
||||||
"ariaLabel": "앱",
|
|
||||||
"label": "앱",
|
|
||||||
"cliGroup": "CLI 앱",
|
|
||||||
"mcpGroup": "MCP 서비스",
|
|
||||||
"cliBadge": "CLI",
|
|
||||||
"mcpBadge": "MCP",
|
|
||||||
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
|
|
||||||
"mcpDescription": "@{{name}}을 MCP 서버로 사용"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "맨 아래로 스크롤",
|
"scrollToBottom": "맨 아래로 스크롤",
|
||||||
@@ -656,19 +499,7 @@
|
|||||||
"imageAttachment": "이미지 첨부",
|
"imageAttachment": "이미지 첨부",
|
||||||
"copyReply": "답변 복사",
|
"copyReply": "답변 복사",
|
||||||
"copiedReply": "답변이 복사됨",
|
"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": {
|
"lightbox": {
|
||||||
"title": "이미지 미리보기",
|
"title": "이미지 미리보기",
|
||||||
|
|||||||
@@ -63,8 +63,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "Ngôn ngữ",
|
"label": "Ngôn ngữ",
|
||||||
"ariaLabel": "Đổi ngôn ngữ"
|
"ariaLabel": "Đổi ngôn ngữ"
|
||||||
},
|
}
|
||||||
"apps": "Ứng dụng"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Quay lại trò chuyện",
|
"backToChat": "Quay lại trò chuyện",
|
||||||
@@ -82,10 +81,7 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"web": "Web",
|
"web": "Web",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced"
|
||||||
"cliApps": "Ứng dụng CLI",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"apps": "Ứng dụng"
|
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "Giao diện",
|
"interface": "Giao diện",
|
||||||
@@ -101,10 +97,7 @@
|
|||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "Khả năng",
|
"capabilities": "Khả năng",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations"
|
||||||
"cliApps": "Ứng dụng CLI",
|
|
||||||
"mcp": "Dịch vụ MCP",
|
|
||||||
"apps": "Ứng dụng"
|
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "Giao diện",
|
"theme": "Giao diện",
|
||||||
@@ -148,11 +141,7 @@
|
|||||||
"ssrfWhitelist": "SSRF whitelist",
|
"ssrfWhitelist": "SSRF whitelist",
|
||||||
"mcpServers": "MCP servers",
|
"mcpServers": "MCP servers",
|
||||||
"pathAppend": "PATH append",
|
"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": {
|
"help": {
|
||||||
"theme": "Chuyển giữa giao diện sáng và tối.",
|
"theme": "Chuyển giữa giao diện sáng và tối.",
|
||||||
@@ -179,13 +168,7 @@
|
|||||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||||
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
|
"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": {
|
"values": {
|
||||||
"light": "Sáng",
|
"light": "Sáng",
|
||||||
@@ -202,7 +185,9 @@
|
|||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"notConfigured": "Not configured"
|
"notConfigured": "Not configured",
|
||||||
|
"restartRequired": "Restart required",
|
||||||
|
"liveReload": "Live reload ready"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Đang tải cài đặt...",
|
"loading": "Đang tải cài đặt...",
|
||||||
@@ -274,8 +259,7 @@
|
|||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search.",
|
"noMatches": "No providers match this search."
|
||||||
"saveProvider": "Lưu nhà cung cấp"
|
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "Chọn nhà cung cấp",
|
"selectProvider": "Chọn nhà cung cấp",
|
||||||
@@ -283,120 +267,6 @@
|
|||||||
"selectSize": "Chọn kích thước",
|
"selectSize": "Chọn kích thước",
|
||||||
"configureProvider": "Cấu hình nhà cung cấp",
|
"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."
|
"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": {
|
"chat": {
|
||||||
@@ -500,7 +370,8 @@
|
|||||||
"title": "Chỉnh sửa ảnh",
|
"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."
|
"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": {
|
"header": {
|
||||||
"toggleSidebar": "Bật/tắt thanh bên",
|
"toggleSidebar": "Bật/tắt thanh bên",
|
||||||
@@ -568,10 +439,6 @@
|
|||||||
"title": "Hiển thị trạng thái",
|
"title": "Hiển thị trạng thái",
|
||||||
"description": "Hiển thị trạng thái runtime, provider và channel."
|
"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": {
|
"history": {
|
||||||
"title": "Hiển thị lịch sử",
|
"title": "Hiển thị lịch sử",
|
||||||
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
|
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
|
||||||
@@ -595,21 +462,7 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Hiển thị trợ giúp",
|
"title": "Hiển thị trợ giúp",
|
||||||
"description": "Liệt kê các lệnh slash có sẵn."
|
"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ý…",
|
"encoding": "Đang xử lý…",
|
||||||
@@ -622,16 +475,6 @@
|
|||||||
"decode_failed": "Không thể giải mã ảnh này",
|
"decode_failed": "Không thể giải mã ảnh này",
|
||||||
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
|
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
|
||||||
"io": "Không thể đọc tệp này"
|
"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",
|
"scrollToBottom": "Cuộn xuống cuối",
|
||||||
@@ -656,19 +499,7 @@
|
|||||||
"imageAttachment": "Tệp hình ảnh đính kèm",
|
"imageAttachment": "Tệp hình ảnh đính kèm",
|
||||||
"copyReply": "Sao chép trả lời",
|
"copyReply": "Sao chép trả lời",
|
||||||
"copiedReply": "Đã 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": {
|
"lightbox": {
|
||||||
"title": "Xem trước ảnh",
|
"title": "Xem trước ảnh",
|
||||||
|
|||||||
@@ -9,18 +9,6 @@
|
|||||||
"title": "无法连接到 nanobot",
|
"title": "无法连接到 nanobot",
|
||||||
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
|
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
|
||||||
},
|
},
|
||||||
"auth": {
|
|
||||||
"title": "需要验证",
|
|
||||||
"hint": "请输入 gateway 配置中的 tokenIssueSecret。",
|
|
||||||
"placeholder": "密码",
|
|
||||||
"submit": "连接",
|
|
||||||
"invalid": "密码无效,请重试。"
|
|
||||||
},
|
|
||||||
"account": {
|
|
||||||
"section": "账户",
|
|
||||||
"logoutHint": "断开此浏览器与 gateway 的连接。",
|
|
||||||
"logout": "退出登录"
|
|
||||||
},
|
|
||||||
"system": {
|
"system": {
|
||||||
"section": "系统",
|
"section": "系统",
|
||||||
"restartHint": "重启 nanobot 以应用运行时更改。",
|
"restartHint": "重启 nanobot 以应用运行时更改。",
|
||||||
@@ -63,8 +51,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "语言",
|
"label": "语言",
|
||||||
"ariaLabel": "切换语言"
|
"ariaLabel": "切换语言"
|
||||||
},
|
}
|
||||||
"apps": "应用"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "返回对话",
|
"backToChat": "返回对话",
|
||||||
@@ -82,10 +69,8 @@
|
|||||||
"image": "图片",
|
"image": "图片",
|
||||||
"web": "网页",
|
"web": "网页",
|
||||||
"cliApps": "CLI 应用",
|
"cliApps": "CLI 应用",
|
||||||
"mcp": "MCP",
|
|
||||||
"runtime": "运行时",
|
"runtime": "运行时",
|
||||||
"advanced": "高级",
|
"advanced": "高级"
|
||||||
"apps": "应用"
|
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "界面",
|
"interface": "界面",
|
||||||
@@ -99,20 +84,10 @@
|
|||||||
"webSearch": "网页搜索",
|
"webSearch": "网页搜索",
|
||||||
"webBehavior": "行为",
|
"webBehavior": "行为",
|
||||||
"cliApps": "CLI 应用",
|
"cliApps": "CLI 应用",
|
||||||
"mcp": "MCP 服务",
|
|
||||||
"identity": "身份",
|
"identity": "身份",
|
||||||
"safety": "安全",
|
"safety": "安全",
|
||||||
"capabilities": "能力",
|
"capabilities": "能力",
|
||||||
"integrations": "集成",
|
"integrations": "集成"
|
||||||
"apps": "应用"
|
|
||||||
},
|
|
||||||
"models": {
|
|
||||||
"selectModel": "选择模型",
|
|
||||||
"addConfiguration": "添加配置",
|
|
||||||
"newConfiguration": "新建模型配置",
|
|
||||||
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
|
|
||||||
"configurationName": "名称",
|
|
||||||
"configurationNamePlaceholder": "快速写作"
|
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
@@ -125,7 +100,6 @@
|
|||||||
"gateway": "网关",
|
"gateway": "网关",
|
||||||
"restartState": "重启状态",
|
"restartState": "重启状态",
|
||||||
"pendingChanges": "待处理更改",
|
"pendingChanges": "待处理更改",
|
||||||
"currentModel": "当前模型",
|
|
||||||
"selectedPreset": "选中的预设",
|
"selectedPreset": "选中的预设",
|
||||||
"presetModel": "预设模型",
|
"presetModel": "预设模型",
|
||||||
"density": "密度",
|
"density": "密度",
|
||||||
@@ -168,9 +142,6 @@
|
|||||||
"provider": "选择新模型请求使用的服务商。",
|
"provider": "选择新模型请求使用的服务商。",
|
||||||
"model": "设置 nanobot 默认使用的模型名称。",
|
"model": "设置 nanobot 默认使用的模型名称。",
|
||||||
"configPath": "当前网关正在使用的配置文件。",
|
"configPath": "当前网关正在使用的配置文件。",
|
||||||
"currentModel": "选择 nanobot 接下来回复时使用的模型。",
|
|
||||||
"selectedModelProvider": "由当前模型决定。",
|
|
||||||
"selectedModelValue": "由当前模型决定。",
|
|
||||||
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
|
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
|
||||||
"presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。",
|
"presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。",
|
||||||
"density": "仅保存在当前浏览器。",
|
"density": "仅保存在当前浏览器。",
|
||||||
@@ -195,11 +166,6 @@
|
|||||||
"cliAppsFilter": "按应用、分类或能力搜索。",
|
"cliAppsFilter": "按应用、分类或能力搜索。",
|
||||||
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
|
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
|
||||||
},
|
},
|
||||||
"timezone": {
|
|
||||||
"select": "选择时区",
|
|
||||||
"search": "搜索时区",
|
|
||||||
"empty": "没有匹配的时区。"
|
|
||||||
},
|
|
||||||
"cliApps": {
|
"cliApps": {
|
||||||
"allCategories": "全部分类",
|
"allCategories": "全部分类",
|
||||||
"availableCount": "{{count}} 个应用",
|
"availableCount": "{{count}} 个应用",
|
||||||
@@ -231,59 +197,6 @@
|
|||||||
"unavailable": "不可用",
|
"unavailable": "不可用",
|
||||||
"noDescription": "暂无描述。"
|
"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": {
|
"values": {
|
||||||
"light": "浅色",
|
"light": "浅色",
|
||||||
"dark": "深色",
|
"dark": "深色",
|
||||||
@@ -371,8 +284,7 @@
|
|||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "搜索服务商",
|
"searchPlaceholder": "搜索服务商",
|
||||||
"noMatches": "没有匹配的服务商。",
|
"noMatches": "没有匹配的服务商。"
|
||||||
"saveProvider": "保存服务商"
|
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||||
@@ -383,20 +295,6 @@
|
|||||||
"selectSize": "选择尺寸",
|
"selectSize": "选择尺寸",
|
||||||
"configureProvider": "配置服务商",
|
"configureProvider": "配置服务商",
|
||||||
"missingCredential": "启用图片生成前,请先配置这个服务商。"
|
"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": {
|
"chat": {
|
||||||
@@ -550,16 +448,6 @@
|
|||||||
"navigateHint": "↑↓ 选择",
|
"navigateHint": "↑↓ 选择",
|
||||||
"selectHint": "Enter/Tab 填入",
|
"selectHint": "Enter/Tab 填入",
|
||||||
"closeHint": "Esc 关闭",
|
"closeHint": "Esc 关闭",
|
||||||
"badges": {
|
|
||||||
"current": "当前",
|
|
||||||
"recent": "最近"
|
|
||||||
},
|
|
||||||
"details": {
|
|
||||||
"goalActive": "目标正在运行",
|
|
||||||
"goalReady": "开始一个持续目标",
|
|
||||||
"history": "查看最近消息",
|
|
||||||
"stopRunning": "正在运行"
|
|
||||||
},
|
|
||||||
"commands": {
|
"commands": {
|
||||||
"new": {
|
"new": {
|
||||||
"title": "新建对话",
|
"title": "新建对话",
|
||||||
@@ -577,10 +465,6 @@
|
|||||||
"title": "查看状态",
|
"title": "查看状态",
|
||||||
"description": "显示运行时、服务商和通道状态。"
|
"description": "显示运行时、服务商和通道状态。"
|
||||||
},
|
},
|
||||||
"model": {
|
|
||||||
"title": "模型",
|
|
||||||
"description": "查看或切换当前模型预设。"
|
|
||||||
},
|
|
||||||
"history": {
|
"history": {
|
||||||
"title": "查看对话历史",
|
"title": "查看对话历史",
|
||||||
"description": "打印最近 N 条已持久化的对话消息。"
|
"description": "打印最近 N 条已持久化的对话消息。"
|
||||||
@@ -604,22 +488,12 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "查看帮助",
|
"title": "查看帮助",
|
||||||
"description": "列出可用的斜杠命令。"
|
"description": "列出可用的斜杠命令。"
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "配对",
|
|
||||||
"description": "管理配对请求。"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mentions": {
|
"mentions": {
|
||||||
"ariaLabel": "应用",
|
"ariaLabel": "CLI 应用",
|
||||||
"label": "应用",
|
"label": "CLI 应用"
|
||||||
"cliGroup": "CLI 应用",
|
|
||||||
"mcpGroup": "MCP 服务",
|
|
||||||
"cliBadge": "CLI",
|
|
||||||
"mcpBadge": "MCP",
|
|
||||||
"cliDescription": "使用 @{{name}} 调用本地 CLI",
|
|
||||||
"mcpDescription": "使用 @{{name}} 调用 MCP 服务"
|
|
||||||
},
|
},
|
||||||
"encoding": "处理中…",
|
"encoding": "处理中…",
|
||||||
"remove": "移除附件",
|
"remove": "移除附件",
|
||||||
@@ -653,18 +527,15 @@
|
|||||||
"agentActivityToolsOnly": "{{tools}} 次工具调用",
|
"agentActivityToolsOnly": "{{tools}} 次工具调用",
|
||||||
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
|
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
|
||||||
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
|
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
|
||||||
"activityThinkingFor": "思考中 {{duration}}",
|
"cliActivityRunningOne": "正在运行 CLI @{{name}}",
|
||||||
"activityThought": "已思考",
|
"cliActivityRanOne": "已运行 CLI @{{name}}",
|
||||||
"activityThoughtFor": "思考了 {{duration}}",
|
"cliActivityFailedOne": "CLI 调用失败 @{{name}}",
|
||||||
"cliActivityRunningOne": "正在使用 @{{name}}",
|
"cliActivityRunningMany": "正在运行 {{count}} 个 CLI",
|
||||||
"cliActivityRanOne": "已使用 @{{name}}",
|
"cliActivityRanMany": "已运行 {{count}} 个 CLI",
|
||||||
"cliActivityFailedOne": "使用 @{{name}} 失败",
|
"cliActivityFailedMany": "{{count}} 个 CLI 调用失败",
|
||||||
"cliActivityRunningMany": "正在使用 {{count}} 个 CLI 应用",
|
"cliRunRunning": "正在运行 CLI",
|
||||||
"cliActivityRanMany": "已使用 {{count}} 个 CLI 应用",
|
"cliRunRan": "已运行 CLI",
|
||||||
"cliActivityFailedMany": "{{count}} 个 CLI 应用失败",
|
"cliRunFailed": "CLI 调用失败",
|
||||||
"cliRunRunning": "正在使用",
|
|
||||||
"cliRunRan": "已使用",
|
|
||||||
"cliRunFailed": "失败",
|
|
||||||
"imageAttachment": "图片附件",
|
"imageAttachment": "图片附件",
|
||||||
"copyReply": "复制回复",
|
"copyReply": "复制回复",
|
||||||
"copiedReply": "已复制回复",
|
"copiedReply": "已复制回复",
|
||||||
|
|||||||
@@ -63,8 +63,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "語言",
|
"label": "語言",
|
||||||
"ariaLabel": "切換語言"
|
"ariaLabel": "切換語言"
|
||||||
},
|
}
|
||||||
"apps": "應用"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "返回對話",
|
"backToChat": "返回對話",
|
||||||
@@ -82,10 +81,7 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"web": "Web",
|
"web": "Web",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced"
|
||||||
"cliApps": "CLI 應用",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"apps": "應用"
|
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "介面",
|
"interface": "介面",
|
||||||
@@ -101,10 +97,7 @@
|
|||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "功能",
|
"capabilities": "功能",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations"
|
||||||
"cliApps": "CLI 應用",
|
|
||||||
"mcp": "MCP 服務",
|
|
||||||
"apps": "應用"
|
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "主題",
|
"theme": "主題",
|
||||||
@@ -148,11 +141,7 @@
|
|||||||
"ssrfWhitelist": "SSRF whitelist",
|
"ssrfWhitelist": "SSRF whitelist",
|
||||||
"mcpServers": "MCP servers",
|
"mcpServers": "MCP servers",
|
||||||
"pathAppend": "PATH append",
|
"pathAppend": "PATH append",
|
||||||
"configurationDocs": "Configuration docs",
|
"configurationDocs": "Configuration docs"
|
||||||
"currentModel": "目前模型",
|
|
||||||
"brandLogos": "品牌標誌",
|
|
||||||
"cliAppsCatalog": "CLI 應用目錄",
|
|
||||||
"cliAppsFilter": "CLI 應用篩選"
|
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"theme": "在淺色與深色外觀之間切換。",
|
"theme": "在淺色與深色外觀之間切換。",
|
||||||
@@ -179,13 +168,7 @@
|
|||||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||||
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
|
"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": {
|
"values": {
|
||||||
"light": "淺色",
|
"light": "淺色",
|
||||||
@@ -202,7 +185,9 @@
|
|||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"notConfigured": "Not configured"
|
"notConfigured": "Not configured",
|
||||||
|
"restartRequired": "Restart required",
|
||||||
|
"liveReload": "Live reload ready"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "正在載入設定...",
|
"loading": "正在載入設定...",
|
||||||
@@ -274,8 +259,7 @@
|
|||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search.",
|
"noMatches": "No providers match this search."
|
||||||
"saveProvider": "儲存服務商"
|
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "選擇服務商",
|
"selectProvider": "選擇服務商",
|
||||||
@@ -283,120 +267,6 @@
|
|||||||
"selectSize": "選擇尺寸",
|
"selectSize": "選擇尺寸",
|
||||||
"configureProvider": "設定服務商",
|
"configureProvider": "設定服務商",
|
||||||
"missingCredential": "啟用圖片生成前,請先設定此服務商。"
|
"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": {
|
"chat": {
|
||||||
@@ -500,7 +370,8 @@
|
|||||||
"title": "編輯圖片",
|
"title": "編輯圖片",
|
||||||
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
|
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "切換側邊欄",
|
"toggleSidebar": "切換側邊欄",
|
||||||
@@ -568,10 +439,6 @@
|
|||||||
"title": "查看狀態",
|
"title": "查看狀態",
|
||||||
"description": "顯示執行環境、provider 和 channel 狀態。"
|
"description": "顯示執行環境、provider 和 channel 狀態。"
|
||||||
},
|
},
|
||||||
"model": {
|
|
||||||
"title": "模型",
|
|
||||||
"description": "查看或切換目前模型預設。"
|
|
||||||
},
|
|
||||||
"history": {
|
"history": {
|
||||||
"title": "查看對話歷史",
|
"title": "查看對話歷史",
|
||||||
"description": "列印最近 N 則已持久化的對話訊息。"
|
"description": "列印最近 N 則已持久化的對話訊息。"
|
||||||
@@ -595,21 +462,7 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "查看說明",
|
"title": "查看說明",
|
||||||
"description": "列出可用的斜線命令。"
|
"description": "列出可用的斜線命令。"
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "配對",
|
|
||||||
"description": "管理配對請求。"
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"badges": {
|
|
||||||
"current": "目前",
|
|
||||||
"recent": "最近"
|
|
||||||
},
|
|
||||||
"details": {
|
|
||||||
"goalActive": "目標正在執行",
|
|
||||||
"goalReady": "開始一個持續目標",
|
|
||||||
"history": "查看最近訊息",
|
|
||||||
"stopRunning": "正在執行"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "處理中…",
|
"encoding": "處理中…",
|
||||||
@@ -622,16 +475,6 @@
|
|||||||
"decode_failed": "無法解碼這張圖片",
|
"decode_failed": "無法解碼這張圖片",
|
||||||
"too_large": "圖片太大,請換一張小一點的",
|
"too_large": "圖片太大,請換一張小一點的",
|
||||||
"io": "無法讀取這個檔案"
|
"io": "無法讀取這個檔案"
|
||||||
},
|
|
||||||
"mentions": {
|
|
||||||
"ariaLabel": "應用",
|
|
||||||
"label": "應用",
|
|
||||||
"cliGroup": "CLI 應用",
|
|
||||||
"mcpGroup": "MCP 服務",
|
|
||||||
"cliBadge": "CLI",
|
|
||||||
"mcpBadge": "MCP",
|
|
||||||
"cliDescription": "使用 @{{name}} 呼叫本機 CLI",
|
|
||||||
"mcpDescription": "使用 @{{name}} 呼叫 MCP 服務"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "捲動到底部",
|
"scrollToBottom": "捲動到底部",
|
||||||
@@ -656,19 +499,7 @@
|
|||||||
"imageAttachment": "圖片附件",
|
"imageAttachment": "圖片附件",
|
||||||
"copyReply": "複製回覆",
|
"copyReply": "複製回覆",
|
||||||
"copiedReply": "已複製回覆",
|
"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": {
|
"lightbox": {
|
||||||
"title": "圖片預覽",
|
"title": "圖片預覽",
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import type {
|
|||||||
ChatSummary,
|
ChatSummary,
|
||||||
CliAppsPayload,
|
CliAppsPayload,
|
||||||
ImageGenerationSettingsUpdate,
|
ImageGenerationSettingsUpdate,
|
||||||
McpPresetsPayload,
|
|
||||||
ModelConfigurationCreate,
|
|
||||||
ProviderSettingsUpdate,
|
ProviderSettingsUpdate,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
SettingsUpdate,
|
SettingsUpdate,
|
||||||
@@ -41,21 +39,6 @@ async function request<T>(
|
|||||||
return (await res.json()) as 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 } {
|
function splitKey(key: string): { channel: string; chatId: string } {
|
||||||
const idx = key.indexOf(":");
|
const idx = key.indexOf(":");
|
||||||
if (idx === -1) return { channel: "", chatId: key };
|
if (idx === -1) return { channel: "", chatId: key };
|
||||||
@@ -142,66 +125,6 @@ export async function runCliAppAction(
|
|||||||
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
|
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(
|
export async function listSlashCommands(
|
||||||
token: string,
|
token: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
@@ -265,22 +188,6 @@ export async function updateSettings(
|
|||||||
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
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(
|
export async function updateProviderSettings(
|
||||||
token: string,
|
token: string,
|
||||||
update: ProviderSettingsUpdate,
|
update: ProviderSettingsUpdate,
|
||||||
@@ -290,7 +197,6 @@ export async function updateProviderSettings(
|
|||||||
query.set("provider", update.provider);
|
query.set("provider", update.provider);
|
||||||
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
|
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
|
||||||
if (update.apiBase !== undefined) query.set("api_base", update.apiBase);
|
if (update.apiBase !== undefined) query.set("api_base", update.apiBase);
|
||||||
if (update.apiType !== undefined) query.set("api_type", update.apiType);
|
|
||||||
return request<SettingsPayload>(
|
return request<SettingsPayload>(
|
||||||
`${base}/api/settings/provider/update?${query}`,
|
`${base}/api/settings/provider/update?${query}`,
|
||||||
token,
|
token,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user