mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 05:48:38 +03:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1250f0d16 | ||
|
|
376d82fec7 | ||
|
|
8dd35c373a | ||
|
|
418cb23da2 | ||
|
|
179acfe104 | ||
|
|
cfabc29f74 | ||
|
|
92f2ff3a33 | ||
|
|
c433d60681 | ||
|
|
d472595417 | ||
|
|
92915ea424 | ||
|
|
3f0098839e | ||
|
|
c4e2fcaf0c | ||
|
|
8fedee276b | ||
|
|
547f81e4aa | ||
|
|
00a6e720dc | ||
|
|
6ea7a6a2ac | ||
|
|
704ac558f6 | ||
|
|
8be258212e | ||
|
|
c9ff64fc0f | ||
|
|
9efdce276f | ||
|
|
7a6cc657db | ||
|
|
ec99232208 | ||
|
|
43a1784c5f | ||
|
|
3d3ef586e7 | ||
|
|
ef2ef4f789 | ||
|
|
5b71f61f55 | ||
|
|
5937236f9d | ||
|
|
192d2af19d | ||
|
|
3e6f9907fe |
@@ -98,3 +98,4 @@ tmp/
|
|||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
exp/
|
exp/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
+104
-2
@@ -126,8 +126,10 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
|||||||
> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
|
> - **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 |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
@@ -166,6 +168,43 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
|||||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
| `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>
|
||||||
|
|
||||||
@@ -477,6 +516,68 @@ 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>
|
||||||
|
|
||||||
@@ -956,7 +1057,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
|||||||
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
|
| `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 is auto-resolved from the matching provider config. |
|
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key and optional `apiBase` are auto-resolved from the matching provider config. Chat-style bases such as `https://api.groq.com/openai/v1` are normalized to the audio transcription endpoint. |
|
||||||
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
|
| `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
|
||||||
@@ -1195,7 +1296,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 provider credentials from `providers.openrouter` or `providers.aihubmix`.
|
Image generation is configured under `tools.imageGeneration` and uses credentials from the selected provider's `providers.<name>` block.
|
||||||
|
|
||||||
See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting.
|
See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting.
|
||||||
|
|
||||||
@@ -1288,6 +1389,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
|
|||||||
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
|
| `tools.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, and StepFun configuration examples.
|
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu 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` |
|
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
|
||||||
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
| `tools.imageGeneration.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,6 +245,31 @@ StepPlan is StepFun's subscription tier and uses a different API base URL. The i
|
|||||||
|
|
||||||
`apiBase` takes precedence over the registry default, so with the StepPlan base URL configured, image requests are sent to `https://api.stepfun.com/step_plan/v1/images/generations` — the same path prefix used for LLM calls. The API key is shared with the standard StepFun provider.
|
`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:
|
||||||
@@ -299,8 +324,7 @@ Use the reference image. Keep the same robot and composition, change the palette
|
|||||||
|---------|-------|
|
|---------|-------|
|
||||||
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
|
| `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`, or `stepfun` |
|
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
|
||||||
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
| 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,6 +10,10 @@ 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,
|
||||||
@@ -19,6 +23,32 @@ 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."""
|
||||||
|
|
||||||
|
|||||||
+278
-27
@@ -8,17 +8,25 @@ 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 Consolidator, Dream
|
from nanobot.agent.memory import (
|
||||||
|
_STALE_THRESHOLD_DAYS,
|
||||||
|
Consolidator,
|
||||||
|
Dream,
|
||||||
|
_estimate_tokens,
|
||||||
|
_strip_skip_lines,
|
||||||
|
)
|
||||||
from nanobot.agent.progress_hook import AgentProgressHook
|
from nanobot.agent.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
|
||||||
@@ -28,13 +36,15 @@ 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 (
|
||||||
@@ -47,7 +57,11 @@ from nanobot.utils.helpers import image_placeholder_text
|
|||||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
from nanobot.utils.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.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
from nanobot.utils.prompt_templates import _TEMPLATES_ROOT, render_template
|
||||||
|
from nanobot.utils.runtime import (
|
||||||
|
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||||
|
SUSTAINED_GOAL_CONTINUE_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
@@ -164,6 +178,7 @@ 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,
|
||||||
@@ -190,6 +205,7 @@ 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
|
||||||
|
|
||||||
@@ -202,6 +218,7 @@ 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()
|
||||||
@@ -262,6 +279,7 @@ 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
|
||||||
@@ -308,6 +326,7 @@ 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
|
||||||
@@ -347,6 +366,7 @@ 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,
|
||||||
@@ -366,6 +386,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -391,7 +412,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.dream.set_provider(provider, model)
|
self._configure_dream()
|
||||||
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(
|
||||||
@@ -400,6 +421,20 @@ 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
|
||||||
@@ -476,26 +511,8 @@ 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 to configured MCP servers (one-time, lazy)."""
|
"""Connect configured MCP servers."""
|
||||||
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
|
await agent_context.connect_mcp(self, self.tools)
|
||||||
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,
|
||||||
@@ -568,7 +585,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 {}) | cli_app_utils.session_extra(msg.metadata)
|
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
||||||
extra.update(kwargs)
|
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)
|
||||||
@@ -593,7 +610,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=cli_app_utils.runtime_lines(msg, self.context.workspace),
|
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
@@ -744,6 +761,15 @@ 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,
|
||||||
@@ -771,6 +797,8 @@ 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)
|
||||||
@@ -811,6 +839,8 @@ 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(
|
||||||
@@ -1019,6 +1049,28 @@ 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):
|
||||||
@@ -1058,7 +1110,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=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
|
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace, skip=is_subagent),
|
||||||
)
|
)
|
||||||
t_wall = time.time()
|
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(
|
||||||
@@ -1095,6 +1147,205 @@ 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,
|
||||||
|
|||||||
+168
-191
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -15,7 +16,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, AgentRunSpec
|
from nanobot.agent.runner import AgentRunner
|
||||||
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
|
||||||
@@ -33,6 +34,20 @@ 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
|
||||||
@@ -400,6 +415,26 @@ 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
|
||||||
@@ -618,19 +653,21 @@ 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) -> str:
|
def _truncate_to_token_budget(self, text: str, reserve_tokens: int = 0) -> str:
|
||||||
"""Truncate text so it fits within the consolidation LLM's token budget."""
|
"""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)
|
||||||
try:
|
if _TIKTOKEN_ENC is not None:
|
||||||
enc = tiktoken.get_encoding("cl100k_base")
|
tokens = _TIKTOKEN_ENC.encode(text)
|
||||||
tokens = enc.encode(text)
|
|
||||||
if len(tokens) <= budget:
|
if len(tokens) <= budget:
|
||||||
return text
|
return text
|
||||||
return enc.decode(tokens[:budget]) + "\n... (truncated)"
|
return _TIKTOKEN_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:
|
||||||
"""Summarize messages via LLM and append to history.jsonl.
|
"""Summarize messages via LLM and append to history.jsonl.
|
||||||
@@ -639,9 +676,53 @@ 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)
|
||||||
formatted = self._truncate_to_token_budget(formatted)
|
logger.debug(
|
||||||
|
"Consolidator: {} messages, formatted={} chars",
|
||||||
|
len(messages), len(formatted),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inject current memory context for dedup-aware summarization.
|
||||||
|
memory_preview = self.store.read_memory()[:4000]
|
||||||
|
user_preview = self.store.read_user()[:2000]
|
||||||
|
dedup_context = ""
|
||||||
|
if memory_preview:
|
||||||
|
dedup_context += f"\n\n## Current MEMORY.md (for dedup)\n{memory_preview}"
|
||||||
|
if user_preview:
|
||||||
|
dedup_context += f"\n\n## Current USER.md (for dedup)\n{user_preview}"
|
||||||
|
|
||||||
|
reserve_tokens = 0
|
||||||
|
if dedup_context:
|
||||||
|
if _TIKTOKEN_ENC is not None:
|
||||||
|
reserve_tokens = len(_TIKTOKEN_ENC.encode(dedup_context)) + 100
|
||||||
|
else:
|
||||||
|
reserve_tokens = len(dedup_context) // 4 + 100
|
||||||
|
|
||||||
|
if self._input_token_budget <= reserve_tokens:
|
||||||
|
logger.warning(
|
||||||
|
"Consolidator: dedup_context ({} tokens) exceeds budget ({}), dropping it",
|
||||||
|
reserve_tokens, self._input_token_budget,
|
||||||
|
)
|
||||||
|
dedup_context = ""
|
||||||
|
reserve_tokens = 0
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Consolidator: dedup_context={} chars, reserve_tokens={}",
|
||||||
|
len(dedup_context), reserve_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
formatted_before = len(formatted)
|
||||||
|
formatted = self._truncate_to_token_budget(
|
||||||
|
formatted, reserve_tokens=reserve_tokens
|
||||||
|
)
|
||||||
|
if len(formatted) < formatted_before:
|
||||||
|
logger.warning(
|
||||||
|
"Consolidator: truncated formatted messages from {} to {} chars",
|
||||||
|
formatted_before, len(formatted),
|
||||||
|
)
|
||||||
|
|
||||||
response = await self.provider.chat_with_retry(
|
response = await self.provider.chat_with_retry(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
messages=[
|
messages=[
|
||||||
@@ -652,18 +733,31 @@ class Consolidator:
|
|||||||
strip=True,
|
strip=True,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{"role": "user", "content": formatted},
|
{"role": "user", "content": formatted + dedup_context},
|
||||||
],
|
],
|
||||||
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:
|
||||||
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
elapsed = time.perf_counter() - t_start
|
||||||
|
logger.warning(
|
||||||
|
"Consolidation LLM call failed after {:.1f}s, raw-dumping to history",
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
self.store.raw_archive(messages)
|
self.store.raw_archive(messages)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -851,38 +945,48 @@ 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 Phase 1 prompt template (passed as `stale_threshold_days`).
|
# *and* in the system prompt template (passed as `stale_threshold_days`).
|
||||||
# Keep code and prompt aligned — if you bump this, the LLM's instruction string
|
# 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:
|
||||||
"""Two-phase memory processor: analyze history.jsonl, then edit files via AgentRunner.
|
"""Single-phase memory processor: analyze history.jsonl and edit files via AgentRunner.
|
||||||
|
|
||||||
Phase 1 produces an analysis summary (plain LLM call).
|
Delegates to AgentRunner with read_file / edit_file tools so the LLM can
|
||||||
Phase 2 delegates to AgentRunner with read_file / edit_file tools so the
|
analyze conversation history, extract facts, deduplicate, and make targeted
|
||||||
LLM can make targeted, incremental edits instead of replacing entire files.
|
incremental edits — all in a single agent run.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 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 in Phase 2 — these caps only bound the Phase 1/2 prompt preview.
|
# needs it — these caps only bound the prompt preview.
|
||||||
_MEMORY_FILE_MAX_CHARS = 32_000
|
_MEMORY_FILE_MAX_CHARS = 16_000
|
||||||
_SOUL_FILE_MAX_CHARS = 16_000
|
_SOUL_FILE_MAX_CHARS = 4_000
|
||||||
_USER_FILE_MAX_CHARS = 16_000
|
_USER_FILE_MAX_CHARS = 4_000
|
||||||
_HISTORY_ENTRY_PREVIEW_MAX_CHARS = 4_000
|
_HISTORY_ENTRY_PREVIEW_MAX_CHARS = 2_000
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
store: MemoryStore,
|
store: MemoryStore,
|
||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
model: str,
|
model: str,
|
||||||
max_batch_size: int = 20,
|
max_batch_size: int = 5,
|
||||||
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
|
||||||
@@ -890,10 +994,13 @@ 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 Phase 1.
|
# Kill switch for the git-blame-based per-line age annotation in the prompt.
|
||||||
# Default True keeps the #3212 behavior; set False to feed MEMORY.md raw
|
# Default True keeps the #3212 behavior; set False to feed all memory
|
||||||
# (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
|
# files raw (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()
|
||||||
|
|
||||||
@@ -907,6 +1014,7 @@ 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
|
||||||
|
|
||||||
@@ -924,6 +1032,7 @@ 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"
|
||||||
@@ -933,15 +1042,25 @@ class Dream:
|
|||||||
|
|
||||||
# -- skill listing --------------------------------------------------------
|
# -- skill listing --------------------------------------------------------
|
||||||
|
|
||||||
def _list_existing_skills(self) -> list[str]:
|
def _list_existing_skills(self, tag_origin: bool = False) -> list[str]:
|
||||||
"""List existing skills as 'name — description' for dedup context."""
|
"""List existing skills as 'name — description [origin]' for dedup context.
|
||||||
|
|
||||||
|
When *tag_origin* is True each entry gets an origin tag:
|
||||||
|
``[dream]`` for skills with ``dream_managed: true`` in frontmatter,
|
||||||
|
``[user]`` for other workspace skills, ``[builtin]`` for bundled skills.
|
||||||
|
"""
|
||||||
import re as _re
|
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)
|
||||||
entries: dict[str, str] = {}
|
managed_re = _re.compile(r"^dream_managed:\s*true$", _re.MULTILINE | _re.IGNORECASE)
|
||||||
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():
|
||||||
@@ -951,18 +1070,31 @@ 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_SKILLS_DIR:
|
if d.name in entries and base == builtin_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
|
|
||||||
return [f"{name} — {desc}" for name, desc in sorted(entries.items())]
|
if tag_origin:
|
||||||
|
if base == builtin_dir:
|
||||||
|
tag = "[builtin]"
|
||||||
|
elif managed_re.search(content):
|
||||||
|
tag = "[dream]"
|
||||||
|
else:
|
||||||
|
tag = "[user]"
|
||||||
|
entries[d.name] = (desc, tag)
|
||||||
|
else:
|
||||||
|
entries[d.name] = (desc, "")
|
||||||
|
|
||||||
|
if tag_origin:
|
||||||
|
return [f"{name} — {desc} {tag}" for name, (desc, tag) in sorted(entries.items())]
|
||||||
|
return [f"{name} — {desc}" for name, (desc, _) in sorted(entries.items())]
|
||||||
|
|
||||||
# -- main entry ----------------------------------------------------------
|
# -- main entry ----------------------------------------------------------
|
||||||
|
|
||||||
def _annotate_with_ages(self, content: str) -> str:
|
def _annotate_with_ages(self, content: str, file_path: str = "memory/MEMORY.md") -> str:
|
||||||
"""Append per-line age suffixes to MEMORY.md content.
|
"""Append per-line age suffixes to file 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.
|
||||||
@@ -970,9 +1102,7 @@ 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:
|
||||||
@@ -1007,156 +1137,3 @@ 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
|
|
||||||
|
|||||||
+10
-1
@@ -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
|
from typing import Any, Callable
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ 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,
|
||||||
@@ -97,6 +98,8 @@ 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)
|
||||||
@@ -167,6 +170,7 @@ 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).
|
||||||
|
|
||||||
@@ -178,6 +182,10 @@ 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
|
||||||
@@ -475,6 +483,7 @@ 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,6 +79,7 @@ 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()
|
||||||
@@ -95,7 +96,11 @@ 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 = defaults.max_concurrent_subagents
|
self.max_concurrent_subagents = (
|
||||||
|
max_concurrent_subagents
|
||||||
|
if max_concurrent_subagents is not None
|
||||||
|
else defaults.max_concurrent_subagents
|
||||||
|
)
|
||||||
self.runner = AgentRunner(provider)
|
self.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]] = {}
|
||||||
@@ -140,6 +145,7 @@ 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]
|
||||||
@@ -155,7 +161,9 @@ 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(task_id, task, display_label, origin, status, origin_message_id)
|
self._run_subagent(
|
||||||
|
task_id, task, display_label, origin, status, origin_message_id, temperature
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self._running_tasks[task_id] = bg_task
|
self._running_tasks[task_id] = bg_task
|
||||||
if session_key:
|
if session_key:
|
||||||
@@ -182,6 +190,7 @@ 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)
|
||||||
@@ -208,6 +217,7 @@ 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.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -53,14 +53,15 @@ class _ExecSession:
|
|||||||
process: asyncio.subprocess.Process,
|
process: asyncio.subprocess.Process,
|
||||||
command: str,
|
command: str,
|
||||||
cwd: str,
|
cwd: str,
|
||||||
timeout: int,
|
timeout: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.process = process
|
self.process = process
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.started_at = time.monotonic()
|
self.started_at = time.monotonic()
|
||||||
self.deadline = time.monotonic() + timeout
|
# timeout None/0 means no limit; an infinite deadline is never reached.
|
||||||
|
self.deadline = time.monotonic() + timeout if timeout else float("inf")
|
||||||
self.last_access = time.monotonic()
|
self.last_access = time.monotonic()
|
||||||
self._chunks: list[str] = []
|
self._chunks: list[str] = []
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
@@ -169,7 +170,7 @@ class ExecSessionManager:
|
|||||||
command: str,
|
command: str,
|
||||||
cwd: str,
|
cwd: str,
|
||||||
env: dict[str, str],
|
env: dict[str, str],
|
||||||
timeout: int,
|
timeout: int | None,
|
||||||
shell_program: str | None,
|
shell_program: str | None,
|
||||||
login: bool,
|
login: bool,
|
||||||
yield_time_ms: int,
|
yield_time_ms: int,
|
||||||
|
|||||||
+279
-1
@@ -6,13 +6,20 @@ 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
|
from typing import Any, Mapping
|
||||||
|
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
|
||||||
@@ -33,6 +40,7 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
|
|||||||
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
|
# 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:
|
||||||
@@ -503,6 +511,7 @@ 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":
|
||||||
@@ -662,3 +671,272 @@ 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 = 60
|
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
|
||||||
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
|
timeout: int | None
|
||||||
shell_program: str | None
|
shell_program: str | None
|
||||||
login: bool
|
login: bool
|
||||||
|
|
||||||
@@ -324,6 +324,20 @@ 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,
|
||||||
@@ -369,7 +383,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 = min(timeout or self.timeout, self._MAX_TIMEOUT)
|
effective_timeout = self._resolve_timeout(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 StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
@@ -17,6 +17,15 @@ 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"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -58,7 +67,13 @@ class SpawnTool(Tool, ContextAware):
|
|||||||
"and use a dedicated subdirectory when helpful."
|
"and use a dedicated subdirectory when helpful."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
async def execute(
|
||||||
|
self,
|
||||||
|
task: str,
|
||||||
|
label: str | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
"""Spawn a subagent to execute the given task."""
|
"""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
|
||||||
@@ -75,4 +90,5 @@ 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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Shared app protocol helpers."""
|
||||||
|
|
||||||
|
from nanobot.apps.protocol import APP_PROTOCOL_SCHEMA, app_manifest
|
||||||
|
|
||||||
|
__all__ = ["APP_PROTOCOL_SCHEMA", "app_manifest"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""CLI Apps integration helpers."""
|
"""CLI app adapter for the unified Apps domain."""
|
||||||
|
|
||||||
from nanobot.cli_apps.service import (
|
from nanobot.apps.cli.service import (
|
||||||
CliAppError,
|
CliAppError,
|
||||||
CliAppManager,
|
CliAppManager,
|
||||||
CliAppsRuntimeConfig,
|
CliAppsRuntimeConfig,
|
||||||
@@ -11,12 +11,14 @@ 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"
|
||||||
@@ -25,9 +27,43 @@ CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/ma
|
|||||||
CLI_ANYTHING_RAW_SKILLS_BASE = f"{CLI_ANYTHING_RAW_BASE}/skills/"
|
CLI_ANYTHING_RAW_SKILLS_BASE = f"{CLI_ANYTHING_RAW_BASE}/skills/"
|
||||||
|
|
||||||
_MAX_TOOL_OUTPUT_CHARS = 12_000
|
_MAX_TOOL_OUTPUT_CHARS = 12_000
|
||||||
|
_MAX_ARTIFACT_SCAN_PATHS = 4_000
|
||||||
|
_MAX_ARTIFACT_REPORT = 12
|
||||||
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
|
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
|
||||||
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
||||||
|
_ARTIFACT_EXTENSIONS = frozenset({
|
||||||
|
".csv",
|
||||||
|
".drawio",
|
||||||
|
".gif",
|
||||||
|
".html",
|
||||||
|
".jpeg",
|
||||||
|
".jpg",
|
||||||
|
".json",
|
||||||
|
".md",
|
||||||
|
".pdf",
|
||||||
|
".png",
|
||||||
|
".svg",
|
||||||
|
".txt",
|
||||||
|
".vsdx",
|
||||||
|
".webp",
|
||||||
|
".xml",
|
||||||
|
})
|
||||||
|
_INLINE_ARTIFACT_EXTENSIONS = frozenset({".gif", ".jpeg", ".jpg", ".png", ".webp"})
|
||||||
|
_ARTIFACT_IGNORE_DIRS = frozenset({
|
||||||
|
".git",
|
||||||
|
".hg",
|
||||||
|
".mypy_cache",
|
||||||
|
".nanobot",
|
||||||
|
".pytest_cache",
|
||||||
|
".ruff_cache",
|
||||||
|
".venv",
|
||||||
|
"__pycache__",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"venv",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class CliAppError(ValueError):
|
class CliAppError(ValueError):
|
||||||
@@ -105,7 +141,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.com", "#111827"),
|
"anygen": ("anygen.io", "#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"),
|
||||||
@@ -210,6 +246,29 @@ 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("-")
|
||||||
|
|
||||||
@@ -506,8 +565,86 @@ 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()
|
||||||
@@ -547,7 +684,14 @@ class CliAppManager:
|
|||||||
prefix.extend(["--upgrade", "--force-reinstall"])
|
prefix.extend(["--upgrade", "--force-reinstall"])
|
||||||
return prefix + args
|
return prefix + args
|
||||||
|
|
||||||
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
|
def _pip_uninstall_argv(
|
||||||
|
self,
|
||||||
|
app: dict[str, Any],
|
||||||
|
installed_entry: dict[str, Any] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
distribution = str((installed_entry or {}).get("pip_distribution") or "").strip()
|
||||||
|
if distribution:
|
||||||
|
return [sys.executable, "-m", "pip", "uninstall", "-y", distribution]
|
||||||
uninstall_cmd = str(app.get("uninstall_cmd") or "")
|
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:
|
||||||
@@ -585,14 +729,19 @@ class CliAppManager:
|
|||||||
raise CliAppError(f"unsupported {expected} command")
|
raise CliAppError(f"unsupported {expected} command")
|
||||||
return argv
|
return argv
|
||||||
|
|
||||||
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
|
def _argv_for_action(
|
||||||
|
self,
|
||||||
|
app: dict[str, Any],
|
||||||
|
action: str,
|
||||||
|
installed_entry: dict[str, Any] | None = None,
|
||||||
|
) -> list[str] | None:
|
||||||
strategy = self._strategy(app)
|
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)
|
return self._pip_uninstall_argv(app, installed_entry=installed_entry)
|
||||||
if strategy == "npm":
|
if strategy == "npm":
|
||||||
return self._npm_argv(app, action)
|
return self._npm_argv(app, action)
|
||||||
if strategy == "brew":
|
if strategy == "brew":
|
||||||
@@ -614,13 +763,23 @@ class CliAppManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
|
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
return {
|
entry_point = str(app.get("entry_point") or "")
|
||||||
|
strategy = self._strategy(app)
|
||||||
|
entry: dict[str, Any] = {
|
||||||
"version": app.get("version") or "unknown",
|
"version": app.get("version") or "unknown",
|
||||||
"entry_point": app.get("entry_point") or "",
|
"entry_point": entry_point,
|
||||||
"source": app.get("_source") or "harness",
|
"source": app.get("_source") or "harness",
|
||||||
"strategy": self._strategy(app),
|
"strategy": strategy,
|
||||||
"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()
|
||||||
@@ -696,11 +855,13 @@ 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]) -> None:
|
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
installed[str(app["name"])] = self._installed_entry(app)
|
entry = self._installed_entry(app)
|
||||||
|
installed[str(app["name"])] = entry
|
||||||
self._save_installed(installed)
|
self._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)
|
||||||
@@ -711,7 +872,14 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
|
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() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}}
|
return self.payload() | {
|
||||||
|
"last_action": {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"CLI for {app['display_name']} is available.",
|
||||||
|
"installed": True,
|
||||||
|
"verification": ["entry_point_available", "state_recorded"],
|
||||||
|
}
|
||||||
|
}
|
||||||
note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app."
|
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")
|
||||||
@@ -720,7 +888,14 @@ 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() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}}
|
return self.payload() | {
|
||||||
|
"last_action": {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"Installed CLI for {app['display_name']}.",
|
||||||
|
"installed": True,
|
||||||
|
"verification": ["package_manager_ok", "state_recorded", "managed_paths_present"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def update(self, name: str) -> dict[str, Any]:
|
def update(self, name: str) -> dict[str, Any]:
|
||||||
app = self.get_app(name, force_refresh=True)
|
app = self.get_app(name, force_refresh=True)
|
||||||
@@ -728,30 +903,94 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
raise CliAppError("CLI app is not installed")
|
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() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}}
|
return self.payload() | {
|
||||||
|
"last_action": {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"Checked {app['display_name']}.",
|
||||||
|
"installed": True,
|
||||||
|
"verification": ["state_recorded"],
|
||||||
|
}
|
||||||
|
}
|
||||||
argv = self._argv_for_action(app, "update")
|
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() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}}
|
return self.payload() | {
|
||||||
|
"last_action": {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"Updated CLI for {app['display_name']}.",
|
||||||
|
"installed": True,
|
||||||
|
"verification": ["package_manager_ok", "state_recorded", "managed_paths_present"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def uninstall(self, name: str) -> dict[str, Any]:
|
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")
|
||||||
if self._strategy(app) != "bundled":
|
raw_installed_entry = installed.get(str(app["name"]))
|
||||||
argv = self._argv_for_action(app, "uninstall")
|
installed_entry = raw_installed_entry if isinstance(raw_installed_entry, dict) else {}
|
||||||
|
strategy = self._strategy(app)
|
||||||
|
entry_point = str(app.get("entry_point") or "").strip()
|
||||||
|
managed_entry_path = str(installed_entry.get("entry_point_path") or "").strip()
|
||||||
|
if strategy != "bundled":
|
||||||
|
argv = self._argv_for_action(app, "uninstall", installed_entry=installed_entry)
|
||||||
assert argv is not None
|
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"]))
|
||||||
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}}
|
if strategy == "bundled" and still_available:
|
||||||
|
message = (
|
||||||
|
f"Removed {app['display_name']} from nanobot. {entry_point} "
|
||||||
|
"is still available because it is managed outside nanobot."
|
||||||
|
)
|
||||||
|
elif still_available:
|
||||||
|
message = (
|
||||||
|
f"Uninstalled CLI for {app['display_name']}, but another {entry_point} "
|
||||||
|
"is still available on PATH."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = f"Uninstalled CLI for {app['display_name']}."
|
||||||
|
return self.payload() | {
|
||||||
|
"last_action": {
|
||||||
|
"ok": True,
|
||||||
|
"message": message,
|
||||||
|
"removed": True,
|
||||||
|
"still_available": still_available,
|
||||||
|
"verification": ["state_absent", "managed_paths_absent"]
|
||||||
|
if still_available
|
||||||
|
else ["entry_point_absent", "state_absent", "managed_paths_absent"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def test(self, name: str) -> dict[str, Any]:
|
def test(self, name: str) -> dict[str, Any]:
|
||||||
app = self.get_app(name)
|
app = self.get_app(name)
|
||||||
@@ -783,6 +1022,87 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
raise CliAppError("working_dir is outside the configured workspace")
|
raise CliAppError("working_dir is outside the configured workspace")
|
||||||
return cwd
|
return cwd
|
||||||
|
|
||||||
|
def _iter_artifact_candidates(self, cwd: Path) -> list[Path]:
|
||||||
|
if not cwd.is_dir():
|
||||||
|
return []
|
||||||
|
out: list[Path] = []
|
||||||
|
stack = [cwd]
|
||||||
|
scanned = 0
|
||||||
|
while stack and scanned < _MAX_ARTIFACT_SCAN_PATHS:
|
||||||
|
directory = stack.pop()
|
||||||
|
try:
|
||||||
|
entries = sorted(directory.iterdir(), key=lambda path: path.name.lower())
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for path in entries:
|
||||||
|
if scanned >= _MAX_ARTIFACT_SCAN_PATHS:
|
||||||
|
break
|
||||||
|
scanned += 1
|
||||||
|
try:
|
||||||
|
if path.is_dir() and not path.is_symlink():
|
||||||
|
if path.name not in _ARTIFACT_IGNORE_DIRS:
|
||||||
|
stack.append(path)
|
||||||
|
continue
|
||||||
|
if path.is_file() and path.suffix.lower() in _ARTIFACT_EXTENSIONS:
|
||||||
|
out.append(path.resolve(strict=False))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _artifact_snapshot(self, cwd: Path) -> dict[Path, tuple[int, int]]:
|
||||||
|
snapshot: dict[Path, tuple[int, int]] = {}
|
||||||
|
for path in self._iter_artifact_candidates(cwd):
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
snapshot[path] = (stat.st_mtime_ns, stat.st_size)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def _changed_artifacts(
|
||||||
|
self,
|
||||||
|
cwd: Path,
|
||||||
|
before: dict[Path, tuple[int, int]],
|
||||||
|
) -> list[Path]:
|
||||||
|
changed: list[tuple[int, Path]] = []
|
||||||
|
for path, stamp in self._artifact_snapshot(cwd).items():
|
||||||
|
if before.get(path) == stamp:
|
||||||
|
continue
|
||||||
|
changed.append((stamp[0], path))
|
||||||
|
changed.sort(key=lambda item: (item[0], item[1].name.lower()))
|
||||||
|
return [path for _, path in changed[-_MAX_ARTIFACT_REPORT:]]
|
||||||
|
|
||||||
|
def _format_artifact_path(self, cwd: Path, path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.relative_to(cwd).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return path.name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_artifact_size(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
return "unknown size"
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size} B"
|
||||||
|
if size < 1024 * 1024:
|
||||||
|
return f"{size / 1024:.1f} KB"
|
||||||
|
return f"{size / (1024 * 1024):.1f} MB"
|
||||||
|
|
||||||
|
def _format_artifact_lines(self, cwd: Path, paths: list[Path]) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
for path in paths:
|
||||||
|
rel = self._format_artifact_path(cwd, path)
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
kind = (
|
||||||
|
"previewable image"
|
||||||
|
if ext in _INLINE_ARTIFACT_EXTENSIONS
|
||||||
|
else ext.lstrip(".") or "file"
|
||||||
|
)
|
||||||
|
lines.append(f"- {rel} ({kind}, {self._format_artifact_size(path)})")
|
||||||
|
return lines
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -806,6 +1126,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
if json_output and "--json" not in clean_args:
|
if json_output and "--json" not in clean_args:
|
||||||
clean_args = ["--json", *clean_args]
|
clean_args = ["--json", *clean_args]
|
||||||
effective_timeout = max(1, min(timeout or self.runtime.run_timeout, 600))
|
effective_timeout = max(1, min(timeout or self.runtime.run_timeout, 600))
|
||||||
|
artifact_snapshot = self._artifact_snapshot(cwd)
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[resolved, *clean_args],
|
[resolved, *clean_args],
|
||||||
@@ -825,4 +1146,15 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
output.append("\nSTDOUT:\n" + result.stdout.rstrip())
|
output.append("\nSTDOUT:\n" + result.stdout.rstrip())
|
||||||
if result.stderr:
|
if result.stderr:
|
||||||
output.append("\nSTDERR:\n" + result.stderr.rstrip())
|
output.append("\nSTDERR:\n" + result.stderr.rstrip())
|
||||||
|
artifacts = self._changed_artifacts(cwd, artifact_snapshot)
|
||||||
|
if artifacts:
|
||||||
|
output.append(
|
||||||
|
"\nArtifacts created or updated:\n"
|
||||||
|
+ "\n".join(self._format_artifact_lines(cwd, artifacts))
|
||||||
|
)
|
||||||
|
if any(path.suffix.lower() in _INLINE_ARTIFACT_EXTENSIONS for path in artifacts):
|
||||||
|
output.append(
|
||||||
|
"\nTo show a preview in WebUI, reference a raster artifact with Markdown "
|
||||||
|
"using its workspace-relative path, for example ``."
|
||||||
|
)
|
||||||
return _truncate("\n".join(output))
|
return _truncate("\n".join(output))
|
||||||
@@ -46,7 +46,7 @@ def _cli_app_runtime_lines(
|
|||||||
if "@" not in text:
|
if "@" not in text:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
from nanobot.cli_apps import CliAppManager
|
from nanobot.apps.cli import CliAppManager
|
||||||
|
|
||||||
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
|
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Neutral manifest shape for settings-managed agent apps.
|
||||||
|
|
||||||
|
The manifest is intentionally descriptive. Installers still live in their
|
||||||
|
own adapters, while this protocol gives the WebUI and future registries one
|
||||||
|
small vocabulary for capabilities, trust, and verified install/remove plans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
APP_PROTOCOL_SCHEMA = "agent-app.v1"
|
||||||
|
|
||||||
|
|
||||||
|
def compact_dict(values: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Drop empty optional values while preserving explicit booleans and zeros."""
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in values.items()
|
||||||
|
if value is not None and value != "" and value != [] and value != {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def app_manifest(
|
||||||
|
*,
|
||||||
|
app_id: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str,
|
||||||
|
category: str,
|
||||||
|
source: str,
|
||||||
|
capabilities: list[dict[str, Any]],
|
||||||
|
install: dict[str, Any],
|
||||||
|
remove: dict[str, Any],
|
||||||
|
trust: dict[str, Any],
|
||||||
|
version: str | None = None,
|
||||||
|
logo_url: str | None = None,
|
||||||
|
brand_color: str | None = None,
|
||||||
|
docs_url: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a stable app manifest dictionary."""
|
||||||
|
return compact_dict({
|
||||||
|
"schema": APP_PROTOCOL_SCHEMA,
|
||||||
|
"id": app_id,
|
||||||
|
"display_name": display_name,
|
||||||
|
"version": version,
|
||||||
|
"description": description,
|
||||||
|
"category": category,
|
||||||
|
"source": source,
|
||||||
|
"logo_url": logo_url,
|
||||||
|
"brand_color": brand_color,
|
||||||
|
"docs_url": docs_url,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"install": install,
|
||||||
|
"remove": remove,
|
||||||
|
"trust": trust,
|
||||||
|
})
|
||||||
@@ -9,6 +9,12 @@ from typing import Any
|
|||||||
# render it and other channels may ignore unknown keys.
|
# 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:
|
||||||
@@ -45,4 +51,3 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class ChannelManager:
|
|||||||
static_path = _default_webui_dist()
|
static_path = _default_webui_dist()
|
||||||
if static_path is not None:
|
if static_path is not None:
|
||||||
kwargs["static_dist_path"] = static_path
|
kwargs["static_dist_path"] = static_path
|
||||||
|
kwargs["workspace_path"] = self.config.workspace_path
|
||||||
if self._webui_runtime_model_name is not None:
|
if self._webui_runtime_model_name is not None:
|
||||||
kwargs["runtime_model_name"] = self._webui_runtime_model_name
|
kwargs["runtime_model_name"] = self._webui_runtime_model_name
|
||||||
channel = cls(section, self.bus, **kwargs)
|
channel = cls(section, self.bus, **kwargs)
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ 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
|
||||||
from nanobot.command.builtin import builtin_command_palette
|
from nanobot.command.builtin import builtin_command_palette
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir, get_workspace_path
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
||||||
@@ -46,6 +47,7 @@ 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,
|
||||||
@@ -57,12 +59,32 @@ 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 append_transcript_object, build_webui_thread_response
|
from nanobot.webui.transcript import (
|
||||||
|
append_transcript_object,
|
||||||
|
build_webui_thread_response,
|
||||||
|
rewrite_local_markdown_images,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||||
|
"/api/settings/mcp-presets/enable": "enable",
|
||||||
|
"/api/settings/mcp-presets/remove": "remove",
|
||||||
|
"/api/settings/mcp-presets/test": "test",
|
||||||
|
"/api/settings/mcp-presets/custom": "custom",
|
||||||
|
"/api/settings/mcp-presets/import": "import",
|
||||||
|
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
||||||
|
"/api/settings/mcp-presets/tools": "tools",
|
||||||
|
}
|
||||||
|
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||||
|
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@@ -233,6 +255,34 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
|
|||||||
return _parse_request_path(path_with_query)[1]
|
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)
|
||||||
@@ -425,8 +475,6 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
|||||||
"video/webm",
|
"video/webm",
|
||||||
"video/quicktime",
|
"video/quicktime",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -454,6 +502,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
*,
|
*,
|
||||||
session_manager: "SessionManager | None" = None,
|
session_manager: "SessionManager | None" = None,
|
||||||
static_dist_path: Path | None = None,
|
static_dist_path: Path | None = None,
|
||||||
|
workspace_path: Path | None = None,
|
||||||
runtime_model_name: Callable[[], str | None] | None = None,
|
runtime_model_name: Callable[[], str | None] | None = None,
|
||||||
):
|
):
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
@@ -476,8 +525,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._static_dist_path: Path | None = (
|
self._static_dist_path: Path | None = (
|
||||||
static_dist_path.resolve() if static_dist_path is not None else None
|
static_dist_path.resolve() if static_dist_path is not None else None
|
||||||
)
|
)
|
||||||
|
self._workspace_path = (
|
||||||
|
Path(workspace_path).expanduser()
|
||||||
|
if workspace_path is not None
|
||||||
|
else get_workspace_path()
|
||||||
|
).resolve(strict=False)
|
||||||
self._runtime_model_name = runtime_model_name
|
self._runtime_model_name = runtime_model_name
|
||||||
self._settings_restart_sections: set[str] = set()
|
self._settings_restart_sections: set[str] = set()
|
||||||
|
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||||
# Process-local secret used to HMAC-sign media URLs. The signed URL is
|
# Process-local secret used to HMAC-sign media URLs. The signed URL is
|
||||||
# the capability — anyone who holds a valid URL can fetch that one
|
# the capability — anyone who holds a valid URL can fetch that one
|
||||||
# file, nothing else. The secret regenerates on restart so links
|
# file, nothing else. The secret regenerates on restart so links
|
||||||
@@ -649,6 +704,9 @@ 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)
|
||||||
|
|
||||||
@@ -673,6 +731,13 @@ 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))
|
||||||
@@ -864,6 +929,16 @@ 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")
|
||||||
@@ -920,6 +995,31 @@ 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."""
|
||||||
@@ -961,6 +1061,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
data = build_webui_thread_response(
|
data = build_webui_thread_response(
|
||||||
decoded_key,
|
decoded_key,
|
||||||
augment_user_media=self._augment_transcript_user_media,
|
augment_user_media=self._augment_transcript_user_media,
|
||||||
|
augment_assistant_text=self._rewrite_local_markdown_images,
|
||||||
)
|
)
|
||||||
if data is None:
|
if data is None:
|
||||||
return _http_error(404, "webui thread not found")
|
return _http_error(404, "webui thread not found")
|
||||||
@@ -1010,6 +1111,9 @@ 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,
|
||||||
@@ -1099,6 +1203,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return None
|
return None
|
||||||
return {"url": signed, "name": path.name}
|
return {"url": signed, "name": path.name}
|
||||||
|
|
||||||
|
def _rewrite_local_markdown_images(self, text: str) -> str:
|
||||||
|
return rewrite_local_markdown_images(
|
||||||
|
text,
|
||||||
|
workspace_path=self._workspace_path,
|
||||||
|
sign_path=self._sign_or_stage_media_path,
|
||||||
|
)
|
||||||
|
|
||||||
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
|
||||||
:meth:`_sign_media_path`. Validates the signature, decodes the
|
:meth:`_sign_media_path`. Validates the signature, decodes the
|
||||||
@@ -1473,6 +1584,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
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")
|
||||||
@@ -1584,10 +1698,11 @@ 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)
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"chat_id": msg.chat_id,
|
"chat_id": msg.chat_id,
|
||||||
"text": text,
|
"text": wire_text,
|
||||||
}
|
}
|
||||||
if msg.media:
|
if msg.media:
|
||||||
payload["media"] = msg.media
|
payload["media"] = msg.media
|
||||||
@@ -1615,7 +1730,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
payload["kind"] = "tool_hint"
|
payload["kind"] = "tool_hint"
|
||||||
elif msg.metadata.get("_progress"):
|
elif msg.metadata.get("_progress"):
|
||||||
payload["kind"] = "progress"
|
payload["kind"] = "progress"
|
||||||
self._try_append_webui_transcript(msg.chat_id, payload)
|
transcript_payload = dict(payload)
|
||||||
|
transcript_payload["text"] = text
|
||||||
|
self._try_append_webui_transcript(msg.chat_id, transcript_payload)
|
||||||
raw = json.dumps(payload, ensure_ascii=False)
|
raw = json.dumps(payload, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" ")
|
await self._safe_send_to(connection, raw, label=" ")
|
||||||
@@ -1680,14 +1797,23 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not conns:
|
if not conns:
|
||||||
return
|
return
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
|
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
|
||||||
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}
|
||||||
|
buffered = self._stream_text_buffers.pop(stream_key, [])
|
||||||
|
if delta:
|
||||||
|
buffered.append(delta)
|
||||||
|
full_text = "".join(buffered)
|
||||||
|
rewritten = self._rewrite_local_markdown_images(full_text)
|
||||||
|
if rewritten != full_text:
|
||||||
|
body["text"] = rewritten
|
||||||
else:
|
else:
|
||||||
body = {
|
body = {
|
||||||
"event": "delta",
|
"event": "delta",
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"text": delta,
|
"text": 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)
|
self._try_append_webui_transcript(chat_id, body)
|
||||||
|
|||||||
@@ -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 OutboundMessage
|
from nanobot.bus.events import InboundMessage, 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":
|
||||||
try:
|
await bus.publish_inbound(InboundMessage(
|
||||||
await agent.dream.run()
|
channel="system",
|
||||||
logger.info("Dream cron job completed")
|
sender_id="dream",
|
||||||
except Exception:
|
chat_id="dream",
|
||||||
logger.exception("Dream cron job failed")
|
content="",
|
||||||
|
))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from nanobot.utils.evaluator import evaluate_response
|
from nanobot.utils.evaluator import evaluate_response
|
||||||
@@ -1027,11 +1027,10 @@ 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",
|
||||||
|
|||||||
+33
-24
@@ -299,30 +299,22 @@ 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."""
|
||||||
import time
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
loop = ctx.loop
|
await ctx.loop.bus.publish_inbound(InboundMessage(
|
||||||
msg = ctx.msg
|
channel="system",
|
||||||
|
sender_id="dream",
|
||||||
async def _run_dream():
|
chat_id="dream",
|
||||||
t0 = time.monotonic()
|
content="",
|
||||||
try:
|
metadata={
|
||||||
did_work = await loop.dream.run()
|
"trigger_channel": ctx.msg.channel,
|
||||||
elapsed = time.monotonic() - t0
|
"trigger_chat_id": ctx.msg.chat_id,
|
||||||
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=msg.channel, chat_id=msg.chat_id, content="Dreaming...",
|
channel=ctx.msg.channel,
|
||||||
|
chat_id=ctx.msg.chat_id,
|
||||||
|
content="Dream started. It will process memory backlog and report when done.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -355,6 +347,18 @@ def _format_changed_files(diff: str) -> str:
|
|||||||
|
|
||||||
def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str:
|
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",
|
||||||
"",
|
"",
|
||||||
@@ -362,8 +366,12 @@ 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([
|
||||||
"",
|
"",
|
||||||
@@ -389,7 +397,8 @@ def _format_dream_restore_list(commits: list) -> str:
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for c in commits:
|
for c in commits:
|
||||||
lines.append(f"- `{c.sha}` {c.timestamp} - {c.message.splitlines()[0]}")
|
summary = c.message.splitlines()[0] if c.message else "(no message)"
|
||||||
|
lines.append(f"- `{c.sha}` {c.timestamp} - {summary}")
|
||||||
lines.extend([
|
lines.extend([
|
||||||
"",
|
"",
|
||||||
"Preview a version with `/dream-log <sha>` before restoring it.",
|
"Preview a version with `/dream-log <sha>` before restoring it.",
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import pydantic
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||||
|
|
||||||
# Global variable to store current config path (for multi-instance support)
|
# 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:
|
||||||
@@ -39,6 +40,11 @@ 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,14 +52,17 @@ 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
|
) # Optional Dream-specific model override. Supports preset names (resolved against model_presets) or raw model identifiers.
|
||||||
max_batch_size: int = Field(default=20, ge=1) # Max history entries per run
|
max_batch_size: int = Field(default=5, ge=1) # Max history entries per run
|
||||||
# Bumped from 10 to 15 in #3212 (exp002: +30% dedup, no accuracy loss; >15 plateaus).
|
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Dream run
|
||||||
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Phase 2
|
# Per-line git-blame age annotation in the Dream prompt (see #3212). Default
|
||||||
# Per-line git-blame age annotation in Phase 1 prompt (see #3212). Default
|
# on — set to False to feed all memory files raw if a specific LLM reacts
|
||||||
# on — set to False to feed MEMORY.md raw if a specific LLM reacts poorly
|
# poorly to the `← Nd` suffix or you want deterministic, git-independent prompts.
|
||||||
# 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."""
|
||||||
@@ -92,6 +95,7 @@ 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
|
||||||
@@ -170,8 +174,9 @@ 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 fields merged into every request body
|
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
|
||||||
|
|
||||||
|
|
||||||
class BedrockProviderConfig(ProviderConfig):
|
class BedrockProviderConfig(ProviderConfig):
|
||||||
@@ -222,6 +227,16 @@ 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."""
|
||||||
@@ -254,6 +269,7 @@ 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,6 +98,7 @@ 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()
|
||||||
@@ -183,6 +184,7 @@ 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,
|
||||||
@@ -199,6 +201,7 @@ 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,6 +1445,149 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1457,3 +1600,4 @@ 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,6 +274,47 @@ 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.
|
||||||
|
|
||||||
@@ -289,12 +330,14 @@ 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)
|
||||||
@@ -428,6 +471,10 @@ 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."""
|
||||||
@@ -466,10 +513,13 @@ 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:
|
||||||
@@ -685,8 +735,14 @@ 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
|
||||||
@@ -700,7 +756,14 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if not wants:
|
if not wants:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Circuit breaker: skip after repeated failures, probe periodically.
|
return self._responses_circuit_allows_probe(model, reasoning_effort)
|
||||||
|
|
||||||
|
def _responses_circuit_allows_probe(
|
||||||
|
self,
|
||||||
|
model: str | None,
|
||||||
|
reasoning_effort: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return False when the Responses API circuit breaker is open."""
|
||||||
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
|
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:
|
||||||
@@ -792,6 +855,10 @@ 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
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -956,7 +1023,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=_short_tool_id(),
|
id=str(tc_map.get("id") or _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,
|
||||||
@@ -999,7 +1066,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=_short_tool_id(),
|
id=str(getattr(tc, "id", None) or _short_tool_id()),
|
||||||
name=tc.function.name,
|
name=tc.function.name,
|
||||||
arguments=args,
|
arguments=args,
|
||||||
extra_content=ec,
|
extra_content=ec,
|
||||||
@@ -1262,6 +1329,8 @@ 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)
|
||||||
@@ -1335,6 +1404,8 @@ 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,6 +7,25 @@ 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.
|
||||||
@@ -127,12 +146,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 = (
|
self.api_url = _resolve_transcription_url(
|
||||||
api_base
|
api_base or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL"),
|
||||||
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
|
"https://api.openai.com/v1/audio/transcriptions",
|
||||||
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:
|
||||||
@@ -166,12 +185,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 = (
|
self.api_url = _resolve_transcription_url(
|
||||||
api_base
|
api_base or os.environ.get("GROQ_BASE_URL"),
|
||||||
or os.environ.get("GROQ_BASE_URL")
|
"https://api.groq.com/openai/v1/audio/transcriptions",
|
||||||
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,6 +27,8 @@ _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:
|
||||||
@@ -182,6 +184,28 @@ 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():
|
||||||
@@ -621,9 +645,18 @@ 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,3 +34,5 @@ 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,13 +1,27 @@
|
|||||||
Extract key facts from this conversation. Only output items matching these categories, skip everything else:
|
Extract key facts from this conversation. For each fact, annotate its memory attributes.
|
||||||
- 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
|
|
||||||
|
|
||||||
Priority: user corrections and preferences > solutions > decisions > events > environment facts. The most valuable memory prevents the user from having to repeat themselves.
|
Only SNIP facts deserve a non-[skip] mark:
|
||||||
|
- Signal: would the user need to repeat this if forgotten?
|
||||||
|
- Novel: not already in MEMORY.md or USER.md (check context below)
|
||||||
|
- Important: prevents rework or captures preferences / rules
|
||||||
|
- Persistent: still relevant after 2 weeks
|
||||||
|
|
||||||
Skip: code patterns derivable from source, git history, or anything already captured in existing memory.
|
Output one fact per line in this format:
|
||||||
|
- [mark] fact content
|
||||||
|
|
||||||
|
Marks (choose the best match):
|
||||||
|
- [permanent] Core preferences, personal traits, habits — never becomes stale
|
||||||
|
- [durable] Technical discoveries, project knowledge, config details — valid for months
|
||||||
|
- [ephemeral] Active task state, temporary decisions — may change in weeks
|
||||||
|
- [correction] Correction to a previous memory — must state what it replaces
|
||||||
|
- [skip] Does not meet SNIP criteria — still written to history.jsonl for audit, but Dream will ignore it
|
||||||
|
|
||||||
|
Categories to capture: people/roles, decisions/rationale, solutions, events/dates, preferences.
|
||||||
|
Decisions must include their motivation.
|
||||||
|
Write densely. Prefer 'X=A, Y=B' over separate bullets for tightly coupled facts.
|
||||||
|
Priority: user corrections > decisions with rationale > solutions > specific events > general context.
|
||||||
|
Output in the same language as the input conversation.
|
||||||
|
CRITICAL: Never drop person names, team names, or project names.
|
||||||
|
Skip: code patterns derivable from source, git history, or anything already in existing memory.
|
||||||
|
|
||||||
Output as concise bullet points, one fact per line. No preamble, no commentary.
|
|
||||||
If nothing noteworthy happened, output: (nothing)
|
If nothing noteworthy happened, output: (nothing)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
Update memory files by analyzing conversation history and editing files directly.
|
||||||
|
Prune before adding — removing stale content is as important as adding new facts.
|
||||||
|
|
||||||
|
## File routing
|
||||||
|
Do NOT guess paths. Route each fact to its canonical file:
|
||||||
|
|
||||||
|
| File | Full path | Content |
|
||||||
|
|------|------|---------|
|
||||||
|
| SOUL.md | `{{ soul_path }}` | Agent behavior, guardrails, tone, interaction patterns |
|
||||||
|
| USER.md | `{{ user_path }}` | Personal info, preferences, habits, work context, communication style |
|
||||||
|
| MEMORY.md | `{{ memory_path }}` | Technical knowledge, project context, infrastructure, accounts |
|
||||||
|
| SKILL.md | `skills/<name>/SKILL.md` | Reusable workflow templates ([SKILL] entries only) |
|
||||||
|
|
||||||
|
Cross-boundary rule: no technical configs in USER.md, no user facts in SOUL.md, no preferences in MEMORY.md. If a fact fits multiple files, keep the most specific copy and remove the rest.
|
||||||
|
|
||||||
|
## Delete-or-keep
|
||||||
|
|
||||||
|
**Always delete:**
|
||||||
|
- Same fact at multiple locations — keep canonical copy only
|
||||||
|
- Merged/closed PR notes, resolved incidents, superseded info
|
||||||
|
- Verbose entries restatable in fewer words
|
||||||
|
- Overlapping or nested sections covering the same topic
|
||||||
|
|
||||||
|
**Likely delete** (apply judgment):
|
||||||
|
- Same fact at different detail levels — keep most complete version only
|
||||||
|
- Debugging steps unlikely to recur
|
||||||
|
- Ephemeral facts past their useful life
|
||||||
|
- Tool/service details documented upstream
|
||||||
|
- Lines with ``← Nd`` where N>{{ stale_threshold_days }} — closer review, not automatic removal
|
||||||
|
|
||||||
|
**Never delete:**
|
||||||
|
- User preferences and personality traits (permanent regardless of age)
|
||||||
|
- Active project context still referenced in conversations
|
||||||
|
- Behavioral rules in SOUL.md
|
||||||
|
|
||||||
|
When removing: prefer deleting individual items over entire sections.
|
||||||
|
|
||||||
|
## Fact extraction
|
||||||
|
- Atomic facts: "has a cat named Luna" not "discussed pet care"
|
||||||
|
- Corrections: edit the existing entry, don't append a new one
|
||||||
|
- Capture confirmed approaches the user validated
|
||||||
|
|
||||||
|
## Skill discovery & creation
|
||||||
|
Flag [SKILL] only when ALL are true: repeatable workflow appeared 2+ times, involves clear steps (not vague preferences), substantial enough for its own instruction set. Check existing skills to avoid redundancy.
|
||||||
|
|
||||||
|
For [SKILL] entries:
|
||||||
|
- Use write_file to create skills/<name>/SKILL.md; read_file `{{ skill_creator_path }}` for format reference
|
||||||
|
- YAML frontmatter must include name, description, **and `dream_managed: true`** (marks this skill as Dream-created)
|
||||||
|
- Under 2000 words: when to use, steps, output format, example
|
||||||
|
- Do NOT overwrite existing skills — if overlapping, merge delta into the existing skill
|
||||||
|
- Skills are instruction sets, not code. Keep concrete values in MEMORY.md; skills use placeholders
|
||||||
|
|
||||||
|
## Skill edit policy
|
||||||
|
Each skill in the Existing Skills list is tagged with an origin:
|
||||||
|
- **[dream]** — Dream-created (has `dream_managed: true` in frontmatter). You MAY edit these.
|
||||||
|
- **[user]** — User-created workspace skill. {% if dream_edit_user_skills %}You MAY edit these.{% else %}You MUST NOT modify, rename, or delete these — you can only read them for context.{% endif %}
|
||||||
|
- **[builtin]** — Bundled with nanobot. You MUST NEVER modify these.
|
||||||
|
|
||||||
|
## Editing
|
||||||
|
- Default tool: apply_patch. Use edit_file only for small exact replacements.
|
||||||
|
- File contents provided below — no read_file needed for initial edits.
|
||||||
|
- Batch all changes into a single apply_patch call. Surgical edits only.
|
||||||
|
- dry_run=true to preview. If nothing to update, stop without calling tools.
|
||||||
|
|
||||||
|
Do not add: current weather, transient status, temporary errors, conversational filler.
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
You have TWO equally important tasks:
|
|
||||||
1. Extract new facts from conversation history
|
|
||||||
2. Deduplicate existing memory files — find and flag redundant, overlapping, or stale content even if NOT mentioned in history
|
|
||||||
|
|
||||||
Output one line per finding:
|
|
||||||
[FILE] atomic fact (not already in memory)
|
|
||||||
[FILE-REMOVE] reason for removal
|
|
||||||
[SKILL] kebab-case-name: one-line description of the reusable pattern
|
|
||||||
|
|
||||||
Files: USER (identity, preferences), SOUL (bot behavior, tone), MEMORY (knowledge, project context)
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Atomic facts: "has a cat named Luna" not "discussed pet care"
|
|
||||||
- Corrections: [USER] location is Tokyo, not Osaka
|
|
||||||
- Capture confirmed approaches the user validated
|
|
||||||
|
|
||||||
Deduplication — scan ALL memory files for these redundancy patterns:
|
|
||||||
- Same fact stated in multiple places (e.g., "communicates in Chinese" in both USER.md and multiple MEMORY.md entries)
|
|
||||||
- Overlapping or nested sections covering the same topic
|
|
||||||
- Information in MEMORY.md that is already captured in USER.md or SOUL.md (MEMORY.md should not duplicate permanent-file content)
|
|
||||||
- Verbose entries that can be condensed without losing information
|
|
||||||
For each duplicate found, output [FILE-REMOVE] for the less authoritative copy (prefer keeping facts in their canonical location)
|
|
||||||
|
|
||||||
Staleness — MEMORY.md lines may have a ``← Nd`` suffix showing days since last modification:
|
|
||||||
- SOUL.md and USER.md have no age annotations — they are permanent, only update with corrections
|
|
||||||
- Age only indicates when content was last touched, not whether it should be removed
|
|
||||||
- Use content judgment: user habits/preferences/personality traits are permanent regardless of age
|
|
||||||
- Only prune content that is objectively outdated: passed events, resolved tracking, superseded approaches
|
|
||||||
- Lines with ``← Nd`` (N>{{ stale_threshold_days }}) deserve closer review but are NOT automatically removable
|
|
||||||
- When removing: prefer deleting individual items over entire sections
|
|
||||||
|
|
||||||
Skill discovery — flag [SKILL] when ALL of these are true:
|
|
||||||
- A specific, repeatable workflow appeared 2+ times in the conversation history
|
|
||||||
- It involves clear steps (not vague preferences like "likes concise answers")
|
|
||||||
- It is substantial enough to warrant its own instruction set (not trivial like "read a file")
|
|
||||||
- Do not worry about duplicates — the next phase will check against existing skills
|
|
||||||
|
|
||||||
Do not add: current weather, transient status, temporary errors, conversational filler.
|
|
||||||
|
|
||||||
[SKIP] if nothing needs updating.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
Update memory files based on the analysis below.
|
|
||||||
- [FILE] entries: add the described content to the appropriate file
|
|
||||||
- [FILE-REMOVE] entries: delete the corresponding content from memory files
|
|
||||||
- [SKILL] entries: create a new skill under skills/<name>/SKILL.md using write_file
|
|
||||||
|
|
||||||
## File paths (relative to workspace root)
|
|
||||||
- SOUL.md
|
|
||||||
- USER.md
|
|
||||||
- memory/MEMORY.md
|
|
||||||
- skills/<name>/SKILL.md (for [SKILL] entries only)
|
|
||||||
|
|
||||||
Do NOT guess paths.
|
|
||||||
|
|
||||||
## Editing rules
|
|
||||||
- Edit directly — file contents provided below, no read_file needed
|
|
||||||
- Use exact text as old_text, include surrounding blank lines for unique match
|
|
||||||
- Batch changes to the same file into one edit_file call
|
|
||||||
- For deletions: section header + all bullets as old_text, new_text empty
|
|
||||||
- Surgical edits only — never rewrite entire files
|
|
||||||
- If nothing to update, stop without calling tools
|
|
||||||
|
|
||||||
## Skill creation rules (for [SKILL] entries)
|
|
||||||
- Use write_file to create skills/<name>/SKILL.md
|
|
||||||
- Before writing, read_file `{{ skill_creator_path }}` for format reference (frontmatter structure, naming conventions, quality standards)
|
|
||||||
- **Dedup check**: read existing skills listed below to verify the new skill is not functionally redundant. Skip creation if an existing skill already covers the same workflow.
|
|
||||||
- Include YAML frontmatter with name and description fields
|
|
||||||
- Keep SKILL.md under 2000 words — concise and actionable
|
|
||||||
- Include: when to use, steps, output format, at least one example
|
|
||||||
- Do NOT overwrite existing skills — skip if the skill directory already exists
|
|
||||||
- Reference specific tools the agent has access to (read_file, write_file, exec, web_search, etc.)
|
|
||||||
- Skills are instruction sets, not code — do not include implementation code
|
|
||||||
|
|
||||||
## Quality
|
|
||||||
- Every line must carry standalone value
|
|
||||||
- Concise bullets under clear headers
|
|
||||||
- When reducing (not deleting): keep essential facts, drop verbose details
|
|
||||||
- If uncertain whether to delete, keep but add "(verify currency)"
|
|
||||||
@@ -19,7 +19,8 @@ class CommitInfo:
|
|||||||
|
|
||||||
def format(self, diff: str = "") -> str:
|
def format(self, diff: str = "") -> str:
|
||||||
"""Format this commit for display, optionally with a diff."""
|
"""Format this commit for display, optionally with a diff."""
|
||||||
header = f"## {self.message.splitlines()[0]}\n`{self.sha}` — {self.timestamp}\n"
|
summary = self.message.splitlines()[0] if self.message else "(no message)"
|
||||||
|
header = f"## {summary}\n`{self.sha}` — {self.timestamp}\n"
|
||||||
if diff:
|
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,6 +29,11 @@ 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."""
|
||||||
@@ -65,6 +70,11 @@ 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.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli 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
@@ -0,0 +1,5 @@
|
|||||||
|
"""Compatibility exports for WebUI-attached MCP preset annotations."""
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp import runtime_lines, session_extra
|
||||||
|
|
||||||
|
__all__ = ["runtime_lines", "session_extra"]
|
||||||
@@ -6,10 +6,12 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
|
|||||||
|
|
||||||
from __future__ import annotations
|
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,
|
||||||
@@ -41,6 +43,7 @@ _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):
|
||||||
@@ -100,6 +103,32 @@ 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():
|
||||||
@@ -152,17 +181,18 @@ 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
|
||||||
providers.append(
|
row = {
|
||||||
{
|
"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),
|
"api_key_required": _provider_requires_api_key(spec),
|
||||||
"api_key_required": _provider_requires_api_key(spec),
|
"api_key_hint": _mask_secret_hint(provider_config.api_key),
|
||||||
"api_key_hint": _mask_secret_hint(provider_config.api_key),
|
"api_base": provider_config.api_base,
|
||||||
"api_base": provider_config.api_base,
|
"default_api_base": spec.default_api_base or None,
|
||||||
"default_api_base": spec.default_api_base or None,
|
}
|
||||||
}
|
if 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
|
||||||
@@ -198,7 +228,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
|
|||||||
model_presets.append(
|
model_presets.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"label": name,
|
"label": preset.label or name,
|
||||||
"active": active_preset_name == name,
|
"active": active_preset_name == name,
|
||||||
"is_default": False,
|
"is_default": False,
|
||||||
"model": preset.model,
|
"model": preset.model,
|
||||||
@@ -277,6 +307,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
|
|||||||
"max_batch_size": defaults.dream.max_batch_size,
|
"max_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,
|
||||||
},
|
},
|
||||||
@@ -321,15 +352,7 @@ 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")
|
||||||
spec = find_by_name(provider)
|
_validate_configured_provider(config, 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
|
||||||
@@ -388,6 +411,40 @@ 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:
|
||||||
@@ -416,6 +473,17 @@ 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,10 +4,12 @@ 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
|
from typing import Any, Callable, Mapping
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -16,6 +18,61 @@ 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:
|
||||||
@@ -185,6 +242,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
lines: list[dict[str, Any]],
|
lines: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
||||||
|
augment_assistant_text: Callable[[str], str] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI.
|
"""Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI.
|
||||||
|
|
||||||
@@ -457,6 +515,11 @@ 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
|
||||||
|
|
||||||
@@ -501,6 +564,24 @@ def replay_transcript_to_ui_messages(
|
|||||||
buffer_message_id = None
|
buffer_message_id = None
|
||||||
buffer_parts = []
|
buffer_parts = []
|
||||||
continue
|
continue
|
||||||
|
final_text = rec.get("text")
|
||||||
|
if isinstance(final_text, str):
|
||||||
|
if buffer_message_id is None:
|
||||||
|
buffer_message_id = _new_id("buf", idx)
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"id": buffer_message_id,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": final_text,
|
||||||
|
"isStreaming": True,
|
||||||
|
"createdAt": _ts_base + idx,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for i, m in enumerate(messages):
|
||||||
|
if m.get("id") == buffer_message_id:
|
||||||
|
messages[i] = {**m, "content": final_text, "isStreaming": True}
|
||||||
|
break
|
||||||
buffer_message_id = None
|
buffer_message_id = None
|
||||||
buffer_parts = []
|
buffer_parts = []
|
||||||
continue
|
continue
|
||||||
@@ -626,7 +707,14 @@ def replay_transcript_to_ui_messages(
|
|||||||
buffer_parts = []
|
buffer_parts = []
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for m in messages:
|
for i, m in enumerate(messages):
|
||||||
|
if (
|
||||||
|
augment_assistant_text is not None
|
||||||
|
and m.get("role") == "assistant"
|
||||||
|
and m.get("kind") != "trace"
|
||||||
|
and isinstance(m.get("content"), str)
|
||||||
|
):
|
||||||
|
messages[i] = {**m, "content": augment_assistant_text(m["content"])}
|
||||||
m.pop("isStreaming", None)
|
m.pop("isStreaming", None)
|
||||||
m.pop("reasoningStreaming", None)
|
m.pop("reasoningStreaming", None)
|
||||||
return messages
|
return messages
|
||||||
@@ -636,12 +724,17 @@ def build_webui_thread_response(
|
|||||||
session_key: str,
|
session_key: str,
|
||||||
*,
|
*,
|
||||||
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
||||||
|
augment_assistant_text: Callable[[str], str] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Return a payload compatible with ``WebuiThreadPersistedPayload``."""
|
"""Return a payload compatible with ``WebuiThreadPersistedPayload``."""
|
||||||
lines = read_transcript_lines(session_key)
|
lines = read_transcript_lines(session_key)
|
||||||
if not lines:
|
if not lines:
|
||||||
return None
|
return None
|
||||||
msgs = replay_transcript_to_ui_messages(lines, augment_user_media=augment_user_media)
|
msgs = replay_transcript_to_ui_messages(
|
||||||
|
lines,
|
||||||
|
augment_user_media=augment_user_media,
|
||||||
|
augment_assistant_text=augment_assistant_text,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||||
"sessionKey": session_key,
|
"sessionKey": session_key,
|
||||||
|
|||||||
+397
-216
@@ -1,19 +1,32 @@
|
|||||||
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
|
"""Tests for Dream driven through AgentLoop._process_system_message."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
|
||||||
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")
|
||||||
@@ -23,9 +36,7 @@ def store(tmp_path):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_provider():
|
def mock_provider():
|
||||||
p = MagicMock()
|
return _provider("test-model")
|
||||||
p.chat_with_retry = AsyncMock()
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -34,10 +45,16 @@ def mock_runner():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def dream(store, mock_provider, mock_runner):
|
def loop(tmp_path, mock_provider, mock_runner):
|
||||||
d = Dream(store=store, provider=mock_provider, model="test-model", max_batch_size=5)
|
loop = AgentLoop(
|
||||||
d._runner = mock_runner
|
bus=MessageBus(),
|
||||||
return d
|
provider=mock_provider,
|
||||||
|
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(
|
||||||
@@ -56,254 +73,418 @@ def _make_run_result(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDreamRun:
|
class TestDreamAgentLoopIntegration:
|
||||||
async def test_noop_when_no_unprocessed_history(self, dream, mock_provider, mock_runner, store):
|
async def test_completes_goal_state_after_full_backlog(self, loop, mock_runner, store):
|
||||||
"""Dream should not call LLM when there's nothing to process."""
|
"""Goal should be completed after processing all backlog in internal loop."""
|
||||||
result = await dream.run()
|
for i in range(6):
|
||||||
assert result is False
|
store.append_history(f"event {i}")
|
||||||
mock_provider.chat_with_retry.assert_not_called()
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
session = loop.sessions.get_or_create("system:dream")
|
||||||
|
goal = session.metadata.get("goal_state")
|
||||||
|
assert isinstance(goal, dict)
|
||||||
|
assert goal["status"] == "completed"
|
||||||
|
assert store.get_last_dream_cursor() == 6
|
||||||
|
|
||||||
|
async def test_completes_goal_state_on_finish(self, loop, mock_runner, store):
|
||||||
|
"""Goal should be marked completed when backlog is fully processed."""
|
||||||
|
store.append_history("event 1")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
session = loop.sessions.get_or_create("system:dream")
|
||||||
|
goal = session.metadata.get("goal_state")
|
||||||
|
assert goal["status"] == "completed"
|
||||||
|
assert "completed_at" in goal
|
||||||
|
assert "recap" in goal
|
||||||
|
|
||||||
|
async def test_noop_when_no_unprocessed_history(self, loop, mock_runner):
|
||||||
|
"""Dream should not call runner when there's nothing to process."""
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
result = await loop._process_system_message(msg)
|
||||||
|
assert result is None
|
||||||
mock_runner.run.assert_not_called()
|
mock_runner.run.assert_not_called()
|
||||||
|
|
||||||
async def test_calls_runner_for_unprocessed_entries(self, dream, mock_provider, mock_runner, store):
|
async def test_calls_runner_for_unprocessed_entries(self, loop, mock_runner, store):
|
||||||
"""Dream should call AgentRunner when there are unprocessed history entries."""
|
"""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_provider.chat_with_retry.return_value = MagicMock(content="New fact")
|
mock_runner.run = AsyncMock(
|
||||||
mock_runner.run = AsyncMock(return_value=_make_run_result(
|
return_value=_make_run_result(
|
||||||
tool_events=[{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}],
|
tool_events=[
|
||||||
))
|
{"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, dream, mock_provider, mock_runner, store):
|
async def test_advances_dream_cursor(self, loop, 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())
|
||||||
await dream.run()
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
assert store.get_last_dream_cursor() == 2
|
assert store.get_last_dream_cursor() == 2
|
||||||
|
|
||||||
async def test_compacts_processed_history(self, dream, mock_provider, mock_runner, store):
|
async def test_compacts_processed_history(self, loop, mock_runner, store):
|
||||||
"""Dream should compact history after processing."""
|
"""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())
|
||||||
await dream.run()
|
msg = InboundMessage(
|
||||||
# After Dream, cursor is advanced and 3, compact keeps last max_history_entries
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
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_skill_phase_uses_builtin_skill_creator_path(self, dream, mock_provider, mock_runner, store):
|
async def test_processes_full_backlog_in_one_call(self, loop, mock_runner, store):
|
||||||
"""Dream should point skill creation guidance at the builtin skill-creator template."""
|
"""Backlog larger than max_batch_size should be fully processed in one call."""
|
||||||
|
for i in range(12):
|
||||||
|
store.append_history(f"event {i}")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
assert store.get_last_dream_cursor() == 12
|
||||||
|
assert mock_runner.run.call_count == 3 # 5 + 5 + 2
|
||||||
|
|
||||||
|
async def test_single_git_commit_for_multi_batch(self, loop, mock_runner, store):
|
||||||
|
"""Multi-batch run should collapse into exactly one git commit."""
|
||||||
|
store.git.init()
|
||||||
|
store.git.auto_commit("initial")
|
||||||
|
for i in range(12):
|
||||||
|
store.append_history(f"event {i}")
|
||||||
|
mock_runner.run = AsyncMock(
|
||||||
|
return_value=_make_run_result(
|
||||||
|
tool_events=[
|
||||||
|
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
commits = store.git.log()
|
||||||
|
dream_commits = [c for c in commits if c.message.startswith("dream:")]
|
||||||
|
assert len(dream_commits) == 1
|
||||||
|
|
||||||
|
async def test_system_prompt_cached(self, loop, mock_runner, store):
|
||||||
|
"""Batches within one run should reuse cached system prompt when template mtime unchanged."""
|
||||||
|
for i in range(6):
|
||||||
|
store.append_history(f"event {i}")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
# Two batches (5 + 1), both should use the same cached prompt
|
||||||
|
assert mock_runner.run.call_count == 2
|
||||||
|
first_prompt = mock_runner.run.call_args_list[0][0][0].initial_messages[0]["content"]
|
||||||
|
second_prompt = mock_runner.run.call_args_list[1][0][0].initial_messages[0]["content"]
|
||||||
|
assert second_prompt is first_prompt
|
||||||
|
|
||||||
|
async def test_noop_when_empty_backlog(self, loop, mock_runner, store):
|
||||||
|
"""Empty backlog should not advance cursor or create a commit."""
|
||||||
|
store.git.init()
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
assert store.get_last_dream_cursor() == 0
|
||||||
|
commits = store.git.log()
|
||||||
|
assert len([c for c in commits if c.message.startswith("dream:")]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestDreamPrompt:
|
||||||
|
async def test_prompt_contains_mece_rules(self, loop, mock_runner, store):
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
spec = mock_runner.run.call_args[0][0]
|
||||||
|
system_prompt = spec.initial_messages[0]["content"]
|
||||||
|
assert "Do NOT guess paths" in system_prompt
|
||||||
|
assert "SOUL.md" in system_prompt
|
||||||
|
assert "USER.md" in system_prompt
|
||||||
|
assert "MEMORY.md" in system_prompt
|
||||||
|
|
||||||
|
async def test_skill_phase_uses_builtin_skill_creator_path(self, loop, mock_runner, store):
|
||||||
store.append_history("Repeated workflow one")
|
store.append_history("Repeated workflow 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(
|
||||||
await dream.run()
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
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_skill_write_tool_accepts_workspace_relative_skill_path(self, dream, store):
|
async def test_system_prompt_uses_threshold_from_template_var(self, loop, mock_runner, store):
|
||||||
"""Dream skill creation should allow skills/<name>/SKILL.md relative to workspace root."""
|
store.append_history("some event")
|
||||||
write_tool = dream._tools.get("write_file")
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
assert write_tool is not None
|
msg = InboundMessage(
|
||||||
|
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)
|
||||||
assert "Successfully wrote" in result
|
spec = mock_runner.run.call_args[0][0]
|
||||||
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
|
system_msg = spec.initial_messages[0]["content"]
|
||||||
|
|
||||||
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:
|
||||||
"""Dream's Phase 1/2 prompt must not be poisoned by a legacy oversized
|
async def test_caps_huge_memory_file(self, loop, mock_runner, store):
|
||||||
history entry or a runaway MEMORY.md. Without caps, a single pre-#3412
|
store.write_memory("M" * (loop.dream._MEMORY_FILE_MAX_CHARS * 5))
|
||||||
raw_archive dump in history.jsonl would make every subsequent Dream run
|
|
||||||
exceed the context window and silently advance the cursor past real work.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def test_phase1_caps_huge_memory_file(
|
|
||||||
self, dream, mock_provider, mock_runner, store,
|
|
||||||
):
|
|
||||||
"""A MEMORY.md much larger than _MEMORY_FILE_MAX_CHARS must be truncated
|
|
||||||
in the prompt preview (full content is still reachable via read_file)."""
|
|
||||||
store.write_memory("M" * (dream._MEMORY_FILE_MAX_CHARS * 5))
|
|
||||||
store.append_history("some event")
|
store.append_history("some event")
|
||||||
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"]
|
||||||
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
|
||||||
|
"## Current SOUL.md"
|
||||||
|
)[0]
|
||||||
|
assert len(memory_section) < loop.dream._MEMORY_FILE_MAX_CHARS + 500
|
||||||
|
|
||||||
await dream.run()
|
async def test_caps_huge_history_entry(self, loop, mock_runner, store):
|
||||||
|
|
||||||
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,
|
{
|
||||||
"timestamp": "2026-04-01 10:00",
|
"cursor": 1,
|
||||||
"content": "H" * (dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
"timestamp": "2026-04-01 10:00",
|
||||||
}) + "\n",
|
"content": "H" * (loop.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\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()
|
|
||||||
|
|
||||||
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
class TestDreamTools:
|
||||||
history_section = user_msg.split("## Conversation History\n")[1].split("\n\n## Current Date")[0]
|
def test_apply_patch_tool_registered(self, loop):
|
||||||
assert len(history_section) < dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
|
tool = loop.dream._tools.get("apply_patch")
|
||||||
|
assert tool is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDreamCaps:
|
||||||
|
def test_batch_size_default_is_5(self):
|
||||||
|
from nanobot.config.schema import DreamConfig
|
||||||
|
|
||||||
|
assert DreamConfig().max_batch_size == 5
|
||||||
|
|
||||||
|
def test_memory_cap_is_16k(self, loop):
|
||||||
|
assert loop.dream._MEMORY_FILE_MAX_CHARS == 16_000
|
||||||
|
|
||||||
|
|
||||||
|
class TestDreamSkipFiltering:
|
||||||
|
async def test_skip_entries_removed_from_prompt(self, loop, mock_runner, store):
|
||||||
|
store.append_history("- [skip] greeting\n- [permanent] User prefers dark mode")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
spec = mock_runner.run.call_args[0][0]
|
||||||
|
user_msg = spec.initial_messages[1]["content"]
|
||||||
|
assert "User prefers dark mode" in user_msg
|
||||||
|
assert "[skip]" not in user_msg
|
||||||
|
assert "greeting" not in user_msg
|
||||||
|
|
||||||
|
|
||||||
|
class TestDreamAgeAnnotations:
|
||||||
|
async def test_prompt_includes_line_age_annotations(self, loop, mock_runner, store):
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
store.git.init()
|
||||||
|
store.git.auto_commit("initial memory state")
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
spec = mock_runner.run.call_args[0][0]
|
||||||
|
user_msg = spec.initial_messages[1]["content"]
|
||||||
|
assert "## Current MEMORY.md" in user_msg
|
||||||
|
|
||||||
|
async def test_annotates_only_memory_not_soul_or_user(self, loop, mock_runner, store):
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
store.git.init()
|
||||||
|
store.git.auto_commit("initial state")
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
spec = mock_runner.run.call_args[0][0]
|
||||||
|
user_msg = spec.initial_messages[1]["content"]
|
||||||
|
soul_section = user_msg.split("## Current SOUL.md")[1].split(
|
||||||
|
"## Current USER.md"
|
||||||
|
)[0]
|
||||||
|
user_section = user_msg.split("## Current USER.md")[1]
|
||||||
|
assert "←" not in soul_section
|
||||||
|
assert "←" not in user_section
|
||||||
|
|
||||||
|
async def test_prompt_works_without_git(self, loop, mock_runner, store):
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
mock_runner.run.assert_called_once()
|
||||||
|
spec = mock_runner.run.call_args[0][0]
|
||||||
|
user_msg = spec.initial_messages[1]["content"]
|
||||||
|
assert "## Current MEMORY.md" in user_msg
|
||||||
|
|
||||||
|
async def test_prompt_carries_age_suffix_for_stale_lines(self, loop, mock_runner, store):
|
||||||
|
store.write_memory(
|
||||||
|
"# Memory\n- Project X active\n- fresh item\n- edge case line"
|
||||||
|
)
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
fake_ages = [
|
||||||
|
LineAge(age_days=30),
|
||||||
|
LineAge(age_days=20),
|
||||||
|
LineAge(age_days=14),
|
||||||
|
LineAge(age_days=5),
|
||||||
|
]
|
||||||
|
with patch.object(loop.dream.store.git, "line_ages", return_value=fake_ages):
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
spec = mock_runner.run.call_args[0][0]
|
||||||
|
user_msg = spec.initial_messages[1]["content"]
|
||||||
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
|
||||||
|
"## Current SOUL.md"
|
||||||
|
)[0]
|
||||||
|
assert "← 30d" in memory_section
|
||||||
|
assert "← 20d" in memory_section
|
||||||
|
assert "← 14d" not in memory_section
|
||||||
|
assert "← 5d" not in memory_section
|
||||||
|
|
||||||
|
async def test_skips_annotation_when_disabled(self, loop, mock_runner, store):
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
loop.dream.annotate_line_ages = False
|
||||||
|
with patch.object(loop.dream.store.git, "line_ages") as mock_line_ages:
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
mock_line_ages.assert_not_called()
|
||||||
|
spec = mock_runner.run.call_args[0][0]
|
||||||
|
user_msg = spec.initial_messages[1]["content"]
|
||||||
|
assert "←" not in user_msg
|
||||||
|
|
||||||
|
async def test_skips_annotation_on_line_ages_length_mismatch(self, loop, mock_runner, store):
|
||||||
|
store.append_history("some event")
|
||||||
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||||
|
with patch.object(
|
||||||
|
loop.dream.store.git, "line_ages", return_value=[LineAge(age_days=999)]
|
||||||
|
):
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
spec = mock_runner.run.call_args[0][0]
|
||||||
|
user_msg = spec.initial_messages[1]["content"]
|
||||||
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
|
||||||
|
"## Current SOUL.md"
|
||||||
|
)[0]
|
||||||
|
assert "←" not in memory_section
|
||||||
|
|
||||||
|
|
||||||
|
class TestDreamSessionPersistence:
|
||||||
|
async def test_writes_session_on_success(self, loop, mock_runner, store):
|
||||||
|
store.append_history("event one")
|
||||||
|
store.append_history("event two")
|
||||||
|
mock_runner.run = AsyncMock(
|
||||||
|
return_value=_make_run_result(
|
||||||
|
tool_events=[
|
||||||
|
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
session_path = store.memory_dir / ".dream_session.json"
|
||||||
|
assert session_path.exists()
|
||||||
|
data = json.loads(session_path.read_text(encoding="utf-8"))
|
||||||
|
assert data["batch"]["from_cursor"] == 0
|
||||||
|
assert data["batch"]["to_cursor"] == 2
|
||||||
|
assert data["batch"]["count"] == 2
|
||||||
|
assert data["stop_reason"] == "completed"
|
||||||
|
assert data["changelog"] == ["edit_file: memory/MEMORY.md"]
|
||||||
|
assert "timestamp" in data
|
||||||
|
assert "elapsed_seconds" in data
|
||||||
|
assert "messages" in data
|
||||||
|
|
||||||
|
async def test_no_session_record_on_failure(self, loop, mock_runner, store):
|
||||||
|
"""Failed batch should not write a session record (cursor stays put for retry)."""
|
||||||
|
store.append_history("event one")
|
||||||
|
mock_runner.run = AsyncMock(side_effect=RuntimeError("LLM error"))
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
session_path = store.memory_dir / ".dream_session.json"
|
||||||
|
assert not session_path.exists()
|
||||||
|
assert store.get_last_dream_cursor() == 0
|
||||||
|
|
||||||
|
async def test_session_contains_full_messages(self, loop, mock_runner, store):
|
||||||
|
store.append_history("event one")
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "you are a memory bot"},
|
||||||
|
{"role": "user", "content": "history here"},
|
||||||
|
{"role": "assistant", "content": "I will edit MEMORY.md"},
|
||||||
|
]
|
||||||
|
result = _make_run_result()
|
||||||
|
result.messages = messages
|
||||||
|
mock_runner.run = AsyncMock(return_value=result)
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||||
|
)
|
||||||
|
await loop._process_system_message(msg)
|
||||||
|
session_path = store.memory_dir / ".dream_session.json"
|
||||||
|
data = json.loads(session_path.read_text(encoding="utf-8"))
|
||||||
|
assert data["messages"] == messages
|
||||||
|
assert data["prompt_chars"] > 0
|
||||||
|
assert data["commit_sha"] is None
|
||||||
|
|||||||
@@ -2,12 +2,39 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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:
|
||||||
@@ -42,3 +69,152 @@ 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()
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Tests for memory system: Consolidator, token estimation, truncation."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.memory import _TIKTOKEN_ENC, Consolidator, MemoryStore, _estimate_tokens
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path):
|
||||||
|
s = MemoryStore(tmp_path)
|
||||||
|
s.write_soul("# Soul\n- Helpful")
|
||||||
|
s.write_user("# User\n- Developer")
|
||||||
|
s.write_memory("# Memory\n- Project X active")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_provider():
|
||||||
|
p = MagicMock()
|
||||||
|
p.chat_with_retry = AsyncMock()
|
||||||
|
p.generation.max_tokens = 4096
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_sessions():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_build_messages():
|
||||||
|
return MagicMock(return_value=[])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_tool_definitions():
|
||||||
|
return MagicMock(return_value=[])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def consolidator(store, mock_provider, mock_sessions, mock_build_messages, mock_get_tool_definitions):
|
||||||
|
return Consolidator(
|
||||||
|
store=store,
|
||||||
|
provider=mock_provider,
|
||||||
|
model="test-model",
|
||||||
|
sessions=mock_sessions,
|
||||||
|
context_window_tokens=128_000,
|
||||||
|
build_messages=mock_build_messages,
|
||||||
|
get_tool_definitions=mock_get_tool_definitions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEstimateTokens:
|
||||||
|
def test_estimate_tokens_returns_positive(self):
|
||||||
|
assert _estimate_tokens("hello world") > 0
|
||||||
|
|
||||||
|
def test_estimate_tokens_english_approximate(self):
|
||||||
|
# English is roughly 1 token per 4 chars as fallback
|
||||||
|
text = "a " * 100
|
||||||
|
if _TIKTOKEN_ENC is not None:
|
||||||
|
expected = len(_TIKTOKEN_ENC.encode(text))
|
||||||
|
else:
|
||||||
|
expected = len(text) // 4
|
||||||
|
assert _estimate_tokens(text) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncateToTokenBudget:
|
||||||
|
def test_reserve_tokens_reduces_budget(self, consolidator):
|
||||||
|
long_text = "word " * 200_000
|
||||||
|
# Without reserve, more text survives
|
||||||
|
no_reserve = consolidator._truncate_to_token_budget(long_text, reserve_tokens=0)
|
||||||
|
with_reserve = consolidator._truncate_to_token_budget(long_text, reserve_tokens=500)
|
||||||
|
assert len(with_reserve) < len(no_reserve)
|
||||||
|
|
||||||
|
def test_reserve_tokens_zero_default(self, consolidator):
|
||||||
|
text = "hello world"
|
||||||
|
result = consolidator._truncate_to_token_budget(text)
|
||||||
|
assert result == text
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsolidatorPrompt:
|
||||||
|
def test_prompt_contains_snip(self):
|
||||||
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
text = render_template("agent/consolidator_archive.md", strip=True)
|
||||||
|
assert "SNIP" in text
|
||||||
|
assert "[permanent]" in text
|
||||||
|
assert "[skip]" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsolidatorArchive:
|
||||||
|
async def test_archive_injects_dedup_context(self, consolidator, mock_provider, store):
|
||||||
|
store.write_memory("- User prefers dark mode")
|
||||||
|
store.write_user("- Developer")
|
||||||
|
messages = [{"role": "user", "content": "hello", "timestamp": "2026-01-01 10:00"}]
|
||||||
|
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
|
content="(nothing)", finish_reason="stop"
|
||||||
|
)
|
||||||
|
await consolidator.archive(messages)
|
||||||
|
|
||||||
|
call_args = mock_provider.chat_with_retry.call_args
|
||||||
|
user_msg = call_args.kwargs["messages"][1]["content"]
|
||||||
|
assert "## Current MEMORY.md (for dedup)" in user_msg
|
||||||
|
assert "User prefers dark mode" in user_msg
|
||||||
|
assert "## Current USER.md (for dedup)" in user_msg
|
||||||
|
assert "Developer" in user_msg
|
||||||
|
|
||||||
|
async def test_archive_skips_dedup_when_budget_exhausted(self, consolidator, mock_provider, store):
|
||||||
|
# Shrink token budget so dedup context (always capped at ~6000 chars)
|
||||||
|
# exceeds the available room.
|
||||||
|
consolidator.context_window_tokens = 6_000
|
||||||
|
store.write_memory("word " * 10_000)
|
||||||
|
messages = [{"role": "user", "content": "hello", "timestamp": "2026-01-01 10:00"}]
|
||||||
|
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
|
content="(nothing)", finish_reason="stop"
|
||||||
|
)
|
||||||
|
await consolidator.archive(messages)
|
||||||
|
|
||||||
|
call_args = mock_provider.chat_with_retry.call_args
|
||||||
|
user_msg = call_args.kwargs["messages"][1]["content"]
|
||||||
|
# Should not contain dedup context when budget is exhausted
|
||||||
|
assert "## Current MEMORY.md (for dedup)" not in user_msg
|
||||||
@@ -241,7 +241,7 @@ def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
|
|||||||
signature = provider_signature(config)
|
signature = provider_signature(config)
|
||||||
fallback_signatures = signature[-1]
|
fallback_signatures = signature[-1]
|
||||||
|
|
||||||
assert fallback_signatures[0][11] is None
|
assert fallback_signatures[0][12] is None
|
||||||
|
|
||||||
|
|
||||||
# -- FallbackProvider tests --
|
# -- FallbackProvider tests --
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Tests for sustained-goal continuation in AgentRunner.
|
||||||
|
|
||||||
|
When a goal_active_predicate returns True, the runner must not exit with
|
||||||
|
stop_reason="completed" after a plain-text final response. Instead it should
|
||||||
|
inject a continuation message and keep looping (similar to mid-turn injection).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||||
|
|
||||||
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_exits_normally_without_predicate():
|
||||||
|
"""Baseline: no predicate, runner exits with completed on final text."""
|
||||||
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||||
|
content="all done", tool_calls=[], usage={},
|
||||||
|
))
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "do task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=2,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.stop_reason == "completed"
|
||||||
|
assert result.final_content == "all done"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_exits_normally_with_inactive_goal():
|
||||||
|
"""Predicate returns False, runner should exit normally."""
|
||||||
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||||
|
content="all done", tool_calls=[], usage={},
|
||||||
|
))
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "do task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=2,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
goal_active_predicate=lambda: False,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.stop_reason == "completed"
|
||||||
|
assert result.final_content == "all done"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_forces_continue_when_goal_active():
|
||||||
|
"""Predicate returns True on final text → runner injects continuation and loops.
|
||||||
|
|
||||||
|
We set max_iterations=3 and let the provider return final text every time.
|
||||||
|
Without the fix this would exit on the first iteration with stop_reason
|
||||||
|
"completed". With the fix the runner is forced to continue until
|
||||||
|
max_iterations is hit.
|
||||||
|
"""
|
||||||
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||||
|
content="still working", tool_calls=[], usage={},
|
||||||
|
))
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "do task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=3,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
goal_active_predicate=lambda: True,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Because the predicate keeps returning True, the runner should never
|
||||||
|
# naturally complete. It loops until max_iterations is exhausted.
|
||||||
|
assert result.stop_reason == "max_iterations"
|
||||||
|
# The injected continuation message should be present in the message list.
|
||||||
|
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
||||||
|
assert any("active sustained goal" in str(m.get("content", "")) for m in user_msgs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_respects_max_iterations_even_with_active_goal():
|
||||||
|
"""A single iteration with active goal still hits max_iterations."""
|
||||||
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||||
|
content="still working", tool_calls=[], usage={},
|
||||||
|
))
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "do task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=1,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
goal_active_predicate=lambda: True,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.stop_reason == "max_iterations"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_does_not_force_continue_on_error():
|
||||||
|
"""Even with active goal, an LLM error should exit with stop_reason="error"."""
|
||||||
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||||
|
content=None, tool_calls=[], usage={},
|
||||||
|
finish_reason="error",
|
||||||
|
))
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "do task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=2,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
goal_active_predicate=lambda: True,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.stop_reason == "error"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_uses_custom_goal_continue_message():
|
||||||
|
"""Custom goal_continue_message should be injected instead of the default."""
|
||||||
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||||
|
content="still working", tool_calls=[], usage={},
|
||||||
|
))
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
|
custom_msg = "CUSTOM_CONTINUE_PLEASE"
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "do task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=2,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
goal_active_predicate=lambda: True,
|
||||||
|
goal_continue_message=custom_msg,
|
||||||
|
))
|
||||||
|
|
||||||
|
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
||||||
|
assert any(custom_msg in str(m.get("content", "")) for m in user_msgs)
|
||||||
@@ -292,3 +292,95 @@ def test_from_config_static_preset_loader_does_not_enable_hot_reload(tmp_path) -
|
|||||||
loop = AgentLoop.from_config(config)
|
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,6 +56,20 @@ 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,6 +94,39 @@ 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
|
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
||||||
|
|
||||||
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
||||||
|
|
||||||
@@ -480,6 +480,72 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
|
|||||||
assert second["stream_id"] == "sid"
|
assert second["stream_id"] == "sid"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch, tmp_path) -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
|
||||||
|
media = tmp_path / "media"
|
||||||
|
|
||||||
|
def fake_media_dir(channel: str | None = None):
|
||||||
|
path = media / channel if channel else media
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
|
||||||
|
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", "
|
||||||
|
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
|
||||||
|
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
|
||||||
|
|
||||||
|
assert mock_ws.send.await_count == 3
|
||||||
|
final = json.loads(mock_ws.send.call_args_list[2][0][0])
|
||||||
|
assert final["event"] == "stream_end"
|
||||||
|
assert final["text"].startswith("
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp_path) -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
|
||||||
|
media = tmp_path / "media"
|
||||||
|
|
||||||
|
def fake_media_dir(channel: str | None = None):
|
||||||
|
path = media / channel if channel else media
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
|
||||||
|
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"
|
||||||
|
assert final["text"].startswith("
|
||||||
|
|
||||||
|
|
||||||
@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()
|
||||||
@@ -1122,6 +1188,30 @@ 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"
|
||||||
@@ -1189,7 +1279,10 @@ 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 == "deep"
|
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||||
|
assert saved.model_presets["fast-writing"].label == "Fast writing"
|
||||||
|
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
||||||
|
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||||
assert saved.agents.defaults.timezone == "Asia/Shanghai"
|
assert saved.agents.defaults.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"
|
||||||
@@ -1258,6 +1351,37 @@ 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,6 +209,156 @@ 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
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ def _ch(
|
|||||||
bus: Any,
|
bus: Any,
|
||||||
*,
|
*,
|
||||||
session_manager: SessionManager | None = None,
|
session_manager: SessionManager | None = None,
|
||||||
|
workspace_path: Path | None = None,
|
||||||
port: int,
|
port: int,
|
||||||
) -> WebSocketChannel:
|
) -> WebSocketChannel:
|
||||||
return WebSocketChannel(
|
return WebSocketChannel(
|
||||||
@@ -57,6 +58,7 @@ def _ch(
|
|||||||
},
|
},
|
||||||
bus,
|
bus,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
|
workspace_path=workspace_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +69,15 @@ def bus() -> MagicMock:
|
|||||||
return b
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_media_dir(root: Path):
|
||||||
|
def inner(channel: str | None = None) -> Path:
|
||||||
|
path = root / channel if channel else root
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
|
||||||
async def _http_get(
|
async def _http_get(
|
||||||
url: str, headers: dict[str, str] | None = None
|
url: str, headers: dict[str, str] | None = None
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
@@ -123,6 +134,45 @@ def test_sign_media_path_round_trips_via_hmac(
|
|||||||
assert _b64url_decode(payload).decode() == "a.png"
|
assert _b64url_decode(payload).decode() == "a.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_markdown_image_is_staged_and_rewritten(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
(workspace / "demo_arch.png").write_bytes(_PNG_BYTES)
|
||||||
|
media = tmp_path / "media"
|
||||||
|
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||||
|
|
||||||
|
with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||||
|
rewritten = channel._rewrite_local_markdown_images(
|
||||||
|
"The result:\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ".iterdir())
|
||||||
|
assert len(staged) == 1
|
||||||
|
assert staged[0].read_bytes() == _PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_markdown_image_rejects_workspace_escape(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
outside = tmp_path / "outside.png"
|
||||||
|
outside.write_bytes(_PNG_BYTES)
|
||||||
|
media = tmp_path / "media"
|
||||||
|
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||||
|
assert channel._rewrite_local_markdown_images(text) == text
|
||||||
|
|
||||||
|
assert not (media / "websocket").exists()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /api/media/<sig>/<payload>: the serving handler
|
# /api/media/<sig>/<payload>: the serving handler
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ 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.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
def _write_cache(path: Path, registry: dict) -> None:
|
def _write_cache(path: Path, registry: dict) -> None:
|
||||||
@@ -146,6 +147,15 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
|
|||||||
assert apps["jimeng"]["install_supported"] is False
|
assert apps["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"
|
||||||
@@ -156,6 +166,33 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
|
||||||
|
_write_cache(
|
||||||
|
manager._cache_path("public"),
|
||||||
|
{
|
||||||
|
"meta": {"updated": "2026-04-18"},
|
||||||
|
"clis": [
|
||||||
|
{
|
||||||
|
"name": "anygen",
|
||||||
|
"display_name": "AnyGen",
|
||||||
|
"description": "Generate docs, slides, websites and more via AnyGen cloud API",
|
||||||
|
"category": "generation",
|
||||||
|
"install_cmd": "pip install cli-anything-anygen",
|
||||||
|
"entry_point": "cli-anything-anygen",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = manager.payload()
|
||||||
|
|
||||||
|
app = payload["apps"][0]
|
||||||
|
assert app["name"] == "anygen"
|
||||||
|
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
|
||||||
|
|
||||||
|
|
||||||
def test_install_dispatches_safe_pip_and_installs_skill(
|
def test_install_dispatches_safe_pip_and_installs_skill(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -179,6 +216,8 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
|
|
||||||
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
|
assert 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"
|
||||||
@@ -186,6 +225,49 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
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)
|
||||||
|
|
||||||
@@ -206,7 +288,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.cli_apps.service.httpx.get", fail_get)
|
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
|
||||||
|
|
||||||
assert manager._fetch_skill_content({
|
assert manager._fetch_skill_content({
|
||||||
"name": "evil",
|
"name": "evil",
|
||||||
@@ -236,7 +318,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.cli_apps.service.httpx.get", fake_get)
|
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get)
|
||||||
|
|
||||||
content = manager._fetch_skill_content({
|
content = manager._fetch_skill_content({
|
||||||
"name": "gimp",
|
"name": "gimp",
|
||||||
@@ -293,6 +375,91 @@ 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(
|
||||||
@@ -341,7 +508,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.cli_apps.service.shutil.which",
|
"nanobot.apps.cli.service.shutil.which",
|
||||||
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -354,7 +521,7 @@ def test_run_installed_cli_uses_argv_without_shell(
|
|||||||
stderr="",
|
stderr="",
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
|
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
||||||
manager._save_installed(
|
manager._save_installed(
|
||||||
{
|
{
|
||||||
"gimp": {
|
"gimp": {
|
||||||
@@ -372,6 +539,33 @@ def test_run_installed_cli_uses_argv_without_shell(
|
|||||||
assert "['--json', 'project', 'list']" in result
|
assert "['--json', 'project', 'list']" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_reports_created_artifacts(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_seed_catalog(manager)
|
||||||
|
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.apps.cli.service.shutil.which",
|
||||||
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||||
|
cwd = Path(str(kwargs["cwd"]))
|
||||||
|
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
|
||||||
|
return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="")
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
||||||
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||||
|
|
||||||
|
result = manager.run("gimp", ["render"])
|
||||||
|
|
||||||
|
assert "Artifacts created or updated:" in result
|
||||||
|
assert "diagram.png (previewable image" in result
|
||||||
|
assert "" in result
|
||||||
|
|
||||||
|
|
||||||
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
|
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
|
|||||||
@@ -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.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli.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.cli_apps.service.shutil.which",
|
"nanobot.apps.cli.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.cli_apps.service.subprocess.run", fake_run)
|
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
||||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
|
|
||||||
tool = CliAppsTool(
|
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.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
|
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.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
|
|
||||||
tool = CliAppsTool(workspace=workspace)
|
tool = CliAppsTool(workspace=workspace)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from nanobot.cli_apps.service import CliAppManager
|
from nanobot.apps.cli.service import CliAppManager
|
||||||
from nanobot.cli_apps.utils import runtime_lines, session_extra
|
from nanobot.apps.cli.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.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
manager = CliAppManager(workspace=tmp_path)
|
manager = CliAppManager(workspace=tmp_path)
|
||||||
manager._save_installed(
|
manager._save_installed(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,6 +113,52 @@ async def test_dream_restore_lists_versions_with_next_steps() -> None:
|
|||||||
assert "Restore a version with `/dream-restore <sha>`." in out.content
|
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,3 +1,5 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +14,40 @@ def test_resolve_preset_returns_defaults_when_no_preset() -> None:
|
|||||||
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
|
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,6 +9,7 @@ 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
|
||||||
@@ -185,6 +186,86 @@ 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,6 +20,7 @@ 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,6 +18,7 @@ from nanobot.providers.image_generation import (
|
|||||||
OpenAIImageGenerationClient,
|
OpenAIImageGenerationClient,
|
||||||
OpenRouterImageGenerationClient,
|
OpenRouterImageGenerationClient,
|
||||||
StepFunImageGenerationClient,
|
StepFunImageGenerationClient,
|
||||||
|
ZhipuImageGenerationClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
PNG_BYTES = (
|
PNG_BYTES = (
|
||||||
@@ -1027,3 +1028,102 @@ 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,6 +602,7 @@ 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"}
|
||||||
|
|
||||||
@@ -994,7 +995,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_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
|
def test_openai_compat_preserves_tool_call_ids_after_consecutive_assistant_messages() -> None:
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = OpenAICompatProvider()
|
provider = OpenAICompatProvider()
|
||||||
|
|
||||||
@@ -1016,6 +1017,34 @@ def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -
|
|||||||
{"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,6 +18,7 @@ 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
|
||||||
@@ -27,6 +28,33 @@ 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,7 +8,11 @@ from unittest.mock import AsyncMock, patch
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.providers.transcription import GroqTranscriptionProvider, OpenAITranscriptionProvider
|
from nanobot.providers.transcription import (
|
||||||
|
GroqTranscriptionProvider,
|
||||||
|
OpenAITranscriptionProvider,
|
||||||
|
_resolve_transcription_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -290,3 +294,37 @@ 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,6 +64,7 @@ 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}"
|
||||||
@@ -176,6 +177,7 @@ 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}"
|
||||||
@@ -208,6 +210,7 @@ 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,6 +171,23 @@ 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,10 +52,17 @@ def _fake_mcp_module(
|
|||||||
)
|
)
|
||||||
|
|
||||||
class _FakeStdioServerParameters:
|
class _FakeStdioServerParameters:
|
||||||
def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
args: list[str],
|
||||||
|
env: dict | None = None,
|
||||||
|
cwd: str | None = None,
|
||||||
|
) -> None:
|
||||||
self.command = command
|
self.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:
|
||||||
@@ -561,6 +568,32 @@ 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,6 +4,7 @@ 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,
|
||||||
@@ -16,7 +17,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
|
from nanobot.agent.tools.shell import ExecTool, ExecToolConfig
|
||||||
from nanobot.security.network import configure_ssrf_whitelist
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
|
|
||||||
@@ -663,6 +664,26 @@ async def test_exec_timeout_capped_at_max() -> None:
|
|||||||
assert "Exit code: 0" in result
|
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 ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-213
@@ -1,216 +1,13 @@
|
|||||||
"""Tests for GitStore — line_ages() and core git operations."""
|
from nanobot.utils.gitstore import CommitInfo
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.utils.gitstore import GitStore
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
class TestCommitInfo:
|
||||||
def git(tmp_path):
|
def test_format_with_empty_message(self):
|
||||||
"""Create an initialized GitStore with tracked MEMORY.md."""
|
commit = CommitInfo(sha="abcd1234", message="", timestamp="2026-04-04 12:00")
|
||||||
g = GitStore(tmp_path, tracked_files=["MEMORY.md", "SOUL.md"])
|
result = commit.format()
|
||||||
g.init()
|
assert "(no message)" in result or "## " in result
|
||||||
return g
|
|
||||||
|
|
||||||
|
def test_format_with_message(self):
|
||||||
class TestLineAges:
|
commit = CommitInfo(sha="abcd1234", message="dream: update", timestamp="2026-04-04 12:00")
|
||||||
def test_returns_empty_when_not_initialized(self, tmp_path):
|
result = commit.format()
|
||||||
"""line_ages should return [] if the git repo is not initialized."""
|
assert "dream: update" in result
|
||||||
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()
|
|
||||||
|
|||||||
@@ -42,6 +42,30 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
|||||||
assert msgs[1]["latencyMs"] == 42
|
assert msgs[1]["latencyMs"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_augments_assistant_text() -> None:
|
||||||
|
msgs = replay_transcript_to_ui_messages(
|
||||||
|
[
|
||||||
|
{"event": "user", "chat_id": "t-img", "text": "draw"},
|
||||||
|
{"event": "delta", "chat_id": "t-img", "text": ""},
|
||||||
|
{"event": "stream_end", "chat_id": "t-img"},
|
||||||
|
],
|
||||||
|
augment_assistant_text=lambda text: text.replace("diagram.png", "/api/media/sig/payload"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert msgs[1]["content"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_uses_stream_end_final_text() -> None:
|
||||||
|
msgs = replay_transcript_to_ui_messages(
|
||||||
|
[
|
||||||
|
{"event": "user", "chat_id": "t-img", "text": "draw"},
|
||||||
|
{"event": "stream_end", "chat_id": "t-img", "text": ""},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert msgs[1]["content"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
|
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
key = "websocket:t-file"
|
key = "websocket:t-file"
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.config.loader import load_config
|
||||||
|
from nanobot.webui.mcp_presets_api import (
|
||||||
|
McpPresetError,
|
||||||
|
custom_mcp_action,
|
||||||
|
mcp_presets_action,
|
||||||
|
mcp_presets_payload,
|
||||||
|
mcp_presets_test_action,
|
||||||
|
normalize_mcp_preset_mentions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
payload = mcp_presets_payload()
|
||||||
|
names = {preset["name"] for preset in payload["presets"]}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
"browserbase",
|
||||||
|
"playwright",
|
||||||
|
"github",
|
||||||
|
"figma",
|
||||||
|
"context7",
|
||||||
|
"firecrawl",
|
||||||
|
"exa",
|
||||||
|
"microsoft-learn",
|
||||||
|
"aws-docs",
|
||||||
|
"brave-search",
|
||||||
|
"postman",
|
||||||
|
}.issubset(names)
|
||||||
|
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
||||||
|
assert browserbase["installed"] is False
|
||||||
|
assert browserbase["install_supported"] is True
|
||||||
|
assert browserbase["required_fields"][0]["configured"] is False
|
||||||
|
assert "browserbaseApiKey" not in browserbase["connection_summary"]
|
||||||
|
manifest = browserbase["manifest"]
|
||||||
|
assert manifest["schema"] == "agent-app.v1"
|
||||||
|
assert manifest["id"] == "browserbase"
|
||||||
|
assert manifest["source"] == "mcp-preset"
|
||||||
|
assert manifest["capabilities"][0]["type"] == "mcp"
|
||||||
|
assert manifest["capabilities"][0]["transport"] == "streamableHttp"
|
||||||
|
assert manifest["install"]["strategy"] == "config"
|
||||||
|
assert manifest["remove"]["verification"] == ["config_absent"]
|
||||||
|
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
payload = mcp_presets_action(
|
||||||
|
"enable",
|
||||||
|
{
|
||||||
|
"name": ["browserbase"],
|
||||||
|
"browserbase_api_key": ["bb_live_secret"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["requires_restart"] is True
|
||||||
|
assert payload["last_action"]["ok"] is True
|
||||||
|
assert payload["last_action"]["installed"] is True
|
||||||
|
assert payload["last_action"]["verification"] == ["config_present"]
|
||||||
|
preset = next(row for row in payload["presets"] if row["name"] == "browserbase")
|
||||||
|
assert preset["installed"] is True
|
||||||
|
assert preset["configured"] is True
|
||||||
|
assert "bb_live_secret" not in str(payload)
|
||||||
|
config = load_config()
|
||||||
|
assert "browserbaseApiKey=bb_live_secret" in config.tools.mcp_servers["browserbase"].url
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_requires_missing_secret(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
with pytest.raises(McpPresetError) as exc:
|
||||||
|
mcp_presets_action("enable", {"name": ["browserbase"]})
|
||||||
|
|
||||||
|
assert exc.value.status == 400
|
||||||
|
assert "Browserbase API key" in exc.value.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_context7_optional_api_key_appends_arg(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
payload = mcp_presets_action(
|
||||||
|
"enable",
|
||||||
|
{
|
||||||
|
"name": ["context7"],
|
||||||
|
"context7_api_key": ["ctx7_secret"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "ctx7_secret" not in str(payload)
|
||||||
|
row = next(item for item in payload["presets"] if item["name"] == "context7")
|
||||||
|
assert row["configured"] is True
|
||||||
|
config = load_config()
|
||||||
|
assert config.tools.mcp_servers["context7"].args == [
|
||||||
|
"-y",
|
||||||
|
"@upstash/context7-mcp@latest",
|
||||||
|
"--api-key",
|
||||||
|
"ctx7_secret",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_stdio_preset_uses_config_scoped_cwd(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
cwd = config.tools.mcp_servers["playwright"].cwd
|
||||||
|
assert cwd == str(tmp_path / "mcp" / "playwright")
|
||||||
|
assert (tmp_path / "mcp" / "playwright").is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_no_auth_remote_presets_write_url(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
mcp_presets_action("enable", {"name": ["microsoft-learn"]})
|
||||||
|
mcp_presets_action("enable", {"name": ["exa"]})
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
assert config.tools.mcp_servers["microsoft-learn"].url == "https://learn.microsoft.com/api/mcp"
|
||||||
|
assert config.tools.mcp_servers["exa"].url == "https://mcp.exa.ai/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_firecrawl_writes_scrubbed_env(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
payload = mcp_presets_action(
|
||||||
|
"enable",
|
||||||
|
{
|
||||||
|
"name": ["firecrawl"],
|
||||||
|
"firecrawl_api_key": ["fc-secret"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "fc-secret" not in str(payload)
|
||||||
|
config = load_config()
|
||||||
|
assert config.tools.mcp_servers["firecrawl"].env["FIRECRAWL_API_KEY"] == "fc-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_mcp_preset_updates_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||||
|
managed_cwd = tmp_path / "mcp" / "playwright"
|
||||||
|
(managed_cwd / "cache.txt").write_text("managed runtime data", encoding="utf-8")
|
||||||
|
|
||||||
|
payload = mcp_presets_action("remove", {"name": ["playwright"]})
|
||||||
|
|
||||||
|
assert payload["requires_restart"] is True
|
||||||
|
assert payload["last_action"]["ok"] is True
|
||||||
|
assert payload["last_action"]["removed"] is True
|
||||||
|
assert payload["last_action"]["managed_paths_removed"] == ["runtime:mcp/playwright"]
|
||||||
|
assert not managed_cwd.exists()
|
||||||
|
config = load_config()
|
||||||
|
assert "playwright" not in config.tools.mcp_servers
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_custom_mcp_server_preserves_user_cwd(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
user_cwd = tmp_path / "user-cwd"
|
||||||
|
user_cwd.mkdir()
|
||||||
|
custom_mcp_action(
|
||||||
|
"custom",
|
||||||
|
{
|
||||||
|
"name": ["internal-docs"],
|
||||||
|
"transport": ["stdio"],
|
||||||
|
"command": ["node"],
|
||||||
|
"args": ['["server.js"]'],
|
||||||
|
"cwd": [str(user_cwd)],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = mcp_presets_action("remove", {"name": ["internal-docs"]})
|
||||||
|
|
||||||
|
assert payload["last_action"]["ok"] is True
|
||||||
|
assert user_cwd.exists()
|
||||||
|
config = load_config()
|
||||||
|
assert "internal-docs" not in config.tools.mcp_servers
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_mcp_preset_reports_missing_dependency(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||||
|
monkeypatch.setattr("nanobot.webui.mcp_presets_api.shutil.which", lambda _command: None)
|
||||||
|
|
||||||
|
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
|
||||||
|
|
||||||
|
assert payload["last_action"]["ok"] is False
|
||||||
|
assert "npx" in payload["last_action"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_mcp_preset_connects_and_reports_tools(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||||
|
|
||||||
|
class FakeStack:
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_connect(servers, registry):
|
||||||
|
assert list(servers) == ["playwright"]
|
||||||
|
|
||||||
|
class FakeTool:
|
||||||
|
name = "mcp_playwright_browser_navigate"
|
||||||
|
|
||||||
|
def to_schema(self):
|
||||||
|
return {"name": self.name, "description": "", "parameters": {}}
|
||||||
|
|
||||||
|
registry.register(FakeTool())
|
||||||
|
return {"playwright": FakeStack()}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
|
||||||
|
|
||||||
|
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
|
||||||
|
|
||||||
|
assert payload["last_action"]["ok"] is True
|
||||||
|
assert payload["last_action"]["tool_count"] == 1
|
||||||
|
assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_mcp_preset_scrubs_connection_errors(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
mcp_presets_action(
|
||||||
|
"enable",
|
||||||
|
{
|
||||||
|
"name": ["browserbase"],
|
||||||
|
"browserbase_api_key": ["bb_live_secret"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_connect(_servers, _registry):
|
||||||
|
raise RuntimeError("failed https://mcp.browserbase.com/mcp?browserbaseApiKey=bb_live_secret")
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
|
||||||
|
|
||||||
|
payload = asyncio.run(mcp_presets_test_action({"name": ["browserbase"]}))
|
||||||
|
|
||||||
|
assert payload["last_action"]["ok"] is False
|
||||||
|
assert "bb_live_secret" not in str(payload)
|
||||||
|
assert "<redacted>" in payload["last_action"]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
with pytest.raises(McpPresetError) as exc:
|
||||||
|
mcp_presets_action("enable", {"name": ["linear"]})
|
||||||
|
|
||||||
|
assert exc.value.status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_mcp_preset_mentions_keeps_known_presets_only() -> None:
|
||||||
|
payload = normalize_mcp_preset_mentions([
|
||||||
|
{
|
||||||
|
"name": "browserbase",
|
||||||
|
"display_name": "Browserbase",
|
||||||
|
"transport": "streamableHttp",
|
||||||
|
"configured": True,
|
||||||
|
"logo_url": "https://example.invalid/logo.svg",
|
||||||
|
},
|
||||||
|
{"name": "totally-unknown"},
|
||||||
|
"bad",
|
||||||
|
])
|
||||||
|
|
||||||
|
assert payload == [{
|
||||||
|
"name": "browserbase",
|
||||||
|
"display_name": "Browserbase",
|
||||||
|
"transport": "streamableHttp",
|
||||||
|
"configured": True,
|
||||||
|
"logo_url": "https://example.invalid/logo.svg",
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_mcp_server_writes_config_and_catalog_row(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
payload = custom_mcp_action(
|
||||||
|
"custom",
|
||||||
|
{
|
||||||
|
"name": ["internal-docs"],
|
||||||
|
"transport": ["stdio"],
|
||||||
|
"command": ["node"],
|
||||||
|
"args": ['["server.js"]'],
|
||||||
|
"env": ['{"DOCS_TOKEN":"docs-secret-value"}'],
|
||||||
|
"tool_timeout": ["45"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["requires_restart"] is True
|
||||||
|
row = next(item for item in payload["presets"] if item["name"] == "internal-docs")
|
||||||
|
assert row["source"] == "custom"
|
||||||
|
assert row["transport"] == "stdio"
|
||||||
|
assert row["connection_summary"] == "node server.js"
|
||||||
|
assert row["manifest"]["schema"] == "agent-app.v1"
|
||||||
|
assert row["manifest"]["source"] == "mcp-custom"
|
||||||
|
assert row["manifest"]["capabilities"][0]["command"] == "node"
|
||||||
|
assert "server.js" not in str(row["manifest"])
|
||||||
|
assert "docs-secret-value" not in str(payload)
|
||||||
|
config = load_config()
|
||||||
|
assert config.tools.mcp_servers["internal-docs"].args == ["server.js"]
|
||||||
|
assert config.tools.mcp_servers["internal-docs"].env["DOCS_TOKEN"] == "docs-secret-value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_mcp_config_and_tool_allowlist(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
payload = custom_mcp_action(
|
||||||
|
"import",
|
||||||
|
{
|
||||||
|
"config": [
|
||||||
|
(
|
||||||
|
'{"mcpServers":{'
|
||||||
|
'"docs":{"command":"npx","args":["-y","docs-mcp"],"env":{"API_KEY":"config-secret-value"}},'
|
||||||
|
'"remote-docs":{"transport":"sse","url":"https://example.com/sse"}'
|
||||||
|
'}}'
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["last_action"]["message"] == "Imported 2 MCP server(s)."
|
||||||
|
config = load_config()
|
||||||
|
assert config.tools.mcp_servers["docs"].command == "npx"
|
||||||
|
assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"]
|
||||||
|
assert config.tools.mcp_servers["remote-docs"].type == "sse"
|
||||||
|
assert config.tools.mcp_servers["remote-docs"].url == "https://example.com/sse"
|
||||||
|
assert config.tools.mcp_servers["docs"].env["API_KEY"] == "config-secret-value"
|
||||||
|
assert "config-secret-value" not in str(payload)
|
||||||
|
|
||||||
|
payload = custom_mcp_action(
|
||||||
|
"tools",
|
||||||
|
{
|
||||||
|
"name": ["docs"],
|
||||||
|
"enabled_tools": ['["mcp_docs_search"]'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
||||||
|
assert row["enabled_tools"] == ["mcp_docs_search"]
|
||||||
|
assert load_config().tools.mcp_servers["docs"].enabled_tools == ["mcp_docs_search"]
|
||||||
|
|
||||||
|
payload = custom_mcp_action(
|
||||||
|
"tools",
|
||||||
|
{
|
||||||
|
"name": ["docs"],
|
||||||
|
"enabled_tools": ["[]"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
||||||
|
assert row["enabled_tools"] == []
|
||||||
|
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
custom_mcp_action(
|
||||||
|
"custom",
|
||||||
|
{
|
||||||
|
"name": ["docs"],
|
||||||
|
"transport": ["streamableHttp"],
|
||||||
|
"url": ["https://example.com/mcp"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = normalize_mcp_preset_mentions([
|
||||||
|
{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"},
|
||||||
|
])
|
||||||
|
|
||||||
|
assert payload == [{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"}]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from nanobot.webui import mcp_presets_runtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_preset_runtime_lines_describe_tool_prefix() -> None:
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content="use @browserbase",
|
||||||
|
metadata={
|
||||||
|
"mcp_presets": [{
|
||||||
|
"name": "browserbase",
|
||||||
|
"display_name": "Browserbase",
|
||||||
|
"transport": "streamableHttp",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = mcp_presets_runtime.runtime_lines(
|
||||||
|
msg,
|
||||||
|
configured_server_names={"browserbase"},
|
||||||
|
connected_server_names={"browserbase"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lines
|
||||||
|
assert "@browserbase" in lines[0]
|
||||||
|
assert "mcp_browserbase_" in lines[0]
|
||||||
|
assert "shell commands" in lines[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_preset_runtime_lines_warn_when_restart_needed() -> None:
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content="use @browserbase",
|
||||||
|
metadata={
|
||||||
|
"mcp_presets": [{
|
||||||
|
"name": "browserbase",
|
||||||
|
"display_name": "Browserbase",
|
||||||
|
"transport": "streamableHttp",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = mcp_presets_runtime.runtime_lines(
|
||||||
|
msg,
|
||||||
|
configured_server_names=set(),
|
||||||
|
connected_server_names=set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lines
|
||||||
|
assert "has not loaded the latest MCP settings" in lines[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_preset_runtime_lines_warn_when_connection_not_live() -> None:
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content="use @browserbase",
|
||||||
|
metadata={
|
||||||
|
"mcp_presets": [{
|
||||||
|
"name": "browserbase",
|
||||||
|
"display_name": "Browserbase",
|
||||||
|
"transport": "streamableHttp",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = mcp_presets_runtime.runtime_lines(
|
||||||
|
msg,
|
||||||
|
configured_server_names={"browserbase"},
|
||||||
|
connected_server_names=set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lines
|
||||||
|
assert "connection is not currently live" in lines[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_preset_session_extra_only_persists_structured_mentions() -> None:
|
||||||
|
assert mcp_presets_runtime.session_extra({}) == {}
|
||||||
|
assert mcp_presets_runtime.session_extra({
|
||||||
|
"mcp_presets": [{"name": "browserbase"}],
|
||||||
|
}) == {"mcp_presets": [{"name": "browserbase"}]}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.config.loader import load_config, save_config
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.webui.settings_api import WebUISettingsError, create_model_configuration
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_model_configuration_writes_label_and_selects(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.model = "openai/gpt-4o"
|
||||||
|
config.agents.defaults.provider = "openai"
|
||||||
|
config.providers.openai.api_key = "sk-test"
|
||||||
|
save_config(config, config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
payload = create_model_configuration(
|
||||||
|
{
|
||||||
|
"label": ["Fast writing"],
|
||||||
|
"provider": ["openai"],
|
||||||
|
"model": ["openai/gpt-4.1-mini"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["agent"]["model_preset"] == "fast-writing"
|
||||||
|
assert payload["agent"]["model"] == "openai/gpt-4.1-mini"
|
||||||
|
rows = {row["name"]: row for row in payload["model_presets"]}
|
||||||
|
assert rows["fast-writing"]["label"] == "Fast writing"
|
||||||
|
|
||||||
|
saved = load_config(config_path)
|
||||||
|
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||||
|
assert saved.model_presets["fast-writing"].label == "Fast writing"
|
||||||
|
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
||||||
|
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||||
|
|
||||||
|
with pytest.raises(WebUISettingsError) as duplicate:
|
||||||
|
create_model_configuration(
|
||||||
|
{
|
||||||
|
"label": ["Fast writing"],
|
||||||
|
"provider": ["openai"],
|
||||||
|
"model": ["openai/gpt-4.1-mini"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert duplicate.value.status == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_model_configuration_rejects_unconfigured_provider(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(Config(), config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
with pytest.raises(WebUISettingsError, match="provider is not configured"):
|
||||||
|
create_model_configuration(
|
||||||
|
{
|
||||||
|
"label": ["Deep"],
|
||||||
|
"provider": ["openai"],
|
||||||
|
"model": ["openai/gpt-4.1"],
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -6,10 +6,8 @@
|
|||||||
"name": "nanobot-webui",
|
"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",
|
||||||
@@ -165,16 +163,12 @@
|
|||||||
|
|
||||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
"@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=="],
|
||||||
@@ -207,8 +201,6 @@
|
|||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
"@radix-ui/react-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=="],
|
||||||
@@ -223,8 +215,6 @@
|
|||||||
|
|
||||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
"@radix-ui/react-use-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=="],
|
||||||
@@ -891,10 +881,6 @@
|
|||||||
|
|
||||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-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,10 +13,8 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
+24
-5
@@ -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 } from "@/components/settings/SettingsView";
|
import { SettingsView, type SettingsSectionKey } 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";
|
type ShellView = "chat" | "settings" | "apps";
|
||||||
|
|
||||||
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,6 +325,7 @@ 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);
|
||||||
@@ -588,12 +589,20 @@ function Shell({
|
|||||||
[onSelectChat],
|
[onSelectChat],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onOpenSettings = useCallback(() => {
|
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
|
||||||
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);
|
||||||
@@ -711,6 +720,12 @@ 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");
|
||||||
@@ -728,7 +743,9 @@ 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,
|
||||||
@@ -805,7 +822,7 @@ function Shell({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 flex flex-col",
|
"absolute inset-0 flex flex-col",
|
||||||
view === "settings" && "invisible pointer-events-none",
|
view !== "chat" && "invisible pointer-events-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ThreadShell
|
<ThreadShell
|
||||||
@@ -820,10 +837,12 @@ function Shell({
|
|||||||
hideSidebarToggleOnDesktop
|
hideSidebarToggleOnDesktop
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view === "settings" && (
|
{view !== "chat" && (
|
||||||
<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,12 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { CliAppInfo } from "@/lib/types";
|
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||||
|
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
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 (
|
||||||
@@ -19,6 +24,18 @@ export function cliAppInitials(app: CliAppInfo): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_name">): string {
|
||||||
|
const value = preset.display_name || preset.name;
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase())
|
||||||
|
.join("") || preset.name.slice(0, 2).toUpperCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function splitCliAppMentionSegments(
|
export function splitCliAppMentionSegments(
|
||||||
value: string,
|
value: string,
|
||||||
cliApps: CliAppInfo[],
|
cliApps: CliAppInfo[],
|
||||||
@@ -55,22 +72,76 @@ 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 = splitCliAppMentionSegments(text, cliApps);
|
const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets);
|
||||||
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
|
if (!segments.some((segment) => segment.kind === "cli" || segment.kind === "mcp")) 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>;
|
||||||
}
|
}
|
||||||
return (
|
if (segment.kind === "cli") return (
|
||||||
<CliAppMentionToken
|
<CliAppMentionToken
|
||||||
key={`cli-${segment.app.name}-${index}`}
|
key={`cli-${segment.app.name}-${index}`}
|
||||||
app={segment.app}
|
app={segment.app}
|
||||||
@@ -78,6 +149,14 @@ export function CliAppMentionText({
|
|||||||
variant="message"
|
variant="message"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
|
<McpPresetMentionToken
|
||||||
|
key={`mcp-${segment.preset.name}-${index}`}
|
||||||
|
preset={segment.preset}
|
||||||
|
label={segment.text}
|
||||||
|
variant="message"
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -94,15 +173,20 @@ export function CliAppMentionToken({
|
|||||||
variant: "composer" | "message";
|
variant: "composer" | "message";
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [logoIndex, setLogoIndex] = useState(0);
|
||||||
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 showLogo = Boolean(app.logo_url) && !failed;
|
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
|
||||||
|
const logoUrl = logoUrls[logoIndex];
|
||||||
|
const showLogo = Boolean(logoUrl);
|
||||||
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
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,
|
||||||
@@ -124,10 +208,69 @@ export function CliAppMentionToken({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={app.logo_url ?? ""}
|
src={logoUrl ?? ""}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
onError={() => setFailed(true)}
|
onError={() => setLogoIndex((index) => index + 1)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{mentionName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpPresetMentionToken({
|
||||||
|
preset,
|
||||||
|
label,
|
||||||
|
variant,
|
||||||
|
isHero = false,
|
||||||
|
}: {
|
||||||
|
preset: McpPresetInfo;
|
||||||
|
label: string;
|
||||||
|
variant: "composer" | "message";
|
||||||
|
isHero?: boolean;
|
||||||
|
}) {
|
||||||
|
const [logoIndex, setLogoIndex] = useState(0);
|
||||||
|
const color = preset.brand_color || "hsl(var(--primary))";
|
||||||
|
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||||
|
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
||||||
|
const logoUrl = logoUrls[logoIndex];
|
||||||
|
const showLogo = Boolean(logoUrl);
|
||||||
|
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||||
|
|
||||||
|
useEffect(() => setLogoIndex(0), [preset.logo_url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid={`${testIdPrefix}-mcp-mention-${preset.name}`}
|
||||||
|
title={`MCP server: ${preset.display_name || preset.name}`}
|
||||||
|
className="relative inline transition-[color,text-shadow] duration-150"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn("relative inline-block", showLogo && "text-transparent")}
|
||||||
|
style={{ lineHeight: "inherit" }}
|
||||||
|
>
|
||||||
|
@
|
||||||
|
{showLogo ? (
|
||||||
|
<span
|
||||||
|
data-testid={`${testIdPrefix}-mcp-mention-logo-${preset.name}`}
|
||||||
|
className={cn(
|
||||||
|
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
|
||||||
|
"-translate-x-1/2 -translate-y-1/2",
|
||||||
|
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={logoUrl ?? ""}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
onError={() => setLogoIndex((index) => index + 1)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { ArrowUp } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ComposerProps {
|
|
||||||
onSend: (content: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
/** Visually collapse the outer padding when embedded inside a welcome screen. */
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rounded, shadowed composer with an embedded send button — modeled after the
|
|
||||||
* agent-chat-ui input: a single surface that looks like one interactive unit
|
|
||||||
* rather than a textarea + button pair.
|
|
||||||
*/
|
|
||||||
export function Composer({
|
|
||||||
onSend,
|
|
||||||
disabled,
|
|
||||||
placeholder = "Type your message…",
|
|
||||||
compact = false,
|
|
||||||
}: ComposerProps) {
|
|
||||||
const [value, setValue] = useState("");
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
// Autofocus on mount — coming back to a chat, switching sessions, or
|
|
||||||
// opening the welcome screen should always land the caret in the box.
|
|
||||||
useEffect(() => {
|
|
||||||
if (disabled) return;
|
|
||||||
const el = textareaRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
// Defer so layout settles first (important during enter animations).
|
|
||||||
const id = requestAnimationFrame(() => el.focus());
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, [disabled]);
|
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed || disabled) return;
|
|
||||||
onSend(trimmed);
|
|
||||||
setValue("");
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = textareaRef.current;
|
|
||||||
if (el) {
|
|
||||||
el.style.height = "auto";
|
|
||||||
el.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [disabled, onSend, value]);
|
|
||||||
|
|
||||||
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
||||||
e.preventDefault();
|
|
||||||
submit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
|
|
||||||
const el = e.currentTarget;
|
|
||||||
el.style.height = "auto";
|
|
||||||
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
submit();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"w-full",
|
|
||||||
compact ? "px-0" : "bg-background/95 px-4 pb-4 pt-2 backdrop-blur",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative mx-auto flex w-full max-w-[64rem] flex-col overflow-hidden rounded-3xl",
|
|
||||||
"border bg-muted/60 shadow-sm transition-all duration-200",
|
|
||||||
"focus-within:bg-muted focus-within:shadow-md focus-within:ring-1 focus-within:ring-foreground/10",
|
|
||||||
disabled && "opacity-60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onInput={onInput}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
rows={1}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label="Message input"
|
|
||||||
className={cn(
|
|
||||||
"min-h-[56px] w-full resize-none bg-transparent px-5 pt-4 pb-2 text-sm",
|
|
||||||
"placeholder:text-muted-foreground",
|
|
||||||
"focus:outline-none focus-visible:outline-none",
|
|
||||||
"disabled:cursor-not-allowed",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between gap-2 px-3 pb-2">
|
|
||||||
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline">
|
|
||||||
Enter to send · Shift+Enter for newline
|
|
||||||
</span>
|
|
||||||
<span className="sm:hidden" aria-hidden />
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="icon"
|
|
||||||
disabled={disabled || !value.trim()}
|
|
||||||
aria-label="Send message"
|
|
||||||
className={cn(
|
|
||||||
"h-9 w-9 rounded-full shadow-sm transition-transform",
|
|
||||||
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ArrowUp className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { MessageSquarePlus } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export function EmptyState({
|
|
||||||
onNewChat,
|
|
||||||
}: {
|
|
||||||
onNewChat: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
|
|
||||||
<MessageSquarePlus
|
|
||||||
className="h-10 w-10 text-muted-foreground"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-lg font-medium">No chats yet</p>
|
|
||||||
<p className="max-w-sm text-sm text-muted-foreground">
|
|
||||||
Start a conversation — your sessions are stored locally on the nanobot
|
|
||||||
workspace and stay available across reloads.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={onNewChat}>New chat</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,7 @@ export function FileReferenceChip({
|
|||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
aria-label={fullPath}
|
aria-label={fullPath}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex max-w-full items-center gap-1 font-medium leading-[inherit]",
|
"inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]",
|
||||||
"text-sky-600 transition-colors hover:text-sky-700",
|
"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.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] text-sky-500 dark:text-sky-300"
|
||||||
viewBox="0 0 24 24"
|
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.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] text-sky-500 dark:text-sky-300"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -195,16 +195,36 @@ function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
|||||||
}
|
}
|
||||||
const label = fileKindLabel(kind);
|
const label = fileKindLabel(kind);
|
||||||
return (
|
return (
|
||||||
<span
|
<svg
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className="h-[0.96em] w-[0.96em] shrink-0 translate-y-[0.12em] text-sky-500 dark:text-sky-300"
|
||||||
"inline-flex h-[1.05em] min-w-[1.05em] shrink-0 items-center justify-center",
|
viewBox="0 0 24 24"
|
||||||
"rounded-[4px] bg-sky-500/12 px-[0.22em] text-[0.58em] font-bold uppercase leading-none",
|
fill="none"
|
||||||
"text-sky-600 dark:bg-sky-400/15 dark:text-sky-300",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{label}
|
<path
|
||||||
</span>
|
d="M7 3.5h6.6L18 7.9V19a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 19V5a1.5 1.5 0 0 1 1.5-1.5Z"
|
||||||
|
fill="currentColor"
|
||||||
|
opacity="0.12"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M13.5 3.75V8h4.25M7 3.5h6.6L18 7.9V19a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 19V5a1.5 1.5 0 0 1 1.5-1.5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="12"
|
||||||
|
y="15.7"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="currentColor"
|
||||||
|
fontSize={label.length > 1 ? "5.8" : "7.2"}
|
||||||
|
fontWeight="800"
|
||||||
|
letterSpacing="-0.2"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,46 @@ export default function MarkdownTextRenderer({
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
img({ src, alt, node: _node, className: imgClassName, ...props }) {
|
||||||
|
void _node;
|
||||||
|
const source = typeof src === "string" ? src : "";
|
||||||
|
if (!source) return null;
|
||||||
|
const label = typeof alt === "string" ? alt : "";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
|
||||||
|
"border border-border/70 bg-background shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={source}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="block bg-muted/20"
|
||||||
|
aria-label={label ? `Open ${label}` : "Open image"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={source}
|
||||||
|
alt={label}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
className={cn(
|
||||||
|
"block h-auto max-h-[34rem] max-w-full bg-background object-contain",
|
||||||
|
imgClassName,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{label ? (
|
||||||
|
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[highlightCode],
|
[highlightCode],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ 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 { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
|
import type {
|
||||||
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +45,7 @@ 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);
|
||||||
@@ -45,6 +55,10 @@ 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 () => {
|
||||||
@@ -96,7 +110,11 @@ 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 text={message.content} cliApps={mentionCliApps} />
|
<CliAppMentionText
|
||||||
|
text={message.content}
|
||||||
|
cliApps={mentionCliApps}
|
||||||
|
mcpPresets={mentionMcpPresets}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -166,6 +184,39 @@ export function MessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeMcpMentionPresets(
|
||||||
|
presets: McpPresetInfo[],
|
||||||
|
attachments: UIMcpPresetAttachment[] | undefined,
|
||||||
|
): McpPresetInfo[] {
|
||||||
|
if (!attachments?.length) return presets;
|
||||||
|
const byName = new Map(presets.map((preset) => [preset.name.toLowerCase(), preset]));
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const name = attachment.name?.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
const existing = byName.get(name.toLowerCase());
|
||||||
|
byName.set(name.toLowerCase(), {
|
||||||
|
name,
|
||||||
|
display_name: attachment.display_name || existing?.display_name || name,
|
||||||
|
category: attachment.category || existing?.category || "mcp",
|
||||||
|
description: existing?.description || "",
|
||||||
|
docs_url: existing?.docs_url || "",
|
||||||
|
transport: attachment.transport || existing?.transport || "mcp",
|
||||||
|
requires: existing?.requires || "",
|
||||||
|
note: existing?.note || "",
|
||||||
|
install_supported: existing?.install_supported ?? true,
|
||||||
|
installed: true,
|
||||||
|
configured: attachment.configured ?? existing?.configured ?? true,
|
||||||
|
available: existing?.available ?? true,
|
||||||
|
status: attachment.status || existing?.status || "configured",
|
||||||
|
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
|
||||||
|
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
|
||||||
|
required_fields: existing?.required_fields || [],
|
||||||
|
connection_summary: existing?.connection_summary || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from(byName.values());
|
||||||
|
}
|
||||||
|
|
||||||
function mergeCliMentionApps(
|
function mergeCliMentionApps(
|
||||||
cliApps: CliAppInfo[],
|
cliApps: CliAppInfo[],
|
||||||
attachments: UICliAppAttachment[] | undefined,
|
attachments: UICliAppAttachment[] | undefined,
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { ArrowDown } from "lucide-react";
|
|
||||||
|
|
||||||
import { MessageBubble } from "@/components/MessageBubble";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { UIMessage } from "@/lib/types";
|
|
||||||
|
|
||||||
interface MessageListProps {
|
|
||||||
messages: UIMessage[];
|
|
||||||
isStreaming: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scrollable message log. Auto-sticks to the bottom as new content arrives,
|
|
||||||
* but only when the user was already at the bottom — preserving scroll
|
|
||||||
* position when they've scrolled up to read earlier turns. A floating
|
|
||||||
* "scroll to bottom" button appears whenever we're detached from the bottom.
|
|
||||||
*/
|
|
||||||
export function MessageList({ messages, isStreaming }: MessageListProps) {
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = false) => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollTo({
|
|
||||||
top: el.scrollHeight,
|
|
||||||
behavior: smooth ? "smooth" : "auto",
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keep the viewport pinned to the bottom as long as the user hasn't
|
|
||||||
// scrolled up. During streaming we do instant jumps (smooth scrolling each
|
|
||||||
// token fights the incoming animations); on settled updates we animate.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!atBottom) return;
|
|
||||||
scrollToBottom(!isStreaming);
|
|
||||||
}, [messages, isStreaming, atBottom, scrollToBottom]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const onScroll = () => {
|
|
||||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
||||||
setAtBottom(distance < NEAR_BOTTOM_PX);
|
|
||||||
};
|
|
||||||
el.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => el.removeEventListener("scroll", onScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
Say hi to get started.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className={cn(
|
|
||||||
"h-full overflow-y-auto scroll-smooth",
|
|
||||||
"[&::-webkit-scrollbar]:w-1.5",
|
|
||||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
|
||||||
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
|
||||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="mx-auto flex w-full max-w-[64rem] flex-col gap-6 px-4 pt-4 pb-8">
|
|
||||||
{messages.map((m) => (
|
|
||||||
<MessageBubble key={m.id} message={m} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top fade so messages slide under the header gracefully. */}
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
|
||||||
/>
|
|
||||||
{/* Bottom fade so messages fade out behind the composer. */}
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!atBottom && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => scrollToBottom(true)}
|
|
||||||
className={cn(
|
|
||||||
"absolute bottom-2 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
|
||||||
"bg-background/90 backdrop-blur",
|
|
||||||
"animate-in fade-in-0 zoom-in-95",
|
|
||||||
)}
|
|
||||||
aria-label="Scroll to bottom"
|
|
||||||
>
|
|
||||||
<ArrowDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,7 @@ export function SessionSearchDialog({
|
|||||||
}: SessionSearchDialogProps) {
|
}: 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);
|
||||||
|
|
||||||
@@ -46,7 +47,6 @@ 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,6 +65,18 @@ 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);
|
||||||
@@ -107,18 +119,18 @@ export function SessionSearchDialog({
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[min(34rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] gap-0 overflow-hidden p-0",
|
"flex max-h-[min(40rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
|
||||||
"rounded-2xl border border-border/70 bg-popover/95 text-popover-foreground shadow-2xl backdrop-blur-xl",
|
"rounded-[22px] border border-border bg-background text-foreground shadow-[0_22px_70px_rgba(0,0,0,0.22)]",
|
||||||
"sm:rounded-2xl",
|
"dark:border-white/14 dark:bg-[#2b2b2b] dark:shadow-[0_26px_90px_rgba(0,0,0,0.44)] sm:rounded-[22px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
|
<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-14 items-center gap-3 border-b border-border/60 px-5">
|
<div className="flex h-[62px] shrink-0 items-center gap-3 border-b border-border px-[18px]">
|
||||||
<Search
|
<Search
|
||||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -128,16 +140,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-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
|
className="h-full min-w-0 flex-1 bg-transparent text-[19px] font-normal leading-none text-foreground outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<kbd className="hidden h-6 shrink-0 items-center rounded-md border border-border/70 bg-muted/60 px-2 text-[11px] font-medium text-muted-foreground sm:inline-flex">
|
|
||||||
{shortcutLabel}
|
|
||||||
</kbd>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
|
<div
|
||||||
|
data-testid="session-search-scroll"
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2.5 scrollbar-thin scrollbar-track-transparent"
|
||||||
|
>
|
||||||
<section>
|
<section>
|
||||||
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
|
<div className="px-2.5 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground">
|
||||||
{sectionLabel}
|
{sectionLabel}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -150,7 +162,7 @@ export function SessionSearchDialog({
|
|||||||
{emptyLabel}
|
{emptyLabel}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-0.5">
|
||||||
{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() ||
|
||||||
@@ -164,15 +176,18 @@ 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(
|
||||||
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
|
"grid min-h-[54px] w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[11px] px-3 py-2 text-left transition-colors",
|
||||||
highlighted
|
highlighted
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-muted text-foreground"
|
||||||
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
|
: "text-foreground hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
@@ -181,17 +196,17 @@ export function SessionSearchDialog({
|
|||||||
</span>
|
</span>
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className="block truncate text-[12px] leading-4 text-muted-foreground"
|
||||||
"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>
|
||||||
);
|
);
|
||||||
@@ -221,13 +236,3 @@ 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,6 +6,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
|
Blocks,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -41,7 +42,9 @@ 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;
|
||||||
@@ -129,6 +132,13 @@ 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}
|
||||||
@@ -201,12 +211,14 @@ 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 (
|
||||||
@@ -214,14 +226,16 @@ 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,15 +11,17 @@ import {
|
|||||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
import {
|
import {
|
||||||
CliAppMentionToken,
|
CliAppMentionToken,
|
||||||
|
McpPresetMentionToken,
|
||||||
cliAppInitials,
|
cliAppInitials,
|
||||||
splitCliAppMentionSegments,
|
mcpPresetInitials,
|
||||||
type CliAppMentionSegment,
|
splitCapabilityMentionSegments,
|
||||||
|
type CapabilityMentionSegment,
|
||||||
} from "@/components/CliAppMentionText";
|
} from "@/components/CliAppMentionText";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
AtSign,
|
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Brain,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
@@ -48,7 +51,19 @@ 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 { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
|
import type {
|
||||||
|
CliAppInfo,
|
||||||
|
GoalStateWsPayload,
|
||||||
|
McpPresetInfo,
|
||||||
|
OutboundCliAppMention,
|
||||||
|
OutboundMcpPresetMention,
|
||||||
|
SlashCommand,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import {
|
||||||
|
inferProviderFromModelName,
|
||||||
|
logoFallbackUrls,
|
||||||
|
providerBrand,
|
||||||
|
} from "@/lib/provider-brand";
|
||||||
import { cn } from "@/lib/utils";
|
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
|
||||||
@@ -67,9 +82,12 @@ 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;
|
||||||
@@ -82,9 +100,11 @@ 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,
|
||||||
@@ -97,7 +117,9 @@ const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "
|
|||||||
const SLASH_PALETTE_GAP_PX = 8;
|
const SLASH_PALETTE_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 = 64;
|
const SLASH_PALETTE_CHROME_PX = 12;
|
||||||
|
const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents";
|
||||||
|
const SLASH_RECENTS_LIMIT = 5;
|
||||||
|
|
||||||
type SlashPalettePlacement = "above" | "below";
|
type SlashPalettePlacement = "above" | "below";
|
||||||
|
|
||||||
@@ -112,10 +134,45 @@ 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;
|
||||||
@@ -192,6 +249,19 @@ function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMention {
|
||||||
|
return {
|
||||||
|
name: preset.name,
|
||||||
|
display_name: preset.display_name,
|
||||||
|
category: preset.category,
|
||||||
|
transport: preset.transport,
|
||||||
|
status: preset.status,
|
||||||
|
configured: preset.configured,
|
||||||
|
logo_url: preset.logo_url ?? null,
|
||||||
|
brand_color: preset.brand_color ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function RunElapsedStrip({
|
function RunElapsedStrip({
|
||||||
startedAt,
|
startedAt,
|
||||||
goalState,
|
goalState,
|
||||||
@@ -394,9 +464,12 @@ 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,
|
||||||
@@ -414,6 +487,7 @@ 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);
|
||||||
@@ -498,26 +572,86 @@ export function ThreadComposer({
|
|||||||
return commandToken.toLowerCase();
|
return commandToken.toLowerCase();
|
||||||
}, [disabled, slashMenuDismissed, value]);
|
}, [disabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const filteredSlashCommands = useMemo(() => {
|
const visibleSlashCommands = useMemo(() => {
|
||||||
|
if (!(isStreaming && onStop)) return slashCommands;
|
||||||
|
if (slashCommands.some((command) => command.command === "/stop")) return slashCommands;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
command: "/stop",
|
||||||
|
title: "Stop current task",
|
||||||
|
description: "Cancel the active agent turn for this chat.",
|
||||||
|
icon: "square",
|
||||||
|
},
|
||||||
|
...slashCommands,
|
||||||
|
];
|
||||||
|
}, [isStreaming, onStop, slashCommands]);
|
||||||
|
|
||||||
|
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
|
||||||
if (slashQuery === null) return [];
|
if (slashQuery === null) return [];
|
||||||
return slashCommands
|
const withDetails = visibleSlashCommands
|
||||||
.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 ?? "",
|
||||||
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
|
title,
|
||||||
defaultValue: "",
|
description,
|
||||||
}),
|
|
||||||
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);
|
||||||
}, [slashCommands, slashQuery, t]);
|
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
|
||||||
|
|
||||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||||
@@ -534,9 +668,9 @@ export function ThreadComposer({
|
|||||||
};
|
};
|
||||||
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
||||||
|
|
||||||
const filteredCliApps = useMemo(() => {
|
const filteredMentionCandidates = useMemo<MentionCandidate[]>(() => {
|
||||||
if (!cliAppMention) return [];
|
if (!cliAppMention) return [];
|
||||||
return cliApps
|
const cliCandidates: MentionCandidate[] = cliApps
|
||||||
.filter((app) => app.installed)
|
.filter((app) => app.installed)
|
||||||
.filter((app) => {
|
.filter((app) => {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
@@ -548,16 +682,32 @@ export function ThreadComposer({
|
|||||||
].join(" ").toLowerCase();
|
].join(" ").toLowerCase();
|
||||||
return haystack.includes(cliAppMention.query);
|
return haystack.includes(cliAppMention.query);
|
||||||
})
|
})
|
||||||
.slice(0, 8);
|
.map((app) => ({ kind: "cli", name: app.name, app }));
|
||||||
}, [cliAppMention, cliApps]);
|
const mcpCandidates: MentionCandidate[] = mcpPresets
|
||||||
|
.filter((preset) => preset.installed && preset.configured)
|
||||||
|
.filter((preset) => {
|
||||||
|
const haystack = [
|
||||||
|
preset.name,
|
||||||
|
preset.display_name,
|
||||||
|
preset.category,
|
||||||
|
preset.description,
|
||||||
|
preset.transport,
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
return haystack.includes(cliAppMention.query);
|
||||||
|
})
|
||||||
|
.map((preset) => ({ kind: "mcp", name: preset.name, preset }));
|
||||||
|
return [...cliCandidates, ...mcpCandidates].slice(0, 8);
|
||||||
|
}, [cliAppMention, cliApps, mcpPresets]);
|
||||||
|
|
||||||
const showCliAppMenu = filteredCliApps.length > 0;
|
const showCliAppMenu = filteredMentionCandidates.length > 0;
|
||||||
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
||||||
const mentionSegments = useMemo(
|
const mentionSegments = useMemo(
|
||||||
() => splitCliAppMentionSegments(value, cliApps),
|
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets),
|
||||||
[cliApps, value],
|
[cliApps, mcpPresets, value],
|
||||||
|
);
|
||||||
|
const hasMentionDecorations = mentionSegments.some(
|
||||||
|
(segment) => segment.kind === "cli" || segment.kind === "mcp",
|
||||||
);
|
);
|
||||||
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
|
|
||||||
const activeCliMentionApps = useMemo(() => {
|
const activeCliMentionApps = useMemo(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return mentionSegments.flatMap((segment) => {
|
return mentionSegments.flatMap((segment) => {
|
||||||
@@ -566,6 +716,14 @@ 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,
|
||||||
@@ -586,10 +744,10 @@ export function ThreadComposer({
|
|||||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCliAppIndex >= filteredCliApps.length) {
|
if (selectedCliAppIndex >= filteredMentionCandidates.length) {
|
||||||
setSelectedCliAppIndex(0);
|
setSelectedCliAppIndex(0);
|
||||||
}
|
}
|
||||||
}, [filteredCliApps.length, selectedCliAppIndex]);
|
}, [filteredMentionCandidates.length, selectedCliAppIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showAnyPalette) return;
|
if (!showAnyPalette) return;
|
||||||
@@ -640,7 +798,7 @@ export function ThreadComposer({
|
|||||||
window.removeEventListener("resize", updateLayout);
|
window.removeEventListener("resize", updateLayout);
|
||||||
document.removeEventListener("scroll", updateLayout, true);
|
document.removeEventListener("scroll", updateLayout, true);
|
||||||
};
|
};
|
||||||
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
|
}, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!aspectMenuOpen) return;
|
if (!aspectMenuOpen) return;
|
||||||
@@ -686,20 +844,37 @@ 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();
|
||||||
},
|
},
|
||||||
[resizeTextarea],
|
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chooseCliApp = useCallback(
|
const chooseMentionCandidate = useCallback(
|
||||||
(app: CliAppInfo) => {
|
(candidate: MentionCandidate) => {
|
||||||
if (!cliAppMention) return;
|
if (!cliAppMention) return;
|
||||||
const suffix = value.slice(cliAppMention.end);
|
const suffix = value.slice(cliAppMention.end);
|
||||||
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
|
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
|
||||||
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
||||||
const nextCursor = cliAppMention.start + mention.length;
|
const nextCursor = cliAppMention.start + mention.length;
|
||||||
setValue(next);
|
setValue(next);
|
||||||
@@ -736,8 +911,9 @@ 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
|
imageMode || attachedCliApps.length > 0 || attachedMcpPresets.length > 0
|
||||||
? {
|
? {
|
||||||
...(imageMode
|
...(imageMode
|
||||||
? {
|
? {
|
||||||
@@ -748,6 +924,7 @@ 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);
|
||||||
@@ -760,25 +937,36 @@ 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) % filteredCliApps.length);
|
setSelectedCliAppIndex((idx) => (idx + 1) % filteredMentionCandidates.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowUp") {
|
if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedCliAppIndex(
|
setSelectedCliAppIndex(
|
||||||
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
|
(idx) => (idx - 1 + filteredMentionCandidates.length) % filteredMentionCandidates.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();
|
||||||
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
|
chooseMentionCandidate(filteredMentionCandidates[selectedCliAppIndex]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@@ -894,12 +1082,12 @@ export function ThreadComposer({
|
|||||||
) : null}
|
) : null}
|
||||||
{showCliAppMenu ? (
|
{showCliAppMenu ? (
|
||||||
<CliAppMentionPalette
|
<CliAppMentionPalette
|
||||||
apps={filteredCliApps}
|
candidates={filteredMentionCandidates}
|
||||||
selectedIndex={selectedCliAppIndex}
|
selectedIndex={selectedCliAppIndex}
|
||||||
layout={slashPaletteLayout}
|
layout={slashPaletteLayout}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
onHover={setSelectedCliAppIndex}
|
onHover={setSelectedCliAppIndex}
|
||||||
onChoose={chooseCliApp}
|
onChoose={chooseMentionCandidate}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
@@ -947,7 +1135,7 @@ export function ThreadComposer({
|
|||||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{hasCliMentionDecorations ? (
|
{hasMentionDecorations ? (
|
||||||
<ComposerCliMentionOverlay
|
<ComposerCliMentionOverlay
|
||||||
segments={mentionSegments}
|
segments={mentionSegments}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
@@ -978,7 +1166,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",
|
||||||
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
|
hasMentionDecorations && "text-transparent selection:bg-primary/20",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1019,7 +1207,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-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
|
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
|
||||||
@@ -1038,7 +1226,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)]",
|
||||||
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
|
"h-9 text-[12px]",
|
||||||
imageMode
|
imageMode
|
||||||
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
|
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
|
||||||
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
|
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
|
||||||
@@ -1058,7 +1246,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",
|
||||||
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
|
"h-9 text-[12px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
|
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
|
||||||
@@ -1078,22 +1266,12 @@ export function ThreadComposer({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{modelLabel ? (
|
{modelLabel ? (
|
||||||
<span
|
<ComposerModelBadge
|
||||||
title={modelLabel}
|
label={modelLabel}
|
||||||
className={cn(
|
provider={modelProvider}
|
||||||
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
|
providerLabel={modelProviderLabel}
|
||||||
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
|
isHero={isHero}
|
||||||
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">
|
||||||
@@ -1115,7 +1293,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",
|
||||||
isHero ? "" : "h-7.5 w-7.5",
|
"h-9 w-9",
|
||||||
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1133,12 +1311,79 @@ export function ThreadComposer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ComposerModelBadge({
|
||||||
|
label,
|
||||||
|
provider,
|
||||||
|
providerLabel,
|
||||||
|
isHero,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
provider?: string | null;
|
||||||
|
providerLabel?: string | null;
|
||||||
|
isHero: boolean;
|
||||||
|
}) {
|
||||||
|
const inferredProvider = provider || inferProviderFromModelName(label);
|
||||||
|
const brand = providerBrand(inferredProvider);
|
||||||
|
const [logoIndex, setLogoIndex] = useState(0);
|
||||||
|
const logoUrl = brand?.logoUrls[logoIndex];
|
||||||
|
const showLogo = !!logoUrl;
|
||||||
|
const title = providerLabel ? `${label} · ${providerLabel}` : label;
|
||||||
|
|
||||||
|
useEffect(() => setLogoIndex(0), [inferredProvider]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={title}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
|
||||||
|
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||||
|
isHero ? "h-9 max-w-[13.5rem] gap-2 px-2.5 text-[12px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
|
||||||
|
className={cn(
|
||||||
|
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
|
||||||
|
"h-5 w-5",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: brand ? `${brand.color}28` : undefined,
|
||||||
|
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{showLogo ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-3.5 w-3.5 object-contain"
|
||||||
|
onError={() => setLogoIndex((index) => index + 1)}
|
||||||
|
/>
|
||||||
|
) : brand ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-full w-full place-items-center rounded-full text-white",
|
||||||
|
"text-[8px]",
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: brand.color }}
|
||||||
|
>
|
||||||
|
{brand.initials.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ComposerCliMentionOverlay({
|
function ComposerCliMentionOverlay({
|
||||||
segments,
|
segments,
|
||||||
isHero,
|
isHero,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
segments: CliAppMentionSegment[];
|
segments: CapabilityMentionSegment[];
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
className: string;
|
className: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -1154,7 +1399,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>;
|
||||||
}
|
}
|
||||||
return (
|
if (segment.kind === "cli") return (
|
||||||
<CliAppMentionToken
|
<CliAppMentionToken
|
||||||
key={`cli-${segment.app.name}-${index}`}
|
key={`cli-${segment.app.name}-${index}`}
|
||||||
app={segment.app}
|
app={segment.app}
|
||||||
@@ -1163,26 +1408,35 @@ 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: SlashCommand[];
|
commands: SlashPaletteCommand[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
layout: SlashPaletteLayout;
|
layout: SlashPaletteLayout;
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
onHover: (index: number) => void;
|
onHover: (index: number) => void;
|
||||||
onChoose: (command: SlashCommand) => void;
|
onChoose: (command: SlashPaletteCommand) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CliAppMentionPaletteProps {
|
interface CliAppMentionPaletteProps {
|
||||||
apps: CliAppInfo[];
|
candidates: MentionCandidate[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
layout: SlashPaletteLayout;
|
layout: SlashPaletteLayout;
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
onHover: (index: number) => void;
|
onHover: (index: number) => void;
|
||||||
onChoose: (app: CliAppInfo) => void;
|
onChoose: (candidate: MentionCandidate) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageAspectMenu({
|
function ImageAspectMenu({
|
||||||
@@ -1239,7 +1493,7 @@ function ImageAspectMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CliAppMentionPalette({
|
function CliAppMentionPalette({
|
||||||
apps,
|
candidates,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
layout,
|
layout,
|
||||||
isHero,
|
isHero,
|
||||||
@@ -1257,98 +1511,117 @@ 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-[18px] border",
|
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[22px] border",
|
||||||
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
|
"border-border/70 bg-popover p-2 text-popover-foreground shadow-[0_20px_60px_rgba(15,23,42,0.12)]",
|
||||||
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
"dark:border-white/10 dark:shadow-[0_24px_60px_rgba(0,0,0,0.42)]",
|
||||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
|
<div className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
|
||||||
<AtSign className="h-3 w-3" aria-hidden />
|
{t("thread.composer.mentions.label")}
|
||||||
<span>{t("thread.composer.mentions.label")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
|
||||||
{apps.map((app, index) => {
|
{candidates.map((candidate, 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={app.name}
|
key={`${candidate.kind}-${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(app);
|
onChoose(candidate);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
"flex h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 text-left transition-colors",
|
||||||
selected
|
selected
|
||||||
? "bg-primary/10 text-foreground"
|
? "bg-foreground/[0.055] text-foreground"
|
||||||
: "text-foreground/86 hover:bg-accent/55",
|
: "text-foreground/90 hover:bg-foreground/[0.04]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CliAppMentionLogo app={app} selected={selected} />
|
<MentionCandidateLogo candidate={candidate} selected={selected} />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||||
<span className="flex min-w-0 items-baseline gap-2">
|
<span className="shrink-0 text-[15px] font-medium tracking-normal text-foreground">
|
||||||
<span className="font-mono text-[13px] font-semibold text-foreground">
|
{displayName}
|
||||||
@{app.name}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-[13px] font-medium">
|
|
||||||
{app.display_name}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
|
||||||
{app.category}
|
@{name}
|
||||||
{app.entry_point ? ` · ${app.entry_point}` : ""}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-2 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-normal",
|
||||||
|
candidate.kind === "cli"
|
||||||
|
? "bg-orange-500/10 text-orange-600 dark:text-orange-300"
|
||||||
|
: "bg-sky-500/10 text-sky-600 dark:text-sky-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
</button>
|
</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 CliAppMentionLogo({
|
function MentionCandidateLogo({
|
||||||
app,
|
candidate,
|
||||||
selected,
|
selected,
|
||||||
}: {
|
}: {
|
||||||
app: CliAppInfo;
|
candidate: MentionCandidate;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [logoIndex, setLogoIndex] = useState(0);
|
||||||
const color = app.brand_color || "hsl(var(--primary))";
|
const color = (candidate.kind === "cli"
|
||||||
if (app.logo_url && !failed) {
|
? candidate.app.brand_color
|
||||||
|
: candidate.preset.brand_color) || "hsl(var(--primary))";
|
||||||
|
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
|
||||||
|
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
|
||||||
|
const logoUrl = logoUrls[logoIndex];
|
||||||
|
|
||||||
|
useEffect(() => setLogoIndex(0), [rawLogoUrl]);
|
||||||
|
|
||||||
|
if (logoUrl) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
|
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px]",
|
||||||
selected ? "border-primary/25" : "border-border/65",
|
selected ? "bg-background/55" : "bg-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={app.logo_url}
|
src={logoUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-4.5 w-4.5 object-contain"
|
className="h-5 w-5 object-contain"
|
||||||
onError={() => setFailed(true)}
|
onError={() => setLogoIndex((index) => index + 1)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
>
|
>
|
||||||
{cliAppInitials(app)}
|
{candidate.kind === "cli"
|
||||||
|
? cliAppInitials(candidate.app)
|
||||||
|
: mcpPresetInitials(candidate.preset)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1374,14 +1647,11 @@ 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.18)]",
|
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
|
||||||
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
"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;
|
||||||
@@ -1405,49 +1675,42 @@ function SlashCommandPalette({
|
|||||||
onChoose(command);
|
onChoose(command);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
"flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors",
|
||||||
selected
|
selected
|
||||||
? "bg-primary/10 text-foreground"
|
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
|
||||||
: "text-foreground/86 hover:bg-accent/55",
|
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border",
|
"flex h-7 w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors",
|
||||||
selected
|
selected && "text-foreground",
|
||||||
? "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="min-w-0 flex-1">
|
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||||
<span className="flex min-w-0 items-baseline gap-2">
|
<span className="min-w-0 truncate text-[13.5px] font-semibold tracking-normal text-foreground">
|
||||||
<span className="font-mono text-[13px] font-semibold text-foreground">
|
{title}
|
||||||
{command.command}
|
|
||||||
</span>
|
|
||||||
{command.argHint ? (
|
|
||||||
<span className="font-mono text-[12px] text-muted-foreground">
|
|
||||||
{command.argHint}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="truncate text-[13px] font-medium">
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
<span className="min-w-0 truncate text-[13px] text-muted-foreground">
|
||||||
{description}
|
{command.detail || description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 flex shrink-0 items-center gap-1.5">
|
||||||
|
{command.badge || command.recent ? (
|
||||||
|
<span className="hidden rounded-full bg-foreground/[0.055] px-2 py-1 text-[11px] font-medium text-muted-foreground sm:inline-flex">
|
||||||
|
{command.badge ?? t("thread.composer.slash.badges.recent")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="font-mono text-[12px] text-muted-foreground/60">
|
||||||
|
{command.argHint ? `${command.command} ${command.argHint}` : command.command}
|
||||||
</span>
|
</span>
|
||||||
</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, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface ThreadMessagesProps {
|
interface ThreadMessagesProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
@@ -15,6 +15,7 @@ interface ThreadMessagesProps {
|
|||||||
hiddenMessageCount?: number;
|
hiddenMessageCount?: number;
|
||||||
onLoadEarlier?: () => void;
|
onLoadEarlier?: () => void;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
|
mcpPresets?: McpPresetInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisplayUnit =
|
export type DisplayUnit =
|
||||||
@@ -133,6 +134,7 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +168,7 @@ 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]);
|
||||||
@@ -202,6 +205,8 @@ 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}>
|
||||||
@@ -210,7 +215,9 @@ 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
|
||||||
@@ -221,6 +228,7 @@ export function ThreadMessages({
|
|||||||
: true
|
: true
|
||||||
}
|
}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
|
mcpPresets={mcpPresets}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,6 +238,28 @@ export function ThreadMessages({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activityClusterTurnLatencyMs(
|
||||||
|
messages: UIMessage[],
|
||||||
|
next: DisplayUnit | undefined,
|
||||||
|
): number | undefined {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||||
|
const latency = messages[i].latencyMs;
|
||||||
|
if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) {
|
||||||
|
return latency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
next?.type === "single"
|
||||||
|
&& next.message.role === "assistant"
|
||||||
|
&& typeof next.message.latencyMs === "number"
|
||||||
|
&& Number.isFinite(next.message.latencyMs)
|
||||||
|
&& next.message.latencyMs >= 0
|
||||||
|
) {
|
||||||
|
return next.message.latencyMs;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
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,8 +19,19 @@ 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, listSlashCommands } from "@/lib/api";
|
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
|
||||||
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
|
import {
|
||||||
|
CLI_APPS_CHANGED_EVENT,
|
||||||
|
installedCliAppsFromPayload,
|
||||||
|
isCliAppsPayload,
|
||||||
|
} from "@/lib/cli-app-events";
|
||||||
|
import {
|
||||||
|
MCP_PRESETS_CHANGED_EVENT,
|
||||||
|
installedMcpPresetsFromPayload,
|
||||||
|
isMcpPresetsPayload,
|
||||||
|
} from "@/lib/mcp-preset-events";
|
||||||
|
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||||
|
import type { ChatSummary, CliAppInfo, McpPresetInfo, SettingsPayload, SlashCommand, UIMessage } from "@/lib/types";
|
||||||
import { 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";
|
||||||
@@ -29,6 +40,20 @@ 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;
|
||||||
@@ -50,6 +75,41 @@ 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]" },
|
||||||
@@ -98,6 +158,8 @@ 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);
|
||||||
@@ -136,6 +198,28 @@ 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;
|
||||||
@@ -149,19 +233,28 @@ 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);
|
||||||
const normalized = projectWebuiThreadMessages(historical);
|
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||||
messageCacheRef.current.set(chatId, normalized);
|
return normalizedHistory;
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
|
if (cached && cached.length > 0) {
|
||||||
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
|
const normalizedCached = projectWebuiThreadMessages(cached);
|
||||||
|
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
|
||||||
|
return normalizedCached;
|
||||||
|
}
|
||||||
|
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||||
const next = projectWebuiThreadMessages(historical);
|
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
||||||
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
|
return normalizedHistory;
|
||||||
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]);
|
||||||
@@ -251,18 +344,27 @@ export function ThreadShell({
|
|||||||
const refreshCliApps = useCallback(async () => {
|
const refreshCliApps = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchCliApps(token);
|
const payload = await fetchCliApps(token);
|
||||||
setCliApps(payload.apps.filter((app) => app.installed));
|
setCliApps(installedCliAppsFromPayload(payload));
|
||||||
} catch {
|
} catch {
|
||||||
setCliApps([]);
|
setCliApps([]);
|
||||||
}
|
}
|
||||||
}, [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 () => {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchCliApps(token);
|
const payload = await fetchCliApps(token);
|
||||||
if (!cancelled) setCliApps(payload.apps.filter((app) => app.installed));
|
if (!cancelled) setCliApps(installedCliAppsFromPayload(payload));
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setCliApps([]);
|
if (!cancelled) setCliApps([]);
|
||||||
}
|
}
|
||||||
@@ -275,13 +377,58 @@ export function ThreadShell({
|
|||||||
};
|
};
|
||||||
window.addEventListener("focus", refreshOnFocus);
|
window.addEventListener("focus", refreshOnFocus);
|
||||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
document.addEventListener("visibilitychange", refreshOnFocus);
|
||||||
|
const refreshOnCliAppsChanged = (event: Event) => {
|
||||||
|
const payload = (event as CustomEvent<unknown>).detail;
|
||||||
|
if (isCliAppsPayload(payload)) {
|
||||||
|
setCliApps(installedCliAppsFromPayload(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshCliApps();
|
||||||
|
};
|
||||||
|
window.addEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.removeEventListener("focus", refreshOnFocus);
|
window.removeEventListener("focus", refreshOnFocus);
|
||||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||||
|
window.removeEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
|
||||||
};
|
};
|
||||||
}, [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;
|
||||||
@@ -364,10 +511,13 @@ export function ThreadShell({
|
|||||||
? t("thread.composer.placeholderHero")
|
? t("thread.composer.placeholderHero")
|
||||||
: t("thread.composer.placeholderThread")
|
: t("thread.composer.placeholderThread")
|
||||||
}
|
}
|
||||||
modelLabel={toModelBadgeLabel(modelName)}
|
modelLabel={modelBadge.label}
|
||||||
|
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}
|
||||||
@@ -384,10 +534,13 @@ export function ThreadShell({
|
|||||||
? t("thread.composer.placeholderOpening")
|
? t("thread.composer.placeholderOpening")
|
||||||
: t("thread.composer.placeholderHero")
|
: t("thread.composer.placeholderHero")
|
||||||
}
|
}
|
||||||
modelLabel={toModelBadgeLabel(modelName)}
|
modelLabel={modelBadge.label}
|
||||||
|
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}
|
||||||
@@ -429,6 +582,7 @@ 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, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface ThreadViewportProps {
|
interface ThreadViewportProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
@@ -25,6 +25,7 @@ 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;
|
||||||
@@ -55,6 +56,7 @@ 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);
|
||||||
@@ -252,6 +254,7 @@ export function ThreadViewport({
|
|||||||
hiddenMessageCount={hiddenMessageCount}
|
hiddenMessageCount={hiddenMessageCount}
|
||||||
onLoadEarlier={loadEarlierMessages}
|
onLoadEarlier={loadEarlierMessages}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
|
mcpPresets={mcpPresets}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AvatarPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
|
||||||
|
|
||||||
const AvatarImage = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AvatarPrimitive.Image
|
|
||||||
ref={ref}
|
|
||||||
className={cn("aspect-square h-full w-full", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
|
||||||
|
|
||||||
const AvatarFallback = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AvatarPrimitive.Fallback
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
|
||||||
|
|
||||||
export { Avatar, AvatarFallback, AvatarImage };
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const ScrollArea = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<ScrollAreaPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn("relative overflow-hidden", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full min-w-0 rounded-[inherit]">
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
<ScrollBar />
|
|
||||||
<ScrollAreaPrimitive.Corner />
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
));
|
|
||||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
|
||||||
|
|
||||||
const ScrollBar = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
||||||
ref={ref}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"flex touch-none select-none transition-colors",
|
|
||||||
orientation === "vertical" &&
|
|
||||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
|
||||||
orientation === "horizontal" &&
|
|
||||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
));
|
|
||||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar };
|
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
InboundEvent,
|
InboundEvent,
|
||||||
OutboundCliAppMention,
|
OutboundCliAppMention,
|
||||||
OutboundImageGeneration,
|
OutboundImageGeneration,
|
||||||
|
OutboundMcpPresetMention,
|
||||||
OutboundMedia,
|
OutboundMedia,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
UIImage,
|
UIImage,
|
||||||
@@ -193,6 +194,15 @@ function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMess
|
|||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findLatestAssistantAnswerIndex(prev: UIMessage[]): number | null {
|
||||||
|
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||||
|
const m = prev[i];
|
||||||
|
if (m.role === "assistant" && m.kind !== "trace") return i;
|
||||||
|
if (m.role === "user") break;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function absorbCompleteAssistantMessage(
|
function absorbCompleteAssistantMessage(
|
||||||
prev: UIMessage[],
|
prev: UIMessage[],
|
||||||
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
||||||
@@ -304,6 +314,7 @@ 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(
|
||||||
@@ -489,23 +500,54 @@ export function useNanobotStream(
|
|||||||
[appendAnswerChunk, ensureActivitySegmentId],
|
[appendAnswerChunk, ensureActivitySegmentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => {
|
const flushPendingStreamEvents = useCallback((options?: {
|
||||||
|
closeAnswerSegment?: boolean;
|
||||||
|
finalAnswerText?: string;
|
||||||
|
}) => {
|
||||||
if (streamFrameRef.current !== null) {
|
if (streamFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(streamFrameRef.current);
|
window.cancelAnimationFrame(streamFrameRef.current);
|
||||||
streamFrameRef.current = null;
|
streamFrameRef.current = null;
|
||||||
}
|
}
|
||||||
const events = pendingStreamEventsRef.current;
|
const events = pendingStreamEventsRef.current;
|
||||||
if (events.length === 0) {
|
const finalAnswerText = options?.finalAnswerText;
|
||||||
|
if (events.length === 0 && finalAnswerText === undefined) {
|
||||||
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingStreamEventsRef.current = [];
|
pendingStreamEventsRef.current = [];
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const next = applyPendingStreamEvents(prev, events);
|
let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev;
|
||||||
|
if (finalAnswerText !== undefined) {
|
||||||
|
const targetIndex =
|
||||||
|
resolveActiveAssistantIndex(next)
|
||||||
|
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current)
|
||||||
|
?? findLatestAssistantAnswerIndex(next);
|
||||||
|
if (targetIndex !== null) {
|
||||||
|
const target = next[targetIndex];
|
||||||
|
next = replaceMessageAt(next, targetIndex, {
|
||||||
|
...target,
|
||||||
|
content: finalAnswerText,
|
||||||
|
isStreaming: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
closedAssistantStreamIdsRef.current.add(id);
|
||||||
|
next = [
|
||||||
|
...next,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content: finalAnswerText,
|
||||||
|
isStreaming: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [applyPendingStreamEvents, closeActiveAssistantStream]);
|
}, [applyPendingStreamEvents, closeActiveAssistantStream, resolveActiveAssistantIndex]);
|
||||||
|
|
||||||
const schedulePendingStreamFlush = useCallback(() => {
|
const schedulePendingStreamFlush = useCallback(() => {
|
||||||
if (streamFrameRef.current !== null) return;
|
if (streamFrameRef.current !== null) return;
|
||||||
@@ -583,7 +625,10 @@ export function useNanobotStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ev.event === "stream_end") {
|
if (ev.event === "stream_end") {
|
||||||
flushPendingStreamEvents({ closeAnswerSegment: true });
|
flushPendingStreamEvents({
|
||||||
|
closeAnswerSegment: true,
|
||||||
|
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
|
||||||
|
});
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
// stream_end only means the text segment finished — the model may
|
// stream_end only means the text segment finished — the model may
|
||||||
// still be executing tools. Do NOT reset isStreaming here; the
|
// still be executing tools. Do NOT reset isStreaming here; the
|
||||||
@@ -848,6 +893,7 @@ 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,7 +63,8 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
"ariaLabel": "Change language"
|
"ariaLabel": "Change language"
|
||||||
}
|
},
|
||||||
|
"apps": "Apps"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Back to chat",
|
"backToChat": "Back to chat",
|
||||||
@@ -81,8 +82,10 @@
|
|||||||
"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",
|
||||||
@@ -95,11 +98,21 @@
|
|||||||
"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",
|
||||||
@@ -112,6 +125,7 @@
|
|||||||
"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",
|
||||||
@@ -154,6 +168,9 @@
|
|||||||
"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.",
|
||||||
@@ -178,6 +195,11 @@
|
|||||||
"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",
|
||||||
@@ -209,6 +231,59 @@
|
|||||||
"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",
|
||||||
@@ -296,7 +371,8 @@
|
|||||||
},
|
},
|
||||||
"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."
|
||||||
@@ -307,6 +383,20 @@
|
|||||||
"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": {
|
||||||
@@ -461,6 +551,16 @@
|
|||||||
"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",
|
||||||
@@ -478,6 +578,10 @@
|
|||||||
"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."
|
||||||
@@ -501,12 +605,22 @@
|
|||||||
"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": "CLI Apps",
|
"ariaLabel": "Apps",
|
||||||
"label": "CLI APPS"
|
"label": "Apps",
|
||||||
|
"cliGroup": "CLI apps",
|
||||||
|
"mcpGroup": "MCP services",
|
||||||
|
"cliBadge": "CLI",
|
||||||
|
"mcpBadge": "MCP",
|
||||||
|
"cliDescription": "Use @{{name}} as a local CLI app",
|
||||||
|
"mcpDescription": "Use @{{name}} as an MCP server"
|
||||||
},
|
},
|
||||||
"encoding": "Encoding…",
|
"encoding": "Encoding…",
|
||||||
"remove": "Remove attachment",
|
"remove": "Remove attachment",
|
||||||
@@ -539,15 +653,18 @@
|
|||||||
"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",
|
||||||
"cliActivityRunningOne": "Running CLI @{{name}}",
|
"activityThinkingFor": "Thinking for {{duration}}",
|
||||||
"cliActivityRanOne": "Ran CLI @{{name}}",
|
"activityThought": "Thought",
|
||||||
"cliActivityFailedOne": "CLI failed @{{name}}",
|
"activityThoughtFor": "Thought for {{duration}}",
|
||||||
"cliActivityRunningMany": "Running {{count}} CLIs",
|
"cliActivityRunningOne": "Using @{{name}}",
|
||||||
"cliActivityRanMany": "Ran {{count}} CLIs",
|
"cliActivityRanOne": "Used @{{name}}",
|
||||||
"cliActivityFailedMany": "{{count}} CLI failed",
|
"cliActivityFailedOne": "Failed @{{name}}",
|
||||||
"cliRunRunning": "Running CLI",
|
"cliActivityRunningMany": "Using {{count}} CLI apps",
|
||||||
"cliRunRan": "Ran CLI",
|
"cliActivityRanMany": "Used {{count}} CLI apps",
|
||||||
"cliRunFailed": "CLI failed",
|
"cliActivityFailedMany": "{{count}} CLI apps 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,7 +63,8 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "Idioma",
|
"label": "Idioma",
|
||||||
"ariaLabel": "Cambiar idioma"
|
"ariaLabel": "Cambiar idioma"
|
||||||
}
|
},
|
||||||
|
"apps": "Apps"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Volver al chat",
|
"backToChat": "Volver al chat",
|
||||||
@@ -81,7 +82,10 @@
|
|||||||
"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",
|
||||||
@@ -97,7 +101,10 @@
|
|||||||
"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",
|
||||||
@@ -141,7 +148,11 @@
|
|||||||
"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.",
|
||||||
@@ -168,7 +179,13 @@
|
|||||||
"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",
|
||||||
@@ -185,9 +202,7 @@
|
|||||||
"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...",
|
||||||
@@ -259,7 +274,8 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -267,6 +283,120 @@
|
|||||||
"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": {
|
||||||
@@ -370,8 +500,7 @@
|
|||||||
"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",
|
||||||
@@ -439,6 +568,10 @@
|
|||||||
"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."
|
||||||
@@ -462,7 +595,21 @@
|
|||||||
"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…",
|
||||||
@@ -475,6 +622,16 @@
|
|||||||
"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",
|
||||||
@@ -499,7 +656,19 @@
|
|||||||
"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,7 +63,8 @@
|
|||||||
"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",
|
||||||
@@ -81,7 +82,10 @@
|
|||||||
"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",
|
||||||
@@ -97,7 +101,10 @@
|
|||||||
"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",
|
||||||
@@ -141,7 +148,11 @@
|
|||||||
"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.",
|
||||||
@@ -168,7 +179,13 @@
|
|||||||
"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",
|
||||||
@@ -185,9 +202,7 @@
|
|||||||
"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...",
|
||||||
@@ -259,7 +274,8 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -267,6 +283,120 @@
|
|||||||
"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": {
|
||||||
@@ -370,8 +500,7 @@
|
|||||||
"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",
|
||||||
@@ -439,6 +568,10 @@
|
|||||||
"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."
|
||||||
@@ -462,7 +595,21 @@
|
|||||||
"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…",
|
||||||
@@ -475,6 +622,16 @@
|
|||||||
"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",
|
||||||
@@ -499,7 +656,19 @@
|
|||||||
"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,7 +63,8 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "Bahasa",
|
"label": "Bahasa",
|
||||||
"ariaLabel": "Ganti bahasa"
|
"ariaLabel": "Ganti bahasa"
|
||||||
}
|
},
|
||||||
|
"apps": "Aplikasi"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Kembali ke obrolan",
|
"backToChat": "Kembali ke obrolan",
|
||||||
@@ -81,7 +82,10 @@
|
|||||||
"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",
|
||||||
@@ -97,7 +101,10 @@
|
|||||||
"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",
|
||||||
@@ -141,7 +148,11 @@
|
|||||||
"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.",
|
||||||
@@ -168,7 +179,13 @@
|
|||||||
"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",
|
||||||
@@ -185,9 +202,7 @@
|
|||||||
"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...",
|
||||||
@@ -259,7 +274,8 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -267,6 +283,120 @@
|
|||||||
"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": {
|
||||||
@@ -370,8 +500,7 @@
|
|||||||
"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",
|
||||||
@@ -439,6 +568,10 @@
|
|||||||
"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."
|
||||||
@@ -462,7 +595,21 @@
|
|||||||
"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…",
|
||||||
@@ -475,6 +622,16 @@
|
|||||||
"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",
|
||||||
@@ -499,7 +656,19 @@
|
|||||||
"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,7 +63,8 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "言語",
|
"label": "言語",
|
||||||
"ariaLabel": "言語を変更"
|
"ariaLabel": "言語を変更"
|
||||||
}
|
},
|
||||||
|
"apps": "アプリ"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "チャットに戻る",
|
"backToChat": "チャットに戻る",
|
||||||
@@ -81,7 +82,10 @@
|
|||||||
"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": "インターフェース",
|
||||||
@@ -97,7 +101,10 @@
|
|||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "機能",
|
"capabilities": "機能",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations",
|
||||||
|
"cliApps": "CLI アプリ",
|
||||||
|
"mcp": "MCP サービス",
|
||||||
|
"apps": "アプリ"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
@@ -141,7 +148,11 @@
|
|||||||
"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": "ライト表示とダーク表示を切り替えます。",
|
||||||
@@ -168,7 +179,13 @@
|
|||||||
"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": "ライト",
|
||||||
@@ -185,9 +202,7 @@
|
|||||||
"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": "設定を読み込んでいます...",
|
||||||
@@ -259,7 +274,8 @@
|
|||||||
},
|
},
|
||||||
"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": "プロバイダーを選択",
|
||||||
@@ -267,6 +283,120 @@
|
|||||||
"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": {
|
||||||
@@ -370,8 +500,7 @@
|
|||||||
"title": "画像を編集",
|
"title": "画像を編集",
|
||||||
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
|
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
|
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "サイドバーを切り替える",
|
"toggleSidebar": "サイドバーを切り替える",
|
||||||
@@ -439,6 +568,10 @@
|
|||||||
"title": "ステータスを表示",
|
"title": "ステータスを表示",
|
||||||
"description": "ランタイム、provider、channel の状態を表示します。"
|
"description": "ランタイム、provider、channel の状態を表示します。"
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "モデル",
|
||||||
|
"description": "有効なモデルプリセットを表示または切り替えます。"
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "会話履歴を表示",
|
"title": "会話履歴を表示",
|
||||||
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
|
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
|
||||||
@@ -462,7 +595,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "ヘルプを表示",
|
"title": "ヘルプを表示",
|
||||||
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "ペアリング",
|
||||||
|
"description": "ペアリングリクエストを管理します。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "現在",
|
||||||
|
"recent": "最近"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "目標が実行中",
|
||||||
|
"goalReady": "継続的な目標を開始",
|
||||||
|
"history": "最近のメッセージを表示",
|
||||||
|
"stopRunning": "実行中"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "処理中…",
|
"encoding": "処理中…",
|
||||||
@@ -475,6 +622,16 @@
|
|||||||
"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": "一番下へスクロール",
|
||||||
@@ -499,7 +656,19 @@
|
|||||||
"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,7 +63,8 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"label": "언어",
|
"label": "언어",
|
||||||
"ariaLabel": "언어 변경"
|
"ariaLabel": "언어 변경"
|
||||||
}
|
},
|
||||||
|
"apps": "앱"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "채팅으로 돌아가기",
|
"backToChat": "채팅으로 돌아가기",
|
||||||
@@ -81,7 +82,10 @@
|
|||||||
"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": "인터페이스",
|
||||||
@@ -97,7 +101,10 @@
|
|||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "기능",
|
"capabilities": "기능",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations",
|
||||||
|
"cliApps": "CLI 앱",
|
||||||
|
"mcp": "MCP 서비스",
|
||||||
|
"apps": "앱"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "테마",
|
"theme": "테마",
|
||||||
@@ -141,7 +148,11 @@
|
|||||||
"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": "밝은 모드와 어두운 모드를 전환합니다.",
|
||||||
@@ -168,7 +179,13 @@
|
|||||||
"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": "라이트",
|
||||||
@@ -185,9 +202,7 @@
|
|||||||
"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": "설정을 불러오는 중...",
|
||||||
@@ -259,7 +274,8 @@
|
|||||||
},
|
},
|
||||||
"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": "제공자 선택",
|
||||||
@@ -267,6 +283,120 @@
|
|||||||
"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": {
|
||||||
@@ -370,8 +500,7 @@
|
|||||||
"title": "이미지 편집",
|
"title": "이미지 편집",
|
||||||
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
|
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
|
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "사이드바 전환",
|
"toggleSidebar": "사이드바 전환",
|
||||||
@@ -439,6 +568,10 @@
|
|||||||
"title": "상태 보기",
|
"title": "상태 보기",
|
||||||
"description": "런타임, provider, channel 상태를 표시합니다."
|
"description": "런타임, provider, channel 상태를 표시합니다."
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "모델",
|
||||||
|
"description": "활성 모델 프리셋을 보거나 전환합니다."
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "대화 기록 보기",
|
"title": "대화 기록 보기",
|
||||||
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
|
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
|
||||||
@@ -462,7 +595,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "도움말 보기",
|
"title": "도움말 보기",
|
||||||
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "페어링",
|
||||||
|
"description": "페어링 요청을 관리합니다."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "현재",
|
||||||
|
"recent": "최근"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "목표 실행 중",
|
||||||
|
"goalReady": "지속 목표 시작",
|
||||||
|
"history": "최근 메시지 보기",
|
||||||
|
"stopRunning": "실행 중"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "처리 중…",
|
"encoding": "처리 중…",
|
||||||
@@ -475,6 +622,16 @@
|
|||||||
"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": "맨 아래로 스크롤",
|
||||||
@@ -499,7 +656,19 @@
|
|||||||
"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": "이미지 미리보기",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user