mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 05:48:38 +03:00
Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4819c27be0 | ||
|
|
56465e7017 | ||
|
|
7033152a2c | ||
|
|
2048c38a22 | ||
|
|
c0d4f012c8 | ||
|
|
e2d00ffc8f | ||
|
|
a5a956d9af | ||
|
|
8c5acea3b0 | ||
|
|
545294c62c | ||
|
|
25d00b1ea4 | ||
|
|
ff173045fe | ||
|
|
b1140f6aee | ||
|
|
782d761b81 | ||
|
|
c1073f2986 | ||
|
|
143224e25a | ||
|
|
055c9be359 | ||
|
|
ddfe5c3bdf | ||
|
|
f5534bcaa0 | ||
|
|
8c0b2c1a29 | ||
|
|
ffd85a8611 | ||
|
|
65dff4f3a5 | ||
|
|
3483141ed7 | ||
|
|
b0d3069621 | ||
|
|
3d9f50a0cc | ||
|
|
effc1efd92 | ||
|
|
9b2f452b6e | ||
|
|
d660573b18 | ||
|
|
cb7daa77db | ||
|
|
8281cd1946 | ||
|
|
e5476573f4 | ||
|
|
0d1d23b5fb | ||
|
|
835bab5f5a | ||
|
|
ccbc0bb6e3 | ||
|
|
722b760eae | ||
|
|
23d5148a57 | ||
|
|
d29fcaf5d1 | ||
|
|
84603f4cf2 | ||
|
|
581faa34f7 | ||
|
|
7e3af8c38b | ||
|
|
e645fbcb34 | ||
|
|
4f895e6307 | ||
|
|
0cd2f626c0 | ||
|
|
44ef697aac | ||
|
|
e2b51fa5dc | ||
|
|
7e122d6e49 | ||
|
|
de0a8f5e41 | ||
|
|
3d3ebf1110 | ||
|
|
77ec55bf8e | ||
|
|
8141df0d3f | ||
|
|
5f0ba05de5 | ||
|
|
886e7e43d5 | ||
|
|
b3d0d24a52 | ||
|
|
82dfe8c1f7 | ||
|
|
dc33247671 | ||
|
|
d376ec129d | ||
|
|
d653f23aba | ||
|
|
96767ca179 | ||
|
|
b300ea495f | ||
|
|
632f41e418 | ||
|
|
9c486b90d5 | ||
|
|
590ac99c8a | ||
|
|
7733a7840e | ||
|
|
83aed43682 | ||
|
|
ad7c1ac381 | ||
|
|
882d4139d7 | ||
|
|
ca72f6b6c9 | ||
|
|
96eb3b7194 | ||
|
|
8f6b7611a2 | ||
|
|
1a6fe093e7 | ||
|
|
8ec1025193 | ||
|
|
480ca28a2d | ||
|
|
3e154bb5cf | ||
|
|
6851fa57a6 | ||
|
|
09a692be6f | ||
|
|
3f789bd9f9 | ||
|
|
65cecc01fb | ||
|
|
a7b34422f3 | ||
|
|
72f999f8f7 | ||
|
|
e6587a8d8e |
@@ -20,7 +20,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest","windows-latest"]') }}
|
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
|
||||||
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
|
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
|
||||||
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||

|

|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
<p>
|
||||||
|
<a href="https://nanobot.wiki/docs/latest/getting-started/nanobot-overview">English</a> |
|
||||||
|
<a href="https://nanobot.wiki/cn/docs/latest/getting-started/nanobot-overview">简体中文</a> |
|
||||||
|
<a href="https://nanobot.wiki/zh-Hant/docs/latest/getting-started/nanobot-overview">繁體中文</a> |
|
||||||
|
<a href="https://nanobot.wiki/es/docs/latest/getting-started/nanobot-overview">Español</a> |
|
||||||
|
<a href="https://nanobot.wiki/fr/docs/latest/getting-started/nanobot-overview">Français</a> |
|
||||||
|
<a href="https://nanobot.wiki/id/docs/latest/getting-started/nanobot-overview">Bahasa Indonesia</a> |
|
||||||
|
<a href="https://nanobot.wiki/ja/docs/latest/getting-started/nanobot-overview">日本語</a> |
|
||||||
|
<a href="https://nanobot.wiki/ko/docs/latest/getting-started/nanobot-overview">한국어</a> |
|
||||||
|
<a href="https://nanobot.wiki/ru/docs/latest/getting-started/nanobot-overview">Русский</a> |
|
||||||
|
<a href="https://nanobot.wiki/vi/docs/latest/getting-started/nanobot-overview">Tiếng Việt</a>
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a>
|
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a>
|
||||||
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a>
|
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a>
|
||||||
@@ -61,7 +73,7 @@
|
|||||||
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
|
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
|
||||||
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
|
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
|
||||||
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
||||||
- **2026-04-10** 📓 Notebook editing tool, multiple MCP servers, Feishu streaming & done-emoji.
|
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
|
||||||
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
|
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
|
||||||
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
|
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
|
||||||
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
|
|||||||
| **Wecom** | Bot ID + Bot Secret |
|
| **Wecom** | Bot ID + Bot Secret |
|
||||||
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
|
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
|
||||||
| **Mochat** | Claw token (auto-setup available) |
|
| **Mochat** | Claw token (auto-setup available) |
|
||||||
|
| **Signal** | signal-cli daemon + phone number |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
@@ -669,3 +670,69 @@ nanobot gateway
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Signal</b></summary>
|
||||||
|
|
||||||
|
Uses **signal-cli** daemon in HTTP mode — receive messages via SSE, send via JSON-RPC.
|
||||||
|
|
||||||
|
**1. Install signal-cli**
|
||||||
|
|
||||||
|
Install [signal-cli](https://github.com/AsamK/signal-cli) and register a phone number:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
signal-cli -u +1234567890 register
|
||||||
|
signal-cli -u +1234567890 verify <CODE>
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
signal-cli -a +1234567890 daemon --http localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"signal": {
|
||||||
|
"enabled": true,
|
||||||
|
"phoneNumber": "+1234567890",
|
||||||
|
"daemonHost": "localhost",
|
||||||
|
"daemonPort": 8080,
|
||||||
|
"dm": {
|
||||||
|
"enabled": true,
|
||||||
|
"policy": "open"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"enabled": true,
|
||||||
|
"policy": "open",
|
||||||
|
"requireMention": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> - `phoneNumber`: Your registered Signal phone number.
|
||||||
|
> - `daemonHost` / `daemonPort`: Where signal-cli daemon is listening (default `localhost:8080`).
|
||||||
|
> - `dm.policy`: `"open"` (anyone can DM) or `"allowlist"` (only listed numbers/UUIDs). When `"allowlist"`, unlisted DM senders receive a pairing code.
|
||||||
|
> - `dm.allowFrom`: List of allowed phone numbers or UUIDs (used when policy is `"allowlist"`).
|
||||||
|
> - `group.policy`: `"open"` (all groups) or `"allowlist"` (only listed group IDs).
|
||||||
|
> - `group.requireMention`: When `true` (default), the bot only responds in groups when @mentioned.
|
||||||
|
> - `group.allowFrom`: List of allowed group IDs (used when group policy is `"allowlist"`).
|
||||||
|
> - `attachmentsDir`: Override the directory where signal-cli stores inbound attachments. Defaults to `~/.local/share/signal-cli/attachments` (the Linux default). Set this if signal-cli runs with a custom `XDG_DATA_HOME` or on macOS/Windows.
|
||||||
|
> - `groupMessageBufferSize`: Number of recent group messages kept for context (default `20`, must be > 0).
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> The channel automatically reconnects to the signal-cli daemon with exponential backoff if the connection drops.
|
||||||
|
> Markdown in bot replies is automatically converted to Signal text styles (bold, italic, code, etc.).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
|||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
|
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
|
||||||
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
||||||
|
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
||||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
||||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||||
|
|||||||
@@ -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, and Gemini configuration examples.
|
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, and StepFun configuration examples.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
||||||
@@ -46,7 +46,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
|
|||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
||||||
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `stepfun` |
|
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun` |
|
||||||
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
||||||
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
|
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
|
||||||
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
|
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
|
||||||
@@ -168,6 +168,31 @@ For reference-image edits, use a Gemini Flash image model:
|
|||||||
|
|
||||||
Imagen 4 supports the aspect ratios `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`. Unsupported ratios are ignored and the model uses its default. The `defaultImageSize` setting has no effect on Gemini models; sizing is controlled by `defaultAspectRatio` only. Reference images passed with an Imagen model are ignored (with a warning logged).
|
Imagen 4 supports the aspect ratios `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`. Unsupported ratios are ignored and the model uses its default. The `defaultImageSize` setting has no effect on Gemini models; sizing is controlled by `defaultAspectRatio` only. Reference images passed with an Imagen model are ignored (with a warning logged).
|
||||||
|
|
||||||
|
### Ollama
|
||||||
|
|
||||||
|
Ollama's experimental native image generation API works with local servers and hosted ollama.com models. Local access at `http://localhost:11434/api` does not require an API key; set `providers.ollama.apiKey` only when targeting `https://ollama.com/api`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"ollama": {
|
||||||
|
"apiBase": "http://localhost:11434/api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "x/z-image-turbo",
|
||||||
|
"defaultAspectRatio": "16:9",
|
||||||
|
"defaultImageSize": "2K"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ollama maps `defaultAspectRatio` and `defaultImageSize` to native `width` and `height` values. Reference images are not supported by this integration.
|
||||||
|
|
||||||
### StepFun
|
### StepFun
|
||||||
|
|
||||||
StepFun (阶跃星辰) `step-image-edit-2` supports text-to-image generation. The `step-1x-medium` variant additionally supports **style-reference** image edits, where a reference image guides the visual style of the output.
|
StepFun (阶跃星辰) `step-image-edit-2` supports text-to-image generation. The `step-1x-medium` variant additionally supports **style-reference** image edits, where a reference image guides the visual style of the output.
|
||||||
@@ -274,7 +299,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`, or `stepfun` |
|
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, or `stepfun` |
|
||||||
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
||||||
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
|
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
|
||||||
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
|
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from nanobot.utils.prompt_templates import render_template
|
|||||||
class ContextBuilder:
|
class ContextBuilder:
|
||||||
"""Builds the context (system prompt + messages) for the agent."""
|
"""Builds the context (system prompt + messages) for the agent."""
|
||||||
|
|
||||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
|
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||||
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
|
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
|
||||||
@@ -47,6 +47,8 @@ class ContextBuilder:
|
|||||||
if bootstrap:
|
if bootstrap:
|
||||||
parts.append(bootstrap)
|
parts.append(bootstrap)
|
||||||
|
|
||||||
|
parts.append(render_template("agent/tool_contract.md"))
|
||||||
|
|
||||||
memory = self.memory.get_memory_context()
|
memory = self.memory.get_memory_context()
|
||||||
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
|
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
|
||||||
parts.append(f"# Memory\n\n{memory}")
|
parts.append(f"# Memory\n\n{memory}")
|
||||||
@@ -154,9 +156,14 @@ class ContextBuilder:
|
|||||||
sender_id: str | None = None,
|
sender_id: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
session_metadata: Mapping[str, Any] | None = None,
|
session_metadata: Mapping[str, Any] | None = None,
|
||||||
|
current_runtime_lines: Sequence[str] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""Build the complete message list for an LLM call."""
|
||||||
extra = goal_state_runtime_lines(session_metadata)
|
extra = [
|
||||||
|
*goal_state_runtime_lines(session_metadata),
|
||||||
|
]
|
||||||
|
if current_runtime_lines:
|
||||||
|
extra.extend(line for line in current_runtime_lines if line)
|
||||||
runtime_ctx = self._build_runtime_context(
|
runtime_ctx = self._build_runtime_context(
|
||||||
channel,
|
channel,
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -210,4 +217,3 @@ class ContextBuilder:
|
|||||||
if not images:
|
if not images:
|
||||||
return text
|
return text
|
||||||
return images + [{"type": "text", "text": text}]
|
return images + [{"type": "text", "text": text}]
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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
|
||||||
@@ -59,7 +60,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
UNIFIED_SESSION_KEY = "unified:default"
|
UNIFIED_SESSION_KEY = "unified:default"
|
||||||
|
|
||||||
|
|
||||||
class TurnState(Enum):
|
class TurnState(Enum):
|
||||||
RESTORE = auto()
|
RESTORE = auto()
|
||||||
COMPACT = auto()
|
COMPACT = auto()
|
||||||
@@ -568,7 +568,7 @@ class AgentLoop:
|
|||||||
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
||||||
has_text = isinstance(msg.content, str) and msg.content.strip()
|
has_text = isinstance(msg.content, str) and msg.content.strip()
|
||||||
if has_text or media_paths:
|
if has_text or media_paths:
|
||||||
extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {}
|
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata)
|
||||||
extra.update(kwargs)
|
extra.update(kwargs)
|
||||||
text = msg.content if isinstance(msg.content, str) else ""
|
text = msg.content if isinstance(msg.content, str) else ""
|
||||||
session.add_message("user", text, **extra)
|
session.add_message("user", text, **extra)
|
||||||
@@ -593,7 +593,7 @@ class AgentLoop:
|
|||||||
chat_id=self._runtime_chat_id(msg),
|
chat_id=self._runtime_chat_id(msg),
|
||||||
sender_id=msg.sender_id,
|
sender_id=msg.sender_id,
|
||||||
session_summary=pending_summary,
|
session_summary=pending_summary,
|
||||||
session_metadata=session.metadata,
|
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
@@ -1058,7 +1058,7 @@ class AgentLoop:
|
|||||||
current_role=current_role,
|
current_role=current_role,
|
||||||
sender_id=msg.sender_id,
|
sender_id=msg.sender_id,
|
||||||
session_summary=pending,
|
session_summary=pending,
|
||||||
session_metadata=session.metadata,
|
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
|
||||||
)
|
)
|
||||||
t_wall = time.time()
|
t_wall = time.time()
|
||||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||||
|
|||||||
+23
-13
@@ -19,7 +19,8 @@ from nanobot.utils.file_edit_events import (
|
|||||||
build_file_edit_end_event,
|
build_file_edit_end_event,
|
||||||
build_file_edit_error_event,
|
build_file_edit_error_event,
|
||||||
build_file_edit_start_event,
|
build_file_edit_start_event,
|
||||||
prepare_file_edit_tracker,
|
prepare_file_edit_tracker as _prepare_file_edit_tracker,
|
||||||
|
prepare_file_edit_trackers,
|
||||||
StreamingFileEditTracker,
|
StreamingFileEditTracker,
|
||||||
)
|
)
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
@@ -58,11 +59,14 @@ _SNIP_SAFETY_BUFFER = 1024
|
|||||||
_MICROCOMPACT_KEEP_RECENT = 10
|
_MICROCOMPACT_KEEP_RECENT = 10
|
||||||
_MICROCOMPACT_MIN_CHARS = 500
|
_MICROCOMPACT_MIN_CHARS = 500
|
||||||
_COMPACTABLE_TOOLS = frozenset({
|
_COMPACTABLE_TOOLS = frozenset({
|
||||||
"read_file", "exec", "grep",
|
"read_file", "exec", "grep", "find_files",
|
||||||
"web_search", "web_fetch", "list_dir",
|
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||||
})
|
})
|
||||||
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||||
|
|
||||||
|
# Backward-compatible module attribute for tests/extensions that monkeypatch
|
||||||
|
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
|
||||||
|
prepare_file_edit_tracker = _prepare_file_edit_tracker
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -857,8 +861,8 @@ class AgentRunner:
|
|||||||
and on_progress_accepts_file_edit_events(spec.progress_callback)
|
and on_progress_accepts_file_edit_events(spec.progress_callback)
|
||||||
)
|
)
|
||||||
progress_callback = spec.progress_callback if emit_file_edit_events else None
|
progress_callback = spec.progress_callback if emit_file_edit_events else None
|
||||||
file_edit_tracker = (
|
file_edit_trackers = (
|
||||||
prepare_file_edit_tracker(
|
prepare_file_edit_trackers(
|
||||||
call_id=tool_call.id,
|
call_id=tool_call.id,
|
||||||
tool_name=tool_call.name,
|
tool_name=tool_call.name,
|
||||||
tool=tool,
|
tool=tool,
|
||||||
@@ -868,13 +872,13 @@ class AgentRunner:
|
|||||||
if progress_callback is not None
|
if progress_callback is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
if file_edit_tracker is not None and progress_callback is not None:
|
if file_edit_trackers and progress_callback is not None:
|
||||||
await invoke_file_edit_progress(
|
await invoke_file_edit_progress(
|
||||||
progress_callback,
|
progress_callback,
|
||||||
[build_file_edit_start_event(
|
[build_file_edit_start_event(
|
||||||
file_edit_tracker,
|
file_edit_tracker,
|
||||||
params if isinstance(params, dict) else None,
|
params if isinstance(params, dict) else None,
|
||||||
)],
|
) for file_edit_tracker in file_edit_trackers],
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if tool is not None:
|
if tool is not None:
|
||||||
@@ -884,10 +888,13 @@ class AgentRunner:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
if file_edit_tracker is not None and progress_callback is not None:
|
if file_edit_trackers and progress_callback is not None:
|
||||||
await invoke_file_edit_progress(
|
await invoke_file_edit_progress(
|
||||||
progress_callback,
|
progress_callback,
|
||||||
[build_file_edit_error_event(file_edit_tracker, str(exc))],
|
[
|
||||||
|
build_file_edit_error_event(file_edit_tracker, str(exc))
|
||||||
|
for file_edit_tracker in file_edit_trackers
|
||||||
|
],
|
||||||
)
|
)
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
@@ -910,10 +917,13 @@ class AgentRunner:
|
|||||||
return payload, event, None
|
return payload, event, None
|
||||||
|
|
||||||
if isinstance(result, str) and result.startswith("Error"):
|
if isinstance(result, str) and result.startswith("Error"):
|
||||||
if file_edit_tracker is not None and progress_callback is not None:
|
if file_edit_trackers and progress_callback is not None:
|
||||||
await invoke_file_edit_progress(
|
await invoke_file_edit_progress(
|
||||||
progress_callback,
|
progress_callback,
|
||||||
[build_file_edit_error_event(file_edit_tracker, result)],
|
[
|
||||||
|
build_file_edit_error_event(file_edit_tracker, result)
|
||||||
|
for file_edit_tracker in file_edit_trackers
|
||||||
|
],
|
||||||
)
|
)
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
@@ -933,13 +943,13 @@ class AgentRunner:
|
|||||||
return result + hint, event, RuntimeError(result)
|
return result + hint, event, RuntimeError(result)
|
||||||
return result + hint, event, None
|
return result + hint, event, None
|
||||||
|
|
||||||
if file_edit_tracker is not None and progress_callback is not None:
|
if file_edit_trackers and progress_callback is not None:
|
||||||
await invoke_file_edit_progress(
|
await invoke_file_edit_progress(
|
||||||
progress_callback,
|
progress_callback,
|
||||||
[build_file_edit_end_event(
|
[build_file_edit_end_event(
|
||||||
file_edit_tracker,
|
file_edit_tracker,
|
||||||
params if isinstance(params, dict) else None,
|
params if isinstance(params, dict) else None,
|
||||||
)],
|
) for file_edit_tracker in file_edit_trackers],
|
||||||
)
|
)
|
||||||
|
|
||||||
detail = "" if result is None else str(result)
|
detail = "" if result is None else str(result)
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Apply file edits by providing structured edit instructions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.agent.tools.base import tool_parameters
|
||||||
|
from nanobot.agent.tools.filesystem import _FsTool
|
||||||
|
from nanobot.agent.tools.schema import (
|
||||||
|
ArraySchema,
|
||||||
|
BooleanSchema,
|
||||||
|
ObjectSchema,
|
||||||
|
StringSchema,
|
||||||
|
tool_parameters_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _PatchSummary:
|
||||||
|
action: str
|
||||||
|
path: str
|
||||||
|
added: int = 0
|
||||||
|
deleted: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class _PatchError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_relative_path(path: str) -> str:
|
||||||
|
normalized = path.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise _PatchError("patch path cannot be empty")
|
||||||
|
if "\0" in normalized:
|
||||||
|
raise _PatchError(f"patch path contains a null byte: {path!r}")
|
||||||
|
if normalized.startswith(("~", "/", "\\")) or _ABSOLUTE_WINDOWS_RE.match(normalized):
|
||||||
|
raise _PatchError(f"patch path must be relative: {path}")
|
||||||
|
if any(part == ".." for part in re.split(r"[\\/]+", normalized)):
|
||||||
|
raise _PatchError(f"patch path must not contain '..': {path}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _lines_to_text(lines: list[str]) -> str:
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _text_line_count(text: str) -> int:
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
return len(text.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def _line_diff_stats(before: str, after: str) -> tuple[int, int]:
|
||||||
|
before_lines = before.replace("\r\n", "\n").splitlines()
|
||||||
|
after_lines = after.replace("\r\n", "\n").splitlines()
|
||||||
|
added = 0
|
||||||
|
deleted = 0
|
||||||
|
matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == "equal":
|
||||||
|
continue
|
||||||
|
if tag in ("replace", "delete"):
|
||||||
|
deleted += i2 - i1
|
||||||
|
if tag in ("replace", "insert"):
|
||||||
|
added += j2 - j1
|
||||||
|
return added, deleted
|
||||||
|
|
||||||
|
|
||||||
|
def _format_summary(summary: _PatchSummary) -> str:
|
||||||
|
stats = ""
|
||||||
|
if summary.added or summary.deleted:
|
||||||
|
stats = f" (+{summary.added}/-{summary.deleted})"
|
||||||
|
return f"- {summary.action} {summary.path}{stats}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(
|
||||||
|
tool_parameters_schema(
|
||||||
|
edits=ArraySchema(
|
||||||
|
items=ObjectSchema(
|
||||||
|
path=StringSchema("Relative path to the file to edit."),
|
||||||
|
action=StringSchema(
|
||||||
|
"Operation type: replace (find and replace text), add (append new content or create file), delete (remove text).",
|
||||||
|
enum=["replace", "add", "delete"],
|
||||||
|
),
|
||||||
|
old_text=StringSchema(
|
||||||
|
"Exact text to search for in the file. Required for replace and delete.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
new_text=StringSchema(
|
||||||
|
"Text to replace with or append. Required for replace and add.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
required=["path", "action"],
|
||||||
|
),
|
||||||
|
description="List of edits to apply. Each edit specifies a file and the change to make.",
|
||||||
|
min_items=1,
|
||||||
|
max_items=20,
|
||||||
|
),
|
||||||
|
dry_run=BooleanSchema(
|
||||||
|
description="Validate and summarize the patch without writing files.",
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
|
required=["edits"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class ApplyPatchTool(_FsTool):
|
||||||
|
"""Apply file edits by providing structured edit instructions."""
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "apply_patch"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"Default tool for code edits. Supports multi-file changes in a single call. "
|
||||||
|
"Provide a list of structured edits, each specifying a file path, action (replace/add/delete), and the text to change. "
|
||||||
|
"Paths must be relative. Set dry_run=true to validate and preview without writing files. "
|
||||||
|
"Use edit_file only for small exact replacements on a single file."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
edits: list[dict] | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
if not edits:
|
||||||
|
raise _PatchError("must provide edits")
|
||||||
|
|
||||||
|
writes: dict[Path, str] = {}
|
||||||
|
deletes: set[Path] = set()
|
||||||
|
summaries: list[_PatchSummary] = []
|
||||||
|
|
||||||
|
for edit in edits:
|
||||||
|
if not isinstance(edit, dict):
|
||||||
|
raise _PatchError("each edit must be an object")
|
||||||
|
raw_path = edit.get("path")
|
||||||
|
if not isinstance(raw_path, str):
|
||||||
|
raise _PatchError("path required for edit")
|
||||||
|
path = _validate_relative_path(raw_path)
|
||||||
|
action = edit.get("action")
|
||||||
|
if not isinstance(action, str):
|
||||||
|
raise _PatchError(f"action required for edit: {path}")
|
||||||
|
source = self._resolve(path)
|
||||||
|
|
||||||
|
if action == "add":
|
||||||
|
new_text = edit.get("new_text")
|
||||||
|
if new_text is None:
|
||||||
|
raise _PatchError(f"new_text required for add: {path}")
|
||||||
|
|
||||||
|
pending = writes.get(source)
|
||||||
|
if pending is not None:
|
||||||
|
content = pending
|
||||||
|
exists = True
|
||||||
|
elif source.exists():
|
||||||
|
raw = source.read_bytes()
|
||||||
|
try:
|
||||||
|
content = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise _PatchError(f"file is not UTF-8 text: {path}")
|
||||||
|
exists = True
|
||||||
|
else:
|
||||||
|
content = ""
|
||||||
|
exists = False
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
uses_crlf = "\r\n" in content
|
||||||
|
new_norm = content.replace("\r\n", "\n") + new_text.replace("\r\n", "\n")
|
||||||
|
if new_norm and not new_norm.endswith("\n"):
|
||||||
|
new_norm += "\n"
|
||||||
|
if uses_crlf:
|
||||||
|
new_norm = new_norm.replace("\n", "\r\n")
|
||||||
|
writes[source] = new_norm
|
||||||
|
deletes.discard(source)
|
||||||
|
added, deleted = _line_diff_stats(content, new_norm)
|
||||||
|
action_name = "update"
|
||||||
|
else:
|
||||||
|
new_norm = new_text.replace("\r\n", "\n")
|
||||||
|
if new_norm and not new_norm.endswith("\n"):
|
||||||
|
new_norm += "\n"
|
||||||
|
writes[source] = new_norm
|
||||||
|
deletes.discard(source)
|
||||||
|
added = _text_line_count(new_norm)
|
||||||
|
deleted = 0
|
||||||
|
action_name = "add"
|
||||||
|
|
||||||
|
summaries.append(
|
||||||
|
_PatchSummary(
|
||||||
|
action=action_name, path=path, added=added, deleted=deleted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "replace":
|
||||||
|
old_text = edit.get("old_text") or ""
|
||||||
|
if not old_text:
|
||||||
|
raise _PatchError(f"old_text required for replace: {path}")
|
||||||
|
new_text = edit.get("new_text")
|
||||||
|
if new_text is None:
|
||||||
|
raise _PatchError(f"new_text required for replace: {path}")
|
||||||
|
|
||||||
|
pending = writes.get(source)
|
||||||
|
if pending is not None:
|
||||||
|
content = pending
|
||||||
|
elif source.exists():
|
||||||
|
raw = source.read_bytes()
|
||||||
|
try:
|
||||||
|
content = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise _PatchError(f"file is not UTF-8 text: {path}")
|
||||||
|
else:
|
||||||
|
raise _PatchError(f"file to update does not exist: {path}")
|
||||||
|
|
||||||
|
if pending is None and not source.is_file():
|
||||||
|
raise _PatchError(f"path to update is not a file: {path}")
|
||||||
|
|
||||||
|
uses_crlf = "\r\n" in content
|
||||||
|
norm_content = content.replace("\r\n", "\n")
|
||||||
|
norm_old = old_text.replace("\r\n", "\n")
|
||||||
|
|
||||||
|
pos = norm_content.find(norm_old)
|
||||||
|
if pos < 0:
|
||||||
|
raise _PatchError(f"old_text not found in {path}")
|
||||||
|
if norm_content.find(norm_old, pos + 1) >= 0:
|
||||||
|
raise _PatchError(f"old_text appears multiple times in {path}")
|
||||||
|
|
||||||
|
new_norm = (
|
||||||
|
norm_content[:pos]
|
||||||
|
+ new_text.replace("\r\n", "\n")
|
||||||
|
+ norm_content[pos + len(norm_old) :]
|
||||||
|
)
|
||||||
|
if new_norm and not new_norm.endswith("\n"):
|
||||||
|
new_norm += "\n"
|
||||||
|
if uses_crlf:
|
||||||
|
new_norm = new_norm.replace("\n", "\r\n")
|
||||||
|
|
||||||
|
writes[source] = new_norm
|
||||||
|
deletes.discard(source)
|
||||||
|
added, deleted = _line_diff_stats(content, new_norm)
|
||||||
|
summaries.append(
|
||||||
|
_PatchSummary(
|
||||||
|
action="update", path=path, added=added, deleted=deleted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
old_text = edit.get("old_text") or ""
|
||||||
|
if not old_text:
|
||||||
|
raise _PatchError(f"old_text required for delete: {path}")
|
||||||
|
|
||||||
|
pending = writes.get(source)
|
||||||
|
if pending is not None:
|
||||||
|
content = pending
|
||||||
|
elif source.exists():
|
||||||
|
raw = source.read_bytes()
|
||||||
|
try:
|
||||||
|
content = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise _PatchError(f"file is not UTF-8 text: {path}")
|
||||||
|
else:
|
||||||
|
raise _PatchError(f"file to update does not exist: {path}")
|
||||||
|
|
||||||
|
if pending is None and not source.is_file():
|
||||||
|
raise _PatchError(f"path to update is not a file: {path}")
|
||||||
|
|
||||||
|
uses_crlf = "\r\n" in content
|
||||||
|
norm_content = content.replace("\r\n", "\n")
|
||||||
|
norm_old = old_text.replace("\r\n", "\n")
|
||||||
|
|
||||||
|
pos = norm_content.find(norm_old)
|
||||||
|
if pos < 0:
|
||||||
|
raise _PatchError(f"old_text not found in {path}")
|
||||||
|
if norm_content.find(norm_old, pos + 1) >= 0:
|
||||||
|
raise _PatchError(f"old_text appears multiple times in {path}")
|
||||||
|
|
||||||
|
if norm_old == norm_content:
|
||||||
|
deletes.add(source)
|
||||||
|
writes.pop(source, None)
|
||||||
|
added, deleted = 0, _text_line_count(content)
|
||||||
|
summaries.append(
|
||||||
|
_PatchSummary(
|
||||||
|
action="delete", path=path, added=added, deleted=deleted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_norm = (
|
||||||
|
norm_content[:pos] + norm_content[pos + len(norm_old) :]
|
||||||
|
)
|
||||||
|
if new_norm and not new_norm.endswith("\n"):
|
||||||
|
new_norm += "\n"
|
||||||
|
if uses_crlf:
|
||||||
|
new_norm = new_norm.replace("\n", "\r\n")
|
||||||
|
writes[source] = new_norm
|
||||||
|
deletes.discard(source)
|
||||||
|
added, deleted = _line_diff_stats(content, new_norm)
|
||||||
|
summaries.append(
|
||||||
|
_PatchSummary(
|
||||||
|
action="update", path=path, added=added, deleted=deleted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise _PatchError(f"unknown action: {action}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return "Patch dry-run succeeded:\n" + "\n".join(
|
||||||
|
_format_summary(summary) for summary in summaries
|
||||||
|
)
|
||||||
|
|
||||||
|
backups: dict[Path, bytes | None] = {}
|
||||||
|
for path in set(writes) | deletes:
|
||||||
|
backups[path] = path.read_bytes() if path.exists() else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
for path in deletes:
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
for path, content in writes.items():
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8", newline="")
|
||||||
|
except Exception:
|
||||||
|
for path, data in backups.items():
|
||||||
|
if data is None:
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
else:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(data)
|
||||||
|
raise
|
||||||
|
|
||||||
|
for path in set(writes) | deletes:
|
||||||
|
self._file_states.record_write(path)
|
||||||
|
return "Patch applied:\n" + "\n".join(
|
||||||
|
_format_summary(summary) for summary in summaries
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
except _PatchError as exc:
|
||||||
|
return f"Error applying patch: {exc}"
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error applying patch: {exc}"
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Controlled runner for installed CLI Apps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
|
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||||
|
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CliAppsToolConfig(Base):
|
||||||
|
"""CLI Apps tool configuration."""
|
||||||
|
|
||||||
|
enable: bool = True
|
||||||
|
install_timeout: int = Field(default=300, ge=1, le=3600)
|
||||||
|
run_timeout: int = Field(default=60, ge=1, le=600)
|
||||||
|
catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400)
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(
|
||||||
|
tool_parameters_schema(
|
||||||
|
required=["name"],
|
||||||
|
name=StringSchema("Installed CLI app registry name, for example gimp, safari, or obsidian."),
|
||||||
|
args=ArraySchema(
|
||||||
|
StringSchema("One command-line argument."),
|
||||||
|
description="Arguments to pass to the CLI entry point. Do not include the entry point itself.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
json=BooleanSchema(
|
||||||
|
description="Whether to prepend --json when supported by the CLI.",
|
||||||
|
default=False,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
working_dir=StringSchema("Optional working directory for the CLI call.", nullable=True),
|
||||||
|
timeout=IntegerSchema(
|
||||||
|
description="Timeout in seconds for this CLI call.",
|
||||||
|
minimum=1,
|
||||||
|
maximum=600,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class CliAppsTool(Tool):
|
||||||
|
"""Run an installed CLI-Anything or public CLI app through a controlled argv subprocess."""
|
||||||
|
|
||||||
|
config_key = "cli_apps"
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_cls(cls):
|
||||||
|
return CliAppsToolConfig
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls, ctx: Any) -> bool:
|
||||||
|
return ctx.config.cli_apps.enable
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, ctx: Any) -> Tool:
|
||||||
|
cfg = ctx.config.cli_apps
|
||||||
|
return cls(
|
||||||
|
workspace=Path(ctx.workspace),
|
||||||
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
|
runtime=CliAppsRuntimeConfig(
|
||||||
|
install_timeout=cfg.install_timeout,
|
||||||
|
run_timeout=cfg.run_timeout,
|
||||||
|
catalog_ttl_seconds=cfg.catalog_ttl_seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workspace: Path,
|
||||||
|
restrict_to_workspace: bool = False,
|
||||||
|
runtime: CliAppsRuntimeConfig | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.workspace = workspace
|
||||||
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
|
self.runtime = runtime or CliAppsRuntimeConfig()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "run_cli_app"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
try:
|
||||||
|
installed = CliAppManager(workspace=self.workspace, runtime=self.runtime).installed_names()
|
||||||
|
except Exception:
|
||||||
|
installed = []
|
||||||
|
installed_note = (
|
||||||
|
f" Installed Settings CLI Apps: {', '.join(installed)}."
|
||||||
|
if installed
|
||||||
|
else " No Settings CLI Apps are currently installed."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"Run a CLI App that the user explicitly installed in Settings or attached as @app. "
|
||||||
|
"Do not use this for ordinary system CLIs such as git, gh, python, npm, or brew; "
|
||||||
|
"unknown names are rejected. Execution uses argv, not shell."
|
||||||
|
+ installed_note
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
args: list[str] | None = None,
|
||||||
|
json: bool | None = False,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
manager = CliAppManager(workspace=self.workspace, runtime=self.runtime)
|
||||||
|
try:
|
||||||
|
return manager.run(
|
||||||
|
name,
|
||||||
|
args=args or [],
|
||||||
|
json_output=bool(json),
|
||||||
|
working_dir=working_dir,
|
||||||
|
timeout=timeout,
|
||||||
|
restrict_to_workspace=self.restrict_to_workspace,
|
||||||
|
)
|
||||||
|
except CliAppError as exc:
|
||||||
|
return f"Error: {exc.message}"
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
"""Session support for long-running exec workflows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
|
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_YIELD_MS = 1000
|
||||||
|
MAX_YIELD_MS = 30_000
|
||||||
|
DEFAULT_WAIT_FOR_MS = 10_000
|
||||||
|
MAX_WAIT_FOR_MS = 120_000
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS = 10_000
|
||||||
|
MAX_OUTPUT_CHARS = 50_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _SessionPoll:
|
||||||
|
output: str
|
||||||
|
done: bool
|
||||||
|
exit_code: int | None
|
||||||
|
elapsed_s: float = 0.0
|
||||||
|
timed_out: bool = False
|
||||||
|
terminated: bool = False
|
||||||
|
stdin_closed: bool = False
|
||||||
|
truncated_chars: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ExecSessionInfo:
|
||||||
|
session_id: str
|
||||||
|
command: str
|
||||||
|
cwd: str
|
||||||
|
elapsed_s: float
|
||||||
|
idle_s: float
|
||||||
|
remaining_s: float
|
||||||
|
returncode: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecSession:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
process: asyncio.subprocess.Process,
|
||||||
|
command: str,
|
||||||
|
cwd: str,
|
||||||
|
timeout: int,
|
||||||
|
) -> None:
|
||||||
|
self.session_id = session_id
|
||||||
|
self.process = process
|
||||||
|
self.command = command
|
||||||
|
self.cwd = cwd
|
||||||
|
self.started_at = time.monotonic()
|
||||||
|
self.deadline = time.monotonic() + timeout
|
||||||
|
self.last_access = time.monotonic()
|
||||||
|
self._chunks: list[str] = []
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._timed_out = False
|
||||||
|
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, ""))
|
||||||
|
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, "STDERR:\n"))
|
||||||
|
|
||||||
|
async def _read_stream(
|
||||||
|
self,
|
||||||
|
stream: asyncio.StreamReader | None,
|
||||||
|
prefix: str,
|
||||||
|
) -> None:
|
||||||
|
if stream is None:
|
||||||
|
return
|
||||||
|
first = True
|
||||||
|
while True:
|
||||||
|
chunk = await stream.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
text = chunk.decode("utf-8", errors="replace")
|
||||||
|
if prefix and first:
|
||||||
|
text = prefix + text
|
||||||
|
first = False
|
||||||
|
async with self._lock:
|
||||||
|
self._chunks.append(text)
|
||||||
|
|
||||||
|
async def write(self, chars: str) -> str | None:
|
||||||
|
if self.process.returncode is not None:
|
||||||
|
return "session has already exited"
|
||||||
|
if self.process.stdin is None:
|
||||||
|
return "session stdin is not available"
|
||||||
|
try:
|
||||||
|
self.process.stdin.write(chars.encode("utf-8"))
|
||||||
|
await self.process.stdin.drain()
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
return "session stdin is closed"
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def close_stdin(self) -> str | None:
|
||||||
|
if self.process.returncode is not None:
|
||||||
|
return "session has already exited"
|
||||||
|
if self.process.stdin is None:
|
||||||
|
return "session stdin is not available"
|
||||||
|
self.process.stdin.close()
|
||||||
|
with suppress(BrokenPipeError, ConnectionResetError):
|
||||||
|
await self.process.stdin.wait_closed()
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def poll(
|
||||||
|
self,
|
||||||
|
yield_time_ms: int,
|
||||||
|
max_output_chars: int,
|
||||||
|
*,
|
||||||
|
terminated: bool = False,
|
||||||
|
stdin_closed: bool = False,
|
||||||
|
) -> _SessionPoll:
|
||||||
|
self.last_access = time.monotonic()
|
||||||
|
if yield_time_ms > 0 and self.process.returncode is None:
|
||||||
|
await asyncio.sleep(min(yield_time_ms, MAX_YIELD_MS) / 1000)
|
||||||
|
|
||||||
|
if self.process.returncode is None and time.monotonic() >= self.deadline:
|
||||||
|
self._timed_out = True
|
||||||
|
await self.kill()
|
||||||
|
|
||||||
|
if self.process.returncode is not None:
|
||||||
|
with suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(self._stdout_task, self._stderr_task),
|
||||||
|
timeout=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
output = "".join(self._chunks)
|
||||||
|
self._chunks.clear()
|
||||||
|
|
||||||
|
output, truncated = _truncate_output(output, max_output_chars)
|
||||||
|
return _SessionPoll(
|
||||||
|
output=output,
|
||||||
|
done=self.process.returncode is not None,
|
||||||
|
exit_code=self.process.returncode,
|
||||||
|
elapsed_s=max(0.0, time.monotonic() - self.started_at),
|
||||||
|
timed_out=self._timed_out,
|
||||||
|
terminated=terminated,
|
||||||
|
stdin_closed=stdin_closed,
|
||||||
|
truncated_chars=truncated,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def kill(self) -> None:
|
||||||
|
if self.process.returncode is not None:
|
||||||
|
return
|
||||||
|
self.process.kill()
|
||||||
|
with suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecSessionManager:
|
||||||
|
def __init__(self, *, max_sessions: int = 8, idle_timeout: int = 1800) -> None:
|
||||||
|
self.max_sessions = max_sessions
|
||||||
|
self.idle_timeout = idle_timeout
|
||||||
|
self._sessions: dict[str, _ExecSession] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
command: str,
|
||||||
|
cwd: str,
|
||||||
|
env: dict[str, str],
|
||||||
|
timeout: int,
|
||||||
|
shell_program: str | None,
|
||||||
|
login: bool,
|
||||||
|
yield_time_ms: int,
|
||||||
|
max_output_chars: int,
|
||||||
|
) -> tuple[str, _SessionPoll]:
|
||||||
|
async with self._lock:
|
||||||
|
await self._cleanup_locked()
|
||||||
|
if len(self._sessions) >= self.max_sessions:
|
||||||
|
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
|
||||||
|
process = await self._spawn(command, cwd, env, shell_program, login)
|
||||||
|
session_id = uuid.uuid4().hex[:12]
|
||||||
|
session = _ExecSession(
|
||||||
|
session_id=session_id,
|
||||||
|
process=process,
|
||||||
|
command=command,
|
||||||
|
cwd=cwd,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
self._sessions[session_id] = session
|
||||||
|
|
||||||
|
poll = await session.poll(yield_time_ms, max_output_chars)
|
||||||
|
if poll.done:
|
||||||
|
async with self._lock:
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
return session_id, poll
|
||||||
|
|
||||||
|
async def write(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
chars: str | None,
|
||||||
|
close_stdin: bool,
|
||||||
|
terminate: bool,
|
||||||
|
yield_time_ms: int,
|
||||||
|
max_output_chars: int,
|
||||||
|
) -> _SessionPoll:
|
||||||
|
async with self._lock:
|
||||||
|
await self._cleanup_locked()
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if session is None:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
|
||||||
|
if chars:
|
||||||
|
error = await session.write(chars)
|
||||||
|
if error:
|
||||||
|
raise RuntimeError(error)
|
||||||
|
stdin_closed = False
|
||||||
|
if close_stdin:
|
||||||
|
error = await session.close_stdin()
|
||||||
|
if error:
|
||||||
|
raise RuntimeError(error)
|
||||||
|
stdin_closed = True
|
||||||
|
if terminate:
|
||||||
|
await session.kill()
|
||||||
|
poll = await session.poll(
|
||||||
|
yield_time_ms,
|
||||||
|
max_output_chars,
|
||||||
|
terminated=terminate,
|
||||||
|
stdin_closed=stdin_closed,
|
||||||
|
)
|
||||||
|
if poll.done:
|
||||||
|
async with self._lock:
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
return poll
|
||||||
|
|
||||||
|
async def list(self) -> list[ExecSessionInfo]:
|
||||||
|
async with self._lock:
|
||||||
|
await self._cleanup_locked()
|
||||||
|
now = time.monotonic()
|
||||||
|
return [
|
||||||
|
ExecSessionInfo(
|
||||||
|
session_id=session_id,
|
||||||
|
command=session.command,
|
||||||
|
cwd=session.cwd,
|
||||||
|
elapsed_s=max(0.0, now - session.started_at),
|
||||||
|
idle_s=max(0.0, now - session.last_access),
|
||||||
|
remaining_s=max(0.0, session.deadline - now),
|
||||||
|
returncode=session.process.returncode,
|
||||||
|
)
|
||||||
|
for session_id, session in sorted(self._sessions.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _cleanup_locked(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
stale = [
|
||||||
|
session_id
|
||||||
|
for session_id, session in self._sessions.items()
|
||||||
|
if now - session.last_access > self.idle_timeout
|
||||||
|
]
|
||||||
|
for session_id in stale:
|
||||||
|
session = self._sessions.pop(session_id)
|
||||||
|
await session.kill()
|
||||||
|
|
||||||
|
async def _spawn(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
cwd: str,
|
||||||
|
env: dict[str, str],
|
||||||
|
shell_program: str | None,
|
||||||
|
login: bool,
|
||||||
|
) -> asyncio.subprocess.Process:
|
||||||
|
from nanobot.agent.tools import shell
|
||||||
|
|
||||||
|
if shell._IS_WINDOWS:
|
||||||
|
return await asyncio.create_subprocess_shell(
|
||||||
|
command,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||||
|
args = [shell_program]
|
||||||
|
if login and shell_program.rsplit("/", 1)[-1] in {"bash", "zsh"}:
|
||||||
|
args.append("-l")
|
||||||
|
args.extend(["-c", command])
|
||||||
|
return await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_EXEC_SESSION_MANAGER = ExecSessionManager()
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_session_int(value: int | None, default: int, minimum: int, maximum: int) -> int:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return min(max(value, minimum), maximum)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
|
||||||
|
if len(output) <= max_output_chars:
|
||||||
|
return output, 0
|
||||||
|
half = max_output_chars // 2
|
||||||
|
omitted = len(output) - max_output_chars
|
||||||
|
return (
|
||||||
|
output[:half]
|
||||||
|
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
|
||||||
|
+ output[-half:],
|
||||||
|
omitted,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
||||||
|
parts = [poll.output] if poll.output else []
|
||||||
|
if poll.truncated_chars:
|
||||||
|
parts.append(f"(output truncated by {poll.truncated_chars:,} chars)")
|
||||||
|
if poll.timed_out:
|
||||||
|
parts.append("Error: Command timed out; session was terminated.")
|
||||||
|
if poll.terminated and not poll.timed_out:
|
||||||
|
parts.append("Session terminated.")
|
||||||
|
if poll.stdin_closed:
|
||||||
|
parts.append("Stdin closed.")
|
||||||
|
if poll.done:
|
||||||
|
parts.append(f"Exit code: {poll.exit_code}")
|
||||||
|
else:
|
||||||
|
parts.append(f"Process running. session_id: {session_id}")
|
||||||
|
parts.append(f"Elapsed: {poll.elapsed_s:.1f}s")
|
||||||
|
return "\n".join(parts) if parts else "(no output yet)"
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(
|
||||||
|
tool_parameters_schema(
|
||||||
|
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
|
||||||
|
chars=StringSchema(
|
||||||
|
"Bytes/text to write to stdin. Omit or pass an empty string to only poll recent output.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
close_stdin=BooleanSchema(
|
||||||
|
description="Close stdin after writing chars. Useful for commands waiting for EOF.",
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
|
terminate=BooleanSchema(
|
||||||
|
description="Terminate the running exec session.",
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
|
yield_time_ms=IntegerSchema(
|
||||||
|
DEFAULT_YIELD_MS,
|
||||||
|
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
|
||||||
|
minimum=0,
|
||||||
|
maximum=MAX_YIELD_MS,
|
||||||
|
),
|
||||||
|
wait_for=StringSchema(
|
||||||
|
"Optional text to wait for in output before returning. "
|
||||||
|
"Useful for interactive commands and dev servers.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
wait_timeout_ms=IntegerSchema(
|
||||||
|
DEFAULT_WAIT_FOR_MS,
|
||||||
|
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
|
||||||
|
minimum=0,
|
||||||
|
maximum=MAX_WAIT_FOR_MS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
max_output_chars=IntegerSchema(
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
description="Maximum output characters to return from this poll (default 10000, max 50000).",
|
||||||
|
minimum=1000,
|
||||||
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
),
|
||||||
|
max_output_tokens=IntegerSchema(
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
|
||||||
|
minimum=1000,
|
||||||
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
required=["session_id"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class WriteStdinTool(Tool):
|
||||||
|
"""Write to or poll a running exec session."""
|
||||||
|
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
config_key = "exec"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_cls(cls):
|
||||||
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
|
|
||||||
|
return ExecToolConfig
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls, ctx: Any) -> bool:
|
||||||
|
return ctx.config.exec.enable
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
manager: ExecSessionManager | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, ctx: Any) -> Tool:
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exclusive(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "write_stdin"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"Interact with a running exec session created by exec with "
|
||||||
|
"yield_time_ms. Use chars='' to poll without writing, chars to send "
|
||||||
|
"stdin, close_stdin=true to send EOF, or terminate=true to stop the "
|
||||||
|
"process. Use wait_for with wait_timeout_ms for dev servers, test "
|
||||||
|
"watchers, and prompts where you need to wait for expected output. "
|
||||||
|
"Do not use this to start new commands; start them with exec."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
chars: str | None = None,
|
||||||
|
close_stdin: bool = False,
|
||||||
|
terminate: bool = False,
|
||||||
|
yield_time_ms: int | None = None,
|
||||||
|
wait_for: str | None = None,
|
||||||
|
wait_timeout_ms: int | None = None,
|
||||||
|
max_output_chars: int | None = None,
|
||||||
|
max_output_tokens: int | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
if max_output_chars is None:
|
||||||
|
max_output_chars = max_output_tokens
|
||||||
|
output_limit = clamp_session_int(
|
||||||
|
max_output_chars,
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
1000,
|
||||||
|
MAX_OUTPUT_CHARS,
|
||||||
|
)
|
||||||
|
if wait_for:
|
||||||
|
return await self._wait_for_output(
|
||||||
|
session_id=session_id,
|
||||||
|
chars=chars,
|
||||||
|
close_stdin=close_stdin,
|
||||||
|
terminate=terminate,
|
||||||
|
wait_for=wait_for,
|
||||||
|
wait_timeout_ms=clamp_session_int(
|
||||||
|
wait_timeout_ms,
|
||||||
|
DEFAULT_WAIT_FOR_MS,
|
||||||
|
0,
|
||||||
|
MAX_WAIT_FOR_MS,
|
||||||
|
),
|
||||||
|
max_output_chars=output_limit,
|
||||||
|
)
|
||||||
|
poll = await self._manager.write(
|
||||||
|
session_id=session_id,
|
||||||
|
chars=chars,
|
||||||
|
close_stdin=close_stdin,
|
||||||
|
terminate=terminate,
|
||||||
|
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
|
||||||
|
max_output_chars=output_limit,
|
||||||
|
)
|
||||||
|
return format_session_poll(session_id, poll)
|
||||||
|
except KeyError:
|
||||||
|
return f"Error: exec session not found: {session_id}"
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error writing to exec session: {exc}"
|
||||||
|
|
||||||
|
async def _wait_for_output(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
chars: str | None,
|
||||||
|
close_stdin: bool,
|
||||||
|
terminate: bool,
|
||||||
|
wait_for: str,
|
||||||
|
wait_timeout_ms: int,
|
||||||
|
max_output_chars: int,
|
||||||
|
) -> str:
|
||||||
|
deadline = time.monotonic() + (wait_timeout_ms / 1000)
|
||||||
|
aggregate: list[str] = []
|
||||||
|
first = True
|
||||||
|
poll: _SessionPoll | None = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
|
||||||
|
step_ms = min(500, remaining_ms)
|
||||||
|
poll = await self._manager.write(
|
||||||
|
session_id=session_id,
|
||||||
|
chars=chars if first else None,
|
||||||
|
close_stdin=close_stdin if first else False,
|
||||||
|
terminate=terminate if first else False,
|
||||||
|
yield_time_ms=step_ms,
|
||||||
|
max_output_chars=max_output_chars,
|
||||||
|
)
|
||||||
|
first = False
|
||||||
|
if poll.output:
|
||||||
|
aggregate.append(poll.output)
|
||||||
|
joined = "".join(aggregate)
|
||||||
|
if wait_for in joined:
|
||||||
|
poll.output = joined
|
||||||
|
return format_session_poll(session_id, poll)
|
||||||
|
if poll.done or remaining_ms <= 0:
|
||||||
|
poll.output = "".join(aggregate)
|
||||||
|
result = format_session_poll(session_id, poll)
|
||||||
|
if wait_for not in poll.output:
|
||||||
|
result += f"\nWait target not observed: {wait_for!r}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(tool_parameters_schema())
|
||||||
|
class ListExecSessionsTool(Tool):
|
||||||
|
"""List active exec sessions."""
|
||||||
|
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
config_key = "exec"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_cls(cls):
|
||||||
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
|
|
||||||
|
return ExecToolConfig
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls, ctx: Any) -> bool:
|
||||||
|
return ctx.config.exec.enable
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
manager: ExecSessionManager | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, ctx: Any) -> Tool:
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "list_exec_sessions"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"List active long-running exec sessions, including session_id, cwd, "
|
||||||
|
"elapsed time, idle time, remaining timeout, and command preview. "
|
||||||
|
"Use this to recover a session_id after context shifts before "
|
||||||
|
"polling, writing stdin, or terminating with write_stdin."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def read_only(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def execute(self, **kwargs: Any) -> str:
|
||||||
|
try:
|
||||||
|
sessions = await self._manager.list()
|
||||||
|
if not sessions:
|
||||||
|
return "No active exec sessions."
|
||||||
|
lines = []
|
||||||
|
for info in sessions:
|
||||||
|
command = " ".join(info.command.split())
|
||||||
|
if len(command) > 120:
|
||||||
|
command = command[:119] + "..."
|
||||||
|
status = "exited" if info.returncode is not None else "running"
|
||||||
|
lines.append(
|
||||||
|
f"{info.session_id} | {status} | elapsed={info.elapsed_s:.1f}s "
|
||||||
|
f"| idle={info.idle_s:.1f}s | remaining={info.remaining_s:.1f}s "
|
||||||
|
f"| cwd={info.cwd} | {command}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error listing exec sessions: {exc}"
|
||||||
@@ -132,6 +132,10 @@ def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
|
|||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
|
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
|
||||||
|
force=BooleanSchema(
|
||||||
|
description="Bypass same-file read deduplication and return content again.",
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
required=["path"],
|
required=["path"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -154,7 +158,11 @@ class ReadFileTool(_FsTool):
|
|||||||
"Text output format: LINE_NUM|CONTENT. "
|
"Text output format: LINE_NUM|CONTENT. "
|
||||||
"Images return visual content for analysis. "
|
"Images return visual content for analysis. "
|
||||||
"Supports PDF, DOCX, XLSX, PPTX documents. "
|
"Supports PDF, DOCX, XLSX, PPTX documents. "
|
||||||
|
"Use find_files/list_dir first when the path is uncertain. "
|
||||||
|
"Read the relevant range before editing so replacements or patches "
|
||||||
|
"are based on current content. "
|
||||||
"Use offset and limit for large text files. "
|
"Use offset and limit for large text files. "
|
||||||
|
"Use force=true to re-read content even if unchanged. "
|
||||||
"Reads exceeding ~128K chars are truncated."
|
"Reads exceeding ~128K chars are truncated."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,7 +170,15 @@ class ReadFileTool(_FsTool):
|
|||||||
def read_only(self) -> bool:
|
def read_only(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, pages: str | None = None, **kwargs: Any) -> Any:
|
async def execute(
|
||||||
|
self,
|
||||||
|
path: str | None = None,
|
||||||
|
offset: int = 1,
|
||||||
|
limit: int | None = None,
|
||||||
|
pages: str | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Any:
|
||||||
try:
|
try:
|
||||||
if not path:
|
if not path:
|
||||||
return "Error reading file: Unknown path"
|
return "Error reading file: Unknown path"
|
||||||
@@ -202,7 +218,13 @@ class ReadFileTool(_FsTool):
|
|||||||
current_mtime = os.path.getmtime(fp)
|
current_mtime = os.path.getmtime(fp)
|
||||||
except OSError:
|
except OSError:
|
||||||
current_mtime = 0.0
|
current_mtime = 0.0
|
||||||
if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit:
|
if (
|
||||||
|
not force
|
||||||
|
and entry
|
||||||
|
and entry.can_dedup
|
||||||
|
and entry.offset == offset
|
||||||
|
and entry.limit == limit
|
||||||
|
):
|
||||||
if current_mtime != entry.mtime:
|
if current_mtime != entry.mtime:
|
||||||
# File was modified externally - force full read and mark as not dedupable
|
# File was modified externally - force full read and mark as not dedupable
|
||||||
entry.can_dedup = False
|
entry.can_dedup = False
|
||||||
@@ -365,9 +387,10 @@ class WriteFileTool(_FsTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Write content to a file. Overwrites if the file already exists; "
|
"Create a new file or intentionally replace an entire file with "
|
||||||
"creates parent directories as needed. "
|
"the provided content. Overwrites existing files and creates parent "
|
||||||
"For partial edits, prefer edit_file instead."
|
"directories as needed. For code changes or partial edits, prefer "
|
||||||
|
"apply_patch; use edit_file only for small exact replacements."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
|
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
|
||||||
@@ -657,6 +680,24 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
|||||||
old_text=StringSchema("The text to find and replace"),
|
old_text=StringSchema("The text to find and replace"),
|
||||||
new_text=StringSchema("The text to replace with"),
|
new_text=StringSchema("The text to replace with"),
|
||||||
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
|
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
|
||||||
|
occurrence=IntegerSchema(
|
||||||
|
1,
|
||||||
|
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
|
||||||
|
minimum=1,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
line_hint=IntegerSchema(
|
||||||
|
1,
|
||||||
|
description="Optional 1-based line hint used to choose the nearest match.",
|
||||||
|
minimum=1,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
expected_replacements=IntegerSchema(
|
||||||
|
1,
|
||||||
|
description="Optional guard for the number of replacements that must be made.",
|
||||||
|
minimum=1,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
required=["path", "old_text", "new_text"],
|
required=["path", "old_text", "new_text"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -674,10 +715,13 @@ class EditFileTool(_FsTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Edit a file by replacing old_text with new_text. "
|
"Perform a small, exact replacement in one file by replacing "
|
||||||
"Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. "
|
"old_text with new_text. Use this for narrow text substitutions "
|
||||||
"If old_text matches multiple times, you must provide more context "
|
"with old_text copied from read_file. For multi-file, structural, "
|
||||||
"or set replace_all=true. Shows a diff of the closest match on failure."
|
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||||
|
"multiple times, provide more context or set occurrence, line_hint, "
|
||||||
|
"replace_all, and expected_replacements. Shows closest-match "
|
||||||
|
"diagnostics on failure."
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -688,7 +732,8 @@ class EditFileTool(_FsTool):
|
|||||||
async def execute(
|
async def execute(
|
||||||
self, path: str | None = None, old_text: str | None = None,
|
self, path: str | None = None, old_text: str | None = None,
|
||||||
new_text: str | None = None,
|
new_text: str | None = None,
|
||||||
replace_all: bool = False, **kwargs: Any,
|
replace_all: bool = False, occurrence: int | None = None,
|
||||||
|
line_hint: int | None = None, expected_replacements: int | None = None, **kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
try:
|
try:
|
||||||
if not path:
|
if not path:
|
||||||
@@ -697,10 +742,12 @@ class EditFileTool(_FsTool):
|
|||||||
raise ValueError("Unknown old_text")
|
raise ValueError("Unknown old_text")
|
||||||
if new_text is None:
|
if new_text is None:
|
||||||
raise ValueError("Unknown new_text")
|
raise ValueError("Unknown new_text")
|
||||||
|
if occurrence is not None and occurrence < 1:
|
||||||
# .ipynb detection
|
return "Error: occurrence must be >= 1."
|
||||||
if path.endswith(".ipynb"):
|
if line_hint is not None and line_hint < 1:
|
||||||
return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file."
|
return "Error: line_hint must be >= 1."
|
||||||
|
if expected_replacements is not None and expected_replacements < 1:
|
||||||
|
return "Error: expected_replacements must be >= 1."
|
||||||
|
|
||||||
fp = self._resolve(path)
|
fp = self._resolve(path)
|
||||||
|
|
||||||
@@ -743,15 +790,42 @@ class EditFileTool(_FsTool):
|
|||||||
if not matches:
|
if not matches:
|
||||||
return self._not_found_msg(old_text, content, path)
|
return self._not_found_msg(old_text, content, path)
|
||||||
count = len(matches)
|
count = len(matches)
|
||||||
|
if replace_all and occurrence is not None:
|
||||||
|
return "Error: occurrence cannot be used with replace_all=true."
|
||||||
|
if replace_all and line_hint is not None:
|
||||||
|
return "Error: line_hint cannot be used with replace_all=true."
|
||||||
|
if occurrence is not None and line_hint is not None:
|
||||||
|
return "Error: line_hint cannot be used with occurrence."
|
||||||
if count > 1 and not replace_all:
|
if count > 1 and not replace_all:
|
||||||
line_numbers = [match.line for match in matches]
|
if occurrence is not None:
|
||||||
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
|
if occurrence > count:
|
||||||
if len(line_numbers) > 3:
|
return (
|
||||||
preview += ", ..."
|
f"Error: occurrence {occurrence} is out of range; "
|
||||||
location_hint = f" at {preview}" if preview else ""
|
f"old_text appears {count} times."
|
||||||
|
)
|
||||||
|
elif line_hint is not None:
|
||||||
|
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
|
||||||
|
distance = abs(nearest.line - line_hint)
|
||||||
|
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
|
||||||
|
return (
|
||||||
|
f"Error: line_hint {line_hint} is ambiguous; "
|
||||||
|
f"old_text appears {count} times."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
line_numbers = [match.line for match in matches]
|
||||||
|
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
|
||||||
|
if len(line_numbers) > 3:
|
||||||
|
preview += ", ..."
|
||||||
|
location_hint = f" at {preview}" if preview else ""
|
||||||
|
return (
|
||||||
|
f"Warning: old_text appears {count} times{location_hint}. "
|
||||||
|
"Provide more context, set occurrence to choose one match, "
|
||||||
|
"or set replace_all=true."
|
||||||
|
)
|
||||||
|
elif occurrence is not None and occurrence > count:
|
||||||
return (
|
return (
|
||||||
f"Warning: old_text appears {count} times{location_hint}. "
|
f"Error: occurrence {occurrence} is out of range; "
|
||||||
"Provide more context to make it unique, or set replace_all=true."
|
f"old_text appears {count} time."
|
||||||
)
|
)
|
||||||
|
|
||||||
norm_new = new_text.replace("\r\n", "\n")
|
norm_new = new_text.replace("\r\n", "\n")
|
||||||
@@ -760,7 +834,17 @@ class EditFileTool(_FsTool):
|
|||||||
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
|
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
|
||||||
norm_new = self._strip_trailing_ws(norm_new)
|
norm_new = self._strip_trailing_ws(norm_new)
|
||||||
|
|
||||||
selected = matches if replace_all else matches[:1]
|
if replace_all:
|
||||||
|
selected = matches
|
||||||
|
elif line_hint is not None:
|
||||||
|
selected = [min(matches, key=lambda match: abs(match.line - line_hint))]
|
||||||
|
else:
|
||||||
|
selected = [matches[occurrence - 1 if occurrence else 0]]
|
||||||
|
if expected_replacements is not None and len(selected) != expected_replacements:
|
||||||
|
return (
|
||||||
|
f"Error: expected {expected_replacements} replacements but "
|
||||||
|
f"would make {len(selected)}."
|
||||||
|
)
|
||||||
new_content = content
|
new_content = content
|
||||||
for match in reversed(selected):
|
for match in reversed(selected):
|
||||||
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
|
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
|
||||||
|
|||||||
@@ -130,12 +130,6 @@ class ImageGenerationTool(Tool):
|
|||||||
}
|
}
|
||||||
return cls(**kwargs)
|
return cls(**kwargs)
|
||||||
|
|
||||||
def _missing_api_key_error(self) -> str:
|
|
||||||
cls = get_image_gen_provider(self.config.provider)
|
|
||||||
if cls and cls.missing_key_message:
|
|
||||||
return f"Error: {cls.missing_key_message}"
|
|
||||||
return f"Error: {self.config.provider} API key is not configured."
|
|
||||||
|
|
||||||
def _resolve_reference_image(self, value: str) -> str:
|
def _resolve_reference_image(self, value: str) -> str:
|
||||||
raw_path = Path(value).expanduser()
|
raw_path = Path(value).expanduser()
|
||||||
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path
|
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path
|
||||||
@@ -173,9 +167,6 @@ class ImageGenerationTool(Tool):
|
|||||||
client = self._provider_client()
|
client = self._provider_client()
|
||||||
if client is None:
|
if client is None:
|
||||||
return f"Error: unsupported image generation provider '{self.config.provider}'"
|
return f"Error: unsupported image generation provider '{self.config.provider}'"
|
||||||
provider = self._provider_config()
|
|
||||||
if not provider or not provider.api_key:
|
|
||||||
return self._missing_api_key_error()
|
|
||||||
|
|
||||||
requested = count or 1
|
requested = count or 1
|
||||||
if requested > self.config.max_images_per_turn:
|
if requested > self.config.max_images_per_turn:
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
"""NotebookEditTool — edit Jupyter .ipynb notebooks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.agent.tools.base import tool_parameters
|
|
||||||
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
|
|
||||||
from nanobot.agent.tools.filesystem import _FsTool
|
|
||||||
|
|
||||||
|
|
||||||
def _new_cell(source: str, cell_type: str = "code", generate_id: bool = False) -> dict:
|
|
||||||
cell: dict[str, Any] = {
|
|
||||||
"cell_type": cell_type,
|
|
||||||
"source": source,
|
|
||||||
"metadata": {},
|
|
||||||
}
|
|
||||||
if cell_type == "code":
|
|
||||||
cell["outputs"] = []
|
|
||||||
cell["execution_count"] = None
|
|
||||||
if generate_id:
|
|
||||||
cell["id"] = uuid.uuid4().hex[:8]
|
|
||||||
return cell
|
|
||||||
|
|
||||||
|
|
||||||
def _make_empty_notebook() -> dict:
|
|
||||||
return {
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5,
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
|
||||||
"language_info": {"name": "python"},
|
|
||||||
},
|
|
||||||
"cells": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
|
||||||
tool_parameters_schema(
|
|
||||||
path=StringSchema("Path to the .ipynb notebook file"),
|
|
||||||
cell_index=IntegerSchema(0, description="0-based index of the cell to edit", minimum=0),
|
|
||||||
new_source=StringSchema("New source content for the cell"),
|
|
||||||
cell_type=StringSchema(
|
|
||||||
"Cell type: 'code' or 'markdown' (default: code)",
|
|
||||||
enum=["code", "markdown"],
|
|
||||||
),
|
|
||||||
edit_mode=StringSchema(
|
|
||||||
"Mode: 'replace' (default), 'insert' (after target), or 'delete'",
|
|
||||||
enum=["replace", "insert", "delete"],
|
|
||||||
),
|
|
||||||
required=["path", "cell_index"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
class NotebookEditTool(_FsTool):
|
|
||||||
"""Edit Jupyter notebook cells: replace, insert, or delete."""
|
|
||||||
_scopes = {"core"}
|
|
||||||
|
|
||||||
_VALID_CELL_TYPES = frozenset({"code", "markdown"})
|
|
||||||
_VALID_EDIT_MODES = frozenset({"replace", "insert", "delete"})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "notebook_edit"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Edit a Jupyter notebook (.ipynb) cell. "
|
|
||||||
"Modes: replace (default) replaces cell content, "
|
|
||||||
"insert adds a new cell after the target index, "
|
|
||||||
"delete removes the cell at the index. "
|
|
||||||
"cell_index is 0-based."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
path: str | None = None,
|
|
||||||
cell_index: int = 0,
|
|
||||||
new_source: str = "",
|
|
||||||
cell_type: str = "code",
|
|
||||||
edit_mode: str = "replace",
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
if not path:
|
|
||||||
return "Error: path is required"
|
|
||||||
|
|
||||||
if not path.endswith(".ipynb"):
|
|
||||||
return "Error: notebook_edit only works on .ipynb files. Use edit_file for other files."
|
|
||||||
|
|
||||||
if edit_mode not in self._VALID_EDIT_MODES:
|
|
||||||
return (
|
|
||||||
f"Error: Invalid edit_mode '{edit_mode}'. "
|
|
||||||
"Use one of: replace, insert, delete."
|
|
||||||
)
|
|
||||||
|
|
||||||
if cell_type not in self._VALID_CELL_TYPES:
|
|
||||||
return (
|
|
||||||
f"Error: Invalid cell_type '{cell_type}'. "
|
|
||||||
"Use one of: code, markdown."
|
|
||||||
)
|
|
||||||
|
|
||||||
fp = self._resolve(path)
|
|
||||||
|
|
||||||
# Create new notebook if file doesn't exist and mode is insert
|
|
||||||
if not fp.exists():
|
|
||||||
if edit_mode != "insert":
|
|
||||||
return f"Error: File not found: {path}"
|
|
||||||
nb = _make_empty_notebook()
|
|
||||||
cell = _new_cell(new_source, cell_type, generate_id=True)
|
|
||||||
nb["cells"].append(cell)
|
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully created {fp} with 1 cell"
|
|
||||||
|
|
||||||
try:
|
|
||||||
nb = json.loads(fp.read_text(encoding="utf-8"))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
||||||
return f"Error: Failed to parse notebook: {e}"
|
|
||||||
|
|
||||||
cells = nb.get("cells", [])
|
|
||||||
nbformat_minor = nb.get("nbformat_minor", 0)
|
|
||||||
generate_id = nb.get("nbformat", 0) >= 4 and nbformat_minor >= 5
|
|
||||||
|
|
||||||
if edit_mode == "delete":
|
|
||||||
if cell_index < 0 or cell_index >= len(cells):
|
|
||||||
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
|
|
||||||
cells.pop(cell_index)
|
|
||||||
nb["cells"] = cells
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully deleted cell {cell_index} from {fp}"
|
|
||||||
|
|
||||||
if edit_mode == "insert":
|
|
||||||
insert_at = min(cell_index + 1, len(cells))
|
|
||||||
cell = _new_cell(new_source, cell_type, generate_id=generate_id)
|
|
||||||
cells.insert(insert_at, cell)
|
|
||||||
nb["cells"] = cells
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully inserted cell at index {insert_at} in {fp}"
|
|
||||||
|
|
||||||
# Default: replace
|
|
||||||
if cell_index < 0 or cell_index >= len(cells):
|
|
||||||
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
|
|
||||||
cells[cell_index]["source"] = new_source
|
|
||||||
if cell_type and cells[cell_index].get("cell_type") != cell_type:
|
|
||||||
cells[cell_index]["cell_type"] = cell_type
|
|
||||||
if cell_type == "code":
|
|
||||||
cells[cell_index].setdefault("outputs", [])
|
|
||||||
cells[cell_index].setdefault("execution_count", None)
|
|
||||||
elif "outputs" in cells[cell_index]:
|
|
||||||
del cells[cell_index]["outputs"]
|
|
||||||
cells[cell_index].pop("execution_count", None)
|
|
||||||
nb["cells"] = cells
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully edited cell {cell_index} in {fp}"
|
|
||||||
|
|
||||||
except PermissionError as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error editing notebook: {e}"
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Search tools: grep."""
|
"""Search tools: file discovery and grep."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from typing import Any, Iterable, TypeVar
|
|||||||
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
||||||
|
|
||||||
_DEFAULT_HEAD_LIMIT = 250
|
_DEFAULT_HEAD_LIMIT = 250
|
||||||
|
_DEFAULT_FILE_HEAD_LIMIT = 200
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
_TYPE_GLOB_MAP = {
|
_TYPE_GLOB_MAP = {
|
||||||
"py": ("*.py", "*.pyi"),
|
"py": ("*.py", "*.pyi"),
|
||||||
@@ -88,6 +89,14 @@ def _matches_type(name: str, file_type: str | None) -> bool:
|
|||||||
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns)
|
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_query(rel_path: str, query: str | None) -> bool:
|
||||||
|
if not query:
|
||||||
|
return True
|
||||||
|
haystack = rel_path.lower()
|
||||||
|
terms = [part for part in query.lower().split() if part]
|
||||||
|
return all(term in haystack for term in terms)
|
||||||
|
|
||||||
|
|
||||||
class _SearchTool(_FsTool):
|
class _SearchTool(_FsTool):
|
||||||
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
|
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
|
||||||
|
|
||||||
@@ -109,6 +118,163 @@ class _SearchTool(_FsTool):
|
|||||||
yield current / filename
|
yield current / filename
|
||||||
|
|
||||||
|
|
||||||
|
class FindFilesTool(_SearchTool):
|
||||||
|
"""Find files by path fragment, glob, or type."""
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "find_files"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"Find files by path fragment, glob, or file type. "
|
||||||
|
"Use this before read_file when you need to locate files, and "
|
||||||
|
"prefer it over shell find/ls for ordinary workspace discovery. "
|
||||||
|
"Returns workspace-relative paths and skips common dependency/build "
|
||||||
|
"directories."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def read_only(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Directory or file to search in (default '.')",
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Optional case-insensitive path fragment search. "
|
||||||
|
"Whitespace-separated terms must all be present."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"glob": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
|
||||||
|
},
|
||||||
|
"include_dirs": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Include matching directories as well as files (default false)",
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["path", "modified"],
|
||||||
|
"description": "Sort by path or most recently modified first (default path)",
|
||||||
|
},
|
||||||
|
"head_limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of paths to return (default 200, 0 for all, max 1000)",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1000,
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Skip the first N results before applying head_limit",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _iter_paths(self, root: Path, *, include_dirs: bool) -> Iterable[Path]:
|
||||||
|
if root.is_file():
|
||||||
|
yield root
|
||||||
|
return
|
||||||
|
if include_dirs:
|
||||||
|
yield root
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
dirnames[:] = sorted(d for d in dirnames if d not in self._IGNORE_DIRS)
|
||||||
|
current = Path(dirpath)
|
||||||
|
if include_dirs and current != root:
|
||||||
|
yield current
|
||||||
|
for filename in sorted(filenames):
|
||||||
|
yield current / filename
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
path: str = ".",
|
||||||
|
query: str | None = None,
|
||||||
|
glob: str | None = None,
|
||||||
|
type: str | None = None,
|
||||||
|
include_dirs: bool = False,
|
||||||
|
sort: str = "path",
|
||||||
|
head_limit: int | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
target = self._resolve(path or ".")
|
||||||
|
if not target.exists():
|
||||||
|
return f"Error: Path not found: {path}"
|
||||||
|
if not (target.is_dir() or target.is_file()):
|
||||||
|
return f"Error: Unsupported path: {path}"
|
||||||
|
|
||||||
|
if sort not in {"path", "modified"}:
|
||||||
|
return "Error: sort must be 'path' or 'modified'"
|
||||||
|
|
||||||
|
limit = (
|
||||||
|
_DEFAULT_FILE_HEAD_LIMIT
|
||||||
|
if head_limit is None
|
||||||
|
else None if head_limit == 0 else head_limit
|
||||||
|
)
|
||||||
|
root = target if target.is_dir() else target.parent
|
||||||
|
matches: list[tuple[str, float]] = []
|
||||||
|
|
||||||
|
for candidate in self._iter_paths(target, include_dirs=include_dirs):
|
||||||
|
if candidate.is_dir() and not include_dirs:
|
||||||
|
continue
|
||||||
|
rel_path = candidate.relative_to(root).as_posix()
|
||||||
|
display_path = self._display_path(candidate, root)
|
||||||
|
name = candidate.name
|
||||||
|
|
||||||
|
if glob and not _match_glob(rel_path, name, glob):
|
||||||
|
continue
|
||||||
|
if candidate.is_file() and not _matches_type(name, type):
|
||||||
|
continue
|
||||||
|
if candidate.is_dir() and type:
|
||||||
|
continue
|
||||||
|
if not _matches_query(display_path, query):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mtime = candidate.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
mtime = 0.0
|
||||||
|
suffix = "/" if candidate.is_dir() else ""
|
||||||
|
matches.append((display_path + suffix, mtime))
|
||||||
|
|
||||||
|
if sort == "modified":
|
||||||
|
matches.sort(key=lambda item: (-item[1], item[0]))
|
||||||
|
else:
|
||||||
|
matches.sort(key=lambda item: item[0])
|
||||||
|
|
||||||
|
paths = [item[0] for item in matches]
|
||||||
|
paged, truncated = _paginate(paths, limit, offset)
|
||||||
|
if not paged:
|
||||||
|
return "No files found"
|
||||||
|
|
||||||
|
result = "\n".join(paged)
|
||||||
|
note = _pagination_note(limit, offset, truncated)
|
||||||
|
if note:
|
||||||
|
result += "\n\n" + note
|
||||||
|
return result
|
||||||
|
except PermissionError as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error finding files: {e}"
|
||||||
|
|
||||||
|
|
||||||
class GrepTool(_SearchTool):
|
class GrepTool(_SearchTool):
|
||||||
"""Search file contents using a regex-like pattern."""
|
"""Search file contents using a regex-like pattern."""
|
||||||
_scopes = {"core", "subagent"}
|
_scopes = {"core", "subagent"}
|
||||||
@@ -125,7 +291,8 @@ class GrepTool(_SearchTool):
|
|||||||
return (
|
return (
|
||||||
"Search file contents with a regex pattern. "
|
"Search file contents with a regex pattern. "
|
||||||
"Default output_mode is files_with_matches (file paths only); "
|
"Default output_mode is files_with_matches (file paths only); "
|
||||||
"use content mode for matching lines with context. "
|
"use content mode for matching lines with context. Prefer this "
|
||||||
|
"over shell grep for ordinary workspace searches. "
|
||||||
"Skips binary and files >2 MB. Supports glob/type filtering."
|
"Skips binary and files >2 MB. Supports glob/type filtering."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+221
-51
@@ -8,6 +8,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -15,8 +16,17 @@ from loguru import logger
|
|||||||
from pydantic import Field
|
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.exec_session import (
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
DEFAULT_YIELD_MS,
|
||||||
|
DEFAULT_EXEC_SESSION_MANAGER,
|
||||||
|
MAX_OUTPUT_CHARS,
|
||||||
|
MAX_YIELD_MS,
|
||||||
|
clamp_session_int,
|
||||||
|
format_session_poll,
|
||||||
|
)
|
||||||
from nanobot.agent.tools.sandbox import wrap_command
|
from nanobot.agent.tools.sandbox import wrap_command
|
||||||
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
@@ -44,10 +54,22 @@ class ExecToolConfig(Base):
|
|||||||
deny_patterns: list[str] = Field(default_factory=list)
|
deny_patterns: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _PreparedCommand:
|
||||||
|
command: str
|
||||||
|
cwd: str
|
||||||
|
env: dict[str, str]
|
||||||
|
timeout: int
|
||||||
|
shell_program: str | None
|
||||||
|
login: bool
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
command=StringSchema("The shell command to execute"),
|
command=StringSchema("The shell command to execute"),
|
||||||
|
cmd=StringSchema("Compatibility alias for command"),
|
||||||
working_dir=StringSchema("Optional working directory for the command"),
|
working_dir=StringSchema("Optional working directory for the command"),
|
||||||
|
workdir=StringSchema("Compatibility alias for working_dir"),
|
||||||
timeout=IntegerSchema(
|
timeout=IntegerSchema(
|
||||||
60,
|
60,
|
||||||
description=(
|
description=(
|
||||||
@@ -57,7 +79,44 @@ class ExecToolConfig(Base):
|
|||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=600,
|
maximum=600,
|
||||||
),
|
),
|
||||||
required=["command"],
|
shell=StringSchema(
|
||||||
|
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
login=BooleanSchema(
|
||||||
|
description="Whether to run bash/zsh with login shell semantics (default true).",
|
||||||
|
default=True,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
yield_time_ms=IntegerSchema(
|
||||||
|
description=(
|
||||||
|
"Optional milliseconds to wait before returning output. "
|
||||||
|
"When set, a still-running command returns a session_id that "
|
||||||
|
"can be polled or written to with write_stdin. Omit this field "
|
||||||
|
"to keep one-shot exec behavior."
|
||||||
|
),
|
||||||
|
minimum=0,
|
||||||
|
maximum=MAX_YIELD_MS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
max_output_chars=IntegerSchema(
|
||||||
|
description=(
|
||||||
|
"Maximum output characters to return when yield_time_ms is used "
|
||||||
|
"(default 10000, max 50000)."
|
||||||
|
),
|
||||||
|
minimum=1000,
|
||||||
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
max_output_tokens=IntegerSchema(
|
||||||
|
description=(
|
||||||
|
"Compatibility alias for max_output_chars. The current runtime "
|
||||||
|
"uses a character budget."
|
||||||
|
),
|
||||||
|
minimum=1000,
|
||||||
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
class ExecTool(Tool):
|
class ExecTool(Tool):
|
||||||
@@ -98,6 +157,7 @@ class ExecTool(Tool):
|
|||||||
sandbox: str = "",
|
sandbox: str = "",
|
||||||
path_append: str = "",
|
path_append: str = "",
|
||||||
allowed_env_keys: list[str] | None = None,
|
allowed_env_keys: list[str] | None = None,
|
||||||
|
session_manager: Any | None = None,
|
||||||
):
|
):
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.working_dir = working_dir
|
self.working_dir = working_dir
|
||||||
@@ -125,6 +185,7 @@ class ExecTool(Tool):
|
|||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self.path_append = path_append
|
self.path_append = path_append
|
||||||
self.allowed_env_keys = allowed_env_keys or []
|
self.allowed_env_keys = allowed_env_keys or []
|
||||||
|
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -150,10 +211,15 @@ class ExecTool(Tool):
|
|||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Execute a shell command and return its output. "
|
"Execute a shell command and return its output. "
|
||||||
"Prefer read_file/write_file/edit_file over cat/echo/sed, "
|
"Use this for tests, builds, package commands, git commands, and "
|
||||||
"and grep/glob over shell find/grep. "
|
"other process execution. Prefer read_file/find_files/grep for "
|
||||||
|
"inspection and apply_patch/write_file/edit_file for file changes "
|
||||||
|
"instead of cat, shell find/grep, echo, or sed. "
|
||||||
"Use -y or --yes flags to avoid interactive prompts. "
|
"Use -y or --yes flags to avoid interactive prompts. "
|
||||||
"Output is truncated at 10 000 chars; timeout defaults to 60s."
|
"For long-running or interactive commands, pass yield_time_ms; "
|
||||||
|
"if the command keeps running, exec returns a session_id that can "
|
||||||
|
"be polled or written to with write_stdin. Output is truncated at "
|
||||||
|
"10 000 chars; timeout defaults to 60s."
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -161,9 +227,111 @@ class ExecTool(Tool):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self, command: str, working_dir: str | None = None,
|
self, command: str | None = None, cmd: str | None = None,
|
||||||
timeout: int | None = None, **kwargs: Any,
|
working_dir: str | None = None, workdir: str | None = None,
|
||||||
|
timeout: int | None = None, shell: str | None = None,
|
||||||
|
login: bool | None = None, yield_time_ms: int | None = None,
|
||||||
|
max_output_chars: int | None = None,
|
||||||
|
max_output_tokens: int | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
command = command or cmd
|
||||||
|
working_dir = working_dir or workdir
|
||||||
|
if not command:
|
||||||
|
return "Error: Missing command. Provide command or cmd."
|
||||||
|
if max_output_chars is None:
|
||||||
|
max_output_chars = max_output_tokens
|
||||||
|
|
||||||
|
prepared = self._prepare_command(command, working_dir, timeout, shell, login)
|
||||||
|
if isinstance(prepared, str):
|
||||||
|
return prepared
|
||||||
|
|
||||||
|
if yield_time_ms is not None:
|
||||||
|
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await self._spawn(
|
||||||
|
prepared.command,
|
||||||
|
prepared.cwd,
|
||||||
|
prepared.env,
|
||||||
|
prepared.shell_program,
|
||||||
|
prepared.login,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
process.communicate(),
|
||||||
|
timeout=prepared.timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await self._kill_process(process)
|
||||||
|
return f"Error: Command timed out after {prepared.timeout} seconds"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await self._kill_process(process)
|
||||||
|
raise
|
||||||
|
|
||||||
|
output_parts = []
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
output_parts.append(stdout.decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
if stderr:
|
||||||
|
stderr_text = stderr.decode("utf-8", errors="replace")
|
||||||
|
if stderr_text.strip():
|
||||||
|
output_parts.append(f"STDERR:\n{stderr_text}")
|
||||||
|
|
||||||
|
output_parts.append(f"\nExit code: {process.returncode}")
|
||||||
|
|
||||||
|
result = "\n".join(output_parts) if output_parts else "(no output)"
|
||||||
|
|
||||||
|
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
|
||||||
|
if len(result) > max_len:
|
||||||
|
half = max_len // 2
|
||||||
|
result = (
|
||||||
|
result[:half]
|
||||||
|
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
|
||||||
|
+ result[-half:]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error executing command: {str(e)}"
|
||||||
|
|
||||||
|
async def _execute_session(
|
||||||
|
self,
|
||||||
|
prepared: _PreparedCommand,
|
||||||
|
yield_time_ms: int | None,
|
||||||
|
max_output_chars: int | None,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
session_id, poll = await self._session_manager.start(
|
||||||
|
command=prepared.command,
|
||||||
|
cwd=prepared.cwd,
|
||||||
|
env=prepared.env,
|
||||||
|
timeout=prepared.timeout,
|
||||||
|
shell_program=prepared.shell_program,
|
||||||
|
login=prepared.login,
|
||||||
|
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
|
||||||
|
max_output_chars=clamp_session_int(
|
||||||
|
max_output_chars,
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
1000,
|
||||||
|
MAX_OUTPUT_CHARS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return format_session_poll(session_id, poll)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error executing command: {exc}"
|
||||||
|
|
||||||
|
def _prepare_command(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
shell: str | None = None,
|
||||||
|
login: bool | None = None,
|
||||||
|
) -> _PreparedCommand | str:
|
||||||
cwd = working_dir or self.working_dir or os.getcwd()
|
cwd = working_dir or self.working_dir or os.getcwd()
|
||||||
|
|
||||||
# Prevent an LLM-supplied working_dir from escaping the configured
|
# Prevent an LLM-supplied working_dir from escaping the configured
|
||||||
@@ -211,52 +379,24 @@ class ExecTool(Tool):
|
|||||||
env["NANOBOT_PATH_APPEND"] = self.path_append
|
env["NANOBOT_PATH_APPEND"] = self.path_append
|
||||||
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
|
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
|
||||||
|
|
||||||
try:
|
shell_program, shell_error = self._resolve_shell(shell)
|
||||||
process = await self._spawn(command, cwd, env)
|
if shell_error:
|
||||||
|
return shell_error
|
||||||
|
|
||||||
try:
|
return _PreparedCommand(
|
||||||
stdout, stderr = await asyncio.wait_for(
|
command=command,
|
||||||
process.communicate(),
|
cwd=cwd,
|
||||||
timeout=effective_timeout,
|
env=env,
|
||||||
)
|
timeout=effective_timeout,
|
||||||
except asyncio.TimeoutError:
|
shell_program=shell_program,
|
||||||
await self._kill_process(process)
|
login=True if login is None else login,
|
||||||
return f"Error: Command timed out after {effective_timeout} seconds"
|
)
|
||||||
except asyncio.CancelledError:
|
|
||||||
await self._kill_process(process)
|
|
||||||
raise
|
|
||||||
|
|
||||||
output_parts = []
|
|
||||||
|
|
||||||
if stdout:
|
|
||||||
output_parts.append(stdout.decode("utf-8", errors="replace"))
|
|
||||||
|
|
||||||
if stderr:
|
|
||||||
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
||||||
if stderr_text.strip():
|
|
||||||
output_parts.append(f"STDERR:\n{stderr_text}")
|
|
||||||
|
|
||||||
output_parts.append(f"\nExit code: {process.returncode}")
|
|
||||||
|
|
||||||
result = "\n".join(output_parts) if output_parts else "(no output)"
|
|
||||||
|
|
||||||
max_len = self._MAX_OUTPUT
|
|
||||||
if len(result) > max_len:
|
|
||||||
half = max_len // 2
|
|
||||||
result = (
|
|
||||||
result[:half]
|
|
||||||
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
|
|
||||||
+ result[-half:]
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error executing command: {str(e)}"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _spawn(
|
async def _spawn(
|
||||||
command: str, cwd: str, env: dict[str, str],
|
command: str, cwd: str, env: dict[str, str],
|
||||||
|
shell_program: str | None = None,
|
||||||
|
login: bool = True,
|
||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Launch *command* in a platform-appropriate shell."""
|
"""Launch *command* in a platform-appropriate shell."""
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
@@ -272,9 +412,14 @@ class ExecTool(Tool):
|
|||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
bash = shutil.which("bash") or "/bin/bash"
|
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||||
|
args = [shell_program]
|
||||||
|
shell_name = Path(shell_program).name.lower()
|
||||||
|
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
|
||||||
|
args.append("-l")
|
||||||
|
args.extend(["-c", command])
|
||||||
return await asyncio.create_subprocess_exec(
|
return await asyncio.create_subprocess_exec(
|
||||||
bash, "-l", "-c", command,
|
*args,
|
||||||
stdin=asyncio.subprocess.DEVNULL,
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
@@ -282,6 +427,31 @@ class ExecTool(Tool):
|
|||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
|
||||||
|
if not shell:
|
||||||
|
return None, None
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
return None, "Error: shell parameter is not supported on Windows"
|
||||||
|
if "\0" in shell or "\n" in shell or "\r" in shell:
|
||||||
|
return None, "Error: shell contains invalid characters"
|
||||||
|
allowed = {"sh", "bash", "zsh"}
|
||||||
|
path = Path(shell).expanduser()
|
||||||
|
if path.is_absolute():
|
||||||
|
if path.name not in allowed:
|
||||||
|
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||||
|
if not path.is_file() or not os.access(path, os.X_OK):
|
||||||
|
return None, f"Error: shell is not executable: {shell}"
|
||||||
|
return str(path), None
|
||||||
|
if "/" in shell or "\\" in shell:
|
||||||
|
return None, "Error: shell must be a shell name or absolute path"
|
||||||
|
if shell not in allowed:
|
||||||
|
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||||
|
resolved = shutil.which(shell)
|
||||||
|
if not resolved:
|
||||||
|
return None, f"Error: shell not found: {shell}"
|
||||||
|
return resolved, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _kill_process(process: asyncio.subprocess.Process) -> None:
|
async def _kill_process(process: asyncio.subprocess.Process) -> None:
|
||||||
"""Kill a subprocess and reap it to prevent zombies."""
|
"""Kill a subprocess and reap it to prevent zombies."""
|
||||||
@@ -418,7 +588,7 @@ class ExecTool(Tool):
|
|||||||
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share`
|
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share`
|
||||||
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
|
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
|
||||||
win_paths = re.findall(
|
win_paths = re.findall(
|
||||||
r"(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||||
command
|
command
|
||||||
)
|
)
|
||||||
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||||
|
|||||||
+99
-18
@@ -8,7 +8,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -78,9 +78,82 @@ def _validate_url(url: str) -> tuple[bool, str]:
|
|||||||
def _validate_url_safe(url: str) -> tuple[bool, str]:
|
def _validate_url_safe(url: str) -> tuple[bool, str]:
|
||||||
"""Validate URL with SSRF protection: scheme, domain, and resolved IP check."""
|
"""Validate URL with SSRF protection: scheme, domain, and resolved IP check."""
|
||||||
from nanobot.security.network import validate_url_target
|
from nanobot.security.network import validate_url_target
|
||||||
|
|
||||||
return validate_url_target(url)
|
return validate_url_target(url)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_with_safe_redirects(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> tuple[httpx.Response | None, str | None]:
|
||||||
|
"""GET a URL while validating every redirect target before requesting it."""
|
||||||
|
current_url = url
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
is_valid, error_msg = _validate_url_safe(current_url)
|
||||||
|
if not is_valid:
|
||||||
|
return None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
|
response = await client.get(current_url, headers=headers, follow_redirects=False)
|
||||||
|
is_redirect = 300 <= response.status_code < 400
|
||||||
|
if not is_redirect:
|
||||||
|
return response, None
|
||||||
|
|
||||||
|
location = response.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
return response, None
|
||||||
|
|
||||||
|
next_url = urljoin(str(response.url), location)
|
||||||
|
is_valid, error_msg = _validate_url_safe(next_url)
|
||||||
|
if not is_valid:
|
||||||
|
await response.aclose()
|
||||||
|
return None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
|
await response.aclose()
|
||||||
|
current_url = next_url
|
||||||
|
|
||||||
|
return None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_with_safe_redirects(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> tuple[httpx.Response | None, Any | None, str | None]:
|
||||||
|
"""Open a streamed response while validating every redirect target first."""
|
||||||
|
current_url = url
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
is_valid, error_msg = _validate_url_safe(current_url)
|
||||||
|
if not is_valid:
|
||||||
|
return None, None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
|
stream = client.stream(
|
||||||
|
"GET",
|
||||||
|
current_url,
|
||||||
|
headers=headers,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
response = await stream.__aenter__()
|
||||||
|
is_redirect = 300 <= response.status_code < 400
|
||||||
|
if not is_redirect:
|
||||||
|
return response, stream, None
|
||||||
|
|
||||||
|
location = response.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
return response, stream, None
|
||||||
|
|
||||||
|
next_url = urljoin(str(response.url), location)
|
||||||
|
is_valid, error_msg = _validate_url_safe(next_url)
|
||||||
|
if not is_valid:
|
||||||
|
await stream.__aexit__(None, None, None)
|
||||||
|
return None, None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
|
await stream.__aexit__(None, None, None)
|
||||||
|
current_url = next_url
|
||||||
|
|
||||||
|
return None, None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
|
||||||
|
|
||||||
|
|
||||||
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
|
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
|
||||||
"""Format provider results into shared plaintext output."""
|
"""Format provider results into shared plaintext output."""
|
||||||
if not items:
|
if not items:
|
||||||
@@ -488,19 +561,26 @@ class WebFetchTool(Tool):
|
|||||||
|
|
||||||
# Detect and fetch images directly to avoid Jina's textual image captioning
|
# Detect and fetch images directly to avoid Jina's textual image captioning
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(proxy=self.proxy, follow_redirects=True, max_redirects=MAX_REDIRECTS, timeout=15.0) as client:
|
async with httpx.AsyncClient(proxy=self.proxy, timeout=15.0) as client:
|
||||||
async with client.stream("GET", url, headers={"User-Agent": self.user_agent}) as r:
|
r, stream, redirect_error = await _stream_with_safe_redirects(
|
||||||
from nanobot.security.network import validate_resolved_url
|
client,
|
||||||
|
url,
|
||||||
redir_ok, redir_err = validate_resolved_url(str(r.url))
|
headers={"User-Agent": self.user_agent},
|
||||||
if not redir_ok:
|
)
|
||||||
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False)
|
if redirect_error:
|
||||||
|
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
|
||||||
|
if r is None:
|
||||||
|
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
|
||||||
|
|
||||||
|
try:
|
||||||
ctype = r.headers.get("content-type", "")
|
ctype = r.headers.get("content-type", "")
|
||||||
if ctype.startswith("image/"):
|
if ctype.startswith("image/"):
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
raw = await r.aread()
|
raw = await r.aread()
|
||||||
return build_image_content_blocks(raw, ctype, url, f"(Image fetched from: {url})")
|
return build_image_content_blocks(raw, ctype, url, f"(Image fetched from: {url})")
|
||||||
|
finally:
|
||||||
|
if stream is not None:
|
||||||
|
await stream.__aexit__(None, None, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
|
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
|
||||||
|
|
||||||
@@ -549,23 +629,22 @@ class WebFetchTool(Tool):
|
|||||||
|
|
||||||
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
|
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
|
||||||
"""Local fallback using readability-lxml."""
|
"""Local fallback using readability-lxml."""
|
||||||
from readability import Document
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
follow_redirects=True,
|
|
||||||
max_redirects=MAX_REDIRECTS,
|
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
proxy=self.proxy,
|
proxy=self.proxy,
|
||||||
) as client:
|
) as client:
|
||||||
r = await client.get(url, headers={"User-Agent": self.user_agent})
|
r, redirect_error = await _get_with_safe_redirects(
|
||||||
|
client,
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": self.user_agent},
|
||||||
|
)
|
||||||
|
if redirect_error:
|
||||||
|
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
|
||||||
|
if r is None:
|
||||||
|
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
from nanobot.security.network import validate_resolved_url
|
|
||||||
redir_ok, redir_err = validate_resolved_url(str(r.url))
|
|
||||||
if not redir_ok:
|
|
||||||
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False)
|
|
||||||
|
|
||||||
ctype = r.headers.get("content-type", "")
|
ctype = r.headers.get("content-type", "")
|
||||||
if ctype.startswith("image/"):
|
if ctype.startswith("image/"):
|
||||||
return build_image_content_blocks(r.content, ctype, url, f"(Image fetched from: {url})")
|
return build_image_content_blocks(r.content, ctype, url, f"(Image fetched from: {url})")
|
||||||
@@ -573,6 +652,8 @@ class WebFetchTool(Tool):
|
|||||||
if "application/json" in ctype:
|
if "application/json" in ctype:
|
||||||
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
|
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
|
||||||
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
|
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
|
||||||
|
from readability import Document
|
||||||
|
|
||||||
doc = Document(r.text)
|
doc = Document(r.text)
|
||||||
content = self._to_markdown(doc.summary()) if extract_mode == "markdown" else _strip_tags(doc.summary())
|
content = self._to_markdown(doc.summary()) if extract_mode == "markdown" else _strip_tags(doc.summary())
|
||||||
text = f"# {doc.title()}\n\n{content}" if doc.title() else content
|
text = f"# {doc.title()}\n\n{content}" if doc.title() else content
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ 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
|
||||||
@@ -52,6 +52,11 @@ from nanobot.webui.settings_api import (
|
|||||||
update_provider_settings,
|
update_provider_settings,
|
||||||
update_web_search_settings,
|
update_web_search_settings,
|
||||||
)
|
)
|
||||||
|
from nanobot.webui.cli_apps_api import (
|
||||||
|
cli_apps_action,
|
||||||
|
cli_apps_payload,
|
||||||
|
normalize_cli_app_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,
|
||||||
@@ -420,6 +425,16 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
|||||||
"video/webm",
|
"video/webm",
|
||||||
"video/quicktime",
|
"video/quicktime",
|
||||||
})
|
})
|
||||||
|
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
||||||
|
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
||||||
|
)
|
||||||
|
_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".webp",
|
||||||
|
".gif",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
||||||
@@ -449,6 +464,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):
|
||||||
@@ -461,6 +477,10 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_chats: dict[Any, set[str]] = {}
|
self._conn_chats: dict[Any, set[str]] = {}
|
||||||
# connection -> default chat_id for legacy frames that omit routing.
|
# connection -> default chat_id for legacy frames that omit routing.
|
||||||
self._conn_default: dict[Any, str] = {}
|
self._conn_default: dict[Any, str] = {}
|
||||||
|
# Chat IDs that opted into WebUI-specific rendering by sending a typed
|
||||||
|
# envelope with ``webui: true``. Raw WebSocket clients keep the legacy
|
||||||
|
# wire shape.
|
||||||
|
self._webui_chats: set[str] = set()
|
||||||
# Single-use tokens consumed at WebSocket handshake.
|
# Single-use tokens consumed at WebSocket handshake.
|
||||||
self._issued_tokens: dict[str, float] = {}
|
self._issued_tokens: dict[str, float] = {}
|
||||||
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
|
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
|
||||||
@@ -471,8 +491,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
|
||||||
@@ -653,6 +679,21 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if got == "/api/settings/image-generation/update":
|
if got == "/api/settings/image-generation/update":
|
||||||
return self._handle_settings_image_generation_update(request)
|
return self._handle_settings_image_generation_update(request)
|
||||||
|
|
||||||
|
if got == "/api/settings/cli-apps":
|
||||||
|
return self._handle_settings_cli_apps(request)
|
||||||
|
|
||||||
|
if got == "/api/settings/cli-apps/install":
|
||||||
|
return await self._handle_settings_cli_apps_action(request, "install")
|
||||||
|
|
||||||
|
if got == "/api/settings/cli-apps/update":
|
||||||
|
return await self._handle_settings_cli_apps_action(request, "update")
|
||||||
|
|
||||||
|
if got == "/api/settings/cli-apps/uninstall":
|
||||||
|
return await self._handle_settings_cli_apps_action(request, "uninstall")
|
||||||
|
|
||||||
|
if got == "/api/settings/cli-apps/test":
|
||||||
|
return await self._handle_settings_cli_apps_action(request, "test")
|
||||||
|
|
||||||
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))
|
||||||
@@ -874,6 +915,32 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return _http_error(e.status, e.message)
|
return _http_error(e.status, e.message)
|
||||||
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
|
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
|
||||||
|
|
||||||
|
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
|
||||||
|
if not self._check_api_token(request):
|
||||||
|
return _http_error(401, "Unauthorized")
|
||||||
|
try:
|
||||||
|
payload = cli_apps_payload()
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load CLI Apps payload")
|
||||||
|
return _http_error(500, "failed to load CLI Apps")
|
||||||
|
return _http_json_response(payload)
|
||||||
|
|
||||||
|
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
|
||||||
|
if not self._check_api_token(request):
|
||||||
|
return _http_error(401, "Unauthorized")
|
||||||
|
query = _parse_query(request.path)
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(cli_apps_action, action, query)
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return _http_error(e.status, e.message)
|
||||||
|
except Exception as e:
|
||||||
|
status = getattr(e, "status", 500)
|
||||||
|
message = getattr(e, "message", str(e))
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||||
|
return _http_error(status, message)
|
||||||
|
return _http_json_response(payload)
|
||||||
|
|
||||||
@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."""
|
||||||
@@ -915,6 +982,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")
|
||||||
@@ -961,6 +1029,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
if media:
|
if media:
|
||||||
user_obj["media_paths"] = list(media)
|
user_obj["media_paths"] = list(media)
|
||||||
|
cli_apps = meta.get("cli_apps")
|
||||||
|
if isinstance(cli_apps, list) and cli_apps:
|
||||||
|
user_obj["cli_apps"] = cli_apps
|
||||||
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,
|
||||||
@@ -1050,6 +1121,46 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return None
|
return None
|
||||||
return {"url": signed, "name": path.name}
|
return {"url": signed, "name": path.name}
|
||||||
|
|
||||||
|
def _markdown_image_url_for_local_path(self, raw_url: str) -> str | None:
|
||||||
|
url = raw_url.strip()
|
||||||
|
if url.startswith("<") and url.endswith(">"):
|
||||||
|
url = url[1:-1].strip()
|
||||||
|
if not url or url.startswith(("/api/media/", "#")):
|
||||||
|
return None
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme or parsed.netloc:
|
||||||
|
return None
|
||||||
|
if parsed.query or parsed.fragment:
|
||||||
|
return None
|
||||||
|
path_text = unquote(url)
|
||||||
|
if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
|
||||||
|
return None
|
||||||
|
candidate = Path(path_text).expanduser()
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = self._workspace_path / candidate
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve(strict=False)
|
||||||
|
resolved.relative_to(self._workspace_path)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if not resolved.is_file():
|
||||||
|
return None
|
||||||
|
signed = self._sign_or_stage_media_path(resolved)
|
||||||
|
return signed["url"] if signed else None
|
||||||
|
|
||||||
|
def _rewrite_local_markdown_images(self, text: str) -> str:
|
||||||
|
if "![" not in text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
signed_url = self._markdown_image_url_for_local_path(match.group(2))
|
||||||
|
if not signed_url:
|
||||||
|
return match.group(0)
|
||||||
|
title = match.group(3) or ""
|
||||||
|
return f""
|
||||||
|
|
||||||
|
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
|
||||||
|
|
||||||
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
|
||||||
@@ -1421,6 +1532,10 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
||||||
if envelope.get("webui") is True:
|
if envelope.get("webui") is True:
|
||||||
metadata["webui"] = True
|
metadata["webui"] = True
|
||||||
|
self._webui_chats.add(cid)
|
||||||
|
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
|
||||||
|
if cli_apps:
|
||||||
|
metadata["cli_apps"] = cli_apps
|
||||||
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")
|
||||||
@@ -1454,6 +1569,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._subs.clear()
|
self._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
|
self._webui_chats.clear()
|
||||||
self._issued_tokens.clear()
|
self._issued_tokens.clear()
|
||||||
self._api_tokens.clear()
|
self._api_tokens.clear()
|
||||||
|
|
||||||
@@ -1532,10 +1648,12 @@ 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
|
||||||
|
should_rewrite_images = msg.chat_id in self._webui_chats
|
||||||
|
wire_text = self._rewrite_local_markdown_images(text) if should_rewrite_images else text
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"chat_id": msg.chat_id,
|
"chat_id": msg.chat_id,
|
||||||
"text": text,
|
"text": wire_text,
|
||||||
}
|
}
|
||||||
if msg.media:
|
if msg.media:
|
||||||
payload["media"] = msg.media
|
payload["media"] = msg.media
|
||||||
@@ -1563,7 +1681,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=" ")
|
||||||
@@ -1628,17 +1748,33 @@ 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 ""))
|
||||||
|
should_rewrite_images = chat_id in self._webui_chats
|
||||||
|
transcript_body: dict[str, Any] | None = None
|
||||||
if meta.get("_stream_end"):
|
if meta.get("_stream_end"):
|
||||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||||
|
if should_rewrite_images:
|
||||||
|
buffered = self._stream_text_buffers.pop(stream_key, [])
|
||||||
|
if delta:
|
||||||
|
buffered.append(delta)
|
||||||
|
full_text = "".join(buffered)
|
||||||
|
rewritten = self._rewrite_local_markdown_images(full_text)
|
||||||
|
if rewritten != full_text or delta:
|
||||||
|
body["text"] = rewritten
|
||||||
|
transcript_body = {**body, "text": full_text}
|
||||||
else:
|
else:
|
||||||
body = {
|
body = {
|
||||||
"event": "delta",
|
"event": "delta",
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"text": delta,
|
"text": delta,
|
||||||
}
|
}
|
||||||
|
if should_rewrite_images:
|
||||||
|
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
||||||
if meta.get("_stream_id") is not None:
|
if meta.get("_stream_id") is not None:
|
||||||
body["stream_id"] = meta["_stream_id"]
|
body["stream_id"] = meta["_stream_id"]
|
||||||
self._try_append_webui_transcript(chat_id, body)
|
if transcript_body is not None:
|
||||||
|
transcript_body["stream_id"] = meta["_stream_id"]
|
||||||
|
self._try_append_webui_transcript(chat_id, transcript_body or body)
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" stream ")
|
await self._safe_send_to(connection, raw, label=" stream ")
|
||||||
|
|||||||
+163
-6
@@ -79,6 +79,12 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
|
|||||||
ERRCODE_SESSION_EXPIRED = -14
|
ERRCODE_SESSION_EXPIRED = -14
|
||||||
SESSION_PAUSE_DURATION_S = 60 * 60
|
SESSION_PAUSE_DURATION_S = 60 * 60
|
||||||
|
|
||||||
|
# iLink context_token is observed to expire server-side after ~90-160s of
|
||||||
|
# agent inactivity (openclaw/openclaw#61174). Proactively refresh before
|
||||||
|
# sending if the cached token is older than this threshold.
|
||||||
|
CONTEXT_TOKEN_MAX_AGE_S = 60
|
||||||
|
|
||||||
|
|
||||||
# Retry constants (matching the reference plugin's monitor.ts)
|
# Retry constants (matching the reference plugin's monitor.ts)
|
||||||
MAX_CONSECUTIVE_FAILURES = 3
|
MAX_CONSECUTIVE_FAILURES = 3
|
||||||
BACKOFF_DELAY_S = 30
|
BACKOFF_DELAY_S = 30
|
||||||
@@ -159,6 +165,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._session_pause_until: float = 0.0
|
self._session_pause_until: float = 0.0
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||||
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
||||||
|
self._context_token_at: dict[str, float] = {}
|
||||||
|
self._pending_tool_hints: dict[str, list[str]] = {}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# State persistence
|
# State persistence
|
||||||
@@ -486,6 +494,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
except Exception:
|
except Exception:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
break
|
break
|
||||||
|
self.logger.exception("WeChat poll loop error")
|
||||||
consecutive_failures += 1
|
consecutive_failures += 1
|
||||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
@@ -495,6 +504,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._pending_tool_hints.clear()
|
||||||
if self._poll_task and not self._poll_task.done():
|
if self._poll_task and not self._poll_task.done():
|
||||||
self._poll_task.cancel()
|
self._poll_task.cancel()
|
||||||
for chat_id in list(self._typing_tasks):
|
for chat_id in list(self._typing_tasks):
|
||||||
@@ -545,6 +555,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
# Check for API-level errors (monitor.ts checks both ret and errcode)
|
# Check for API-level errors (monitor.ts checks both ret and errcode)
|
||||||
ret = data.get("ret", 0)
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
|
|
||||||
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
|
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
|
||||||
|
|
||||||
if is_error:
|
if is_error:
|
||||||
@@ -575,8 +586,10 @@ class WeixinChannel(BaseChannel):
|
|||||||
# Process messages (WeixinMessage[] from types.ts)
|
# Process messages (WeixinMessage[] from types.ts)
|
||||||
msgs: list[dict] = data.get("msgs", []) or []
|
msgs: list[dict] = data.get("msgs", []) or []
|
||||||
for msg in msgs:
|
for msg in msgs:
|
||||||
with suppress(Exception):
|
try:
|
||||||
await self._process_message(msg)
|
await self._process_message(msg)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Failed to process WeChat message")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Inbound message processing (matches inbound.ts + process-message.ts)
|
# Inbound message processing (matches inbound.ts + process-message.ts)
|
||||||
@@ -610,6 +623,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
ctx_token = msg.get("context_token", "")
|
ctx_token = msg.get("context_token", "")
|
||||||
if ctx_token:
|
if ctx_token:
|
||||||
self._context_tokens[from_user_id] = ctx_token
|
self._context_tokens[from_user_id] = ctx_token
|
||||||
|
self._context_token_at[from_user_id] = time.time()
|
||||||
self._save_state()
|
self._save_state()
|
||||||
|
|
||||||
# Parse item_list (WeixinMessage.item_list — types.ts:161)
|
# Parse item_list (WeixinMessage.item_list — types.ts:161)
|
||||||
@@ -915,6 +929,99 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def _refresh_context_token_if_stale(
|
||||||
|
self, chat_id: str, context_token: str
|
||||||
|
) -> str:
|
||||||
|
"""Return a fresh context_token if the cached one is too old.
|
||||||
|
|
||||||
|
iLink context_token expires server-side after a short idle period
|
||||||
|
(empirically ~90s). Proactively refreshing before sending prevents
|
||||||
|
silent message loss on long agent turns or cron pushes.
|
||||||
|
"""
|
||||||
|
if not context_token:
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
cached_at = self._context_token_at.get(chat_id, 0)
|
||||||
|
age = now - cached_at
|
||||||
|
|
||||||
|
if age < CONTEXT_TOKEN_MAX_AGE_S:
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
"WeChat context_token for {} is {:.0f}s old; refreshing via getconfig",
|
||||||
|
chat_id,
|
||||||
|
age,
|
||||||
|
)
|
||||||
|
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"ilink_user_id": chat_id,
|
||||||
|
"context_token": context_token,
|
||||||
|
"base_info": BASE_INFO,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await self._api_post("ilink/bot/getconfig", body)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning("WeChat getconfig failed for {}: {}", chat_id, e)
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
if data.get("ret", 0) != 0:
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat getconfig returned ret={} for {}: {}",
|
||||||
|
data.get("ret"),
|
||||||
|
chat_id,
|
||||||
|
data.get("errmsg", ""),
|
||||||
|
)
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
new_token = str(data.get("context_token", "") or "")
|
||||||
|
if new_token and new_token != context_token:
|
||||||
|
self.logger.info(
|
||||||
|
"WeChat context_token refreshed for {} (age {:.0f}s -> fresh)",
|
||||||
|
chat_id,
|
||||||
|
age,
|
||||||
|
)
|
||||||
|
self._context_tokens[chat_id] = new_token
|
||||||
|
self._context_token_at[chat_id] = now
|
||||||
|
self._save_state()
|
||||||
|
return new_token
|
||||||
|
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
async def _flush_tool_hints(self, chat_id: str) -> None:
|
||||||
|
"""Send any buffered tool hints for *chat_id* as a single message.
|
||||||
|
|
||||||
|
Tool hints are coalesced to reduce message count and avoid hitting the
|
||||||
|
WeChat iLink rate limit (~7 msgs / 5 min). Failures are logged but
|
||||||
|
not raised so that the main message send is never blocked.
|
||||||
|
"""
|
||||||
|
hints = self._pending_tool_hints.pop(chat_id, None)
|
||||||
|
if not hints:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"Flushing {} buffered tool hint(s) for {}",
|
||||||
|
len(hints),
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx_token = self._context_tokens.get(chat_id, "")
|
||||||
|
ctx_token = await self._refresh_context_token_if_stale(chat_id, ctx_token)
|
||||||
|
if not ctx_token:
|
||||||
|
self.logger.warning(
|
||||||
|
"Dropped {} buffered tool hint(s) for {}: no context_token",
|
||||||
|
len(hints),
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._send_text(chat_id, "\n\n".join(hints), ctx_token)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(
|
||||||
|
"Failed to flush buffered tool hints for {}", chat_id
|
||||||
|
)
|
||||||
|
|
||||||
async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None:
|
async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None:
|
||||||
"""Best-effort sendtyping wrapper."""
|
"""Best-effort sendtyping wrapper."""
|
||||||
if not typing_ticket:
|
if not typing_ticket:
|
||||||
@@ -944,11 +1051,47 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._assert_session_active()
|
self._assert_session_active()
|
||||||
|
|
||||||
is_progress = bool((msg.metadata or {}).get("_progress", False))
|
is_progress = bool((msg.metadata or {}).get("_progress", False))
|
||||||
|
|
||||||
|
# Buffer tool hints to coalesce consecutive ones and avoid burning
|
||||||
|
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
|
||||||
|
if is_progress and (msg.metadata or {}).get("_tool_hint"):
|
||||||
|
if not self.send_tool_hints:
|
||||||
|
return
|
||||||
|
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
|
||||||
|
self.logger.debug(
|
||||||
|
"Buffered tool hint for {} (count={})",
|
||||||
|
msg.chat_id,
|
||||||
|
len(self._pending_tool_hints[msg.chat_id]),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reasoning deltas are invisible in WeChat (there is no reasoning
|
||||||
|
# UI). Skip them entirely — do not send and do not flush buffer.
|
||||||
|
if is_progress and (msg.metadata or {}).get("_reasoning_delta"):
|
||||||
|
self.logger.debug(
|
||||||
|
"Dropped invisible reasoning delta for {}", msg.chat_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
content = msg.content.strip()
|
||||||
|
|
||||||
|
# Empty progress messages (e.g. after_iteration tool_events) must
|
||||||
|
# NOT act as separators — they have no visible content.
|
||||||
|
if is_progress and not content and not (msg.media or []):
|
||||||
|
self.logger.debug(
|
||||||
|
"Skipped empty progress message for {} (no visible content)",
|
||||||
|
msg.chat_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Flush buffered hints before sending any visible message.
|
||||||
|
await self._flush_tool_hints(msg.chat_id)
|
||||||
|
|
||||||
if not is_progress:
|
if not is_progress:
|
||||||
await self._stop_typing(msg.chat_id, clear_remote=True)
|
await self._stop_typing(msg.chat_id, clear_remote=True)
|
||||||
|
|
||||||
content = msg.content.strip()
|
|
||||||
ctx_token = self._context_tokens.get(msg.chat_id, "")
|
ctx_token = self._context_tokens.get(msg.chat_id, "")
|
||||||
|
ctx_token = await self._refresh_context_token_if_stale(msg.chat_id, ctx_token)
|
||||||
if not ctx_token:
|
if not ctx_token:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
|
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
|
||||||
@@ -1037,6 +1180,18 @@ class WeixinChannel(BaseChannel):
|
|||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
||||||
|
|
||||||
|
async def send_delta(
|
||||||
|
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Weixin iLink does not support native streaming deltas.
|
||||||
|
|
||||||
|
We only hook ``_stream_end`` so buffered tool hints are flushed even
|
||||||
|
when the final answer carries the ``_streamed`` flag and bypasses
|
||||||
|
:meth:`send`.
|
||||||
|
"""
|
||||||
|
if metadata and metadata.get("_stream_end"):
|
||||||
|
await self._flush_tool_hints(chat_id)
|
||||||
|
|
||||||
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
||||||
"""Start typing indicator immediately when a message is received."""
|
"""Start typing indicator immediately when a message is received."""
|
||||||
if not self._client or not self._token or not chat_id:
|
if not self._client or not self._token or not chat_id:
|
||||||
@@ -1120,10 +1275,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
if errcode and errcode != 0:
|
if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}"
|
f"WeChat send text error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _send_media_file(
|
async def _send_media_file(
|
||||||
@@ -1270,10 +1426,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
if errcode and errcode != 0:
|
if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
|
f"WeChat send media error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""CLI Apps integration helpers."""
|
||||||
|
|
||||||
|
from nanobot.cli_apps.service import (
|
||||||
|
CliAppError,
|
||||||
|
CliAppManager,
|
||||||
|
CliAppsRuntimeConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CliAppError",
|
||||||
|
"CliAppManager",
|
||||||
|
"CliAppsRuntimeConfig",
|
||||||
|
]
|
||||||
@@ -0,0 +1,955 @@
|
|||||||
|
"""CLI-Anything catalog, install state, and safe CLI execution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from nanobot.config.paths import get_runtime_subdir
|
||||||
|
|
||||||
|
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
||||||
|
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||||
|
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
||||||
|
CLI_ANYTHING_RAW_SKILLS_BASE = f"{CLI_ANYTHING_RAW_BASE}/skills/"
|
||||||
|
|
||||||
|
_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_-]+")
|
||||||
|
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
|
||||||
|
_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):
|
||||||
|
"""User-facing CLI Apps failure."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CliAppsRuntimeConfig:
|
||||||
|
"""Runtime knobs for CLI Apps."""
|
||||||
|
|
||||||
|
install_timeout: int = 300
|
||||||
|
run_timeout: int = 60
|
||||||
|
catalog_ttl_seconds: int = 3600
|
||||||
|
|
||||||
|
|
||||||
|
_BRANDS: dict[str, tuple[str, str]] = {
|
||||||
|
"1password-cli": ("1password", "#3B66BC"),
|
||||||
|
"audacity": ("audacity", "#0000CC"),
|
||||||
|
"blender": ("blender", "#E87D0D"),
|
||||||
|
"browser": ("googlechrome", "#4285F4"),
|
||||||
|
"calibre": ("calibre", "#45B29D"),
|
||||||
|
"chromadb": ("chroma", "#FFDE2D"),
|
||||||
|
"comfyui": ("comfyui", "#111827"),
|
||||||
|
"contentful": ("contentful", "#2478CC"),
|
||||||
|
"dify": ("dify", "#155EEF"),
|
||||||
|
"drawio": ("diagramsdotnet", "#F08705"),
|
||||||
|
"elevenlabs": ("elevenlabs", "#000000"),
|
||||||
|
"eth2-quickstart": ("ethereum", "#627EEA"),
|
||||||
|
"firefly-iii": ("fireflyiii", "#CD5029"),
|
||||||
|
"freecad": ("freecad", "#418FDE"),
|
||||||
|
"generate-veo-video": ("googlegemini", "#8E75B2"),
|
||||||
|
"gimp": ("gimp", "#5C5543"),
|
||||||
|
"godot": ("godotengine", "#478CBF"),
|
||||||
|
"hacker-feeds-cli": ("rss", "#FFA500"),
|
||||||
|
"inkscape": ("inkscape", "#000000"),
|
||||||
|
"intelwatch": ("intel", "#0071C5"),
|
||||||
|
"iterm2": ("iterm2", "#000000"),
|
||||||
|
"jimeng": ("bytedance", "#3C8CFF"),
|
||||||
|
"kdenlive": ("kdenlive", "#527EB2"),
|
||||||
|
"krita": ("krita", "#3BABFF"),
|
||||||
|
"libreoffice": ("libreoffice", "#18A303"),
|
||||||
|
"mailchimp": ("mailchimp", "#FFE01B"),
|
||||||
|
"mermaid": ("mermaid", "#FF3670"),
|
||||||
|
"minimax": ("minimax", "#111827"),
|
||||||
|
"musescore": ("musescore", "#1A70B8"),
|
||||||
|
"n8n": ("n8n", "#EA4B71"),
|
||||||
|
"notebooklm": ("googlenotebooklm", "#4285F4"),
|
||||||
|
"obs-studio": ("obsstudio", "#302E31"),
|
||||||
|
"obsidian": ("obsidian", "#7C3AED"),
|
||||||
|
"ollama": ("ollama", "#000000"),
|
||||||
|
"pm2": ("pm2", "#2B037A"),
|
||||||
|
"qgis": ("qgis", "#589632"),
|
||||||
|
"safari": ("safari", "#006CFF"),
|
||||||
|
"sanity": ("sanity", "#F03E2F"),
|
||||||
|
"sentry": ("sentry", "#362D59"),
|
||||||
|
"sketch": ("sketch", "#F7B500"),
|
||||||
|
"shopify": ("shopify", "#7AB55C"),
|
||||||
|
"nsight-graphics": ("nvidia", "#76B900"),
|
||||||
|
"unrealinsights": ("unrealengine", "#0E1128"),
|
||||||
|
"ueatelier": ("unrealengine", "#0E1128"),
|
||||||
|
"ve-twini": ("x", "#000000"),
|
||||||
|
"wecom": ("wechat", "#07C160"),
|
||||||
|
"suno": ("suno", "#000000"),
|
||||||
|
"lldb": ("llvm", "#262D3A"),
|
||||||
|
"android-cli": ("android", "#3DDC84"),
|
||||||
|
"adguardhome": ("adguard", "#68BC71"),
|
||||||
|
"zotero": ("zotero", "#CC2936"),
|
||||||
|
"zoom": ("zoom", "#0B5CFF"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
||||||
|
"3mf": ("3mf.io", "#00A1DE"),
|
||||||
|
"anygen": ("anygen.com", "#111827"),
|
||||||
|
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
||||||
|
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
||||||
|
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
||||||
|
"deployhq": ("deployhq.com", "#00A2D9"),
|
||||||
|
"exa": ("exa.ai", "#111827"),
|
||||||
|
"feishu": ("larksuite.com", "#00A5FF"),
|
||||||
|
"inkstitch": ("inkstitch.org", "#222222"),
|
||||||
|
"macrocli": ("github.com/HKUDS/CLI-Anything/tree/main/macrocli", "#24292F"),
|
||||||
|
"mubu": ("mubu.com", "#16A085"),
|
||||||
|
"nslogger": ("github.com/fpillet/NSLogger", "#24292F"),
|
||||||
|
"novita": ("novita.ai", "#7C3AED"),
|
||||||
|
"openscreen": ("openscreen.com", "#2563EB"),
|
||||||
|
"py4csr": ("github.com/yanmingyu92/py4csr", "#24292F"),
|
||||||
|
"quietshrink": ("github.com/achiya-automation/quietshrink", "#111827"),
|
||||||
|
"renderdoc": ("renderdoc.org", "#2C7DB8"),
|
||||||
|
"rms": ("rms.teltonika-networks.com", "#0054A6"),
|
||||||
|
"sbox": ("sbox.game", "#F59E0B"),
|
||||||
|
"seaclip": ("github.com/SeaClip-Lite/SeaClip", "#0284C7"),
|
||||||
|
"shotcut": ("shotcut.org", "#3B82F6"),
|
||||||
|
"slay-the-spire-ii": ("megacrit.com", "#B91C1C"),
|
||||||
|
"stata": ("stata.com", "#1F4E79"),
|
||||||
|
"unimol-tools": ("github.com/deepmodeling/Uni-Mol", "#4F46E5"),
|
||||||
|
"videocaptioner": ("github.com/WEIFENG2333/VideoCaptioner", "#2563EB"),
|
||||||
|
"wiremock": ("wiremock.org", "#FF6A00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_BRAND_ALIASES: dict[str, str] = {
|
||||||
|
"1password": "1password-cli",
|
||||||
|
"dify-workflow": "dify",
|
||||||
|
"feishu-lark": "feishu",
|
||||||
|
"lark-cli": "feishu",
|
||||||
|
"minimax-cli": "minimax",
|
||||||
|
"obsidian-cli": "obsidian",
|
||||||
|
"slay-the-spire-2": "slay-the-spire-ii",
|
||||||
|
"slay-the-spire-ii": "slay-the-spire-ii",
|
||||||
|
"unimol-tools": "unimol-tools",
|
||||||
|
"unimol": "unimol-tools",
|
||||||
|
"veo": "generate-veo-video",
|
||||||
|
}
|
||||||
|
|
||||||
|
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> float:
|
||||||
|
return time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_skill_name(name: str) -> str:
|
||||||
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||||
|
return f"cli-app-{clean or 'app'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_shell_meta(command: str) -> bool:
|
||||||
|
return any(char in command for char in _SHELL_META_CHARS)
|
||||||
|
|
||||||
|
|
||||||
|
def _command_exists(command: str) -> bool:
|
||||||
|
try:
|
||||||
|
parts = shlex.split(command)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
return shutil.which(parts[0]) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pip_install_command(command: str) -> bool:
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(command)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
len(tokens) >= 3
|
||||||
|
and tokens[:2] == ["pip", "install"]
|
||||||
|
) or (
|
||||||
|
len(tokens) >= 5
|
||||||
|
and tokens[1:4] == ["-m", "pip", "install"]
|
||||||
|
and tokens[0] in {"python", "python3", sys.executable}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pip_uninstall_args_from_command(command: str) -> list[str] | None:
|
||||||
|
if not command or _has_shell_meta(command):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(command)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if tokens[:2] == ["pip", "uninstall"]:
|
||||||
|
args = tokens[2:]
|
||||||
|
elif (
|
||||||
|
len(tokens) >= 5
|
||||||
|
and tokens[1:4] == ["-m", "pip", "uninstall"]
|
||||||
|
and tokens[0] in {"python", "python3", sys.executable}
|
||||||
|
):
|
||||||
|
args = tokens[4:]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
packages = [arg for arg in args if arg not in {"-y", "--yes"}]
|
||||||
|
if not packages or any(arg.startswith("-") for arg in packages):
|
||||||
|
return None
|
||||||
|
return packages
|
||||||
|
|
||||||
|
|
||||||
|
def _brand_key(value: str) -> str:
|
||||||
|
return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def _brand_candidates(app: dict[str, Any]) -> list[str]:
|
||||||
|
values = [
|
||||||
|
str(app.get("name") or ""),
|
||||||
|
str(app.get("display_name") or ""),
|
||||||
|
str(app.get("entry_point") or "").removeprefix("cli-anything-"),
|
||||||
|
]
|
||||||
|
seen: set[str] = set()
|
||||||
|
candidates: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
key = _brand_key(value)
|
||||||
|
while key and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
candidates.append(key)
|
||||||
|
parts = key.split("-")
|
||||||
|
if len(parts) <= 1 or parts[-1] not in _BRAND_TRAILING_WORDS:
|
||||||
|
break
|
||||||
|
key = "-".join(parts[:-1])
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||||
|
brand = None
|
||||||
|
domain_brand = None
|
||||||
|
for candidate in _brand_candidates(app):
|
||||||
|
key = _BRAND_ALIASES.get(candidate, candidate)
|
||||||
|
brand = _BRANDS.get(key)
|
||||||
|
if brand:
|
||||||
|
break
|
||||||
|
domain_brand = _BRAND_DOMAINS.get(key)
|
||||||
|
if domain_brand:
|
||||||
|
break
|
||||||
|
if not brand:
|
||||||
|
if not domain_brand:
|
||||||
|
return None, None
|
||||||
|
domain, color = domain_brand
|
||||||
|
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
|
||||||
|
slug, color = brand
|
||||||
|
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
tmp_path = path.with_name(f".{path.name}.{os.getpid()}.{int(_now() * 1_000_000)}.tmp")
|
||||||
|
try:
|
||||||
|
tmp_path.write_text(payload, encoding="utf-8")
|
||||||
|
tmp_path.replace(path)
|
||||||
|
finally:
|
||||||
|
if tmp_path.exists():
|
||||||
|
tmp_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_skill_path(value: str) -> str | None:
|
||||||
|
if not value.startswith("skills/"):
|
||||||
|
return None
|
||||||
|
parts = value.split("/")
|
||||||
|
if any(part in {"", ".", ".."} for part in parts):
|
||||||
|
return None
|
||||||
|
return value if parts[-1] == "SKILL.md" else None
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_content_url(skill_md: str) -> str | None:
|
||||||
|
safe_path = _safe_skill_path(skill_md)
|
||||||
|
if safe_path:
|
||||||
|
return f"{CLI_ANYTHING_RAW_BASE}/{safe_path}"
|
||||||
|
parsed = urlparse(skill_md)
|
||||||
|
if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
|
||||||
|
return None
|
||||||
|
if not skill_md.startswith(CLI_ANYTHING_RAW_SKILLS_BASE):
|
||||||
|
return None
|
||||||
|
suffix = skill_md.removeprefix(f"{CLI_ANYTHING_RAW_BASE}/")
|
||||||
|
return skill_md if _safe_skill_path(suffix) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, limit: int = _MAX_TOOL_OUTPUT_CHARS) -> str:
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
omitted = len(text) - limit
|
||||||
|
return text[:limit] + f"\n\n... truncated {omitted} characters ..."
|
||||||
|
|
||||||
|
|
||||||
|
class CliAppManager:
|
||||||
|
"""Manage CLI-Anything registry entries and local install state."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workspace: Path,
|
||||||
|
data_dir: Path | None = None,
|
||||||
|
runtime: CliAppsRuntimeConfig | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.workspace = Path(workspace).expanduser()
|
||||||
|
self.data_dir = Path(data_dir) if data_dir is not None else get_runtime_subdir("cli-apps")
|
||||||
|
self.runtime = runtime or CliAppsRuntimeConfig()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def installed_path(self) -> Path:
|
||||||
|
return self.data_dir / "installed.json"
|
||||||
|
|
||||||
|
def _cache_path(self, source: str) -> Path:
|
||||||
|
return self.data_dir / f"{source}_registry_cache.json"
|
||||||
|
|
||||||
|
def _load_installed(self) -> dict[str, Any]:
|
||||||
|
data = _read_json(self.installed_path) or {}
|
||||||
|
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data
|
||||||
|
return apps if isinstance(apps, dict) else {}
|
||||||
|
|
||||||
|
def _save_installed(self, installed: dict[str, Any]) -> None:
|
||||||
|
_write_json(self.installed_path, {"schema_version": 1, "apps": installed})
|
||||||
|
|
||||||
|
def installed_names(self) -> list[str]:
|
||||||
|
"""Return registry names explicitly installed through CLI Apps."""
|
||||||
|
return sorted(str(name) for name in self._load_installed())
|
||||||
|
|
||||||
|
def _fetch_registry(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
cache_path: Path,
|
||||||
|
*,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
cached = _read_json(cache_path)
|
||||||
|
if (
|
||||||
|
not force_refresh
|
||||||
|
and cached
|
||||||
|
and _now() - float(cached.get("_cached_at", 0)) < self.runtime.catalog_ttl_seconds
|
||||||
|
):
|
||||||
|
data = cached.get("data")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.get(url, timeout=15.0, follow_redirects=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("registry response must be an object")
|
||||||
|
except Exception:
|
||||||
|
if cached and isinstance(cached.get("data"), dict):
|
||||||
|
return cached["data"]
|
||||||
|
raise
|
||||||
|
|
||||||
|
_write_json(cache_path, {"_cached_at": _now(), "data": data})
|
||||||
|
return data
|
||||||
|
|
||||||
|
def catalog(self, *, force_refresh: bool = False) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
registries = [
|
||||||
|
(
|
||||||
|
"harness",
|
||||||
|
self._fetch_registry(
|
||||||
|
CLI_ANYTHING_REGISTRY_URL,
|
||||||
|
self._cache_path("harness"),
|
||||||
|
force_refresh=force_refresh,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"public",
|
||||||
|
self._fetch_registry(
|
||||||
|
CLI_ANYTHING_PUBLIC_REGISTRY_URL,
|
||||||
|
self._cache_path("public"),
|
||||||
|
force_refresh=force_refresh,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
apps_by_name: dict[str, dict[str, Any]] = {}
|
||||||
|
updated_values: list[str] = []
|
||||||
|
for source, registry in registries:
|
||||||
|
meta = registry.get("meta")
|
||||||
|
if isinstance(meta, dict) and isinstance(meta.get("updated"), str):
|
||||||
|
updated_values.append(meta["updated"])
|
||||||
|
for row in registry.get("clis", []):
|
||||||
|
if not isinstance(row, dict) or not row.get("name"):
|
||||||
|
continue
|
||||||
|
entry = dict(row)
|
||||||
|
entry["_source"] = source
|
||||||
|
key = str(entry["name"]).lower()
|
||||||
|
previous = apps_by_name.get(key)
|
||||||
|
if previous:
|
||||||
|
previous_source = str(previous.get("_source") or source)
|
||||||
|
merged_source = (
|
||||||
|
previous_source if previous_source == source else f"{previous_source}+{source}"
|
||||||
|
)
|
||||||
|
apps_by_name[key] = {**previous, **entry, "_source": merged_source}
|
||||||
|
else:
|
||||||
|
apps_by_name[key] = entry
|
||||||
|
return list(apps_by_name.values()), max(updated_values) if updated_values else None
|
||||||
|
|
||||||
|
def get_app(self, name: str, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||||
|
wanted = name.lower()
|
||||||
|
for app in self.catalog(force_refresh=force_refresh)[0]:
|
||||||
|
if str(app.get("name", "")).lower() == wanted:
|
||||||
|
return app
|
||||||
|
raise CliAppError(f"CLI app '{name}' not found", status=404)
|
||||||
|
|
||||||
|
def mentioned_installed_apps(self, text: str) -> list[dict[str, str]]:
|
||||||
|
"""Return installed CLI Apps referenced as ``@name`` in user text."""
|
||||||
|
if "@" not in text:
|
||||||
|
return []
|
||||||
|
installed = self._load_installed()
|
||||||
|
if not installed:
|
||||||
|
return []
|
||||||
|
installed_by_name = {
|
||||||
|
str(name).lower(): (str(name), data if isinstance(data, dict) else {})
|
||||||
|
for name, data in installed.items()
|
||||||
|
}
|
||||||
|
seen: set[str] = set()
|
||||||
|
mentions: list[dict[str, str]] = []
|
||||||
|
for match in _MENTION_RE.finditer(text):
|
||||||
|
wanted = str(match.group(2)).lower()
|
||||||
|
if wanted in seen or wanted not in installed_by_name:
|
||||||
|
continue
|
||||||
|
installed_name, data = installed_by_name[wanted]
|
||||||
|
seen.add(wanted)
|
||||||
|
entry_point = str(data.get("entry_point") or "")
|
||||||
|
mentions.append(
|
||||||
|
{
|
||||||
|
"name": installed_name,
|
||||||
|
"entry_point": entry_point,
|
||||||
|
"source": str(data.get("source") or ""),
|
||||||
|
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
||||||
|
"tool": "run_cli_app",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return mentions
|
||||||
|
|
||||||
|
def _strategy(self, app: dict[str, Any]) -> str:
|
||||||
|
package_manager = str(app.get("package_manager") or "").lower()
|
||||||
|
install_strategy = str(app.get("install_strategy") or "").lower()
|
||||||
|
if package_manager == "bundled" or install_strategy == "bundled":
|
||||||
|
return "bundled"
|
||||||
|
if package_manager in {"npm", "brew", "uv", "pip"}:
|
||||||
|
return package_manager
|
||||||
|
if app.get("npm_package"):
|
||||||
|
return "npm"
|
||||||
|
install_cmd = str(app.get("install_cmd") or "")
|
||||||
|
if _is_pip_install_command(install_cmd):
|
||||||
|
return "pip"
|
||||||
|
return "unsupported"
|
||||||
|
|
||||||
|
def _install_supported(self, app: dict[str, Any]) -> bool:
|
||||||
|
if self._strategy(app) == "unsupported":
|
||||||
|
return False
|
||||||
|
install_cmd = str(app.get("install_cmd") or "")
|
||||||
|
return not _has_shell_meta(install_cmd)
|
||||||
|
|
||||||
|
def _skill_path(self, name: str) -> Path:
|
||||||
|
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
||||||
|
|
||||||
|
def _app_payload(
|
||||||
|
self,
|
||||||
|
app: dict[str, Any],
|
||||||
|
installed: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
name = str(app["name"])
|
||||||
|
entry_point = str(app.get("entry_point") or "")
|
||||||
|
install_supported = self._install_supported(app)
|
||||||
|
is_installed = name in installed
|
||||||
|
available = bool(entry_point and shutil.which(entry_point))
|
||||||
|
if is_installed and available:
|
||||||
|
status = "installed"
|
||||||
|
elif is_installed:
|
||||||
|
status = "missing"
|
||||||
|
elif not install_supported:
|
||||||
|
status = "unsupported"
|
||||||
|
elif available:
|
||||||
|
status = "available"
|
||||||
|
else:
|
||||||
|
status = "not_installed"
|
||||||
|
logo_url, brand_color = _brand_payload(app)
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"display_name": app.get("display_name") or name,
|
||||||
|
"category": app.get("category") or "uncategorized",
|
||||||
|
"description": app.get("description") or "",
|
||||||
|
"requires": app.get("requires") or "",
|
||||||
|
"source": app.get("_source") or "harness",
|
||||||
|
"entry_point": entry_point,
|
||||||
|
"install_supported": install_supported,
|
||||||
|
"installed": is_installed,
|
||||||
|
"available": available,
|
||||||
|
"status": status,
|
||||||
|
"logo_url": logo_url,
|
||||||
|
"brand_color": brand_color,
|
||||||
|
"skill_installed": self._skill_path(name).is_file(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||||
|
apps, updated = self.catalog(force_refresh=force_refresh)
|
||||||
|
installed = self._load_installed()
|
||||||
|
rows = [self._app_payload(app, installed) for app in apps]
|
||||||
|
rows.sort(key=lambda item: (str(item["category"]), str(item["display_name"]).lower()))
|
||||||
|
return {
|
||||||
|
"apps": rows,
|
||||||
|
"installed_count": sum(1 for item in rows if item["installed"]),
|
||||||
|
"catalog_updated_at": updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _pip_package_from_install(self, app: dict[str, Any]) -> str | None:
|
||||||
|
install_cmd = str(app.get("install_cmd") or "")
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(install_cmd)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if tokens[:2] == ["pip", "install"]:
|
||||||
|
args = tokens[2:]
|
||||||
|
elif len(tokens) >= 5 and tokens[1:4] == ["-m", "pip", "install"]:
|
||||||
|
args = tokens[4:]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
args = [arg for arg in args if not arg.startswith("-")]
|
||||||
|
if len(args) != 1 or args[0].startswith("git+"):
|
||||||
|
return None
|
||||||
|
return args[0]
|
||||||
|
|
||||||
|
def _pip_install_argv(self, app: dict[str, Any], *, update: bool = False) -> list[str]:
|
||||||
|
install_cmd = str(app.get("install_cmd") or "")
|
||||||
|
if not _is_pip_install_command(install_cmd) or _has_shell_meta(install_cmd):
|
||||||
|
raise CliAppError("unsupported pip install command")
|
||||||
|
tokens = shlex.split(install_cmd)
|
||||||
|
args = tokens[2:] if tokens[:2] == ["pip", "install"] else tokens[4:]
|
||||||
|
prefix = [sys.executable, "-m", "pip", "install"]
|
||||||
|
if update:
|
||||||
|
prefix.extend(["--upgrade", "--force-reinstall"])
|
||||||
|
return prefix + args
|
||||||
|
|
||||||
|
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
|
||||||
|
uninstall_cmd = str(app.get("uninstall_cmd") or "")
|
||||||
|
packages = _pip_uninstall_args_from_command(uninstall_cmd)
|
||||||
|
if packages:
|
||||||
|
return [sys.executable, "-m", "pip", "uninstall", "-y", *packages]
|
||||||
|
package = str(app.get("pip_package") or "").strip() or self._pip_package_from_install(app)
|
||||||
|
if not package:
|
||||||
|
entry_point = str(app.get("entry_point") or "").strip()
|
||||||
|
package = entry_point if entry_point.startswith("cli-anything-") else f"cli-anything-{_brand_key(str(app['name']))}"
|
||||||
|
return [sys.executable, "-m", "pip", "uninstall", "-y", package]
|
||||||
|
|
||||||
|
def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]:
|
||||||
|
npm = shutil.which("npm")
|
||||||
|
if not npm:
|
||||||
|
raise CliAppError("npm is not installed")
|
||||||
|
package = str(app.get("npm_package") or "")
|
||||||
|
if not package:
|
||||||
|
raise CliAppError("registry entry has no npm_package")
|
||||||
|
if action == "install":
|
||||||
|
return [npm, "install", "-g", package]
|
||||||
|
if action == "update":
|
||||||
|
return [npm, "install", "-g", package + "@latest"]
|
||||||
|
return [npm, "uninstall", "-g", package]
|
||||||
|
|
||||||
|
def _split_safe_command(self, app: dict[str, Any], key: str, expected: str) -> list[str]:
|
||||||
|
command = str(app.get(key) or "")
|
||||||
|
if not command:
|
||||||
|
raise CliAppError(f"no {key} is defined for {app['name']}")
|
||||||
|
if _has_shell_meta(command):
|
||||||
|
raise CliAppError("script-style install commands are disabled in this MVP")
|
||||||
|
try:
|
||||||
|
argv = shlex.split(command)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CliAppError(f"invalid command: {exc}") from exc
|
||||||
|
if not argv or argv[0] != expected:
|
||||||
|
raise CliAppError(f"unsupported {expected} command")
|
||||||
|
return argv
|
||||||
|
|
||||||
|
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
|
||||||
|
strategy = self._strategy(app)
|
||||||
|
if strategy == "pip":
|
||||||
|
if action == "install":
|
||||||
|
return self._pip_install_argv(app)
|
||||||
|
if action == "update":
|
||||||
|
return self._pip_install_argv(app, update=True)
|
||||||
|
return self._pip_uninstall_argv(app)
|
||||||
|
if strategy == "npm":
|
||||||
|
return self._npm_argv(app, action)
|
||||||
|
if strategy == "brew":
|
||||||
|
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
|
||||||
|
return self._split_safe_command(app, key, "brew")
|
||||||
|
if strategy == "uv":
|
||||||
|
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
|
||||||
|
return self._split_safe_command(app, key, "uv")
|
||||||
|
if strategy == "bundled":
|
||||||
|
return None
|
||||||
|
raise CliAppError("this CLI app uses an unsupported install strategy")
|
||||||
|
|
||||||
|
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
argv,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"version": app.get("version") or "unknown",
|
||||||
|
"entry_point": app.get("entry_point") or "",
|
||||||
|
"source": app.get("_source") or "harness",
|
||||||
|
"strategy": self._strategy(app),
|
||||||
|
"installed_at": int(_now()),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_skill_content(self, app: dict[str, Any]) -> str | None:
|
||||||
|
skill_md = str(app.get("skill_md") or "").strip()
|
||||||
|
if not skill_md:
|
||||||
|
return None
|
||||||
|
url = _skill_content_url(skill_md)
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = httpx.get(url, timeout=15.0, follow_redirects=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
text = response.text
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if "SKILL.md" not in url and not text.lstrip().startswith("---"):
|
||||||
|
return None
|
||||||
|
return text if len(text) < 250_000 else None
|
||||||
|
|
||||||
|
def _fallback_skill(self, app: dict[str, Any]) -> str:
|
||||||
|
name = str(app.get("name") or "unknown")
|
||||||
|
display = str(app.get("display_name") or name)
|
||||||
|
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||||
|
description = str(app.get("description") or f"Use {display} from nanobot.")
|
||||||
|
return f"""---
|
||||||
|
name: {_safe_skill_name(name)}
|
||||||
|
description: >-
|
||||||
|
{description}
|
||||||
|
---
|
||||||
|
|
||||||
|
# {display}
|
||||||
|
|
||||||
|
Use this skill when the user asks nanobot to operate {display} through its installed CLI app.
|
||||||
|
|
||||||
|
If the user attached `@{name}` in chat, treat that as the selected app for the current turn.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{entry} --help
|
||||||
|
{entry} --json --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer machine-readable output when the CLI supports `--json`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _with_nanobot_skill_note(self, content: str, app: dict[str, Any]) -> str:
|
||||||
|
marker = "<!-- nanobot-cli-app-note -->"
|
||||||
|
if marker in content:
|
||||||
|
return content
|
||||||
|
name = str(app.get("name") or "unknown")
|
||||||
|
note = f"""{marker}
|
||||||
|
## Nanobot execution
|
||||||
|
|
||||||
|
Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not invoke this CLI through shell unless the user explicitly asks. Prefer this skill when Runtime Context mentions `@{name}` as a CLI App Attachment.
|
||||||
|
"""
|
||||||
|
lines = content.splitlines(keepends=True)
|
||||||
|
if lines and lines[0].strip() == "---":
|
||||||
|
for index, line in enumerate(lines[1:], start=1):
|
||||||
|
if line.strip() == "---":
|
||||||
|
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
|
||||||
|
return note + "\n" + content
|
||||||
|
|
||||||
|
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||||
|
path = self._skill_path(str(app["name"]))
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||||
|
content = self._with_nanobot_skill_note(content, app)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def remove_skill(self, name: str) -> None:
|
||||||
|
skill_dir = self._skill_path(name).parent
|
||||||
|
if skill_dir.is_dir():
|
||||||
|
shutil.rmtree(skill_dir)
|
||||||
|
|
||||||
|
def _record_installed(self, app: dict[str, Any]) -> None:
|
||||||
|
installed = self._load_installed()
|
||||||
|
installed[str(app["name"])] = self._installed_entry(app)
|
||||||
|
self._save_installed(installed)
|
||||||
|
self.install_skill(app)
|
||||||
|
|
||||||
|
def install(self, name: str) -> dict[str, Any]:
|
||||||
|
app = self.get_app(name)
|
||||||
|
if not self._install_supported(app):
|
||||||
|
raise CliAppError("this CLI app uses an unsupported install strategy")
|
||||||
|
strategy = self._strategy(app)
|
||||||
|
if strategy == "bundled":
|
||||||
|
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
|
||||||
|
if detect_cmd and _command_exists(detect_cmd):
|
||||||
|
self._record_installed(app)
|
||||||
|
return self.payload() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}}
|
||||||
|
note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app."
|
||||||
|
raise CliAppError(str(note))
|
||||||
|
argv = self._argv_for_action(app, "install")
|
||||||
|
assert argv is not None
|
||||||
|
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500)
|
||||||
|
self._record_installed(app)
|
||||||
|
return self.payload() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}}
|
||||||
|
|
||||||
|
def update(self, name: str) -> dict[str, Any]:
|
||||||
|
app = self.get_app(name, force_refresh=True)
|
||||||
|
if str(app["name"]) not in self._load_installed():
|
||||||
|
raise CliAppError("CLI app is not installed")
|
||||||
|
if self._strategy(app) == "bundled":
|
||||||
|
self._record_installed(app)
|
||||||
|
return self.payload() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}}
|
||||||
|
argv = self._argv_for_action(app, "update")
|
||||||
|
assert argv is not None
|
||||||
|
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500)
|
||||||
|
self._record_installed(app)
|
||||||
|
return self.payload() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}}
|
||||||
|
|
||||||
|
def uninstall(self, name: str) -> dict[str, Any]:
|
||||||
|
app = self.get_app(name)
|
||||||
|
installed = self._load_installed()
|
||||||
|
if str(app["name"]) not in installed:
|
||||||
|
raise CliAppError("CLI app is not installed")
|
||||||
|
if self._strategy(app) != "bundled":
|
||||||
|
argv = self._argv_for_action(app, "uninstall")
|
||||||
|
assert argv is not None
|
||||||
|
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise CliAppError(_truncate(result.stderr or result.stdout or "uninstall failed"), status=500)
|
||||||
|
installed.pop(str(app["name"]), None)
|
||||||
|
self._save_installed(installed)
|
||||||
|
self.remove_skill(str(app["name"]))
|
||||||
|
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}}
|
||||||
|
|
||||||
|
def test(self, name: str) -> dict[str, Any]:
|
||||||
|
app = self.get_app(name)
|
||||||
|
entry = str(app.get("entry_point") or "")
|
||||||
|
resolved = shutil.which(entry)
|
||||||
|
if not entry or not resolved:
|
||||||
|
raise CliAppError(f"{entry or name} is not available on PATH")
|
||||||
|
result = self._run_argv([resolved, "--help"], timeout=min(self.runtime.run_timeout, 30))
|
||||||
|
ok = result.returncode == 0
|
||||||
|
output = _truncate((result.stdout or result.stderr or "").strip(), 3000)
|
||||||
|
return self.payload() | {
|
||||||
|
"last_action": {
|
||||||
|
"ok": ok,
|
||||||
|
"message": f"{entry} --help exited {result.returncode}",
|
||||||
|
"output": output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_cwd(
|
||||||
|
self,
|
||||||
|
working_dir: str | None,
|
||||||
|
*,
|
||||||
|
restrict_to_workspace: bool,
|
||||||
|
) -> Path:
|
||||||
|
cwd = Path(working_dir).expanduser() if working_dir else self.workspace
|
||||||
|
cwd = cwd.resolve(strict=False)
|
||||||
|
workspace = self.workspace.resolve(strict=False)
|
||||||
|
if restrict_to_workspace and cwd != workspace and not cwd.is_relative_to(workspace):
|
||||||
|
raise CliAppError("working_dir is outside the configured workspace")
|
||||||
|
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(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
args: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
json_output: bool = False,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
restrict_to_workspace: bool = False,
|
||||||
|
) -> str:
|
||||||
|
app = self.get_app(name)
|
||||||
|
installed = self._load_installed()
|
||||||
|
if str(app["name"]) not in installed:
|
||||||
|
raise CliAppError(f"CLI app '{name}' is not installed")
|
||||||
|
cwd = self._resolve_cwd(working_dir, restrict_to_workspace=restrict_to_workspace)
|
||||||
|
entry = str(installed[str(app["name"])].get("entry_point") or app.get("entry_point") or "")
|
||||||
|
resolved = shutil.which(entry)
|
||||||
|
if not entry or not resolved:
|
||||||
|
raise CliAppError(f"{entry or name} is not available on PATH")
|
||||||
|
clean_args = [str(arg) for arg in (args or [])]
|
||||||
|
if json_output and "--json" not in clean_args:
|
||||||
|
clean_args = ["--json", *clean_args]
|
||||||
|
effective_timeout = max(1, min(timeout or self.runtime.run_timeout, 600))
|
||||||
|
artifact_snapshot = self._artifact_snapshot(cwd)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[resolved, *clean_args],
|
||||||
|
cwd=str(cwd),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=effective_timeout,
|
||||||
|
env=os.environ.copy(),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return f"CLI app '{name}' timed out after {effective_timeout}s"
|
||||||
|
output = [
|
||||||
|
f"CLI app '{name}' exited {result.returncode}.",
|
||||||
|
f"Command: {entry} {' '.join(shlex.quote(arg) for arg in clean_args)}".rstrip(),
|
||||||
|
]
|
||||||
|
if result.stdout:
|
||||||
|
output.append("\nSTDOUT:\n" + result.stdout.rstrip())
|
||||||
|
if result.stderr:
|
||||||
|
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))
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""CLI Apps helpers shared by the agent loop and settings surfaces."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Return persisted session kwargs for CLI app attachments."""
|
||||||
|
cli_apps = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
|
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
||||||
|
"""Return model-visible CLI app annotations for the current turn."""
|
||||||
|
if skip:
|
||||||
|
return []
|
||||||
|
text = message.content if isinstance(getattr(message, "content", None), str) else ""
|
||||||
|
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
||||||
|
return _cli_app_runtime_lines(text, metadata, workspace)
|
||||||
|
|
||||||
|
|
||||||
|
def _cli_app_runtime_lines(
|
||||||
|
text: str,
|
||||||
|
metadata: Mapping[str, Any] | None,
|
||||||
|
workspace: Path,
|
||||||
|
) -> list[str]:
|
||||||
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
|
if isinstance(structured, list):
|
||||||
|
mentions = [
|
||||||
|
item for item in structured
|
||||||
|
if isinstance(item, Mapping) and isinstance(item.get("name"), str)
|
||||||
|
]
|
||||||
|
if mentions:
|
||||||
|
return [
|
||||||
|
"CLI App Attachment: "
|
||||||
|
f"@{str(item['name']).strip().lower()} "
|
||||||
|
f"(installed; tool=run_cli_app; "
|
||||||
|
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||||
|
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
||||||
|
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||||
|
for item in mentions
|
||||||
|
if str(item.get("name") or "").strip()
|
||||||
|
]
|
||||||
|
if "@" not in text:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
from nanobot.cli_apps import CliAppManager
|
||||||
|
|
||||||
|
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
"CLI App Mention: "
|
||||||
|
f"@{item['name']} "
|
||||||
|
f"(installed; tool={item['tool']}; "
|
||||||
|
f"entry_point={item['entry_point'] or 'unknown'}; "
|
||||||
|
f"skill={item['skill']}). "
|
||||||
|
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||||
|
for item in mentions
|
||||||
|
]
|
||||||
@@ -11,6 +11,7 @@ from pydantic_settings import BaseSettings
|
|||||||
from nanobot.cron.types import CronSchedule
|
from nanobot.cron.types import CronSchedule
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||||
from nanobot.agent.tools.self import MyToolConfig
|
from nanobot.agent.tools.self import MyToolConfig
|
||||||
from nanobot.agent.tools.shell import ExecToolConfig
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
@@ -211,6 +212,7 @@ class ProvidersConfig(Base):
|
|||||||
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
|
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
|
||||||
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
||||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
||||||
|
novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI
|
||||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
||||||
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
|
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
|
||||||
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
|
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
|
||||||
@@ -275,6 +277,7 @@ class ToolsConfig(Base):
|
|||||||
|
|
||||||
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
|
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
|
||||||
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
|
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
|
||||||
|
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
|
||||||
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
|
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
|
||||||
image_generation: ImageGenerationToolConfig = Field(
|
image_generation: ImageGenerationToolConfig = Field(
|
||||||
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
|
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
|
||||||
@@ -461,6 +464,7 @@ def _resolve_tool_config_refs() -> None:
|
|||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||||
from nanobot.agent.tools.self import MyToolConfig
|
from nanobot.agent.tools.self import MyToolConfig
|
||||||
from nanobot.agent.tools.shell import ExecToolConfig
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
@@ -469,6 +473,7 @@ def _resolve_tool_config_refs() -> None:
|
|||||||
# Re-export into this module's namespace
|
# Re-export into this module's namespace
|
||||||
mod = sys.modules[__name__]
|
mod = sys.modules[__name__]
|
||||||
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
||||||
|
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
||||||
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
||||||
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
|
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
|
||||||
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
|
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
|
import re
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -31,6 +33,14 @@ _AIHUBMIX_ASPECT_RATIO_SIZES = {
|
|||||||
}
|
}
|
||||||
_GEMINI_DEFAULT_TIMEOUT_S = 120.0
|
_GEMINI_DEFAULT_TIMEOUT_S = 120.0
|
||||||
_GEMINI_IMAGEN_ASPECT_RATIOS = {"1:1", "9:16", "16:9", "3:4", "4:3"}
|
_GEMINI_IMAGEN_ASPECT_RATIOS = {"1:1", "9:16", "16:9", "3:4", "4:3"}
|
||||||
|
_OLLAMA_DEFAULT_SIDE = 1024
|
||||||
|
_OLLAMA_SIZE_PRESETS = {
|
||||||
|
"1K": 1024,
|
||||||
|
"2K": 2048,
|
||||||
|
"4K": 4096,
|
||||||
|
}
|
||||||
|
_OLLAMA_EXPLICIT_SIZE_RE = re.compile(r"^\s*(\d+)\s*[xX]\s*(\d+)\s*$")
|
||||||
|
_OLLAMA_ASPECT_RATIO_RE = re.compile(r"^\s*(\d+)\s*:\s*(\d+)\s*$")
|
||||||
|
|
||||||
|
|
||||||
class ImageGenerationError(RuntimeError):
|
class ImageGenerationError(RuntimeError):
|
||||||
@@ -129,6 +139,11 @@ _IMAGE_GEN_PROVIDERS: dict[str, type[ImageGenerationProvider]] = {}
|
|||||||
|
|
||||||
|
|
||||||
def register_image_gen_provider(cls: type[ImageGenerationProvider]) -> None:
|
def register_image_gen_provider(cls: type[ImageGenerationProvider]) -> None:
|
||||||
|
"""Register an image provider at import time only.
|
||||||
|
|
||||||
|
The registry is populated by module side effects so provider discovery
|
||||||
|
stays lazy and consistent across the process.
|
||||||
|
"""
|
||||||
name = cls.provider_name
|
name = cls.provider_name
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError(f"{cls.__name__} must set provider_name")
|
raise ValueError(f"{cls.__name__} must set provider_name")
|
||||||
@@ -219,7 +234,10 @@ class ImageGenerationProvider(ABC):
|
|||||||
*,
|
*,
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
|
if client is not None:
|
||||||
|
return await client.post(url, headers=headers, json=body)
|
||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
return await self._client.post(url, headers=headers, json=body)
|
return await self._client.post(url, headers=headers, json=body)
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||||
@@ -390,10 +408,11 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
|
|||||||
model_path = _aihubmix_model_path(model)
|
model_path = _aihubmix_model_path(model)
|
||||||
url = f"{self.api_base}/models/{model_path}/predictions"
|
url = f"{self.api_base}/models/{model_path}/predictions"
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await self._http_post(
|
||||||
url,
|
url,
|
||||||
headers={**headers, "Content-Type": "application/json"},
|
headers={**headers, "Content-Type": "application/json"},
|
||||||
json=body,
|
body=body,
|
||||||
|
client=client,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise ImageGenerationError("AIHubMix image generation timed out") from exc
|
raise ImageGenerationError("AIHubMix image generation timed out") from exc
|
||||||
@@ -429,6 +448,139 @@ def _http_error_detail(response: httpx.Response) -> str:
|
|||||||
return response.text[:500] or "<empty response body>"
|
return response.text[:500] or "<empty response body>"
|
||||||
|
|
||||||
|
|
||||||
|
def _round_to_multiple(value: float, multiple: int = 8) -> int:
|
||||||
|
rounded = int(round(value / multiple) * multiple)
|
||||||
|
return max(multiple, rounded)
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_dimensions(aspect_ratio: str | None, image_size: str | None) -> tuple[int, int]:
|
||||||
|
if image_size:
|
||||||
|
size = image_size.strip()
|
||||||
|
explicit = _OLLAMA_EXPLICIT_SIZE_RE.fullmatch(size)
|
||||||
|
if explicit:
|
||||||
|
return int(explicit.group(1)), int(explicit.group(2))
|
||||||
|
long_side = _OLLAMA_SIZE_PRESETS.get(size.upper(), _OLLAMA_DEFAULT_SIDE)
|
||||||
|
else:
|
||||||
|
long_side = _OLLAMA_DEFAULT_SIDE
|
||||||
|
|
||||||
|
if not aspect_ratio:
|
||||||
|
return long_side, long_side
|
||||||
|
|
||||||
|
ratio = _OLLAMA_ASPECT_RATIO_RE.fullmatch(aspect_ratio.strip())
|
||||||
|
if ratio is None:
|
||||||
|
return long_side, long_side
|
||||||
|
|
||||||
|
width_ratio = int(ratio.group(1))
|
||||||
|
height_ratio = int(ratio.group(2))
|
||||||
|
if width_ratio <= 0 or height_ratio <= 0:
|
||||||
|
return long_side, long_side
|
||||||
|
|
||||||
|
if width_ratio >= height_ratio:
|
||||||
|
width = long_side
|
||||||
|
height = _round_to_multiple(long_side * height_ratio / width_ratio)
|
||||||
|
else:
|
||||||
|
height = long_side
|
||||||
|
width = _round_to_multiple(long_side * width_ratio / height_ratio)
|
||||||
|
return max(8, width), max(8, height)
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_image_data_url(value: str) -> str:
|
||||||
|
if value.startswith("data:image/"):
|
||||||
|
return value
|
||||||
|
return _b64_image_data_url(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
||||||
|
images: list[str] = []
|
||||||
|
|
||||||
|
def collect(value: Any) -> None:
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
images.append(_ollama_image_data_url(value))
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
collect(item)
|
||||||
|
|
||||||
|
collect(payload.get("image"))
|
||||||
|
collect(payload.get("images"))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaImageGenerationClient(ImageGenerationProvider):
|
||||||
|
"""Async client for Ollama native image generation models."""
|
||||||
|
|
||||||
|
provider_name = "ollama"
|
||||||
|
default_timeout = 300.0
|
||||||
|
|
||||||
|
def _default_base_url(self) -> str:
|
||||||
|
return "http://localhost:11434/api"
|
||||||
|
|
||||||
|
def _resolve_base_url(self, api_base: str | None) -> str:
|
||||||
|
if api_base:
|
||||||
|
base = api_base.rstrip("/")
|
||||||
|
if base.endswith("/v1"):
|
||||||
|
return f"{base[:-3]}/api"
|
||||||
|
return base
|
||||||
|
return self._default_base_url()
|
||||||
|
|
||||||
|
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 reference_images:
|
||||||
|
raise ImageGenerationError(
|
||||||
|
"Ollama image generation does not support reference images"
|
||||||
|
)
|
||||||
|
|
||||||
|
width, height = _ollama_dimensions(aspect_ratio, image_size)
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"steps": 0,
|
||||||
|
}
|
||||||
|
body.update(self.extra_body)
|
||||||
|
body["stream"] = False
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
|
url = f"{self.api_base}/generate"
|
||||||
|
response = await self._http_post(url, headers=headers, body=body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = _http_error_detail(response)
|
||||||
|
logger.error(
|
||||||
|
"Ollama image generation failed (HTTP {}): {}",
|
||||||
|
response.status_code,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
raise ImageGenerationError(
|
||||||
|
f"Ollama image generation failed (HTTP {response.status_code}): {detail}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
images = _ollama_images_from_payload(data)
|
||||||
|
|
||||||
|
self._require_images(images, data)
|
||||||
|
|
||||||
|
response_text = data.get("response")
|
||||||
|
content = response_text if isinstance(response_text, str) else ""
|
||||||
|
|
||||||
|
return GeneratedImageResponse(images=images, content=content, raw=data)
|
||||||
|
|
||||||
|
|
||||||
class GeminiImageGenerationClient(ImageGenerationProvider):
|
class GeminiImageGenerationClient(ImageGenerationProvider):
|
||||||
"""Async client for Gemini/Imagen image generation via the Generative Language API."""
|
"""Async client for Gemini/Imagen image generation via the Generative Language API."""
|
||||||
|
|
||||||
@@ -442,9 +594,9 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
|
|||||||
return "https://generativelanguage.googleapis.com/v1beta"
|
return "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
|
||||||
def _resolve_base_url(self, api_base: str | None) -> str:
|
def _resolve_base_url(self, api_base: str | None) -> str:
|
||||||
# The Gemini provider's registry default_api_base is the OpenAI-compat
|
# Gemini chat completions use the registry's OpenAI-compatible shim.
|
||||||
# shim (.../v1beta/openai/), which has no image endpoints.
|
# Image generation must hit the native Generative Language API, so we
|
||||||
# Skip the registry lookup and use the native API base directly.
|
# intentionally bypass the shared registry lookup here.
|
||||||
if api_base:
|
if api_base:
|
||||||
return api_base.rstrip("/")
|
return api_base.rstrip("/")
|
||||||
return self._default_base_url()
|
return self._default_base_url()
|
||||||
@@ -706,22 +858,16 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider):
|
|||||||
|
|
||||||
body.update(self.extra_body)
|
body.update(self.extra_body)
|
||||||
|
|
||||||
client = self._client or httpx.AsyncClient(timeout=self.timeout)
|
return await self._generate_with_client(body, headers)
|
||||||
try:
|
|
||||||
return await self._generate_with_client(client, body, headers)
|
|
||||||
finally:
|
|
||||||
if self._client is None:
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
async def _generate_with_client(
|
async def _generate_with_client(
|
||||||
self,
|
self,
|
||||||
client: httpx.AsyncClient,
|
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
) -> GeneratedImageResponse:
|
) -> GeneratedImageResponse:
|
||||||
url = f"{self.api_base}/image_generation"
|
url = f"{self.api_base}/image_generation"
|
||||||
try:
|
try:
|
||||||
response = await client.post(url, headers=headers, json=body)
|
response = await self._http_post(url, headers=headers, body=body)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise ImageGenerationError("MiniMax image generation timed out") from exc
|
raise ImageGenerationError("MiniMax image generation timed out") from exc
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
@@ -756,6 +902,426 @@ def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
|||||||
return images
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OpenAI image generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_OPENAI_DALLE2_SUPPORTED_SIZES = {"256x256", "512x512", "1024x1024"}
|
||||||
|
_OPENAI_DALLE3_SUPPORTED_SIZES = {"1024x1024", "1792x1024", "1024x1792"}
|
||||||
|
_OPENAI_GPT_IMAGE_SUPPORTED_SIZES = {
|
||||||
|
"1024x1024",
|
||||||
|
"1536x1024",
|
||||||
|
"1024x1536",
|
||||||
|
"auto",
|
||||||
|
}
|
||||||
|
_OPENAI_DALLE2_ASPECT_RATIO_SIZES = {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"16:9": "1024x1024",
|
||||||
|
"9:16": "1024x1024",
|
||||||
|
"3:4": "1024x1024",
|
||||||
|
"4:3": "1024x1024",
|
||||||
|
}
|
||||||
|
_OPENAI_DALLE3_ASPECT_RATIO_SIZES = {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"16:9": "1792x1024",
|
||||||
|
"9:16": "1024x1792",
|
||||||
|
"3:4": "1024x1792",
|
||||||
|
"4:3": "1792x1024",
|
||||||
|
}
|
||||||
|
_OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES = {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"16:9": "1536x1024",
|
||||||
|
"9:16": "1024x1536",
|
||||||
|
"3:4": "1024x1536",
|
||||||
|
"4:3": "1536x1024",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIImageGenerationClient(ImageGenerationProvider):
|
||||||
|
"""OpenAI Images API using an API key (``providers.openai.apiKey``)."""
|
||||||
|
|
||||||
|
provider_name = "openai"
|
||||||
|
missing_key_message = (
|
||||||
|
"OpenAI API key is not configured. Set providers.openai.apiKey."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _default_base_url(self) -> str:
|
||||||
|
return "https://api.openai.com/v1"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_model_prefix(model: str) -> str:
|
||||||
|
"""Remove ``openai/`` prefix if present (OpenRouter convention)."""
|
||||||
|
if model.startswith("openai/") or model.startswith("openai_codex/"):
|
||||||
|
return model.split("/", 1)[1]
|
||||||
|
return model
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.warning(
|
||||||
|
"DALL-E models do not support reference images; "
|
||||||
|
"ignoring {} reference image(s) for {}",
|
||||||
|
len(reference_images),
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
clean_model = self._strip_model_prefix(model)
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"model": clean_model,
|
||||||
|
"prompt": prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not _openai_is_gpt_image_model(clean_model):
|
||||||
|
body["response_format"] = "b64_json"
|
||||||
|
body["n"] = 1
|
||||||
|
|
||||||
|
size = _openai_size(clean_model, aspect_ratio, image_size)
|
||||||
|
if size:
|
||||||
|
body["size"] = size
|
||||||
|
|
||||||
|
body.update(self.extra_body)
|
||||||
|
|
||||||
|
logger.info("OpenAI Images API request: POST {}/images/generations body={}", self.api_base, body)
|
||||||
|
|
||||||
|
response = await self._http_post(
|
||||||
|
f"{self.api_base}/images/generations",
|
||||||
|
headers=headers,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = response.text[:1000]
|
||||||
|
logger.error("OpenAI Images API error ({}): {}", response.status_code, detail)
|
||||||
|
raise ImageGenerationError(
|
||||||
|
f"OpenAI image generation failed (HTTP {response.status_code}): {detail}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
logger.info("OpenAI Images API response ({}): {}", response.status_code,
|
||||||
|
{k: v for k, v in payload.items() if k != "data"})
|
||||||
|
|
||||||
|
client = self._client
|
||||||
|
owns_client = client is None
|
||||||
|
if owns_client:
|
||||||
|
client = httpx.AsyncClient(timeout=self.timeout)
|
||||||
|
try:
|
||||||
|
images = await _openai_images_from_payload(client, payload)
|
||||||
|
finally:
|
||||||
|
if owns_client:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
self._require_images(images, payload)
|
||||||
|
|
||||||
|
return GeneratedImageResponse(images=images, content="", raw=payload)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OpenAI Codex image generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CodexImageGenerationClient(ImageGenerationProvider):
|
||||||
|
"""OpenAI image generation via Codex subscription OAuth.
|
||||||
|
|
||||||
|
Uses the Codex Responses API with the ``image_generation`` tool
|
||||||
|
(the same mechanism ChatGPT uses internally). No API key required —
|
||||||
|
the Codex OAuth token from ``oauth_cli_kit`` is used instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider_name = "openai_codex"
|
||||||
|
missing_key_message = (
|
||||||
|
"Codex OAuth token is unavailable. "
|
||||||
|
"Log in with Codex subscription first."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _default_base_url(self) -> str:
|
||||||
|
return "https://chatgpt.com/backend-api"
|
||||||
|
|
||||||
|
def _codex_model(self, model: str) -> str:
|
||||||
|
"""Strip the ``openai-codex/`` prefix if present."""
|
||||||
|
if model.startswith(("openai-codex/", "openai_codex/")):
|
||||||
|
return model.split("/", 1)[1]
|
||||||
|
return model
|
||||||
|
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
from oauth_cli_kit import get_token as get_codex_token
|
||||||
|
except ImportError:
|
||||||
|
raise ImageGenerationError(self.missing_key_message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = await asyncio.to_thread(get_codex_token)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ImageGenerationError(self.missing_key_message) from exc
|
||||||
|
if not token or not token.access:
|
||||||
|
raise ImageGenerationError(self.missing_key_message)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Using Codex OAuth token for image generation (account: {})",
|
||||||
|
token.account_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if reference_images:
|
||||||
|
logger.warning(
|
||||||
|
"Codex image generation does not support reference images; "
|
||||||
|
"ignoring {} reference image(s)",
|
||||||
|
len(reference_images),
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token.access}",
|
||||||
|
"chatgpt-account-id": token.account_id,
|
||||||
|
"OpenAI-Beta": "responses=experimental",
|
||||||
|
"originator": "nanobot",
|
||||||
|
"User-Agent": "nanobot (python)",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"model": self._codex_model(model),
|
||||||
|
"instructions": "Generate an image based on the user's request.",
|
||||||
|
"input": [{"role": "user", "content": prompt}],
|
||||||
|
"tools": [{"type": "image_generation"}],
|
||||||
|
"tool_choice": "auto",
|
||||||
|
"stream": True,
|
||||||
|
"store": False,
|
||||||
|
}
|
||||||
|
body.update(self.extra_body)
|
||||||
|
|
||||||
|
logger.info("Codex Responses API request: POST {}/codex/responses body={}",
|
||||||
|
self.api_base, {k: v for k, v in body.items() if k != "input"})
|
||||||
|
|
||||||
|
response = await self._http_post(
|
||||||
|
f"{self.api_base}/codex/responses",
|
||||||
|
headers=headers,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = response.text[:1000]
|
||||||
|
logger.error("Codex Responses API error ({}): {}", response.status_code, detail)
|
||||||
|
raise ImageGenerationError(
|
||||||
|
f"Codex image generation failed (HTTP {response.status_code}): {detail}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
images, content_text = await _parse_codex_sse_images(response)
|
||||||
|
|
||||||
|
raw = {"status": "completed"}
|
||||||
|
self._require_images(images, raw)
|
||||||
|
|
||||||
|
return GeneratedImageResponse(images=images, content=content_text, raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_size(
|
||||||
|
model: str,
|
||||||
|
aspect_ratio: str | None,
|
||||||
|
image_size: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve aspect ratio or image_size to an OpenAI Images API size string."""
|
||||||
|
sizes, supported_sizes = _openai_size_options(model)
|
||||||
|
explicit_size = _normalize_openai_image_size(image_size)
|
||||||
|
if explicit_size and _openai_explicit_size_supported(
|
||||||
|
explicit_size,
|
||||||
|
supported_sizes=supported_sizes,
|
||||||
|
):
|
||||||
|
return explicit_size
|
||||||
|
if explicit_size:
|
||||||
|
logger.warning(
|
||||||
|
"OpenAI image size '{}' is not supported by {}; using aspect ratio/default size",
|
||||||
|
explicit_size,
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
if aspect_ratio and aspect_ratio in sizes:
|
||||||
|
return sizes[aspect_ratio]
|
||||||
|
return "1024x1024"
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_is_gpt_image_model(model: str) -> bool:
|
||||||
|
normalized = model.lower()
|
||||||
|
return normalized.startswith(("gpt-image", "chatgpt-image"))
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_size_options(model: str) -> tuple[dict[str, str], set[str] | None]:
|
||||||
|
normalized = model.lower()
|
||||||
|
if normalized.startswith("dall-e-2"):
|
||||||
|
return _OPENAI_DALLE2_ASPECT_RATIO_SIZES, _OPENAI_DALLE2_SUPPORTED_SIZES
|
||||||
|
if normalized.startswith("dall-e-3"):
|
||||||
|
return _OPENAI_DALLE3_ASPECT_RATIO_SIZES, _OPENAI_DALLE3_SUPPORTED_SIZES
|
||||||
|
if normalized.startswith("gpt-image-2"):
|
||||||
|
return _OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, None
|
||||||
|
return _OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, _OPENAI_GPT_IMAGE_SUPPORTED_SIZES
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_openai_image_size(image_size: str | None) -> str | None:
|
||||||
|
if not image_size:
|
||||||
|
return None
|
||||||
|
normalized = image_size.strip().lower()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_explicit_size_supported(
|
||||||
|
size: str,
|
||||||
|
*,
|
||||||
|
supported_sizes: set[str] | None,
|
||||||
|
) -> bool:
|
||||||
|
if supported_sizes is not None:
|
||||||
|
return size in supported_sizes
|
||||||
|
width, sep, height = size.partition("x")
|
||||||
|
return bool(sep and width.isdecimal() and height.isdecimal())
|
||||||
|
|
||||||
|
|
||||||
|
async def _openai_images_from_payload(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract images from OpenAI Images API response.
|
||||||
|
|
||||||
|
Handles both ``b64_json`` (preferred) and ``url`` (downloaded) formats.
|
||||||
|
"""
|
||||||
|
images: list[str] = []
|
||||||
|
for item in payload.get("data") or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
b64 = item.get("b64_json")
|
||||||
|
if isinstance(b64, str) and b64:
|
||||||
|
images.append(_b64_image_data_url(b64))
|
||||||
|
continue
|
||||||
|
url = item.get("url")
|
||||||
|
if isinstance(url, str) and url:
|
||||||
|
images.append(await _download_image_data_url(client, url))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_responses_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
||||||
|
"""Extract images from Codex Responses API ``image_generation_call`` output."""
|
||||||
|
images: list[str] = []
|
||||||
|
for item in payload.get("output") or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("type") != "image_generation_call":
|
||||||
|
continue
|
||||||
|
result = item.get("result")
|
||||||
|
if isinstance(result, str):
|
||||||
|
images.append(result if result.startswith("data:image/") else _b64_image_data_url(result))
|
||||||
|
continue
|
||||||
|
if isinstance(result, dict):
|
||||||
|
image_url = result.get("image_url") or result.get("image") or ""
|
||||||
|
if isinstance(image_url, str):
|
||||||
|
images.append(image_url if image_url.startswith("data:image/") else _b64_image_data_url(image_url))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_codex_sse_images(
|
||||||
|
response: httpx.Response,
|
||||||
|
) -> tuple[list[str], str]:
|
||||||
|
"""Parse a Codex Responses API SSE stream for image generation output.
|
||||||
|
|
||||||
|
Returns ``(images, content_text)``.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
images: list[str] = []
|
||||||
|
text_parts: list[str] = []
|
||||||
|
|
||||||
|
buffer: list[str] = []
|
||||||
|
async for line_bytes in response.aiter_lines():
|
||||||
|
line = line_bytes.strip()
|
||||||
|
if line == "":
|
||||||
|
if buffer:
|
||||||
|
data_lines = []
|
||||||
|
for bl in buffer:
|
||||||
|
if bl.startswith("data:"):
|
||||||
|
data_lines.append(bl[5:].strip())
|
||||||
|
buffer.clear()
|
||||||
|
if data_lines:
|
||||||
|
raw = "".join(data_lines)
|
||||||
|
if raw == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
event = _json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
ev_type = event.get("type", "")
|
||||||
|
if ev_type in ("error", "response.failed"):
|
||||||
|
logger.error("Codex SSE failure: {}", raw[:2000])
|
||||||
|
_collect_images_from_sse_event(event, images)
|
||||||
|
_collect_text_from_sse_event(event, text_parts)
|
||||||
|
continue
|
||||||
|
buffer.append(line)
|
||||||
|
|
||||||
|
# flush remaining
|
||||||
|
if buffer:
|
||||||
|
data_lines = [bl[5:].strip() for bl in buffer if bl.startswith("data:")]
|
||||||
|
raw = "".join(data_lines)
|
||||||
|
if raw and raw != "[DONE]":
|
||||||
|
try:
|
||||||
|
event = _json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
_collect_images_from_sse_event(event, images)
|
||||||
|
_collect_text_from_sse_event(event, text_parts)
|
||||||
|
|
||||||
|
return images, "".join(text_parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_images_from_sse_event(event: dict[str, Any], images: list[str]) -> None:
|
||||||
|
if event.get("type") != "response.output_item.done":
|
||||||
|
return
|
||||||
|
item = event.get("item") or {}
|
||||||
|
if item.get("type") != "image_generation_call":
|
||||||
|
return
|
||||||
|
result = item.get("result")
|
||||||
|
if isinstance(result, str):
|
||||||
|
if result.startswith("data:image/"):
|
||||||
|
images.append(result)
|
||||||
|
else:
|
||||||
|
images.append(_b64_image_data_url(result))
|
||||||
|
elif isinstance(result, dict):
|
||||||
|
image_url = result.get("image_url") or result.get("image") or ""
|
||||||
|
if isinstance(image_url, str):
|
||||||
|
if image_url.startswith("data:image/"):
|
||||||
|
images.append(image_url)
|
||||||
|
else:
|
||||||
|
images.append(_b64_image_data_url(image_url))
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_text_from_sse_event(event: dict[str, Any], text_parts: list[str]) -> None:
|
||||||
|
if event.get("type") == "response.output_text.delta":
|
||||||
|
delta = event.get("delta")
|
||||||
|
if isinstance(delta, str) and delta:
|
||||||
|
text_parts.append(delta)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# StepFun (阶跃星辰) image generation
|
# StepFun (阶跃星辰) image generation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -883,8 +1449,11 @@ def _stepfun_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
|||||||
# Provider registration
|
# Provider registration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
register_image_gen_provider(OpenRouterImageGenerationClient)
|
|
||||||
register_image_gen_provider(AIHubMixImageGenerationClient)
|
register_image_gen_provider(AIHubMixImageGenerationClient)
|
||||||
|
register_image_gen_provider(CodexImageGenerationClient)
|
||||||
register_image_gen_provider(GeminiImageGenerationClient)
|
register_image_gen_provider(GeminiImageGenerationClient)
|
||||||
|
register_image_gen_provider(OllamaImageGenerationClient)
|
||||||
register_image_gen_provider(MiniMaxImageGenerationClient)
|
register_image_gen_provider(MiniMaxImageGenerationClient)
|
||||||
|
register_image_gen_provider(OpenAIImageGenerationClient)
|
||||||
|
register_image_gen_provider(OpenRouterImageGenerationClient)
|
||||||
register_image_gen_provider(StepFunImageGenerationClient)
|
register_image_gen_provider(StepFunImageGenerationClient)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import secrets
|
|||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -74,41 +75,43 @@ _THINKING_STYLE_MAP: dict[str, Any] = {
|
|||||||
"enable_thinking": lambda on: {"enable_thinking": on},
|
"enable_thinking": lambda on: {"enable_thinking": on},
|
||||||
"reasoning_split": lambda on: {"reasoning_split": on},
|
"reasoning_split": lambda on: {"reasoning_split": on},
|
||||||
}
|
}
|
||||||
|
_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = {
|
||||||
|
"reasoning_effort": lambda effort: {"reasoning": {"effort": effort}},
|
||||||
|
}
|
||||||
|
_MODEL_THINKING_STYLES: dict[str, str] = {
|
||||||
|
**dict.fromkeys(_KIMI_THINKING_MODELS, "thinking_type"),
|
||||||
|
**dict.fromkeys(_MIMO_THINKING_MODELS, "thinking_type"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_kimi_thinking_model(model_name: str) -> bool:
|
def _model_slug(model_name: str) -> str:
|
||||||
"""Return True if model_name refers to a Kimi thinking-capable model.
|
return model_name.lower().rsplit("/", 1)[-1]
|
||||||
|
|
||||||
Supports two forms:
|
|
||||||
- Exact match: e.g. kimi-k2.5 / kimi-k2.6 in _KIMI_THINKING_MODELS
|
|
||||||
- Slug match: moonshotai/kimi-k2.5 -> the part after the last "/"
|
|
||||||
is checked against _KIMI_THINKING_MODELS
|
|
||||||
|
|
||||||
This covers both the native Moonshot provider (bare slug) and
|
|
||||||
OpenRouter-style names (``"publisher/slug"``).
|
|
||||||
"""
|
|
||||||
name = model_name.lower()
|
|
||||||
if name in _KIMI_THINKING_MODELS:
|
|
||||||
return True
|
|
||||||
if "/" in name and name.rsplit("/", 1)[1] in _KIMI_THINKING_MODELS:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_mimo_thinking_model(model_name: str) -> bool:
|
def _model_thinking_style(model_name: str) -> str:
|
||||||
"""Return True if model_name refers to a MiMo thinking-capable model.
|
return _MODEL_THINKING_STYLES.get(_model_slug(model_name), "")
|
||||||
|
|
||||||
Mirrors _is_kimi_thinking_model: gateway providers (e.g. OpenRouter
|
|
||||||
routing ``xiaomi/mimo-v2.5-pro``) have no ``thinking_style`` on their
|
def _thinking_styles_for(spec: ProviderSpec | None, model_name: str) -> list[str]:
|
||||||
spec, so the spec-driven branch in _build_kwargs misses them. The
|
styles: list[str] = []
|
||||||
model-name path catches those cases.
|
if spec and spec.thinking_style:
|
||||||
"""
|
styles.append(spec.thinking_style)
|
||||||
name = model_name.lower()
|
model_style = _model_thinking_style(model_name)
|
||||||
if name in _MIMO_THINKING_MODELS:
|
if model_style and model_style not in styles:
|
||||||
return True
|
styles.append(model_style)
|
||||||
if "/" in name and name.rsplit("/", 1)[1] in _MIMO_THINKING_MODELS:
|
return styles
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
def _thinking_extra_body(style: str, thinking_enabled: bool) -> dict[str, Any] | None:
|
||||||
|
builder = _THINKING_STYLE_MAP.get(style)
|
||||||
|
return builder(thinking_enabled) if builder else None
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_reasoning_extra_body(style: str, effort: str | None) -> dict[str, Any] | None:
|
||||||
|
if not effort:
|
||||||
|
return None
|
||||||
|
builder = _GATEWAY_REASONING_STYLE_MAP.get(style)
|
||||||
|
return builder(effort) if builder else None
|
||||||
|
|
||||||
|
|
||||||
def _openai_compat_timeout_s() -> float:
|
def _openai_compat_timeout_s() -> float:
|
||||||
@@ -461,6 +464,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||||
id_map: dict[str, str] = {}
|
id_map: dict[str, 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")
|
||||||
|
|
||||||
def map_id(value: Any) -> Any:
|
def map_id(value: Any) -> Any:
|
||||||
@@ -468,15 +472,49 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
return value
|
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:
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
base = map_id(value)
|
||||||
|
else:
|
||||||
|
base = _short_tool_id()
|
||||||
|
if not isinstance(base, str) or not base:
|
||||||
|
base = _short_tool_id()
|
||||||
|
if base not in used_ids:
|
||||||
|
return base
|
||||||
|
seed = value if isinstance(value, str) and value else base
|
||||||
|
salt = 1
|
||||||
|
while True:
|
||||||
|
candidate = self._normalize_tool_call_id(f"{seed}:{idx}:{salt}")
|
||||||
|
if isinstance(candidate, str) and candidate not in used_ids:
|
||||||
|
return candidate
|
||||||
|
salt += 1
|
||||||
|
|
||||||
|
def map_tool_result_id(value: Any) -> Any:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
queue = pending_tool_ids.get(value)
|
||||||
|
if queue:
|
||||||
|
mapped = queue.popleft()
|
||||||
|
if not queue:
|
||||||
|
pending_tool_ids.pop(value, None)
|
||||||
|
return mapped
|
||||||
|
return map_id(value)
|
||||||
|
|
||||||
for clean in sanitized:
|
for clean in sanitized:
|
||||||
if isinstance(clean.get("tool_calls"), list):
|
if isinstance(clean.get("tool_calls"), list):
|
||||||
normalized = []
|
normalized = []
|
||||||
for tc in clean["tool_calls"]:
|
used_ids: set[str] = set()
|
||||||
|
for idx, tc in enumerate(clean["tool_calls"]):
|
||||||
if not isinstance(tc, dict):
|
if not isinstance(tc, dict):
|
||||||
normalized.append(tc)
|
normalized.append(tc)
|
||||||
continue
|
continue
|
||||||
tc_clean = dict(tc)
|
tc_clean = dict(tc)
|
||||||
tc_clean["id"] = map_id(tc_clean.get("id"))
|
raw_id = tc_clean.get("id")
|
||||||
|
mapped_id = unique_tool_id(raw_id, used_ids, idx)
|
||||||
|
tc_clean["id"] = mapped_id
|
||||||
|
used_ids.add(mapped_id)
|
||||||
|
if isinstance(raw_id, str) and raw_id:
|
||||||
|
pending_tool_ids.setdefault(raw_id, deque()).append(mapped_id)
|
||||||
function = tc_clean.get("function")
|
function = tc_clean.get("function")
|
||||||
if isinstance(function, dict):
|
if isinstance(function, dict):
|
||||||
function_clean = dict(function)
|
function_clean = dict(function)
|
||||||
@@ -494,7 +532,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# that mix non-empty content with tool_calls.
|
# that mix non-empty content with tool_calls.
|
||||||
clean["content"] = None
|
clean["content"] = None
|
||||||
if "tool_call_id" in clean and clean["tool_call_id"]:
|
if "tool_call_id" in clean and clean["tool_call_id"]:
|
||||||
clean["tool_call_id"] = map_id(clean["tool_call_id"])
|
clean["tool_call_id"] = map_tool_result_id(clean["tool_call_id"])
|
||||||
if (
|
if (
|
||||||
force_string_content
|
force_string_content
|
||||||
and not (clean.get("role") == "assistant" and clean.get("tool_calls"))
|
and not (clean.get("role") == "assistant" and clean.get("tool_calls"))
|
||||||
@@ -581,39 +619,27 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if wire_effort and semantic_effort != "none":
|
if wire_effort and semantic_effort != "none":
|
||||||
kwargs["reasoning_effort"] = wire_effort
|
kwargs["reasoning_effort"] = wire_effort
|
||||||
|
|
||||||
# Provider-specific thinking parameters.
|
# Only send thinking controls when reasoning_effort is explicit so
|
||||||
# Only sent when reasoning_effort is explicitly configured so that
|
# omitting the config preserves each provider's default.
|
||||||
# the provider default is preserved otherwise.
|
if reasoning_effort is not None:
|
||||||
# The mapping is driven by ProviderSpec.thinking_style so that adding
|
|
||||||
# a new provider never requires touching this function.
|
|
||||||
if spec and spec.thinking_style and reasoning_effort is not None:
|
|
||||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
thinking_enabled = semantic_effort not in ("none", "minimal")
|
||||||
extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled)
|
for thinking_style in _thinking_styles_for(spec, model_name):
|
||||||
if extra:
|
extra = _thinking_extra_body(thinking_style, thinking_enabled)
|
||||||
kwargs.setdefault("extra_body", {}).update(extra)
|
if extra:
|
||||||
|
kwargs.setdefault("extra_body", {}).update(extra)
|
||||||
|
gateway_style = getattr(spec, "gateway_reasoning_style", "") if spec else ""
|
||||||
|
if gateway_style and _model_thinking_style(model_name):
|
||||||
|
extra = _gateway_reasoning_extra_body(gateway_style, semantic_effort)
|
||||||
|
if extra:
|
||||||
|
kwargs.setdefault("extra_body", {}).update(extra)
|
||||||
|
|
||||||
# Model-level thinking injection for Kimi thinking-capable models.
|
# Moonshot rejects requests that carry both 'reasoning_effort'
|
||||||
# Strip any provider prefix (e.g. "moonshotai/") before the set lookup
|
# and the native 'thinking' param. We already expressed the
|
||||||
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
|
# user's intent via the provider-native shape, so drop the
|
||||||
# identically to bare names like "kimi-k2.5".
|
# redundant wire-level kwarg. Only kimi models need this —
|
||||||
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
|
# Xiaomi's API accepts both params.
|
||||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
if _model_slug(model_name) in _KIMI_THINKING_MODELS:
|
||||||
kwargs.setdefault("extra_body", {}).update(
|
kwargs.pop("reasoning_effort", None)
|
||||||
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Model-level thinking injection for MiMo thinking-capable models.
|
|
||||||
# Same shape as Kimi: gateway providers (OpenRouter, etc.) lack the
|
|
||||||
# xiaomi_mimo spec's thinking_style, so the spec-driven branch above
|
|
||||||
# misses them — match by model name to catch "xiaomi/mimo-v2.5-pro"
|
|
||||||
# and friends. (Direct xiaomi_mimo requests are also covered here;
|
|
||||||
# both branches write the same payload, so the dict update is a
|
|
||||||
# safe no-op for already-handled cases.)
|
|
||||||
if reasoning_effort is not None and _is_mimo_thinking_model(model_name):
|
|
||||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
|
||||||
kwargs.setdefault("extra_body", {}).update(
|
|
||||||
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
|
||||||
)
|
|
||||||
|
|
||||||
if tools:
|
if tools:
|
||||||
kwargs["tools"] = tools
|
kwargs["tools"] = tools
|
||||||
@@ -628,8 +654,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
and semantic_effort not in ("none", "minimal")
|
and semantic_effort not in ("none", "minimal")
|
||||||
and (
|
and (
|
||||||
(spec and spec.thinking_style)
|
(spec and spec.thinking_style)
|
||||||
or _is_kimi_thinking_model(model_name)
|
or _model_thinking_style(model_name)
|
||||||
or _is_mimo_thinking_model(model_name)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
implicit_deepseek_thinking = (
|
implicit_deepseek_thinking = (
|
||||||
@@ -1097,6 +1122,15 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if delta:
|
if delta:
|
||||||
_accum_legacy_function_call(getattr(delta, "function_call", None))
|
_accum_legacy_function_call(getattr(delta, "function_call", None))
|
||||||
|
|
||||||
|
# Some providers (e.g. Zhipu/GLM) reuse the same tool_call id for
|
||||||
|
# parallel tool calls in streaming mode. Deduplicate before building
|
||||||
|
# the response so downstream tool messages don't collide.
|
||||||
|
_seen_tc_ids: set[str] = set()
|
||||||
|
for b in tc_bufs.values():
|
||||||
|
if not b["id"] or b["id"] in _seen_tc_ids:
|
||||||
|
b["id"] = _short_tool_id()
|
||||||
|
_seen_tc_ids.add(b["id"])
|
||||||
|
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content="".join(content_parts) or None,
|
content="".join(content_parts) or None,
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
|||||||
"""
|
"""
|
||||||
system_prompt = ""
|
system_prompt = ""
|
||||||
input_items: list[dict[str, Any]] = []
|
input_items: list[dict[str, Any]] = []
|
||||||
|
used_item_ids: set[str] = set()
|
||||||
|
|
||||||
for idx, msg in enumerate(messages):
|
for idx, msg in enumerate(messages):
|
||||||
role = msg.get("role")
|
role = msg.get("role")
|
||||||
@@ -30,17 +31,19 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
|||||||
|
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
if isinstance(content, str) and content:
|
if isinstance(content, str) and content:
|
||||||
|
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
||||||
input_items.append({
|
input_items.append({
|
||||||
"type": "message", "role": "assistant",
|
"type": "message", "role": "assistant",
|
||||||
"content": [{"type": "output_text", "text": content}],
|
"content": [{"type": "output_text", "text": content}],
|
||||||
"status": "completed", "id": f"msg_{idx}",
|
"status": "completed", "id": message_id,
|
||||||
})
|
})
|
||||||
for tool_call in msg.get("tool_calls", []) or []:
|
for tool_call in msg.get("tool_calls", []) or []:
|
||||||
fn = tool_call.get("function") or {}
|
fn = tool_call.get("function") or {}
|
||||||
call_id, item_id = split_tool_call_id(tool_call.get("id"))
|
call_id, item_id = split_tool_call_id(tool_call.get("id"))
|
||||||
|
response_item_id = _unique_item_id(item_id or f"fc_{idx}", used_item_ids)
|
||||||
input_items.append({
|
input_items.append({
|
||||||
"type": "function_call",
|
"type": "function_call",
|
||||||
"id": item_id or f"fc_{idx}",
|
"id": response_item_id,
|
||||||
"call_id": call_id or f"call_{idx}",
|
"call_id": call_id or f"call_{idx}",
|
||||||
"name": fn.get("name"),
|
"name": fn.get("name"),
|
||||||
"arguments": fn.get("arguments") or "{}",
|
"arguments": fn.get("arguments") or "{}",
|
||||||
@@ -97,6 +100,20 @@ def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
return converted
|
return converted
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_item_id(item_id: str, used: set[str]) -> str:
|
||||||
|
"""Return a Responses input item id that is unique within one request."""
|
||||||
|
if item_id not in used:
|
||||||
|
used.add(item_id)
|
||||||
|
return item_id
|
||||||
|
|
||||||
|
suffix = 2
|
||||||
|
while f"{item_id}_{suffix}" in used:
|
||||||
|
suffix += 1
|
||||||
|
unique = f"{item_id}_{suffix}"
|
||||||
|
used.add(unique)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
||||||
"""Split a compound ``call_id|item_id`` string.
|
"""Split a compound ``call_id|item_id`` string.
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ class ProviderSpec:
|
|||||||
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
|
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
|
||||||
thinking_style: str = ""
|
thinking_style: str = ""
|
||||||
|
|
||||||
|
# Gateway-native reasoning control to pair with model-level thinking styles.
|
||||||
|
# "reasoning_effort" — {"reasoning": {"effort": <none|minimal|...>}}
|
||||||
|
# (OpenRouter)
|
||||||
|
gateway_reasoning_style: str = ""
|
||||||
|
|
||||||
# When True, treat the "reasoning" response field as formal content
|
# When True, treat the "reasoning" response field as formal content
|
||||||
# when "content" is empty. Only set this for providers (e.g. StepFun)
|
# when "content" is empty. Only set this for providers (e.g. StepFun)
|
||||||
# whose API returns the actual answer in "reasoning" instead of "content".
|
# whose API returns the actual answer in "reasoning" instead of "content".
|
||||||
@@ -142,6 +147,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
detect_by_base_keyword="openrouter",
|
detect_by_base_keyword="openrouter",
|
||||||
default_api_base="https://openrouter.ai/api/v1",
|
default_api_base="https://openrouter.ai/api/v1",
|
||||||
supports_prompt_caching=True,
|
supports_prompt_caching=True,
|
||||||
|
gateway_reasoning_style="reasoning_effort",
|
||||||
),
|
),
|
||||||
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
|
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
@@ -193,6 +199,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
default_api_base="https://api.siliconflow.cn/v1",
|
default_api_base="https://api.siliconflow.cn/v1",
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Novita AI: OpenAI-compatible gateway for hosted model APIs.
|
||||||
|
ProviderSpec(
|
||||||
|
name="novita",
|
||||||
|
keywords=("novita",),
|
||||||
|
env_key="NOVITA_API_KEY",
|
||||||
|
display_name="Novita AI",
|
||||||
|
backend="openai_compat",
|
||||||
|
is_gateway=True,
|
||||||
|
detect_by_base_keyword="novita",
|
||||||
|
default_api_base="https://api.novita.ai/openai",
|
||||||
|
),
|
||||||
|
|
||||||
# VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models
|
# VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
name="volcengine",
|
name="volcengine",
|
||||||
|
|||||||
@@ -165,6 +165,23 @@ class Session:
|
|||||||
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
||||||
)
|
)
|
||||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||||
|
cli_apps = message.get("cli_apps")
|
||||||
|
if role == "user" and isinstance(cli_apps, list) and cli_apps and isinstance(content, str):
|
||||||
|
cli_lines: list[str] = []
|
||||||
|
for item in cli_apps[:8]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = str(item.get("name") or "").strip().lower()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
entry = str(item.get("entry_point") or "unknown").strip() or "unknown"
|
||||||
|
cli_lines.append(
|
||||||
|
f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry}; "
|
||||||
|
f"skill=skills/cli-app-{name}/SKILL.md]"
|
||||||
|
)
|
||||||
|
if cli_lines:
|
||||||
|
breadcrumbs = "\n".join(cli_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():
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Agent Instructions
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Workspace Guidance
|
||||||
|
|
||||||
|
Use this file for project-specific preferences, recurring workflow conventions, and instructions you want the agent to remember for this workspace. Keep durable facts about the user in `USER.md`, personality/style guidance in `SOUL.md`, and long-term memory in `memory/MEMORY.md`.
|
||||||
|
|
||||||
## Scheduled Reminders
|
## Scheduled Reminders
|
||||||
|
|
||||||
Before scheduling reminders, check available skills and follow skill guidance first.
|
Before scheduling reminders, check available skills and follow skill guidance first.
|
||||||
@@ -10,10 +14,10 @@ Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegr
|
|||||||
|
|
||||||
## Heartbeat Tasks
|
## Heartbeat Tasks
|
||||||
|
|
||||||
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks:
|
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks.
|
||||||
|
|
||||||
- **Add**: `edit_file` to append new tasks
|
- Use `apply_patch` for normal task-list updates, especially when adding, removing, or changing multiple lines.
|
||||||
- **Remove**: `edit_file` to delete completed tasks
|
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
|
||||||
- **Rewrite**: `write_file` to replace all tasks
|
- Use `write_file` for first creation or intentional full-file rewrites.
|
||||||
|
|
||||||
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
|
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# Tool Usage Notes
|
|
||||||
|
|
||||||
Tool signatures are provided automatically via function calling.
|
|
||||||
This file documents non-obvious constraints and usage patterns.
|
|
||||||
|
|
||||||
## exec — Safety Limits
|
|
||||||
|
|
||||||
- Commands have a configurable timeout (default 60s)
|
|
||||||
- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.)
|
|
||||||
- Output is truncated at 10,000 characters
|
|
||||||
- `restrictToWorkspace` config can limit file access to the workspace
|
|
||||||
|
|
||||||
## grep — Content Search
|
|
||||||
|
|
||||||
- Use `grep` to search file contents inside the workspace
|
|
||||||
- Default behavior returns only matching file paths (`output_mode="files_with_matches"`)
|
|
||||||
- Supports optional `glob` filtering (e.g. `glob="*.py"`) plus `context_before` / `context_after`
|
|
||||||
- Supports `type="py"`, `type="ts"`, `type="md"` and similar shorthand filters
|
|
||||||
- Use `fixed_strings=true` for literal keywords containing regex characters
|
|
||||||
- Use `output_mode="files_with_matches"` to get only matching file paths
|
|
||||||
- Use `output_mode="count"` to size a search before reading full matches
|
|
||||||
- Use `head_limit` and `offset` to page across results
|
|
||||||
- Prefer this over `exec` for code and history searches
|
|
||||||
- Binary or oversized files may be skipped to keep results readable
|
|
||||||
|
|
||||||
## cron — Scheduled Reminders
|
|
||||||
|
|
||||||
- Please refer to cron skill for usage.
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Tool Usage Notes
|
||||||
|
|
||||||
|
Tool signatures are provided automatically via function calling. This section
|
||||||
|
documents the general tool contract and non-obvious usage patterns.
|
||||||
|
|
||||||
|
## General Tool Contract
|
||||||
|
|
||||||
|
- Use the narrowest structured tool that directly matches the task.
|
||||||
|
- Use read-only discovery before writes when state is uncertain.
|
||||||
|
- Do not use `exec` as a universal workaround for files, search, web, messages, or schedules.
|
||||||
|
- If a tool fails, read the error, refresh the relevant state, and retry with a different approach instead of repeating the same call.
|
||||||
|
- After meaningful changes, verify with the smallest reliable check: re-read changed state, run targeted tests, or inspect command output.
|
||||||
|
- Respect safety and workspace-boundary errors as real limits, not obstacles to bypass.
|
||||||
|
|
||||||
|
## Discovery and Reading
|
||||||
|
|
||||||
|
- Use `find_files` or `list_dir` to locate workspace paths before `read_file` when a path is uncertain.
|
||||||
|
- Use `grep` for content search inside the workspace; prefer it over shell grep for ordinary searches.
|
||||||
|
- `grep` defaults to `output_mode="files_with_matches"`; use `output_mode="content"` for matching lines with context.
|
||||||
|
- Use `fixed_strings=true` for literal keywords containing regex characters.
|
||||||
|
- Use `output_mode="count"` to size a broad search before reading full matches.
|
||||||
|
- Use `head_limit` and `offset` to page across large result sets.
|
||||||
|
- Binary or oversized files may be skipped to keep results readable.
|
||||||
|
|
||||||
|
## File and Coding Workflows
|
||||||
|
|
||||||
|
- For code or config changes, the default loop is: locate (`find_files`/`grep`), inspect (`read_file`), edit (`apply_patch`), then verify (`exec` or re-read).
|
||||||
|
- Use `apply_patch` as the default code editing tool, especially for multi-file changes, structural edits, generated code, moves, adds, or deletes.
|
||||||
|
- Use `apply_patch dry_run=true` when the patch is uncertain and you want validation plus a change summary before writing.
|
||||||
|
- Use `edit_file` only for small exact replacements in one file, with `old_text` copied from `read_file`; add `occurrence`, `line_hint`, or `expected_replacements` when ambiguity matters.
|
||||||
|
- Use `write_file` for new files or intentional full-file rewrites, not routine partial edits.
|
||||||
|
- If `apply_patch` or `edit_file` fails, re-read with `force=true`, narrow the context, and try a smaller patch rather than switching to shell `sed` or `echo`.
|
||||||
|
|
||||||
|
## Process Execution
|
||||||
|
|
||||||
|
- Use `exec` for tests, builds, package commands, git commands, and other process execution.
|
||||||
|
- Prefer dedicated file/search tools over `cat`, shell `find`, shell `grep`, `sed`, or `echo` for ordinary workspace inspection and edits.
|
||||||
|
- Use non-interactive flags such as `-y` or `--yes` when available.
|
||||||
|
- Commands have a configurable timeout (default 60s), dangerous commands are blocked, and output is truncated.
|
||||||
|
- For long-running or interactive commands, pass `yield_time_ms`; if the process keeps running, continue with `write_stdin`.
|
||||||
|
- Use `write_stdin` to poll, provide stdin, close stdin, wait for expected output with `wait_for`, or terminate an existing exec session.
|
||||||
|
- Use `list_exec_sessions` to recover active session IDs after context shifts.
|
||||||
|
|
||||||
|
## CLI App Attachments
|
||||||
|
|
||||||
|
- When Runtime Context lists a `CLI App Attachment` or `CLI App Mention`, treat the `@name` as an app capability the user intentionally attached to the current turn.
|
||||||
|
- If the task may need app-specific behavior, read the listed skill first, then call `run_cli_app` with that `name`.
|
||||||
|
- Do not run an attached CLI app through shell or generic process tools unless the user explicitly asks for that lower-level path.
|
||||||
|
- If the app CLI is missing, lacks local desktop/app/API prerequisites, or cannot complete the requested action, explain that concrete blocker and what was attempted.
|
||||||
|
|
||||||
|
## Web and External Information
|
||||||
|
|
||||||
|
- Use web tools when the user asks for current information, a specific URL, or information likely to have changed.
|
||||||
|
- Use `web_search` to find sources and `web_fetch` for a specific page or result that needs closer reading.
|
||||||
|
- Do not invent freshness-sensitive facts when tools can verify them.
|
||||||
|
|
||||||
|
## Messaging and Media
|
||||||
|
|
||||||
|
- Use `message` to send content or local media to the user/channel.
|
||||||
|
- `read_file` only reads content for your analysis; it does not deliver a file to the user.
|
||||||
|
- When sending an existing local file, attach it through the message/media mechanism instead of pasting file contents unless the user asked for text.
|
||||||
|
|
||||||
|
## Scheduling and Background Work
|
||||||
|
|
||||||
|
- Use `cron` for scheduled reminders or recurring jobs; do not run `nanobot cron` through `exec`.
|
||||||
|
- For heartbeat tasks, update `HEARTBEAT.md` according to the agent instructions.
|
||||||
|
- Do not write reminders only to memory files when the user expects an actual notification.
|
||||||
@@ -3,15 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "apply_patch"})
|
||||||
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
|
|
||||||
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
|
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
|
||||||
_LIVE_EMIT_INTERVAL_S = 0.18
|
_LIVE_EMIT_INTERVAL_S = 0.18
|
||||||
_LIVE_EMIT_LINE_STEP = 24
|
_LIVE_EMIT_LINE_STEP = 24
|
||||||
@@ -154,19 +152,108 @@ def prepare_file_edit_tracker(
|
|||||||
workspace: Path | None,
|
workspace: Path | None,
|
||||||
params: dict[str, Any] | None,
|
params: dict[str, Any] | None,
|
||||||
) -> FileEditTracker | None:
|
) -> FileEditTracker | None:
|
||||||
|
trackers = prepare_file_edit_trackers(
|
||||||
|
call_id=call_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool=tool,
|
||||||
|
workspace=workspace,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
return trackers[0] if trackers else None
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_file_edit_trackers(
|
||||||
|
*,
|
||||||
|
call_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
tool: Any,
|
||||||
|
workspace: Path | None,
|
||||||
|
params: dict[str, Any] | None,
|
||||||
|
) -> list[FileEditTracker]:
|
||||||
if not is_file_edit_tool(tool_name):
|
if not is_file_edit_tool(tool_name):
|
||||||
return None
|
return []
|
||||||
|
paths = resolve_file_edit_paths(tool_name, tool, workspace, params)
|
||||||
|
trackers: list[FileEditTracker] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
resolved = path.resolve()
|
||||||
|
except Exception:
|
||||||
|
resolved = path
|
||||||
|
if resolved in seen:
|
||||||
|
continue
|
||||||
|
seen.add(resolved)
|
||||||
|
before = read_file_snapshot(path)
|
||||||
|
trackers.append(FileEditTracker(
|
||||||
|
call_id=str(call_id or ""),
|
||||||
|
tool=tool_name,
|
||||||
|
path=path,
|
||||||
|
display_path=display_file_edit_path(path, workspace),
|
||||||
|
before=before,
|
||||||
|
))
|
||||||
|
return trackers
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_file_edit_paths(
|
||||||
|
tool_name: str,
|
||||||
|
tool: Any,
|
||||||
|
workspace: Path | None,
|
||||||
|
params: dict[str, Any] | None,
|
||||||
|
) -> list[Path]:
|
||||||
|
if tool_name == "apply_patch":
|
||||||
|
return _resolve_apply_patch_paths(tool, workspace, params)
|
||||||
path = resolve_file_edit_path(tool, workspace, params)
|
path = resolve_file_edit_path(tool, workspace, params)
|
||||||
if path is None:
|
if path is None:
|
||||||
return None
|
return []
|
||||||
before = read_file_snapshot(path)
|
return [path]
|
||||||
return FileEditTracker(
|
|
||||||
call_id=str(call_id or ""),
|
|
||||||
tool=tool_name,
|
def _resolve_apply_patch_paths(
|
||||||
path=path,
|
tool: Any,
|
||||||
display_path=display_file_edit_path(path, workspace),
|
workspace: Path | None,
|
||||||
before=before,
|
params: dict[str, Any] | None,
|
||||||
)
|
) -> list[Path]:
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
return []
|
||||||
|
edits = params.get("edits")
|
||||||
|
if not isinstance(edits, list) or not edits:
|
||||||
|
return []
|
||||||
|
if params.get("dry_run") is True:
|
||||||
|
return []
|
||||||
|
|
||||||
|
resolved: list[Path] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for edit in edits:
|
||||||
|
if not isinstance(edit, dict):
|
||||||
|
continue
|
||||||
|
raw_path = edit.get("path")
|
||||||
|
if not isinstance(raw_path, str) or not raw_path.strip():
|
||||||
|
continue
|
||||||
|
path = _resolve_raw_file_edit_path(tool, workspace, raw_path)
|
||||||
|
if path is not None and path not in seen:
|
||||||
|
seen.add(path)
|
||||||
|
resolved.append(path)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_raw_file_edit_path(
|
||||||
|
tool: Any,
|
||||||
|
workspace: Path | None,
|
||||||
|
raw_path: str,
|
||||||
|
) -> Path | None:
|
||||||
|
resolver = getattr(tool, "_resolve", None)
|
||||||
|
if callable(resolver):
|
||||||
|
try:
|
||||||
|
resolved = resolver(raw_path)
|
||||||
|
if isinstance(resolved, Path):
|
||||||
|
return resolved
|
||||||
|
if resolved:
|
||||||
|
return Path(resolved)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if workspace is None:
|
||||||
|
return Path(raw_path).expanduser().resolve()
|
||||||
|
return (workspace / raw_path).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
def build_file_edit_start_event(
|
def build_file_edit_start_event(
|
||||||
@@ -304,6 +391,9 @@ class StreamingFileEditTracker:
|
|||||||
self._states[key] = state
|
self._states[key] = state
|
||||||
|
|
||||||
state.apply_delta(payload)
|
state.apply_delta(payload)
|
||||||
|
if state.name == "apply_patch":
|
||||||
|
await self._update_apply_patch(state)
|
||||||
|
return
|
||||||
if state.name not in {"write_file", "edit_file"}:
|
if state.name not in {"write_file", "edit_file"}:
|
||||||
return
|
return
|
||||||
if state.path is None:
|
if state.path is None:
|
||||||
@@ -343,10 +433,80 @@ class StreamingFileEditTracker:
|
|||||||
deleted=deleted,
|
deleted=deleted,
|
||||||
)])
|
)])
|
||||||
|
|
||||||
|
async def _update_apply_patch(self, state: _StreamingFileEditState) -> None:
|
||||||
|
if _json_bool_true(state.arguments, "dry_run"):
|
||||||
|
return
|
||||||
|
tool = self._tools.get("apply_patch") if hasattr(self._tools, "get") else None
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
path_matches = list(re.finditer(r'"path"\s*:\s*"([^"]+)"', state.arguments))
|
||||||
|
if not path_matches:
|
||||||
|
return
|
||||||
|
|
||||||
|
for i, m in enumerate(path_matches):
|
||||||
|
raw_path = m.group(1)
|
||||||
|
path = _resolve_raw_file_edit_path(tool, self._workspace, raw_path)
|
||||||
|
if path is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
segment_start = m.start()
|
||||||
|
segment_end = path_matches[i + 1].start() if i + 1 < len(path_matches) else len(state.arguments)
|
||||||
|
segment = state.arguments[segment_start:segment_end]
|
||||||
|
|
||||||
|
action_match = re.search(r'"action"\s*:\s*"(replace|add|delete)"', segment)
|
||||||
|
action = action_match.group(1) if action_match else "replace"
|
||||||
|
|
||||||
|
old_text = _extract_json_string_prefix(segment, "old_text") or ""
|
||||||
|
new_text = _extract_json_string_prefix(segment, "new_text") or ""
|
||||||
|
|
||||||
|
added = _text_line_count(new_text) if action in ("replace", "add") else 0
|
||||||
|
deleted = _text_line_count(old_text) if action in ("replace", "delete") else 0
|
||||||
|
delete_file = action == "delete"
|
||||||
|
|
||||||
|
file_state = state.patch_files.get(raw_path)
|
||||||
|
if file_state is None:
|
||||||
|
tracker = FileEditTracker(
|
||||||
|
call_id=state.call_id or state.key,
|
||||||
|
tool="apply_patch",
|
||||||
|
path=path,
|
||||||
|
display_path=display_file_edit_path(path, self._workspace),
|
||||||
|
before=read_file_snapshot(path),
|
||||||
|
)
|
||||||
|
file_state = _StreamingPatchFileState(tracker=tracker)
|
||||||
|
state.patch_files[raw_path] = file_state
|
||||||
|
if delete_file and added == 0 and deleted == 0 and file_state.tracker.before.countable:
|
||||||
|
deleted = _text_line_count(file_state.tracker.before.text or "")
|
||||||
|
if not file_state.should_emit(added, deleted, now):
|
||||||
|
continue
|
||||||
|
file_state.mark_emitted(added, deleted, now)
|
||||||
|
events.append(build_file_edit_live_event(
|
||||||
|
file_state.tracker,
|
||||||
|
added=added,
|
||||||
|
deleted=deleted,
|
||||||
|
))
|
||||||
|
if events:
|
||||||
|
await self._emit(events)
|
||||||
|
|
||||||
async def flush(self) -> None:
|
async def flush(self) -> None:
|
||||||
events: list[dict[str, Any]] = []
|
events: list[dict[str, Any]] = []
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
for state in self._states.values():
|
for state in self._states.values():
|
||||||
|
for file_state in state.patch_files.values():
|
||||||
|
added, deleted = file_state.last_added, file_state.last_deleted
|
||||||
|
if not file_state.emitted_once:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
file_state.last_emitted_added == added
|
||||||
|
and file_state.last_emitted_deleted == deleted
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
file_state.mark_emitted(added, deleted, now)
|
||||||
|
events.append(build_file_edit_live_event(
|
||||||
|
file_state.tracker,
|
||||||
|
added=added,
|
||||||
|
deleted=deleted,
|
||||||
|
))
|
||||||
if state.tracker is None:
|
if state.tracker is None:
|
||||||
continue
|
continue
|
||||||
added, deleted = state.live_diff_counts()
|
added, deleted = state.live_diff_counts()
|
||||||
@@ -367,12 +527,14 @@ class StreamingFileEditTracker:
|
|||||||
|
|
||||||
def apply_final_call_ids(self, final_tool_calls: list[Any]) -> None:
|
def apply_final_call_ids(self, final_tool_calls: list[Any]) -> None:
|
||||||
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
||||||
|
used_canonicals: set[str] = set()
|
||||||
for tool_call in final_tool_calls:
|
for tool_call in final_tool_calls:
|
||||||
canonical = self.canonical_call_id_for(tool_call)
|
canonical = self.canonical_call_id_for(tool_call)
|
||||||
if canonical:
|
if canonical and canonical not in used_canonicals:
|
||||||
try:
|
try:
|
||||||
tool_call.id = canonical
|
tool_call.id = canonical
|
||||||
except Exception:
|
used_canonicals.add(canonical)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def canonical_call_id_for(self, tool_call: Any) -> str | None:
|
def canonical_call_id_for(self, tool_call: Any) -> str | None:
|
||||||
@@ -389,6 +551,10 @@ class StreamingFileEditTracker:
|
|||||||
"""Mark streamed edits as failed when no final tool call will run."""
|
"""Mark streamed edits as failed when no final tool call will run."""
|
||||||
events: list[dict[str, Any]] = []
|
events: list[dict[str, Any]] = []
|
||||||
for state in self._states.values():
|
for state in self._states.values():
|
||||||
|
for file_state in state.patch_files.values():
|
||||||
|
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
|
||||||
|
continue
|
||||||
|
events.append(build_file_edit_error_event(file_state.tracker, error))
|
||||||
if state.tracker is None:
|
if state.tracker is None:
|
||||||
continue
|
continue
|
||||||
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
|
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
|
||||||
@@ -492,6 +658,39 @@ class _StreamingJsonStringField:
|
|||||||
self.last_char_cr = False
|
self.last_char_cr = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _StreamingPatchFileState:
|
||||||
|
tracker: FileEditTracker
|
||||||
|
emitted_once: bool = False
|
||||||
|
last_emitted_added: int = -1
|
||||||
|
last_emitted_deleted: int = -1
|
||||||
|
last_emit_at: float = 0.0
|
||||||
|
last_added: int = 0
|
||||||
|
last_deleted: int = 0
|
||||||
|
|
||||||
|
def should_emit(self, added: int, deleted: int, now: float) -> bool:
|
||||||
|
self.last_added = added
|
||||||
|
self.last_deleted = deleted
|
||||||
|
if not self.emitted_once:
|
||||||
|
return True
|
||||||
|
if added == self.last_emitted_added and deleted == self.last_emitted_deleted:
|
||||||
|
return False
|
||||||
|
if max(
|
||||||
|
abs(added - self.last_emitted_added),
|
||||||
|
abs(deleted - self.last_emitted_deleted),
|
||||||
|
) >= _LIVE_EMIT_LINE_STEP:
|
||||||
|
return True
|
||||||
|
return now - self.last_emit_at >= _LIVE_EMIT_INTERVAL_S
|
||||||
|
|
||||||
|
def mark_emitted(self, added: int, deleted: int, now: float) -> None:
|
||||||
|
self.emitted_once = True
|
||||||
|
self.last_added = added
|
||||||
|
self.last_deleted = deleted
|
||||||
|
self.last_emitted_added = added
|
||||||
|
self.last_emitted_deleted = deleted
|
||||||
|
self.last_emit_at = now
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class _StreamingFileEditState:
|
class _StreamingFileEditState:
|
||||||
key: str
|
key: str
|
||||||
@@ -509,6 +708,7 @@ class _StreamingFileEditState:
|
|||||||
new_text: _StreamingJsonStringField = field(
|
new_text: _StreamingJsonStringField = field(
|
||||||
default_factory=lambda: _StreamingJsonStringField("new_text")
|
default_factory=lambda: _StreamingJsonStringField("new_text")
|
||||||
)
|
)
|
||||||
|
patch_files: dict[str, _StreamingPatchFileState] = field(default_factory=dict)
|
||||||
emitted_once: bool = False
|
emitted_once: bool = False
|
||||||
last_emitted_added: int = -1
|
last_emitted_added: int = -1
|
||||||
last_emitted_deleted: int = -1
|
last_emitted_deleted: int = -1
|
||||||
@@ -531,6 +731,7 @@ class _StreamingFileEditState:
|
|||||||
self.content.reset()
|
self.content.reset()
|
||||||
self.old_text.reset()
|
self.old_text.reset()
|
||||||
self.new_text.reset()
|
self.new_text.reset()
|
||||||
|
self.patch_files.clear()
|
||||||
return
|
return
|
||||||
delta = payload.get("arguments_delta")
|
delta = payload.get("arguments_delta")
|
||||||
if isinstance(delta, str) and delta:
|
if isinstance(delta, str) and delta:
|
||||||
@@ -590,6 +791,14 @@ class _StreamingFileEditState:
|
|||||||
name = getattr(tool_call, "name", None)
|
name = getattr(tool_call, "name", None)
|
||||||
if name != self.name:
|
if name != self.name:
|
||||||
return False
|
return False
|
||||||
|
if self.name == "apply_patch":
|
||||||
|
arguments = getattr(tool_call, "arguments", None)
|
||||||
|
if not isinstance(arguments, dict):
|
||||||
|
return False
|
||||||
|
edits = arguments.get("edits")
|
||||||
|
if not isinstance(edits, list):
|
||||||
|
return False
|
||||||
|
return '"edits"' in self.arguments
|
||||||
arguments = getattr(tool_call, "arguments", None)
|
arguments = getattr(tool_call, "arguments", None)
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
return False
|
return False
|
||||||
@@ -612,6 +821,51 @@ def _stream_key(payload: dict[str, Any]) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _json_bool_true(source: str, key: str) -> bool:
|
||||||
|
return re.search(rf'"{re.escape(key)}"\s*:\s*true\b', source) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_string_prefix(source: str, key: str) -> str | None:
|
||||||
|
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
out: list[str] = []
|
||||||
|
i = match.end()
|
||||||
|
escape = False
|
||||||
|
while i < len(source):
|
||||||
|
ch = source[i]
|
||||||
|
if escape:
|
||||||
|
escape = False
|
||||||
|
if ch == "n":
|
||||||
|
out.append("\n")
|
||||||
|
elif ch == "r":
|
||||||
|
out.append("\r")
|
||||||
|
elif ch == "t":
|
||||||
|
out.append("\t")
|
||||||
|
elif ch == "u":
|
||||||
|
digits = source[i + 1:i + 5]
|
||||||
|
if len(digits) < 4:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
out.append(chr(int(digits, 16)))
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
i += 4
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if ch == "\\":
|
||||||
|
escape = True
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if ch == '"':
|
||||||
|
return "".join(out)
|
||||||
|
out.append(ch)
|
||||||
|
i += 1
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def _extract_complete_json_string(source: str, key: str) -> str | None:
|
def _extract_complete_json_string(source: str, key: str) -> str | None:
|
||||||
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
|
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
|
||||||
if match is None:
|
if match is None:
|
||||||
@@ -704,77 +958,4 @@ def _predict_after_text(
|
|||||||
return before_text.replace(old_text, new_text)
|
return before_text.replace(old_text, new_text)
|
||||||
return before_text.replace(old_text, new_text, 1)
|
return before_text.replace(old_text, new_text, 1)
|
||||||
return None
|
return None
|
||||||
if tool_name == "notebook_edit":
|
|
||||||
return _predict_notebook_after_text(params, before_text)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
|
|
||||||
try:
|
|
||||||
nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
cells = nb.get("cells")
|
|
||||||
if not isinstance(cells, list):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
cell_index = int(params.get("cell_index", 0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
new_source = params.get("new_source")
|
|
||||||
source = new_source if isinstance(new_source, str) else ""
|
|
||||||
cell_type = (
|
|
||||||
params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
|
|
||||||
)
|
|
||||||
mode = (
|
|
||||||
params.get("edit_mode")
|
|
||||||
if params.get("edit_mode") in ("replace", "insert", "delete")
|
|
||||||
else "replace"
|
|
||||||
)
|
|
||||||
if mode == "delete":
|
|
||||||
if 0 <= cell_index < len(cells):
|
|
||||||
cells.pop(cell_index)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
elif mode == "insert":
|
|
||||||
insert_at = min(max(cell_index + 1, 0), len(cells))
|
|
||||||
cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
|
|
||||||
else:
|
|
||||||
if not (0 <= cell_index < len(cells)):
|
|
||||||
return None
|
|
||||||
cell = cells[cell_index]
|
|
||||||
if not isinstance(cell, dict):
|
|
||||||
return None
|
|
||||||
cell["source"] = source
|
|
||||||
cell["cell_type"] = cell_type
|
|
||||||
if cell_type == "code":
|
|
||||||
cell.setdefault("outputs", [])
|
|
||||||
cell.setdefault("execution_count", None)
|
|
||||||
else:
|
|
||||||
cell.pop("outputs", None)
|
|
||||||
cell.pop("execution_count", None)
|
|
||||||
nb["cells"] = cells
|
|
||||||
try:
|
|
||||||
return json.dumps(nb, indent=1, ensure_ascii=False)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_notebook() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5,
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
|
||||||
"language_info": {"name": "python"},
|
|
||||||
},
|
|
||||||
"cells": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
|
|
||||||
cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
|
|
||||||
if cell_type == "code":
|
|
||||||
cell["outputs"] = []
|
|
||||||
cell["execution_count"] = None
|
|
||||||
return cell
|
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ def build_status_content(
|
|||||||
|
|
||||||
|
|
||||||
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
|
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
|
||||||
"""Sync bundled templates to workspace. Only creates missing files."""
|
"""Sync bundled templates to workspace. Creates missing files without overwriting user files."""
|
||||||
from importlib.resources import files as pkg_files
|
from importlib.resources import files as pkg_files
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -589,10 +589,11 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
|
|||||||
added: list[str] = []
|
added: list[str] = []
|
||||||
|
|
||||||
def _write(src, dest: Path):
|
def _write(src, dest: Path):
|
||||||
|
content = src.read_text(encoding="utf-8") if src else ""
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
return
|
return
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8")
|
dest.write_text(content, encoding="utf-8")
|
||||||
added.append(str(dest.relative_to(workspace)))
|
added.append(str(dest.relative_to(workspace)))
|
||||||
|
|
||||||
for item in tpl.iterdir():
|
for item in tpl.iterdir():
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ _TOOL_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = {
|
|||||||
"read_file": (["path", "file_path"], "read {}", True, False),
|
"read_file": (["path", "file_path"], "read {}", True, False),
|
||||||
"write_file": (["path", "file_path"], "write {}", True, False),
|
"write_file": (["path", "file_path"], "write {}", True, False),
|
||||||
"edit": (["file_path", "path"], "edit {}", True, False),
|
"edit": (["file_path", "path"], "edit {}", True, False),
|
||||||
|
"find_files": (["query", "glob", "path"], "find {}", False, False),
|
||||||
"grep": (["pattern"], 'grep "{}"', False, False),
|
"grep": (["pattern"], 'grep "{}"', False, False),
|
||||||
"exec": (["command"], "$ {}", False, True),
|
"exec": (["command"], "$ {}", False, True),
|
||||||
|
"list_exec_sessions": ([], "exec sessions", False, False),
|
||||||
"web_search": (["query"], 'search "{}"', False, False),
|
"web_search": (["query"], 'search "{}"', False, False),
|
||||||
"web_fetch": (["url"], "fetch {}", True, False),
|
"web_fetch": (["url"], "fetch {}", True, False),
|
||||||
"list_dir": (["path"], "ls {}", True, False),
|
"list_dir": (["path"], "ls {}", True, False),
|
||||||
@@ -81,6 +83,8 @@ def _extract_arg(tc, key_args: list[str]) -> str | None:
|
|||||||
|
|
||||||
def _fmt_known(tc, fmt: tuple, max_length: int = 40) -> str:
|
def _fmt_known(tc, fmt: tuple, max_length: int = 40) -> str:
|
||||||
"""Format a registered tool using its template."""
|
"""Format a registered tool using its template."""
|
||||||
|
if not fmt[0] and "{}" not in fmt[1]:
|
||||||
|
return fmt[1]
|
||||||
val = _extract_arg(tc, fmt[0])
|
val = _extract_arg(tc, fmt[0])
|
||||||
if val is None:
|
if val is None:
|
||||||
return tc.name
|
return tc.name
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""CLI Apps helpers for the WebUI HTTP and message surfaces."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
_CLI_APP_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||||
|
_CLI_APP_ATTACHMENT_KEYS = (
|
||||||
|
"name",
|
||||||
|
"display_name",
|
||||||
|
"category",
|
||||||
|
"entry_point",
|
||||||
|
"logo_url",
|
||||||
|
"brand_color",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_cli_app_mentions(raw: Any) -> list[dict[str, str]]:
|
||||||
|
"""Sanitize structured CLI app mentions sent by the WebUI."""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
out: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in raw[:8]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = _clip_ws_string(item.get("name"), 64)
|
||||||
|
if not name or _CLI_APP_NAME_RE.match(name) is None:
|
||||||
|
continue
|
||||||
|
key = name.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
row: dict[str, str] = {"name": key}
|
||||||
|
for field in _CLI_APP_ATTACHMENT_KEYS[1:]:
|
||||||
|
value = _clip_ws_string(item.get(field), 512 if field == "logo_url" else 160)
|
||||||
|
if value:
|
||||||
|
row[field] = value
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _query_first(query: QueryParams, key: str) -> str | None:
|
||||||
|
values = query.get(key)
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def _manager() -> CliAppManager:
|
||||||
|
config = load_config()
|
||||||
|
cli_cfg = config.tools.cli_apps
|
||||||
|
return CliAppManager(
|
||||||
|
workspace=config.workspace_path,
|
||||||
|
runtime=CliAppsRuntimeConfig(
|
||||||
|
install_timeout=cli_cfg.install_timeout,
|
||||||
|
run_timeout=cli_cfg.run_timeout,
|
||||||
|
catalog_ttl_seconds=cli_cfg.catalog_ttl_seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cli_apps_payload() -> dict[str, Any]:
|
||||||
|
return _manager().payload()
|
||||||
|
|
||||||
|
|
||||||
|
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
|
name = (_query_first(query, "name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise CliAppError("missing CLI app name")
|
||||||
|
manager = _manager()
|
||||||
|
if action == "install":
|
||||||
|
return manager.install(name)
|
||||||
|
if action == "update":
|
||||||
|
return manager.update(name)
|
||||||
|
if action == "uninstall":
|
||||||
|
return manager.uninstall(name)
|
||||||
|
if action == "test":
|
||||||
|
return manager.test(name)
|
||||||
|
raise CliAppError(f"unknown CLI app action '{action}'", status=404)
|
||||||
@@ -73,12 +73,16 @@ def _mask_secret_hint(secret: str | None) -> str | None:
|
|||||||
def _provider_requires_api_key(spec: Any) -> bool:
|
def _provider_requires_api_key(spec: Any) -> bool:
|
||||||
if spec.backend == "azure_openai":
|
if spec.backend == "azure_openai":
|
||||||
return True
|
return True
|
||||||
|
if spec.is_oauth:
|
||||||
|
return False
|
||||||
if spec.is_local or spec.is_direct:
|
if spec.is_local or spec.is_direct:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
|
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
|
||||||
|
if spec.is_oauth:
|
||||||
|
return True
|
||||||
if _provider_requires_api_key(spec):
|
if _provider_requires_api_key(spec):
|
||||||
return bool(provider_config.api_key)
|
return bool(provider_config.api_key)
|
||||||
return bool(
|
return bool(
|
||||||
|
|||||||
@@ -116,6 +116,55 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
_PHASE_RANK = {"start": 1, "end": 2, "error": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tool_events(events: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(events, list):
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for event in events:
|
||||||
|
if not event or not isinstance(event, dict):
|
||||||
|
continue
|
||||||
|
if event.get("phase") not in {"start", "end", "error"}:
|
||||||
|
continue
|
||||||
|
if not isinstance(event.get("name"), str):
|
||||||
|
fn = event.get("function")
|
||||||
|
if not (isinstance(fn, dict) and isinstance(fn.get("name"), str)):
|
||||||
|
continue
|
||||||
|
out.append(dict(event))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_event_key(event: dict[str, Any]) -> str:
|
||||||
|
call_id = event.get("call_id")
|
||||||
|
if isinstance(call_id, str) and call_id:
|
||||||
|
return f"call:{call_id}"
|
||||||
|
return _format_tool_call_trace(event) or json.dumps(event, sort_keys=True, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(previous, list) or not previous:
|
||||||
|
return incoming
|
||||||
|
if not incoming:
|
||||||
|
return [dict(event) for event in previous if isinstance(event, dict)]
|
||||||
|
merged = [dict(event) for event in previous if isinstance(event, dict)]
|
||||||
|
index_by_key = {_tool_event_key(event): idx for idx, event in enumerate(merged)}
|
||||||
|
for event in incoming:
|
||||||
|
key = _tool_event_key(event)
|
||||||
|
existing_index = index_by_key.get(key)
|
||||||
|
if existing_index is None:
|
||||||
|
index_by_key[key] = len(merged)
|
||||||
|
merged.append(event)
|
||||||
|
continue
|
||||||
|
existing = merged[existing_index]
|
||||||
|
incoming_rank = _PHASE_RANK.get(str(event.get("phase")), 0)
|
||||||
|
existing_rank = _PHASE_RANK.get(str(existing.get("phase")), 0)
|
||||||
|
if incoming_rank >= existing_rank:
|
||||||
|
merged[existing_index] = {**existing, **event}
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _merge_unique_tool_trace_lines(
|
def _merge_unique_tool_trace_lines(
|
||||||
previous_traces: list[str],
|
previous_traces: list[str],
|
||||||
lines: list[str],
|
lines: list[str],
|
||||||
@@ -136,6 +185,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.
|
||||||
|
|
||||||
@@ -405,6 +455,9 @@ def replay_transcript_to_ui_messages(
|
|||||||
row["media"] = media_att
|
row["media"] = media_att
|
||||||
if all(m.get("kind") == "image" for m in media_att):
|
if all(m.get("kind") == "image" for m in media_att):
|
||||||
row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att]
|
row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att]
|
||||||
|
cli_apps = rec.get("cli_apps")
|
||||||
|
if isinstance(cli_apps, list) and cli_apps:
|
||||||
|
row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)]
|
||||||
messages.append(row)
|
messages.append(row)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -449,6 +502,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
|
||||||
@@ -486,6 +557,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
close_reasoning(messages)
|
close_reasoning(messages)
|
||||||
continue
|
continue
|
||||||
if kind in ("tool_hint", "progress"):
|
if kind in ("tool_hint", "progress"):
|
||||||
|
structured_events = _normalize_tool_events(rec.get("tool_events"))
|
||||||
structured = tool_trace_lines_from_events(rec.get("tool_events"))
|
structured = tool_trace_lines_from_events(rec.get("tool_events"))
|
||||||
text = rec.get("text")
|
text = rec.get("text")
|
||||||
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
|
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
|
||||||
@@ -502,7 +574,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
prev_traces = list(last.get("traces") or [last.get("content")])
|
prev_traces = list(last.get("traces") or [last.get("content")])
|
||||||
if structured:
|
if structured:
|
||||||
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
|
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
|
||||||
if not added:
|
if not added and not structured_events:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
merged_traces = prev_traces + trace_lines
|
merged_traces = prev_traces + trace_lines
|
||||||
@@ -510,6 +582,9 @@ def replay_transcript_to_ui_messages(
|
|||||||
**last,
|
**last,
|
||||||
"traces": merged_traces,
|
"traces": merged_traces,
|
||||||
"content": merged_traces[-1],
|
"content": merged_traces[-1],
|
||||||
|
"toolEvents": _merge_tool_events(last.get("toolEvents"), structured_events)
|
||||||
|
if structured_events
|
||||||
|
else last.get("toolEvents"),
|
||||||
"activitySegmentId": last.get("activitySegmentId") or segment,
|
"activitySegmentId": last.get("activitySegmentId") or segment,
|
||||||
}
|
}
|
||||||
messages[-1] = merged
|
messages[-1] = merged
|
||||||
@@ -521,6 +596,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
"kind": "trace",
|
"kind": "trace",
|
||||||
"content": trace_lines[-1],
|
"content": trace_lines[-1],
|
||||||
"traces": trace_lines,
|
"traces": trace_lines,
|
||||||
|
**({"toolEvents": structured_events} if structured_events else {}),
|
||||||
"activitySegmentId": segment,
|
"activitySegmentId": segment,
|
||||||
"createdAt": _ts_base + idx,
|
"createdAt": _ts_base + idx,
|
||||||
},
|
},
|
||||||
@@ -569,7 +645,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
|
||||||
@@ -579,12 +662,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,
|
||||||
|
|||||||
@@ -139,6 +139,13 @@ class TestLoadBootstrapFiles:
|
|||||||
for name in ContextBuilder.BOOTSTRAP_FILES:
|
for name in ContextBuilder.BOOTSTRAP_FILES:
|
||||||
assert f"## {name}" in result
|
assert f"## {name}" in result
|
||||||
|
|
||||||
|
def test_legacy_tools_md_is_not_bootstrapped(self, tmp_path):
|
||||||
|
(tmp_path / "TOOLS.md").write_text("workspace tool notes", encoding="utf-8")
|
||||||
|
builder = _builder(tmp_path)
|
||||||
|
result = builder._load_bootstrap_files()
|
||||||
|
assert "TOOLS.md" not in result
|
||||||
|
assert "workspace tool notes" not in result
|
||||||
|
|
||||||
def test_utf8_content(self, tmp_path):
|
def test_utf8_content(self, tmp_path):
|
||||||
(tmp_path / "AGENTS.md").write_text("用中文回复", encoding="utf-8")
|
(tmp_path / "AGENTS.md").write_text("用中文回复", encoding="utf-8")
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
@@ -171,6 +178,37 @@ class TestIsTemplateContent:
|
|||||||
assert ContextBuilder._is_template_content("totally different", "memory/MEMORY.md") is False
|
assert ContextBuilder._is_template_content("totally different", "memory/MEMORY.md") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bundled bootstrap templates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBundledToolContract:
|
||||||
|
def test_tool_contract_balances_general_and_coding_workflows(self):
|
||||||
|
from importlib.resources import files as pkg_files
|
||||||
|
|
||||||
|
tpl = pkg_files("nanobot") / "templates" / "agent" / "tool_contract.md"
|
||||||
|
content = tpl.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "## General Tool Contract" in content
|
||||||
|
assert "Use the narrowest structured tool" in content
|
||||||
|
assert "Do not use `exec` as a universal workaround" in content
|
||||||
|
assert "## File and Coding Workflows" in content
|
||||||
|
assert "apply_patch" in content
|
||||||
|
assert "## Web and External Information" in content
|
||||||
|
assert "## Messaging and Media" in content
|
||||||
|
assert "## Scheduling and Background Work" in content
|
||||||
|
assert "pure coding" not in content.lower()
|
||||||
|
|
||||||
|
def test_tool_contract_is_injected_without_workspace_file(self, tmp_path):
|
||||||
|
builder = _builder(tmp_path)
|
||||||
|
prompt = builder.build_system_prompt()
|
||||||
|
|
||||||
|
assert "# Tool Usage Notes" in prompt
|
||||||
|
assert "## General Tool Contract" in prompt
|
||||||
|
assert "Do not use `exec` as a universal workaround" in prompt
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _build_user_content
|
# _build_user_content
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -324,6 +362,21 @@ class TestBuildMessages:
|
|||||||
assert "Other chat goal." not in str(without_goal[-1]["content"])
|
assert "Other chat goal." not in str(without_goal[-1]["content"])
|
||||||
assert "Goal (active):" not in str(without_goal[-1]["content"])
|
assert "Goal (active):" not in str(without_goal[-1]["content"])
|
||||||
|
|
||||||
|
def test_current_runtime_lines_are_injected(self, tmp_path):
|
||||||
|
builder = _builder(tmp_path)
|
||||||
|
messages = builder.build_messages(
|
||||||
|
[],
|
||||||
|
"please use @zoom tonight",
|
||||||
|
current_runtime_lines=[
|
||||||
|
"CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
user_msg = str(messages[-1]["content"])
|
||||||
|
|
||||||
|
assert "CLI App Attachment: @zoom" in user_msg
|
||||||
|
assert "tool=run_cli_app" in user_msg
|
||||||
|
assert "entry_point=cli-anything-zoom" in user_msg
|
||||||
|
|
||||||
def test_consecutive_same_role_merged(self, tmp_path):
|
def test_consecutive_same_role_merged(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
history = [{"role": "user", "content": "previous user message"}]
|
history = [{"role": "user", "content": "previous user message"}]
|
||||||
|
|||||||
@@ -346,6 +346,26 @@ class TestSyncWorkspaceTemplates:
|
|||||||
content = (workspace / "AGENTS.md").read_text()
|
content = (workspace / "AGENTS.md").read_text()
|
||||||
assert content == "existing content"
|
assert content == "existing content"
|
||||||
|
|
||||||
|
def test_does_not_create_tools_md(self, tmp_path):
|
||||||
|
"""Tool contract is injected internally, not copied into user workspaces."""
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
|
||||||
|
added = sync_workspace_templates(workspace, silent=True)
|
||||||
|
|
||||||
|
assert "TOOLS.md" not in added
|
||||||
|
assert not (workspace / "TOOLS.md").exists()
|
||||||
|
|
||||||
|
def test_preserves_existing_tools_md_without_overwriting(self, tmp_path):
|
||||||
|
"""Legacy user workspaces may have TOOLS.md; sync should leave it untouched."""
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir(parents=True)
|
||||||
|
tools_path = workspace / "TOOLS.md"
|
||||||
|
tools_path.write_text("custom tool notes", encoding="utf-8")
|
||||||
|
|
||||||
|
sync_workspace_templates(workspace, silent=True)
|
||||||
|
|
||||||
|
assert tools_path.read_text(encoding="utf-8") == "custom tool notes"
|
||||||
|
|
||||||
def test_creates_memory_directory(self, tmp_path):
|
def test_creates_memory_directory(self, tmp_path):
|
||||||
"""Should create memory directory structure."""
|
"""Should create memory directory structure."""
|
||||||
workspace = tmp_path / "workspace"
|
workspace = tmp_path / "workspace"
|
||||||
|
|||||||
@@ -359,6 +359,31 @@ def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
|
|||||||
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
|
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
|
||||||
|
session = Session(key="test:cli-app")
|
||||||
|
session.messages.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "please use @drawio",
|
||||||
|
"cli_apps": [{
|
||||||
|
"name": "drawio",
|
||||||
|
"entry_point": "cli-anything-drawio",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
history = session.get_history(max_messages=500)
|
||||||
|
|
||||||
|
assert history == [{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"please use @drawio\n"
|
||||||
|
"[CLI App Attachment: @drawio; tool=run_cli_app; "
|
||||||
|
"entry_point=cli-anything-drawio; skill=skills/cli-app-drawio/SKILL.md]"
|
||||||
|
),
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
def test_get_history_ignores_media_kwarg_on_non_user_rows():
|
def test_get_history_ignores_media_kwarg_on_non_user_rows():
|
||||||
"""``media`` only ever appears on user entries in practice, but the
|
"""``media`` only ever appears on user entries in practice, but the
|
||||||
synthesizer must be defensive: assistants / tools with list content
|
synthesizer must be defensive: assistants / tools with list content
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
|||||||
|
"""Unit tests for the Signal markdown → plain text + textStyle converter."""
|
||||||
|
|
||||||
|
from nanobot.channels.signal import _markdown_to_signal, _partition_styles
|
||||||
|
from nanobot.utils.helpers import split_message
|
||||||
|
|
||||||
|
|
||||||
|
def _utf16_len(s: str) -> int:
|
||||||
|
return len(s.encode("utf-16-le")) // 2
|
||||||
|
|
||||||
|
|
||||||
|
def styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
|
||||||
|
"""Return a dict mapping each styled substring to its style list."""
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for entry in text_styles:
|
||||||
|
start_s, length_s, style = entry.split(":", 2)
|
||||||
|
start, length = int(start_s), int(length_s)
|
||||||
|
span = plain[start : start + length]
|
||||||
|
result.setdefault(span, []).append(style)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def utf16_styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
|
||||||
|
"""Like styles_for, but slices `plain` using UTF-16 offsets (Signal's units)."""
|
||||||
|
encoded = plain.encode("utf-16-le")
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for entry in text_styles:
|
||||||
|
start_s, length_s, style = entry.split(":", 2)
|
||||||
|
start, length = int(start_s), int(length_s)
|
||||||
|
span = encoded[start * 2 : (start + length) * 2].decode("utf-16-le")
|
||||||
|
result.setdefault(span, []).append(style)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Basic cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty():
|
||||||
|
plain, styles = _markdown_to_signal("")
|
||||||
|
assert plain == ""
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_text():
|
||||||
|
plain, styles = _markdown_to_signal("hello world")
|
||||||
|
assert plain == "hello world"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_stars():
|
||||||
|
plain, styles = _markdown_to_signal("say **hello** now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_underscores():
|
||||||
|
plain, styles = _markdown_to_signal("say __hello__ now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_italic_star():
|
||||||
|
plain, styles = _markdown_to_signal("say *hello* now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_italic_underscore():
|
||||||
|
plain, styles = _markdown_to_signal("say _hello_ now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_strikethrough():
|
||||||
|
plain, styles = _markdown_to_signal("say ~~hello~~ now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["STRIKETHROUGH"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Code
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_code():
|
||||||
|
plain, styles = _markdown_to_signal("run `ls -la` here")
|
||||||
|
assert plain == "run ls -la here"
|
||||||
|
assert styles_for(plain, styles) == {"ls -la": ["MONOSPACE"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_block():
|
||||||
|
plain, styles = _markdown_to_signal("```\nprint('hi')\n```")
|
||||||
|
assert "print('hi')" in plain
|
||||||
|
assert styles_for(plain, styles).get("print('hi')\n") == ["MONOSPACE"] or "MONOSPACE" in str(
|
||||||
|
styles_for(plain, styles)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_block_with_lang():
|
||||||
|
plain, styles = _markdown_to_signal("```python\ncode\n```")
|
||||||
|
assert "code" in plain
|
||||||
|
assert any("MONOSPACE" in s for s in styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_block_not_processed_further():
|
||||||
|
"""Markdown inside a code block must not be styled."""
|
||||||
|
plain, styles = _markdown_to_signal("```\n**not bold**\n```")
|
||||||
|
assert "**not bold**" in plain
|
||||||
|
# Only MONOSPACE should be applied, no BOLD
|
||||||
|
for entry in styles:
|
||||||
|
assert "BOLD" not in entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_code_not_processed_further():
|
||||||
|
"""Markdown inside inline code must not be styled."""
|
||||||
|
plain, styles = _markdown_to_signal("use `**raw**` please")
|
||||||
|
assert "**raw**" in plain
|
||||||
|
for entry in styles:
|
||||||
|
assert "BOLD" not in entry
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Headers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_becomes_bold():
|
||||||
|
plain, styles = _markdown_to_signal("# My Title")
|
||||||
|
assert plain == "My Title"
|
||||||
|
assert styles_for(plain, styles) == {"My Title": ["BOLD"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_h2_becomes_bold():
|
||||||
|
plain, styles = _markdown_to_signal("## Sub-section")
|
||||||
|
assert plain == "Sub-section"
|
||||||
|
assert styles_for(plain, styles) == {"Sub-section": ["BOLD"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Blockquotes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_blockquote_strips_marker():
|
||||||
|
plain, styles = _markdown_to_signal("> some quote")
|
||||||
|
assert plain == "some quote"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bullet_dash():
|
||||||
|
plain, styles = _markdown_to_signal("- item one")
|
||||||
|
assert plain == "• item one"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bullet_star():
|
||||||
|
plain, styles = _markdown_to_signal("* item two")
|
||||||
|
assert plain == "• item two"
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbered_list():
|
||||||
|
plain, styles = _markdown_to_signal("1. first\n2. second")
|
||||||
|
assert "1. first" in plain
|
||||||
|
assert "2. second" in plain
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Links
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_text_differs_from_url():
|
||||||
|
plain, styles = _markdown_to_signal("[Click here](https://example.com)")
|
||||||
|
assert plain == "Click here (https://example.com)"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_text_equals_url():
|
||||||
|
plain, styles = _markdown_to_signal("[https://example.com](https://example.com)")
|
||||||
|
assert plain == "https://example.com"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_text_equals_url_without_scheme():
|
||||||
|
plain, styles = _markdown_to_signal("[example.com](https://example.com)")
|
||||||
|
assert plain == "https://example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mixed / nesting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_and_italic_adjacent():
|
||||||
|
plain, styles = _markdown_to_signal("**bold** and *italic*")
|
||||||
|
assert plain == "bold and italic"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert sd.get("bold") == ["BOLD"]
|
||||||
|
assert sd.get("italic") == ["ITALIC"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_with_inline_code():
|
||||||
|
"""Header becomes BOLD; code inside becomes MONOSPACE (not double-BOLD)."""
|
||||||
|
plain, styles = _markdown_to_signal("# Use `grep`")
|
||||||
|
assert plain == "Use grep"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert "BOLD" in sd.get("Use ", []) or "BOLD" in str(styles)
|
||||||
|
assert "MONOSPACE" in sd.get("grep", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_mixed():
|
||||||
|
md = "**Title**\n\nSome *italic* text.\n\n- bullet\n- another"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
assert "Title" in plain
|
||||||
|
assert "italic" in plain
|
||||||
|
assert "• bullet" in plain
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert "BOLD" in sd.get("Title", [])
|
||||||
|
assert "ITALIC" in sd.get("italic", [])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Table rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_rendered_as_monospace():
|
||||||
|
md = "| A | B |\n| - | - |\n| 1 | 2 |"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
assert "A" in plain and "B" in plain
|
||||||
|
assert any("MONOSPACE" in s for s in styles)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Style range format
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_range_format():
|
||||||
|
"""Each style entry must be 'start:length:STYLE'."""
|
||||||
|
_, styles = _markdown_to_signal("**bold** text")
|
||||||
|
for entry in styles:
|
||||||
|
parts = entry.split(":")
|
||||||
|
assert len(parts) == 3
|
||||||
|
assert parts[0].isdigit()
|
||||||
|
assert parts[1].isdigit()
|
||||||
|
assert parts[2] in {"BOLD", "ITALIC", "STRIKETHROUGH", "MONOSPACE", "SPOILER"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_ranges_are_within_bounds():
|
||||||
|
text = "hello **world** end"
|
||||||
|
plain, styles = _markdown_to_signal(text)
|
||||||
|
for entry in styles:
|
||||||
|
start_s, length_s, _ = entry.split(":", 2)
|
||||||
|
start, length = int(start_s), int(length_s)
|
||||||
|
assert start >= 0
|
||||||
|
assert start + length <= len(plain)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-BMP / UTF-16 offsets
|
||||||
|
#
|
||||||
|
# Signal's BodyRange (and signal-cli's textStyle) interprets start/length in
|
||||||
|
# UTF-16 code units. Python's len() counts code points, so characters outside
|
||||||
|
# the BMP (emojis, supplementary CJK) shift offsets by +1 per occurrence.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def assert_within_utf16_bounds(plain: str, styles: list[str]) -> None:
|
||||||
|
limit = _utf16_len(plain)
|
||||||
|
for entry in styles:
|
||||||
|
start_s, length_s, _ = entry.split(":", 2)
|
||||||
|
start, length = int(start_s), int(length_s)
|
||||||
|
assert start >= 0
|
||||||
|
assert start + length <= limit, f"range {entry} exceeds utf-16 length {limit} of {plain!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_with_emoji_inside():
|
||||||
|
plain, styles = _markdown_to_signal("**hi 🎉 bye**")
|
||||||
|
assert plain == "hi 🎉 bye"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"hi 🎉 bye": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_italic_with_trailing_emoji():
|
||||||
|
plain, styles = _markdown_to_signal("*bye 🎉*")
|
||||||
|
assert plain == "bye 🎉"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"bye 🎉": ["ITALIC"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_after_emoji_prefix():
|
||||||
|
plain, styles = _markdown_to_signal("🎉 **bold**")
|
||||||
|
assert plain == "🎉 bold"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"bold": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_after_and_inside_emoji():
|
||||||
|
plain, styles = _markdown_to_signal("🎉 **a 🎊 b**")
|
||||||
|
assert plain == "🎉 a 🎊 b"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"a 🎊 b": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_supplementary_cjk_in_bold():
|
||||||
|
"""Non-BMP CJK (U+20BB7) proves the bug is UTF-16, not emoji-specific."""
|
||||||
|
plain, styles = _markdown_to_signal("**𠮷野家**")
|
||||||
|
assert plain == "𠮷野家"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"𠮷野家": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwj_emoji_in_bold():
|
||||||
|
"""ZWJ family sequence = multiple surrogate pairs + BMP ZWJs."""
|
||||||
|
plain, styles = _markdown_to_signal("**hi 👨👩👧 bye**")
|
||||||
|
assert plain == "hi 👨👩👧 bye"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"hi 👨👩👧 bye": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ascii_offsets_unchanged():
|
||||||
|
"""ASCII-only path must produce the same offsets as before the UTF-16 fix."""
|
||||||
|
plain, styles = _markdown_to_signal("**bold** plain *it*")
|
||||||
|
assert plain == "bold plain it"
|
||||||
|
assert sorted(styles) == sorted(["0:4:BOLD", "11:2:ITALIC"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_reported_daily_brief_pattern():
|
||||||
|
"""Regression for the reported bug: a single non-BMP emoji shifts every
|
||||||
|
subsequent styled span left by 1 UTF-16 unit, lopping off the last letter.
|
||||||
|
"""
|
||||||
|
md = (
|
||||||
|
"**Weather**\n"
|
||||||
|
"- Conditions: 🌩️ Thunderstorms\n\n"
|
||||||
|
"**News**\n"
|
||||||
|
"*World*\n"
|
||||||
|
"*Local*\n\n"
|
||||||
|
"**Quote of the Day**"
|
||||||
|
)
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
sd = utf16_styles_for(plain, styles)
|
||||||
|
assert sd.get("Weather") == ["BOLD"]
|
||||||
|
assert sd.get("News") == ["BOLD"]
|
||||||
|
assert sd.get("World") == ["ITALIC"]
|
||||||
|
assert sd.get("Local") == ["ITALIC"]
|
||||||
|
assert sd.get("Quote of the Day") == ["BOLD"]
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chunk redistribution
|
||||||
|
#
|
||||||
|
# split_message can break a long Signal payload into multiple chunks. The
|
||||||
|
# style ranges from _markdown_to_signal are anchored to the full text, so
|
||||||
|
# they must be redistributed per-chunk with rebased offsets — otherwise
|
||||||
|
# styles for chunks 1..N are silently lost.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_chunk_styles(text: str, max_len: int) -> tuple[list[str], list[list[str]]]:
|
||||||
|
"""Helper: full markdown → signal pipeline, including chunking."""
|
||||||
|
plain, styles = _markdown_to_signal(text)
|
||||||
|
chunks = split_message(plain, max_len) if plain else [""]
|
||||||
|
return chunks, _partition_styles(plain, chunks, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_single_chunk_passthrough():
|
||||||
|
plain, styles = _markdown_to_signal("**bold** plain *it*")
|
||||||
|
parts = _partition_styles(plain, [plain], styles)
|
||||||
|
assert parts == [styles]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_no_styles():
|
||||||
|
plain = "hello world"
|
||||||
|
assert _partition_styles(plain, [plain], []) == [[]]
|
||||||
|
assert _partition_styles(plain, ["hello", "world"], []) == [[], []]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_drops_styles_outside_chunks():
|
||||||
|
"""Whitespace trimmed by split_message must not carry a style range."""
|
||||||
|
plain = "a b"
|
||||||
|
# Fake a style spanning the trimmed whitespace only.
|
||||||
|
chunks = ["a", "b"]
|
||||||
|
parts = _partition_styles(plain, chunks, ["1:3:BOLD"])
|
||||||
|
assert parts == [[], []]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_long_message_preserves_chunk_one_styles():
|
||||||
|
"""A bold span deep in the message must follow the message into chunk 1."""
|
||||||
|
# Two ~30-char paragraphs separated by a blank line, then **tail**.
|
||||||
|
line_a = "alpha " * 5 # 30 chars, ends with space
|
||||||
|
line_b = "beta " * 5
|
||||||
|
md = f"{line_a.strip()}\n\n{line_b.strip()}\n\n**tail**"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
# Force a split between the paragraphs.
|
||||||
|
max_len = len(line_a.strip()) + 2 # fits paragraph A + the "\n\n"
|
||||||
|
chunks = split_message(plain, max_len)
|
||||||
|
assert len(chunks) >= 2, "test setup must produce a split"
|
||||||
|
parts = _partition_styles(plain, chunks, styles)
|
||||||
|
# The bold "tail" should land in the last chunk, with chunk-relative offset.
|
||||||
|
final_chunk = chunks[-1]
|
||||||
|
final_styles = parts[-1]
|
||||||
|
assert any("BOLD" in s for s in final_styles)
|
||||||
|
for entry in final_styles:
|
||||||
|
s, ln, _ = entry.split(":", 2)
|
||||||
|
start, length = int(s), int(ln)
|
||||||
|
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
|
||||||
|
"utf-16-le"
|
||||||
|
)
|
||||||
|
assert slice_ == "tail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_chunk_zero_styles_unchanged():
|
||||||
|
"""Styles entirely in chunk 0 keep their original offsets."""
|
||||||
|
md = "**head** middle and **tail**"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
# Split so chunk 0 contains "head" and part of the rest, chunk 1 contains "tail".
|
||||||
|
chunks = split_message(plain, 12)
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
parts = _partition_styles(plain, chunks, styles)
|
||||||
|
# "head" lives in chunk 0; assert its offset is unchanged (chunk 0 starts at 0).
|
||||||
|
head_entries = [s for s in parts[0] if "BOLD" in s]
|
||||||
|
assert any(s.startswith("0:4:") for s in head_entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_with_non_bmp_chunk_offset():
|
||||||
|
"""Chunk-start offsets must be expressed in UTF-16 code units."""
|
||||||
|
# Emoji in chunk 0, bold in chunk 1.
|
||||||
|
md = "🎉 alpha beta gamma\n\n**tail**"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
chunks = split_message(plain, 18)
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
parts = _partition_styles(plain, chunks, styles)
|
||||||
|
final_styles = parts[-1]
|
||||||
|
assert any("BOLD" in s for s in final_styles)
|
||||||
|
final_chunk = chunks[-1]
|
||||||
|
for entry in final_styles:
|
||||||
|
s, ln, _ = entry.split(":", 2)
|
||||||
|
start, length = int(s), int(ln)
|
||||||
|
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
|
||||||
|
"utf-16-le"
|
||||||
|
)
|
||||||
|
assert slice_ == "tail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_range_spanning_chunks_is_split():
|
||||||
|
"""A style range that straddles a chunk boundary gets sliced into both chunks."""
|
||||||
|
# Construct manually: plain = "abc def", style covers "abc def" (whole thing).
|
||||||
|
plain = "abc def"
|
||||||
|
chunks = split_message(plain, 4) # "abc" / "def"
|
||||||
|
assert chunks == ["abc", "def"]
|
||||||
|
parts = _partition_styles(plain, chunks, ["0:7:BOLD"])
|
||||||
|
# Chunk 0 holds 0:3:BOLD, chunk 1 holds 0:3:BOLD (length=3 each, "def" only
|
||||||
|
# since the space was trimmed by lstrip).
|
||||||
|
assert parts[0] == ["0:3:BOLD"]
|
||||||
|
assert parts[1] == ["0:3:BOLD"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Adjacency, nesting, and malformed input
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_italic_combo_outer_bold_inner_italic():
|
||||||
|
"""`**_combo_**` carries both BOLD and ITALIC over the same span."""
|
||||||
|
plain, styles = _markdown_to_signal("**_combo_**")
|
||||||
|
assert plain == "combo"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert set(sd.get("combo", [])) == {"BOLD", "ITALIC"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_and_italic_adjacent_no_separator():
|
||||||
|
"""`**bold***italic*` produces BOLD on `bold` and ITALIC on `italic`."""
|
||||||
|
plain, styles = _markdown_to_signal("**bold***italic*")
|
||||||
|
assert plain == "bolditalic"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert sd.get("bold") == ["BOLD"]
|
||||||
|
assert sd.get("italic") == ["ITALIC"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unclosed_bold_falls_through_as_plain():
|
||||||
|
"""An unmatched `**` opener round-trips as literal text with no style."""
|
||||||
|
plain, styles = _markdown_to_signal("**bold")
|
||||||
|
assert plain == "**bold"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unclosed_inline_code_falls_through_as_plain():
|
||||||
|
"""An unmatched backtick round-trips as literal text with no style."""
|
||||||
|
plain, styles = _markdown_to_signal("use `grep")
|
||||||
|
assert plain == "use `grep"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_code_inside_blockquote():
|
||||||
|
"""Blockquote prefix is stripped; inline code becomes MONOSPACE."""
|
||||||
|
plain, styles = _markdown_to_signal("> use `grep`")
|
||||||
|
assert plain == "use grep"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert sd.get("grep") == ["MONOSPACE"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_with_inner_bold_produces_contiguous_bold_ranges():
|
||||||
|
"""`# **wrap** me` — header forces BOLD over the whole line; the inner `**`
|
||||||
|
splits the run, yielding two contiguous BOLD ranges that together cover
|
||||||
|
"wrap me". This is intentional — Signal renders adjacent same-style ranges
|
||||||
|
as a single visual span.
|
||||||
|
"""
|
||||||
|
plain, styles = _markdown_to_signal("# **wrap** me")
|
||||||
|
assert plain == "wrap me"
|
||||||
|
# Both ranges are BOLD; collectively they cover the whole "wrap me".
|
||||||
|
bold_ranges = [s for s in styles if s.endswith(":BOLD")]
|
||||||
|
assert len(bold_ranges) == 2
|
||||||
|
covered = set()
|
||||||
|
for entry in bold_ranges:
|
||||||
|
start, length, _ = entry.split(":", 2)
|
||||||
|
for i in range(int(start), int(start) + int(length)):
|
||||||
|
covered.add(i)
|
||||||
|
assert covered == set(range(len(plain)))
|
||||||
@@ -480,6 +480,99 @@ 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")
|
||||||
|
channel._webui_chats.add("chat-1")
|
||||||
|
|
||||||
|
await channel.send_delta("chat-1", "
|
||||||
|
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
|
||||||
|
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")
|
||||||
|
channel._webui_chats.add("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
|
||||||
|
async def test_send_delta_stream_end_leaves_non_webui_payload_unchanged(tmp_path) -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||||
|
bus,
|
||||||
|
workspace_path=workspace,
|
||||||
|
)
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._attach(mock_ws, "chat-1")
|
||||||
|
|
||||||
|
await channel.send_delta(
|
||||||
|
"chat-1",
|
||||||
|
"",
|
||||||
|
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_ws.send.assert_awaited_once()
|
||||||
|
final = json.loads(mock_ws.send.await_args.args[0])
|
||||||
|
assert final == {"event": "stream_end", "chat_id": "chat-1", "stream_id": "sid"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
|
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
@@ -1055,6 +1148,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert image_providers["openrouter"]["label"] == "OpenRouter"
|
assert image_providers["openrouter"]["label"] == "OpenRouter"
|
||||||
assert image_providers["openrouter"]["configured"] is False
|
assert image_providers["openrouter"]["configured"] is False
|
||||||
|
assert image_providers["openai_codex"]["configured"] is True
|
||||||
assert image_providers["gemini"]["label"] == "Gemini"
|
assert image_providers["gemini"]["label"] == "Gemini"
|
||||||
assert body["runtime"]["config_path"] == str(config_path)
|
assert body["runtime"]["config_path"] == str(config_path)
|
||||||
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
|
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
|
||||||
|
|||||||
@@ -105,6 +105,43 @@ async def test_message_without_media_backward_compatible() -> None:
|
|||||||
assert call.kwargs["media"] is None
|
assert call.kwargs["media"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_message_forwards_normalized_cli_app_attachments() -> None:
|
||||||
|
channel = _make_channel()
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
envelope = {
|
||||||
|
"type": "message",
|
||||||
|
"chat_id": "abc123",
|
||||||
|
"content": "please use @drawio",
|
||||||
|
"webui": True,
|
||||||
|
"cli_apps": [
|
||||||
|
{
|
||||||
|
"name": "DrawIO",
|
||||||
|
"display_name": "Draw.io",
|
||||||
|
"category": "diagram",
|
||||||
|
"entry_point": "cli-anything-drawio",
|
||||||
|
"logo_url": "https://example.invalid/drawio.svg",
|
||||||
|
"brand_color": "#F08705",
|
||||||
|
},
|
||||||
|
{"name": "bad name", "entry_point": "nope"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||||
|
|
||||||
|
channel._handle_message.assert_awaited_once()
|
||||||
|
metadata = channel._handle_message.call_args.kwargs["metadata"]
|
||||||
|
assert metadata["webui"] is True
|
||||||
|
assert metadata["cli_apps"] == [{
|
||||||
|
"name": "drawio",
|
||||||
|
"display_name": "Draw.io",
|
||||||
|
"category": "diagram",
|
||||||
|
"entry_point": "cli-anything-drawio",
|
||||||
|
"logo_url": "https://example.invalid/drawio.svg",
|
||||||
|
"brand_color": "#F08705",
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
|
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
|
||||||
channel = _make_channel()
|
channel = _make_channel()
|
||||||
|
|||||||
@@ -140,6 +140,75 @@ async def test_sessions_routes_require_bearer_token(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cli_apps_routes_require_token_and_return_payload(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.channels.websocket.cli_apps_payload",
|
||||||
|
lambda: {
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "gimp",
|
||||||
|
"display_name": "GIMP",
|
||||||
|
"category": "image",
|
||||||
|
"description": "Image editing",
|
||||||
|
"requires": "Python",
|
||||||
|
"source": "harness",
|
||||||
|
"entry_point": "cli-anything-gimp",
|
||||||
|
"install_supported": True,
|
||||||
|
"installed": False,
|
||||||
|
"available": False,
|
||||||
|
"status": "not_installed",
|
||||||
|
"logo_url": None,
|
||||||
|
"brand_color": None,
|
||||||
|
"skill_installed": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"installed_count": 0,
|
||||||
|
"catalog_updated_at": "2026-04-18",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.channels.websocket.cli_apps_action",
|
||||||
|
lambda action, query: {
|
||||||
|
"apps": [],
|
||||||
|
"installed_count": 1,
|
||||||
|
"catalog_updated_at": "2026-04-18",
|
||||||
|
"last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29912)
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
try:
|
||||||
|
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
|
||||||
|
assert deny.status_code == 401
|
||||||
|
|
||||||
|
boot = await _http_get("http://127.0.0.1:29912/webui/bootstrap")
|
||||||
|
token = boot.json()["token"]
|
||||||
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
catalog = await _http_get(
|
||||||
|
"http://127.0.0.1:29912/api/settings/cli-apps",
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
|
assert catalog.status_code == 200
|
||||||
|
assert catalog.json()["apps"][0]["name"] == "gimp"
|
||||||
|
|
||||||
|
installed = await _http_get(
|
||||||
|
"http://127.0.0.1:29912/api/settings/cli-apps/install?name=gimp",
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
|
assert installed.status_code == 200
|
||||||
|
assert installed.json()["last_action"]["message"] == "install:gimp"
|
||||||
|
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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
@@ -374,6 +375,7 @@ async def test_send_uses_typing_start_and_cancel_when_ticket_available() -> None
|
|||||||
channel._client = object()
|
channel._client = object()
|
||||||
channel._token = "token"
|
channel._token = "token"
|
||||||
channel._context_tokens["wx-user"] = "ctx-typing"
|
channel._context_tokens["wx-user"] = "ctx-typing"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
channel._send_text = AsyncMock()
|
channel._send_text = AsyncMock()
|
||||||
channel._api_post = AsyncMock(
|
channel._api_post = AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
@@ -402,6 +404,7 @@ async def test_send_still_sends_text_when_typing_ticket_missing() -> None:
|
|||||||
channel._client = object()
|
channel._client = object()
|
||||||
channel._token = "token"
|
channel._token = "token"
|
||||||
channel._context_tokens["wx-user"] = "ctx-no-ticket"
|
channel._context_tokens["wx-user"] = "ctx-no-ticket"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
channel._send_text = AsyncMock()
|
channel._send_text = AsyncMock()
|
||||||
channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "no config"})
|
channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "no config"})
|
||||||
|
|
||||||
@@ -1254,3 +1257,526 @@ async def test_send_text_succeeds_on_zero_errcode() -> None:
|
|||||||
await channel._send_text("wx-user", "hello", "ctx-ok")
|
await channel._send_text("wx-user", "hello", "ctx-ok")
|
||||||
|
|
||||||
channel._api_post.assert_awaited_once()
|
channel._api_post.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None:
|
||||||
|
"""_send_text must raise when the API returns ret != 0, even if errcode is 0.
|
||||||
|
|
||||||
|
The iLink API signals failure through either field. Checking only errcode
|
||||||
|
caused silent message drops (responses generated but never delivered).
|
||||||
|
"""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel._api_post = AsyncMock(
|
||||||
|
return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"):
|
||||||
|
await channel._send_text("wx-user", "hello", "ctx-ok")
|
||||||
|
|
||||||
|
channel._api_post.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for _poll_once not silently dropping messages on processing errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_once_logs_exception_on_process_message_failure(monkeypatch) -> None:
|
||||||
|
"""When _process_message raises, _poll_once must log the error and continue
|
||||||
|
processing remaining messages instead of silently swallowing the exception."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = SimpleNamespace(timeout=None)
|
||||||
|
channel._token = "token"
|
||||||
|
channel._get_updates_buf = "old-buf"
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
logged_messages: list[str] = []
|
||||||
|
|
||||||
|
async def _failing_process(msg: dict) -> None:
|
||||||
|
calls.append(msg.get("message_id"))
|
||||||
|
if msg.get("message_id") == "msg-1":
|
||||||
|
raise RuntimeError("processing failed")
|
||||||
|
|
||||||
|
channel._process_message = _failing_process # type: ignore[method-assign]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
channel.logger,
|
||||||
|
"exception",
|
||||||
|
lambda message, *args, **kwargs: logged_messages.append(str(message)),
|
||||||
|
)
|
||||||
|
|
||||||
|
channel._api_post = AsyncMock( # type: ignore[method-assign]
|
||||||
|
return_value={
|
||||||
|
"ret": 0,
|
||||||
|
"errcode": 0,
|
||||||
|
"get_updates_buf": "new-buf",
|
||||||
|
"msgs": [
|
||||||
|
{"message_id": "msg-1", "message_type": 1},
|
||||||
|
{"message_id": "msg-2", "message_type": 1},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._poll_once()
|
||||||
|
|
||||||
|
# Both messages should have been attempted
|
||||||
|
assert calls == ["msg-1", "msg-2"]
|
||||||
|
# Buffer should still advance (already updated before processing)
|
||||||
|
assert channel._get_updates_buf == "new-buf"
|
||||||
|
# Error should be logged
|
||||||
|
assert any("Failed to process WeChat message" in m for m in logged_messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_loop_logs_exception_and_continues_on_poll_failure(monkeypatch) -> None:
|
||||||
|
"""When _poll_once raises a non-timeout exception, the start() loop must log
|
||||||
|
the error and continue polling instead of exiting silently."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.config.token = "token" # skip QR login in start()
|
||||||
|
channel._running = True
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
logged_messages: list[str] = []
|
||||||
|
|
||||||
|
async def _failing_poll() -> None:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise RuntimeError("poll exploded")
|
||||||
|
channel._running = False # Stop after second call
|
||||||
|
|
||||||
|
channel._poll_once = _failing_poll # type: ignore[method-assign]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
channel.logger,
|
||||||
|
"exception",
|
||||||
|
lambda message, *args, **kwargs: logged_messages.append(str(message)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use a tiny retry delay so the test finishes quickly
|
||||||
|
original_retry = weixin_mod.RETRY_DELAY_S
|
||||||
|
weixin_mod.RETRY_DELAY_S = 0.01
|
||||||
|
try:
|
||||||
|
await channel.start()
|
||||||
|
finally:
|
||||||
|
weixin_mod.RETRY_DELAY_S = original_retry
|
||||||
|
|
||||||
|
assert call_count == 2
|
||||||
|
assert any("WeChat poll loop error" in m for m in logged_messages)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool-hint buffering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buffer_single_tool_hint_not_sent_immediately() -> None:
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.send_tool_hints = True
|
||||||
|
channel._context_tokens["wx-user"] = "ctx-1"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "Using tool",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_hint": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
channel._send_text.assert_not_awaited()
|
||||||
|
assert channel._pending_tool_hints["wx-user"] == ["Using tool"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buffer_multiple_tool_hints_flushed_on_final_answer() -> None:
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.send_tool_hints = True
|
||||||
|
channel._context_tokens["wx-user"] = "ctx-1"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
|
for hint in ["tool1", "tool2"]:
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": hint,
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_hint": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "Done",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel._send_text.await_count == 2
|
||||||
|
channel._send_text.assert_any_await("wx-user", "tool1\n\ntool2", "ctx-1")
|
||||||
|
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
|
||||||
|
assert "wx-user" not in channel._pending_tool_hints
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_thought_progress_flushes_tool_hints() -> None:
|
||||||
|
"""Thoughts are visible progress messages and must act as separators,
|
||||||
|
flushing buffered tool hints before they are sent."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.send_tool_hints = True
|
||||||
|
channel._context_tokens["wx-user"] = "ctx-1"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
|
# Buffer a tool hint
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "search 'foo'",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_hint": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send a thought — progress but not a tool_hint.
|
||||||
|
# It must act as a separator and flush the buffered hint.
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "Let me think...",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
# The buffered hint was flushed before the thought was sent.
|
||||||
|
channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
|
||||||
|
channel._send_text.assert_any_await("wx-user", "Let me think...", "ctx-1")
|
||||||
|
assert "wx-user" not in channel._pending_tool_hints
|
||||||
|
|
||||||
|
# Final answer arrives with nothing left to flush.
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "Done",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel._send_text.await_count == 3
|
||||||
|
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reasoning_delta_does_not_flush_tool_hints() -> None:
|
||||||
|
"""Reasoning deltas are invisible in WeChat and must NOT flush buffered
|
||||||
|
tool hints — otherwise hints separated only by hidden reasoning would
|
||||||
|
fail to coalesce."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.send_tool_hints = True
|
||||||
|
channel._context_tokens["wx-user"] = "ctx-1"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
|
# Buffer a tool hint
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "search 'foo'",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_hint": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send a reasoning delta — invisible in WeChat, must NOT flush
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "Thinking step 1...",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_reasoning_delta": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reasoning is invisible; hint stays buffered, _send_text not called
|
||||||
|
channel._send_text.assert_not_awaited()
|
||||||
|
assert channel._pending_tool_hints["wx-user"] == ["search 'foo'"]
|
||||||
|
|
||||||
|
# Final answer flushes the buffered hint
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "Done",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
|
||||||
|
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
|
||||||
|
assert "wx-user" not in channel._pending_tool_hints
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_progress_message_does_not_flush_tool_hints() -> None:
|
||||||
|
"""Empty progress messages (e.g. after_iteration tool_events) have no
|
||||||
|
visible content and must NOT act as separators."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.send_tool_hints = True
|
||||||
|
channel._context_tokens["wx-user"] = "ctx-1"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
|
# Buffer a tool hint
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "search 'foo'",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_hint": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send an empty progress message (no content, no media)
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_events": [{"phase": "end"}]},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nothing should have been sent yet
|
||||||
|
channel._send_text.assert_not_awaited()
|
||||||
|
assert channel._pending_tool_hints["wx-user"] == ["search 'foo'"]
|
||||||
|
|
||||||
|
# Final answer flushes the buffered hint
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "Done",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
|
||||||
|
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
|
||||||
|
assert "wx-user" not in channel._pending_tool_hints
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buffer_flush_refreshes_context_token() -> None:
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.send_tool_hints = True
|
||||||
|
channel._context_tokens["wx-user"] = "ctx-old"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
|
channel._refresh_context_token_if_stale = AsyncMock(return_value="ctx-refreshed")
|
||||||
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "hint",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_hint": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "Done",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel._refresh_context_token_if_stale.await_count == 2
|
||||||
|
channel._refresh_context_token_if_stale.assert_any_await("wx-user", "ctx-old")
|
||||||
|
channel._send_text.assert_any_await("wx-user", "hint", "ctx-refreshed")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buffer_flush_failure_does_not_block_final_answer() -> None:
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.send_tool_hints = True
|
||||||
|
channel._context_tokens["wx-user"] = "ctx-1"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
|
channel._send_text = AsyncMock(side_effect=[RuntimeError("boom"), None])
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "hint",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_hint": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "Done",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel._send_text.await_count == 2
|
||||||
|
channel._send_text.assert_any_await("wx-user", "hint", "ctx-1")
|
||||||
|
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buffer_flushed_on_stream_end() -> None:
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.send_tool_hints = True
|
||||||
|
channel._context_tokens["wx-user"] = "ctx-1"
|
||||||
|
channel._context_token_at["wx-user"] = time.time()
|
||||||
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "hint",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_hint": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send_delta("wx-user", "", {"_stream_end": True})
|
||||||
|
|
||||||
|
channel._send_text.assert_awaited_once_with("wx-user", "hint", "ctx-1")
|
||||||
|
assert "wx-user" not in channel._pending_tool_hints
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_clears_buffer() -> None:
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._pending_tool_hints["wx-user"] = ["hint1", "hint2"]
|
||||||
|
await channel.stop()
|
||||||
|
assert "wx-user" not in channel._pending_tool_hints
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_tool_hints_false_drops_tool_hints() -> None:
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.send_tool_hints = False
|
||||||
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
type(
|
||||||
|
"Msg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"chat_id": "wx-user",
|
||||||
|
"content": "hint",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {"_progress": True, "_tool_hint": True},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
|
||||||
|
channel._send_text.assert_not_awaited()
|
||||||
|
assert "wx-user" not in channel._pending_tool_hints
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cache(path: Path, registry: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"_cached_at": time.time(), "data": registry}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(tmp_path: Path) -> CliAppManager:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
return CliAppManager(
|
||||||
|
workspace=workspace,
|
||||||
|
data_dir=tmp_path / "data",
|
||||||
|
runtime=CliAppsRuntimeConfig(catalog_ttl_seconds=3600, install_timeout=5, run_timeout=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_catalog(manager: CliAppManager) -> None:
|
||||||
|
harness = {
|
||||||
|
"meta": {"updated": "2026-04-16"},
|
||||||
|
"clis": [
|
||||||
|
{
|
||||||
|
"name": "gimp",
|
||||||
|
"display_name": "GIMP",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Image editing",
|
||||||
|
"category": "image",
|
||||||
|
"requires": "Python 3.10+",
|
||||||
|
"install_cmd": "pip install cli-anything-gimp",
|
||||||
|
"entry_point": "cli-anything-gimp",
|
||||||
|
"skill_md": "skills/cli-anything-gimp/SKILL.md",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
public = {
|
||||||
|
"meta": {"updated": "2026-04-18"},
|
||||||
|
"clis": [
|
||||||
|
{
|
||||||
|
"name": "gimp",
|
||||||
|
"display_name": "GIMP",
|
||||||
|
"description": "Public duplicate entry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "jimeng",
|
||||||
|
"display_name": "Jimeng",
|
||||||
|
"version": "latest",
|
||||||
|
"description": "Script install",
|
||||||
|
"category": "ai",
|
||||||
|
"install_strategy": "script",
|
||||||
|
"install_cmd": "curl -fsSL https://example.invalid/install.sh | bash",
|
||||||
|
"entry_point": "dreamina",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "feishu",
|
||||||
|
"display_name": "Feishu/Lark CLI",
|
||||||
|
"version": "latest",
|
||||||
|
"description": "Official Lark CLI",
|
||||||
|
"category": "communication",
|
||||||
|
"package_manager": "npm",
|
||||||
|
"npm_package": "@larksuite/cli",
|
||||||
|
"install_cmd": "npm install -g @larksuite/cli",
|
||||||
|
"entry_point": "lark-cli",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dify-workflow",
|
||||||
|
"display_name": "Dify Workflow",
|
||||||
|
"version": "latest",
|
||||||
|
"description": "Run Dify workflows",
|
||||||
|
"category": "ai",
|
||||||
|
"install_cmd": "pip install cli-anything-dify-workflow",
|
||||||
|
"entry_point": "cli-anything-dify-workflow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shopify",
|
||||||
|
"display_name": "Shopify CLI",
|
||||||
|
"version": "latest",
|
||||||
|
"description": "Shopify",
|
||||||
|
"category": "web",
|
||||||
|
"package_manager": "npm",
|
||||||
|
"npm_package": "@shopify/cli",
|
||||||
|
"install_cmd": "npm install -g @shopify/cli",
|
||||||
|
"entry_point": "shopify",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "clibrowser",
|
||||||
|
"display_name": "clibrowser",
|
||||||
|
"version": "latest",
|
||||||
|
"description": "Cargo install",
|
||||||
|
"category": "web",
|
||||||
|
"install_cmd": "cargo install --git https://example.invalid/clibrowser.git",
|
||||||
|
"entry_point": "clibrowser",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "suno",
|
||||||
|
"display_name": "Suno CLI",
|
||||||
|
"version": "latest",
|
||||||
|
"description": "python3 pip install",
|
||||||
|
"category": "music",
|
||||||
|
"package_manager": "pip",
|
||||||
|
"install_strategy": "command",
|
||||||
|
"install_cmd": "python3 -m pip install git+https://example.invalid/suno-cli.git",
|
||||||
|
"uninstall_cmd": "python3 -m pip uninstall -y suno-cli",
|
||||||
|
"entry_point": "suno",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_write_cache(manager._cache_path("harness"), harness)
|
||||||
|
_write_cache(manager._cache_path("public"), public)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_seed_catalog(manager)
|
||||||
|
|
||||||
|
payload = manager.payload()
|
||||||
|
|
||||||
|
assert payload["catalog_updated_at"] == "2026-04-18"
|
||||||
|
apps = {app["name"]: app for app in payload["apps"]}
|
||||||
|
assert set(apps) == {
|
||||||
|
"clibrowser",
|
||||||
|
"dify-workflow",
|
||||||
|
"feishu",
|
||||||
|
"gimp",
|
||||||
|
"jimeng",
|
||||||
|
"shopify",
|
||||||
|
"suno",
|
||||||
|
}
|
||||||
|
assert apps["gimp"]["install_supported"] is True
|
||||||
|
assert apps["gimp"]["source"] == "harness+public"
|
||||||
|
assert apps["gimp"]["description"] == "Public duplicate entry"
|
||||||
|
assert apps["clibrowser"]["install_supported"] is False
|
||||||
|
assert apps["jimeng"]["install_supported"] is False
|
||||||
|
assert apps["suno"]["install_supported"] is True
|
||||||
|
assert apps["gimp"]["logo_url"]
|
||||||
|
assert apps["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF"
|
||||||
|
assert apps["feishu"]["logo_url"] == (
|
||||||
|
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
|
||||||
|
)
|
||||||
|
assert apps["jimeng"]["logo_url"] == "https://cdn.simpleicons.org/bytedance/3C8CFF"
|
||||||
|
assert apps["clibrowser"]["logo_url"] == (
|
||||||
|
"https://www.google.com/s2/favicons?domain=github.com/allthingssecurity/clibrowser&sz=64"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_dispatches_safe_pip_and_installs_skill(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_seed_catalog(manager)
|
||||||
|
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)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
manager,
|
||||||
|
"_fetch_skill_content",
|
||||||
|
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = manager.install("gimp")
|
||||||
|
|
||||||
|
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
|
||||||
|
assert payload["last_action"]["ok"] is True
|
||||||
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
|
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||||
|
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||||
|
assert skill.is_file()
|
||||||
|
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
|
||||||
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||||
|
manager._save_installed({"zoom": {"entry_point": "cli-anything-zoom"}})
|
||||||
|
|
||||||
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
|
assert set(installed) == {"zoom"}
|
||||||
|
assert not list(manager.installed_path.parent.glob(".installed.json.*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_skill_content_rejects_untrusted_urls(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
|
||||||
|
def fail_get(*args, **kwargs):
|
||||||
|
raise AssertionError("untrusted skill URL should not be fetched")
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fail_get)
|
||||||
|
|
||||||
|
assert manager._fetch_skill_content({
|
||||||
|
"name": "evil",
|
||||||
|
"skill_md": "https://example.com/SKILL.md",
|
||||||
|
}) is None
|
||||||
|
assert manager._fetch_skill_content({
|
||||||
|
"name": "evil",
|
||||||
|
"skill_md": "skills/../evil/SKILL.md",
|
||||||
|
}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
text = "---\nname: cli-app-test\ndescription: Test\n---\n# Test\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def raise_for_status() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fake_get(url: str, **kwargs):
|
||||||
|
seen.append(url)
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get)
|
||||||
|
|
||||||
|
content = manager._fetch_skill_content({
|
||||||
|
"name": "gimp",
|
||||||
|
"skill_md": "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert content and "# Test" in content
|
||||||
|
assert seen == [
|
||||||
|
"https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstall_removes_installed_state_and_generated_skill(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_seed_catalog(manager)
|
||||||
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||||
|
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||||
|
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 True
|
||||||
|
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
|
assert not skill_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_seed_catalog(manager)
|
||||||
|
manager._save_installed({"suno": {"entry_point": "suno"}})
|
||||||
|
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("suno")
|
||||||
|
|
||||||
|
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "suno-cli"]]
|
||||||
|
assert payload["last_action"]["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
manager._save_installed(
|
||||||
|
{
|
||||||
|
"gimp": {"entry_point": "cli-anything-gimp", "source": "harness"},
|
||||||
|
"zoom": {"entry_point": "cli-anything-zoom", "source": "public"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
mentions = manager.mentioned_installed_apps("use @zoom and @krita, then @GIMP")
|
||||||
|
|
||||||
|
assert mentions == [
|
||||||
|
{
|
||||||
|
"name": "zoom",
|
||||||
|
"entry_point": "cli-anything-zoom",
|
||||||
|
"source": "public",
|
||||||
|
"skill": "skills/cli-app-zoom/SKILL.md",
|
||||||
|
"tool": "run_cli_app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gimp",
|
||||||
|
"entry_point": "cli-anything-gimp",
|
||||||
|
"source": "harness",
|
||||||
|
"skill": "skills/cli-app-gimp/SKILL.md",
|
||||||
|
"tool": "run_cli_app",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_seed_catalog(manager)
|
||||||
|
|
||||||
|
with pytest.raises(CliAppError, match="not found"):
|
||||||
|
manager.install("missing")
|
||||||
|
|
||||||
|
with pytest.raises(CliAppError, match="unsupported"):
|
||||||
|
manager.install("jimeng")
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_installed_cli_uses_argv_without_shell(
|
||||||
|
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.cli_apps.service.shutil.which",
|
||||||
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||||
|
assert "shell" not in kwargs or kwargs["shell"] is False
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
argv,
|
||||||
|
0,
|
||||||
|
stdout="ARGS=" + repr(argv[1:]),
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
|
||||||
|
manager._save_installed(
|
||||||
|
{
|
||||||
|
"gimp": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry_point": "cli-anything-gimp",
|
||||||
|
"source": "harness",
|
||||||
|
"strategy": "pip",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = manager.run("gimp", ["project", "list"], json_output=True)
|
||||||
|
|
||||||
|
assert "CLI app 'gimp' exited 0" 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.cli_apps.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.cli_apps.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:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_seed_catalog(manager)
|
||||||
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||||
|
|
||||||
|
with pytest.raises(CliAppError, match="outside the configured workspace"):
|
||||||
|
manager.run("gimp", working_dir="/etc", restrict_to_workspace=True)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.agent.tools.cli_apps import CliAppsTool
|
||||||
|
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cache(path: Path, registry: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"_cached_at": time.time(), "data": registry}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cli_app_uses_installed_registry_app(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
registry = {
|
||||||
|
"meta": {"updated": "2026-04-16"},
|
||||||
|
"clis": [
|
||||||
|
{
|
||||||
|
"name": "gimp",
|
||||||
|
"display_name": "GIMP",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Image editing",
|
||||||
|
"category": "image",
|
||||||
|
"install_cmd": "pip install cli-anything-gimp",
|
||||||
|
"entry_point": "cli-anything-gimp",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_write_cache(data_dir / "harness_registry_cache.json", registry)
|
||||||
|
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
||||||
|
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
||||||
|
{"gimp": {"entry_point": "cli-anything-gimp"}}
|
||||||
|
)
|
||||||
|
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli_apps.service.shutil.which",
|
||||||
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||||
|
assert "shell" not in kwargs or kwargs["shell"] is False
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
argv,
|
||||||
|
0,
|
||||||
|
stdout="tool:" + " ".join(argv[1:]),
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
|
||||||
|
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
|
|
||||||
|
tool = CliAppsTool(
|
||||||
|
workspace=workspace,
|
||||||
|
restrict_to_workspace=True,
|
||||||
|
runtime=CliAppsRuntimeConfig(run_timeout=5),
|
||||||
|
)
|
||||||
|
assert tool.name == "run_cli_app"
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
name="gimp",
|
||||||
|
args=["project", "list"],
|
||||||
|
json=True,
|
||||||
|
working_dir=str(workspace),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "CLI app 'gimp' exited 0" in result
|
||||||
|
assert "tool:--json project list" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
registry = {
|
||||||
|
"meta": {"updated": "2026-04-16"},
|
||||||
|
"clis": [
|
||||||
|
{
|
||||||
|
"name": "gimp",
|
||||||
|
"display_name": "GIMP",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Image editing",
|
||||||
|
"category": "image",
|
||||||
|
"install_cmd": "pip install cli-anything-gimp",
|
||||||
|
"entry_point": "cli-anything-gimp",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_write_cache(data_dir / "harness_registry_cache.json", registry)
|
||||||
|
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
||||||
|
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
|
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(name="gimp"))
|
||||||
|
|
||||||
|
assert "not installed" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
||||||
|
{"drawio": {"entry_point": "cli-anything-drawio"}}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
|
|
||||||
|
tool = CliAppsTool(workspace=workspace)
|
||||||
|
|
||||||
|
assert "Settings CLI Apps: drawio" in tool.description
|
||||||
|
assert "ordinary system CLIs such as git, gh" in tool.description
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Tests for CLI Apps loop helpers."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from nanobot.cli_apps.service import CliAppManager
|
||||||
|
from nanobot.cli_apps.utils import runtime_lines, session_extra
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_extra_returns_cli_apps_only_when_present() -> None:
|
||||||
|
cli_apps = [{"name": "zoom"}]
|
||||||
|
assert session_extra({"cli_apps": cli_apps}) == {"cli_apps": cli_apps}
|
||||||
|
assert session_extra({}) == {}
|
||||||
|
assert session_extra(None) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
|
manager = CliAppManager(workspace=tmp_path)
|
||||||
|
manager._save_installed(
|
||||||
|
{
|
||||||
|
"zoom": {
|
||||||
|
"entry_point": "cli-anything-zoom",
|
||||||
|
"source": "harness",
|
||||||
|
},
|
||||||
|
"krita": {
|
||||||
|
"entry_point": "cli-anything-krita",
|
||||||
|
"source": "harness",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = runtime_lines(
|
||||||
|
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
joined = "\n".join(lines)
|
||||||
|
assert "CLI App Mention: @zoom" in joined
|
||||||
|
assert "tool=run_cli_app" in joined
|
||||||
|
assert "entry_point=cli-anything-zoom" in joined
|
||||||
|
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||||
|
lines = runtime_lines(
|
||||||
|
SimpleNamespace(
|
||||||
|
content="please use @zoom tonight",
|
||||||
|
metadata={
|
||||||
|
"cli_apps": [{
|
||||||
|
"name": "zoom",
|
||||||
|
"entry_point": "cli-anything-zoom",
|
||||||
|
"display_name": "Zoom",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
joined = "\n".join(lines)
|
||||||
|
assert "CLI App Attachment: @zoom" in joined
|
||||||
|
assert "tool=run_cli_app" in joined
|
||||||
|
assert "entry_point=cli-anything-zoom" in joined
|
||||||
|
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||||
@@ -192,3 +192,20 @@ def test_match_provider_uses_preset_provider_when_forced() -> None:
|
|||||||
})
|
})
|
||||||
name = config.get_provider_name()
|
name = config.get_provider_name()
|
||||||
assert name == "anthropic"
|
assert name == "anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_provider_routes_forced_novita_model_api_models() -> None:
|
||||||
|
config = Config.model_validate({
|
||||||
|
"providers": {
|
||||||
|
"novita": {"apiKey": "sk-test"},
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"provider": "novita",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert config.get_provider_name() == "novita"
|
||||||
|
assert config.get_api_base() == "https://api.novita.ai/openai"
|
||||||
|
|||||||
@@ -56,6 +56,35 @@ def test_custom_provider_parse_chunks_accepts_plain_text_chunks() -> None:
|
|||||||
assert result.content == "hello world"
|
assert result.content == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_provider_parse_chunks_deduplicates_parallel_tool_call_ids() -> None:
|
||||||
|
chunks = [{
|
||||||
|
"choices": [{
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"id": "call_dup",
|
||||||
|
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"id": "call_dup",
|
||||||
|
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
|
||||||
|
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||||
|
ids = [tool_call.id for tool_call in result.tool_calls or []]
|
||||||
|
|
||||||
|
assert ids[0] == "call_dup"
|
||||||
|
assert len(ids) == 2
|
||||||
|
assert len(set(ids)) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_local_provider_502_error_includes_reachability_hint() -> None:
|
def test_local_provider_502_error_includes_reachability_hint() -> None:
|
||||||
spec = find_by_name("ollama")
|
spec = find_by_name("ollama")
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
AIHubMixImageGenerationClient,
|
AIHubMixImageGenerationClient,
|
||||||
|
CodexImageGenerationClient,
|
||||||
GeminiImageGenerationClient,
|
GeminiImageGenerationClient,
|
||||||
GeneratedImageResponse,
|
GeneratedImageResponse,
|
||||||
ImageGenerationError,
|
ImageGenerationError,
|
||||||
MiniMaxImageGenerationClient,
|
MiniMaxImageGenerationClient,
|
||||||
|
OllamaImageGenerationClient,
|
||||||
|
OpenAIImageGenerationClient,
|
||||||
OpenRouterImageGenerationClient,
|
OpenRouterImageGenerationClient,
|
||||||
StepFunImageGenerationClient,
|
StepFunImageGenerationClient,
|
||||||
)
|
)
|
||||||
@@ -36,12 +39,14 @@ class FakeResponse:
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
status_code: int = 200,
|
status_code: int = 200,
|
||||||
content: bytes = b"",
|
content: bytes = b"",
|
||||||
|
sse_lines: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._payload = payload
|
self._payload = payload
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.text = str(payload)
|
self.text = str(payload)
|
||||||
self.content = content
|
self.content = content
|
||||||
self.request = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
|
self.request = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
|
||||||
|
self._sse_lines = sse_lines
|
||||||
|
|
||||||
def json(self) -> dict[str, Any]:
|
def json(self) -> dict[str, Any]:
|
||||||
return self._payload
|
return self._payload
|
||||||
@@ -51,6 +56,15 @@ class FakeResponse:
|
|||||||
response = httpx.Response(self.status_code, request=self.request, text=self.text)
|
response = httpx.Response(self.status_code, request=self.request, text=self.text)
|
||||||
raise httpx.HTTPStatusError("failed", request=self.request, response=response)
|
raise httpx.HTTPStatusError("failed", request=self.request, response=response)
|
||||||
|
|
||||||
|
async def aiter_lines(self):
|
||||||
|
if self._sse_lines is not None:
|
||||||
|
for line in self._sse_lines:
|
||||||
|
yield line
|
||||||
|
return
|
||||||
|
# Fallback: treat response text as SSE lines
|
||||||
|
for line in self.text.split("\n"):
|
||||||
|
yield line
|
||||||
|
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def __init__(self, response: FakeResponse) -> None:
|
def __init__(self, response: FakeResponse) -> None:
|
||||||
@@ -133,6 +147,54 @@ async def test_openrouter_image_generation_requires_api_key() -> None:
|
|||||||
await client.generate(prompt="draw", model="model")
|
await client.generate(prompt="draw", model="model")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ollama_image_generation_payload_and_response() -> None:
|
||||||
|
raw_b64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
|
||||||
|
fake = FakeClient(FakeResponse({"image": raw_b64}))
|
||||||
|
client = OllamaImageGenerationClient(
|
||||||
|
api_key="ollama-test",
|
||||||
|
api_base="http://localhost:11434/v1/",
|
||||||
|
extra_headers={"X-Test": "1"},
|
||||||
|
extra_body={"seed": 123},
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(
|
||||||
|
prompt="a sunset",
|
||||||
|
model="x/z-image-turbo",
|
||||||
|
aspect_ratio="16:9",
|
||||||
|
image_size="1K",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.images == [PNG_DATA_URL]
|
||||||
|
assert response.content == ""
|
||||||
|
|
||||||
|
call = fake.calls[0]
|
||||||
|
assert call["url"] == "http://localhost:11434/api/generate"
|
||||||
|
assert call["headers"]["Authorization"] == "Bearer ollama-test"
|
||||||
|
assert call["headers"]["X-Test"] == "1"
|
||||||
|
body = call["json"]
|
||||||
|
assert body["model"] == "x/z-image-turbo"
|
||||||
|
assert body["prompt"] == "a sunset"
|
||||||
|
assert body["width"] == 1024
|
||||||
|
assert body["height"] == 576
|
||||||
|
assert body["steps"] == 0
|
||||||
|
assert body["stream"] is False
|
||||||
|
assert body["seed"] == 123
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ollama_image_generation_rejects_reference_images() -> None:
|
||||||
|
client = OllamaImageGenerationClient(api_key=None)
|
||||||
|
|
||||||
|
with pytest.raises(ImageGenerationError, match="reference images"):
|
||||||
|
await client.generate(
|
||||||
|
prompt="edit this",
|
||||||
|
model="x/z-image-turbo",
|
||||||
|
reference_images=["ref.png"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aihubmix_image_generation_payload_and_response() -> None:
|
async def test_aihubmix_image_generation_payload_and_response() -> None:
|
||||||
raw_b64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
|
raw_b64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
|
||||||
@@ -348,6 +410,11 @@ async def test_gemini_requires_api_key() -> None:
|
|||||||
await client.generate(prompt="draw", model="imagen-4.0-generate-001")
|
await client.generate(prompt="draw", model="imagen-4.0-generate-001")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemini_image_client_uses_native_api_base_by_default() -> None:
|
||||||
|
client = GeminiImageGenerationClient(api_key="AIza-test")
|
||||||
|
assert client.api_base == "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_gemini_no_images_raises() -> None:
|
async def test_gemini_no_images_raises() -> None:
|
||||||
fake = FakeClient(FakeResponse({"candidates": [{"content": {"parts": [{"text": "sorry"}]}}]}))
|
fake = FakeClient(FakeResponse({"candidates": [{"content": {"parts": [{"text": "sorry"}]}}]}))
|
||||||
@@ -390,6 +457,17 @@ async def test_minimax_payload_and_response_with_reference_image(tmp_path: Path)
|
|||||||
assert body["subject_reference"][0]["image_file"].startswith("data:image/png;base64,")
|
assert body["subject_reference"][0]["image_file"].startswith("data:image/png;base64,")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_minimax_base64_response_uses_detected_mime() -> None:
|
||||||
|
raw_b64 = base64.b64encode(JPEG_BYTES).decode("ascii")
|
||||||
|
fake = FakeClient(FakeResponse({"data": {"image_base64": [raw_b64]}}))
|
||||||
|
client = MiniMaxImageGenerationClient(api_key="sk-mm-test", client=fake) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
response = await client.generate(prompt="draw", model="image-01")
|
||||||
|
|
||||||
|
assert response.images == [f"data:image/jpeg;base64,{raw_b64}"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# StepFun (阶跃星辰)
|
# StepFun (阶跃星辰)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -515,3 +593,437 @@ async def test_stepfun_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="step-image-edit-2")
|
await client.generate(prompt="draw", model="step-image-edit-2")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OpenAI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_payload_and_response() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
api_base="https://api.openai.com/v1",
|
||||||
|
extra_headers={"X-Test": "1"},
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(
|
||||||
|
prompt="a cat on the moon",
|
||||||
|
model="dall-e-3",
|
||||||
|
aspect_ratio="16:9",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.images == [PNG_DATA_URL]
|
||||||
|
call = fake.calls[0]
|
||||||
|
assert call["url"] == "https://api.openai.com/v1/images/generations"
|
||||||
|
assert call["headers"]["Authorization"] == "Bearer sk-openai-test"
|
||||||
|
assert call["headers"]["X-Test"] == "1"
|
||||||
|
body = call["json"]
|
||||||
|
assert body["model"] == "dall-e-3"
|
||||||
|
assert body["prompt"] == "a cat on the moon"
|
||||||
|
assert body["response_format"] == "b64_json"
|
||||||
|
assert body["n"] == 1
|
||||||
|
assert body["size"] == "1792x1024"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_b64_json_response_uses_detected_mime() -> None:
|
||||||
|
raw_b64 = base64.b64encode(JPEG_BYTES).decode("ascii")
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": raw_b64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(prompt="draw", model="dall-e-3")
|
||||||
|
|
||||||
|
assert response.images == [f"data:image/jpeg;base64,{raw_b64}"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_url_download_fallback() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"url": "https://cdn.example/image.png"}]}))
|
||||||
|
fake.get_response = FakeResponse({}, content=PNG_BYTES)
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(prompt="draw", model="dall-e-3")
|
||||||
|
|
||||||
|
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_openai_multiple_images() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({
|
||||||
|
"data": [
|
||||||
|
{"b64_json": RAW_B64},
|
||||||
|
{"b64_json": RAW_B64},
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(prompt="draw", model="dall-e-3")
|
||||||
|
|
||||||
|
assert len(response.images) == 2
|
||||||
|
assert response.images == [PNG_DATA_URL, PNG_DATA_URL]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_aspect_ratio_to_size() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.generate(prompt="draw", model="dall-e-3", aspect_ratio="1:1")
|
||||||
|
assert fake.calls[0]["json"]["size"] == "1024x1024"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_dalle3_uses_supported_orientation_sizes() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.generate(prompt="draw", model="dall-e-3", aspect_ratio="3:4")
|
||||||
|
await client.generate(prompt="draw", model="dall-e-3", aspect_ratio="4:3")
|
||||||
|
|
||||||
|
assert fake.calls[0]["json"]["size"] == "1024x1792"
|
||||||
|
assert fake.calls[1]["json"]["size"] == "1792x1024"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_dalle2_uses_square_size_for_non_square_ratios() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.generate(prompt="draw", model="dall-e-2", aspect_ratio="16:9")
|
||||||
|
|
||||||
|
assert fake.calls[0]["json"]["size"] == "1024x1024"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_gpt_image_uses_supported_landscape_size() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.generate(prompt="draw", model="gpt-image-1", aspect_ratio="16:9")
|
||||||
|
|
||||||
|
assert fake.calls[0]["json"]["size"] == "1536x1024"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_gpt_image_uses_supported_orientation_sizes() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.generate(prompt="draw", model="gpt-image-1", aspect_ratio="3:4")
|
||||||
|
await client.generate(prompt="draw", model="gpt-image-1", aspect_ratio="4:3")
|
||||||
|
|
||||||
|
assert fake.calls[0]["json"]["size"] == "1024x1536"
|
||||||
|
assert fake.calls[1]["json"]["size"] == "1536x1024"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_default_size_when_no_aspect_ratio() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.generate(prompt="draw", model="dall-e-3")
|
||||||
|
|
||||||
|
body = fake.calls[0]["json"]
|
||||||
|
assert body["size"] == "1024x1024"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_ignores_explicit_size_unsupported_by_model_family() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.generate(
|
||||||
|
prompt="draw",
|
||||||
|
model="dall-e-3",
|
||||||
|
aspect_ratio="16:9",
|
||||||
|
image_size="1536x1024",
|
||||||
|
)
|
||||||
|
|
||||||
|
body = fake.calls[0]["json"]
|
||||||
|
assert body["size"] == "1792x1024"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_uses_explicit_image_size() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.generate(
|
||||||
|
prompt="draw",
|
||||||
|
model="dall-e-3",
|
||||||
|
aspect_ratio="16:9",
|
||||||
|
image_size="1024x1024",
|
||||||
|
)
|
||||||
|
|
||||||
|
body = fake.calls[0]["json"]
|
||||||
|
assert body["size"] == "1024x1024"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_requires_api_key() -> None:
|
||||||
|
client = OpenAIImageGenerationClient(api_key=None)
|
||||||
|
|
||||||
|
with pytest.raises(ImageGenerationError, match="API key"):
|
||||||
|
await client.generate(prompt="draw", model="dall-e-3")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OpenAI Codex (Responses API)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codex_payload_and_response(monkeypatch) -> None:
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeToken:
|
||||||
|
account_id: str = "acct-123"
|
||||||
|
access: str = "oauth-token"
|
||||||
|
|
||||||
|
async def fake_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
|
||||||
|
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
|
||||||
|
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
|
||||||
|
|
||||||
|
sse_lines = [
|
||||||
|
'data: {"type":"response.output_item.added","item":{"id":"ig_1","type":"image_generation_call","status":"in_progress"}}',
|
||||||
|
"",
|
||||||
|
f'data: {{"type":"response.output_item.done","item":{{"id":"ig_1","type":"image_generation_call","result":"{PNG_DATA_URL}","status":"completed"}}}}',
|
||||||
|
"",
|
||||||
|
'data: [DONE]',
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
fake = FakeClient(FakeResponse({}, sse_lines=sse_lines))
|
||||||
|
client = CodexImageGenerationClient(
|
||||||
|
api_key=None,
|
||||||
|
api_base="https://chatgpt.com/backend-api",
|
||||||
|
extra_headers={"X-Test": "1"},
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(
|
||||||
|
prompt="draw a cat",
|
||||||
|
model="gpt-5.4",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.images == [PNG_DATA_URL]
|
||||||
|
assert response.content == ""
|
||||||
|
call = fake.calls[0]
|
||||||
|
assert call["url"] == "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
assert call["headers"]["Authorization"] == "Bearer oauth-token"
|
||||||
|
assert call["headers"]["chatgpt-account-id"] == "acct-123"
|
||||||
|
assert call["headers"]["OpenAI-Beta"] == "responses=experimental"
|
||||||
|
assert call["headers"]["X-Test"] == "1"
|
||||||
|
body = call["json"]
|
||||||
|
assert body["model"] == "gpt-5.4"
|
||||||
|
assert body["instructions"] == "Generate an image based on the user's request."
|
||||||
|
assert body["input"] == [{"role": "user", "content": "draw a cat"}]
|
||||||
|
assert body["tools"] == [{"type": "image_generation"}]
|
||||||
|
assert body["tool_choice"] == "auto"
|
||||||
|
assert body["store"] is False
|
||||||
|
assert body["stream"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codex_strips_model_prefix(monkeypatch) -> None:
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeToken:
|
||||||
|
account_id: str = "acct-123"
|
||||||
|
access: str = "oauth-token"
|
||||||
|
|
||||||
|
async def fake_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
|
||||||
|
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
|
||||||
|
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
|
||||||
|
|
||||||
|
fake = FakeClient(FakeResponse({}, sse_lines=[
|
||||||
|
f'data: {{"type":"response.output_item.done","item":{{"type":"image_generation_call","result":"{PNG_DATA_URL}"}}}}',
|
||||||
|
"",
|
||||||
|
'data: [DONE]',
|
||||||
|
"",
|
||||||
|
]))
|
||||||
|
client = CodexImageGenerationClient(
|
||||||
|
api_key=None, client=fake # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.generate(prompt="draw", model="openai-codex/gpt-5.4")
|
||||||
|
|
||||||
|
assert fake.calls[0]["json"]["model"] == "gpt-5.4"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codex_requires_oauth(monkeypatch) -> None:
|
||||||
|
async def fake_to_thread(fn, *args, **kwargs):
|
||||||
|
raise RuntimeError("no token")
|
||||||
|
|
||||||
|
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
|
||||||
|
|
||||||
|
client = CodexImageGenerationClient(api_key=None)
|
||||||
|
|
||||||
|
with pytest.raises(ImageGenerationError, match="OAuth token"):
|
||||||
|
await client.generate(prompt="draw", model="gpt-5.4")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codex_no_images_raises(monkeypatch) -> None:
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeToken:
|
||||||
|
account_id: str = "acct-123"
|
||||||
|
access: str = "oauth-token"
|
||||||
|
|
||||||
|
async def fake_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
|
||||||
|
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
|
||||||
|
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
|
||||||
|
|
||||||
|
fake = FakeClient(FakeResponse({}, sse_lines=[
|
||||||
|
'data: {"type":"response.completed","response":{"status":"completed"}}',
|
||||||
|
"",
|
||||||
|
'data: [DONE]',
|
||||||
|
"",
|
||||||
|
]))
|
||||||
|
client = CodexImageGenerationClient(
|
||||||
|
api_key=None, client=fake # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ImageGenerationError, match="returned no images"):
|
||||||
|
await client.generate(prompt="draw", model="gpt-5.4")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codex_extracts_text_content(monkeypatch) -> None:
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeToken:
|
||||||
|
account_id: str = "acct-123"
|
||||||
|
access: str = "oauth-token"
|
||||||
|
|
||||||
|
async def fake_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
|
||||||
|
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
|
||||||
|
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
|
||||||
|
|
||||||
|
fake = FakeClient(FakeResponse({}, sse_lines=[
|
||||||
|
'data: {"type":"response.output_text.delta","delta":"Here "}',
|
||||||
|
"",
|
||||||
|
'data: {"type":"response.output_text.delta","delta":"is your cat image."}',
|
||||||
|
"",
|
||||||
|
f'data: {{"type":"response.output_item.done","item":{{"type":"image_generation_call","result":"{PNG_DATA_URL}"}}}}',
|
||||||
|
"",
|
||||||
|
'data: [DONE]',
|
||||||
|
"",
|
||||||
|
]))
|
||||||
|
client = CodexImageGenerationClient(
|
||||||
|
api_key=None, client=fake # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(prompt="draw a cat", model="gpt-5.4")
|
||||||
|
|
||||||
|
assert response.images == [PNG_DATA_URL]
|
||||||
|
assert response.content == "Here is your cat image."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codex_json_result_format(monkeypatch) -> None:
|
||||||
|
"""image_generation_call result can be a dict with image_url key."""
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeToken:
|
||||||
|
account_id: str = "acct-123"
|
||||||
|
access: str = "oauth-token"
|
||||||
|
|
||||||
|
async def fake_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
|
||||||
|
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
|
||||||
|
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
|
||||||
|
|
||||||
|
fake = FakeClient(FakeResponse({}, sse_lines=[
|
||||||
|
f'data: {{"type":"response.output_item.done","item":{{"type":"image_generation_call","result":{{"image_url":"{PNG_DATA_URL}"}}}}}}',
|
||||||
|
"",
|
||||||
|
'data: [DONE]',
|
||||||
|
"",
|
||||||
|
]))
|
||||||
|
client = CodexImageGenerationClient(
|
||||||
|
api_key=None, client=fake # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(prompt="draw", model="gpt-5.4")
|
||||||
|
|
||||||
|
assert response.images == [PNG_DATA_URL]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_no_images_raises() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"data": []}))
|
||||||
|
client = OpenAIImageGenerationClient(
|
||||||
|
api_key="sk-openai-test",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ImageGenerationError, match="returned no images"):
|
||||||
|
await client.generate(prompt="draw", model="dall-e-3")
|
||||||
|
|||||||
@@ -441,6 +441,15 @@ def test_openrouter_spec_is_gateway() -> None:
|
|||||||
assert spec.default_api_base == "https://openrouter.ai/api/v1"
|
assert spec.default_api_base == "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_novita_spec_uses_openai_compatible_gateway() -> None:
|
||||||
|
spec = find_by_name("novita")
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.is_gateway is True
|
||||||
|
assert spec.backend == "openai_compat"
|
||||||
|
assert spec.env_key == "NOVITA_API_KEY"
|
||||||
|
assert spec.default_api_base == "https://api.novita.ai/openai"
|
||||||
|
|
||||||
|
|
||||||
def test_gemma_routes_to_gemini_provider() -> None:
|
def test_gemma_routes_to_gemini_provider() -> None:
|
||||||
"""gemma models (e.g. gemma-3-27b-it) must auto-route to Gemini when GEMINI_API_KEY is set.
|
"""gemma models (e.g. gemma-3-27b-it) must auto-route to Gemini when GEMINI_API_KEY is set.
|
||||||
Users running gemma via the Gemini API endpoint expect automatic provider detection."""
|
Users running gemma via the Gemini API endpoint expect automatic provider detection."""
|
||||||
@@ -449,6 +458,12 @@ def test_gemma_routes_to_gemini_provider() -> None:
|
|||||||
assert "gemma" in spec.keywords
|
assert "gemma" in spec.keywords
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemini_spec_keeps_openai_compat_base() -> None:
|
||||||
|
spec = find_by_name("gemini")
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.default_api_base == "https://generativelanguage.googleapis.com/v1beta/openai/"
|
||||||
|
|
||||||
|
|
||||||
async def test_openrouter_sets_default_attribution_headers() -> None:
|
async def test_openrouter_sets_default_attribution_headers() -> None:
|
||||||
spec = find_by_name("openrouter")
|
spec = find_by_name("openrouter")
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls:
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls:
|
||||||
@@ -1007,6 +1022,41 @@ def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -
|
|||||||
assert sanitized[2]["tool_call_id"] == "3ec83c30d"
|
assert sanitized[2]["tool_call_id"] == "3ec83c30d"
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_compat_deduplicates_duplicate_tool_call_ids_in_history() -> None:
|
||||||
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
|
provider = OpenAICompatProvider()
|
||||||
|
|
||||||
|
sanitized = provider._sanitize_messages([
|
||||||
|
{"role": "user", "content": "check both files"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "ab1b45c2a",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab1b45c2a",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_call_id": "ab1b45c2a", "name": "read_file", "content": "a"},
|
||||||
|
{"role": "tool", "tool_call_id": "ab1b45c2a", "name": "read_file", "content": "b"},
|
||||||
|
{"role": "user", "content": "continue"},
|
||||||
|
])
|
||||||
|
|
||||||
|
tool_call_ids = [tc["id"] for tc in sanitized[1]["tool_calls"]]
|
||||||
|
tool_result_ids = [sanitized[2]["tool_call_id"], sanitized[3]["tool_call_id"]]
|
||||||
|
|
||||||
|
assert tool_call_ids[0] == "ab1b45c2a"
|
||||||
|
assert len(tool_call_ids) == len(set(tool_call_ids)) == 2
|
||||||
|
assert tool_result_ids == tool_call_ids
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compat_stringifies_dict_tool_arguments() -> None:
|
def test_openai_compat_stringifies_dict_tool_arguments() -> None:
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = OpenAICompatProvider()
|
provider = OpenAICompatProvider()
|
||||||
@@ -1376,12 +1426,15 @@ def test_kimi_k25_thinking_enabled() -> None:
|
|||||||
"""kimi-k2.5 with reasoning_effort set should opt in to thinking."""
|
"""kimi-k2.5 with reasoning_effort set should opt in to thinking."""
|
||||||
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="medium")
|
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="medium")
|
||||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
||||||
|
# Moonshot rejects both 'reasoning_effort' and 'thinking' (#3939)
|
||||||
|
assert "reasoning_effort" not in kw
|
||||||
|
|
||||||
|
|
||||||
def test_kimi_k25_thinking_disabled_for_minimal() -> None:
|
def test_kimi_k25_thinking_disabled_for_minimal() -> None:
|
||||||
"""reasoning_effort='minimal' maps to thinking disabled for kimi-k2.5."""
|
"""reasoning_effort='minimal' maps to thinking disabled for kimi-k2.5."""
|
||||||
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="minimal")
|
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="minimal")
|
||||||
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
|
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
|
||||||
|
assert "reasoning_effort" not in kw
|
||||||
|
|
||||||
|
|
||||||
def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
|
def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
|
||||||
@@ -1391,21 +1444,36 @@ def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
|
def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
|
||||||
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking."""
|
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking.
|
||||||
|
|
||||||
|
OR drops upstream-provider `thinking` fields, so the same intent also has
|
||||||
|
to go through OR's `reasoning.effort` shape (#3851 follow-up).
|
||||||
|
"""
|
||||||
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium")
|
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium")
|
||||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
assert kw.get("extra_body") == {
|
||||||
|
"thinking": {"type": "enabled"},
|
||||||
|
"reasoning": {"effort": "medium"},
|
||||||
|
}
|
||||||
|
# Even via OR, reasoning_effort wire kwarg is dropped for kimi models
|
||||||
|
assert "reasoning_effort" not in kw
|
||||||
|
|
||||||
|
|
||||||
def test_kimi_k26_thinking_enabled() -> None:
|
def test_kimi_k26_thinking_enabled() -> None:
|
||||||
"""kimi-k2.6 with reasoning_effort set should opt in to thinking."""
|
"""kimi-k2.6 with reasoning_effort set should opt in to thinking."""
|
||||||
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort="medium")
|
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort="medium")
|
||||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
||||||
|
assert "reasoning_effort" not in kw
|
||||||
|
|
||||||
|
|
||||||
def test_kimi_k26_thinking_enabled_with_openrouter_prefix() -> None:
|
def test_kimi_k26_thinking_enabled_with_openrouter_prefix() -> None:
|
||||||
"""OpenRouter-style names like moonshotai/kimi-k2.6 must trigger thinking."""
|
"""OpenRouter-style names like moonshotai/kimi-k2.6 must trigger thinking
|
||||||
|
via both upstream `thinking` and OR's `reasoning.effort`."""
|
||||||
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.6", reasoning_effort="medium")
|
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.6", reasoning_effort="medium")
|
||||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
assert kw.get("extra_body") == {
|
||||||
|
"thinking": {"type": "enabled"},
|
||||||
|
"reasoning": {"effort": "medium"},
|
||||||
|
}
|
||||||
|
assert "reasoning_effort" not in kw
|
||||||
|
|
||||||
|
|
||||||
def test_moonshot_kimi_k26_temperature_override() -> None:
|
def test_moonshot_kimi_k26_temperature_override() -> None:
|
||||||
@@ -1424,6 +1492,7 @@ def test_kimi_k26_code_preview_thinking_enabled() -> None:
|
|||||||
"""k2.6-code-preview also supports thinking; should behave like k2.5."""
|
"""k2.6-code-preview also supports thinking; should behave like k2.5."""
|
||||||
kw = _build_kwargs_for("moonshot", "k2.6-code-preview", reasoning_effort="high")
|
kw = _build_kwargs_for("moonshot", "k2.6-code-preview", reasoning_effort="high")
|
||||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
||||||
|
assert "reasoning_effort" not in kw
|
||||||
|
|
||||||
|
|
||||||
def test_kimi_k2_series_no_thinking_injection() -> None:
|
def test_kimi_k2_series_no_thinking_injection() -> None:
|
||||||
@@ -1453,6 +1522,7 @@ def test_kimi_k25_thinking_disabled_for_none_string() -> None:
|
|||||||
"""reasoning_effort='none' maps to thinking disabled for kimi-k2.5."""
|
"""reasoning_effort='none' maps to thinking disabled for kimi-k2.5."""
|
||||||
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="none")
|
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="none")
|
||||||
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
|
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
|
||||||
|
assert "reasoning_effort" not in kw
|
||||||
|
|
||||||
|
|
||||||
def test_dashscope_thinking_disabled_for_none_string() -> None:
|
def test_dashscope_thinking_disabled_for_none_string() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Tests for the Novita AI provider registration."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config, ProvidersConfig
|
||||||
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
from nanobot.providers.registry import PROVIDERS, find_by_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_novita_config_field_exists() -> None:
|
||||||
|
config = ProvidersConfig()
|
||||||
|
|
||||||
|
assert hasattr(config, "novita")
|
||||||
|
|
||||||
|
|
||||||
|
def test_novita_provider_in_registry() -> None:
|
||||||
|
specs = {spec.name: spec for spec in PROVIDERS}
|
||||||
|
|
||||||
|
assert "novita" in specs
|
||||||
|
novita = specs["novita"]
|
||||||
|
assert novita.backend == "openai_compat"
|
||||||
|
assert novita.env_key == "NOVITA_API_KEY"
|
||||||
|
assert novita.display_name == "Novita AI"
|
||||||
|
assert novita.is_gateway is True
|
||||||
|
assert novita.detect_by_base_keyword == "novita"
|
||||||
|
assert novita.default_api_base == "https://api.novita.ai/openai"
|
||||||
|
assert novita.strip_model_prefix is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_by_name_novita() -> None:
|
||||||
|
spec = find_by_name("novita")
|
||||||
|
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.name == "novita"
|
||||||
|
|
||||||
|
|
||||||
|
def test_novita_forced_provider_uses_default_api_base() -> None:
|
||||||
|
config = Config.model_validate({
|
||||||
|
"providers": {
|
||||||
|
"novita": {
|
||||||
|
"apiKey": "novita-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"provider": "novita",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert config.get_provider_name("deepseek-v4-pro") == "novita"
|
||||||
|
assert config.get_api_key("deepseek-v4-pro") == "novita-key"
|
||||||
|
assert config.get_api_base("deepseek-v4-pro") == "https://api.novita.ai/openai"
|
||||||
|
|
||||||
|
|
||||||
|
def test_novita_gateway_routes_unprefixed_models_when_configured() -> None:
|
||||||
|
config = Config.model_validate({
|
||||||
|
"providers": {
|
||||||
|
"novita": {
|
||||||
|
"apiKey": "novita-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert config.get_provider_name("deepseek-v4-pro") == "novita"
|
||||||
|
assert config.get_api_key("deepseek-v4-pro") == "novita-key"
|
||||||
|
assert config.get_api_base("deepseek-v4-pro") == "https://api.novita.ai/openai"
|
||||||
|
|
||||||
|
|
||||||
|
def test_novita_preserves_model_api_id() -> None:
|
||||||
|
spec = find_by_name("novita")
|
||||||
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
|
provider = OpenAICompatProvider(
|
||||||
|
api_key="novita-key",
|
||||||
|
default_model="deepseek-v4-pro",
|
||||||
|
spec=spec,
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = provider._build_kwargs(
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
tools=None,
|
||||||
|
model="deepseek-v4-pro",
|
||||||
|
max_tokens=1024,
|
||||||
|
temperature=0.7,
|
||||||
|
reasoning_effort=None,
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert kwargs["model"] == "deepseek-v4-pro"
|
||||||
|
assert kwargs["max_tokens"] == 1024
|
||||||
|
assert "max_completion_tokens" not in kwargs
|
||||||
@@ -155,6 +155,49 @@ class TestConvertMessages:
|
|||||||
assert items[0]["id"] == "fc_1"
|
assert items[0]["id"] == "fc_1"
|
||||||
assert items[0]["name"] == "get_weather"
|
assert items[0]["name"] == "get_weather"
|
||||||
|
|
||||||
|
def test_duplicate_response_item_ids_are_made_unique(self):
|
||||||
|
"""Codex rejects replayed Responses input items with duplicate ids."""
|
||||||
|
_, items = convert_messages([
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": "call_a|rs_same",
|
||||||
|
"function": {"name": "first", "arguments": "{}"},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_call_id": "call_a|rs_same", "content": "ok"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": "call_b|rs_same",
|
||||||
|
"function": {"name": "second", "arguments": "{}"},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_call_id": "call_b|rs_same", "content": "ok"},
|
||||||
|
])
|
||||||
|
function_call_ids = [
|
||||||
|
item["id"] for item in items if item.get("type") == "function_call"
|
||||||
|
]
|
||||||
|
assert function_call_ids == ["rs_same", "rs_same_2"]
|
||||||
|
assert len(function_call_ids) == len(set(function_call_ids))
|
||||||
|
|
||||||
|
def test_fallback_response_item_ids_are_unique_with_multiple_tool_calls(self):
|
||||||
|
_, items = convert_messages([{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [
|
||||||
|
{"id": "call_a", "function": {"name": "first", "arguments": "{}"}},
|
||||||
|
{"id": "call_b", "function": {"name": "second", "arguments": "{}"}},
|
||||||
|
],
|
||||||
|
}])
|
||||||
|
function_call_ids = [
|
||||||
|
item["id"] for item in items if item.get("type") == "function_call"
|
||||||
|
]
|
||||||
|
assert function_call_ids == ["fc_0", "fc_0_2"]
|
||||||
|
assert len(function_call_ids) == len(set(function_call_ids))
|
||||||
|
|
||||||
def test_assistant_with_tool_calls_no_id(self):
|
def test_assistant_with_tool_calls_no_id(self):
|
||||||
"""Fallback IDs when tool_call.id is missing."""
|
"""Fallback IDs when tool_call.id is missing."""
|
||||||
_, items = convert_messages([{
|
_, items = convert_messages([{
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def _mimo_spec():
|
|||||||
|
|
||||||
|
|
||||||
def _openrouter_spec():
|
def _openrouter_spec():
|
||||||
"""Return the registered OpenRouter ProviderSpec (no thinking_style)."""
|
"""Return the registered OpenRouter ProviderSpec."""
|
||||||
specs = {s.name: s for s in PROVIDERS}
|
specs = {s.name: s for s in PROVIDERS}
|
||||||
return specs["openrouter"]
|
return specs["openrouter"]
|
||||||
|
|
||||||
@@ -77,6 +77,13 @@ def test_xiaomi_mimo_uses_thinking_type_style():
|
|||||||
assert spec.default_api_base == "https://api.xiaomimimo.com/v1"
|
assert spec.default_api_base == "https://api.xiaomimimo.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_openrouter_declares_gateway_reasoning_style():
|
||||||
|
"""OpenRouter uses its own reasoning.effort field for routed thinking models."""
|
||||||
|
spec = _openrouter_spec()
|
||||||
|
assert spec.thinking_style == ""
|
||||||
|
assert spec.gateway_reasoning_style == "reasoning_effort"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _build_kwargs wire-format
|
# _build_kwargs wire-format
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -142,9 +149,11 @@ def test_mimo_reasoning_effort_unset_preserves_provider_default():
|
|||||||
|
|
||||||
|
|
||||||
def test_mimo_via_openrouter_reasoning_effort_none_disables_thinking():
|
def test_mimo_via_openrouter_reasoning_effort_none_disables_thinking():
|
||||||
"""OpenRouter routes MiMo as "xiaomi/mimo-v2.5-pro"; the openrouter spec
|
"""OpenRouter routes MiMo as "xiaomi/mimo-v2.5-pro" and does NOT forward
|
||||||
has no thinking_style, so the disable signal must come from the
|
extra_body.thinking to upstream, so a disable signal must also reach OR
|
||||||
model-name path (#3845)."""
|
in its own `reasoning.effort` shape. Verifies both the upstream-MiMo
|
||||||
|
payload (#3845) and the OR-native payload (#3851 follow-up) are sent.
|
||||||
|
"""
|
||||||
provider = _openrouter_provider("xiaomi/mimo-v2.5-pro")
|
provider = _openrouter_provider("xiaomi/mimo-v2.5-pro")
|
||||||
kwargs = provider._build_kwargs(
|
kwargs = provider._build_kwargs(
|
||||||
messages=_simple_messages(),
|
messages=_simple_messages(),
|
||||||
@@ -152,11 +161,15 @@ def test_mimo_via_openrouter_reasoning_effort_none_disables_thinking():
|
|||||||
temperature=0.7, reasoning_effort="none", tool_choice=None,
|
temperature=0.7, reasoning_effort="none", tool_choice=None,
|
||||||
)
|
)
|
||||||
assert "reasoning_effort" not in kwargs
|
assert "reasoning_effort" not in kwargs
|
||||||
assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
|
assert kwargs["extra_body"] == {
|
||||||
|
"thinking": {"type": "disabled"},
|
||||||
|
"reasoning": {"effort": "none"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mimo_via_openrouter_reasoning_effort_medium_enables_thinking():
|
def test_mimo_via_openrouter_reasoning_effort_medium_enables_thinking():
|
||||||
"""Same as the direct path: any non-none/minimal effort enables thinking."""
|
"""Non-none/minimal effort enables thinking and the OR `reasoning.effort`
|
||||||
|
field mirrors the requested effort level."""
|
||||||
provider = _openrouter_provider("xiaomi/mimo-v2.5-pro")
|
provider = _openrouter_provider("xiaomi/mimo-v2.5-pro")
|
||||||
kwargs = provider._build_kwargs(
|
kwargs = provider._build_kwargs(
|
||||||
messages=_simple_messages(),
|
messages=_simple_messages(),
|
||||||
@@ -164,7 +177,10 @@ def test_mimo_via_openrouter_reasoning_effort_medium_enables_thinking():
|
|||||||
temperature=0.7, reasoning_effort="medium", tool_choice=None,
|
temperature=0.7, reasoning_effort="medium", tool_choice=None,
|
||||||
)
|
)
|
||||||
assert kwargs.get("reasoning_effort") == "medium"
|
assert kwargs.get("reasoning_effort") == "medium"
|
||||||
assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}}
|
assert kwargs["extra_body"] == {
|
||||||
|
"thinking": {"type": "enabled"},
|
||||||
|
"reasoning": {"effort": "medium"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mimo_via_openrouter_bare_slug_also_matches():
|
def test_mimo_via_openrouter_bare_slug_also_matches():
|
||||||
@@ -176,12 +192,16 @@ def test_mimo_via_openrouter_bare_slug_also_matches():
|
|||||||
tools=None, model=None, max_tokens=100,
|
tools=None, model=None, max_tokens=100,
|
||||||
temperature=0.7, reasoning_effort="none", tool_choice=None,
|
temperature=0.7, reasoning_effort="none", tool_choice=None,
|
||||||
)
|
)
|
||||||
assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
|
assert kwargs["extra_body"] == {
|
||||||
|
"thinking": {"type": "disabled"},
|
||||||
|
"reasoning": {"effort": "none"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mimo_flash_via_openrouter_does_not_inject_thinking():
|
def test_mimo_flash_via_openrouter_does_not_inject_thinking():
|
||||||
"""mimo-v2-flash has no thinking mode per Xiaomi docs; the allowlist
|
"""mimo-v2-flash has no thinking mode per Xiaomi docs; the allowlist
|
||||||
excludes it, so no thinking field should be injected on the gateway path."""
|
excludes it, so neither the upstream `thinking` field nor OR's
|
||||||
|
`reasoning.effort` should be injected on the gateway path."""
|
||||||
provider = _openrouter_provider("xiaomi/mimo-v2-flash")
|
provider = _openrouter_provider("xiaomi/mimo-v2-flash")
|
||||||
kwargs = provider._build_kwargs(
|
kwargs = provider._build_kwargs(
|
||||||
messages=_simple_messages(),
|
messages=_simple_messages(),
|
||||||
@@ -200,3 +220,18 @@ def test_non_mimo_model_via_openrouter_unaffected():
|
|||||||
temperature=0.7, reasoning_effort="none", tool_choice=None,
|
temperature=0.7, reasoning_effort="none", tool_choice=None,
|
||||||
)
|
)
|
||||||
assert "extra_body" not in kwargs
|
assert "extra_body" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_via_openrouter_also_injects_reasoning_effort():
|
||||||
|
"""Kimi has the same gateway problem as MiMo: OR drops the upstream
|
||||||
|
`thinking` field. The same OR-reasoning injection should fire."""
|
||||||
|
provider = _openrouter_provider("moonshotai/kimi-k2.5")
|
||||||
|
kwargs = provider._build_kwargs(
|
||||||
|
messages=_simple_messages(),
|
||||||
|
tools=None, model=None, max_tokens=100,
|
||||||
|
temperature=0.7, reasoning_effort="none", tool_choice=None,
|
||||||
|
)
|
||||||
|
assert kwargs["extra_body"] == {
|
||||||
|
"thinking": {"type": "disabled"},
|
||||||
|
"reasoning": {"effort": "none"},
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from nanobot.agent.tools.apply_patch import ApplyPatchTool
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_replace(tmp_path):
|
||||||
|
target = tmp_path / "calc.py"
|
||||||
|
target.write_text("def add(a, b):\n return a + b\n")
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "calc.py",
|
||||||
|
"action": "replace",
|
||||||
|
"old_text": " return a + b",
|
||||||
|
"new_text": " return a - b",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "update calc.py" in result
|
||||||
|
assert target.read_text() == "def add(a, b):\n return a - b\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_add_new_file(tmp_path):
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "config.py",
|
||||||
|
"action": "add",
|
||||||
|
"new_text": "DEBUG = True",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "add config.py" in result
|
||||||
|
assert (tmp_path / "config.py").read_text() == "DEBUG = True\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_preserves_new_file_trailing_blank_lines(tmp_path):
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "notes.txt",
|
||||||
|
"action": "add",
|
||||||
|
"new_text": "one\n\n",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "add notes.txt" in result
|
||||||
|
assert (tmp_path / "notes.txt").read_text() == "one\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_add_to_existing_file(tmp_path):
|
||||||
|
target = tmp_path / "log.py"
|
||||||
|
target.write_text("import logging\n\nlogger = logging.getLogger(__name__)\n")
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "log.py",
|
||||||
|
"action": "add",
|
||||||
|
"new_text": "def debug(msg):\n logger.debug(msg)",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "update log.py" in result
|
||||||
|
assert (
|
||||||
|
target.read_text()
|
||||||
|
== "import logging\n\nlogger = logging.getLogger(__name__)\ndef debug(msg):\n logger.debug(msg)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_delete(tmp_path):
|
||||||
|
target = tmp_path / "utils.py"
|
||||||
|
target.write_text("def unused():\n pass\ndef used():\n return 1\n")
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "utils.py",
|
||||||
|
"action": "delete",
|
||||||
|
"old_text": "def unused():\n pass\n",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "update utils.py" in result
|
||||||
|
assert target.read_text() == "def used():\n return 1\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_delete_entire_file(tmp_path):
|
||||||
|
target = tmp_path / "obsolete.txt"
|
||||||
|
target.write_text("remove me\n")
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "obsolete.txt",
|
||||||
|
"action": "delete",
|
||||||
|
"old_text": "remove me\n",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "delete obsolete.txt" in result
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_delete_substring_with_surrounding_whitespace(tmp_path):
|
||||||
|
target = tmp_path / "keep_whitespace.txt"
|
||||||
|
target.write_text(" token \n")
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "keep_whitespace.txt",
|
||||||
|
"action": "delete",
|
||||||
|
"old_text": "token",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "update keep_whitespace.txt" in result
|
||||||
|
assert target.exists()
|
||||||
|
assert target.read_text() == " \n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_batch_multiple_files(tmp_path):
|
||||||
|
a = tmp_path / "a.py"
|
||||||
|
a.write_text("X = 1\n")
|
||||||
|
b = tmp_path / "b.py"
|
||||||
|
b.write_text("from a import X\nprint(X)\n")
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "a.py",
|
||||||
|
"action": "replace",
|
||||||
|
"old_text": "X = 1",
|
||||||
|
"new_text": "Y = 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "b.py",
|
||||||
|
"action": "replace",
|
||||||
|
"old_text": "from a import X",
|
||||||
|
"new_text": "from a import Y",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "update a.py" in result
|
||||||
|
assert "update b.py" in result
|
||||||
|
assert a.read_text() == "Y = 1\n"
|
||||||
|
assert b.read_text() == "from a import Y\nprint(X)\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_rejects_ambiguous_old_text(tmp_path):
|
||||||
|
target = tmp_path / "repeated.txt"
|
||||||
|
target.write_text("target\nmiddle\ntarget\n")
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "repeated.txt",
|
||||||
|
"action": "replace",
|
||||||
|
"old_text": "target",
|
||||||
|
"new_text": "changed",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "old_text appears multiple times" in result
|
||||||
|
assert target.read_text() == "target\nmiddle\ntarget\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_dry_run_validates_without_writing(tmp_path):
|
||||||
|
target = tmp_path / "dry.txt"
|
||||||
|
target.write_text("before\n")
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "dry.txt",
|
||||||
|
"action": "replace",
|
||||||
|
"old_text": "before",
|
||||||
|
"new_text": "after",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "added.txt",
|
||||||
|
"action": "add",
|
||||||
|
"new_text": "new",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Patch dry-run succeeded" in result
|
||||||
|
assert target.read_text() == "before\n"
|
||||||
|
assert not (tmp_path / "added.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_rejects_absolute_and_parent_paths(tmp_path):
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
absolute = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "/tmp/owned.txt",
|
||||||
|
"action": "add",
|
||||||
|
"new_text": "nope",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parent = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "../owned.txt",
|
||||||
|
"action": "add",
|
||||||
|
"new_text": "nope",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
windows_absolute = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": r"C:\owned.txt",
|
||||||
|
"action": "add",
|
||||||
|
"new_text": "nope",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
windows_parent = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": r"..\owned.txt",
|
||||||
|
"action": "add",
|
||||||
|
"new_text": "nope",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "must be relative" in absolute
|
||||||
|
assert "must not contain '..'" in parent
|
||||||
|
assert "must be relative" in windows_absolute
|
||||||
|
assert "must not contain '..'" in windows_parent
|
||||||
|
assert not (tmp_path.parent / "owned.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_reports_invalid_edit_shapes(tmp_path):
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
missing_path = asyncio.run(tool.execute(edits=[{"action": "add", "new_text": "x"}]))
|
||||||
|
missing_action = asyncio.run(tool.execute(edits=[{"path": "x.txt", "new_text": "x"}]))
|
||||||
|
non_object = asyncio.run(tool.execute(edits=["not an object"])) # type: ignore[list-item]
|
||||||
|
|
||||||
|
assert "path required for edit" in missing_path
|
||||||
|
assert "action required for edit: x.txt" in missing_action
|
||||||
|
assert "each edit must be an object" in non_object
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_edits_rolls_back_when_late_operation_fails(tmp_path):
|
||||||
|
first = tmp_path / "first.txt"
|
||||||
|
first.write_text("before\n")
|
||||||
|
tool = ApplyPatchTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.execute(
|
||||||
|
edits=[
|
||||||
|
{
|
||||||
|
"path": "first.txt",
|
||||||
|
"action": "replace",
|
||||||
|
"old_text": "before",
|
||||||
|
"new_text": "after",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "missing.txt",
|
||||||
|
"action": "delete",
|
||||||
|
"old_text": "remove me",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "file to update does not exist: missing.txt" in result
|
||||||
|
assert first.read_text() == "before\n"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Tests for EditFileTool enhancements: read-before-edit tracking, path suggestions,
|
"""Tests for EditFileTool enhancements: read-before-edit tracking, path suggestions,
|
||||||
.ipynb detection, and create-file semantics."""
|
notebook JSON editing, and create-file semantics."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -108,22 +108,27 @@ class TestEditCreateFile:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# .ipynb detection
|
# .ipynb editing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestEditIpynbDetection:
|
class TestEditIpynbFiles:
|
||||||
"""edit_file should refuse .ipynb and suggest notebook_edit."""
|
"""edit_file edits notebooks as normal JSON files."""
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def tool(self, tmp_path):
|
def tool(self, tmp_path):
|
||||||
return EditFileTool(workspace=tmp_path)
|
return EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ipynb_rejected_with_suggestion(self, tool, tmp_path):
|
async def test_ipynb_can_be_edited_as_json(self, tool, tmp_path):
|
||||||
f = tmp_path / "analysis.ipynb"
|
f = tmp_path / "analysis.ipynb"
|
||||||
f.write_text('{"cells": []}', encoding="utf-8")
|
f.write_text('{"cells": []}', encoding="utf-8")
|
||||||
result = await tool.execute(path=str(f), old_text="x", new_text="y")
|
result = await tool.execute(
|
||||||
assert "notebook" in result.lower()
|
path=str(f),
|
||||||
|
old_text='"cells": []',
|
||||||
|
new_text='"cells": [{"cell_type": "markdown", "source": "hi"}]',
|
||||||
|
)
|
||||||
|
assert "Successfully edited" in result
|
||||||
|
assert '"source": "hi"' in f.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ class TestPathAppendPlatform:
|
|||||||
captured_cmd = None
|
captured_cmd = None
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(cmd, cwd, env):
|
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
|
||||||
nonlocal captured_cmd
|
nonlocal captured_cmd
|
||||||
captured_cmd = cmd
|
captured_cmd = cmd
|
||||||
captured_env.update(env)
|
captured_env.update(env)
|
||||||
@@ -190,7 +190,7 @@ class TestPathAppendPlatform:
|
|||||||
|
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(cmd, cwd, env):
|
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
|
||||||
captured_env.update(env)
|
captured_env.update(env)
|
||||||
return mock_proc
|
return mock_proc
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
|
from nanobot.agent.tools.exec_session import ExecSessionManager, ListExecSessionsTool, WriteStdinTool
|
||||||
|
|
||||||
|
|
||||||
|
def _python_command(code: str) -> str:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return f"{subprocess.list2cmdline([sys.executable])} -u -c {subprocess.list2cmdline([code])}"
|
||||||
|
return f"{shlex.quote(sys.executable)} -u -c {shlex.quote(code)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _session_id(output: str) -> str:
|
||||||
|
match = re.search(r"session_id:\s*([0-9a-f]+)", output)
|
||||||
|
assert match, output
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_keeps_one_shot_behavior_without_yield_time_ms(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
|
return await tool.execute(command="echo hello")
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "hello" in result
|
||||||
|
assert "Exit code: 0" in result
|
||||||
|
assert "session_id:" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_accepts_command_aliases(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
tool = ExecTool(working_dir="/")
|
||||||
|
return await tool.execute(
|
||||||
|
cmd=_python_command("import os; print(os.getcwd())"),
|
||||||
|
workdir=str(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert str(tmp_path) in result
|
||||||
|
assert "Exit code: 0" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_returns_completed_session_output_when_yield_time_ms_is_used(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
stdin_tool = WriteStdinTool(manager=manager)
|
||||||
|
|
||||||
|
result = await tool.execute(command="echo hello", yield_time_ms=1000)
|
||||||
|
if "session_id:" in result:
|
||||||
|
sid = _session_id(result)
|
||||||
|
result += "\n" + await stdin_tool.execute(
|
||||||
|
session_id=sid,
|
||||||
|
chars="",
|
||||||
|
yield_time_ms=1000,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "hello" in result
|
||||||
|
assert "Exit code: 0" in result
|
||||||
|
assert "session_id:" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_session_accepts_max_output_tokens_alias(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
command = _python_command("print('A' * 2000)")
|
||||||
|
return await tool.execute(
|
||||||
|
command=command,
|
||||||
|
yield_time_ms=1000,
|
||||||
|
max_output_tokens=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "chars truncated" in result
|
||||||
|
assert "Exit code: 0" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
|
command = _python_command("print('A' * 2000)")
|
||||||
|
return await tool.execute(command=command, max_output_tokens=1000)
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "chars truncated" in result
|
||||||
|
assert "Exit code: 0" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_accepts_supported_shell_parameter(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
|
return await tool.execute(command="echo shell-ok", shell="sh", login=False)
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "shell-ok" in result
|
||||||
|
assert "Exit code: 0" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_rejects_unsupported_shell(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
|
return await tool.execute(command="echo no", shell="python")
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "unsupported shell" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_can_continue_with_stdin(tmp_path):
|
||||||
|
async def run() -> tuple[str, str]:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
stdin_tool = WriteStdinTool(manager=manager)
|
||||||
|
command = _python_command(
|
||||||
|
"import sys; print('ready', flush=True); "
|
||||||
|
"line=sys.stdin.readline(); print('got:' + line.strip(), flush=True)"
|
||||||
|
)
|
||||||
|
|
||||||
|
initial = await exec_tool.execute(command=command, yield_time_ms=500)
|
||||||
|
sid = _session_id(initial)
|
||||||
|
result = await stdin_tool.execute(session_id=sid, chars="ping\n", yield_time_ms=1000)
|
||||||
|
return initial, result
|
||||||
|
|
||||||
|
initial, result = asyncio.run(run())
|
||||||
|
assert "ready" in initial
|
||||||
|
assert "Process running" in initial
|
||||||
|
assert "Elapsed:" in initial
|
||||||
|
assert "got:ping" in result
|
||||||
|
assert "Exit code: 0" in result
|
||||||
|
assert "Elapsed:" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_stdin_can_close_stdin(tmp_path):
|
||||||
|
async def run() -> tuple[str, str]:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
stdin_tool = WriteStdinTool(manager=manager)
|
||||||
|
command = _python_command(
|
||||||
|
"import sys; print('ready', flush=True); "
|
||||||
|
"data=sys.stdin.read(); print('got:' + data, flush=True)"
|
||||||
|
)
|
||||||
|
|
||||||
|
initial = await exec_tool.execute(command=command, yield_time_ms=500)
|
||||||
|
sid = _session_id(initial)
|
||||||
|
result = await stdin_tool.execute(
|
||||||
|
session_id=sid,
|
||||||
|
chars="payload",
|
||||||
|
close_stdin=True,
|
||||||
|
yield_time_ms=1000,
|
||||||
|
)
|
||||||
|
return initial, result
|
||||||
|
|
||||||
|
initial, result = asyncio.run(run())
|
||||||
|
assert "ready" in initial
|
||||||
|
assert "got:payload" in result
|
||||||
|
assert "Stdin closed." in result
|
||||||
|
assert "Exit code: 0" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_stdin_can_terminate_session(tmp_path):
|
||||||
|
async def run() -> tuple[str, str]:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
|
||||||
|
stdin_tool = WriteStdinTool(manager=manager)
|
||||||
|
command = _python_command(
|
||||||
|
"import time; print('ready', flush=True); time.sleep(30)"
|
||||||
|
)
|
||||||
|
|
||||||
|
initial = await exec_tool.execute(command=command, yield_time_ms=500)
|
||||||
|
sid = _session_id(initial)
|
||||||
|
result = await stdin_tool.execute(
|
||||||
|
session_id=sid,
|
||||||
|
terminate=True,
|
||||||
|
yield_time_ms=0,
|
||||||
|
)
|
||||||
|
return initial, result
|
||||||
|
|
||||||
|
initial, result = asyncio.run(run())
|
||||||
|
assert "ready" in initial
|
||||||
|
assert "Session terminated." in result
|
||||||
|
assert "Exit code:" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_stdin_accepts_max_output_tokens_alias(tmp_path):
|
||||||
|
async def run() -> tuple[str, str, str]:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
stdin_tool = WriteStdinTool(manager=manager)
|
||||||
|
command = _python_command(
|
||||||
|
"import time; print('A' * 2000, flush=True); time.sleep(5)"
|
||||||
|
)
|
||||||
|
|
||||||
|
initial = await exec_tool.execute(command=command, yield_time_ms=0)
|
||||||
|
sid = _session_id(initial)
|
||||||
|
poll = await stdin_tool.execute(
|
||||||
|
session_id=sid,
|
||||||
|
yield_time_ms=500,
|
||||||
|
max_output_tokens=1000,
|
||||||
|
)
|
||||||
|
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
|
||||||
|
return initial, poll, cleanup
|
||||||
|
|
||||||
|
initial, poll, cleanup = asyncio.run(run())
|
||||||
|
assert "Process running" in initial
|
||||||
|
assert "chars truncated" in poll
|
||||||
|
assert "Session terminated." in cleanup
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
|
||||||
|
async def run() -> tuple[str, str]:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
stdin_tool = WriteStdinTool(manager=manager)
|
||||||
|
command = _python_command(
|
||||||
|
"import time; print('ready', flush=True); "
|
||||||
|
"time.sleep(1.0); print('done', flush=True)"
|
||||||
|
)
|
||||||
|
|
||||||
|
initial = await exec_tool.execute(command=command, yield_time_ms=300)
|
||||||
|
sid = _session_id(initial)
|
||||||
|
await asyncio.sleep(1.2)
|
||||||
|
final = await stdin_tool.execute(session_id=sid, chars="", yield_time_ms=0)
|
||||||
|
return initial, final
|
||||||
|
|
||||||
|
initial, final = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "ready" in initial
|
||||||
|
assert "done" in final
|
||||||
|
assert "Exit code: 0" in final
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_stdin_can_wait_for_expected_output(tmp_path):
|
||||||
|
async def run() -> tuple[str, str, str]:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
stdin_tool = WriteStdinTool(manager=manager)
|
||||||
|
command = _python_command(
|
||||||
|
"import time; print('booting', flush=True); "
|
||||||
|
"time.sleep(0.4); print('ready', flush=True); time.sleep(5)"
|
||||||
|
)
|
||||||
|
|
||||||
|
initial = await exec_tool.execute(command=command, yield_time_ms=100)
|
||||||
|
sid = _session_id(initial)
|
||||||
|
waited = await stdin_tool.execute(
|
||||||
|
session_id=sid,
|
||||||
|
wait_for="ready",
|
||||||
|
wait_timeout_ms=3000,
|
||||||
|
yield_time_ms=0,
|
||||||
|
)
|
||||||
|
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
|
||||||
|
return initial, waited, cleanup
|
||||||
|
|
||||||
|
initial, waited, cleanup = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "Process running" in initial
|
||||||
|
assert "booting" in initial + waited
|
||||||
|
assert "ready" in waited
|
||||||
|
assert "Wait target not observed" not in waited
|
||||||
|
assert "Session terminated." in cleanup
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path):
|
||||||
|
async def run() -> tuple[str, str, str]:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
stdin_tool = WriteStdinTool(manager=manager)
|
||||||
|
command = _python_command(
|
||||||
|
"import time; print('booting', flush=True); time.sleep(5)"
|
||||||
|
)
|
||||||
|
|
||||||
|
initial = await exec_tool.execute(command=command, yield_time_ms=100)
|
||||||
|
sid = _session_id(initial)
|
||||||
|
waited = await stdin_tool.execute(
|
||||||
|
session_id=sid,
|
||||||
|
wait_for="never-ready",
|
||||||
|
wait_timeout_ms=200,
|
||||||
|
yield_time_ms=0,
|
||||||
|
)
|
||||||
|
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
|
||||||
|
return initial, waited, cleanup
|
||||||
|
|
||||||
|
initial, waited, cleanup = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "Process running" in initial
|
||||||
|
assert "booting" in initial + waited
|
||||||
|
assert "Process running" in waited
|
||||||
|
assert "Wait target not observed: 'never-ready'" in waited
|
||||||
|
assert "Session terminated." in cleanup
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_session_mode_reuses_exec_safety_guard(tmp_path):
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
tool = ExecTool(
|
||||||
|
working_dir=str(tmp_path),
|
||||||
|
deny_patterns=[r"echo\s+blocked"],
|
||||||
|
session_manager=manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(command="echo blocked", yield_time_ms=0))
|
||||||
|
|
||||||
|
assert "blocked by deny pattern" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_stdin_reports_missing_session(tmp_path):
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
tool = WriteStdinTool(manager=manager)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(session_id="missing", chars=""))
|
||||||
|
|
||||||
|
assert "exec session not found" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_exec_sessions_reports_running_commands(tmp_path):
|
||||||
|
async def run() -> tuple[str, str, str]:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
list_tool = ListExecSessionsTool(manager=manager)
|
||||||
|
stdin_tool = WriteStdinTool(manager=manager)
|
||||||
|
command = _python_command(
|
||||||
|
"import time; print('ready', flush=True); time.sleep(5)"
|
||||||
|
)
|
||||||
|
|
||||||
|
initial = await exec_tool.execute(command=command, yield_time_ms=500)
|
||||||
|
sid = _session_id(initial)
|
||||||
|
listing = await list_tool.execute()
|
||||||
|
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
|
||||||
|
return sid, listing, cleanup
|
||||||
|
|
||||||
|
sid, listing, cleanup = asyncio.run(run())
|
||||||
|
|
||||||
|
assert sid in listing
|
||||||
|
assert "running" in listing
|
||||||
|
assert "elapsed=" in listing
|
||||||
|
assert "remaining=" in listing
|
||||||
|
assert str(tmp_path) in listing
|
||||||
|
assert "Session terminated." in cleanup
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_exec_sessions_reports_empty_state():
|
||||||
|
result = asyncio.run(ListExecSessionsTool(manager=ExecSessionManager()).execute())
|
||||||
|
|
||||||
|
assert result == "No active exec sessions."
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_force_bypasses_dedup(tmp_path):
|
||||||
|
target = tmp_path / "data.txt"
|
||||||
|
target.write_text("alpha\n")
|
||||||
|
tool = ReadFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
first = asyncio.run(tool.execute(path=str(target)))
|
||||||
|
second = asyncio.run(tool.execute(path=str(target)))
|
||||||
|
forced = asyncio.run(tool.execute(path=str(target), force=True))
|
||||||
|
|
||||||
|
assert "alpha" in first
|
||||||
|
assert "unchanged" in second.lower()
|
||||||
|
assert "alpha" in forced
|
||||||
|
assert "unchanged" not in forced.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_can_select_occurrence(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("one\nsame\ntwo\nsame\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
occurrence=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "Successfully edited" in result
|
||||||
|
assert target.read_text() == "one\nsame\ntwo\nchanged\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_expected_replacements_guards_replace_all(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("same\nsame\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
replace_all=True,
|
||||||
|
expected_replacements=1,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "expected 1 replacements but would make 2" in result
|
||||||
|
assert target.read_text() == "same\nsame\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_expected_replacements_allows_replace_all_when_count_matches(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("same\nsame\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
replace_all=True,
|
||||||
|
expected_replacements=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "Successfully edited" in result
|
||||||
|
assert target.read_text() == "changed\nchanged\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_can_select_nearest_line_hint(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("one\nsame\ntwo\nsame\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
line_hint=4,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "Successfully edited" in result
|
||||||
|
assert target.read_text() == "one\nsame\ntwo\nchanged\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_can_edit_ipynb_as_json(tmp_path):
|
||||||
|
target = tmp_path / "analysis.ipynb"
|
||||||
|
target.write_text('{"cells": []}')
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text='"cells": []',
|
||||||
|
new_text='"cells": [{"cell_type": "markdown", "source": "hi"}]',
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "Successfully edited" in result
|
||||||
|
assert '"source": "hi"' in target.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_multiple_match_hint_mentions_occurrence(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("same\nsame\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "old_text appears 2 times" in result
|
||||||
|
assert "occurrence" in result
|
||||||
|
assert target.read_text() == "same\nsame\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_rejects_ambiguous_line_hint(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("same\nmiddle\nsame\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
line_hint=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "line_hint 2 is ambiguous" in result
|
||||||
|
assert target.read_text() == "same\nmiddle\nsame\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_rejects_occurrence_with_replace_all(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("same\nsame\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
occurrence=1,
|
||||||
|
replace_all=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "occurrence cannot be used with replace_all" in result
|
||||||
|
assert target.read_text() == "same\nsame\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_rejects_line_hint_with_replace_all(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("same\nsame\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
line_hint=1,
|
||||||
|
replace_all=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "line_hint cannot be used with replace_all" in result
|
||||||
|
assert target.read_text() == "same\nsame\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_rejects_line_hint_with_occurrence(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("same\nsame\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
occurrence=1,
|
||||||
|
line_hint=1,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "line_hint cannot be used with occurrence" in result
|
||||||
|
assert target.read_text() == "same\nsame\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_rejects_zero_occurrence(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("same\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
occurrence=0,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "occurrence must be >= 1" in result
|
||||||
|
assert target.read_text() == "same\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_rejects_zero_line_hint(tmp_path):
|
||||||
|
target = tmp_path / "duplicate.txt"
|
||||||
|
target.write_text("same\n")
|
||||||
|
tool = EditFileTool(workspace=tmp_path)
|
||||||
|
|
||||||
|
result = asyncio.run(tool.execute(
|
||||||
|
path=str(target),
|
||||||
|
old_text="same",
|
||||||
|
new_text="changed",
|
||||||
|
line_hint=0,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert "line_hint must be >= 1" in result
|
||||||
|
assert target.read_text() == "same\n"
|
||||||
@@ -138,6 +138,39 @@ async def test_generate_image_tool_reports_missing_aihubmix_key(tmp_path: Path)
|
|||||||
assert result.startswith("Error: AIHubMix API key is not configured")
|
assert result.startswith("Error: AIHubMix API key is not configured")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_image_tool_allows_ollama_without_api_key(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
set_config_path(tmp_path / "config.json")
|
||||||
|
FakeImageClient.instances = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.agent.tools.image_generation.get_image_gen_provider",
|
||||||
|
lambda name: FakeImageClient if name == "ollama" else None,
|
||||||
|
)
|
||||||
|
tool = ImageGenerationTool(
|
||||||
|
workspace=tmp_path,
|
||||||
|
config=ImageGenerationToolConfig(
|
||||||
|
enabled=True,
|
||||||
|
provider="ollama",
|
||||||
|
model="x/z-image-turbo",
|
||||||
|
),
|
||||||
|
provider_configs={"ollama": ProviderConfig(api_base="http://localhost:11434/v1")},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await tool.execute(prompt="draw a cat")
|
||||||
|
|
||||||
|
payload = json.loads(result)
|
||||||
|
assert len(payload["artifacts"]) == 1
|
||||||
|
|
||||||
|
fake = FakeImageClient.instances[0]
|
||||||
|
assert fake.kwargs["api_key"] is None
|
||||||
|
assert fake.kwargs["api_base"] == "http://localhost:11434/v1"
|
||||||
|
assert fake.calls[0]["aspect_ratio"] == "1:1"
|
||||||
|
assert fake.calls[0]["image_size"] == "1K"
|
||||||
|
|
||||||
|
|
||||||
@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")
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
"""Tests for NotebookEditTool — Jupyter .ipynb editing."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.tools.notebook import NotebookEditTool
|
|
||||||
|
|
||||||
|
|
||||||
def _make_notebook(cells: list[dict] | None = None, nbformat: int = 4, nbformat_minor: int = 5) -> dict:
|
|
||||||
"""Build a minimal valid .ipynb structure."""
|
|
||||||
return {
|
|
||||||
"nbformat": nbformat,
|
|
||||||
"nbformat_minor": nbformat_minor,
|
|
||||||
"metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}},
|
|
||||||
"cells": cells or [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _code_cell(source: str, cell_id: str | None = None) -> dict:
|
|
||||||
cell = {"cell_type": "code", "source": source, "metadata": {}, "outputs": [], "execution_count": None}
|
|
||||||
if cell_id:
|
|
||||||
cell["id"] = cell_id
|
|
||||||
return cell
|
|
||||||
|
|
||||||
|
|
||||||
def _md_cell(source: str, cell_id: str | None = None) -> dict:
|
|
||||||
cell = {"cell_type": "markdown", "source": source, "metadata": {}}
|
|
||||||
if cell_id:
|
|
||||||
cell["id"] = cell_id
|
|
||||||
return cell
|
|
||||||
|
|
||||||
|
|
||||||
def _write_nb(tmp_path, name: str, nb: dict) -> str:
|
|
||||||
p = tmp_path / name
|
|
||||||
p.write_text(json.dumps(nb), encoding="utf-8")
|
|
||||||
return str(p)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNotebookEdit:
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return NotebookEditTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_replace_cell_content(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("print('hello')"), _code_cell("x = 1")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="print('world')")
|
|
||||||
assert "Successfully" in result
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert saved["cells"][0]["source"] == "print('world')"
|
|
||||||
assert saved["cells"][1]["source"] == "x = 1"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_insert_cell_after_target(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("cell 0"), _code_cell("cell 1")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="inserted", edit_mode="insert")
|
|
||||||
assert "Successfully" in result
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert len(saved["cells"]) == 3
|
|
||||||
assert saved["cells"][0]["source"] == "cell 0"
|
|
||||||
assert saved["cells"][1]["source"] == "inserted"
|
|
||||||
assert saved["cells"][2]["source"] == "cell 1"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_cell(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("A"), _code_cell("B"), _code_cell("C")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=1, edit_mode="delete")
|
|
||||||
assert "Successfully" in result
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert len(saved["cells"]) == 2
|
|
||||||
assert saved["cells"][0]["source"] == "A"
|
|
||||||
assert saved["cells"][1]["source"] == "C"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_new_notebook_from_scratch(self, tool, tmp_path):
|
|
||||||
path = str(tmp_path / "new.ipynb")
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="# Hello", edit_mode="insert", cell_type="markdown")
|
|
||||||
assert "Successfully" in result or "created" in result.lower()
|
|
||||||
saved = json.loads((tmp_path / "new.ipynb").read_text())
|
|
||||||
assert saved["nbformat"] == 4
|
|
||||||
assert len(saved["cells"]) == 1
|
|
||||||
assert saved["cells"][0]["cell_type"] == "markdown"
|
|
||||||
assert saved["cells"][0]["source"] == "# Hello"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_cell_index_error(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("only cell")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=5, new_source="x")
|
|
||||||
assert "Error" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_non_ipynb_rejected(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "script.py"
|
|
||||||
f.write_text("pass")
|
|
||||||
result = await tool.execute(path=str(f), cell_index=0, new_source="x")
|
|
||||||
assert "Error" in result
|
|
||||||
assert ".ipynb" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_preserves_metadata_and_outputs(self, tool, tmp_path):
|
|
||||||
cell = _code_cell("old")
|
|
||||||
cell["outputs"] = [{"output_type": "stream", "text": "hello\n"}]
|
|
||||||
cell["execution_count"] = 42
|
|
||||||
nb = _make_notebook([cell])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
await tool.execute(path=path, cell_index=0, new_source="new")
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert saved["metadata"]["kernelspec"]["language"] == "python"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nbformat_45_generates_cell_id(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([], nbformat_minor=5)
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
await tool.execute(path=path, cell_index=0, new_source="x = 1", edit_mode="insert")
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert "id" in saved["cells"][0]
|
|
||||||
assert len(saved["cells"][0]["id"]) > 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_insert_with_cell_type_markdown(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("code")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
await tool.execute(path=path, cell_index=0, new_source="# Title", edit_mode="insert", cell_type="markdown")
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert saved["cells"][1]["cell_type"] == "markdown"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_edit_mode_rejected(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("code")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="x", edit_mode="replcae")
|
|
||||||
assert "Error" in result
|
|
||||||
assert "edit_mode" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_cell_type_rejected(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("code")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="x", cell_type="raw")
|
|
||||||
assert "Error" in result
|
|
||||||
assert "cell_type" in result
|
|
||||||
@@ -12,7 +12,7 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||||
from nanobot.agent.tools.search import GrepTool
|
from nanobot.agent.tools.search import FindFilesTool, GrepTool
|
||||||
from nanobot.agent.tools.web import WebSearchTool
|
from nanobot.agent.tools.web import WebSearchTool
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import WebSearchConfig
|
from nanobot.config.schema import WebSearchConfig
|
||||||
@@ -33,6 +33,68 @@ async def test_web_search_tool_refreshes_dynamic_config_loader(monkeypatch) -> N
|
|||||||
assert await tool.execute("nanobot") == "duckduckgo:nanobot:3"
|
assert await tool.execute("nanobot") == "duckduckgo:nanobot:3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_find_files_filters_by_query_glob_and_type(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
(tmp_path / "src" / "settings_view.tsx").write_text("export {}\n", encoding="utf-8")
|
||||||
|
(tmp_path / "src" / "settings_api.py").write_text("pass\n", encoding="utf-8")
|
||||||
|
(tmp_path / "README.md").write_text("settings\n", encoding="utf-8")
|
||||||
|
|
||||||
|
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(
|
||||||
|
path=".",
|
||||||
|
query="settings",
|
||||||
|
glob="src/**",
|
||||||
|
type="ts",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.splitlines() == ["src/settings_view.tsx"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_find_files_can_include_directories(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "src" / "settings").mkdir(parents=True)
|
||||||
|
(tmp_path / "src" / "settings" / "index.ts").write_text("export {}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(path="src", query="settings", include_dirs=True)
|
||||||
|
|
||||||
|
assert "src/settings/" in result.splitlines()
|
||||||
|
assert "src/settings/index.ts" in result.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_find_files_supports_modified_sort_and_pagination(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
for idx, name in enumerate(("a.py", "b.py", "c.py"), start=1):
|
||||||
|
file_path = tmp_path / "src" / name
|
||||||
|
file_path.write_text("pass\n", encoding="utf-8")
|
||||||
|
os.utime(file_path, (idx, idx))
|
||||||
|
|
||||||
|
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(
|
||||||
|
path="src",
|
||||||
|
type="py",
|
||||||
|
sort="modified",
|
||||||
|
head_limit=1,
|
||||||
|
offset=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.splitlines()[0] == "src/b.py"
|
||||||
|
assert "pagination: limit=1, offset=1" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_find_files_rejects_paths_outside_workspace(tmp_path: Path) -> None:
|
||||||
|
outside = tmp_path.parent / "outside-find-files.txt"
|
||||||
|
outside.write_text("secret\n", encoding="utf-8")
|
||||||
|
|
||||||
|
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(path=str(outside))
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_grep_respects_glob_filter_and_context(tmp_path: Path) -> None:
|
async def test_grep_respects_glob_filter_and_context(tmp_path: Path) -> None:
|
||||||
(tmp_path / "src").mkdir()
|
(tmp_path / "src").mkdir()
|
||||||
@@ -249,6 +311,7 @@ def test_agent_loop_registers_grep(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
|
||||||
|
assert "find_files" in loop.tools.tool_names
|
||||||
assert "grep" in loop.tools.tool_names
|
assert "grep" in loop.tools.tool_names
|
||||||
|
|
||||||
|
|
||||||
@@ -280,6 +343,7 @@ async def test_subagent_registers_grep(tmp_path: Path) -> None:
|
|||||||
status = SubagentStatus(task_id="sub-1", label="label", task_description="search task", started_at=time.monotonic())
|
status = SubagentStatus(task_id="sub-1", label="label", task_description="search task", started_at=time.monotonic())
|
||||||
await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"}, status)
|
await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"}, status)
|
||||||
|
|
||||||
|
assert "find_files" in captured["tool_names"]
|
||||||
assert "grep" in captured["tool_names"]
|
assert "grep" in captured["tool_names"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from nanobot.agent.tools.apply_patch import ApplyPatchTool
|
||||||
|
from nanobot.agent.tools.exec_session import ListExecSessionsTool, WriteStdinTool
|
||||||
|
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
||||||
|
from nanobot.agent.tools.search import FindFilesTool, GrepTool
|
||||||
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
|
|
||||||
|
|
||||||
|
def test_coding_tool_descriptions_steer_editing_priority() -> None:
|
||||||
|
apply_patch = ApplyPatchTool().description.lower()
|
||||||
|
edit_file = EditFileTool().description.lower()
|
||||||
|
write_file = WriteFileTool().description.lower()
|
||||||
|
|
||||||
|
assert "default tool for code edits" in apply_patch
|
||||||
|
assert "multi-file" in apply_patch
|
||||||
|
assert "dry_run=true" in apply_patch
|
||||||
|
assert "edit_file only for small exact replacements" in apply_patch
|
||||||
|
|
||||||
|
assert "small, exact replacement" in edit_file
|
||||||
|
assert "copied from read_file" in edit_file
|
||||||
|
assert "prefer apply_patch" in edit_file
|
||||||
|
|
||||||
|
assert "replace an entire file" in write_file
|
||||||
|
assert "prefer apply_patch" in write_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_coding_tool_descriptions_steer_discovery_and_shell_usage() -> None:
|
||||||
|
read_file = ReadFileTool().description.lower()
|
||||||
|
find_files = FindFilesTool().description.lower()
|
||||||
|
grep = GrepTool().description.lower()
|
||||||
|
exec_tool = ExecTool().description.lower()
|
||||||
|
write_stdin = WriteStdinTool().description.lower()
|
||||||
|
list_sessions = ListExecSessionsTool().description.lower()
|
||||||
|
|
||||||
|
assert "find_files/list_dir first" in read_file
|
||||||
|
assert "before editing" in read_file
|
||||||
|
assert "prefer it over shell find/ls" in find_files
|
||||||
|
assert "prefer this over shell grep" in grep
|
||||||
|
|
||||||
|
assert "tests, builds" in exec_tool
|
||||||
|
assert "prefer read_file/find_files/grep" in exec_tool
|
||||||
|
assert "apply_patch/write_file/edit_file" in exec_tool
|
||||||
|
assert "yield_time_ms" in exec_tool
|
||||||
|
|
||||||
|
assert "do not use this to start new commands" in write_stdin
|
||||||
|
assert "wait_for" in write_stdin
|
||||||
|
assert "recover a session_id" in list_sessions
|
||||||
@@ -89,9 +89,12 @@ def test_discover_finds_concrete_tools():
|
|||||||
loader = ToolLoader()
|
loader = ToolLoader()
|
||||||
discovered = loader.discover()
|
discovered = loader.discover()
|
||||||
class_names = {cls.__name__ for cls in discovered}
|
class_names = {cls.__name__ for cls in discovered}
|
||||||
|
assert "ApplyPatchTool" in class_names
|
||||||
assert "ExecTool" in class_names
|
assert "ExecTool" in class_names
|
||||||
|
assert "CliAppsTool" in class_names
|
||||||
assert "MessageTool" in class_names
|
assert "MessageTool" in class_names
|
||||||
assert "SpawnTool" in class_names
|
assert "SpawnTool" in class_names
|
||||||
|
assert "WriteStdinTool" in class_names
|
||||||
|
|
||||||
|
|
||||||
def test_discover_excludes_abstract_and_mcp():
|
def test_discover_excludes_abstract_and_mcp():
|
||||||
@@ -364,6 +367,7 @@ def test_config_defaults():
|
|||||||
assert config.tools.my.enable is True
|
assert config.tools.my.enable is True
|
||||||
assert config.tools.my.allow_set is False
|
assert config.tools.my.allow_set is False
|
||||||
assert config.tools.image_generation.enabled is False
|
assert config.tools.image_generation.enabled is False
|
||||||
|
assert config.tools.cli_apps.enable is True
|
||||||
assert config.tools.restrict_to_workspace is False
|
assert config.tools.restrict_to_workspace is False
|
||||||
|
|
||||||
|
|
||||||
@@ -406,7 +410,8 @@ def test_loader_registers_same_tools_as_old_hardcoded():
|
|||||||
|
|
||||||
expected = {
|
expected = {
|
||||||
"read_file", "write_file", "edit_file", "list_dir",
|
"read_file", "write_file", "edit_file", "list_dir",
|
||||||
"grep", "notebook_edit", "exec", "web_search", "web_fetch",
|
"find_files", "grep", "exec", "write_stdin", "list_exec_sessions",
|
||||||
|
"web_search", "web_fetch",
|
||||||
"message", "spawn", "cron",
|
"message", "spawn", "cron",
|
||||||
}
|
}
|
||||||
actual = set(registered)
|
actual = set(registered)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.tools import (
|
from nanobot.agent.tools import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
@@ -15,6 +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
|
||||||
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
|
|
||||||
class SampleTool(Tool):
|
class SampleTool(Tool):
|
||||||
@@ -218,6 +221,39 @@ def test_exec_extract_absolute_paths_ignores_relative_posix_segments() -> None:
|
|||||||
assert "/bin/python" not in paths
|
assert "/bin/python" not in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_extract_absolute_paths_ignores_urls() -> None:
|
||||||
|
cmd = 'curl -s -o /dev/null -w "%{http_code}" https://www.google.com'
|
||||||
|
paths = ExecTool._extract_absolute_paths(cmd)
|
||||||
|
assert paths == ["/dev/null"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"command",
|
||||||
|
[
|
||||||
|
'curl -s -o /dev/null -w "%{http_code}" https://www.google.com',
|
||||||
|
'wget -q -O - http://example.com 2>&1 | head -c 100',
|
||||||
|
'python3 -c "import urllib.request; print(urllib.request.urlopen(\'http://example.com\').read()[:100])"',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_exec_guard_allows_public_urls(tmp_path, command: str) -> None:
|
||||||
|
tool = ExecTool(restrict_to_workspace=True)
|
||||||
|
error = tool._guard_command(command, str(tmp_path))
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_guard_allows_whitelisted_internal_urls(tmp_path) -> None:
|
||||||
|
configure_ssrf_whitelist(["10.10.10.0/24"])
|
||||||
|
try:
|
||||||
|
tool = ExecTool(restrict_to_workspace=True)
|
||||||
|
error = tool._guard_command(
|
||||||
|
'curl -s -H "Authorization: Bearer ..." http://10.10.10.3:8123/api/',
|
||||||
|
str(tmp_path),
|
||||||
|
)
|
||||||
|
assert error is None
|
||||||
|
finally:
|
||||||
|
configure_ssrf_whitelist([])
|
||||||
|
|
||||||
|
|
||||||
def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None:
|
def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None:
|
||||||
cmd = "cat /tmp/data.txt > /tmp/out.txt"
|
cmd = "cat /tmp/data.txt > /tmp/out.txt"
|
||||||
paths = ExecTool._extract_absolute_paths(cmd)
|
paths = ExecTool._extract_absolute_paths(cmd)
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import json
|
|||||||
import socket
|
import socket
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools import web as web_module
|
||||||
from nanobot.agent.tools.web import WebFetchTool
|
from nanobot.agent.tools.web import WebFetchTool
|
||||||
from nanobot.config.schema import WebFetchConfig
|
from nanobot.config.schema import WebFetchConfig
|
||||||
|
|
||||||
|
_REAL_GETADDRINFO = socket.getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
def _fake_resolve_private(hostname, port, family=0, type_=0):
|
def _fake_resolve_private(hostname, port, family=0, type_=0):
|
||||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0))]
|
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0))]
|
||||||
@@ -54,6 +58,7 @@ async def test_web_fetch_result_contains_untrusted_flag():
|
|||||||
url = "https://example.com/page"
|
url = "https://example.com/page"
|
||||||
text = fake_html
|
text = fake_html
|
||||||
headers = {"content-type": "text/html"}
|
headers = {"content-type": "text/html"}
|
||||||
|
is_redirect = False
|
||||||
def raise_for_status(self): pass
|
def raise_for_status(self): pass
|
||||||
def json(self): return {}
|
def json(self): return {}
|
||||||
|
|
||||||
@@ -81,6 +86,7 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
|
|||||||
raise AssertionError("Jina Reader should be skipped when disabled")
|
raise AssertionError("Jina Reader should be skipped when disabled")
|
||||||
|
|
||||||
class FakeStreamResponse:
|
class FakeStreamResponse:
|
||||||
|
status_code = 200
|
||||||
headers = {"content-type": "text/html"}
|
headers = {"content-type": "text/html"}
|
||||||
url = "https://example.com/page"
|
url = "https://example.com/page"
|
||||||
|
|
||||||
@@ -90,11 +96,15 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
|
|||||||
async def __aexit__(self, exc_type, exc, tb):
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def aread(self):
|
||||||
|
raise AssertionError("non-image prefetch body should not be read")
|
||||||
|
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
status_code = 200
|
status_code = 200
|
||||||
url = "https://example.com/page"
|
url = "https://example.com/page"
|
||||||
text = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
|
text = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
|
||||||
headers = {"content-type": "text/html"}
|
headers = {"content-type": "text/html"}
|
||||||
|
is_redirect = False
|
||||||
|
|
||||||
def raise_for_status(self):
|
def raise_for_status(self):
|
||||||
return None
|
return None
|
||||||
@@ -109,11 +119,11 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
|
|||||||
async def __aexit__(self, exc_type, exc, tb):
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def stream(self, method, url, headers=None):
|
def stream(self, method, url, headers=None, **kwargs):
|
||||||
seen_headers.append(headers or {})
|
seen_headers.append(headers or {})
|
||||||
return FakeStreamResponse()
|
return FakeStreamResponse()
|
||||||
|
|
||||||
async def get(self, url, headers=None):
|
async def get(self, url, headers=None, **kwargs):
|
||||||
seen_headers.append(headers or {})
|
seen_headers.append(headers or {})
|
||||||
return FakeResponse()
|
return FakeResponse()
|
||||||
|
|
||||||
@@ -132,13 +142,14 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypatch):
|
async def test_web_fetch_blocks_private_redirect_before_readability_request(monkeypatch):
|
||||||
tool = WebFetchTool()
|
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
|
||||||
|
requested: list[str] = []
|
||||||
|
|
||||||
class FakeStreamResponse:
|
class FakeStreamResponse:
|
||||||
headers = {"content-type": "image/png"}
|
status_code = 200
|
||||||
url = "http://127.0.0.1/secret.png"
|
headers = {"content-type": "text/html"}
|
||||||
content = b"\x89PNG\r\n\x1a\n"
|
url = "https://attacker.example/start"
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
@@ -147,9 +158,14 @@ async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypa
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def aread(self):
|
async def aread(self):
|
||||||
return self.content
|
raise AssertionError("non-image prefetch body should not be read")
|
||||||
|
|
||||||
def raise_for_status(self):
|
class FakeRedirectResponse:
|
||||||
|
status_code = 302
|
||||||
|
headers = {"location": "http://127.0.0.1:8765/metadata"}
|
||||||
|
url = "https://attacker.example/start"
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
@@ -162,14 +178,110 @@ async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypa
|
|||||||
async def __aexit__(self, exc_type, exc, tb):
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def stream(self, method, url, headers=None):
|
def stream(self, method, url, headers=None, **kwargs):
|
||||||
return FakeStreamResponse()
|
return FakeStreamResponse()
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
|
async def get(self, url, headers=None, **kwargs):
|
||||||
|
requested.append(url)
|
||||||
|
if url == "http://127.0.0.1:8765/metadata":
|
||||||
|
raise AssertionError("private redirect target should not be requested")
|
||||||
|
return FakeRedirectResponse()
|
||||||
|
|
||||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
|
monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient)
|
||||||
|
|
||||||
|
def resolve_public_start_only(hostname, port, family=0, type_=0):
|
||||||
|
if hostname == "attacker.example":
|
||||||
|
return _fake_resolve_public(hostname, port, family, type_)
|
||||||
|
return _REAL_GETADDRINFO(hostname, port, family, type_)
|
||||||
|
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
|
||||||
|
result = await tool.execute(url="https://attacker.example/start")
|
||||||
|
|
||||||
|
data = json.loads(result)
|
||||||
|
assert "error" in data
|
||||||
|
assert "redirect blocked" in data["error"].lower()
|
||||||
|
assert requested == ["https://attacker.example/start"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypatch):
|
||||||
|
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if str(request.url) == "https://example.com/image.png":
|
||||||
|
return httpx.Response(
|
||||||
|
302,
|
||||||
|
headers={"Location": "http://127.0.0.1/secret.png"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
if str(request.url) == "http://127.0.0.1/secret.png":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "image/png"},
|
||||||
|
content=b"\x89PNG\r\n\x1a\n",
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return httpx.Response(404, request=request)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
real_async_client = httpx.AsyncClient
|
||||||
|
|
||||||
|
class TransportAsyncClient(real_async_client):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs.pop("proxy", None)
|
||||||
|
super().__init__(*args, transport=transport, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", TransportAsyncClient)
|
||||||
|
|
||||||
|
def resolve_public_start_only(hostname, port, family=0, type_=0):
|
||||||
|
if hostname == "example.com":
|
||||||
|
return _fake_resolve_public(hostname, port, family, type_)
|
||||||
|
return _REAL_GETADDRINFO(hostname, port, family, type_)
|
||||||
|
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
|
||||||
result = await tool.execute(url="https://example.com/image.png")
|
result = await tool.execute(url="https://example.com/image.png")
|
||||||
|
|
||||||
data = json.loads(result)
|
data = json.loads(result)
|
||||||
assert "error" in data
|
assert "error" in data
|
||||||
assert "redirect blocked" in data["error"].lower()
|
assert "redirect blocked" in data["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_web_fetch_does_not_request_private_redirect_target(monkeypatch):
|
||||||
|
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
|
||||||
|
requested: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requested.append(str(request.url))
|
||||||
|
if str(request.url) == "https://attacker.example/start":
|
||||||
|
return httpx.Response(
|
||||||
|
302,
|
||||||
|
headers={"Location": "http://127.0.0.1:8765/metadata"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
if str(request.url) == "http://127.0.0.1:8765/metadata":
|
||||||
|
return httpx.Response(200, content=b"internal secret", request=request)
|
||||||
|
return httpx.Response(404, request=request)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
real_async_client = httpx.AsyncClient
|
||||||
|
|
||||||
|
class TransportAsyncClient(real_async_client):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs["transport"] = transport
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(web_module.httpx, "AsyncClient", TransportAsyncClient)
|
||||||
|
|
||||||
|
def resolve_public_start_only(hostname, port, family=0, type_=0):
|
||||||
|
if hostname == "attacker.example":
|
||||||
|
return _fake_resolve_public(hostname, port, family, type_)
|
||||||
|
return _REAL_GETADDRINFO(hostname, port, family, type_)
|
||||||
|
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
|
||||||
|
result = await tool.execute(url="https://attacker.example/start")
|
||||||
|
|
||||||
|
data = json.loads(result)
|
||||||
|
assert "error" in data
|
||||||
|
assert "redirect blocked" in data["error"].lower()
|
||||||
|
assert requested == ["https://attacker.example/start"]
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from nanobot.utils.file_edit_events import (
|
from nanobot.utils.file_edit_events import (
|
||||||
|
StreamingFileEditTracker,
|
||||||
build_file_edit_end_event,
|
build_file_edit_end_event,
|
||||||
build_file_edit_start_event,
|
build_file_edit_start_event,
|
||||||
line_diff_stats,
|
line_diff_stats,
|
||||||
prepare_file_edit_tracker,
|
prepare_file_edit_tracker,
|
||||||
|
prepare_file_edit_trackers,
|
||||||
read_file_snapshot,
|
read_file_snapshot,
|
||||||
StreamingFileEditTracker,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +82,63 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
|||||||
assert (event["added"], event["deleted"]) == (0, 0)
|
assert (event["added"], event["deleted"]) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
existing = tmp_path / "src" / "existing.py"
|
||||||
|
existing.write_text("old\nkeep\n", encoding="utf-8")
|
||||||
|
delete_me = tmp_path / "src" / "delete_me.py"
|
||||||
|
delete_me.write_text("gone\n", encoding="utf-8")
|
||||||
|
|
||||||
|
edits = [
|
||||||
|
{"path": "src/new.py", "action": "add", "new_text": "fresh"},
|
||||||
|
{"path": "src/existing.py", "action": "replace", "old_text": "old", "new_text": "new"},
|
||||||
|
{"path": "src/delete_me.py", "action": "delete", "old_text": "gone\n"},
|
||||||
|
]
|
||||||
|
|
||||||
|
trackers = prepare_file_edit_trackers(
|
||||||
|
call_id="call-patch",
|
||||||
|
tool_name="apply_patch",
|
||||||
|
tool=None,
|
||||||
|
workspace=tmp_path,
|
||||||
|
params={"edits": edits},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [tracker.display_path for tracker in trackers] == [
|
||||||
|
"src/new.py",
|
||||||
|
"src/existing.py",
|
||||||
|
"src/delete_me.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
(tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
|
||||||
|
existing.write_text("new\nkeep\n", encoding="utf-8")
|
||||||
|
delete_me.unlink()
|
||||||
|
|
||||||
|
events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers]
|
||||||
|
by_path = {event["path"]: event for event in events}
|
||||||
|
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
|
||||||
|
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
|
||||||
|
assert (by_path["src/delete_me.py"]["added"], by_path["src/delete_me.py"]["deleted"]) == (0, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "file.txt").write_text("old\n", encoding="utf-8")
|
||||||
|
|
||||||
|
trackers = prepare_file_edit_trackers(
|
||||||
|
call_id="call-patch",
|
||||||
|
tool_name="apply_patch",
|
||||||
|
tool=None,
|
||||||
|
workspace=tmp_path,
|
||||||
|
params={
|
||||||
|
"dry_run": True,
|
||||||
|
"edits": [
|
||||||
|
{"path": "file.txt", "action": "replace", "old_text": "old", "new_text": "new"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trackers == []
|
||||||
|
|
||||||
|
|
||||||
def test_oversized_write_file_end_uses_known_content_for_exact_count(tmp_path: Path) -> None:
|
def test_oversized_write_file_end_uses_known_content_for_exact_count(tmp_path: Path) -> None:
|
||||||
target = tmp_path / "large.txt"
|
target = tmp_path / "large.txt"
|
||||||
params = {"path": "large.txt", "content": "x" * (2 * 1024 * 1024 + 1)}
|
params = {"path": "large.txt", "content": "x" * (2 * 1024 * 1024 + 1)}
|
||||||
@@ -140,6 +198,58 @@ def test_streaming_write_file_tracker_emits_live_line_counts(tmp_path: Path) ->
|
|||||||
assert events[-1]["deleted"] == 0
|
assert events[-1]["deleted"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_apply_patch_tracker_emits_live_counts_per_file(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
(tmp_path / "src" / "existing.py").write_text("old\nkeep\n", encoding="utf-8")
|
||||||
|
events: list[dict] = []
|
||||||
|
|
||||||
|
async def emit(batch: list[dict]) -> None:
|
||||||
|
events.extend(batch)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
|
||||||
|
await tracker.update({
|
||||||
|
"index": 0,
|
||||||
|
"call_id": "call-patch",
|
||||||
|
"name": "apply_patch",
|
||||||
|
"arguments_delta": (
|
||||||
|
'{"edits":[{"path":"src/existing.py","action":"replace","old_text":"old","new_text":"new"}'
|
||||||
|
',{"path":"src/new.py","action":"add","new_text":"fresh"}]}'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
by_path = {event["path"]: event for event in events}
|
||||||
|
assert by_path["src/existing.py"]["tool"] == "apply_patch"
|
||||||
|
assert by_path["src/existing.py"]["status"] == "editing"
|
||||||
|
assert by_path["src/existing.py"]["approximate"] is True
|
||||||
|
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
|
||||||
|
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_apply_patch_tracker_skips_dry_run(tmp_path: Path) -> None:
|
||||||
|
events: list[dict] = []
|
||||||
|
|
||||||
|
async def emit(batch: list[dict]) -> None:
|
||||||
|
events.extend(batch)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
|
||||||
|
await tracker.update({
|
||||||
|
"index": 0,
|
||||||
|
"call_id": "call-patch",
|
||||||
|
"name": "apply_patch",
|
||||||
|
"arguments_delta": (
|
||||||
|
'{"dry_run":true,"edits":[{"path":"dry.md","action":"add","new_text":"preview"}]}'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
def test_streaming_write_file_tracker_emits_pending_before_path(tmp_path: Path) -> None:
|
def test_streaming_write_file_tracker_emits_pending_before_path(tmp_path: Path) -> None:
|
||||||
events: list[dict] = []
|
events: list[dict] = []
|
||||||
|
|
||||||
@@ -308,6 +418,43 @@ def test_streaming_tracker_applies_canonical_call_id_to_final_tool(tmp_path: Pat
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_tracker_does_not_restore_duplicate_canonical_ids(tmp_path: Path) -> None:
|
||||||
|
events: list[dict] = []
|
||||||
|
|
||||||
|
async def emit(batch: list[dict]) -> None:
|
||||||
|
events.extend(batch)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
|
||||||
|
await tracker.update({
|
||||||
|
"index": 0,
|
||||||
|
"call_id": "call_dup",
|
||||||
|
"name": "write_file",
|
||||||
|
"arguments_delta": '{"path":"a.md","content":"one\\n"}',
|
||||||
|
})
|
||||||
|
await tracker.update({
|
||||||
|
"index": 1,
|
||||||
|
"call_id": "call_dup",
|
||||||
|
"name": "write_file",
|
||||||
|
"arguments_delta": '{"path":"b.md","content":"two\\n"}',
|
||||||
|
})
|
||||||
|
final_a = SimpleNamespace(
|
||||||
|
id="call_dup",
|
||||||
|
name="write_file",
|
||||||
|
arguments={"path": "a.md", "content": "one\n"},
|
||||||
|
)
|
||||||
|
final_b = SimpleNamespace(
|
||||||
|
id="call_unique",
|
||||||
|
name="write_file",
|
||||||
|
arguments={"path": "b.md", "content": "two\n"},
|
||||||
|
)
|
||||||
|
tracker.apply_final_call_ids([final_a, final_b])
|
||||||
|
assert final_a.id == "call_dup"
|
||||||
|
assert final_b.id == "call_unique"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_streaming_edit_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None:
|
def test_streaming_edit_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None:
|
||||||
target = tmp_path / "small.py"
|
target = tmp_path / "small.py"
|
||||||
target.write_text("old\n", encoding="utf-8")
|
target.write_text("old\n", encoding="utf-8")
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -143,6 +167,50 @@ def test_replay_tool_events_dedupes_finish_after_start() -> None:
|
|||||||
'exec({"cmd": "ls"})',
|
'exec({"cmd": "ls"})',
|
||||||
'read_file({"path": "notes.md"})',
|
'read_file({"path": "notes.md"})',
|
||||||
]
|
]
|
||||||
|
assert msgs[0]["toolEvents"][0]["phase"] == "end"
|
||||||
|
assert msgs[0]["toolEvents"][0]["call_id"] == "call-exec"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_tool_events_keeps_phase_update_when_trace_is_deduped() -> None:
|
||||||
|
args = {"name": "github", "args": ["repo", "view"], "json": "true"}
|
||||||
|
msgs = replay_transcript_to_ui_messages([
|
||||||
|
{
|
||||||
|
"event": "message",
|
||||||
|
"chat_id": "t-tool",
|
||||||
|
"text": "",
|
||||||
|
"kind": "tool_hint",
|
||||||
|
"tool_events": [
|
||||||
|
{
|
||||||
|
"phase": "start",
|
||||||
|
"call_id": "call-cli",
|
||||||
|
"name": "run_cli_app",
|
||||||
|
"arguments": args,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "message",
|
||||||
|
"chat_id": "t-tool",
|
||||||
|
"text": "",
|
||||||
|
"kind": "progress",
|
||||||
|
"tool_events": [
|
||||||
|
{
|
||||||
|
"phase": "error",
|
||||||
|
"call_id": "call-cli",
|
||||||
|
"name": "run_cli_app",
|
||||||
|
"arguments": args,
|
||||||
|
"error": "Error: CLI app 'github' not found",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
assert len(msgs) == 1
|
||||||
|
assert msgs[0]["traces"] == [
|
||||||
|
'run_cli_app({"name": "github", "args": ["repo", "view"], "json": "true"})',
|
||||||
|
]
|
||||||
|
assert msgs[0]["toolEvents"][0]["phase"] == "error"
|
||||||
|
assert msgs[0]["toolEvents"][0]["error"] == "Error: CLI app 'github' not found"
|
||||||
|
|
||||||
|
|
||||||
def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:
|
def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
+40
-20
@@ -43,6 +43,7 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
|||||||
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
|
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
|
||||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||||
const SIDEBAR_WIDTH = 272;
|
const SIDEBAR_WIDTH = 272;
|
||||||
|
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";
|
||||||
@@ -411,6 +412,10 @@ function Shell({
|
|||||||
setDesktopSidebarOpen(false);
|
setDesktopSidebarOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openDesktopSidebar = useCallback(() => {
|
||||||
|
setDesktopSidebarOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const closeMobileSidebar = useCallback(() => {
|
const closeMobileSidebar = useCallback(() => {
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -560,6 +565,21 @@ function Shell({
|
|||||||
setSessionSearchOpen(true);
|
setSessionSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
const plainCommandK =
|
||||||
|
(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
|
||||||
|
if (!plainCommandK) return;
|
||||||
|
if (event.key.toLowerCase() !== "k") return;
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenSessionSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onOpenSessionSearch]);
|
||||||
|
|
||||||
const onSelectSearchResult = useCallback(
|
const onSelectSearchResult = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
setSessionSearchOpen(false);
|
setSessionSearchOpen(false);
|
||||||
@@ -732,17 +752,19 @@ function Shell({
|
|||||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||||
"transition-[width] duration-300 ease-out",
|
"transition-[width] duration-300 ease-out",
|
||||||
)}
|
)}
|
||||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
style={{
|
||||||
|
width: desktopSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar shadow-inner-right"
|
||||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
|
||||||
"transition-transform duration-300 ease-out",
|
|
||||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
|
||||||
)}
|
|
||||||
style={{ width: SIDEBAR_WIDTH }}
|
|
||||||
>
|
>
|
||||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
<Sidebar
|
||||||
|
{...sidebarProps}
|
||||||
|
collapsed={!desktopSidebarOpen}
|
||||||
|
onCollapse={closeDesktopSidebar}
|
||||||
|
onExpand={openDesktopSidebar}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -769,17 +791,15 @@ function Shell({
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showMainSidebar ? (
|
<SessionSearchDialog
|
||||||
<SessionSearchDialog
|
open={sessionSearchOpen}
|
||||||
open={sessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
onOpenChange={setSessionSearchOpen}
|
sessions={sessions}
|
||||||
sessions={sessions}
|
activeKey={activeKey}
|
||||||
activeKey={activeKey}
|
loading={loading}
|
||||||
loading={loading}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
titleOverrides={sidebarState.title_overrides}
|
onSelect={onSelectSearchResult}
|
||||||
onSelect={onSelectSearchResult}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
||||||
<div
|
<div
|
||||||
@@ -797,7 +817,7 @@ function Shell({
|
|||||||
onTurnEnd={onTurnEnd}
|
onTurnEnd={onTurnEnd}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
hideSidebarToggleOnDesktop
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view === "settings" && (
|
{view === "settings" && (
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
@@ -19,6 +25,9 @@ import { deriveTitle, relativeTime } from "@/lib/format";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||||
|
|
||||||
|
const INITIAL_VISIBLE_SESSIONS = 160;
|
||||||
|
const VISIBLE_SESSIONS_INCREMENT = 160;
|
||||||
|
|
||||||
interface ChatListProps {
|
interface ChatListProps {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
@@ -42,7 +51,7 @@ interface ChatListProps {
|
|||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatList({
|
export const ChatList = memo(function ChatList({
|
||||||
sessions,
|
sessions,
|
||||||
activeKey,
|
activeKey,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -65,6 +74,52 @@ export function ChatList({
|
|||||||
emptyLabel,
|
emptyLabel,
|
||||||
}: ChatListProps) {
|
}: ChatListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
|
||||||
|
const labels = useMemo(() => ({
|
||||||
|
pinned: t("chat.groups.pinned"),
|
||||||
|
all: t("chat.groups.all"),
|
||||||
|
today: t("chat.groups.today"),
|
||||||
|
yesterday: t("chat.groups.yesterday"),
|
||||||
|
earlier: t("chat.groups.earlier"),
|
||||||
|
archived: t("chat.groups.archived"),
|
||||||
|
fallbackTitle: t("chat.newChat"),
|
||||||
|
}), [t]);
|
||||||
|
const groups = useMemo(
|
||||||
|
() => groupSessions(sessions, labels, {
|
||||||
|
pinnedKeys,
|
||||||
|
archivedKeys,
|
||||||
|
titleOverrides,
|
||||||
|
showArchived,
|
||||||
|
sort,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
archivedKeys,
|
||||||
|
labels,
|
||||||
|
pinnedKeys,
|
||||||
|
sessions,
|
||||||
|
showArchived,
|
||||||
|
sort,
|
||||||
|
titleOverrides,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const limitedGroups = useMemo(
|
||||||
|
() => limitGroups(groups, visibleLimit, activeKey),
|
||||||
|
[activeKey, groups, visibleLimit],
|
||||||
|
);
|
||||||
|
const totalSessionCount = useMemo(
|
||||||
|
() => groups.reduce((total, group) => total + group.sessions.length, 0),
|
||||||
|
[groups],
|
||||||
|
);
|
||||||
|
const visibleSessionCount = useMemo(
|
||||||
|
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
|
||||||
|
[limitedGroups],
|
||||||
|
);
|
||||||
|
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||||
|
}, [showArchived, sort]);
|
||||||
|
|
||||||
if (loading && sessions.length === 0) {
|
if (loading && sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||||
@@ -81,21 +136,6 @@ export function ChatList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = groupSessions(sessions, {
|
|
||||||
pinned: t("chat.groups.pinned"),
|
|
||||||
all: t("chat.groups.all"),
|
|
||||||
today: t("chat.groups.today"),
|
|
||||||
yesterday: t("chat.groups.yesterday"),
|
|
||||||
earlier: t("chat.groups.earlier"),
|
|
||||||
archived: t("chat.groups.archived"),
|
|
||||||
fallbackTitle: t("chat.newChat"),
|
|
||||||
}, {
|
|
||||||
pinnedKeys,
|
|
||||||
archivedKeys,
|
|
||||||
titleOverrides,
|
|
||||||
showArchived,
|
|
||||||
sort,
|
|
||||||
});
|
|
||||||
const pinned = new Set(pinnedKeys);
|
const pinned = new Set(pinnedKeys);
|
||||||
const archived = new Set(archivedKeys);
|
const archived = new Set(archivedKeys);
|
||||||
const running = new Set(runningChatIds);
|
const running = new Set(runningChatIds);
|
||||||
@@ -105,7 +145,7 @@ export function ChatList({
|
|||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
|
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
|
||||||
<div className="min-w-0 space-y-3 px-2 py-1.5">
|
<div className="min-w-0 space-y-3 px-2 py-1.5">
|
||||||
{groups.map((group) => (
|
{limitedGroups.map((group) => (
|
||||||
<section key={group.label} aria-label={group.label}>
|
<section key={group.label} aria-label={group.label}>
|
||||||
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
||||||
{group.label}
|
{group.label}
|
||||||
@@ -228,10 +268,25 @@ export function ChatList({
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
{hiddenSessionCount > 0 ? (
|
||||||
|
<div className="px-2 pb-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleLimit((limit) =>
|
||||||
|
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
{t("chat.showMore", { count: hiddenSessionCount })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function SessionActivityIndicator({
|
function SessionActivityIndicator({
|
||||||
state,
|
state,
|
||||||
@@ -366,6 +421,45 @@ function groupSessions(
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function limitGroups(
|
||||||
|
groups: Array<{ label: string; sessions: ChatSummary[] }>,
|
||||||
|
limit: number,
|
||||||
|
activeKey: string | null,
|
||||||
|
): Array<{ label: string; sessions: ChatSummary[] }> {
|
||||||
|
let remaining = Math.max(0, limit);
|
||||||
|
let activeVisible = !activeKey;
|
||||||
|
const out: Array<{ label: string; sessions: ChatSummary[] }> = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const visible = remaining > 0
|
||||||
|
? group.sessions.slice(0, remaining)
|
||||||
|
: [];
|
||||||
|
remaining -= visible.length;
|
||||||
|
if (activeKey && visible.some((session) => session.key === activeKey)) {
|
||||||
|
activeVisible = true;
|
||||||
|
}
|
||||||
|
if (visible.length > 0) {
|
||||||
|
out.push({ label: group.label, sessions: visible });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeVisible || !activeKey) return out;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const active = group.sessions.find((session) => session.key === activeKey);
|
||||||
|
if (!active) continue;
|
||||||
|
const existing = out.find((item) => item.label === group.label);
|
||||||
|
if (existing) {
|
||||||
|
existing.sessions = [...existing.sessions, active];
|
||||||
|
} else {
|
||||||
|
out.push({ label: group.label, sessions: [active] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function sortSessions(
|
function sortSessions(
|
||||||
sessions: ChatSummary[],
|
sessions: ChatSummary[],
|
||||||
sort: SidebarSortMode,
|
sort: SidebarSortMode,
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { CliAppInfo } from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type CliAppMentionSegment =
|
||||||
|
| { kind: "text"; text: string }
|
||||||
|
| { kind: "cli"; text: string; app: CliAppInfo };
|
||||||
|
|
||||||
|
export function cliAppInitials(app: CliAppInfo): string {
|
||||||
|
const value = app.display_name || app.name;
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase())
|
||||||
|
.join("") || app.name.slice(0, 2).toUpperCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitCliAppMentionSegments(
|
||||||
|
value: string,
|
||||||
|
cliApps: CliAppInfo[],
|
||||||
|
): CliAppMentionSegment[] {
|
||||||
|
if (!value || cliApps.length === 0) return value ? [{ kind: "text", text: value }] : [];
|
||||||
|
const appsByName = new Map(
|
||||||
|
cliApps
|
||||||
|
.filter((app) => app.installed)
|
||||||
|
.map((app) => [app.name.toLowerCase(), app]),
|
||||||
|
);
|
||||||
|
if (appsByName.size === 0) return [{ kind: "text", text: value }];
|
||||||
|
|
||||||
|
const segments: CliAppMentionSegment[] = [];
|
||||||
|
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 app = appsByName.get(name.toLowerCase());
|
||||||
|
if (!app) 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) });
|
||||||
|
}
|
||||||
|
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
|
||||||
|
cursor = mentionEnd;
|
||||||
|
}
|
||||||
|
if (cursor < value.length) {
|
||||||
|
segments.push({ kind: "text", text: value.slice(cursor) });
|
||||||
|
}
|
||||||
|
return segments.length ? segments : [{ kind: "text", text: value }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CliAppMentionText({
|
||||||
|
text,
|
||||||
|
cliApps,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
cliApps: CliAppInfo[];
|
||||||
|
}) {
|
||||||
|
const segments = splitCliAppMentionSegments(text, cliApps);
|
||||||
|
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segments.map((segment, index) => {
|
||||||
|
if (segment.kind === "text") {
|
||||||
|
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<CliAppMentionToken
|
||||||
|
key={`cli-${segment.app.name}-${index}`}
|
||||||
|
app={segment.app}
|
||||||
|
label={segment.text}
|
||||||
|
variant="message"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CliAppMentionToken({
|
||||||
|
app,
|
||||||
|
label,
|
||||||
|
variant,
|
||||||
|
isHero = false,
|
||||||
|
}: {
|
||||||
|
app: CliAppInfo;
|
||||||
|
label: string;
|
||||||
|
variant: "composer" | "message";
|
||||||
|
isHero?: boolean;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const color = app.brand_color || "hsl(var(--primary))";
|
||||||
|
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||||
|
const showLogo = Boolean(app.logo_url) && !failed;
|
||||||
|
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid={`${testIdPrefix}-cli-mention-${app.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}-cli-mention-logo-${app.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={app.logo_url ?? ""}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{mentionName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alphaColor(color: string, percent: number): string {
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(color)) {
|
||||||
|
const alpha = Math.round((percent / 100) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
return `${color}${alpha}`;
|
||||||
|
}
|
||||||
|
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -8,16 +9,18 @@ import {
|
|||||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { CliAppMentionText } from "@/components/CliAppMentionText";
|
||||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
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 { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, UICliAppAttachment, 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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,11 +35,16 @@ interface MessageBubbleProps {
|
|||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
showAssistantCopyAction = true,
|
showAssistantCopyAction = true,
|
||||||
|
cliApps = [],
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const copyResetRef = useRef<number | null>(null);
|
const copyResetRef = useRef<number | null>(null);
|
||||||
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
|
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
|
||||||
|
const mentionCliApps = useMemo(
|
||||||
|
() => mergeCliMentionApps(cliApps, message.cliApps),
|
||||||
|
[cliApps, message.cliApps],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -88,7 +96,7 @@ export function MessageBubble({
|
|||||||
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
|
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.content}
|
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,6 +166,36 @@ export function MessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeCliMentionApps(
|
||||||
|
cliApps: CliAppInfo[],
|
||||||
|
attachments: UICliAppAttachment[] | undefined,
|
||||||
|
): CliAppInfo[] {
|
||||||
|
if (!attachments?.length) return cliApps;
|
||||||
|
const byName = new Map(cliApps.map((app) => [app.name.toLowerCase(), app]));
|
||||||
|
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 || "cli",
|
||||||
|
description: existing?.description || "",
|
||||||
|
requires: existing?.requires || "",
|
||||||
|
source: existing?.source || "attached",
|
||||||
|
entry_point: attachment.entry_point || existing?.entry_point || "",
|
||||||
|
install_supported: existing?.install_supported ?? true,
|
||||||
|
installed: true,
|
||||||
|
available: existing?.available ?? true,
|
||||||
|
status: existing?.status || "installed",
|
||||||
|
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
|
||||||
|
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
|
||||||
|
skill_installed: existing?.skill_installed ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from(byName.values());
|
||||||
|
}
|
||||||
|
|
||||||
function MessageMedia({
|
function MessageMedia({
|
||||||
media,
|
media,
|
||||||
align,
|
align,
|
||||||
|
|||||||
@@ -37,13 +37,16 @@ export function SessionSearchDialog({
|
|||||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const results = useMemo(() => {
|
const sessionResults = useMemo(() => {
|
||||||
|
if (!open) return [];
|
||||||
if (!normalizedQuery) return sessions;
|
if (!normalizedQuery) return sessions;
|
||||||
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||||
return sessions.filter((session) =>
|
return sessions.filter((session) =>
|
||||||
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
|
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
|
||||||
);
|
);
|
||||||
}, [normalizedQuery, sessions, titleOverrides]);
|
}, [normalizedQuery, open, sessions, titleOverrides]);
|
||||||
|
const itemCount = sessionResults.length;
|
||||||
|
const shortcutLabel = useMemo(getSearchShortcutLabel, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -58,9 +61,9 @@ export function SessionSearchDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHighlightedIndex((index) =>
|
setHighlightedIndex((index) =>
|
||||||
results.length === 0 ? 0 : Math.min(index, results.length - 1),
|
itemCount === 0 ? 0 : Math.min(index, itemCount - 1),
|
||||||
);
|
);
|
||||||
}, [results.length]);
|
}, [itemCount]);
|
||||||
|
|
||||||
const handleSelect = (key: string) => {
|
const handleSelect = (key: string) => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -71,17 +74,19 @@ export function SessionSearchDialog({
|
|||||||
if (event.key === "ArrowDown") {
|
if (event.key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setHighlightedIndex((index) =>
|
setHighlightedIndex((index) =>
|
||||||
results.length === 0 ? 0 : Math.min(index + 1, results.length - 1),
|
itemCount === 0 ? 0 : (index + 1) % itemCount,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "ArrowUp") {
|
if (event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setHighlightedIndex((index) => Math.max(index - 1, 0));
|
setHighlightedIndex((index) =>
|
||||||
|
itemCount === 0 ? 0 : (index - 1 + itemCount) % itemCount,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
const highlighted = results[highlightedIndex];
|
const highlighted = sessionResults[highlightedIndex];
|
||||||
if (!highlighted) return;
|
if (!highlighted) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSelect(highlighted.key);
|
handleSelect(highlighted.key);
|
||||||
@@ -125,70 +130,75 @@ export function SessionSearchDialog({
|
|||||||
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-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
|
||||||
/>
|
/>
|
||||||
|
<kbd className="hidden h-6 shrink-0 items-center rounded-md border border-border/70 bg-muted/60 px-2 text-[11px] font-medium text-muted-foreground sm:inline-flex">
|
||||||
|
{shortcutLabel}
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
|
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
|
||||||
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
|
<section>
|
||||||
{sectionLabel}
|
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
|
||||||
</div>
|
{sectionLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading && sessions.length === 0 ? (
|
{loading && sessions.length === 0 ? (
|
||||||
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
||||||
{t("chat.loading")}
|
{t("chat.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : results.length === 0 ? (
|
) : sessionResults.length === 0 ? (
|
||||||
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
||||||
{emptyLabel}
|
{emptyLabel}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{results.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() ||
|
||||||
deriveTitle(session.preview, t("chat.newChat"));
|
deriveTitle(session.preview, t("chat.newChat"));
|
||||||
const preview = session.preview.trim();
|
const preview = session.preview.trim();
|
||||||
const showPreview =
|
const showPreview =
|
||||||
preview.length > 0 &&
|
preview.length > 0 &&
|
||||||
preview.toLowerCase() !== title.trim().toLowerCase();
|
preview.toLowerCase() !== title.trim().toLowerCase();
|
||||||
const highlighted = index === highlightedIndex;
|
const highlighted = index === highlightedIndex;
|
||||||
const active = session.key === activeKey;
|
const active = session.key === activeKey;
|
||||||
return (
|
return (
|
||||||
<li key={session.key}>
|
<li key={session.key}>
|
||||||
<button
|
<button
|
||||||
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",
|
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
|
||||||
highlighted
|
highlighted
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
|
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-[14px] font-medium leading-5">
|
<span className="block truncate text-[14px] font-medium leading-5">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
|
||||||
{showPreview ? (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"block truncate text-[12px] leading-4",
|
|
||||||
highlighted
|
|
||||||
? "text-accent-foreground/70"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preview}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
{showPreview ? (
|
||||||
</span>
|
<span
|
||||||
</button>
|
className={cn(
|
||||||
</li>
|
"block truncate text-[12px] leading-4",
|
||||||
);
|
highlighted
|
||||||
})}
|
? "text-accent-foreground/70"
|
||||||
</ul>
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -211,3 +221,13 @@ function sessionMatchesTerms(
|
|||||||
|
|
||||||
return terms.every((term) => haystack.includes(term));
|
return terms.every((term) => haystack.includes(term));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSearchShortcutLabel() {
|
||||||
|
if (typeof navigator === "undefined") return "Ctrl K";
|
||||||
|
const platform = navigator.platform.toLowerCase();
|
||||||
|
const apple =
|
||||||
|
platform.includes("mac") ||
|
||||||
|
platform.includes("iphone") ||
|
||||||
|
platform.includes("ipad");
|
||||||
|
return apple ? "⌘K" : "Ctrl K";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
SidebarSortMode,
|
SidebarSortMode,
|
||||||
SidebarViewState,
|
SidebarViewState,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
@@ -44,7 +45,9 @@ interface SidebarProps {
|
|||||||
onToggleArchived: () => void;
|
onToggleArchived: () => void;
|
||||||
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
|
onExpand?: () => void;
|
||||||
containActionMenus?: boolean;
|
containActionMenus?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
pinnedKeys?: string[];
|
pinnedKeys?: string[];
|
||||||
archivedKeys?: string[];
|
archivedKeys?: string[];
|
||||||
titleOverrides?: Record<string, string>;
|
titleOverrides?: Record<string, string>;
|
||||||
@@ -59,6 +62,8 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [menuPortalContainer, setMenuPortalContainer] =
|
const [menuPortalContainer, setMenuPortalContainer] =
|
||||||
useState<HTMLElement | null>(null);
|
useState<HTMLElement | null>(null);
|
||||||
|
const collapsed = Boolean(props.collapsed);
|
||||||
|
const toggleLabel = t("thread.header.toggleSidebar");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -66,108 +71,189 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
aria-label={t("sidebar.navigation")}
|
aria-label={t("sidebar.navigation")}
|
||||||
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
|
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
|
<div
|
||||||
<picture className="block min-w-0">
|
className={cn(
|
||||||
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
|
"flex items-center px-3 pb-2.5 pt-3",
|
||||||
|
collapsed ? "w-14 justify-start" : "justify-between",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={collapsed ? toggleLabel : undefined}
|
||||||
|
aria-hidden={collapsed ? undefined : true}
|
||||||
|
title={collapsed ? toggleLabel : undefined}
|
||||||
|
onClick={collapsed ? props.onExpand : undefined}
|
||||||
|
tabIndex={collapsed ? 0 : -1}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-colors",
|
||||||
|
collapsed
|
||||||
|
? "-ml-0.5 hover:bg-sidebar-accent/75"
|
||||||
|
: "pointer-events-none -ml-0.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src="/brand/nanobot_logo.png"
|
src="/brand/nanobot_icon.png"
|
||||||
alt="nanobot"
|
alt=""
|
||||||
className="h-6 w-auto select-none object-contain opacity-95"
|
className="h-8 w-8 select-none object-contain"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</picture>
|
</button>
|
||||||
<Button
|
{!collapsed && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
aria-label={t("sidebar.collapse")}
|
size="icon"
|
||||||
onClick={props.onCollapse}
|
aria-label={t("sidebar.collapse")}
|
||||||
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
onClick={props.onCollapse}
|
||||||
>
|
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
||||||
<Menu className="h-3.5 w-3.5" />
|
>
|
||||||
</Button>
|
<Menu className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 px-2 pb-2">
|
<div
|
||||||
<Button
|
className={cn(
|
||||||
|
"space-y-1.5 px-2 pb-2",
|
||||||
|
collapsed && "flex w-14 flex-col items-center px-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarActionButton
|
||||||
|
collapsed={collapsed}
|
||||||
|
label={t("sidebar.newChat")}
|
||||||
onClick={props.onNewChat}
|
onClick={props.onNewChat}
|
||||||
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
icon={<SquarePen className="h-4 w-4" />}
|
||||||
variant="ghost"
|
/>
|
||||||
>
|
<SidebarActionButton
|
||||||
<SquarePen className="h-3.5 w-3.5" />
|
collapsed={collapsed}
|
||||||
{t("sidebar.newChat")}
|
label={t("sidebar.searchAria")}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={props.onOpenSearch}
|
onClick={props.onOpenSearch}
|
||||||
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
icon={<Search className="h-4 w-4" />}
|
||||||
variant="ghost"
|
/>
|
||||||
>
|
|
||||||
<Search className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
{t("sidebar.searchAria")}
|
|
||||||
</Button>
|
|
||||||
<SidebarViewMenu
|
<SidebarViewMenu
|
||||||
|
compact={collapsed}
|
||||||
view={props.viewState}
|
view={props.viewState}
|
||||||
onUpdateView={props.onUpdateView}
|
onUpdateView={props.onUpdateView}
|
||||||
/>
|
/>
|
||||||
{props.archivedCount ? (
|
{props.archivedCount ? (
|
||||||
<Button
|
<SidebarActionButton
|
||||||
type="button"
|
collapsed={collapsed}
|
||||||
|
label={props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
|
||||||
onClick={props.onToggleArchived}
|
onClick={props.onToggleArchived}
|
||||||
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
icon={<Archive className="h-4 w-4" />}
|
||||||
variant="ghost"
|
/>
|
||||||
>
|
|
||||||
<Archive className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
{props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
<div
|
||||||
<ChatList
|
className={cn(
|
||||||
sessions={props.sessions}
|
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
|
||||||
activeKey={props.activeKey}
|
collapsed && "pointer-events-none opacity-0",
|
||||||
loading={props.loading}
|
)}
|
||||||
emptyLabel={t("chat.noSessions")}
|
>
|
||||||
onSelect={props.onSelect}
|
{!collapsed && (
|
||||||
onRequestDelete={props.onRequestDelete}
|
<ChatList
|
||||||
onTogglePin={props.onTogglePin}
|
sessions={props.sessions}
|
||||||
onRequestRename={props.onRequestRename}
|
activeKey={props.activeKey}
|
||||||
onToggleArchive={props.onToggleArchive}
|
loading={props.loading}
|
||||||
pinnedKeys={props.pinnedKeys}
|
emptyLabel={t("chat.noSessions")}
|
||||||
archivedKeys={props.archivedKeys}
|
onSelect={props.onSelect}
|
||||||
titleOverrides={props.titleOverrides}
|
onRequestDelete={props.onRequestDelete}
|
||||||
runningChatIds={props.runningChatIds}
|
onTogglePin={props.onTogglePin}
|
||||||
completedChatIds={props.completedChatIds}
|
onRequestRename={props.onRequestRename}
|
||||||
density={props.viewState?.density}
|
onToggleArchive={props.onToggleArchive}
|
||||||
showPreviews={props.viewState?.show_previews}
|
pinnedKeys={props.pinnedKeys}
|
||||||
showTimestamps={props.viewState?.show_timestamps}
|
archivedKeys={props.archivedKeys}
|
||||||
sort={props.viewState?.sort}
|
titleOverrides={props.titleOverrides}
|
||||||
showArchived={props.showArchived}
|
runningChatIds={props.runningChatIds}
|
||||||
actionMenuPortalContainer={
|
completedChatIds={props.completedChatIds}
|
||||||
props.containActionMenus ? menuPortalContainer : undefined
|
density={props.viewState?.density}
|
||||||
}
|
showPreviews={props.viewState?.show_previews}
|
||||||
/>
|
showTimestamps={props.viewState?.show_timestamps}
|
||||||
|
sort={props.viewState?.sort}
|
||||||
|
showArchived={props.showArchived}
|
||||||
|
actionMenuPortalContainer={
|
||||||
|
props.containActionMenus ? menuPortalContainer : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Separator className="bg-sidebar-border/50" />
|
<Separator className="bg-sidebar-border/50" />
|
||||||
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs">
|
<div
|
||||||
<Button
|
className={cn(
|
||||||
type="button"
|
"flex items-center gap-1 px-2.5 py-2.5 text-xs",
|
||||||
variant="ghost"
|
collapsed && "w-14 flex-col px-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarActionButton
|
||||||
|
collapsed={collapsed}
|
||||||
|
label={t("sidebar.settings")}
|
||||||
onClick={props.onOpenSettings}
|
onClick={props.onOpenSettings}
|
||||||
className="h-8 min-w-0 flex-1 justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
className={collapsed ? undefined : "flex-1"}
|
||||||
>
|
icon={<Settings className="h-4 w-4" />}
|
||||||
<Settings className="h-3.5 w-3.5" aria-hidden />
|
/>
|
||||||
{t("sidebar.settings")}
|
|
||||||
</Button>
|
|
||||||
<ConnectionBadge />
|
<ConnectionBadge />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SidebarActionButton({
|
||||||
|
collapsed,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={label}
|
||||||
|
title={collapsed ? label : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
|
||||||
|
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
|
||||||
|
collapsed
|
||||||
|
? "w-9 justify-center gap-0 rounded-xl px-0"
|
||||||
|
: "w-full justify-start gap-2 px-3 text-[12.5px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center justify-center transition-transform duration-300 ease-out",
|
||||||
|
collapsed ? "translate-x-0" : "translate-x-0",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
|
||||||
|
collapsed
|
||||||
|
? "max-w-0 -translate-x-1 opacity-0"
|
||||||
|
: "max-w-[12rem] translate-x-0 opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarViewMenu({
|
function SidebarViewMenu({
|
||||||
|
compact = false,
|
||||||
view,
|
view,
|
||||||
onUpdateView,
|
onUpdateView,
|
||||||
}: {
|
}: {
|
||||||
|
compact?: boolean;
|
||||||
view?: SidebarViewState;
|
view?: SidebarViewState;
|
||||||
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -182,11 +268,28 @@ function SidebarViewMenu({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
aria-label={t("sidebar.viewOptions")}
|
||||||
|
title={compact ? t("sidebar.viewOptions") : undefined}
|
||||||
|
className={cn(
|
||||||
|
"h-8 min-w-0 overflow-hidden font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
|
||||||
|
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
|
||||||
|
compact
|
||||||
|
? "w-9 justify-center gap-0 rounded-xl px-0"
|
||||||
|
: "w-full justify-start gap-2 rounded-full px-3 text-[12.5px]",
|
||||||
|
)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<ListFilter className="h-3.5 w-3.5" aria-hidden />
|
<ListFilter className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
{t("sidebar.viewOptions")}
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
|
||||||
|
compact
|
||||||
|
? "max-w-0 -translate-x-1 opacity-0"
|
||||||
|
: "max-w-[12rem] translate-x-0 opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("sidebar.viewOptions")}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-52">
|
<DropdownMenuContent align="start" className="w-52">
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
|
Package,
|
||||||
|
PlayCircle,
|
||||||
Orbit,
|
Orbit,
|
||||||
Palette,
|
Palette,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -41,6 +43,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
Triangle,
|
Triangle,
|
||||||
Waves,
|
Waves,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -59,14 +62,19 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
|
fetchCliApps,
|
||||||
|
runCliAppAction,
|
||||||
updateImageGenerationSettings,
|
updateImageGenerationSettings,
|
||||||
updateProviderSettings,
|
updateProviderSettings,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
updateWebSearchSettings,
|
updateWebSearchSettings,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
import type {
|
import type {
|
||||||
|
CliAppInfo,
|
||||||
|
CliAppsPayload,
|
||||||
ImageGenerationSettingsUpdate,
|
ImageGenerationSettingsUpdate,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
WebSearchSettingsUpdate,
|
WebSearchSettingsUpdate,
|
||||||
@@ -79,6 +87,7 @@ type SettingsSectionKey =
|
|||||||
| "providers"
|
| "providers"
|
||||||
| "image"
|
| "image"
|
||||||
| "web"
|
| "web"
|
||||||
|
| "cliApps"
|
||||||
| "runtime"
|
| "runtime"
|
||||||
| "advanced";
|
| "advanced";
|
||||||
|
|
||||||
@@ -89,6 +98,7 @@ interface LocalPreferences {
|
|||||||
density: LocalDensity;
|
density: LocalDensity;
|
||||||
activityMode: LocalActivityMode;
|
activityMode: LocalActivityMode;
|
||||||
codeWrap: boolean;
|
codeWrap: boolean;
|
||||||
|
brandLogos: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentSettingsDraft {
|
interface AgentSettingsDraft {
|
||||||
@@ -110,6 +120,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
|||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
activityMode: "auto",
|
activityMode: "auto",
|
||||||
codeWrap: true,
|
codeWrap: true,
|
||||||
|
brandLogos: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
|
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
|
||||||
@@ -146,6 +157,7 @@ function readLocalPreferences(): LocalPreferences {
|
|||||||
density: parsed.density === "compact" ? "compact" : "comfortable",
|
density: parsed.density === "compact" ? "compact" : "comfortable",
|
||||||
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
||||||
codeWrap: parsed.codeWrap !== false,
|
codeWrap: parsed.codeWrap !== false,
|
||||||
|
brandLogos: parsed.brandLogos !== false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_LOCAL_PREFS;
|
return DEFAULT_LOCAL_PREFS;
|
||||||
@@ -177,8 +189,11 @@ export function SettingsView({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = useClient();
|
const { token } = useClient();
|
||||||
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
||||||
|
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
|
||||||
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||||
@@ -186,6 +201,12 @@ export function SettingsView({
|
|||||||
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("overview");
|
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("overview");
|
||||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||||
const [providerQuery, setProviderQuery] = useState("");
|
const [providerQuery, setProviderQuery] = useState("");
|
||||||
|
const [cliAppsQuery, setCliAppsQuery] = useState("");
|
||||||
|
const [cliAppsCategory, setCliAppsCategory] = useState("all");
|
||||||
|
const [cliAppsInstallFilter, setCliAppsInstallFilter] = useState<"all" | "installed" | "notInstalled">("all");
|
||||||
|
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
|
||||||
|
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
|
||||||
|
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
|
||||||
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
|
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
|
||||||
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
||||||
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
||||||
@@ -285,6 +306,27 @@ export function SettingsView({
|
|||||||
};
|
};
|
||||||
}, [applyPayload, token]);
|
}, [applyPayload, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setCliAppsLoading(true);
|
||||||
|
fetchCliApps(token)
|
||||||
|
.then((payload) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCliApps(payload);
|
||||||
|
setCliAppsError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setCliAppsError((err as Error).message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setCliAppsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
|
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
|
||||||
@@ -574,6 +616,29 @@ export function SettingsView({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCliAppAction = async (
|
||||||
|
action: "install" | "update" | "uninstall" | "test",
|
||||||
|
name: string,
|
||||||
|
) => {
|
||||||
|
const key = `${action}:${name}`;
|
||||||
|
setCliAppsAction(key);
|
||||||
|
setCliAppsMessage(null);
|
||||||
|
setCliAppsError(null);
|
||||||
|
try {
|
||||||
|
const payload = await runCliAppAction(token, action, name);
|
||||||
|
setCliApps(payload);
|
||||||
|
if (action !== "test") {
|
||||||
|
notifyCliAppsChanged(payload);
|
||||||
|
}
|
||||||
|
setCliAppsMessage(payload.last_action?.message ?? null);
|
||||||
|
setCliAppsFocusName(action === "uninstall" ? null : name);
|
||||||
|
} catch (err) {
|
||||||
|
setCliAppsError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setCliAppsAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
@@ -618,6 +683,7 @@ export function SettingsView({
|
|||||||
editingProviderKeys={editingProviderKeys}
|
editingProviderKeys={editingProviderKeys}
|
||||||
providerSaving={providerSaving}
|
providerSaving={providerSaving}
|
||||||
query={providerQuery}
|
query={providerQuery}
|
||||||
|
showBrandLogos={localPrefs.brandLogos}
|
||||||
onQueryChange={setProviderQuery}
|
onQueryChange={setProviderQuery}
|
||||||
onToggleProvider={handleToggleProvider}
|
onToggleProvider={handleToggleProvider}
|
||||||
onToggleProviderKey={toggleProviderKeyVisibility}
|
onToggleProviderKey={toggleProviderKeyVisibility}
|
||||||
@@ -677,6 +743,26 @@ export function SettingsView({
|
|||||||
requiresRestartPending={pendingRestartSections.web}
|
requiresRestartPending={pendingRestartSections.web}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "cliApps":
|
||||||
|
return (
|
||||||
|
<CliAppsSettings
|
||||||
|
payload={cliApps}
|
||||||
|
loading={cliAppsLoading}
|
||||||
|
query={cliAppsQuery}
|
||||||
|
category={cliAppsCategory}
|
||||||
|
installFilter={cliAppsInstallFilter}
|
||||||
|
actionKey={cliAppsAction}
|
||||||
|
message={cliAppsMessage}
|
||||||
|
error={cliAppsError}
|
||||||
|
focusName={cliAppsFocusName}
|
||||||
|
showBrandLogos={localPrefs.brandLogos}
|
||||||
|
onQueryChange={setCliAppsQuery}
|
||||||
|
onCategoryChange={setCliAppsCategory}
|
||||||
|
onInstallFilterChange={setCliAppsInstallFilter}
|
||||||
|
onAction={handleCliAppAction}
|
||||||
|
onBackToChat={onBackToChat}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "runtime":
|
case "runtime":
|
||||||
return (
|
return (
|
||||||
<RuntimeSettings
|
<RuntimeSettings
|
||||||
@@ -752,6 +838,7 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal
|
|||||||
{ key: "providers", icon: KeyRound, fallback: "Providers" },
|
{ key: "providers", icon: KeyRound, fallback: "Providers" },
|
||||||
{ key: "image", icon: ImageIcon, fallback: "Image" },
|
{ key: "image", icon: ImageIcon, fallback: "Image" },
|
||||||
{ key: "web", icon: Globe2, fallback: "Web" },
|
{ key: "web", icon: Globe2, fallback: "Web" },
|
||||||
|
{ key: "cliApps", icon: Package, fallback: "CLI Apps" },
|
||||||
{ key: "runtime", icon: Server, fallback: "Runtime" },
|
{ key: "runtime", icon: Server, fallback: "Runtime" },
|
||||||
{ key: "advanced", icon: ShieldCheck, fallback: "Advanced" },
|
{ key: "advanced", icon: ShieldCheck, fallback: "Advanced" },
|
||||||
];
|
];
|
||||||
@@ -1077,6 +1164,16 @@ function AppearanceSettings({
|
|||||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
<SettingsRow
|
||||||
|
title={tx("settings.rows.brandLogos", "Brand logos")}
|
||||||
|
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
|
||||||
|
>
|
||||||
|
<ToggleButton
|
||||||
|
checked={localPrefs.brandLogos}
|
||||||
|
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
||||||
|
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -1211,6 +1308,7 @@ function ProvidersSettings({
|
|||||||
editingProviderKeys,
|
editingProviderKeys,
|
||||||
providerSaving,
|
providerSaving,
|
||||||
query,
|
query,
|
||||||
|
showBrandLogos,
|
||||||
onQueryChange,
|
onQueryChange,
|
||||||
onToggleProvider,
|
onToggleProvider,
|
||||||
onToggleProviderKey,
|
onToggleProviderKey,
|
||||||
@@ -1229,6 +1327,7 @@ function ProvidersSettings({
|
|||||||
editingProviderKeys: Record<string, boolean>;
|
editingProviderKeys: Record<string, boolean>;
|
||||||
providerSaving: string | null;
|
providerSaving: string | null;
|
||||||
query: string;
|
query: string;
|
||||||
|
showBrandLogos: boolean;
|
||||||
onQueryChange: (query: string) => void;
|
onQueryChange: (query: string) => void;
|
||||||
onToggleProvider: (provider: string) => void;
|
onToggleProvider: (provider: string) => void;
|
||||||
onToggleProviderKey: (provider: string) => void;
|
onToggleProviderKey: (provider: string) => void;
|
||||||
@@ -1272,7 +1371,10 @@ function ProvidersSettings({
|
|||||||
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
|
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-3">
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
<ProviderIcon provider={provider.name} />
|
<ProviderIcon
|
||||||
|
provider={provider.name}
|
||||||
|
showBrandLogos={showBrandLogos}
|
||||||
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
|
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
|
||||||
{provider.label}
|
{provider.label}
|
||||||
@@ -1437,6 +1539,7 @@ function ProvidersSettings({
|
|||||||
>
|
>
|
||||||
{filteredUnconfigured.map(renderProviderRow)}
|
{filteredUnconfigured.map(renderProviderRow)}
|
||||||
</ProviderSection>
|
</ProviderSection>
|
||||||
|
<ThirdPartyBrandNotice />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1831,6 +1934,383 @@ function WebSettings({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CliAppsSettings({
|
||||||
|
payload,
|
||||||
|
loading,
|
||||||
|
query,
|
||||||
|
category,
|
||||||
|
installFilter,
|
||||||
|
actionKey,
|
||||||
|
message,
|
||||||
|
error,
|
||||||
|
focusName,
|
||||||
|
showBrandLogos,
|
||||||
|
onQueryChange,
|
||||||
|
onCategoryChange,
|
||||||
|
onInstallFilterChange,
|
||||||
|
onAction,
|
||||||
|
onBackToChat,
|
||||||
|
}: {
|
||||||
|
payload: CliAppsPayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
query: string;
|
||||||
|
category: string;
|
||||||
|
installFilter: "all" | "installed" | "notInstalled";
|
||||||
|
actionKey: string | null;
|
||||||
|
message: string | null;
|
||||||
|
error: string | null;
|
||||||
|
focusName: string | null;
|
||||||
|
showBrandLogos: boolean;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onCategoryChange: (value: string) => void;
|
||||||
|
onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void;
|
||||||
|
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
||||||
|
onBackToChat: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
|
const apps = payload?.apps ?? [];
|
||||||
|
const categories = useMemo(
|
||||||
|
() => ["all", ...Array.from(new Set(apps.map((app) => app.category))).sort()],
|
||||||
|
[apps],
|
||||||
|
);
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const filteredApps = apps.filter((app) => {
|
||||||
|
const categoryMatch = category === "all" || app.category === category;
|
||||||
|
if (!categoryMatch) return false;
|
||||||
|
if (installFilter === "installed" && !app.installed) return false;
|
||||||
|
if (installFilter === "notInstalled" && app.installed) return false;
|
||||||
|
if (!normalizedQuery) return true;
|
||||||
|
return (
|
||||||
|
app.display_name.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
app.name.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
app.description.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
app.category.toLowerCase().includes(normalizedQuery)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const categoryLabel =
|
||||||
|
category === "all"
|
||||||
|
? tx("settings.cliApps.allCategories", "All categories")
|
||||||
|
: category;
|
||||||
|
const installFilterOptions = [
|
||||||
|
{ value: "all", label: tx("settings.cliApps.filterAll", "All") },
|
||||||
|
{ value: "installed", label: tx("settings.cliApps.filterInstalled", "Installed CLIs") },
|
||||||
|
{ value: "notInstalled", label: tx("settings.cliApps.filterNotInstalled", "Not installed") },
|
||||||
|
];
|
||||||
|
const focusedApp = focusName
|
||||||
|
? apps.find((app) => app.name === focusName && app.installed)
|
||||||
|
: null;
|
||||||
|
const visibleStatusMessage = error || (!focusedApp ? message : null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<SettingsSectionTitle>{tx("settings.sections.cliApps", "CLI Apps")}</SettingsSectionTitle>
|
||||||
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
|
{tx("settings.cliApps.summary", "{{installed}} of {{total}} CLIs installed")
|
||||||
|
.replace("{{installed}}", String(payload?.installed_count ?? 0))
|
||||||
|
.replace("{{total}}", String(apps.length))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={installFilter}
|
||||||
|
options={installFilterOptions}
|
||||||
|
onChange={(value) => onInstallFilterChange(value as "all" | "installed" | "notInstalled")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
placeholder={tx("settings.cliApps.searchPlaceholder", "Search CLIs")}
|
||||||
|
className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-10 justify-between rounded-full bg-card/80 px-4">
|
||||||
|
<span className="max-w-[180px] truncate">{categoryLabel}</span>
|
||||||
|
<ChevronDown className="ml-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="max-h-[320px] overflow-y-auto">
|
||||||
|
{categories.map((item) => (
|
||||||
|
<DropdownMenuItem key={item} onClick={() => onCategoryChange(item)}>
|
||||||
|
{item === "all" ? tx("settings.cliApps.allCategories", "All categories") : item}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{visibleStatusMessage ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-[10px] border px-3.5 py-2.5 text-[12.5px]",
|
||||||
|
error
|
||||||
|
? "border-destructive/20 bg-destructive/5 text-destructive"
|
||||||
|
: "border-border/55 bg-muted/35 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visibleStatusMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{focusedApp ? (
|
||||||
|
<CliAppReadyPanel
|
||||||
|
app={focusedApp}
|
||||||
|
showBrandLogos={showBrandLogos}
|
||||||
|
onBackToChat={onBackToChat}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-36 items-center justify-center rounded-[8px] border border-border/45 bg-card/82 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
{tx("settings.cliApps.loading", "Loading CLI Apps...")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{filteredApps.map((app) => (
|
||||||
|
<CliAppCard
|
||||||
|
key={app.name}
|
||||||
|
app={app}
|
||||||
|
actionKey={actionKey}
|
||||||
|
showBrandLogos={showBrandLogos}
|
||||||
|
onAction={onAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!filteredApps.length ? (
|
||||||
|
<div className="rounded-[8px] border border-border/45 bg-card/82 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{tx("settings.cliApps.empty", "No CLI Apps match this filter.")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<ThirdPartyBrandNotice />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CliAppReadyPanel({
|
||||||
|
app,
|
||||||
|
showBrandLogos,
|
||||||
|
onBackToChat,
|
||||||
|
}: {
|
||||||
|
app: CliAppInfo;
|
||||||
|
showBrandLogos: boolean;
|
||||||
|
onBackToChat: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const prompt = t("settings.cliApps.readyPrompt", {
|
||||||
|
name: app.name,
|
||||||
|
defaultValue: "Use @{{name}} to inspect what this CLI can do.",
|
||||||
|
});
|
||||||
|
const copyPrompt = () => {
|
||||||
|
if (!navigator.clipboard) return;
|
||||||
|
void navigator.clipboard.writeText(prompt).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1400);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"rounded-[12px] border border-border/55 bg-card/88 px-4 py-3",
|
||||||
|
"shadow-[0_8px_26px_rgba(15,23,42,0.055)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
||||||
|
{app.display_name}
|
||||||
|
</h3>
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
|
||||||
|
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-300" aria-hidden />
|
||||||
|
{t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||||
|
<span className="font-mono">@{app.name}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="truncate font-mono">{app.entry_point || app.name}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>{app.category}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={copyPrompt}
|
||||||
|
className="h-8 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted/65 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
|
||||||
|
{copied
|
||||||
|
? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
|
||||||
|
: t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={onBackToChat}
|
||||||
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||||
|
>
|
||||||
|
{t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
|
||||||
|
<ChevronRight className="ml-1.5 h-3.5 w-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CliAppCard({
|
||||||
|
app,
|
||||||
|
actionKey,
|
||||||
|
showBrandLogos,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
app: CliAppInfo;
|
||||||
|
actionKey: string | null;
|
||||||
|
showBrandLogos: boolean;
|
||||||
|
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
|
const installBusy = actionKey === `install:${app.name}`;
|
||||||
|
const updateBusy = actionKey === `update:${app.name}`;
|
||||||
|
const uninstallBusy = actionKey === `uninstall:${app.name}`;
|
||||||
|
const testBusy = actionKey === `test:${app.name}`;
|
||||||
|
const busy = installBusy || updateBusy || uninstallBusy || testBusy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex min-w-0 items-center gap-3 rounded-[8px] border border-border/45 bg-card/82 px-4 py-3 shadow-[0_6px_22px_rgba(15,23,42,0.045)]">
|
||||||
|
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-baseline gap-2">
|
||||||
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
||||||
|
{app.display_name}
|
||||||
|
</h3>
|
||||||
|
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
|
||||||
|
{app.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-[12px] text-muted-foreground">
|
||||||
|
{app.entry_point || app.name}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-[12px] leading-5 text-muted-foreground">
|
||||||
|
{app.requires
|
||||||
|
? `${tx("settings.cliApps.requires", "Requires")}: ${app.requires}`
|
||||||
|
: app.description || tx("settings.cliApps.noDescription", "No description available.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{app.installed ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
className="h-8 rounded-full border-emerald-500/20 bg-emerald-500/10 px-3 text-[12px] font-semibold text-emerald-700 hover:bg-emerald-500/12 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
|
||||||
|
{tx("settings.cliApps.statusInstalled", "CLI installed")}
|
||||||
|
<ChevronDown className="ml-1.5 h-3 w-3" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
|
||||||
|
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
{tx("settings.cliApps.test", "Test CLI")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
{tx("settings.cliApps.update", "Update CLI")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}>
|
||||||
|
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : app.install_supported ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onAction("install", app.name)}
|
||||||
|
className="h-8 rounded-full px-4 text-[12px] font-semibold"
|
||||||
|
>
|
||||||
|
{installBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
|
||||||
|
{tx("settings.cliApps.install", "Install CLI")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||||
|
>
|
||||||
|
{tx("settings.cliApps.unavailable", "Unavailable")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const bg = app.brand_color || "hsl(var(--muted))";
|
||||||
|
const initials = app.display_name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase())
|
||||||
|
.join("") || app.name.slice(0, 2).toUpperCase();
|
||||||
|
if (showBrandLogos && app.logo_url && !failed) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
||||||
|
style={{ boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}22` }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={app.logo_url}
|
||||||
|
alt=""
|
||||||
|
className="h-6 w-6 object-contain"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
|
||||||
|
style={{ backgroundColor: bg }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RuntimeSettings({
|
function RuntimeSettings({
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
@@ -2079,6 +2559,18 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThirdPartyBrandNotice() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
|
||||||
|
{t("settings.legal.thirdPartyBrands", {
|
||||||
|
defaultValue:
|
||||||
|
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function orderUnconfiguredProviders(
|
function orderUnconfiguredProviders(
|
||||||
providers: SettingsPayload["providers"],
|
providers: SettingsPayload["providers"],
|
||||||
): SettingsPayload["providers"] {
|
): SettingsPayload["providers"] {
|
||||||
@@ -2126,6 +2618,63 @@ function providerLabel(
|
|||||||
return providers.find((provider) => provider.name === value)?.label ?? value;
|
return providers.find((provider) => provider.name === value)?.label ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProviderBrand {
|
||||||
|
logoUrl: string;
|
||||||
|
color: string;
|
||||||
|
initials: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function faviconUrl(domain: string): string {
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_BRAND_ALIASES: Record<string, string> = {
|
||||||
|
byteplus_coding_plan: "byteplus",
|
||||||
|
minimax_anthropic: "minimax",
|
||||||
|
openai_codex: "openai",
|
||||||
|
volcengine_coding_plan: "volcengine",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
|
||||||
|
aihubmix: { logoUrl: faviconUrl("aihubmix.com"), color: "#111827", initials: "AH" },
|
||||||
|
ant_ling: { logoUrl: faviconUrl("ant-ling.com"), color: "#7C3AED", initials: "AL" },
|
||||||
|
anthropic: { logoUrl: faviconUrl("anthropic.com"), color: "#D97757", initials: "A" },
|
||||||
|
atomic_chat: { logoUrl: faviconUrl("atomic.chat"), color: "#111827", initials: "AC" },
|
||||||
|
azure_openai: { logoUrl: faviconUrl("azure.microsoft.com"), color: "#0078D4", initials: "AZ" },
|
||||||
|
bedrock: { logoUrl: faviconUrl("aws.amazon.com"), color: "#FF9900", initials: "AWS" },
|
||||||
|
byteplus: { logoUrl: faviconUrl("byteplus.com"), color: "#325CFF", initials: "BP" },
|
||||||
|
dashscope: { logoUrl: faviconUrl("dashscope.aliyun.com"), color: "#FF6A00", initials: "DS" },
|
||||||
|
deepseek: { logoUrl: faviconUrl("deepseek.com"), color: "#4D6BFE", initials: "DS" },
|
||||||
|
gemini: { logoUrl: faviconUrl("gemini.google.com"), color: "#4285F4", initials: "G" },
|
||||||
|
github_copilot: { logoUrl: faviconUrl("github.com"), color: "#24292F", initials: "GH" },
|
||||||
|
groq: { logoUrl: faviconUrl("groq.com"), color: "#F55036", initials: "GQ" },
|
||||||
|
huggingface: { logoUrl: faviconUrl("huggingface.co"), color: "#FF9D00", initials: "HF" },
|
||||||
|
lm_studio: { logoUrl: faviconUrl("lmstudio.ai"), color: "#111827", initials: "LM" },
|
||||||
|
longcat: { logoUrl: faviconUrl("longcat.chat"), color: "#111827", initials: "LC" },
|
||||||
|
minimax: { logoUrl: faviconUrl("minimax.io"), color: "#111827", initials: "MM" },
|
||||||
|
mistral: { logoUrl: faviconUrl("mistral.ai"), color: "#FA520F", initials: "M" },
|
||||||
|
moonshot: { logoUrl: faviconUrl("moonshot.ai"), color: "#111827", initials: "MS" },
|
||||||
|
novita: { logoUrl: faviconUrl("novita.ai"), color: "#7C3AED", initials: "N" },
|
||||||
|
nvidia: { logoUrl: faviconUrl("nvidia.com"), color: "#76B900", initials: "NV" },
|
||||||
|
ollama: { logoUrl: faviconUrl("ollama.com"), color: "#111827", initials: "O" },
|
||||||
|
openai: { logoUrl: faviconUrl("openai.com"), color: "#111827", initials: "AI" },
|
||||||
|
openrouter: { logoUrl: faviconUrl("openrouter.ai"), color: "#111827", initials: "OR" },
|
||||||
|
ovms: { logoUrl: faviconUrl("openvino.ai"), color: "#0071C5", initials: "OV" },
|
||||||
|
qianfan: { logoUrl: faviconUrl("cloud.baidu.com"), color: "#2932E1", initials: "QF" },
|
||||||
|
siliconflow: { logoUrl: faviconUrl("siliconflow.cn"), color: "#111827", initials: "SF" },
|
||||||
|
skywork: { logoUrl: faviconUrl("skywork.ai"), color: "#5B5BF6", initials: "SW" },
|
||||||
|
stepfun: { logoUrl: faviconUrl("stepfun.com"), color: "#2F6BFF", initials: "SF" },
|
||||||
|
volcengine: { logoUrl: faviconUrl("volcengine.com"), color: "#1664FF", initials: "VE" },
|
||||||
|
vllm: { logoUrl: faviconUrl("vllm.ai"), color: "#2563EB", initials: "VL" },
|
||||||
|
xiaomi_mimo: { logoUrl: faviconUrl("xiaomimimo.com"), color: "#FF6900", initials: "MI" },
|
||||||
|
zhipu: { logoUrl: faviconUrl("bigmodel.cn"), color: "#155EEF", initials: "Z" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function providerBrand(provider: string): ProviderBrand | null {
|
||||||
|
const key = PROVIDER_BRAND_ALIASES[provider] ?? provider;
|
||||||
|
return PROVIDER_BRANDS[key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||||
custom: Hexagon,
|
custom: Hexagon,
|
||||||
openrouter: Sparkles,
|
openrouter: Sparkles,
|
||||||
@@ -2160,8 +2709,44 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
|||||||
nvidia: Zap,
|
nvidia: Zap,
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProviderIcon({ provider }: { provider: string }) {
|
function ProviderIcon({
|
||||||
|
provider,
|
||||||
|
showBrandLogos,
|
||||||
|
}: {
|
||||||
|
provider: string;
|
||||||
|
showBrandLogos: boolean;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const brand = providerBrand(provider);
|
||||||
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
||||||
|
if (showBrandLogos && brand?.logoUrl && !failed) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid={`provider-logo-${provider}`}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
|
||||||
|
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={brand.logoUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-6 w-6 object-contain"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (showBrandLogos && brand) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid={`provider-logo-fallback-${provider}`}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
|
||||||
|
style={{ backgroundColor: brand.color }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{brand.initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
|
||||||
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />
|
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
|
import { AlertCircle, ChevronRight, Layers, Terminal } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { cliAppInitials } from "@/components/CliAppMentionText";
|
||||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UIFileEdit, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||||
@@ -24,13 +25,17 @@ export function isAgentActivityMember(m: UIMessage): boolean {
|
|||||||
interface ActivityCounts {
|
interface ActivityCounts {
|
||||||
reasoningSteps: number;
|
reasoningSteps: number;
|
||||||
toolCalls: number;
|
toolCalls: number;
|
||||||
|
cliCount: number;
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
added: number;
|
added: number;
|
||||||
deleted: number;
|
deleted: number;
|
||||||
|
hasDiffStats: boolean;
|
||||||
hasEditingFiles: boolean;
|
hasEditingFiles: boolean;
|
||||||
hasFailedFiles: boolean;
|
hasFailedFiles: boolean;
|
||||||
primaryFilePath?: string;
|
primaryFilePath?: string;
|
||||||
primaryFileTooltipPath?: string;
|
primaryFileTooltipPath?: string;
|
||||||
|
primaryCliName?: string;
|
||||||
|
primaryCliStatus?: CliRunStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileEditSummary {
|
interface FileEditSummary {
|
||||||
@@ -46,21 +51,46 @@ interface FileEditSummary {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
|
interface CliRunSummary {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
args: string[];
|
||||||
|
json: boolean;
|
||||||
|
workingDir?: string;
|
||||||
|
status: CliRunStatus;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CliRunStatus = "running" | "done" | "error";
|
||||||
|
|
||||||
|
function countActivity(
|
||||||
|
messages: UIMessage[],
|
||||||
|
fileEdits: FileEditSummary[],
|
||||||
|
cliRuns: CliRunSummary[],
|
||||||
|
): ActivityCounts {
|
||||||
let reasoningSteps = 0;
|
let reasoningSteps = 0;
|
||||||
let toolCalls = 0;
|
let toolCalls = 0;
|
||||||
|
const cliCount = cliRuns.length;
|
||||||
|
const primaryCli = cliRuns[cliRuns.length - 1];
|
||||||
|
const primaryCliName = primaryCli?.name;
|
||||||
|
const primaryCliStatus = primaryCli?.status;
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (isReasoningOnlyAssistant(m)) {
|
if (isReasoningOnlyAssistant(m)) {
|
||||||
reasoningSteps += 1;
|
reasoningSteps += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (m.kind === "trace") {
|
if (m.kind === "trace") {
|
||||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
const lines = traceLines(m);
|
||||||
toolCalls += lines;
|
for (const line of lines) {
|
||||||
|
if (!isCliRunTraceLine(line)) {
|
||||||
|
toolCalls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let added = 0;
|
let added = 0;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
|
let hasDiffStats = false;
|
||||||
let hasEditingFiles = false;
|
let hasEditingFiles = false;
|
||||||
let failedFileCount = 0;
|
let failedFileCount = 0;
|
||||||
let primaryFilePath: string | undefined;
|
let primaryFilePath: string | undefined;
|
||||||
@@ -77,19 +107,27 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
|
|||||||
if (edit.status === "error" || edit.binary) {
|
if (edit.status === "error" || edit.binary) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!hasVisibleDiffStats(edit)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hasDiffStats = true;
|
||||||
added += edit.added;
|
added += edit.added;
|
||||||
deleted += edit.deleted;
|
deleted += edit.deleted;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
reasoningSteps,
|
reasoningSteps,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
cliCount,
|
||||||
fileCount: fileEdits.length,
|
fileCount: fileEdits.length,
|
||||||
added,
|
added,
|
||||||
deleted,
|
deleted,
|
||||||
|
hasDiffStats,
|
||||||
hasEditingFiles,
|
hasEditingFiles,
|
||||||
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
||||||
primaryFilePath,
|
primaryFilePath,
|
||||||
primaryFileTooltipPath,
|
primaryFileTooltipPath,
|
||||||
|
primaryCliName,
|
||||||
|
primaryCliStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +136,7 @@ interface AgentActivityClusterProps {
|
|||||||
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
|
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
|
||||||
isTurnStreaming: boolean;
|
isTurnStreaming: boolean;
|
||||||
hasBodyBelow: boolean;
|
hasBodyBelow: boolean;
|
||||||
|
cliApps?: CliAppInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,23 +147,33 @@ export function AgentActivityCluster({
|
|||||||
messages,
|
messages,
|
||||||
isTurnStreaming,
|
isTurnStreaming,
|
||||||
hasBodyBelow,
|
hasBodyBelow,
|
||||||
|
cliApps = [],
|
||||||
}: AgentActivityClusterProps) {
|
}: AgentActivityClusterProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileEdits = useMemo(
|
const fileEdits = useMemo(
|
||||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||||
[messages, isTurnStreaming],
|
[messages, isTurnStreaming],
|
||||||
);
|
);
|
||||||
|
const cliRuns = useMemo(() => collectCliRuns(messages), [messages]);
|
||||||
|
const cliAppsByName = useMemo(
|
||||||
|
() => new Map(cliApps.map((app) => [app.name.toLowerCase(), app])),
|
||||||
|
[cliApps],
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
reasoningSteps,
|
reasoningSteps,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
cliCount,
|
||||||
fileCount,
|
fileCount,
|
||||||
added,
|
added,
|
||||||
deleted,
|
deleted,
|
||||||
|
hasDiffStats,
|
||||||
hasEditingFiles,
|
hasEditingFiles,
|
||||||
hasFailedFiles,
|
hasFailedFiles,
|
||||||
primaryFilePath,
|
primaryFilePath,
|
||||||
primaryFileTooltipPath,
|
primaryFileTooltipPath,
|
||||||
} = countActivity(messages, fileEdits);
|
primaryCliName,
|
||||||
|
primaryCliStatus,
|
||||||
|
} = countActivity(messages, fileEdits, cliRuns);
|
||||||
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
|
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
|
||||||
|
|
||||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||||
@@ -140,6 +189,7 @@ export function AgentActivityCluster({
|
|||||||
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||||
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
|
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
|
||||||
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
|
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
|
||||||
|
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || fileCount > 0;
|
||||||
|
|
||||||
const fileActivitySummary = fileCount > 0
|
const fileActivitySummary = fileCount > 0
|
||||||
? hasPendingFileEdit && !singleFilePath
|
? hasPendingFileEdit && !singleFilePath
|
||||||
@@ -155,8 +205,22 @@ export function AgentActivityCluster({
|
|||||||
})
|
})
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const cliActivitySummary = cliCount > 0
|
||||||
|
? cliCount === 1 && primaryCliName
|
||||||
|
? t(cliActivitySummaryKey(primaryCliStatus, isTurnStreaming), {
|
||||||
|
name: primaryCliName,
|
||||||
|
defaultValue: cliActivitySummaryDefault(primaryCliStatus, isTurnStreaming),
|
||||||
|
})
|
||||||
|
: t(cliActivityManySummaryKey(cliRuns, isTurnStreaming), {
|
||||||
|
count: cliCount,
|
||||||
|
defaultValue: cliActivityManySummaryDefault(cliRuns, isTurnStreaming),
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
const summary = fileCount > 0
|
const summary = fileCount > 0
|
||||||
? fileActivitySummary
|
? fileActivitySummary
|
||||||
|
: cliCount > 0
|
||||||
|
? cliActivitySummary
|
||||||
: isTurnStreaming
|
: isTurnStreaming
|
||||||
? reasoningSteps > 0
|
? reasoningSteps > 0
|
||||||
? t("message.agentActivityLiveSummary", {
|
? t("message.agentActivityLiveSummary", {
|
||||||
@@ -243,6 +307,10 @@ export function AgentActivityCluster({
|
|||||||
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
|
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!hasVisibleActivity) return null;
|
||||||
|
|
||||||
|
const HeaderIcon = cliCount > 0 && fileCount === 0 && toolCalls === 0 ? Terminal : Layers;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||||
<button
|
<button
|
||||||
@@ -255,7 +323,7 @@ export function AgentActivityCluster({
|
|||||||
aria-expanded={outerExpanded}
|
aria-expanded={outerExpanded}
|
||||||
aria-label={summary}
|
aria-label={summary}
|
||||||
>
|
>
|
||||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
<HeaderIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
||||||
{singleFilePath ? (
|
{singleFilePath ? (
|
||||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||||
@@ -282,7 +350,7 @@ export function AgentActivityCluster({
|
|||||||
{summary}
|
{summary}
|
||||||
</StreamingLabelSheen>
|
</StreamingLabelSheen>
|
||||||
)}
|
)}
|
||||||
{fileCount > 0 && (
|
{fileCount > 0 && hasDiffStats && (
|
||||||
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
|
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
|
||||||
<DiffPair added={added} deleted={deleted} />
|
<DiffPair added={added} deleted={deleted} />
|
||||||
</span>
|
</span>
|
||||||
@@ -326,15 +394,25 @@ export function AgentActivityCluster({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (m.kind === "trace") {
|
if (m.kind === "trace") {
|
||||||
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
|
const normalLines = traceLines(m).filter((line) => !parseCliRunTrace(line));
|
||||||
return hasTraceLines ? (
|
return normalLines.length > 0 ? (
|
||||||
<div key={m.id} className="flex flex-col gap-1">
|
<div key={m.id} className="flex flex-col gap-1">
|
||||||
<TraceGroup message={m} animClass="" />
|
<TraceGroup
|
||||||
|
message={{
|
||||||
|
...m,
|
||||||
|
traces: normalLines,
|
||||||
|
content: normalLines[normalLines.length - 1],
|
||||||
|
}}
|
||||||
|
animClass=""
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
{cliRuns.length ? (
|
||||||
|
<CliRunGroup runs={cliRuns} active={isTurnStreaming} cliAppsByName={cliAppsByName} />
|
||||||
|
) : null}
|
||||||
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,6 +426,181 @@ function shortFileName(path: string): string {
|
|||||||
return path.split(/[\\/]/).pop() || path;
|
return path.split(/[\\/]/).pop() || path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function traceLines(message: UIMessage): string[] {
|
||||||
|
if (message.traces?.length) return message.traces;
|
||||||
|
return message.content.trim() ? [message.content] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLI_RUN_TOOL_NAMES = new Set(["run_cli_app", "cli_anything_run"]);
|
||||||
|
const CLI_RUN_STATUS_RANK: Record<CliRunStatus, number> = { running: 1, done: 2, error: 3 };
|
||||||
|
|
||||||
|
function isCliRunTraceLine(line: string): boolean {
|
||||||
|
return /^(run_cli_app|cli_anything_run)\(/.test(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
|
||||||
|
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const argsText = match[2].trim();
|
||||||
|
let argsObject: unknown = {};
|
||||||
|
if (argsText) {
|
||||||
|
try {
|
||||||
|
argsObject = JSON.parse(argsText);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
key: line,
|
||||||
|
name: "cli",
|
||||||
|
args: [argsText],
|
||||||
|
json: false,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cliRunFromArguments(argsObject, { key: line, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolEventArguments(event: ToolProgressEvent): unknown {
|
||||||
|
const fnArgs = (event as { function?: { arguments?: unknown } }).function?.arguments;
|
||||||
|
const raw = fnArgs ?? event.arguments;
|
||||||
|
if (typeof raw !== "string") return raw ?? {};
|
||||||
|
if (!raw.trim()) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { args: [raw] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliRunStatusFromPhase(phase: unknown): CliRunStatus {
|
||||||
|
if (phase === "error") return "error";
|
||||||
|
if (phase === "end") return "done";
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliRunError(event: ToolProgressEvent): string | undefined {
|
||||||
|
const error = event.error;
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
if (error && typeof error === "object") return JSON.stringify(error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliRunFromArguments(
|
||||||
|
argsObject: unknown,
|
||||||
|
options: { key: string; status: CliRunStatus; error?: string },
|
||||||
|
): CliRunSummary {
|
||||||
|
if (!argsObject || typeof argsObject !== "object" || Array.isArray(argsObject)) {
|
||||||
|
return {
|
||||||
|
key: options.key,
|
||||||
|
name: "cli",
|
||||||
|
args: [],
|
||||||
|
json: false,
|
||||||
|
status: options.status,
|
||||||
|
error: options.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const record = argsObject as Record<string, unknown>;
|
||||||
|
const appName = typeof record.name === "string" && record.name.trim()
|
||||||
|
? record.name.trim()
|
||||||
|
: "cli";
|
||||||
|
const rawArgs = Array.isArray(record.args) ? record.args : [];
|
||||||
|
const cliArgs = rawArgs.filter((item): item is string => typeof item === "string");
|
||||||
|
return {
|
||||||
|
key: options.key,
|
||||||
|
name: appName,
|
||||||
|
args: cliArgs,
|
||||||
|
json: record.json === true || record.json === "true",
|
||||||
|
workingDir: typeof record.working_dir === "string" ? record.working_dir : undefined,
|
||||||
|
status: options.status,
|
||||||
|
error: options.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliRunFromEvent(event: ToolProgressEvent): CliRunSummary | null {
|
||||||
|
const name =
|
||||||
|
typeof (event as { function?: { name?: unknown } }).function?.name === "string"
|
||||||
|
? String((event as { function?: { name?: unknown } }).function?.name)
|
||||||
|
: typeof event.name === "string"
|
||||||
|
? event.name
|
||||||
|
: "";
|
||||||
|
if (!CLI_RUN_TOOL_NAMES.has(name)) return null;
|
||||||
|
const argsObject = parseToolEventArguments(event);
|
||||||
|
const key = event.call_id ? `call:${event.call_id}` : `${name}:${JSON.stringify(argsObject)}`;
|
||||||
|
return cliRunFromArguments(argsObject, {
|
||||||
|
key,
|
||||||
|
status: cliRunStatusFromPhase(event.phase),
|
||||||
|
error: cliRunError(event),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCliRun(existing: CliRunSummary | undefined, incoming: CliRunSummary): CliRunSummary {
|
||||||
|
if (!existing) return incoming;
|
||||||
|
return CLI_RUN_STATUS_RANK[incoming.status] >= CLI_RUN_STATUS_RANK[existing.status]
|
||||||
|
? { ...existing, ...incoming }
|
||||||
|
: existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCliRuns(messages: UIMessage[]): CliRunSummary[] {
|
||||||
|
const runsByKey = new Map<string, CliRunSummary>();
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.kind !== "trace") continue;
|
||||||
|
let hasStructuredCliRun = false;
|
||||||
|
for (const event of message.toolEvents ?? []) {
|
||||||
|
const run = cliRunFromEvent(event);
|
||||||
|
if (!run) continue;
|
||||||
|
hasStructuredCliRun = true;
|
||||||
|
runsByKey.set(run.key, mergeCliRun(runsByKey.get(run.key), run));
|
||||||
|
}
|
||||||
|
if (hasStructuredCliRun) continue;
|
||||||
|
for (const line of traceLines(message)) {
|
||||||
|
const run = parseCliRunTrace(line);
|
||||||
|
if (!run || runsByKey.has(run.key)) continue;
|
||||||
|
runsByKey.set(run.key, run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...runsByKey.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayCliArg(arg: string): string {
|
||||||
|
return /\s/.test(arg) ? JSON.stringify(arg) : arg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCliArgs(run: CliRunSummary): string {
|
||||||
|
const args = [...(run.json ? ["--json"] : []), ...run.args].map(displayCliArg);
|
||||||
|
return args.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliActivitySummaryKey(status: CliRunStatus | undefined, active: boolean): string {
|
||||||
|
if (status === "error") return "message.cliActivityFailedOne";
|
||||||
|
return active && status === "running" ? "message.cliActivityRunningOne" : "message.cliActivityRanOne";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliActivitySummaryDefault(status: CliRunStatus | undefined, active: boolean): string {
|
||||||
|
if (status === "error") return "CLI failed @{{name}}";
|
||||||
|
return `${active && status === "running" ? "Running" : "Ran"} CLI @{{name}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliActivityManySummaryKey(runs: CliRunSummary[], active: boolean): string {
|
||||||
|
if (runs.some((run) => run.status === "error")) return "message.cliActivityFailedMany";
|
||||||
|
return active && runs.some((run) => run.status === "running")
|
||||||
|
? "message.cliActivityRunningMany"
|
||||||
|
: "message.cliActivityRanMany";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliActivityManySummaryDefault(runs: CliRunSummary[], active: boolean): string {
|
||||||
|
if (runs.some((run) => run.status === "error")) return "{{count}} CLI failed";
|
||||||
|
return `${active && runs.some((run) => run.status === "running") ? "Running" : "Ran"} {{count}} CLIs`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliRunLabelKey(run: CliRunSummary, active: boolean): string {
|
||||||
|
if (run.status === "error") return "message.cliRunFailed";
|
||||||
|
return active && run.status === "running" ? "message.cliRunRunning" : "message.cliRunRan";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliRunLabelDefault(run: CliRunSummary, active: boolean): string {
|
||||||
|
if (run.status === "error") return "CLI failed";
|
||||||
|
return active && run.status === "running" ? "Running CLI" : "Ran CLI";
|
||||||
|
}
|
||||||
|
|
||||||
function fileActivityVerb(editing: boolean, failed: boolean): string {
|
function fileActivityVerb(editing: boolean, failed: boolean): string {
|
||||||
if (failed) return "Failed";
|
if (failed) return "Failed";
|
||||||
return editing ? "Editing" : "Edited";
|
return editing ? "Editing" : "Edited";
|
||||||
@@ -435,6 +688,17 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
summary.absolute_path = edit.absolute_path;
|
summary.absolute_path = edit.absolute_path;
|
||||||
}
|
}
|
||||||
summary.pending = summary.pending || !!edit.pending || !edit.path;
|
summary.pending = summary.pending || !!edit.pending || !edit.path;
|
||||||
|
if (!edit.path && edit.pending) {
|
||||||
|
if (active && edit.status === "editing") {
|
||||||
|
summary.hasActiveEditing = true;
|
||||||
|
summary.approximate = summary.approximate || !!edit.approximate;
|
||||||
|
if (!edit.binary) {
|
||||||
|
summary.added += edit.added;
|
||||||
|
summary.deleted += edit.deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (active && edit.status === "editing") {
|
if (active && edit.status === "editing") {
|
||||||
summary.hasActiveEditing = true;
|
summary.hasActiveEditing = true;
|
||||||
summary.binary = summary.binary || !!edit.binary;
|
summary.binary = summary.binary || !!edit.binary;
|
||||||
@@ -461,8 +725,16 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return order.map((key) => {
|
return order.flatMap((key) => {
|
||||||
const summary = byPath.get(key)!;
|
const summary = byPath.get(key)!;
|
||||||
|
if (
|
||||||
|
!summary.path
|
||||||
|
&& !summary.hasActiveEditing
|
||||||
|
&& !summary.hasSuccessfulChange
|
||||||
|
&& !summary.hasFailed
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const status: UIFileEdit["status"] = summary.hasActiveEditing
|
const status: UIFileEdit["status"] = summary.hasActiveEditing
|
||||||
? "editing"
|
? "editing"
|
||||||
: summary.hasSuccessfulChange
|
: summary.hasSuccessfulChange
|
||||||
@@ -470,7 +742,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
: summary.hasFailed
|
: summary.hasFailed
|
||||||
? "error"
|
? "error"
|
||||||
: "done";
|
: "done";
|
||||||
return {
|
return [{
|
||||||
key: summary.key,
|
key: summary.key,
|
||||||
path: summary.path,
|
path: summary.path,
|
||||||
absolute_path: summary.absolute_path,
|
absolute_path: summary.absolute_path,
|
||||||
@@ -481,10 +753,128 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
status,
|
status,
|
||||||
pending: summary.pending && !summary.path,
|
pending: summary.pending && !summary.path,
|
||||||
error: summary.error,
|
error: summary.error,
|
||||||
};
|
}];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
|
||||||
|
return edit.added > 0 || edit.deleted > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CliRunGroup({
|
||||||
|
runs,
|
||||||
|
active,
|
||||||
|
cliAppsByName,
|
||||||
|
}: {
|
||||||
|
runs: CliRunSummary[];
|
||||||
|
active: boolean;
|
||||||
|
cliAppsByName: Map<string, CliAppInfo>;
|
||||||
|
}) {
|
||||||
|
if (runs.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1 border-l border-cyan-500/20 pl-3" data-testid="activity-cli-runs">
|
||||||
|
{runs.map((run) => (
|
||||||
|
<CliRunRow
|
||||||
|
key={run.key}
|
||||||
|
run={run}
|
||||||
|
active={active}
|
||||||
|
app={cliAppsByName.get(run.name.toLowerCase())}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [logoFailed, setLogoFailed] = useState(false);
|
||||||
|
const args = formatCliArgs(run);
|
||||||
|
const failed = run.status === "error";
|
||||||
|
const rowActive = active && run.status === "running";
|
||||||
|
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
|
||||||
|
const logoUrl = app?.logo_url && !logoFailed ? app.logo_url : null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-0 grid-cols-[minmax(0,1fr)] rounded-[10px] border px-2.5 py-2 text-xs",
|
||||||
|
"shadow-[0_6px_18px_rgba(15,23,42,0.045)] transition-colors",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: alphaColor(color, rowActive ? 34 : failed ? 28 : 22),
|
||||||
|
backgroundColor: alphaColor(color, rowActive ? 9 : failed ? 7 : 6),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
|
||||||
|
className={cn(
|
||||||
|
"grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-[8px] border text-[10px] font-semibold text-white",
|
||||||
|
rowActive && "animate-pulse",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: alphaColor(color, 26),
|
||||||
|
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
|
||||||
|
boxShadow: `0 0 0 3px ${alphaColor(color, rowActive ? 10 : 6)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-[70%] w-[70%] object-contain"
|
||||||
|
onError={() => setLogoFailed(true)}
|
||||||
|
/>
|
||||||
|
) : app ? (
|
||||||
|
cliAppInitials(app).slice(0, 2)
|
||||||
|
) : (
|
||||||
|
<Terminal className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<StreamingLabelSheen active={rowActive} className="shrink-0 text-[12px]">
|
||||||
|
{t(cliRunLabelKey(run, active), {
|
||||||
|
defaultValue: cliRunLabelDefault(run, active),
|
||||||
|
})}
|
||||||
|
</StreamingLabelSheen>
|
||||||
|
<span className="min-w-0 truncate font-mono text-[12px] font-semibold text-foreground/90">
|
||||||
|
@{run.name}
|
||||||
|
</span>
|
||||||
|
{failed ? (
|
||||||
|
<AlertCircle className="h-3 w-3 shrink-0 text-destructive/75" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{args ? (
|
||||||
|
<span className="mt-0.5 block truncate font-mono text-[11px] leading-relaxed text-muted-foreground/82">
|
||||||
|
{args}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{run.error ? (
|
||||||
|
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-destructive/70">
|
||||||
|
{run.error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{run.workingDir ? (
|
||||||
|
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-muted-foreground/58">
|
||||||
|
{run.workingDir}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alphaColor(color: string, percent: number): string {
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(color)) {
|
||||||
|
const alpha = Math.round((percent / 100) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
return `${color}${alpha}`;
|
||||||
|
}
|
||||||
|
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
|
||||||
|
}
|
||||||
|
|
||||||
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
||||||
if (edits.length === 0) return null;
|
if (edits.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
@@ -500,7 +890,7 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editing = edit.status === "editing";
|
const editing = edit.status === "editing";
|
||||||
const failed = edit.status === "error";
|
const failed = edit.status === "error";
|
||||||
const hasCountedDiff = !failed && !edit.binary;
|
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
|
||||||
return (
|
return (
|
||||||
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
|
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
|||||||
@@ -9,9 +9,16 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
|
import {
|
||||||
|
CliAppMentionToken,
|
||||||
|
cliAppInitials,
|
||||||
|
splitCliAppMentionSegments,
|
||||||
|
type CliAppMentionSegment,
|
||||||
|
} from "@/components/CliAppMentionText";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
AtSign,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -41,7 +48,7 @@ import {
|
|||||||
} from "@/hooks/useAttachedImages";
|
} from "@/hooks/useAttachedImages";
|
||||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||||
import type { SlashCommand, GoalStateWsPayload } from "@/lib/types";
|
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
|
||||||
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
|
||||||
@@ -62,6 +69,7 @@ interface ThreadComposerProps {
|
|||||||
modelLabel?: string | null;
|
modelLabel?: string | null;
|
||||||
variant?: "thread" | "hero";
|
variant?: "thread" | "hero";
|
||||||
slashCommands?: SlashCommand[];
|
slashCommands?: SlashCommand[];
|
||||||
|
cliApps?: CliAppInfo[];
|
||||||
imageMode?: boolean;
|
imageMode?: boolean;
|
||||||
onImageModeChange?: (enabled: boolean) => void;
|
onImageModeChange?: (enabled: boolean) => void;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
@@ -98,6 +106,12 @@ interface SlashPaletteLayout {
|
|||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CliAppMentionQuery {
|
||||||
|
query: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
function slashCommandI18nKey(command: string): string {
|
function slashCommandI18nKey(command: string): string {
|
||||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
return command.replace(/^\//, "").replace(/-/g, "_");
|
||||||
}
|
}
|
||||||
@@ -167,6 +181,17 @@ function buildGoalMarkdownBody(summary: string, objective: string): string {
|
|||||||
return o || s;
|
return o || s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
|
||||||
|
return {
|
||||||
|
name: app.name,
|
||||||
|
display_name: app.display_name,
|
||||||
|
category: app.category,
|
||||||
|
entry_point: app.entry_point,
|
||||||
|
logo_url: app.logo_url ?? null,
|
||||||
|
brand_color: app.brand_color ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function RunElapsedStrip({
|
function RunElapsedStrip({
|
||||||
startedAt,
|
startedAt,
|
||||||
goalState,
|
goalState,
|
||||||
@@ -371,6 +396,7 @@ export function ThreadComposer({
|
|||||||
modelLabel = null,
|
modelLabel = null,
|
||||||
variant = "thread",
|
variant = "thread",
|
||||||
slashCommands = [],
|
slashCommands = [],
|
||||||
|
cliApps = [],
|
||||||
imageMode: controlledImageMode,
|
imageMode: controlledImageMode,
|
||||||
onImageModeChange,
|
onImageModeChange,
|
||||||
onStop,
|
onStop,
|
||||||
@@ -382,6 +408,9 @@ export function ThreadComposer({
|
|||||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||||
|
const [cliAppMenuDismissed, setCliAppMenuDismissed] = useState(false);
|
||||||
|
const [selectedCliAppIndex, setSelectedCliAppIndex] = useState(0);
|
||||||
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
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);
|
||||||
@@ -491,6 +520,52 @@ export function ThreadComposer({
|
|||||||
}, [slashCommands, slashQuery, t]);
|
}, [slashCommands, slashQuery, t]);
|
||||||
|
|
||||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||||
|
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||||
|
if (disabled || cliAppMenuDismissed) return null;
|
||||||
|
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||||
|
const beforeCaret = value.slice(0, caret);
|
||||||
|
const match = /(?:^|\s)@([a-z0-9_-]*)$/i.exec(beforeCaret);
|
||||||
|
if (!match) return null;
|
||||||
|
const query = match[1].toLowerCase();
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
start: caret - query.length - 1,
|
||||||
|
end: caret,
|
||||||
|
};
|
||||||
|
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
||||||
|
|
||||||
|
const filteredCliApps = useMemo(() => {
|
||||||
|
if (!cliAppMention) return [];
|
||||||
|
return cliApps
|
||||||
|
.filter((app) => app.installed)
|
||||||
|
.filter((app) => {
|
||||||
|
const haystack = [
|
||||||
|
app.name,
|
||||||
|
app.display_name,
|
||||||
|
app.category,
|
||||||
|
app.description,
|
||||||
|
app.entry_point,
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
return haystack.includes(cliAppMention.query);
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [cliAppMention, cliApps]);
|
||||||
|
|
||||||
|
const showCliAppMenu = filteredCliApps.length > 0;
|
||||||
|
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
||||||
|
const mentionSegments = useMemo(
|
||||||
|
() => splitCliAppMentionSegments(value, cliApps),
|
||||||
|
[cliApps, value],
|
||||||
|
);
|
||||||
|
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
|
||||||
|
const activeCliMentionApps = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return mentionSegments.flatMap((segment) => {
|
||||||
|
if (segment.kind !== "cli" || seen.has(segment.app.name)) return [];
|
||||||
|
seen.add(segment.app.name);
|
||||||
|
return [segment.app];
|
||||||
|
});
|
||||||
|
}, [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,
|
||||||
@@ -500,6 +575,10 @@ export function ThreadComposer({
|
|||||||
setSelectedCommandIndex(0);
|
setSelectedCommandIndex(0);
|
||||||
}, [slashQuery]);
|
}, [slashQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedCliAppIndex(0);
|
||||||
|
}, [cliAppMention?.query]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCommandIndex >= filteredSlashCommands.length) {
|
if (selectedCommandIndex >= filteredSlashCommands.length) {
|
||||||
setSelectedCommandIndex(0);
|
setSelectedCommandIndex(0);
|
||||||
@@ -507,22 +586,29 @@ export function ThreadComposer({
|
|||||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSlashMenu) return;
|
if (selectedCliAppIndex >= filteredCliApps.length) {
|
||||||
|
setSelectedCliAppIndex(0);
|
||||||
|
}
|
||||||
|
}, [filteredCliApps.length, selectedCliAppIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAnyPalette) return;
|
||||||
|
|
||||||
const dismissOnPointerDown = (event: PointerEvent) => {
|
const dismissOnPointerDown = (event: PointerEvent) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (target instanceof Node && formRef.current?.contains(target)) return;
|
if (target instanceof Node && formRef.current?.contains(target)) return;
|
||||||
setSlashMenuDismissed(true);
|
setSlashMenuDismissed(true);
|
||||||
|
setCliAppMenuDismissed(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("pointerdown", dismissOnPointerDown, true);
|
document.addEventListener("pointerdown", dismissOnPointerDown, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("pointerdown", dismissOnPointerDown, true);
|
document.removeEventListener("pointerdown", dismissOnPointerDown, true);
|
||||||
};
|
};
|
||||||
}, [showSlashMenu]);
|
}, [showAnyPalette]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!showSlashMenu) return;
|
if (!showAnyPalette) return;
|
||||||
|
|
||||||
const updateLayout = () => {
|
const updateLayout = () => {
|
||||||
const form = formRef.current;
|
const form = formRef.current;
|
||||||
@@ -554,7 +640,7 @@ export function ThreadComposer({
|
|||||||
window.removeEventListener("resize", updateLayout);
|
window.removeEventListener("resize", updateLayout);
|
||||||
document.removeEventListener("scroll", updateLayout, true);
|
document.removeEventListener("scroll", updateLayout, true);
|
||||||
};
|
};
|
||||||
}, [filteredSlashCommands.length, showSlashMenu]);
|
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!aspectMenuOpen) return;
|
if (!aspectMenuOpen) return;
|
||||||
@@ -602,12 +688,36 @@ export function ThreadComposer({
|
|||||||
(command: SlashCommand) => {
|
(command: SlashCommand) => {
|
||||||
setValue(command.argHint ? `${command.command} ` : command.command);
|
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||||
setSlashMenuDismissed(true);
|
setSlashMenuDismissed(true);
|
||||||
|
setCliAppMenuDismissed(false);
|
||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
resizeTextarea();
|
resizeTextarea();
|
||||||
},
|
},
|
||||||
[resizeTextarea],
|
[resizeTextarea],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const chooseCliApp = useCallback(
|
||||||
|
(app: CliAppInfo) => {
|
||||||
|
if (!cliAppMention) return;
|
||||||
|
const suffix = value.slice(cliAppMention.end);
|
||||||
|
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
|
||||||
|
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
||||||
|
const nextCursor = cliAppMention.start + mention.length;
|
||||||
|
setValue(next);
|
||||||
|
setCursorPosition(nextCursor);
|
||||||
|
setCliAppMenuDismissed(true);
|
||||||
|
setSlashMenuDismissed(false);
|
||||||
|
setInlineError(null);
|
||||||
|
resizeTextarea();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(nextCursor, nextCursor);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[cliAppMention, resizeTextarea, value],
|
||||||
|
);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -625,14 +735,21 @@ export function ThreadComposer({
|
|||||||
preview: { url: img.dataUrl, name: img.file.name },
|
preview: { url: img.dataUrl, name: img.file.name },
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
const options: SendOptions | undefined = imageMode
|
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
|
||||||
? {
|
const options: SendOptions | undefined =
|
||||||
imageGeneration: {
|
imageMode || attachedCliApps.length > 0
|
||||||
enabled: true,
|
? {
|
||||||
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
|
...(imageMode
|
||||||
},
|
? {
|
||||||
}
|
imageGeneration: {
|
||||||
: undefined;
|
enabled: true,
|
||||||
|
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
onSend(trimmed, payload, options);
|
onSend(trimmed, payload, options);
|
||||||
setValue("");
|
setValue("");
|
||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
@@ -640,10 +757,36 @@ export function ThreadComposer({
|
|||||||
// preview here without affecting the rendered message.
|
// preview here without affecting the rendered message.
|
||||||
clear();
|
clear();
|
||||||
setSlashMenuDismissed(false);
|
setSlashMenuDismissed(false);
|
||||||
|
setCliAppMenuDismissed(false);
|
||||||
|
setCursorPosition(0);
|
||||||
resizeTextarea();
|
resizeTextarea();
|
||||||
}, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
|
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
|
||||||
|
|
||||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (showCliAppMenu) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedCliAppIndex((idx) => (idx + 1) % filteredCliApps.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedCliAppIndex(
|
||||||
|
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setCliAppMenuDismissed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (showSlashMenu) {
|
if (showSlashMenu) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -719,6 +862,12 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const attachButtonDisabled = disabled || full;
|
const attachButtonDisabled = disabled || full;
|
||||||
const showStopButton = isStreaming && !!onStop;
|
const showStopButton = isStreaming && !!onStop;
|
||||||
|
const inputTextClasses = cn(
|
||||||
|
"w-full resize-none bg-transparent",
|
||||||
|
isHero
|
||||||
|
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
|
||||||
|
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -743,6 +892,16 @@ export function ThreadComposer({
|
|||||||
onChoose={chooseSlashCommand}
|
onChoose={chooseSlashCommand}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showCliAppMenu ? (
|
||||||
|
<CliAppMentionPalette
|
||||||
|
apps={filteredCliApps}
|
||||||
|
selectedIndex={selectedCliAppIndex}
|
||||||
|
layout={slashPaletteLayout}
|
||||||
|
isHero={isHero}
|
||||||
|
onHover={setSelectedCliAppIndex}
|
||||||
|
onChoose={chooseCliApp}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
||||||
@@ -787,30 +946,42 @@ export function ThreadComposer({
|
|||||||
{runStartedAt != null || goalState?.active ? (
|
{runStartedAt != null || goalState?.active ? (
|
||||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||||
) : null}
|
) : null}
|
||||||
<textarea
|
<div className="relative">
|
||||||
ref={textareaRef}
|
{hasCliMentionDecorations ? (
|
||||||
value={value}
|
<ComposerCliMentionOverlay
|
||||||
onChange={(e) => {
|
segments={mentionSegments}
|
||||||
setValue(e.target.value);
|
isHero={isHero}
|
||||||
setSlashMenuDismissed(false);
|
className={inputTextClasses}
|
||||||
}}
|
/>
|
||||||
onInput={onInput}
|
) : null}
|
||||||
onKeyDown={onKeyDown}
|
<textarea
|
||||||
onPaste={onPaste}
|
ref={textareaRef}
|
||||||
rows={1}
|
value={value}
|
||||||
placeholder={resolvedPlaceholder}
|
onChange={(e) => {
|
||||||
disabled={disabled}
|
setValue(e.target.value);
|
||||||
aria-label={t("thread.composer.inputAria")}
|
setSlashMenuDismissed(false);
|
||||||
className={cn(
|
setCliAppMenuDismissed(false);
|
||||||
"w-full resize-none bg-transparent",
|
setCursorPosition(e.target.selectionStart ?? e.target.value.length);
|
||||||
isHero
|
}}
|
||||||
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
|
onInput={onInput}
|
||||||
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
|
onKeyDown={onKeyDown}
|
||||||
"placeholder:text-muted-foreground/70",
|
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
"focus:outline-none focus-visible:outline-none",
|
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
"disabled:cursor-not-allowed",
|
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
)}
|
onPaste={onPaste}
|
||||||
/>
|
rows={1}
|
||||||
|
placeholder={resolvedPlaceholder}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t("thread.composer.inputAria")}
|
||||||
|
className={cn(
|
||||||
|
inputTextClasses,
|
||||||
|
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||||
|
"focus:outline-none focus-visible:outline-none",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
|
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{inlineError ? (
|
{inlineError ? (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -962,6 +1133,40 @@ export function ThreadComposer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ComposerCliMentionOverlay({
|
||||||
|
segments,
|
||||||
|
isHero,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
segments: CliAppMentionSegment[];
|
||||||
|
isHero: boolean;
|
||||||
|
className: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
"pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{segments.map((segment, index) => {
|
||||||
|
if (segment.kind === "text") {
|
||||||
|
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<CliAppMentionToken
|
||||||
|
key={`cli-${segment.app.name}-${index}`}
|
||||||
|
app={segment.app}
|
||||||
|
label={segment.text}
|
||||||
|
variant="composer"
|
||||||
|
isHero={isHero}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
interface SlashCommandPaletteProps {
|
interface SlashCommandPaletteProps {
|
||||||
commands: SlashCommand[];
|
commands: SlashCommand[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
@@ -971,6 +1176,15 @@ interface SlashCommandPaletteProps {
|
|||||||
onChoose: (command: SlashCommand) => void;
|
onChoose: (command: SlashCommand) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CliAppMentionPaletteProps {
|
||||||
|
apps: CliAppInfo[];
|
||||||
|
selectedIndex: number;
|
||||||
|
layout: SlashPaletteLayout;
|
||||||
|
isHero: boolean;
|
||||||
|
onHover: (index: number) => void;
|
||||||
|
onChoose: (app: CliAppInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function ImageAspectMenu({
|
function ImageAspectMenu({
|
||||||
selected,
|
selected,
|
||||||
isHero,
|
isHero,
|
||||||
@@ -1024,6 +1238,121 @@ function ImageAspectMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CliAppMentionPalette({
|
||||||
|
apps,
|
||||||
|
selectedIndex,
|
||||||
|
layout,
|
||||||
|
isHero,
|
||||||
|
onHover,
|
||||||
|
onChoose,
|
||||||
|
}: CliAppMentionPaletteProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const listMaxHeight = Math.max(
|
||||||
|
0,
|
||||||
|
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t("thread.composer.mentions.ariaLabel")}
|
||||||
|
style={{ maxHeight: layout.maxHeight }}
|
||||||
|
className={cn(
|
||||||
|
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
||||||
|
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||||
|
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
|
||||||
|
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||||
|
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
|
||||||
|
<AtSign className="h-3 w-3" aria-hidden />
|
||||||
|
<span>{t("thread.composer.mentions.label")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||||
|
{apps.map((app, index) => {
|
||||||
|
const selected = index === selectedIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={app.name}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
onMouseEnter={() => onHover(index)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onChoose(app);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||||
|
selected
|
||||||
|
? "bg-primary/10 text-foreground"
|
||||||
|
: "text-foreground/86 hover:bg-accent/55",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CliAppMentionLogo app={app} selected={selected} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex min-w-0 items-baseline gap-2">
|
||||||
|
<span className="font-mono text-[13px] font-semibold text-foreground">
|
||||||
|
@{app.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[13px] font-medium">
|
||||||
|
{app.display_name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||||
|
{app.category}
|
||||||
|
{app.entry_point ? ` · ${app.entry_point}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
|
||||||
|
<span>{t("thread.composer.slash.navigateHint")}</span>
|
||||||
|
<span>{t("thread.composer.slash.selectHint")}</span>
|
||||||
|
<span>{t("thread.composer.slash.closeHint")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CliAppMentionLogo({
|
||||||
|
app,
|
||||||
|
selected,
|
||||||
|
}: {
|
||||||
|
app: CliAppInfo;
|
||||||
|
selected: boolean;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const color = app.brand_color || "hsl(var(--primary))";
|
||||||
|
if (app.logo_url && !failed) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
|
||||||
|
selected ? "border-primary/25" : "border-border/65",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={app.logo_url}
|
||||||
|
alt=""
|
||||||
|
className="h-4.5 w-4.5 object-contain"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{cliAppInitials(app)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SlashCommandPalette({
|
function SlashCommandPalette({
|
||||||
commands,
|
commands,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
|||||||
@@ -32,12 +32,17 @@ export function ThreadHeader({
|
|||||||
onClick={onToggleSidebar}
|
onClick={onToggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||||
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
|
hideSidebarToggleOnDesktop && "lg:hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Menu className="h-3.5 w-3.5" />
|
<Menu className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} />
|
<ThemeButton
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={onToggleTheme}
|
||||||
|
label={t("thread.header.toggleTheme")}
|
||||||
|
className="ml-auto"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -52,7 +57,7 @@ export function ThreadHeader({
|
|||||||
onClick={onToggleSidebar}
|
onClick={onToggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||||
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
|
hideSidebarToggleOnDesktop && "lg:hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Menu className="h-3.5 w-3.5" />
|
<Menu className="h-3.5 w-3.5" />
|
||||||
@@ -62,7 +67,12 @@ export function ThreadHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} />
|
<ThemeButton
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={onToggleTheme}
|
||||||
|
label={t("thread.header.toggleTheme")}
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
|
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
|
||||||
</div>
|
</div>
|
||||||
@@ -73,10 +83,12 @@ function ThemeButton({
|
|||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
label,
|
label,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -84,7 +96,10 @@ function ThemeButton({
|
|||||||
size="icon"
|
size="icon"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
AgentActivityCluster,
|
AgentActivityCluster,
|
||||||
isAgentActivityMember,
|
isAgentActivityMember,
|
||||||
} from "@/components/thread/AgentActivityCluster";
|
} from "@/components/thread/AgentActivityCluster";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface ThreadMessagesProps {
|
interface ThreadMessagesProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
|
|||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
hiddenMessageCount?: number;
|
hiddenMessageCount?: number;
|
||||||
onLoadEarlier?: () => void;
|
onLoadEarlier?: () => void;
|
||||||
|
cliApps?: CliAppInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisplayUnit =
|
export type DisplayUnit =
|
||||||
@@ -164,6 +165,7 @@ export function ThreadMessages({
|
|||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
hiddenMessageCount = 0,
|
hiddenMessageCount = 0,
|
||||||
onLoadEarlier,
|
onLoadEarlier,
|
||||||
|
cliApps = [],
|
||||||
}: ThreadMessagesProps) {
|
}: ThreadMessagesProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||||
@@ -208,6 +210,7 @@ export function ThreadMessages({
|
|||||||
messages={unit.messages}
|
messages={unit.messages}
|
||||||
isTurnStreaming={index === liveActivityClusterIndex}
|
isTurnStreaming={index === liveActivityClusterIndex}
|
||||||
hasBodyBelow={hasBodyBelow}
|
hasBodyBelow={hasBodyBelow}
|
||||||
|
cliApps={cliApps}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
@@ -217,6 +220,7 @@ export function ThreadMessages({
|
|||||||
? copyFlags[index]
|
? copyFlags[index]
|
||||||
: true
|
: true
|
||||||
}
|
}
|
||||||
|
cliApps={cliApps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
|||||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||||
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
||||||
import { useSessionHistory } from "@/hooks/useSessions";
|
import { useSessionHistory } from "@/hooks/useSessions";
|
||||||
import { listSlashCommands } from "@/lib/api";
|
import { fetchCliApps, listSlashCommands } from "@/lib/api";
|
||||||
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
|
import {
|
||||||
|
CLI_APPS_CHANGED_EVENT,
|
||||||
|
installedCliAppsFromPayload,
|
||||||
|
isCliAppsPayload,
|
||||||
|
} from "@/lib/cli-app-events";
|
||||||
|
import type { ChatSummary, CliAppInfo, 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";
|
||||||
@@ -97,6 +102,7 @@ export function ThreadShell({
|
|||||||
const { client, modelName, token } = useClient();
|
const { client, modelName, token } = useClient();
|
||||||
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 [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);
|
||||||
@@ -247,6 +253,50 @@ export function ThreadShell({
|
|||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
const refreshCliApps = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const payload = await fetchCliApps(token);
|
||||||
|
setCliApps(installedCliAppsFromPayload(payload));
|
||||||
|
} catch {
|
||||||
|
setCliApps([]);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const payload = await fetchCliApps(token);
|
||||||
|
if (!cancelled) setCliApps(installedCliAppsFromPayload(payload));
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setCliApps([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
|
||||||
|
const refreshOnFocus = () => {
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
|
void refreshCliApps();
|
||||||
|
};
|
||||||
|
window.addEventListener("focus", 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 () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.removeEventListener("focus", refreshOnFocus);
|
||||||
|
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||||
|
window.removeEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
|
||||||
|
};
|
||||||
|
}, [refreshCliApps, 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;
|
||||||
@@ -332,6 +382,7 @@ export function ThreadShell({
|
|||||||
modelLabel={toModelBadgeLabel(modelName)}
|
modelLabel={toModelBadgeLabel(modelName)}
|
||||||
variant={showHeroComposer ? "hero" : "thread"}
|
variant={showHeroComposer ? "hero" : "thread"}
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
|
cliApps={cliApps}
|
||||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
imageMode={showHeroComposer ? heroImageMode : undefined}
|
||||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
@@ -351,6 +402,7 @@ export function ThreadShell({
|
|||||||
modelLabel={toModelBadgeLabel(modelName)}
|
modelLabel={toModelBadgeLabel(modelName)}
|
||||||
variant="hero"
|
variant="hero"
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
|
cliApps={cliApps}
|
||||||
imageMode={heroImageMode}
|
imageMode={heroImageMode}
|
||||||
onImageModeChange={setHeroImageMode}
|
onImageModeChange={setHeroImageMode}
|
||||||
runStartedAt={runStartedAt}
|
runStartedAt={runStartedAt}
|
||||||
@@ -391,6 +443,7 @@ export function ThreadShell({
|
|||||||
scrollToBottomSignal={scrollToBottomSignal}
|
scrollToBottomSignal={scrollToBottomSignal}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
showScrollToBottomButton={!!session}
|
showScrollToBottomButton={!!session}
|
||||||
|
cliApps={cliApps}
|
||||||
/>
|
/>
|
||||||
</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 { UIMessage } from "@/lib/types";
|
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface ThreadViewportProps {
|
interface ThreadViewportProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
@@ -24,6 +24,7 @@ interface ThreadViewportProps {
|
|||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
conversationKey?: string | null;
|
conversationKey?: string | null;
|
||||||
showScrollToBottomButton?: boolean;
|
showScrollToBottomButton?: boolean;
|
||||||
|
cliApps?: CliAppInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
@@ -53,6 +54,7 @@ export function ThreadViewport({
|
|||||||
scrollToBottomSignal = 0,
|
scrollToBottomSignal = 0,
|
||||||
conversationKey = null,
|
conversationKey = null,
|
||||||
showScrollToBottomButton = true,
|
showScrollToBottomButton = true,
|
||||||
|
cliApps = [],
|
||||||
}: ThreadViewportProps) {
|
}: ThreadViewportProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -249,6 +251,7 @@ export function ThreadViewport({
|
|||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
hiddenMessageCount={hiddenMessageCount}
|
hiddenMessageCount={hiddenMessageCount}
|
||||||
onLoadEarlier={loadEarlierMessages}
|
onLoadEarlier={loadEarlierMessages}
|
||||||
|
cliApps={cliApps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -216,6 +216,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes cli-app-linked-sheen {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-140%) skewX(-14deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
18% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
72%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(140%) skewX(-14deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cli-app-linked-chip::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
hsl(var(--foreground) / 0.14) 46%,
|
||||||
|
hsl(var(--background) / 0.7) 50%,
|
||||||
|
hsl(var(--foreground) / 0.12) 54%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
animation: cli-app-linked-sheen 1.25s ease-out 1;
|
||||||
|
}
|
||||||
|
.dark .cli-app-linked-chip::after {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
hsl(var(--foreground) / 0.12) 46%,
|
||||||
|
hsl(var(--background) / 0.5) 50%,
|
||||||
|
hsl(var(--foreground) / 0.1) 54%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cli-app-linked-chip::after {
|
||||||
|
animation: none;
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Subtle scrollbar that doesn't fight the dark background. */
|
/* Subtle scrollbar that doesn't fight the dark background. */
|
||||||
.scrollbar-thin {
|
.scrollbar-thin {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
import { toMediaAttachment } from "@/lib/media";
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
import { mergeUniqueToolTraceLines, toolTraceLinesFromEvents } from "@/lib/tool-traces";
|
import {
|
||||||
|
mergeToolProgressEvents,
|
||||||
|
mergeUniqueToolTraceLines,
|
||||||
|
normalizeToolProgressEvents,
|
||||||
|
toolTraceLinesFromEvents,
|
||||||
|
} from "@/lib/tool-traces";
|
||||||
import type { StreamError } from "@/lib/nanobot-client";
|
import type { StreamError } from "@/lib/nanobot-client";
|
||||||
import type {
|
import type {
|
||||||
InboundEvent,
|
InboundEvent,
|
||||||
|
OutboundCliAppMention,
|
||||||
OutboundImageGeneration,
|
OutboundImageGeneration,
|
||||||
OutboundMedia,
|
OutboundMedia,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
@@ -187,6 +193,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">,
|
||||||
@@ -297,6 +312,7 @@ export interface SendImage {
|
|||||||
|
|
||||||
export interface SendOptions {
|
export interface SendOptions {
|
||||||
imageGeneration?: OutboundImageGeneration;
|
imageGeneration?: OutboundImageGeneration;
|
||||||
|
cliApps?: OutboundCliAppMention[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNanobotStream(
|
export function useNanobotStream(
|
||||||
@@ -482,23 +498,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;
|
||||||
@@ -576,7 +623,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
|
||||||
@@ -657,6 +707,7 @@ export function useNanobotStream(
|
|||||||
// Attach them to the last trace row if it was the last emitted item
|
// Attach them to the last trace row if it was the last emitted item
|
||||||
// so a sequence of calls collapses into one compact trace group.
|
// so a sequence of calls collapses into one compact trace group.
|
||||||
if (ev.kind === "tool_hint" || ev.kind === "progress") {
|
if (ev.kind === "tool_hint" || ev.kind === "progress") {
|
||||||
|
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
|
||||||
const structuredLines = toolTraceLinesFromEvents(ev.tool_events);
|
const structuredLines = toolTraceLinesFromEvents(ev.tool_events);
|
||||||
const lines = structuredLines.length > 0
|
const lines = structuredLines.length > 0
|
||||||
? structuredLines
|
? structuredLines
|
||||||
@@ -681,13 +732,15 @@ export function useNanobotStream(
|
|||||||
const mergedLines = structuredLines.length > 0
|
const mergedLines = structuredLines.length > 0
|
||||||
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
|
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
|
||||||
: null;
|
: null;
|
||||||
if (mergedLines && !mergedLines.added) return prev;
|
|
||||||
const merged: UIMessage = {
|
const merged: UIMessage = {
|
||||||
...last,
|
...last,
|
||||||
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
|
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
|
||||||
content: mergedLines
|
content: mergedLines
|
||||||
? mergedLines.traces[mergedLines.traces.length - 1]
|
? mergedLines.traces[mergedLines.traces.length - 1]
|
||||||
: lines[lines.length - 1],
|
: lines[lines.length - 1],
|
||||||
|
toolEvents: structuredEvents.length
|
||||||
|
? mergeToolProgressEvents(last.toolEvents, structuredEvents)
|
||||||
|
: last.toolEvents,
|
||||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, -1), merged];
|
return [...prev.slice(0, -1), merged];
|
||||||
@@ -700,6 +753,7 @@ export function useNanobotStream(
|
|||||||
kind: "trace",
|
kind: "trace",
|
||||||
content: lines[lines.length - 1],
|
content: lines[lines.length - 1],
|
||||||
traces: lines,
|
traces: lines,
|
||||||
|
...(structuredEvents.length ? { toolEvents: structuredEvents } : {}),
|
||||||
activitySegmentId: segmentId,
|
activitySegmentId: segmentId,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
@@ -836,6 +890,7 @@ export function useNanobotStream(
|
|||||||
content,
|
content,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
...(previews ? { images: previews } : {}),
|
...(previews ? { images: previews } : {}),
|
||||||
|
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,13 +27,25 @@ export function useSessions(): {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef(token);
|
||||||
|
const optimisticKeysRef = useRef<Set<string>>(new Set());
|
||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const rows = await listSessions(tokenRef.current);
|
const rows = await listSessions(tokenRef.current);
|
||||||
setSessions(rows);
|
const serverKeys = new Set(rows.map((row) => row.key));
|
||||||
|
setSessions((prev) => [
|
||||||
|
...rows,
|
||||||
|
...prev.filter(
|
||||||
|
(session) =>
|
||||||
|
optimisticKeysRef.current.has(session.key) &&
|
||||||
|
!serverKeys.has(session.key),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
for (const key of Array.from(optimisticKeysRef.current)) {
|
||||||
|
if (serverKeys.has(key)) optimisticKeysRef.current.delete(key);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
@@ -57,6 +69,7 @@ export function useSessions(): {
|
|||||||
const createChat = useCallback(async (): Promise<string> => {
|
const createChat = useCallback(async (): Promise<string> => {
|
||||||
const chatId = await client.newChat();
|
const chatId = await client.newChat();
|
||||||
const key = `websocket:${chatId}`;
|
const key = `websocket:${chatId}`;
|
||||||
|
optimisticKeysRef.current.add(key);
|
||||||
// Optimistic insert; a subsequent refresh will replace it with the
|
// Optimistic insert; a subsequent refresh will replace it with the
|
||||||
// authoritative row once the server persists the session.
|
// authoritative row once the server persists the session.
|
||||||
setSessions((prev) => [
|
setSessions((prev) => [
|
||||||
@@ -77,6 +90,7 @@ export function useSessions(): {
|
|||||||
const deleteChat = useCallback(
|
const deleteChat = useCallback(
|
||||||
async (key: string) => {
|
async (key: string) => {
|
||||||
await apiDeleteSession(tokenRef.current, key);
|
await apiDeleteSession(tokenRef.current, key);
|
||||||
|
optimisticKeysRef.current.delete(key);
|
||||||
setSessions((prev) => prev.filter((s) => s.key !== key));
|
setSessions((prev) => prev.filter((s) => s.key !== key));
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
"providers": "Providers",
|
"providers": "Providers",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"web": "Web",
|
"web": "Web",
|
||||||
|
"cliApps": "CLI Apps",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
"advanced": "Advanced"
|
"advanced": "Advanced"
|
||||||
},
|
},
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
"imageDefaults": "Defaults",
|
"imageDefaults": "Defaults",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
|
"cliApps": "CLI Apps",
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
"capabilities": "Capabilities",
|
"capabilities": "Capabilities",
|
||||||
@@ -115,6 +117,7 @@
|
|||||||
"density": "Density",
|
"density": "Density",
|
||||||
"activityMode": "Activity detail",
|
"activityMode": "Activity detail",
|
||||||
"codeWrap": "Code wrapping",
|
"codeWrap": "Code wrapping",
|
||||||
|
"brandLogos": "Brand logos",
|
||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
@@ -141,6 +144,8 @@
|
|||||||
"ssrfWhitelist": "SSRF whitelist",
|
"ssrfWhitelist": "SSRF whitelist",
|
||||||
"mcpServers": "MCP servers",
|
"mcpServers": "MCP servers",
|
||||||
"pathAppend": "PATH append",
|
"pathAppend": "PATH append",
|
||||||
|
"cliAppsCatalog": "Catalog",
|
||||||
|
"cliAppsFilter": "Filter",
|
||||||
"configurationDocs": "Configuration docs"
|
"configurationDocs": "Configuration docs"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
@@ -154,6 +159,7 @@
|
|||||||
"density": "Stored only in this browser.",
|
"density": "Stored only in this browser.",
|
||||||
"activityMode": "Choose how much agent activity chrome to show by default.",
|
"activityMode": "Choose how much agent activity chrome to show by default.",
|
||||||
"codeWrap": "Keep long code lines readable on smaller screens.",
|
"codeWrap": "Keep long code lines readable on smaller screens.",
|
||||||
|
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
@@ -168,8 +174,41 @@
|
|||||||
"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.",
|
||||||
|
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; desktop apps stay untouched.",
|
||||||
|
"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."
|
||||||
},
|
},
|
||||||
|
"cliApps": {
|
||||||
|
"allCategories": "All categories",
|
||||||
|
"availableCount": "{{count}} apps",
|
||||||
|
"installedCount": "{{count}} CLIs installed",
|
||||||
|
"summary": "{{installed}} of {{total}} CLIs installed",
|
||||||
|
"filterAll": "All",
|
||||||
|
"filterInstalled": "Installed CLIs",
|
||||||
|
"filterNotInstalled": "Not installed",
|
||||||
|
"searchPlaceholder": "Search CLIs",
|
||||||
|
"loading": "Loading CLI Apps...",
|
||||||
|
"empty": "No CLI Apps match this filter.",
|
||||||
|
"statusInstalled": "CLI installed",
|
||||||
|
"statusMissing": "Missing",
|
||||||
|
"statusAvailable": "Available",
|
||||||
|
"statusUnsupported": "Unsupported",
|
||||||
|
"statusNotInstalled": "CLI not installed",
|
||||||
|
"requires": "Requires",
|
||||||
|
"test": "Test CLI",
|
||||||
|
"update": "Update CLI",
|
||||||
|
"uninstall": "Uninstall CLI",
|
||||||
|
"install": "Install CLI",
|
||||||
|
"readyTitle": "@{{name}} is ready",
|
||||||
|
"readyStatus": "Ready",
|
||||||
|
"readyTry": "Try @{{name}}",
|
||||||
|
"readyCopied": "Copied",
|
||||||
|
"readyPrompt": "Use @{{name}} to inspect what this CLI can do.",
|
||||||
|
"openChat": "Open chat",
|
||||||
|
"unsupported": "Unsupported",
|
||||||
|
"unavailable": "Unavailable",
|
||||||
|
"noDescription": "No description available."
|
||||||
|
},
|
||||||
"values": {
|
"values": {
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
@@ -259,6 +298,9 @@
|
|||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search."
|
"noMatches": "No providers match this search."
|
||||||
},
|
},
|
||||||
|
"legal": {
|
||||||
|
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
||||||
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "Select provider",
|
"selectProvider": "Select provider",
|
||||||
"selectAspect": "Select aspect",
|
"selectAspect": "Select aspect",
|
||||||
@@ -271,6 +313,7 @@
|
|||||||
"fallbackTitle": "Chat {{id}}",
|
"fallbackTitle": "Chat {{id}}",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"noSessions": "No sessions yet.",
|
"noSessions": "No sessions yet.",
|
||||||
|
"showMore": "Show {{count}} more",
|
||||||
"actions": "Chat actions for {{title}}",
|
"actions": "Chat actions for {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
@@ -461,6 +504,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mentions": {
|
||||||
|
"ariaLabel": "CLI Apps",
|
||||||
|
"label": "CLI APPS"
|
||||||
|
},
|
||||||
"encoding": "Encoding…",
|
"encoding": "Encoding…",
|
||||||
"remove": "Remove attachment",
|
"remove": "Remove attachment",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||||
@@ -492,6 +539,15 @@
|
|||||||
"agentActivityToolsOnly": "{{tools}} tool calls",
|
"agentActivityToolsOnly": "{{tools}} tool calls",
|
||||||
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||||
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
|
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
|
||||||
|
"cliActivityRunningOne": "Running CLI @{{name}}",
|
||||||
|
"cliActivityRanOne": "Ran CLI @{{name}}",
|
||||||
|
"cliActivityFailedOne": "CLI failed @{{name}}",
|
||||||
|
"cliActivityRunningMany": "Running {{count}} CLIs",
|
||||||
|
"cliActivityRanMany": "Ran {{count}} CLIs",
|
||||||
|
"cliActivityFailedMany": "{{count}} CLI failed",
|
||||||
|
"cliRunRunning": "Running CLI",
|
||||||
|
"cliRunRan": "Ran CLI",
|
||||||
|
"cliRunFailed": "CLI failed",
|
||||||
"imageAttachment": "Image attachment",
|
"imageAttachment": "Image attachment",
|
||||||
"copyReply": "Copy reply",
|
"copyReply": "Copy reply",
|
||||||
"copiedReply": "Copied reply",
|
"copiedReply": "Copied reply",
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
"title": "No se pudo conectar con nanobot",
|
"title": "No se pudo conectar con nanobot",
|
||||||
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
|
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Autenticación requerida",
|
||||||
|
"hint": "Introduce el secreto configurado como tokenIssueSecret en la configuración del gateway.",
|
||||||
|
"placeholder": "Contraseña",
|
||||||
|
"submit": "Conectar",
|
||||||
|
"invalid": "Contraseña no válida. Inténtalo de nuevo."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"section": "Cuenta",
|
||||||
|
"logoutHint": "Desconecta este navegador del gateway.",
|
||||||
|
"logout": "Cerrar sesión"
|
||||||
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"section": "Sistema",
|
"section": "Sistema",
|
||||||
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
|
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
|
||||||
@@ -27,9 +39,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"navigation": "Navegación de la barra lateral",
|
||||||
|
"globalActions": "Acciones globales",
|
||||||
"collapse": "Contraer barra lateral",
|
"collapse": "Contraer barra lateral",
|
||||||
"toggleTheme": "Cambiar tema",
|
"toggleTheme": "Cambiar tema",
|
||||||
|
"home": "Inicio",
|
||||||
"newChat": "Nuevo chat",
|
"newChat": "Nuevo chat",
|
||||||
|
"searchAria": "Buscar",
|
||||||
"viewOptions": "View",
|
"viewOptions": "View",
|
||||||
"compactList": "Compact list",
|
"compactList": "Compact list",
|
||||||
"showPreviews": "Show previews",
|
"showPreviews": "Show previews",
|
||||||
@@ -38,17 +54,16 @@
|
|||||||
"sortUpdated": "Recently updated",
|
"sortUpdated": "Recently updated",
|
||||||
"sortCreated": "Recently created",
|
"sortCreated": "Recently created",
|
||||||
"sortTitle": "Title A-Z",
|
"sortTitle": "Title A-Z",
|
||||||
|
"searchPlaceholder": "Buscar",
|
||||||
|
"searchResults": "Resultados",
|
||||||
|
"noSearchResults": "No hay chats coincidentes.",
|
||||||
"recent": "Recientes",
|
"recent": "Recientes",
|
||||||
"refreshSessions": "Actualizar sesiones",
|
"refreshSessions": "Actualizar sesiones",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Idioma",
|
"label": "Idioma",
|
||||||
"ariaLabel": "Cambiar idioma"
|
"ariaLabel": "Cambiar idioma"
|
||||||
},
|
}
|
||||||
"searchAria": "Buscar",
|
|
||||||
"searchPlaceholder": "Buscar",
|
|
||||||
"searchResults": "Resultados",
|
|
||||||
"noSearchResults": "No hay chats coincidentes."
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Volver al chat",
|
"backToChat": "Volver al chat",
|
||||||
@@ -75,10 +90,13 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"localPreferences": "Local preferences",
|
"localPreferences": "Local preferences",
|
||||||
"presets": "Presets",
|
"presets": "Presets",
|
||||||
|
"imageGeneration": "Generación de imágenes",
|
||||||
|
"imageDefaults": "Valores predeterminados",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
|
"capabilities": "Capacidades",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
@@ -91,6 +109,7 @@
|
|||||||
"activePreset": "Active preset",
|
"activePreset": "Active preset",
|
||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"restartState": "Restart state",
|
"restartState": "Restart state",
|
||||||
|
"pendingChanges": "Cambios pendientes",
|
||||||
"selectedPreset": "Selected preset",
|
"selectedPreset": "Selected preset",
|
||||||
"presetModel": "Preset model",
|
"presetModel": "Preset model",
|
||||||
"density": "Density",
|
"density": "Density",
|
||||||
@@ -99,6 +118,15 @@
|
|||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
|
"imageGeneration": "Generación de imágenes",
|
||||||
|
"imageProvider": "Proveedor de imágenes",
|
||||||
|
"imageProviderStatus": "Estado del proveedor",
|
||||||
|
"imageProviderBase": "Base del proveedor",
|
||||||
|
"imageModel": "Modelo de imagen",
|
||||||
|
"defaultAspectRatio": "Relación predeterminada",
|
||||||
|
"defaultImageSize": "Tamaño predeterminado",
|
||||||
|
"maxImagesPerTurn": "Máximo de imágenes por turno",
|
||||||
|
"imageSaveDir": "Directorio de guardado",
|
||||||
"botName": "Bot name",
|
"botName": "Bot name",
|
||||||
"botIcon": "Bot icon",
|
"botIcon": "Bot icon",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
@@ -129,6 +157,13 @@
|
|||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
|
"imageGeneration": "Expone generate_image en los chats cuando hay un proveedor de imágenes configurado disponible.",
|
||||||
|
"imageProvider": "Elige el proveedor del registro que usará generate_image.",
|
||||||
|
"imageProviderStatus": "La generación de imágenes reutiliza las credenciales de Proveedores.",
|
||||||
|
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
|
||||||
|
"defaultAspectRatio": "Se usa cuando el prompt no elige una relación de aspecto.",
|
||||||
|
"defaultImageSize": "Sugerencia de tamaño enviada a los proveedores que la admiten.",
|
||||||
|
"maxImagesPerTurn": "Límite superior para una solicitud generate_image.",
|
||||||
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
||||||
"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.",
|
||||||
@@ -141,8 +176,8 @@
|
|||||||
"notAvailable": "No disponible",
|
"notAvailable": "No disponible",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"restartRequired": "Restart required",
|
"restartPending": "Reinicio pendiente",
|
||||||
"liveReload": "Live reload ready",
|
"ready": "Listo",
|
||||||
"comfortable": "Comfortable",
|
"comfortable": "Comfortable",
|
||||||
"compact": "Compact",
|
"compact": "Compact",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
@@ -150,13 +185,19 @@
|
|||||||
"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...",
|
||||||
"loadError": "No se pudo cargar la configuración",
|
"loadError": "No se pudo cargar la configuración",
|
||||||
"unsaved": "Hay cambios sin guardar.",
|
"unsaved": "Hay cambios sin guardar.",
|
||||||
"savedRestart": "Guardado. Reinicia nanobot para aplicar."
|
"upToDate": "Actualizado.",
|
||||||
|
"savedRestart": "Guardado. Reinicia nanobot para aplicar.",
|
||||||
|
"restartAfterSaving": "Guarda los cambios y reinicia cuando estés listo.",
|
||||||
|
"savedRestartApply": "Guardado. Reinicia cuando estés listo.",
|
||||||
|
"imageProviderRestart": "Cambios del proveedor de imágenes guardados. Reinicia cuando estés listo."
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
@@ -213,17 +254,26 @@
|
|||||||
"configuredCount": "{{count}} configured",
|
"configuredCount": "{{count}} configured",
|
||||||
"totalProviders": "{{count}} available",
|
"totalProviders": "{{count}} available",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
|
"imageGeneration": "Generación de imágenes",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search."
|
"noMatches": "No providers match this search."
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"selectProvider": "Seleccionar proveedor",
|
||||||
|
"selectAspect": "Seleccionar relación",
|
||||||
|
"selectSize": "Seleccionar tamaño",
|
||||||
|
"configureProvider": "Configurar proveedor",
|
||||||
|
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Chat {{id}}",
|
"fallbackTitle": "Chat {{id}}",
|
||||||
"loading": "Cargando…",
|
"loading": "Cargando…",
|
||||||
"noSessions": "Todavía no hay sesiones.",
|
"noSessions": "Todavía no hay sesiones.",
|
||||||
|
"showMore": "Mostrar {{count}} más",
|
||||||
"actions": "Acciones del chat {{title}}",
|
"actions": "Acciones del chat {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
@@ -268,7 +318,6 @@
|
|||||||
"thread": {
|
"thread": {
|
||||||
"loadingConversation": "Cargando conversación…",
|
"loadingConversation": "Cargando conversación…",
|
||||||
"empty": {
|
"empty": {
|
||||||
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo.",
|
|
||||||
"greeting": "¿Qué puedo hacer por ti?",
|
"greeting": "¿Qué puedo hacer por ti?",
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
"plan": {
|
"plan": {
|
||||||
@@ -321,10 +370,14 @@
|
|||||||
"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",
|
||||||
|
"newChat": "Iniciar un chat nuevo",
|
||||||
|
"toggleTheme": "Cambiar tema desde el encabezado",
|
||||||
|
"settings": "Abrir configuración"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"placeholderThread": "Escribe tu mensaje…",
|
"placeholderThread": "Escribe tu mensaje…",
|
||||||
@@ -338,6 +391,7 @@
|
|||||||
"goalStateFallback": "Objetivo",
|
"goalStateFallback": "Objetivo",
|
||||||
"goalStateExpandAria": "Ver objetivo completo",
|
"goalStateExpandAria": "Ver objetivo completo",
|
||||||
"goalStateSheetTitle": "Objetivo",
|
"goalStateSheetTitle": "Objetivo",
|
||||||
|
"goalStateCloseAria": "Cerrar objetivo",
|
||||||
"send": "Enviar mensaje",
|
"send": "Enviar mensaje",
|
||||||
"stop": "Detener respuesta",
|
"stop": "Detener respuesta",
|
||||||
"attachImage": "Adjuntar imagen",
|
"attachImage": "Adjuntar imagen",
|
||||||
@@ -356,16 +410,11 @@
|
|||||||
"16_9": "Panorámico 16:9"
|
"16_9": "Panorámico 16:9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "Procesando…",
|
"tools": {
|
||||||
"remove": "Quitar adjunto",
|
"search": "Buscar",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
"reason": "Razonar",
|
||||||
"imageRejected": {
|
"deepResearch": "Investigación profunda",
|
||||||
"unsupported_type": "Tipo de archivo no compatible",
|
"voice": "Entrada de voz"
|
||||||
"too_many_images": "Máximo {{max}} imágenes por mensaje",
|
|
||||||
"magic_mismatch": "El archivo no parece una imagen real",
|
|
||||||
"decode_failed": "No se pudo decodificar esta imagen",
|
|
||||||
"too_large": "Imagen demasiado grande — prueba una más pequeña",
|
|
||||||
"io": "No se pudo leer este archivo"
|
|
||||||
},
|
},
|
||||||
"slash": {
|
"slash": {
|
||||||
"ariaLabel": "Comandos slash",
|
"ariaLabel": "Comandos slash",
|
||||||
@@ -416,7 +465,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"goalStateCloseAria": "Cerrar objetivo"
|
"encoding": "Procesando…",
|
||||||
|
"remove": "Quitar adjunto",
|
||||||
|
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||||
|
"imageRejected": {
|
||||||
|
"unsupported_type": "Tipo de archivo no compatible",
|
||||||
|
"too_many_images": "Máximo {{max}} imágenes por mensaje",
|
||||||
|
"magic_mismatch": "El archivo no parece una imagen real",
|
||||||
|
"decode_failed": "No se pudo decodificar esta imagen",
|
||||||
|
"too_large": "Imagen demasiado grande — prueba una más pequeña",
|
||||||
|
"io": "No se pudo leer este archivo"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Desplazarse al final",
|
"scrollToBottom": "Desplazarse al final",
|
||||||
"loadEarlier": "Cargar mensajes anteriores"
|
"loadEarlier": "Cargar mensajes anteriores"
|
||||||
@@ -438,6 +497,8 @@
|
|||||||
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
|
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
|
||||||
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
|
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
|
||||||
"imageAttachment": "Imagen adjunta",
|
"imageAttachment": "Imagen adjunta",
|
||||||
|
"copyReply": "Copiar respuesta",
|
||||||
|
"copiedReply": "Respuesta copiada",
|
||||||
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)"
|
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
"title": "Impossible de joindre nanobot",
|
"title": "Impossible de joindre nanobot",
|
||||||
"gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
|
"gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Authentification requise",
|
||||||
|
"hint": "Saisissez le secret configuré comme tokenIssueSecret dans la configuration de votre gateway.",
|
||||||
|
"placeholder": "Mot de passe",
|
||||||
|
"submit": "Se connecter",
|
||||||
|
"invalid": "Mot de passe invalide. Réessayez."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"section": "Compte",
|
||||||
|
"logoutHint": "Déconnecter ce navigateur du gateway.",
|
||||||
|
"logout": "Se déconnecter"
|
||||||
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"section": "Système",
|
"section": "Système",
|
||||||
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exécution.",
|
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exécution.",
|
||||||
@@ -27,9 +39,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"navigation": "Navigation de la barre latérale",
|
||||||
|
"globalActions": "Actions globales",
|
||||||
"collapse": "Réduire la barre latérale",
|
"collapse": "Réduire la barre latérale",
|
||||||
"toggleTheme": "Changer de thème",
|
"toggleTheme": "Changer de thème",
|
||||||
|
"home": "Accueil",
|
||||||
"newChat": "Nouvelle discussion",
|
"newChat": "Nouvelle discussion",
|
||||||
|
"searchAria": "Rechercher",
|
||||||
"viewOptions": "View",
|
"viewOptions": "View",
|
||||||
"compactList": "Compact list",
|
"compactList": "Compact list",
|
||||||
"showPreviews": "Show previews",
|
"showPreviews": "Show previews",
|
||||||
@@ -38,17 +54,16 @@
|
|||||||
"sortUpdated": "Recently updated",
|
"sortUpdated": "Recently updated",
|
||||||
"sortCreated": "Recently created",
|
"sortCreated": "Recently created",
|
||||||
"sortTitle": "Title A-Z",
|
"sortTitle": "Title A-Z",
|
||||||
|
"searchPlaceholder": "Rechercher",
|
||||||
|
"searchResults": "Résultats",
|
||||||
|
"noSearchResults": "Aucun chat correspondant.",
|
||||||
"recent": "Récentes",
|
"recent": "Récentes",
|
||||||
"refreshSessions": "Actualiser les sessions",
|
"refreshSessions": "Actualiser les sessions",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Langue",
|
"label": "Langue",
|
||||||
"ariaLabel": "Changer de langue"
|
"ariaLabel": "Changer de langue"
|
||||||
},
|
}
|
||||||
"searchAria": "Rechercher",
|
|
||||||
"searchPlaceholder": "Rechercher",
|
|
||||||
"searchResults": "Résultats",
|
|
||||||
"noSearchResults": "Aucun chat correspondant."
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Retour à la discussion",
|
"backToChat": "Retour à la discussion",
|
||||||
@@ -75,10 +90,13 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"localPreferences": "Local preferences",
|
"localPreferences": "Local preferences",
|
||||||
"presets": "Presets",
|
"presets": "Presets",
|
||||||
|
"imageGeneration": "Génération d'images",
|
||||||
|
"imageDefaults": "Valeurs par défaut",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
|
"capabilities": "Capacités",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
@@ -91,6 +109,7 @@
|
|||||||
"activePreset": "Active preset",
|
"activePreset": "Active preset",
|
||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"restartState": "Restart state",
|
"restartState": "Restart state",
|
||||||
|
"pendingChanges": "Modifications en attente",
|
||||||
"selectedPreset": "Selected preset",
|
"selectedPreset": "Selected preset",
|
||||||
"presetModel": "Preset model",
|
"presetModel": "Preset model",
|
||||||
"density": "Density",
|
"density": "Density",
|
||||||
@@ -99,6 +118,15 @@
|
|||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
|
"imageGeneration": "Génération d'images",
|
||||||
|
"imageProvider": "Fournisseur d'images",
|
||||||
|
"imageProviderStatus": "État du fournisseur",
|
||||||
|
"imageProviderBase": "Base du fournisseur",
|
||||||
|
"imageModel": "Modèle d'image",
|
||||||
|
"defaultAspectRatio": "Format par défaut",
|
||||||
|
"defaultImageSize": "Taille par défaut",
|
||||||
|
"maxImagesPerTurn": "Nombre max. d'images par tour",
|
||||||
|
"imageSaveDir": "Répertoire de sauvegarde",
|
||||||
"botName": "Bot name",
|
"botName": "Bot name",
|
||||||
"botIcon": "Bot icon",
|
"botIcon": "Bot icon",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
@@ -129,6 +157,13 @@
|
|||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
|
"imageGeneration": "Expose generate_image dans les chats lorsqu’un fournisseur d’images configuré est disponible.",
|
||||||
|
"imageProvider": "Choisissez le fournisseur du registre utilisé par generate_image.",
|
||||||
|
"imageProviderStatus": "La génération d’images réutilise les identifiants de Fournisseurs.",
|
||||||
|
"imageModel": "Nom du modèle envoyé au fournisseur d’images sélectionné.",
|
||||||
|
"defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de format.",
|
||||||
|
"defaultImageSize": "Indication de taille envoyée aux fournisseurs qui la prennent en charge.",
|
||||||
|
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.",
|
||||||
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
||||||
"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.",
|
||||||
@@ -141,8 +176,8 @@
|
|||||||
"notAvailable": "Indisponible",
|
"notAvailable": "Indisponible",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"restartRequired": "Restart required",
|
"restartPending": "Redémarrage en attente",
|
||||||
"liveReload": "Live reload ready",
|
"ready": "Prêt",
|
||||||
"comfortable": "Comfortable",
|
"comfortable": "Comfortable",
|
||||||
"compact": "Compact",
|
"compact": "Compact",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
@@ -150,13 +185,19 @@
|
|||||||
"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...",
|
||||||
"loadError": "Impossible de charger les paramètres",
|
"loadError": "Impossible de charger les paramètres",
|
||||||
"unsaved": "Modifications non enregistrées.",
|
"unsaved": "Modifications non enregistrées.",
|
||||||
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer."
|
"upToDate": "À jour.",
|
||||||
|
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer.",
|
||||||
|
"restartAfterSaving": "Enregistrez les modifications, puis redémarrez lorsque vous êtes prêt.",
|
||||||
|
"savedRestartApply": "Enregistré. Redémarrez lorsque vous êtes prêt.",
|
||||||
|
"imageProviderRestart": "Modifications du fournisseur d’images enregistrées. Redémarrez lorsque vous êtes prêt."
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
@@ -213,17 +254,26 @@
|
|||||||
"configuredCount": "{{count}} configured",
|
"configuredCount": "{{count}} configured",
|
||||||
"totalProviders": "{{count}} available",
|
"totalProviders": "{{count}} available",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
|
"imageGeneration": "Génération d'images",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search."
|
"noMatches": "No providers match this search."
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"selectProvider": "Sélectionner un fournisseur",
|
||||||
|
"selectAspect": "Sélectionner un format",
|
||||||
|
"selectSize": "Sélectionner une taille",
|
||||||
|
"configureProvider": "Configurer le fournisseur",
|
||||||
|
"missingCredential": "Configurez ce fournisseur avant d’activer la génération d’images."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Discussion {{id}}",
|
"fallbackTitle": "Discussion {{id}}",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"noSessions": "Aucune session pour le moment.",
|
"noSessions": "Aucune session pour le moment.",
|
||||||
|
"showMore": "Afficher {{count}} de plus",
|
||||||
"actions": "Actions de la discussion {{title}}",
|
"actions": "Actions de la discussion {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
@@ -268,7 +318,6 @@
|
|||||||
"thread": {
|
"thread": {
|
||||||
"loadingConversation": "Chargement de la conversation…",
|
"loadingConversation": "Chargement de la conversation…",
|
||||||
"empty": {
|
"empty": {
|
||||||
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil.",
|
|
||||||
"greeting": "Que puis-je faire pour vous ?",
|
"greeting": "Que puis-je faire pour vous ?",
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
"plan": {
|
"plan": {
|
||||||
@@ -321,10 +370,14 @@
|
|||||||
"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",
|
||||||
|
"newChat": "Démarrer un nouveau chat",
|
||||||
|
"toggleTheme": "Changer le thème depuis l’en-tête",
|
||||||
|
"settings": "Ouvrir les paramètres"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"placeholderThread": "Saisissez votre message…",
|
"placeholderThread": "Saisissez votre message…",
|
||||||
@@ -338,6 +391,7 @@
|
|||||||
"goalStateFallback": "Objectif",
|
"goalStateFallback": "Objectif",
|
||||||
"goalStateExpandAria": "Afficher l’objectif complet",
|
"goalStateExpandAria": "Afficher l’objectif complet",
|
||||||
"goalStateSheetTitle": "Objectif",
|
"goalStateSheetTitle": "Objectif",
|
||||||
|
"goalStateCloseAria": "Fermer l’objectif",
|
||||||
"send": "Envoyer le message",
|
"send": "Envoyer le message",
|
||||||
"stop": "Arrêter la réponse",
|
"stop": "Arrêter la réponse",
|
||||||
"attachImage": "Joindre une image",
|
"attachImage": "Joindre une image",
|
||||||
@@ -356,16 +410,11 @@
|
|||||||
"16_9": "Large 16:9"
|
"16_9": "Large 16:9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "Traitement…",
|
"tools": {
|
||||||
"remove": "Retirer la pièce jointe",
|
"search": "Rechercher",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
"reason": "Raisonner",
|
||||||
"imageRejected": {
|
"deepResearch": "Recherche approfondie",
|
||||||
"unsupported_type": "Type de fichier non pris en charge",
|
"voice": "Entrée vocale"
|
||||||
"too_many_images": "Maximum {{max}} images par message",
|
|
||||||
"magic_mismatch": "Ce fichier n'est pas une image",
|
|
||||||
"decode_failed": "Impossible de décoder cette image",
|
|
||||||
"too_large": "Image trop grande — essayez-en une plus petite",
|
|
||||||
"io": "Impossible de lire ce fichier"
|
|
||||||
},
|
},
|
||||||
"slash": {
|
"slash": {
|
||||||
"ariaLabel": "Commandes slash",
|
"ariaLabel": "Commandes slash",
|
||||||
@@ -416,7 +465,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"goalStateCloseAria": "Fermer l’objectif"
|
"encoding": "Traitement…",
|
||||||
|
"remove": "Retirer la pièce jointe",
|
||||||
|
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||||
|
"imageRejected": {
|
||||||
|
"unsupported_type": "Type de fichier non pris en charge",
|
||||||
|
"too_many_images": "Maximum {{max}} images par message",
|
||||||
|
"magic_mismatch": "Ce fichier n'est pas une image",
|
||||||
|
"decode_failed": "Impossible de décoder cette image",
|
||||||
|
"too_large": "Image trop grande — essayez-en une plus petite",
|
||||||
|
"io": "Impossible de lire ce fichier"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Faire défiler vers le bas",
|
"scrollToBottom": "Faire défiler vers le bas",
|
||||||
"loadEarlier": "Charger les messages précédents"
|
"loadEarlier": "Charger les messages précédents"
|
||||||
@@ -438,6 +497,8 @@
|
|||||||
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils",
|
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils",
|
||||||
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
|
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
|
||||||
"imageAttachment": "Pièce jointe image",
|
"imageAttachment": "Pièce jointe image",
|
||||||
|
"copyReply": "Copier la réponse",
|
||||||
|
"copiedReply": "Réponse copiée",
|
||||||
"turnLatencyTitle": "Temps de réponse (de bout en bout)"
|
"turnLatencyTitle": "Temps de réponse (de bout en bout)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
"title": "Tidak dapat menjangkau nanobot",
|
"title": "Tidak dapat menjangkau nanobot",
|
||||||
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Autentikasi diperlukan",
|
||||||
|
"hint": "Masukkan secret yang dikonfigurasi sebagai tokenIssueSecret di konfigurasi gateway.",
|
||||||
|
"placeholder": "Kata sandi",
|
||||||
|
"submit": "Hubungkan",
|
||||||
|
"invalid": "Kata sandi tidak valid. Coba lagi."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"section": "Akun",
|
||||||
|
"logoutHint": "Putuskan browser ini dari gateway.",
|
||||||
|
"logout": "Keluar"
|
||||||
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"section": "Sistem",
|
"section": "Sistem",
|
||||||
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
|
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
|
||||||
@@ -27,9 +39,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"navigation": "Navigasi bilah samping",
|
||||||
|
"globalActions": "Aksi global",
|
||||||
"collapse": "Ciutkan sidebar",
|
"collapse": "Ciutkan sidebar",
|
||||||
"toggleTheme": "Ganti tema",
|
"toggleTheme": "Ganti tema",
|
||||||
|
"home": "Beranda",
|
||||||
"newChat": "Obrolan baru",
|
"newChat": "Obrolan baru",
|
||||||
|
"searchAria": "Cari",
|
||||||
"viewOptions": "View",
|
"viewOptions": "View",
|
||||||
"compactList": "Compact list",
|
"compactList": "Compact list",
|
||||||
"showPreviews": "Show previews",
|
"showPreviews": "Show previews",
|
||||||
@@ -38,17 +54,16 @@
|
|||||||
"sortUpdated": "Recently updated",
|
"sortUpdated": "Recently updated",
|
||||||
"sortCreated": "Recently created",
|
"sortCreated": "Recently created",
|
||||||
"sortTitle": "Title A-Z",
|
"sortTitle": "Title A-Z",
|
||||||
|
"searchPlaceholder": "Cari",
|
||||||
|
"searchResults": "Hasil",
|
||||||
|
"noSearchResults": "Tidak ada chat yang cocok.",
|
||||||
"recent": "Terbaru",
|
"recent": "Terbaru",
|
||||||
"refreshSessions": "Segarkan sesi",
|
"refreshSessions": "Segarkan sesi",
|
||||||
"settings": "Pengaturan",
|
"settings": "Pengaturan",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Bahasa",
|
"label": "Bahasa",
|
||||||
"ariaLabel": "Ganti bahasa"
|
"ariaLabel": "Ganti bahasa"
|
||||||
},
|
}
|
||||||
"searchAria": "Cari",
|
|
||||||
"searchPlaceholder": "Cari",
|
|
||||||
"searchResults": "Hasil",
|
|
||||||
"noSearchResults": "Tidak ada chat yang cocok."
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Kembali ke obrolan",
|
"backToChat": "Kembali ke obrolan",
|
||||||
@@ -75,10 +90,13 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"localPreferences": "Local preferences",
|
"localPreferences": "Local preferences",
|
||||||
"presets": "Presets",
|
"presets": "Presets",
|
||||||
|
"imageGeneration": "Pembuatan gambar",
|
||||||
|
"imageDefaults": "Default",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
|
"capabilities": "Kapabilitas",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
@@ -91,6 +109,7 @@
|
|||||||
"activePreset": "Active preset",
|
"activePreset": "Active preset",
|
||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"restartState": "Restart state",
|
"restartState": "Restart state",
|
||||||
|
"pendingChanges": "Perubahan tertunda",
|
||||||
"selectedPreset": "Selected preset",
|
"selectedPreset": "Selected preset",
|
||||||
"presetModel": "Preset model",
|
"presetModel": "Preset model",
|
||||||
"density": "Density",
|
"density": "Density",
|
||||||
@@ -99,6 +118,15 @@
|
|||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
|
"imageGeneration": "Pembuatan gambar",
|
||||||
|
"imageProvider": "Penyedia gambar",
|
||||||
|
"imageProviderStatus": "Status penyedia",
|
||||||
|
"imageProviderBase": "Basis penyedia",
|
||||||
|
"imageModel": "Model gambar",
|
||||||
|
"defaultAspectRatio": "Rasio default",
|
||||||
|
"defaultImageSize": "Ukuran default",
|
||||||
|
"maxImagesPerTurn": "Maks. gambar per giliran",
|
||||||
|
"imageSaveDir": "Direktori penyimpanan",
|
||||||
"botName": "Bot name",
|
"botName": "Bot name",
|
||||||
"botIcon": "Bot icon",
|
"botIcon": "Bot icon",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
@@ -129,6 +157,13 @@
|
|||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
|
"imageGeneration": "Tampilkan generate_image di chat saat penyedia gambar yang dikonfigurasi tersedia.",
|
||||||
|
"imageProvider": "Pilih penyedia registry yang digunakan oleh generate_image.",
|
||||||
|
"imageProviderStatus": "Pembuatan gambar menggunakan ulang kredensial penyedia dari Providers.",
|
||||||
|
"imageModel": "Nama model yang dikirim ke penyedia gambar yang dipilih.",
|
||||||
|
"defaultAspectRatio": "Digunakan saat prompt tidak memilih rasio aspek.",
|
||||||
|
"defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.",
|
||||||
|
"maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.",
|
||||||
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
||||||
"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.",
|
||||||
@@ -141,8 +176,8 @@
|
|||||||
"notAvailable": "Tidak tersedia",
|
"notAvailable": "Tidak tersedia",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"restartRequired": "Restart required",
|
"restartPending": "Menunggu restart",
|
||||||
"liveReload": "Live reload ready",
|
"ready": "Siap",
|
||||||
"comfortable": "Comfortable",
|
"comfortable": "Comfortable",
|
||||||
"compact": "Compact",
|
"compact": "Compact",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
@@ -150,13 +185,19 @@
|
|||||||
"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...",
|
||||||
"loadError": "Tidak dapat memuat pengaturan",
|
"loadError": "Tidak dapat memuat pengaturan",
|
||||||
"unsaved": "Ada perubahan yang belum disimpan.",
|
"unsaved": "Ada perubahan yang belum disimpan.",
|
||||||
"savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan."
|
"upToDate": "Sudah terbaru.",
|
||||||
|
"savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.",
|
||||||
|
"restartAfterSaving": "Simpan perubahan, lalu restart saat siap.",
|
||||||
|
"savedRestartApply": "Tersimpan. Restart saat siap.",
|
||||||
|
"imageProviderRestart": "Perubahan penyedia gambar tersimpan. Restart saat siap."
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Simpan",
|
"save": "Simpan",
|
||||||
@@ -213,17 +254,26 @@
|
|||||||
"configuredCount": "{{count}} configured",
|
"configuredCount": "{{count}} configured",
|
||||||
"totalProviders": "{{count}} available",
|
"totalProviders": "{{count}} available",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
|
"imageGeneration": "Pembuatan gambar",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search."
|
"noMatches": "No providers match this search."
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"selectProvider": "Pilih penyedia",
|
||||||
|
"selectAspect": "Pilih rasio",
|
||||||
|
"selectSize": "Pilih ukuran",
|
||||||
|
"configureProvider": "Konfigurasi penyedia",
|
||||||
|
"missingCredential": "Konfigurasikan penyedia ini sebelum mengaktifkan pembuatan gambar."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Obrolan {{id}}",
|
"fallbackTitle": "Obrolan {{id}}",
|
||||||
"loading": "Memuat…",
|
"loading": "Memuat…",
|
||||||
"noSessions": "Belum ada sesi.",
|
"noSessions": "Belum ada sesi.",
|
||||||
|
"showMore": "Tampilkan {{count}} lagi",
|
||||||
"actions": "Aksi obrolan untuk {{title}}",
|
"actions": "Aksi obrolan untuk {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
@@ -268,7 +318,6 @@
|
|||||||
"thread": {
|
"thread": {
|
||||||
"loadingConversation": "Memuat percakapan…",
|
"loadingConversation": "Memuat percakapan…",
|
||||||
"empty": {
|
"empty": {
|
||||||
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru.",
|
|
||||||
"greeting": "Apa yang bisa saya bantu?",
|
"greeting": "Apa yang bisa saya bantu?",
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
"plan": {
|
"plan": {
|
||||||
@@ -321,10 +370,14 @@
|
|||||||
"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",
|
||||||
|
"newChat": "Mulai chat baru",
|
||||||
|
"toggleTheme": "Alihkan tema dari header",
|
||||||
|
"settings": "Buka pengaturan"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"placeholderThread": "Ketik pesan Anda…",
|
"placeholderThread": "Ketik pesan Anda…",
|
||||||
@@ -338,6 +391,7 @@
|
|||||||
"goalStateFallback": "Tujuan",
|
"goalStateFallback": "Tujuan",
|
||||||
"goalStateExpandAria": "Lihat tujuan lengkap",
|
"goalStateExpandAria": "Lihat tujuan lengkap",
|
||||||
"goalStateSheetTitle": "Tujuan",
|
"goalStateSheetTitle": "Tujuan",
|
||||||
|
"goalStateCloseAria": "Tutup tujuan",
|
||||||
"send": "Kirim pesan",
|
"send": "Kirim pesan",
|
||||||
"stop": "Hentikan respons",
|
"stop": "Hentikan respons",
|
||||||
"attachImage": "Lampirkan gambar",
|
"attachImage": "Lampirkan gambar",
|
||||||
@@ -356,16 +410,11 @@
|
|||||||
"16_9": "Lebar 16:9"
|
"16_9": "Lebar 16:9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "Memproses…",
|
"tools": {
|
||||||
"remove": "Hapus lampiran",
|
"search": "Cari",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
"reason": "Bernalar",
|
||||||
"imageRejected": {
|
"deepResearch": "Riset mendalam",
|
||||||
"unsupported_type": "Tipe file tidak didukung",
|
"voice": "Input suara"
|
||||||
"too_many_images": "Maksimal {{max}} gambar per pesan",
|
|
||||||
"magic_mismatch": "File ini tampaknya bukan gambar asli",
|
|
||||||
"decode_failed": "Tidak dapat mendekode gambar ini",
|
|
||||||
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
|
|
||||||
"io": "Tidak dapat membaca file ini"
|
|
||||||
},
|
},
|
||||||
"slash": {
|
"slash": {
|
||||||
"ariaLabel": "Perintah slash",
|
"ariaLabel": "Perintah slash",
|
||||||
@@ -416,7 +465,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"goalStateCloseAria": "Tutup tujuan"
|
"encoding": "Memproses…",
|
||||||
|
"remove": "Hapus lampiran",
|
||||||
|
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||||
|
"imageRejected": {
|
||||||
|
"unsupported_type": "Tipe file tidak didukung",
|
||||||
|
"too_many_images": "Maksimal {{max}} gambar per pesan",
|
||||||
|
"magic_mismatch": "File ini tampaknya bukan gambar asli",
|
||||||
|
"decode_failed": "Tidak dapat mendekode gambar ini",
|
||||||
|
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
|
||||||
|
"io": "Tidak dapat membaca file ini"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Gulir ke bawah",
|
"scrollToBottom": "Gulir ke bawah",
|
||||||
"loadEarlier": "Muat pesan sebelumnya"
|
"loadEarlier": "Muat pesan sebelumnya"
|
||||||
@@ -438,6 +497,8 @@
|
|||||||
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
|
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
|
||||||
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
|
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
|
||||||
"imageAttachment": "Lampiran gambar",
|
"imageAttachment": "Lampiran gambar",
|
||||||
|
"copyReply": "Salin balasan",
|
||||||
|
"copiedReply": "Balasan disalin",
|
||||||
"turnLatencyTitle": "Waktu respons (ujung ke ujung)"
|
"turnLatencyTitle": "Waktu respons (ujung ke ujung)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
"title": "nanobot に接続できませんでした",
|
"title": "nanobot に接続できませんでした",
|
||||||
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "認証が必要です",
|
||||||
|
"hint": "gateway 設定の tokenIssueSecret に指定されたシークレットを入力してください。",
|
||||||
|
"placeholder": "パスワード",
|
||||||
|
"submit": "接続",
|
||||||
|
"invalid": "パスワードが無効です。もう一度お試しください。"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"section": "アカウント",
|
||||||
|
"logoutHint": "このブラウザーを gateway から切断します。",
|
||||||
|
"logout": "サインアウト"
|
||||||
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"section": "システム",
|
"section": "システム",
|
||||||
"restartHint": "実行時の変更を適用するには nanobot を再起動します。",
|
"restartHint": "実行時の変更を適用するには nanobot を再起動します。",
|
||||||
@@ -27,9 +39,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"navigation": "サイドバーのナビゲーション",
|
||||||
|
"globalActions": "グローバル操作",
|
||||||
"collapse": "サイドバーを閉じる",
|
"collapse": "サイドバーを閉じる",
|
||||||
"toggleTheme": "テーマを切り替える",
|
"toggleTheme": "テーマを切り替える",
|
||||||
|
"home": "ホーム",
|
||||||
"newChat": "新しいチャット",
|
"newChat": "新しいチャット",
|
||||||
|
"searchAria": "検索",
|
||||||
"viewOptions": "View",
|
"viewOptions": "View",
|
||||||
"compactList": "Compact list",
|
"compactList": "Compact list",
|
||||||
"showPreviews": "Show previews",
|
"showPreviews": "Show previews",
|
||||||
@@ -38,17 +54,16 @@
|
|||||||
"sortUpdated": "Recently updated",
|
"sortUpdated": "Recently updated",
|
||||||
"sortCreated": "Recently created",
|
"sortCreated": "Recently created",
|
||||||
"sortTitle": "Title A-Z",
|
"sortTitle": "Title A-Z",
|
||||||
|
"searchPlaceholder": "検索",
|
||||||
|
"searchResults": "検索結果",
|
||||||
|
"noSearchResults": "一致するチャットはありません。",
|
||||||
"recent": "最近のチャット",
|
"recent": "最近のチャット",
|
||||||
"refreshSessions": "セッションを更新",
|
"refreshSessions": "セッションを更新",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "言語",
|
"label": "言語",
|
||||||
"ariaLabel": "言語を変更"
|
"ariaLabel": "言語を変更"
|
||||||
},
|
}
|
||||||
"searchAria": "検索",
|
|
||||||
"searchPlaceholder": "検索",
|
|
||||||
"searchResults": "検索結果",
|
|
||||||
"noSearchResults": "一致するチャットはありません。"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "チャットに戻る",
|
"backToChat": "チャットに戻る",
|
||||||
@@ -75,10 +90,13 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"localPreferences": "Local preferences",
|
"localPreferences": "Local preferences",
|
||||||
"presets": "Presets",
|
"presets": "Presets",
|
||||||
|
"imageGeneration": "画像生成",
|
||||||
|
"imageDefaults": "既定値",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
|
"capabilities": "機能",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
@@ -91,6 +109,7 @@
|
|||||||
"activePreset": "Active preset",
|
"activePreset": "Active preset",
|
||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"restartState": "Restart state",
|
"restartState": "Restart state",
|
||||||
|
"pendingChanges": "保留中の変更",
|
||||||
"selectedPreset": "Selected preset",
|
"selectedPreset": "Selected preset",
|
||||||
"presetModel": "Preset model",
|
"presetModel": "Preset model",
|
||||||
"density": "Density",
|
"density": "Density",
|
||||||
@@ -99,6 +118,15 @@
|
|||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
|
"imageGeneration": "画像生成",
|
||||||
|
"imageProvider": "画像プロバイダー",
|
||||||
|
"imageProviderStatus": "プロバイダーの状態",
|
||||||
|
"imageProviderBase": "プロバイダーのベース URL",
|
||||||
|
"imageModel": "画像モデル",
|
||||||
|
"defaultAspectRatio": "既定の比率",
|
||||||
|
"defaultImageSize": "既定のサイズ",
|
||||||
|
"maxImagesPerTurn": "1 ターンあたりの最大画像数",
|
||||||
|
"imageSaveDir": "保存先ディレクトリ",
|
||||||
"botName": "Bot name",
|
"botName": "Bot name",
|
||||||
"botIcon": "Bot icon",
|
"botIcon": "Bot icon",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
@@ -129,6 +157,13 @@
|
|||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
|
"imageGeneration": "設定済みの画像プロバイダーが利用できる場合、チャットで generate_image を有効にします。",
|
||||||
|
"imageProvider": "generate_image で使用する登録済みプロバイダーを選択します。",
|
||||||
|
"imageProviderStatus": "画像生成は「プロバイダー」の認証情報を再利用します。",
|
||||||
|
"imageModel": "選択した画像プロバイダーへ送信するモデル名です。",
|
||||||
|
"defaultAspectRatio": "プロンプトでアスペクト比が指定されていない場合に使用します。",
|
||||||
|
"defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。",
|
||||||
|
"maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。",
|
||||||
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
||||||
"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.",
|
||||||
@@ -141,8 +176,8 @@
|
|||||||
"notAvailable": "利用不可",
|
"notAvailable": "利用不可",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"restartRequired": "Restart required",
|
"restartPending": "再起動待ち",
|
||||||
"liveReload": "Live reload ready",
|
"ready": "準備完了",
|
||||||
"comfortable": "Comfortable",
|
"comfortable": "Comfortable",
|
||||||
"compact": "Compact",
|
"compact": "Compact",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
@@ -150,13 +185,19 @@
|
|||||||
"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": "設定を読み込んでいます...",
|
||||||
"loadError": "設定を読み込めませんでした",
|
"loadError": "設定を読み込めませんでした",
|
||||||
"unsaved": "未保存の変更があります。",
|
"unsaved": "未保存の変更があります。",
|
||||||
"savedRestart": "保存しました。反映するには nanobot を再起動してください。"
|
"upToDate": "最新です。",
|
||||||
|
"savedRestart": "保存しました。反映するには nanobot を再起動してください。",
|
||||||
|
"restartAfterSaving": "変更を保存してから、準備ができたら再起動してください。",
|
||||||
|
"savedRestartApply": "保存しました。準備ができたら再起動してください。",
|
||||||
|
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
@@ -213,17 +254,26 @@
|
|||||||
"configuredCount": "{{count}} configured",
|
"configuredCount": "{{count}} configured",
|
||||||
"totalProviders": "{{count}} available",
|
"totalProviders": "{{count}} available",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
|
"imageGeneration": "画像生成",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search."
|
"noMatches": "No providers match this search."
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"selectProvider": "プロバイダーを選択",
|
||||||
|
"selectAspect": "比率を選択",
|
||||||
|
"selectSize": "サイズを選択",
|
||||||
|
"configureProvider": "プロバイダーを設定",
|
||||||
|
"missingCredential": "画像生成を有効にする前に、このプロバイダーを設定してください。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "チャット {{id}}",
|
"fallbackTitle": "チャット {{id}}",
|
||||||
"loading": "読み込み中…",
|
"loading": "読み込み中…",
|
||||||
"noSessions": "まだセッションがありません。",
|
"noSessions": "まだセッションがありません。",
|
||||||
|
"showMore": "さらに {{count}} 件表示",
|
||||||
"actions": "「{{title}}」のチャット操作",
|
"actions": "「{{title}}」のチャット操作",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
@@ -268,7 +318,6 @@
|
|||||||
"thread": {
|
"thread": {
|
||||||
"loadingConversation": "会話を読み込み中…",
|
"loadingConversation": "会話を読み込み中…",
|
||||||
"empty": {
|
"empty": {
|
||||||
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。",
|
|
||||||
"greeting": "何をお手伝いしましょうか?",
|
"greeting": "何をお手伝いしましょうか?",
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
"plan": {
|
"plan": {
|
||||||
@@ -321,10 +370,14 @@
|
|||||||
"title": "画像を編集",
|
"title": "画像を編集",
|
||||||
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
|
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "サイドバーを切り替える"
|
"toggleSidebar": "サイドバーを切り替える",
|
||||||
|
"newChat": "新しいチャットを開始",
|
||||||
|
"toggleTheme": "ヘッダーからテーマを切り替える",
|
||||||
|
"settings": "設定を開く"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"placeholderThread": "メッセージを入力…",
|
"placeholderThread": "メッセージを入力…",
|
||||||
@@ -338,6 +391,7 @@
|
|||||||
"goalStateFallback": "目標",
|
"goalStateFallback": "目標",
|
||||||
"goalStateExpandAria": "目標の全文を表示",
|
"goalStateExpandAria": "目標の全文を表示",
|
||||||
"goalStateSheetTitle": "目標",
|
"goalStateSheetTitle": "目標",
|
||||||
|
"goalStateCloseAria": "目標を閉じる",
|
||||||
"send": "メッセージを送信",
|
"send": "メッセージを送信",
|
||||||
"stop": "応答を停止",
|
"stop": "応答を停止",
|
||||||
"attachImage": "画像を添付",
|
"attachImage": "画像を添付",
|
||||||
@@ -356,16 +410,11 @@
|
|||||||
"16_9": "ワイド 16:9"
|
"16_9": "ワイド 16:9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "処理中…",
|
"tools": {
|
||||||
"remove": "添付を削除",
|
"search": "検索",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)",
|
"reason": "推論",
|
||||||
"imageRejected": {
|
"deepResearch": "詳細調査",
|
||||||
"unsupported_type": "対応していないファイル形式です",
|
"voice": "音声入力"
|
||||||
"too_many_images": "1 メッセージにつき最大 {{max}} 枚です",
|
|
||||||
"magic_mismatch": "画像ファイルではないようです",
|
|
||||||
"decode_failed": "この画像をデコードできません",
|
|
||||||
"too_large": "画像が大きすぎます。小さいものを選んでください",
|
|
||||||
"io": "このファイルを読み込めません"
|
|
||||||
},
|
},
|
||||||
"slash": {
|
"slash": {
|
||||||
"ariaLabel": "スラッシュコマンド",
|
"ariaLabel": "スラッシュコマンド",
|
||||||
@@ -416,7 +465,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"goalStateCloseAria": "目標を閉じる"
|
"encoding": "処理中…",
|
||||||
|
"remove": "添付を削除",
|
||||||
|
"normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)",
|
||||||
|
"imageRejected": {
|
||||||
|
"unsupported_type": "対応していないファイル形式です",
|
||||||
|
"too_many_images": "1 メッセージにつき最大 {{max}} 枚です",
|
||||||
|
"magic_mismatch": "画像ファイルではないようです",
|
||||||
|
"decode_failed": "この画像をデコードできません",
|
||||||
|
"too_large": "画像が大きすぎます。小さいものを選んでください",
|
||||||
|
"io": "このファイルを読み込めません"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "一番下へスクロール",
|
"scrollToBottom": "一番下へスクロール",
|
||||||
"loadEarlier": "以前のメッセージを読み込む"
|
"loadEarlier": "以前のメッセージを読み込む"
|
||||||
@@ -438,6 +497,8 @@
|
|||||||
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
|
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
|
||||||
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
|
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
|
||||||
"imageAttachment": "画像の添付",
|
"imageAttachment": "画像の添付",
|
||||||
|
"copyReply": "返信をコピー",
|
||||||
|
"copiedReply": "返信をコピーしました",
|
||||||
"turnLatencyTitle": "応答時間(全行程)"
|
"turnLatencyTitle": "応答時間(全行程)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
"title": "nanobot에 연결할 수 없습니다",
|
"title": "nanobot에 연결할 수 없습니다",
|
||||||
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "인증이 필요합니다",
|
||||||
|
"hint": "gateway 설정의 tokenIssueSecret에 구성된 비밀 값을 입력하세요.",
|
||||||
|
"placeholder": "비밀번호",
|
||||||
|
"submit": "연결",
|
||||||
|
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"section": "계정",
|
||||||
|
"logoutHint": "이 브라우저를 gateway에서 연결 해제합니다.",
|
||||||
|
"logout": "로그아웃"
|
||||||
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"section": "시스템",
|
"section": "시스템",
|
||||||
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
|
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
|
||||||
@@ -27,9 +39,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"navigation": "사이드바 탐색",
|
||||||
|
"globalActions": "전역 작업",
|
||||||
"collapse": "사이드바 접기",
|
"collapse": "사이드바 접기",
|
||||||
"toggleTheme": "테마 전환",
|
"toggleTheme": "테마 전환",
|
||||||
|
"home": "홈",
|
||||||
"newChat": "새 채팅",
|
"newChat": "새 채팅",
|
||||||
|
"searchAria": "검색",
|
||||||
"viewOptions": "View",
|
"viewOptions": "View",
|
||||||
"compactList": "Compact list",
|
"compactList": "Compact list",
|
||||||
"showPreviews": "Show previews",
|
"showPreviews": "Show previews",
|
||||||
@@ -38,17 +54,16 @@
|
|||||||
"sortUpdated": "Recently updated",
|
"sortUpdated": "Recently updated",
|
||||||
"sortCreated": "Recently created",
|
"sortCreated": "Recently created",
|
||||||
"sortTitle": "Title A-Z",
|
"sortTitle": "Title A-Z",
|
||||||
|
"searchPlaceholder": "검색",
|
||||||
|
"searchResults": "결과",
|
||||||
|
"noSearchResults": "일치하는 채팅이 없습니다.",
|
||||||
"recent": "최근 대화",
|
"recent": "최근 대화",
|
||||||
"refreshSessions": "세션 새로고침",
|
"refreshSessions": "세션 새로고침",
|
||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "언어",
|
"label": "언어",
|
||||||
"ariaLabel": "언어 변경"
|
"ariaLabel": "언어 변경"
|
||||||
},
|
}
|
||||||
"searchAria": "검색",
|
|
||||||
"searchPlaceholder": "검색",
|
|
||||||
"searchResults": "결과",
|
|
||||||
"noSearchResults": "일치하는 채팅이 없습니다."
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "채팅으로 돌아가기",
|
"backToChat": "채팅으로 돌아가기",
|
||||||
@@ -75,10 +90,13 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"localPreferences": "Local preferences",
|
"localPreferences": "Local preferences",
|
||||||
"presets": "Presets",
|
"presets": "Presets",
|
||||||
|
"imageGeneration": "이미지 생성",
|
||||||
|
"imageDefaults": "기본값",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
|
"capabilities": "기능",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
@@ -91,6 +109,7 @@
|
|||||||
"activePreset": "Active preset",
|
"activePreset": "Active preset",
|
||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"restartState": "Restart state",
|
"restartState": "Restart state",
|
||||||
|
"pendingChanges": "대기 중인 변경 사항",
|
||||||
"selectedPreset": "Selected preset",
|
"selectedPreset": "Selected preset",
|
||||||
"presetModel": "Preset model",
|
"presetModel": "Preset model",
|
||||||
"density": "Density",
|
"density": "Density",
|
||||||
@@ -99,6 +118,15 @@
|
|||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
|
"imageGeneration": "이미지 생성",
|
||||||
|
"imageProvider": "이미지 제공자",
|
||||||
|
"imageProviderStatus": "제공자 상태",
|
||||||
|
"imageProviderBase": "제공자 기준 주소",
|
||||||
|
"imageModel": "이미지 모델",
|
||||||
|
"defaultAspectRatio": "기본 비율",
|
||||||
|
"defaultImageSize": "기본 크기",
|
||||||
|
"maxImagesPerTurn": "턴당 최대 이미지 수",
|
||||||
|
"imageSaveDir": "저장 디렉터리",
|
||||||
"botName": "Bot name",
|
"botName": "Bot name",
|
||||||
"botIcon": "Bot icon",
|
"botIcon": "Bot icon",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
@@ -129,6 +157,13 @@
|
|||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
|
"imageGeneration": "구성된 이미지 제공자를 사용할 수 있을 때 채팅에서 generate_image를 노출합니다.",
|
||||||
|
"imageProvider": "generate_image가 사용할 레지스트리 제공자를 선택합니다.",
|
||||||
|
"imageProviderStatus": "이미지 생성은 Providers의 제공자 자격 증명을 재사용합니다.",
|
||||||
|
"imageModel": "선택한 이미지 제공자에 보낼 모델 이름입니다.",
|
||||||
|
"defaultAspectRatio": "프롬프트에서 가로세로 비율을 선택하지 않았을 때 사용됩니다.",
|
||||||
|
"defaultImageSize": "지원하는 제공자에게 보내는 크기 힌트입니다.",
|
||||||
|
"maxImagesPerTurn": "한 번의 generate_image 요청에 대한 상한입니다.",
|
||||||
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
||||||
"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.",
|
||||||
@@ -141,8 +176,8 @@
|
|||||||
"notAvailable": "사용할 수 없음",
|
"notAvailable": "사용할 수 없음",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"restartRequired": "Restart required",
|
"restartPending": "재시작 대기 중",
|
||||||
"liveReload": "Live reload ready",
|
"ready": "준비됨",
|
||||||
"comfortable": "Comfortable",
|
"comfortable": "Comfortable",
|
||||||
"compact": "Compact",
|
"compact": "Compact",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
@@ -150,13 +185,19 @@
|
|||||||
"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": "설정을 불러오는 중...",
|
||||||
"loadError": "설정을 불러올 수 없습니다",
|
"loadError": "설정을 불러올 수 없습니다",
|
||||||
"unsaved": "저장되지 않은 변경 사항이 있습니다.",
|
"unsaved": "저장되지 않은 변경 사항이 있습니다.",
|
||||||
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요."
|
"upToDate": "최신 상태입니다.",
|
||||||
|
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요.",
|
||||||
|
"restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.",
|
||||||
|
"savedRestartApply": "저장되었습니다. 준비되면 재시작하세요.",
|
||||||
|
"imageProviderRestart": "이미지 제공자 변경 사항이 저장되었습니다. 준비되면 재시작하세요."
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "저장",
|
"save": "저장",
|
||||||
@@ -213,17 +254,26 @@
|
|||||||
"configuredCount": "{{count}} configured",
|
"configuredCount": "{{count}} configured",
|
||||||
"totalProviders": "{{count}} available",
|
"totalProviders": "{{count}} available",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
|
"imageGeneration": "이미지 생성",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search."
|
"noMatches": "No providers match this search."
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"selectProvider": "제공자 선택",
|
||||||
|
"selectAspect": "비율 선택",
|
||||||
|
"selectSize": "크기 선택",
|
||||||
|
"configureProvider": "제공자 구성",
|
||||||
|
"missingCredential": "이미지 생성을 활성화하기 전에 이 제공자를 구성하세요."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "채팅 {{id}}",
|
"fallbackTitle": "채팅 {{id}}",
|
||||||
"loading": "불러오는 중…",
|
"loading": "불러오는 중…",
|
||||||
"noSessions": "아직 세션이 없습니다.",
|
"noSessions": "아직 세션이 없습니다.",
|
||||||
|
"showMore": "{{count}}개 더 보기",
|
||||||
"actions": "{{title}} 채팅 작업",
|
"actions": "{{title}} 채팅 작업",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
@@ -268,7 +318,6 @@
|
|||||||
"thread": {
|
"thread": {
|
||||||
"loadingConversation": "대화 불러오는 중…",
|
"loadingConversation": "대화 불러오는 중…",
|
||||||
"empty": {
|
"empty": {
|
||||||
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다.",
|
|
||||||
"greeting": "무엇을 도와드릴까요?",
|
"greeting": "무엇을 도와드릴까요?",
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
"plan": {
|
"plan": {
|
||||||
@@ -321,10 +370,14 @@
|
|||||||
"title": "이미지 편집",
|
"title": "이미지 편집",
|
||||||
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
|
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "사이드바 전환"
|
"toggleSidebar": "사이드바 전환",
|
||||||
|
"newChat": "새 채팅 시작",
|
||||||
|
"toggleTheme": "헤더에서 테마 전환",
|
||||||
|
"settings": "설정 열기"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"placeholderThread": "메시지를 입력하세요…",
|
"placeholderThread": "메시지를 입력하세요…",
|
||||||
@@ -338,6 +391,7 @@
|
|||||||
"goalStateFallback": "목표",
|
"goalStateFallback": "목표",
|
||||||
"goalStateExpandAria": "전체 목표 보기",
|
"goalStateExpandAria": "전체 목표 보기",
|
||||||
"goalStateSheetTitle": "목표",
|
"goalStateSheetTitle": "목표",
|
||||||
|
"goalStateCloseAria": "목표 닫기",
|
||||||
"send": "메시지 보내기",
|
"send": "메시지 보내기",
|
||||||
"stop": "응답 중지",
|
"stop": "응답 중지",
|
||||||
"attachImage": "이미지 첨부",
|
"attachImage": "이미지 첨부",
|
||||||
@@ -356,16 +410,11 @@
|
|||||||
"16_9": "와이드 16:9"
|
"16_9": "와이드 16:9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "처리 중…",
|
"tools": {
|
||||||
"remove": "첨부 제거",
|
"search": "검색",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)",
|
"reason": "추론",
|
||||||
"imageRejected": {
|
"deepResearch": "심층 조사",
|
||||||
"unsupported_type": "지원하지 않는 파일 형식입니다",
|
"voice": "음성 입력"
|
||||||
"too_many_images": "메시지당 최대 {{max}}장까지 가능합니다",
|
|
||||||
"magic_mismatch": "이미지 파일이 아닌 것 같습니다",
|
|
||||||
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
|
|
||||||
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
|
|
||||||
"io": "이 파일을 읽을 수 없습니다"
|
|
||||||
},
|
},
|
||||||
"slash": {
|
"slash": {
|
||||||
"ariaLabel": "슬래시 명령",
|
"ariaLabel": "슬래시 명령",
|
||||||
@@ -416,7 +465,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"goalStateCloseAria": "목표 닫기"
|
"encoding": "처리 중…",
|
||||||
|
"remove": "첨부 제거",
|
||||||
|
"normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)",
|
||||||
|
"imageRejected": {
|
||||||
|
"unsupported_type": "지원하지 않는 파일 형식입니다",
|
||||||
|
"too_many_images": "메시지당 최대 {{max}}장까지 가능합니다",
|
||||||
|
"magic_mismatch": "이미지 파일이 아닌 것 같습니다",
|
||||||
|
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
|
||||||
|
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
|
||||||
|
"io": "이 파일을 읽을 수 없습니다"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "맨 아래로 스크롤",
|
"scrollToBottom": "맨 아래로 스크롤",
|
||||||
"loadEarlier": "이전 메시지 불러오기"
|
"loadEarlier": "이전 메시지 불러오기"
|
||||||
@@ -438,6 +497,8 @@
|
|||||||
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
|
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
|
||||||
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
|
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
|
||||||
"imageAttachment": "이미지 첨부",
|
"imageAttachment": "이미지 첨부",
|
||||||
|
"copyReply": "답변 복사",
|
||||||
|
"copiedReply": "답변이 복사됨",
|
||||||
"turnLatencyTitle": "응답 시간(엔드투엔드)"
|
"turnLatencyTitle": "응답 시간(엔드투엔드)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
"title": "Không thể kết nối tới nanobot",
|
"title": "Không thể kết nối tới nanobot",
|
||||||
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
|
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Cần xác thực",
|
||||||
|
"hint": "Nhập secret được cấu hình là tokenIssueSecret trong cấu hình gateway.",
|
||||||
|
"placeholder": "Mật khẩu",
|
||||||
|
"submit": "Kết nối",
|
||||||
|
"invalid": "Mật khẩu không hợp lệ. Hãy thử lại."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"section": "Tài khoản",
|
||||||
|
"logoutHint": "Ngắt kết nối trình duyệt này khỏi gateway.",
|
||||||
|
"logout": "Đăng xuất"
|
||||||
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"section": "Hệ thống",
|
"section": "Hệ thống",
|
||||||
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
|
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
|
||||||
@@ -27,9 +39,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"navigation": "Điều hướng thanh bên",
|
||||||
|
"globalActions": "Hành động toàn cục",
|
||||||
"collapse": "Thu gọn thanh bên",
|
"collapse": "Thu gọn thanh bên",
|
||||||
"toggleTheme": "Chuyển giao diện",
|
"toggleTheme": "Chuyển giao diện",
|
||||||
|
"home": "Trang chủ",
|
||||||
"newChat": "Cuộc trò chuyện mới",
|
"newChat": "Cuộc trò chuyện mới",
|
||||||
|
"searchAria": "Tìm kiếm",
|
||||||
"viewOptions": "View",
|
"viewOptions": "View",
|
||||||
"compactList": "Compact list",
|
"compactList": "Compact list",
|
||||||
"showPreviews": "Show previews",
|
"showPreviews": "Show previews",
|
||||||
@@ -38,17 +54,16 @@
|
|||||||
"sortUpdated": "Recently updated",
|
"sortUpdated": "Recently updated",
|
||||||
"sortCreated": "Recently created",
|
"sortCreated": "Recently created",
|
||||||
"sortTitle": "Title A-Z",
|
"sortTitle": "Title A-Z",
|
||||||
|
"searchPlaceholder": "Tìm kiếm",
|
||||||
|
"searchResults": "Kết quả",
|
||||||
|
"noSearchResults": "Không có cuộc trò chuyện phù hợp.",
|
||||||
"recent": "Gần đây",
|
"recent": "Gần đây",
|
||||||
"refreshSessions": "Làm mới phiên",
|
"refreshSessions": "Làm mới phiên",
|
||||||
"settings": "Cài đặt",
|
"settings": "Cài đặt",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Ngôn ngữ",
|
"label": "Ngôn ngữ",
|
||||||
"ariaLabel": "Đổi ngôn ngữ"
|
"ariaLabel": "Đổi ngôn ngữ"
|
||||||
},
|
}
|
||||||
"searchAria": "Tìm kiếm",
|
|
||||||
"searchPlaceholder": "Tìm kiếm",
|
|
||||||
"searchResults": "Kết quả",
|
|
||||||
"noSearchResults": "Không có cuộc trò chuyện phù hợp."
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Quay lại trò chuyện",
|
"backToChat": "Quay lại trò chuyện",
|
||||||
@@ -75,10 +90,13 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"localPreferences": "Local preferences",
|
"localPreferences": "Local preferences",
|
||||||
"presets": "Presets",
|
"presets": "Presets",
|
||||||
|
"imageGeneration": "Tạo ảnh",
|
||||||
|
"imageDefaults": "Mặc định",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
|
"capabilities": "Khả năng",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
@@ -91,6 +109,7 @@
|
|||||||
"activePreset": "Active preset",
|
"activePreset": "Active preset",
|
||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"restartState": "Restart state",
|
"restartState": "Restart state",
|
||||||
|
"pendingChanges": "Thay đổi đang chờ",
|
||||||
"selectedPreset": "Selected preset",
|
"selectedPreset": "Selected preset",
|
||||||
"presetModel": "Preset model",
|
"presetModel": "Preset model",
|
||||||
"density": "Density",
|
"density": "Density",
|
||||||
@@ -99,6 +118,15 @@
|
|||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
|
"imageGeneration": "Tạo ảnh",
|
||||||
|
"imageProvider": "Nhà cung cấp ảnh",
|
||||||
|
"imageProviderStatus": "Trạng thái nhà cung cấp",
|
||||||
|
"imageProviderBase": "Cơ sở nhà cung cấp",
|
||||||
|
"imageModel": "Mô hình ảnh",
|
||||||
|
"defaultAspectRatio": "Tỷ lệ mặc định",
|
||||||
|
"defaultImageSize": "Kích thước mặc định",
|
||||||
|
"maxImagesPerTurn": "Số ảnh tối đa mỗi lượt",
|
||||||
|
"imageSaveDir": "Thư mục lưu",
|
||||||
"botName": "Bot name",
|
"botName": "Bot name",
|
||||||
"botIcon": "Bot icon",
|
"botIcon": "Bot icon",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
@@ -129,6 +157,13 @@
|
|||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
|
"imageGeneration": "Hiển thị generate_image trong chat khi có nhà cung cấp ảnh đã cấu hình.",
|
||||||
|
"imageProvider": "Chọn nhà cung cấp registry được generate_image sử dụng.",
|
||||||
|
"imageProviderStatus": "Tạo ảnh dùng lại thông tin xác thực nhà cung cấp từ Providers.",
|
||||||
|
"imageModel": "Tên mô hình gửi tới nhà cung cấp ảnh đã chọn.",
|
||||||
|
"defaultAspectRatio": "Được dùng khi prompt không chọn tỷ lệ khung hình.",
|
||||||
|
"defaultImageSize": "Gợi ý kích thước gửi tới các nhà cung cấp hỗ trợ.",
|
||||||
|
"maxImagesPerTurn": "Giới hạn trên cho một yêu cầu generate_image.",
|
||||||
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
||||||
"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.",
|
||||||
@@ -141,8 +176,8 @@
|
|||||||
"notAvailable": "Không khả dụng",
|
"notAvailable": "Không khả dụng",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"restartRequired": "Restart required",
|
"restartPending": "Đang chờ khởi động lại",
|
||||||
"liveReload": "Live reload ready",
|
"ready": "Sẵn sàng",
|
||||||
"comfortable": "Comfortable",
|
"comfortable": "Comfortable",
|
||||||
"compact": "Compact",
|
"compact": "Compact",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
@@ -150,13 +185,19 @@
|
|||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"notConfigured": "Not configured"
|
"notConfigured": "Not configured",
|
||||||
|
"restartRequired": "Restart required",
|
||||||
|
"liveReload": "Live reload ready"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Đang tải cài đặt...",
|
"loading": "Đang tải cài đặt...",
|
||||||
"loadError": "Không thể tải cài đặt",
|
"loadError": "Không thể tải cài đặt",
|
||||||
"unsaved": "Có thay đổi chưa lưu.",
|
"unsaved": "Có thay đổi chưa lưu.",
|
||||||
"savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng."
|
"upToDate": "Đã cập nhật.",
|
||||||
|
"savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng.",
|
||||||
|
"restartAfterSaving": "Lưu thay đổi, rồi khởi động lại khi sẵn sàng.",
|
||||||
|
"savedRestartApply": "Đã lưu. Khởi động lại khi sẵn sàng.",
|
||||||
|
"imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng."
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
@@ -213,17 +254,26 @@
|
|||||||
"configuredCount": "{{count}} configured",
|
"configuredCount": "{{count}} configured",
|
||||||
"totalProviders": "{{count}} available",
|
"totalProviders": "{{count}} available",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
|
"imageGeneration": "Tạo ảnh",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search."
|
"noMatches": "No providers match this search."
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"selectProvider": "Chọn nhà cung cấp",
|
||||||
|
"selectAspect": "Chọn tỷ lệ",
|
||||||
|
"selectSize": "Chọn kích thước",
|
||||||
|
"configureProvider": "Cấu hình nhà cung cấp",
|
||||||
|
"missingCredential": "Cấu hình nhà cung cấp này trước khi bật tạo ảnh."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Trò chuyện {{id}}",
|
"fallbackTitle": "Trò chuyện {{id}}",
|
||||||
"loading": "Đang tải…",
|
"loading": "Đang tải…",
|
||||||
"noSessions": "Chưa có phiên nào.",
|
"noSessions": "Chưa có phiên nào.",
|
||||||
|
"showMore": "Hiển thị thêm {{count}}",
|
||||||
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
|
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
@@ -268,7 +318,6 @@
|
|||||||
"thread": {
|
"thread": {
|
||||||
"loadingConversation": "Đang tải cuộc trò chuyện…",
|
"loadingConversation": "Đang tải cuộc trò chuyện…",
|
||||||
"empty": {
|
"empty": {
|
||||||
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới.",
|
|
||||||
"greeting": "Tôi có thể giúp gì cho bạn?",
|
"greeting": "Tôi có thể giúp gì cho bạn?",
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
"plan": {
|
"plan": {
|
||||||
@@ -321,10 +370,14 @@
|
|||||||
"title": "Chỉnh sửa ảnh",
|
"title": "Chỉnh sửa ảnh",
|
||||||
"prompt": "Giúp tôi chỉnh sửa một ảnh. Trước tiên hãy yêu cầu tôi tải lên hoặc chỉ định ảnh, rồi tạo kết quả đã chỉnh sửa."
|
"prompt": "Giúp tôi chỉnh sửa một ảnh. Trước tiên hãy yêu cầu tôi tải lên hoặc chỉ định ảnh, rồi tạo kết quả đã chỉnh sửa."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "Bật/tắt thanh bên"
|
"toggleSidebar": "Bật/tắt thanh bên",
|
||||||
|
"newChat": "Bắt đầu chat mới",
|
||||||
|
"toggleTheme": "Chuyển chủ đề từ header",
|
||||||
|
"settings": "Mở cài đặt"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"placeholderThread": "Nhập tin nhắn…",
|
"placeholderThread": "Nhập tin nhắn…",
|
||||||
@@ -338,6 +391,7 @@
|
|||||||
"goalStateFallback": "Mục tiêu",
|
"goalStateFallback": "Mục tiêu",
|
||||||
"goalStateExpandAria": "Xem đầy đủ mục tiêu",
|
"goalStateExpandAria": "Xem đầy đủ mục tiêu",
|
||||||
"goalStateSheetTitle": "Mục tiêu",
|
"goalStateSheetTitle": "Mục tiêu",
|
||||||
|
"goalStateCloseAria": "Đóng mục tiêu",
|
||||||
"send": "Gửi tin nhắn",
|
"send": "Gửi tin nhắn",
|
||||||
"stop": "Dừng phản hồi",
|
"stop": "Dừng phản hồi",
|
||||||
"attachImage": "Đính kèm ảnh",
|
"attachImage": "Đính kèm ảnh",
|
||||||
@@ -356,16 +410,11 @@
|
|||||||
"16_9": "Rộng 16:9"
|
"16_9": "Rộng 16:9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "Đang xử lý…",
|
"tools": {
|
||||||
"remove": "Xóa tệp đính kèm",
|
"search": "Tìm kiếm",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (tự động)",
|
"reason": "Suy luận",
|
||||||
"imageRejected": {
|
"deepResearch": "Nghiên cứu sâu",
|
||||||
"unsupported_type": "Loại tệp không được hỗ trợ",
|
"voice": "Nhập bằng giọng nói"
|
||||||
"too_many_images": "Tối đa {{max}} ảnh mỗi tin nhắn",
|
|
||||||
"magic_mismatch": "Tệp này không phải là một ảnh thực",
|
|
||||||
"decode_failed": "Không thể giải mã ảnh này",
|
|
||||||
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
|
|
||||||
"io": "Không thể đọc tệp này"
|
|
||||||
},
|
},
|
||||||
"slash": {
|
"slash": {
|
||||||
"ariaLabel": "Lệnh slash",
|
"ariaLabel": "Lệnh slash",
|
||||||
@@ -416,7 +465,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"goalStateCloseAria": "Đóng mục tiêu"
|
"encoding": "Đang xử lý…",
|
||||||
|
"remove": "Xóa tệp đính kèm",
|
||||||
|
"normalizedSizeHint": "{{orig}} → {{current}} (tự động)",
|
||||||
|
"imageRejected": {
|
||||||
|
"unsupported_type": "Loại tệp không được hỗ trợ",
|
||||||
|
"too_many_images": "Tối đa {{max}} ảnh mỗi tin nhắn",
|
||||||
|
"magic_mismatch": "Tệp này không phải là một ảnh thực",
|
||||||
|
"decode_failed": "Không thể giải mã ảnh này",
|
||||||
|
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
|
||||||
|
"io": "Không thể đọc tệp này"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Cuộn xuống cuối",
|
"scrollToBottom": "Cuộn xuống cuối",
|
||||||
"loadEarlier": "Tải tin nhắn trước đó"
|
"loadEarlier": "Tải tin nhắn trước đó"
|
||||||
@@ -438,6 +497,8 @@
|
|||||||
"agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ",
|
"agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ",
|
||||||
"agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
|
"agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
|
||||||
"imageAttachment": "Tệp hình ảnh đính kèm",
|
"imageAttachment": "Tệp hình ảnh đính kèm",
|
||||||
|
"copyReply": "Sao chép trả lời",
|
||||||
|
"copiedReply": "Đã sao chép trả lời",
|
||||||
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)"
|
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"providers": "提供商",
|
"providers": "提供商",
|
||||||
"image": "图片",
|
"image": "图片",
|
||||||
"web": "网页",
|
"web": "网页",
|
||||||
|
"cliApps": "CLI 应用",
|
||||||
"runtime": "运行时",
|
"runtime": "运行时",
|
||||||
"advanced": "高级"
|
"advanced": "高级"
|
||||||
},
|
},
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
"imageDefaults": "默认值",
|
"imageDefaults": "默认值",
|
||||||
"webSearch": "网页搜索",
|
"webSearch": "网页搜索",
|
||||||
"webBehavior": "行为",
|
"webBehavior": "行为",
|
||||||
|
"cliApps": "CLI 应用",
|
||||||
"identity": "身份",
|
"identity": "身份",
|
||||||
"safety": "安全",
|
"safety": "安全",
|
||||||
"capabilities": "能力",
|
"capabilities": "能力",
|
||||||
@@ -103,6 +105,7 @@
|
|||||||
"density": "密度",
|
"density": "密度",
|
||||||
"activityMode": "活动细节",
|
"activityMode": "活动细节",
|
||||||
"codeWrap": "代码换行",
|
"codeWrap": "代码换行",
|
||||||
|
"brandLogos": "品牌 Logo",
|
||||||
"maxResults": "最大结果数",
|
"maxResults": "最大结果数",
|
||||||
"timeout": "超时",
|
"timeout": "超时",
|
||||||
"jinaReader": "Jina Reader",
|
"jinaReader": "Jina Reader",
|
||||||
@@ -129,6 +132,8 @@
|
|||||||
"ssrfWhitelist": "SSRF 白名单",
|
"ssrfWhitelist": "SSRF 白名单",
|
||||||
"mcpServers": "MCP 服务器",
|
"mcpServers": "MCP 服务器",
|
||||||
"pathAppend": "PATH 追加",
|
"pathAppend": "PATH 追加",
|
||||||
|
"cliAppsCatalog": "目录",
|
||||||
|
"cliAppsFilter": "筛选",
|
||||||
"configurationDocs": "配置文档"
|
"configurationDocs": "配置文档"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
@@ -142,6 +147,7 @@
|
|||||||
"density": "仅保存在当前浏览器。",
|
"density": "仅保存在当前浏览器。",
|
||||||
"activityMode": "选择默认显示多少 agent 活动细节。",
|
"activityMode": "选择默认显示多少 agent 活动细节。",
|
||||||
"codeWrap": "让较小屏幕上的长代码行更易读。",
|
"codeWrap": "让较小屏幕上的长代码行更易读。",
|
||||||
|
"brandLogos": "在设置页显示第三方服务商和 CLI 的 Logo。",
|
||||||
"maxResults": "每次 web_search 返回的结果数量。",
|
"maxResults": "每次 web_search 返回的结果数量。",
|
||||||
"timeout": "搜索服务商请求超时秒数。",
|
"timeout": "搜索服务商请求超时秒数。",
|
||||||
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
||||||
@@ -156,8 +162,41 @@
|
|||||||
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
|
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
|
||||||
"timezone": "运行时上下文和计划任务使用的 IANA 时区。",
|
"timezone": "运行时上下文和计划任务使用的 IANA 时区。",
|
||||||
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
|
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
|
||||||
|
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
|
||||||
|
"cliAppsFilter": "按应用、分类或能力搜索。",
|
||||||
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
|
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
|
||||||
},
|
},
|
||||||
|
"cliApps": {
|
||||||
|
"allCategories": "全部分类",
|
||||||
|
"availableCount": "{{count}} 个应用",
|
||||||
|
"installedCount": "已安装 {{count}} 个 CLI",
|
||||||
|
"summary": "CLI 已安装 {{installed}} / {{total}}",
|
||||||
|
"filterAll": "全部",
|
||||||
|
"filterInstalled": "已装 CLI",
|
||||||
|
"filterNotInstalled": "未装 CLI",
|
||||||
|
"searchPlaceholder": "搜索 CLI",
|
||||||
|
"loading": "正在加载 CLI 应用...",
|
||||||
|
"empty": "没有匹配的 CLI 应用。",
|
||||||
|
"statusInstalled": "CLI 已安装",
|
||||||
|
"statusMissing": "缺失",
|
||||||
|
"statusAvailable": "可用",
|
||||||
|
"statusUnsupported": "暂不支持",
|
||||||
|
"statusNotInstalled": "CLI 未安装",
|
||||||
|
"requires": "依赖",
|
||||||
|
"test": "测试 CLI",
|
||||||
|
"update": "更新 CLI",
|
||||||
|
"uninstall": "卸载 CLI",
|
||||||
|
"install": "安装 CLI",
|
||||||
|
"readyTitle": "@{{name}} 已就绪",
|
||||||
|
"readyStatus": "就绪",
|
||||||
|
"readyTry": "试试 @{{name}}",
|
||||||
|
"readyCopied": "已复制",
|
||||||
|
"readyPrompt": "用 @{{name}} 看看这个 CLI 能做什么。",
|
||||||
|
"openChat": "回到对话",
|
||||||
|
"unsupported": "暂不支持",
|
||||||
|
"unavailable": "不可用",
|
||||||
|
"noDescription": "暂无描述。"
|
||||||
|
},
|
||||||
"values": {
|
"values": {
|
||||||
"light": "浅色",
|
"light": "浅色",
|
||||||
"dark": "深色",
|
"dark": "深色",
|
||||||
@@ -247,6 +286,9 @@
|
|||||||
"searchPlaceholder": "搜索服务商",
|
"searchPlaceholder": "搜索服务商",
|
||||||
"noMatches": "没有匹配的服务商。"
|
"noMatches": "没有匹配的服务商。"
|
||||||
},
|
},
|
||||||
|
"legal": {
|
||||||
|
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||||
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "选择服务商",
|
"selectProvider": "选择服务商",
|
||||||
"selectAspect": "选择比例",
|
"selectAspect": "选择比例",
|
||||||
@@ -259,6 +301,7 @@
|
|||||||
"fallbackTitle": "对话 {{id}}",
|
"fallbackTitle": "对话 {{id}}",
|
||||||
"loading": "加载中…",
|
"loading": "加载中…",
|
||||||
"noSessions": "还没有会话。",
|
"noSessions": "还没有会话。",
|
||||||
|
"showMore": "再显示 {{count}} 个",
|
||||||
"actions": "“{{title}}” 的会话操作",
|
"actions": "“{{title}}” 的会话操作",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent 正在运行",
|
"running": "Agent 正在运行",
|
||||||
@@ -448,6 +491,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mentions": {
|
||||||
|
"ariaLabel": "CLI 应用",
|
||||||
|
"label": "CLI 应用"
|
||||||
|
},
|
||||||
"encoding": "处理中…",
|
"encoding": "处理中…",
|
||||||
"remove": "移除附件",
|
"remove": "移除附件",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
|
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
|
||||||
@@ -480,6 +527,15 @@
|
|||||||
"agentActivityToolsOnly": "{{tools}} 次工具调用",
|
"agentActivityToolsOnly": "{{tools}} 次工具调用",
|
||||||
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
|
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
|
||||||
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
|
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
|
||||||
|
"cliActivityRunningOne": "正在运行 CLI @{{name}}",
|
||||||
|
"cliActivityRanOne": "已运行 CLI @{{name}}",
|
||||||
|
"cliActivityFailedOne": "CLI 调用失败 @{{name}}",
|
||||||
|
"cliActivityRunningMany": "正在运行 {{count}} 个 CLI",
|
||||||
|
"cliActivityRanMany": "已运行 {{count}} 个 CLI",
|
||||||
|
"cliActivityFailedMany": "{{count}} 个 CLI 调用失败",
|
||||||
|
"cliRunRunning": "正在运行 CLI",
|
||||||
|
"cliRunRan": "已运行 CLI",
|
||||||
|
"cliRunFailed": "CLI 调用失败",
|
||||||
"imageAttachment": "图片附件",
|
"imageAttachment": "图片附件",
|
||||||
"copyReply": "复制回复",
|
"copyReply": "复制回复",
|
||||||
"copiedReply": "已复制回复",
|
"copiedReply": "已复制回复",
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
"title": "無法連線到 nanobot",
|
"title": "無法連線到 nanobot",
|
||||||
"gatewayHint": "請確認 gateway 已啟動(`nanobot gateway`),並且目前頁面與 gateway 在同一台機器上開啟。"
|
"gatewayHint": "請確認 gateway 已啟動(`nanobot gateway`),並且目前頁面與 gateway 在同一台機器上開啟。"
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "需要驗證",
|
||||||
|
"hint": "輸入 gateway 設定中 tokenIssueSecret 配置的密鑰。",
|
||||||
|
"placeholder": "密碼",
|
||||||
|
"submit": "連線",
|
||||||
|
"invalid": "密碼無效,請再試一次。"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"section": "帳戶",
|
||||||
|
"logoutHint": "中斷此瀏覽器與 gateway 的連線。",
|
||||||
|
"logout": "登出"
|
||||||
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"section": "系統",
|
"section": "系統",
|
||||||
"restartHint": "重新啟動 nanobot 以套用執行階段變更。",
|
"restartHint": "重新啟動 nanobot 以套用執行階段變更。",
|
||||||
@@ -27,9 +39,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"navigation": "側邊欄導覽",
|
||||||
|
"globalActions": "全域操作",
|
||||||
"collapse": "收合側邊欄",
|
"collapse": "收合側邊欄",
|
||||||
"toggleTheme": "切換主題",
|
"toggleTheme": "切換主題",
|
||||||
|
"home": "首頁",
|
||||||
"newChat": "新增對話",
|
"newChat": "新增對話",
|
||||||
|
"searchAria": "搜尋",
|
||||||
"viewOptions": "檢視",
|
"viewOptions": "檢視",
|
||||||
"compactList": "緊湊列表",
|
"compactList": "緊湊列表",
|
||||||
"showPreviews": "顯示預覽",
|
"showPreviews": "顯示預覽",
|
||||||
@@ -38,17 +54,16 @@
|
|||||||
"sortUpdated": "最近更新",
|
"sortUpdated": "最近更新",
|
||||||
"sortCreated": "最近建立",
|
"sortCreated": "最近建立",
|
||||||
"sortTitle": "標題 A-Z",
|
"sortTitle": "標題 A-Z",
|
||||||
|
"searchPlaceholder": "搜尋",
|
||||||
|
"searchResults": "搜尋結果",
|
||||||
|
"noSearchResults": "沒有符合的對話。",
|
||||||
"recent": "最近對話",
|
"recent": "最近對話",
|
||||||
"refreshSessions": "重新整理會話",
|
"refreshSessions": "重新整理會話",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "語言",
|
"label": "語言",
|
||||||
"ariaLabel": "切換語言"
|
"ariaLabel": "切換語言"
|
||||||
},
|
}
|
||||||
"searchAria": "搜尋",
|
|
||||||
"searchPlaceholder": "搜尋",
|
|
||||||
"searchResults": "搜尋結果",
|
|
||||||
"noSearchResults": "沒有符合的對話。"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "返回對話",
|
"backToChat": "返回對話",
|
||||||
@@ -75,10 +90,13 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"localPreferences": "Local preferences",
|
"localPreferences": "Local preferences",
|
||||||
"presets": "Presets",
|
"presets": "Presets",
|
||||||
|
"imageGeneration": "圖片生成",
|
||||||
|
"imageDefaults": "預設值",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"safety": "Safety",
|
"safety": "Safety",
|
||||||
|
"capabilities": "功能",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
@@ -91,6 +109,7 @@
|
|||||||
"activePreset": "Active preset",
|
"activePreset": "Active preset",
|
||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"restartState": "Restart state",
|
"restartState": "Restart state",
|
||||||
|
"pendingChanges": "待處理變更",
|
||||||
"selectedPreset": "Selected preset",
|
"selectedPreset": "Selected preset",
|
||||||
"presetModel": "Preset model",
|
"presetModel": "Preset model",
|
||||||
"density": "Density",
|
"density": "Density",
|
||||||
@@ -99,6 +118,15 @@
|
|||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
|
"imageGeneration": "圖片生成",
|
||||||
|
"imageProvider": "圖片服務商",
|
||||||
|
"imageProviderStatus": "服務商狀態",
|
||||||
|
"imageProviderBase": "服務商位址",
|
||||||
|
"imageModel": "圖片模型",
|
||||||
|
"defaultAspectRatio": "預設比例",
|
||||||
|
"defaultImageSize": "預設尺寸",
|
||||||
|
"maxImagesPerTurn": "每輪最多圖片數",
|
||||||
|
"imageSaveDir": "儲存目錄",
|
||||||
"botName": "Bot name",
|
"botName": "Bot name",
|
||||||
"botIcon": "Bot icon",
|
"botIcon": "Bot icon",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
@@ -129,6 +157,13 @@
|
|||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
|
"imageGeneration": "當已設定圖片服務商時,在對話中開放 generate_image。",
|
||||||
|
"imageProvider": "選擇 generate_image 使用的登錄服務商。",
|
||||||
|
"imageProviderStatus": "圖片生成會重用「服務商」中的憑證。",
|
||||||
|
"imageModel": "傳送給所選圖片服務商的模型名稱。",
|
||||||
|
"defaultAspectRatio": "當提示詞未指定長寬比時使用。",
|
||||||
|
"defaultImageSize": "傳送給支援此功能的服務商的尺寸提示。",
|
||||||
|
"maxImagesPerTurn": "單次 generate_image 請求的上限。",
|
||||||
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
"botName": "Shown in runtime surfaces that use the configured bot identity.",
|
||||||
"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.",
|
||||||
@@ -141,8 +176,8 @@
|
|||||||
"notAvailable": "不可用",
|
"notAvailable": "不可用",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"restartRequired": "Restart required",
|
"restartPending": "等待重新啟動",
|
||||||
"liveReload": "Live reload ready",
|
"ready": "就緒",
|
||||||
"comfortable": "Comfortable",
|
"comfortable": "Comfortable",
|
||||||
"compact": "Compact",
|
"compact": "Compact",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
@@ -150,13 +185,19 @@
|
|||||||
"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": "正在載入設定...",
|
||||||
"loadError": "無法載入設定",
|
"loadError": "無法載入設定",
|
||||||
"unsaved": "有未儲存的變更。",
|
"unsaved": "有未儲存的變更。",
|
||||||
"savedRestart": "已儲存。重新啟動 nanobot 後生效。"
|
"upToDate": "已是最新。",
|
||||||
|
"savedRestart": "已儲存。重新啟動 nanobot 後生效。",
|
||||||
|
"restartAfterSaving": "儲存變更後,可在準備好時重新啟動。",
|
||||||
|
"savedRestartApply": "已儲存,可在準備好時重新啟動。",
|
||||||
|
"imageProviderRestart": "圖片服務商變更已儲存,可在準備好時重新啟動。"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
@@ -213,17 +254,26 @@
|
|||||||
"configuredCount": "{{count}} configured",
|
"configuredCount": "{{count}} configured",
|
||||||
"totalProviders": "{{count}} available",
|
"totalProviders": "{{count}} available",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
|
"imageGeneration": "圖片生成",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"searchPlaceholder": "Search providers",
|
"searchPlaceholder": "Search providers",
|
||||||
"noMatches": "No providers match this search."
|
"noMatches": "No providers match this search."
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"selectProvider": "選擇服務商",
|
||||||
|
"selectAspect": "選擇比例",
|
||||||
|
"selectSize": "選擇尺寸",
|
||||||
|
"configureProvider": "設定服務商",
|
||||||
|
"missingCredential": "啟用圖片生成前,請先設定此服務商。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "對話 {{id}}",
|
"fallbackTitle": "對話 {{id}}",
|
||||||
"loading": "載入中…",
|
"loading": "載入中…",
|
||||||
"noSessions": "目前還沒有會話。",
|
"noSessions": "目前還沒有會話。",
|
||||||
|
"showMore": "再顯示 {{count}} 個",
|
||||||
"actions": "「{{title}}」的會話操作",
|
"actions": "「{{title}}」的會話操作",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent 正在執行",
|
"running": "Agent 正在執行",
|
||||||
@@ -268,7 +318,6 @@
|
|||||||
"thread": {
|
"thread": {
|
||||||
"loadingConversation": "正在載入對話…",
|
"loadingConversation": "正在載入對話…",
|
||||||
"empty": {
|
"empty": {
|
||||||
"description": "你可以提問、延續本地工作,或是開始新的執行緒。",
|
|
||||||
"greeting": "我可以幫你做什麼?",
|
"greeting": "我可以幫你做什麼?",
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
"plan": {
|
"plan": {
|
||||||
@@ -321,10 +370,14 @@
|
|||||||
"title": "編輯圖片",
|
"title": "編輯圖片",
|
||||||
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
|
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "切換側邊欄"
|
"toggleSidebar": "切換側邊欄",
|
||||||
|
"newChat": "開始新對話",
|
||||||
|
"toggleTheme": "從頂部切換主題",
|
||||||
|
"settings": "開啟設定"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"placeholderThread": "輸入訊息…",
|
"placeholderThread": "輸入訊息…",
|
||||||
@@ -338,6 +391,7 @@
|
|||||||
"goalStateFallback": "目標",
|
"goalStateFallback": "目標",
|
||||||
"goalStateExpandAria": "查看完整目標",
|
"goalStateExpandAria": "查看完整目標",
|
||||||
"goalStateSheetTitle": "目標",
|
"goalStateSheetTitle": "目標",
|
||||||
|
"goalStateCloseAria": "關閉目標",
|
||||||
"send": "送出訊息",
|
"send": "送出訊息",
|
||||||
"stop": "停止回覆",
|
"stop": "停止回覆",
|
||||||
"attachImage": "附加圖片",
|
"attachImage": "附加圖片",
|
||||||
@@ -356,16 +410,11 @@
|
|||||||
"16_9": "寬螢幕 16:9"
|
"16_9": "寬螢幕 16:9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "處理中…",
|
"tools": {
|
||||||
"remove": "移除附件",
|
"search": "搜尋",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)",
|
"reason": "推理",
|
||||||
"imageRejected": {
|
"deepResearch": "深度研究",
|
||||||
"unsupported_type": "不支援的檔案類型",
|
"voice": "語音輸入"
|
||||||
"too_many_images": "每則訊息最多 {{max}} 張圖片",
|
|
||||||
"magic_mismatch": "檔案看起來不像真正的圖片",
|
|
||||||
"decode_failed": "無法解碼這張圖片",
|
|
||||||
"too_large": "圖片太大,請換一張小一點的",
|
|
||||||
"io": "無法讀取這個檔案"
|
|
||||||
},
|
},
|
||||||
"slash": {
|
"slash": {
|
||||||
"ariaLabel": "斜線命令",
|
"ariaLabel": "斜線命令",
|
||||||
@@ -416,7 +465,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"goalStateCloseAria": "關閉目標"
|
"encoding": "處理中…",
|
||||||
|
"remove": "移除附件",
|
||||||
|
"normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)",
|
||||||
|
"imageRejected": {
|
||||||
|
"unsupported_type": "不支援的檔案類型",
|
||||||
|
"too_many_images": "每則訊息最多 {{max}} 張圖片",
|
||||||
|
"magic_mismatch": "檔案看起來不像真正的圖片",
|
||||||
|
"decode_failed": "無法解碼這張圖片",
|
||||||
|
"too_large": "圖片太大,請換一張小一點的",
|
||||||
|
"io": "無法讀取這個檔案"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "捲動到底部",
|
"scrollToBottom": "捲動到底部",
|
||||||
"loadEarlier": "載入更早訊息"
|
"loadEarlier": "載入更早訊息"
|
||||||
@@ -438,6 +497,8 @@
|
|||||||
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
|
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
|
||||||
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
|
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
|
||||||
"imageAttachment": "圖片附件",
|
"imageAttachment": "圖片附件",
|
||||||
|
"copyReply": "複製回覆",
|
||||||
|
"copiedReply": "已複製回覆",
|
||||||
"turnLatencyTitle": "本輪耗時(端到端)"
|
"turnLatencyTitle": "本輪耗時(端到端)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
|
CliAppsPayload,
|
||||||
ImageGenerationSettingsUpdate,
|
ImageGenerationSettingsUpdate,
|
||||||
ProviderSettingsUpdate,
|
ProviderSettingsUpdate,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
@@ -106,6 +107,24 @@ export async function fetchSettings(
|
|||||||
return request<SettingsPayload>(`${base}/api/settings`, token);
|
return request<SettingsPayload>(`${base}/api/settings`, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchCliApps(
|
||||||
|
token: string,
|
||||||
|
base: string = "",
|
||||||
|
): Promise<CliAppsPayload> {
|
||||||
|
return request<CliAppsPayload>(`${base}/api/settings/cli-apps`, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCliAppAction(
|
||||||
|
token: string,
|
||||||
|
action: "install" | "update" | "uninstall" | "test",
|
||||||
|
name: string,
|
||||||
|
base: string = "",
|
||||||
|
): Promise<CliAppsPayload> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("name", name);
|
||||||
|
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listSlashCommands(
|
export async function listSlashCommands(
|
||||||
token: string,
|
token: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user