mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +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:
|
||||
fail-fast: false
|
||||
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).
|
||||
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
||||
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||

|
||||
|
||||
<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>
|
||||
<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>
|
||||
@@ -61,7 +73,7 @@
|
||||
- **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-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-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
|
||||
- **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 |
|
||||
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
|
||||
| **Mochat** | Claw token (auto-setup available) |
|
||||
| **Signal** | signal-cli daemon + phone number |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
@@ -669,3 +670,69 @@ nanobot gateway
|
||||
```
|
||||
|
||||
</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) |
|
||||
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
|
||||
| `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) |
|
||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.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]
|
||||
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
||||
@@ -46,7 +46,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
||||
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `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.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` |
|
||||
@@ -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).
|
||||
|
||||
### 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 (阶跃星辰) `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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -22,7 +22,7 @@ from nanobot.utils.prompt_templates import render_template
|
||||
class ContextBuilder:
|
||||
"""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]"
|
||||
_MAX_RECENT_HISTORY = 50
|
||||
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
|
||||
@@ -47,6 +47,8 @@ class ContextBuilder:
|
||||
if bootstrap:
|
||||
parts.append(bootstrap)
|
||||
|
||||
parts.append(render_template("agent/tool_contract.md"))
|
||||
|
||||
memory = self.memory.get_memory_context()
|
||||
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
|
||||
parts.append(f"# Memory\n\n{memory}")
|
||||
@@ -154,9 +156,14 @@ class ContextBuilder:
|
||||
sender_id: str | None = None,
|
||||
session_summary: str | None = None,
|
||||
session_metadata: Mapping[str, Any] | None = None,
|
||||
current_runtime_lines: Sequence[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""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(
|
||||
channel,
|
||||
chat_id,
|
||||
@@ -210,4 +217,3 @@ class ContextBuilder:
|
||||
if not images:
|
||||
return 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.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.cli_apps import utils as cli_app_utils
|
||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
@@ -59,7 +60,6 @@ if TYPE_CHECKING:
|
||||
|
||||
UNIFIED_SESSION_KEY = "unified:default"
|
||||
|
||||
|
||||
class TurnState(Enum):
|
||||
RESTORE = auto()
|
||||
COMPACT = auto()
|
||||
@@ -568,7 +568,7 @@ class AgentLoop:
|
||||
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
||||
has_text = isinstance(msg.content, str) and msg.content.strip()
|
||||
if has_text or media_paths:
|
||||
extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {}
|
||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata)
|
||||
extra.update(kwargs)
|
||||
text = msg.content if isinstance(msg.content, str) else ""
|
||||
session.add_message("user", text, **extra)
|
||||
@@ -593,7 +593,7 @@ class AgentLoop:
|
||||
chat_id=self._runtime_chat_id(msg),
|
||||
sender_id=msg.sender_id,
|
||||
session_summary=pending_summary,
|
||||
session_metadata=session.metadata,
|
||||
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace),
|
||||
)
|
||||
|
||||
async def _dispatch_command_inline(
|
||||
@@ -1058,7 +1058,7 @@ class AgentLoop:
|
||||
current_role=current_role,
|
||||
sender_id=msg.sender_id,
|
||||
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()
|
||||
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_error_event,
|
||||
build_file_edit_start_event,
|
||||
prepare_file_edit_tracker,
|
||||
prepare_file_edit_tracker as _prepare_file_edit_tracker,
|
||||
prepare_file_edit_trackers,
|
||||
StreamingFileEditTracker,
|
||||
)
|
||||
from nanobot.utils.helpers import (
|
||||
@@ -58,11 +59,14 @@ _SNIP_SAFETY_BUFFER = 1024
|
||||
_MICROCOMPACT_KEEP_RECENT = 10
|
||||
_MICROCOMPACT_MIN_CHARS = 500
|
||||
_COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep",
|
||||
"web_search", "web_fetch", "list_dir",
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
_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)
|
||||
@@ -857,8 +861,8 @@ class AgentRunner:
|
||||
and on_progress_accepts_file_edit_events(spec.progress_callback)
|
||||
)
|
||||
progress_callback = spec.progress_callback if emit_file_edit_events else None
|
||||
file_edit_tracker = (
|
||||
prepare_file_edit_tracker(
|
||||
file_edit_trackers = (
|
||||
prepare_file_edit_trackers(
|
||||
call_id=tool_call.id,
|
||||
tool_name=tool_call.name,
|
||||
tool=tool,
|
||||
@@ -868,13 +872,13 @@ class AgentRunner:
|
||||
if progress_callback is not 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(
|
||||
progress_callback,
|
||||
[build_file_edit_start_event(
|
||||
file_edit_tracker,
|
||||
params if isinstance(params, dict) else None,
|
||||
)],
|
||||
) for file_edit_tracker in file_edit_trackers],
|
||||
)
|
||||
try:
|
||||
if tool is not None:
|
||||
@@ -884,10 +888,13 @@ class AgentRunner:
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
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(
|
||||
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 = {
|
||||
"name": tool_call.name,
|
||||
@@ -910,10 +917,13 @@ class AgentRunner:
|
||||
return payload, event, None
|
||||
|
||||
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(
|
||||
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 = {
|
||||
"name": tool_call.name,
|
||||
@@ -933,13 +943,13 @@ class AgentRunner:
|
||||
return result + hint, event, RuntimeError(result)
|
||||
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(
|
||||
progress_callback,
|
||||
[build_file_edit_end_event(
|
||||
file_edit_tracker,
|
||||
params if isinstance(params, dict) else None,
|
||||
)],
|
||||
) for file_edit_tracker in file_edit_trackers],
|
||||
)
|
||||
|
||||
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,
|
||||
),
|
||||
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"],
|
||||
)
|
||||
)
|
||||
@@ -154,7 +158,11 @@ class ReadFileTool(_FsTool):
|
||||
"Text output format: LINE_NUM|CONTENT. "
|
||||
"Images return visual content for analysis. "
|
||||
"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 force=true to re-read content even if unchanged. "
|
||||
"Reads exceeding ~128K chars are truncated."
|
||||
)
|
||||
|
||||
@@ -162,7 +170,15 @@ class ReadFileTool(_FsTool):
|
||||
def read_only(self) -> bool:
|
||||
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:
|
||||
if not path:
|
||||
return "Error reading file: Unknown path"
|
||||
@@ -202,7 +218,13 @@ class ReadFileTool(_FsTool):
|
||||
current_mtime = os.path.getmtime(fp)
|
||||
except OSError:
|
||||
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:
|
||||
# File was modified externally - force full read and mark as not dedupable
|
||||
entry.can_dedup = False
|
||||
@@ -365,9 +387,10 @@ class WriteFileTool(_FsTool):
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Write content to a file. Overwrites if the file already exists; "
|
||||
"creates parent directories as needed. "
|
||||
"For partial edits, prefer edit_file instead."
|
||||
"Create a new file or intentionally replace an entire file with "
|
||||
"the provided content. Overwrites existing files and creates parent "
|
||||
"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:
|
||||
@@ -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"),
|
||||
new_text=StringSchema("The text to replace with"),
|
||||
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"],
|
||||
)
|
||||
)
|
||||
@@ -674,10 +715,13 @@ class EditFileTool(_FsTool):
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Edit a file by replacing old_text with new_text. "
|
||||
"Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. "
|
||||
"If old_text matches multiple times, you must provide more context "
|
||||
"or set replace_all=true. Shows a diff of the closest match on failure."
|
||||
"Perform a small, exact replacement in one file by replacing "
|
||||
"old_text with new_text. Use this for narrow text substitutions "
|
||||
"with old_text copied from read_file. For multi-file, structural, "
|
||||
"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
|
||||
@@ -688,7 +732,8 @@ class EditFileTool(_FsTool):
|
||||
async def execute(
|
||||
self, path: str | None = None, old_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:
|
||||
try:
|
||||
if not path:
|
||||
@@ -697,10 +742,12 @@ class EditFileTool(_FsTool):
|
||||
raise ValueError("Unknown old_text")
|
||||
if new_text is None:
|
||||
raise ValueError("Unknown new_text")
|
||||
|
||||
# .ipynb detection
|
||||
if path.endswith(".ipynb"):
|
||||
return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file."
|
||||
if occurrence is not None and occurrence < 1:
|
||||
return "Error: occurrence must be >= 1."
|
||||
if line_hint is not None and line_hint < 1:
|
||||
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)
|
||||
|
||||
@@ -743,7 +790,28 @@ class EditFileTool(_FsTool):
|
||||
if not matches:
|
||||
return self._not_found_msg(old_text, content, path)
|
||||
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 occurrence is not None:
|
||||
if occurrence > count:
|
||||
return (
|
||||
f"Error: occurrence {occurrence} is out of range; "
|
||||
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:
|
||||
@@ -751,7 +819,13 @@ class EditFileTool(_FsTool):
|
||||
location_hint = f" at {preview}" if preview else ""
|
||||
return (
|
||||
f"Warning: old_text appears {count} times{location_hint}. "
|
||||
"Provide more context to make it unique, or set replace_all=true."
|
||||
"Provide more context, set occurrence to choose one match, "
|
||||
"or set replace_all=true."
|
||||
)
|
||||
elif occurrence is not None and occurrence > count:
|
||||
return (
|
||||
f"Error: occurrence {occurrence} is out of range; "
|
||||
f"old_text appears {count} time."
|
||||
)
|
||||
|
||||
norm_new = new_text.replace("\r\n", "\n")
|
||||
@@ -760,7 +834,17 @@ class EditFileTool(_FsTool):
|
||||
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
|
||||
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
|
||||
for match in reversed(selected):
|
||||
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
|
||||
|
||||
@@ -130,12 +130,6 @@ class ImageGenerationTool(Tool):
|
||||
}
|
||||
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:
|
||||
raw_path = Path(value).expanduser()
|
||||
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path
|
||||
@@ -173,9 +167,6 @@ class ImageGenerationTool(Tool):
|
||||
client = self._provider_client()
|
||||
if client is None:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Iterable, TypeVar
|
||||
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
||||
|
||||
_DEFAULT_HEAD_LIMIT = 250
|
||||
_DEFAULT_FILE_HEAD_LIMIT = 200
|
||||
T = TypeVar("T")
|
||||
_TYPE_GLOB_MAP = {
|
||||
"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)
|
||||
|
||||
|
||||
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):
|
||||
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
|
||||
|
||||
@@ -109,6 +118,163 @@ class _SearchTool(_FsTool):
|
||||
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):
|
||||
"""Search file contents using a regex-like pattern."""
|
||||
_scopes = {"core", "subagent"}
|
||||
@@ -125,7 +291,8 @@ class GrepTool(_SearchTool):
|
||||
return (
|
||||
"Search file contents with a regex pattern. "
|
||||
"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."
|
||||
)
|
||||
|
||||
|
||||
+219
-49
@@ -8,6 +8,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,8 +16,17 @@ from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
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.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.schema import Base
|
||||
|
||||
@@ -44,10 +54,22 @@ class ExecToolConfig(Base):
|
||||
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_schema(
|
||||
command=StringSchema("The shell command to execute"),
|
||||
cmd=StringSchema("Compatibility alias for command"),
|
||||
working_dir=StringSchema("Optional working directory for the command"),
|
||||
workdir=StringSchema("Compatibility alias for working_dir"),
|
||||
timeout=IntegerSchema(
|
||||
60,
|
||||
description=(
|
||||
@@ -57,7 +79,44 @@ class ExecToolConfig(Base):
|
||||
minimum=1,
|
||||
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):
|
||||
@@ -98,6 +157,7 @@ class ExecTool(Tool):
|
||||
sandbox: str = "",
|
||||
path_append: str = "",
|
||||
allowed_env_keys: list[str] | None = None,
|
||||
session_manager: Any | None = None,
|
||||
):
|
||||
self.timeout = timeout
|
||||
self.working_dir = working_dir
|
||||
@@ -125,6 +185,7 @@ class ExecTool(Tool):
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self.path_append = path_append
|
||||
self.allowed_env_keys = allowed_env_keys or []
|
||||
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -150,10 +211,15 @@ class ExecTool(Tool):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Execute a shell command and return its output. "
|
||||
"Prefer read_file/write_file/edit_file over cat/echo/sed, "
|
||||
"and grep/glob over shell find/grep. "
|
||||
"Use this for tests, builds, package commands, git commands, and "
|
||||
"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. "
|
||||
"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
|
||||
@@ -161,9 +227,111 @@ class ExecTool(Tool):
|
||||
return True
|
||||
|
||||
async def execute(
|
||||
self, command: str, working_dir: str | None = None,
|
||||
timeout: int | None = None, **kwargs: Any,
|
||||
self, command: str | None = None, cmd: str | None = None,
|
||||
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:
|
||||
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()
|
||||
|
||||
# Prevent an LLM-supplied working_dir from escaping the configured
|
||||
@@ -211,52 +379,24 @@ class ExecTool(Tool):
|
||||
env["NANOBOT_PATH_APPEND"] = self.path_append
|
||||
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
|
||||
|
||||
try:
|
||||
process = await self._spawn(command, cwd, env)
|
||||
shell_program, shell_error = self._resolve_shell(shell)
|
||||
if shell_error:
|
||||
return shell_error
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
return _PreparedCommand(
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=effective_timeout,
|
||||
shell_program=shell_program,
|
||||
login=True if login is None else login,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self._kill_process(process)
|
||||
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
|
||||
async def _spawn(
|
||||
command: str, cwd: str, env: dict[str, str],
|
||||
shell_program: str | None = None,
|
||||
login: bool = True,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Launch *command* in a platform-appropriate shell."""
|
||||
if _IS_WINDOWS:
|
||||
@@ -272,9 +412,14 @@ class ExecTool(Tool):
|
||||
cwd=cwd,
|
||||
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(
|
||||
bash, "-l", "-c", command,
|
||||
*args,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -282,6 +427,31 @@ class ExecTool(Tool):
|
||||
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
|
||||
async def _kill_process(process: asyncio.subprocess.Process) -> None:
|
||||
"""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`
|
||||
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
|
||||
win_paths = re.findall(
|
||||
r"(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||
command
|
||||
)
|
||||
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||
|
||||
+99
-18
@@ -8,7 +8,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
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]:
|
||||
"""Validate URL with SSRF protection: scheme, domain, and resolved IP check."""
|
||||
from nanobot.security.network import validate_url_target
|
||||
|
||||
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:
|
||||
"""Format provider results into shared plaintext output."""
|
||||
if not items:
|
||||
@@ -488,19 +561,26 @@ class WebFetchTool(Tool):
|
||||
|
||||
# Detect and fetch images directly to avoid Jina's textual image captioning
|
||||
try:
|
||||
async with httpx.AsyncClient(proxy=self.proxy, follow_redirects=True, max_redirects=MAX_REDIRECTS, timeout=15.0) as client:
|
||||
async with client.stream("GET", url, headers={"User-Agent": self.user_agent}) as r:
|
||||
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)
|
||||
async with httpx.AsyncClient(proxy=self.proxy, timeout=15.0) as client:
|
||||
r, stream, redirect_error = await _stream_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)
|
||||
|
||||
try:
|
||||
ctype = r.headers.get("content-type", "")
|
||||
if ctype.startswith("image/"):
|
||||
r.raise_for_status()
|
||||
raw = await r.aread()
|
||||
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:
|
||||
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:
|
||||
"""Local fallback using readability-lxml."""
|
||||
from readability import Document
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
max_redirects=MAX_REDIRECTS,
|
||||
timeout=30.0,
|
||||
proxy=self.proxy,
|
||||
) 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()
|
||||
|
||||
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", "")
|
||||
if ctype.startswith("image/"):
|
||||
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:
|
||||
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")):
|
||||
from readability import Document
|
||||
|
||||
doc = Document(r.text)
|
||||
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
|
||||
|
||||
@@ -110,6 +110,7 @@ class ChannelManager:
|
||||
static_path = _default_webui_dist()
|
||||
if static_path is not None:
|
||||
kwargs["static_dist_path"] = static_path
|
||||
kwargs["workspace_path"] = self.config.workspace_path
|
||||
if self._webui_runtime_model_name is not None:
|
||||
kwargs["runtime_model_name"] = self._webui_runtime_model_name
|
||||
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.channels.base import BaseChannel
|
||||
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.session.goal_state import goal_state_ws_blob
|
||||
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_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 (
|
||||
read_webui_sidebar_state,
|
||||
write_webui_sidebar_state,
|
||||
@@ -420,6 +425,16 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
})
|
||||
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
||||
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
||||
)
|
||||
_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
})
|
||||
|
||||
|
||||
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
||||
@@ -449,6 +464,7 @@ class WebSocketChannel(BaseChannel):
|
||||
*,
|
||||
session_manager: "SessionManager | None" = None,
|
||||
static_dist_path: Path | None = None,
|
||||
workspace_path: Path | None = None,
|
||||
runtime_model_name: Callable[[], str | None] | None = None,
|
||||
):
|
||||
if isinstance(config, dict):
|
||||
@@ -461,6 +477,10 @@ class WebSocketChannel(BaseChannel):
|
||||
self._conn_chats: dict[Any, set[str]] = {}
|
||||
# connection -> default chat_id for legacy frames that omit routing.
|
||||
self._conn_default: dict[Any, str] = {}
|
||||
# Chat IDs that opted into WebUI-specific rendering by sending a typed
|
||||
# envelope with ``webui: true``. Raw WebSocket clients keep the legacy
|
||||
# wire shape.
|
||||
self._webui_chats: set[str] = set()
|
||||
# Single-use tokens consumed at WebSocket handshake.
|
||||
self._issued_tokens: dict[str, float] = {}
|
||||
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
|
||||
@@ -471,8 +491,14 @@ class WebSocketChannel(BaseChannel):
|
||||
self._static_dist_path: Path | 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._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
|
||||
# the capability — anyone who holds a valid URL can fetch that one
|
||||
# file, nothing else. The secret regenerates on restart so links
|
||||
@@ -653,6 +679,21 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == "/api/settings/image-generation/update":
|
||||
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)
|
||||
if m:
|
||||
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_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
|
||||
def _is_websocket_channel_session_key(key: str) -> bool:
|
||||
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
|
||||
@@ -915,6 +982,7 @@ class WebSocketChannel(BaseChannel):
|
||||
data = build_webui_thread_response(
|
||||
decoded_key,
|
||||
augment_user_media=self._augment_transcript_user_media,
|
||||
augment_assistant_text=self._rewrite_local_markdown_images,
|
||||
)
|
||||
if data is None:
|
||||
return _http_error(404, "webui thread not found")
|
||||
@@ -961,6 +1029,9 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if 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)
|
||||
await super()._handle_message(
|
||||
sender_id,
|
||||
@@ -1050,6 +1121,46 @@ class WebSocketChannel(BaseChannel):
|
||||
return None
|
||||
return {"url": signed, "name": path.name}
|
||||
|
||||
def _markdown_image_url_for_local_path(self, raw_url: str) -> str | None:
|
||||
url = raw_url.strip()
|
||||
if url.startswith("<") and url.endswith(">"):
|
||||
url = url[1:-1].strip()
|
||||
if not url or url.startswith(("/api/media/", "#")):
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return None
|
||||
if parsed.query or parsed.fragment:
|
||||
return None
|
||||
path_text = unquote(url)
|
||||
if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
|
||||
return None
|
||||
candidate = Path(path_text).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = self._workspace_path / candidate
|
||||
try:
|
||||
resolved = candidate.resolve(strict=False)
|
||||
resolved.relative_to(self._workspace_path)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
signed = self._sign_or_stage_media_path(resolved)
|
||||
return signed["url"] if signed else None
|
||||
|
||||
def _rewrite_local_markdown_images(self, text: str) -> str:
|
||||
if "![" not in text:
|
||||
return text
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
signed_url = self._markdown_image_url_for_local_path(match.group(2))
|
||||
if not signed_url:
|
||||
return match.group(0)
|
||||
title = match.group(3) or ""
|
||||
return f""
|
||||
|
||||
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
|
||||
|
||||
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
|
||||
"""Serve a single media file previously signed via
|
||||
: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)}
|
||||
if envelope.get("webui") is True:
|
||||
metadata["webui"] = True
|
||||
self._webui_chats.add(cid)
|
||||
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
|
||||
if cli_apps:
|
||||
metadata["cli_apps"] = cli_apps
|
||||
image_generation = envelope.get("image_generation")
|
||||
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
|
||||
aspect_ratio = image_generation.get("aspect_ratio")
|
||||
@@ -1454,6 +1569,7 @@ class WebSocketChannel(BaseChannel):
|
||||
self._subs.clear()
|
||||
self._conn_chats.clear()
|
||||
self._conn_default.clear()
|
||||
self._webui_chats.clear()
|
||||
self._issued_tokens.clear()
|
||||
self._api_tokens.clear()
|
||||
|
||||
@@ -1532,10 +1648,12 @@ class WebSocketChannel(BaseChannel):
|
||||
await self._safe_send_to(connection, raw, label=" ")
|
||||
return
|
||||
text = msg.content
|
||||
should_rewrite_images = msg.chat_id in self._webui_chats
|
||||
wire_text = self._rewrite_local_markdown_images(text) if should_rewrite_images else text
|
||||
payload: dict[str, Any] = {
|
||||
"event": "message",
|
||||
"chat_id": msg.chat_id,
|
||||
"text": text,
|
||||
"text": wire_text,
|
||||
}
|
||||
if msg.media:
|
||||
payload["media"] = msg.media
|
||||
@@ -1563,7 +1681,9 @@ class WebSocketChannel(BaseChannel):
|
||||
payload["kind"] = "tool_hint"
|
||||
elif msg.metadata.get("_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)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" ")
|
||||
@@ -1628,17 +1748,33 @@ class WebSocketChannel(BaseChannel):
|
||||
if not conns:
|
||||
return
|
||||
meta = metadata or {}
|
||||
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
|
||||
should_rewrite_images = chat_id in self._webui_chats
|
||||
transcript_body: dict[str, Any] | None = None
|
||||
if meta.get("_stream_end"):
|
||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||
if should_rewrite_images:
|
||||
buffered = self._stream_text_buffers.pop(stream_key, [])
|
||||
if delta:
|
||||
buffered.append(delta)
|
||||
full_text = "".join(buffered)
|
||||
rewritten = self._rewrite_local_markdown_images(full_text)
|
||||
if rewritten != full_text or delta:
|
||||
body["text"] = rewritten
|
||||
transcript_body = {**body, "text": full_text}
|
||||
else:
|
||||
body = {
|
||||
"event": "delta",
|
||||
"chat_id": chat_id,
|
||||
"text": delta,
|
||||
}
|
||||
if should_rewrite_images:
|
||||
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
||||
if meta.get("_stream_id") is not None:
|
||||
body["stream_id"] = meta["_stream_id"]
|
||||
self._try_append_webui_transcript(chat_id, body)
|
||||
if transcript_body is not None:
|
||||
transcript_body["stream_id"] = meta["_stream_id"]
|
||||
self._try_append_webui_transcript(chat_id, transcript_body or body)
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" stream ")
|
||||
|
||||
+163
-6
@@ -79,6 +79,12 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
|
||||
ERRCODE_SESSION_EXPIRED = -14
|
||||
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)
|
||||
MAX_CONSECUTIVE_FAILURES = 3
|
||||
BACKOFF_DELAY_S = 30
|
||||
@@ -159,6 +165,8 @@ class WeixinChannel(BaseChannel):
|
||||
self._session_pause_until: float = 0.0
|
||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||
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
|
||||
@@ -486,6 +494,7 @@ class WeixinChannel(BaseChannel):
|
||||
except Exception:
|
||||
if not self._running:
|
||||
break
|
||||
self.logger.exception("WeChat poll loop error")
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
consecutive_failures = 0
|
||||
@@ -495,6 +504,7 @@ class WeixinChannel(BaseChannel):
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
self._pending_tool_hints.clear()
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
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)
|
||||
ret = data.get("ret", 0)
|
||||
errcode = data.get("errcode", 0)
|
||||
|
||||
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
|
||||
|
||||
if is_error:
|
||||
@@ -575,8 +586,10 @@ class WeixinChannel(BaseChannel):
|
||||
# Process messages (WeixinMessage[] from types.ts)
|
||||
msgs: list[dict] = data.get("msgs", []) or []
|
||||
for msg in msgs:
|
||||
with suppress(Exception):
|
||||
try:
|
||||
await self._process_message(msg)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to process WeChat message")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inbound message processing (matches inbound.ts + process-message.ts)
|
||||
@@ -610,6 +623,7 @@ class WeixinChannel(BaseChannel):
|
||||
ctx_token = msg.get("context_token", "")
|
||||
if ctx_token:
|
||||
self._context_tokens[from_user_id] = ctx_token
|
||||
self._context_token_at[from_user_id] = time.time()
|
||||
self._save_state()
|
||||
|
||||
# Parse item_list (WeixinMessage.item_list — types.ts:161)
|
||||
@@ -915,6 +929,99 @@ class WeixinChannel(BaseChannel):
|
||||
}
|
||||
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:
|
||||
"""Best-effort sendtyping wrapper."""
|
||||
if not typing_ticket:
|
||||
@@ -944,11 +1051,47 @@ class WeixinChannel(BaseChannel):
|
||||
self._assert_session_active()
|
||||
|
||||
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:
|
||||
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 = await self._refresh_context_token_if_stale(msg.chat_id, ctx_token)
|
||||
if not ctx_token:
|
||||
raise RuntimeError(
|
||||
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
|
||||
@@ -1037,6 +1180,18 @@ class WeixinChannel(BaseChannel):
|
||||
with suppress(Exception):
|
||||
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:
|
||||
"""Start typing indicator immediately when a message is received."""
|
||||
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)
|
||||
ret = data.get("ret", 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(
|
||||
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(
|
||||
@@ -1270,10 +1426,11 @@ class WeixinChannel(BaseChannel):
|
||||
}
|
||||
|
||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||
ret = data.get("ret", 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(
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
@@ -211,6 +212,7 @@ class ProvidersConfig(Base):
|
||||
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
|
||||
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
||||
novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI
|
||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
||||
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
|
||||
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"))
|
||||
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"))
|
||||
image_generation: ImageGenerationToolConfig = Field(
|
||||
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
|
||||
@@ -461,6 +464,7 @@ def _resolve_tool_config_refs() -> None:
|
||||
"""
|
||||
import sys
|
||||
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
@@ -469,6 +473,7 @@ def _resolve_tool_config_refs() -> None:
|
||||
# Re-export into this module's namespace
|
||||
mod = sys.modules[__name__]
|
||||
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
||||
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
||||
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
||||
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
|
||||
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -31,6 +33,14 @@ _AIHUBMIX_ASPECT_RATIO_SIZES = {
|
||||
}
|
||||
_GEMINI_DEFAULT_TIMEOUT_S = 120.0
|
||||
_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):
|
||||
@@ -129,6 +139,11 @@ _IMAGE_GEN_PROVIDERS: dict[str, type[ImageGenerationProvider]] = {}
|
||||
|
||||
|
||||
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
|
||||
if not name:
|
||||
raise ValueError(f"{cls.__name__} must set provider_name")
|
||||
@@ -219,7 +234,10 @@ class ImageGenerationProvider(ABC):
|
||||
*,
|
||||
headers: dict[str, str],
|
||||
body: dict[str, Any],
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> httpx.Response:
|
||||
if client is not None:
|
||||
return await client.post(url, headers=headers, json=body)
|
||||
if self._client is not None:
|
||||
return await self._client.post(url, headers=headers, json=body)
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||
@@ -390,10 +408,11 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
|
||||
model_path = _aihubmix_model_path(model)
|
||||
url = f"{self.api_base}/models/{model_path}/predictions"
|
||||
try:
|
||||
response = await client.post(
|
||||
response = await self._http_post(
|
||||
url,
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
body=body,
|
||||
client=client,
|
||||
)
|
||||
except httpx.TimeoutException as 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>"
|
||||
|
||||
|
||||
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):
|
||||
"""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"
|
||||
|
||||
def _resolve_base_url(self, api_base: str | None) -> str:
|
||||
# The Gemini provider's registry default_api_base is the OpenAI-compat
|
||||
# shim (.../v1beta/openai/), which has no image endpoints.
|
||||
# Skip the registry lookup and use the native API base directly.
|
||||
# Gemini chat completions use the registry's OpenAI-compatible shim.
|
||||
# Image generation must hit the native Generative Language API, so we
|
||||
# intentionally bypass the shared registry lookup here.
|
||||
if api_base:
|
||||
return api_base.rstrip("/")
|
||||
return self._default_base_url()
|
||||
@@ -706,22 +858,16 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider):
|
||||
|
||||
body.update(self.extra_body)
|
||||
|
||||
client = self._client or httpx.AsyncClient(timeout=self.timeout)
|
||||
try:
|
||||
return await self._generate_with_client(client, body, headers)
|
||||
finally:
|
||||
if self._client is None:
|
||||
await client.aclose()
|
||||
return await self._generate_with_client(body, headers)
|
||||
|
||||
async def _generate_with_client(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
body: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
) -> GeneratedImageResponse:
|
||||
url = f"{self.api_base}/image_generation"
|
||||
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:
|
||||
raise ImageGenerationError("MiniMax image generation timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
@@ -756,6 +902,426 @@ def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -883,8 +1449,11 @@ def _stepfun_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
||||
# Provider registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_image_gen_provider(OpenRouterImageGenerationClient)
|
||||
register_image_gen_provider(AIHubMixImageGenerationClient)
|
||||
register_image_gen_provider(CodexImageGenerationClient)
|
||||
register_image_gen_provider(GeminiImageGenerationClient)
|
||||
register_image_gen_provider(OllamaImageGenerationClient)
|
||||
register_image_gen_provider(MiniMaxImageGenerationClient)
|
||||
register_image_gen_provider(OpenAIImageGenerationClient)
|
||||
register_image_gen_provider(OpenRouterImageGenerationClient)
|
||||
register_image_gen_provider(StepFunImageGenerationClient)
|
||||
|
||||
@@ -11,6 +11,7 @@ import secrets
|
||||
import string
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from ipaddress import ip_address
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -74,41 +75,43 @@ _THINKING_STYLE_MAP: dict[str, Any] = {
|
||||
"enable_thinking": lambda on: {"enable_thinking": 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:
|
||||
"""Return True if model_name refers to a Kimi thinking-capable model.
|
||||
|
||||
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 _model_slug(model_name: str) -> str:
|
||||
return model_name.lower().rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _is_mimo_thinking_model(model_name: str) -> bool:
|
||||
"""Return True if model_name refers to a MiMo thinking-capable model.
|
||||
def _model_thinking_style(model_name: str) -> str:
|
||||
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
|
||||
spec, so the spec-driven branch in _build_kwargs misses them. The
|
||||
model-name path catches those cases.
|
||||
"""
|
||||
name = model_name.lower()
|
||||
if name in _MIMO_THINKING_MODELS:
|
||||
return True
|
||||
if "/" in name and name.rsplit("/", 1)[1] in _MIMO_THINKING_MODELS:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _thinking_styles_for(spec: ProviderSpec | None, model_name: str) -> list[str]:
|
||||
styles: list[str] = []
|
||||
if spec and spec.thinking_style:
|
||||
styles.append(spec.thinking_style)
|
||||
model_style = _model_thinking_style(model_name)
|
||||
if model_style and model_style not in styles:
|
||||
styles.append(model_style)
|
||||
return styles
|
||||
|
||||
|
||||
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:
|
||||
@@ -461,6 +464,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||
id_map: dict[str, str] = {}
|
||||
pending_tool_ids: dict[str, deque[str]] = {}
|
||||
force_string_content = bool(self._spec and self._spec.name == "deepseek")
|
||||
|
||||
def map_id(value: Any) -> Any:
|
||||
@@ -468,15 +472,49 @@ class OpenAICompatProvider(LLMProvider):
|
||||
return value
|
||||
return id_map.setdefault(value, self._normalize_tool_call_id(value))
|
||||
|
||||
def unique_tool_id(value: Any, used_ids: set[str], idx: int) -> str:
|
||||
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:
|
||||
if isinstance(clean.get("tool_calls"), list):
|
||||
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):
|
||||
normalized.append(tc)
|
||||
continue
|
||||
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")
|
||||
if isinstance(function, dict):
|
||||
function_clean = dict(function)
|
||||
@@ -494,7 +532,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
# that mix non-empty content with tool_calls.
|
||||
clean["content"] = None
|
||||
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 (
|
||||
force_string_content
|
||||
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":
|
||||
kwargs["reasoning_effort"] = wire_effort
|
||||
|
||||
# Provider-specific thinking parameters.
|
||||
# Only sent when reasoning_effort is explicitly configured so that
|
||||
# the provider default is preserved otherwise.
|
||||
# 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:
|
||||
# Only send thinking controls when reasoning_effort is explicit so
|
||||
# omitting the config preserves each provider's default.
|
||||
if reasoning_effort is not None:
|
||||
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):
|
||||
extra = _thinking_extra_body(thinking_style, thinking_enabled)
|
||||
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.
|
||||
# Strip any provider prefix (e.g. "moonshotai/") before the set lookup
|
||||
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
|
||||
# identically to bare names like "kimi-k2.5".
|
||||
if reasoning_effort is not None and _is_kimi_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"}}
|
||||
)
|
||||
|
||||
# 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"}}
|
||||
)
|
||||
# Moonshot rejects requests that carry both 'reasoning_effort'
|
||||
# and the native 'thinking' param. We already expressed the
|
||||
# user's intent via the provider-native shape, so drop the
|
||||
# redundant wire-level kwarg. Only kimi models need this —
|
||||
# Xiaomi's API accepts both params.
|
||||
if _model_slug(model_name) in _KIMI_THINKING_MODELS:
|
||||
kwargs.pop("reasoning_effort", None)
|
||||
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
@@ -628,8 +654,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
and semantic_effort not in ("none", "minimal")
|
||||
and (
|
||||
(spec and spec.thinking_style)
|
||||
or _is_kimi_thinking_model(model_name)
|
||||
or _is_mimo_thinking_model(model_name)
|
||||
or _model_thinking_style(model_name)
|
||||
)
|
||||
)
|
||||
implicit_deepseek_thinking = (
|
||||
@@ -1097,6 +1122,15 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if delta:
|
||||
_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(
|
||||
content="".join(content_parts) or None,
|
||||
tool_calls=[
|
||||
|
||||
@@ -15,6 +15,7 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
||||
"""
|
||||
system_prompt = ""
|
||||
input_items: list[dict[str, Any]] = []
|
||||
used_item_ids: set[str] = set()
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
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 isinstance(content, str) and content:
|
||||
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
||||
input_items.append({
|
||||
"type": "message", "role": "assistant",
|
||||
"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 []:
|
||||
fn = tool_call.get("function") or {}
|
||||
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({
|
||||
"type": "function_call",
|
||||
"id": item_id or f"fc_{idx}",
|
||||
"id": response_item_id,
|
||||
"call_id": call_id or f"call_{idx}",
|
||||
"name": fn.get("name"),
|
||||
"arguments": fn.get("arguments") or "{}",
|
||||
@@ -97,6 +100,20 @@ def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
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]:
|
||||
"""Split a compound ``call_id|item_id`` string.
|
||||
|
||||
|
||||
@@ -71,6 +71,11 @@ class ProviderSpec:
|
||||
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
|
||||
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 "content" is empty. Only set this for providers (e.g. StepFun)
|
||||
# whose API returns the actual answer in "reasoning" instead of "content".
|
||||
@@ -142,6 +147,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
detect_by_base_keyword="openrouter",
|
||||
default_api_base="https://openrouter.ai/api/v1",
|
||||
supports_prompt_caching=True,
|
||||
gateway_reasoning_style="reasoning_effort",
|
||||
),
|
||||
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
|
||||
ProviderSpec(
|
||||
@@ -193,6 +199,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
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
|
||||
ProviderSpec(
|
||||
name="volcengine",
|
||||
|
||||
@@ -165,6 +165,23 @@ class Session:
|
||||
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
||||
)
|
||||
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:
|
||||
content = self._annotate_message_time(message, content)
|
||||
if role == "assistant" and isinstance(content, str) and not content.strip():
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 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
|
||||
|
||||
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.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
|
||||
- **Remove**: `edit_file` to delete completed tasks
|
||||
- **Rewrite**: `write_file` to replace all tasks
|
||||
- Use `apply_patch` for normal task-list updates, especially when adding, removing, or changing multiple lines.
|
||||
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
import difflib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
|
||||
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
|
||||
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "apply_patch"})
|
||||
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
|
||||
_LIVE_EMIT_INTERVAL_S = 0.18
|
||||
_LIVE_EMIT_LINE_STEP = 24
|
||||
@@ -154,19 +152,108 @@ def prepare_file_edit_tracker(
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | 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):
|
||||
return None
|
||||
path = resolve_file_edit_path(tool, workspace, params)
|
||||
if path is None:
|
||||
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)
|
||||
return FileEditTracker(
|
||||
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)
|
||||
if path is None:
|
||||
return []
|
||||
return [path]
|
||||
|
||||
|
||||
def _resolve_apply_patch_paths(
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
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(
|
||||
@@ -304,6 +391,9 @@ class StreamingFileEditTracker:
|
||||
self._states[key] = state
|
||||
|
||||
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"}:
|
||||
return
|
||||
if state.path is None:
|
||||
@@ -343,10 +433,80 @@ class StreamingFileEditTracker:
|
||||
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:
|
||||
events: list[dict[str, Any]] = []
|
||||
now = time.monotonic()
|
||||
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:
|
||||
continue
|
||||
added, deleted = state.live_diff_counts()
|
||||
@@ -367,12 +527,14 @@ class StreamingFileEditTracker:
|
||||
|
||||
def apply_final_call_ids(self, final_tool_calls: list[Any]) -> None:
|
||||
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
||||
used_canonicals: set[str] = set()
|
||||
for tool_call in final_tool_calls:
|
||||
canonical = self.canonical_call_id_for(tool_call)
|
||||
if canonical:
|
||||
if canonical and canonical not in used_canonicals:
|
||||
try:
|
||||
tool_call.id = canonical
|
||||
except Exception:
|
||||
used_canonicals.add(canonical)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
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."""
|
||||
events: list[dict[str, Any]] = []
|
||||
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:
|
||||
continue
|
||||
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
|
||||
|
||||
|
||||
@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)
|
||||
class _StreamingFileEditState:
|
||||
key: str
|
||||
@@ -509,6 +708,7 @@ class _StreamingFileEditState:
|
||||
new_text: _StreamingJsonStringField = field(
|
||||
default_factory=lambda: _StreamingJsonStringField("new_text")
|
||||
)
|
||||
patch_files: dict[str, _StreamingPatchFileState] = field(default_factory=dict)
|
||||
emitted_once: bool = False
|
||||
last_emitted_added: int = -1
|
||||
last_emitted_deleted: int = -1
|
||||
@@ -531,6 +731,7 @@ class _StreamingFileEditState:
|
||||
self.content.reset()
|
||||
self.old_text.reset()
|
||||
self.new_text.reset()
|
||||
self.patch_files.clear()
|
||||
return
|
||||
delta = payload.get("arguments_delta")
|
||||
if isinstance(delta, str) and delta:
|
||||
@@ -590,6 +791,14 @@ class _StreamingFileEditState:
|
||||
name = getattr(tool_call, "name", None)
|
||||
if name != self.name:
|
||||
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)
|
||||
if not isinstance(arguments, dict):
|
||||
return False
|
||||
@@ -612,6 +821,51 @@ def _stream_key(payload: dict[str, Any]) -> str:
|
||||
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:
|
||||
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
|
||||
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, 1)
|
||||
return None
|
||||
if tool_name == "notebook_edit":
|
||||
return _predict_notebook_after_text(params, before_text)
|
||||
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]:
|
||||
"""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
|
||||
|
||||
try:
|
||||
@@ -589,10 +589,11 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
|
||||
added: list[str] = []
|
||||
|
||||
def _write(src, dest: Path):
|
||||
content = src.read_text(encoding="utf-8") if src else ""
|
||||
if dest.exists():
|
||||
return
|
||||
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)))
|
||||
|
||||
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),
|
||||
"write_file": (["path", "file_path"], "write {}", True, False),
|
||||
"edit": (["file_path", "path"], "edit {}", True, False),
|
||||
"find_files": (["query", "glob", "path"], "find {}", False, False),
|
||||
"grep": (["pattern"], 'grep "{}"', False, False),
|
||||
"exec": (["command"], "$ {}", False, True),
|
||||
"list_exec_sessions": ([], "exec sessions", False, False),
|
||||
"web_search": (["query"], 'search "{}"', False, False),
|
||||
"web_fetch": (["url"], "fetch {}", 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:
|
||||
"""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])
|
||||
if val is None:
|
||||
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:
|
||||
if spec.backend == "azure_openai":
|
||||
return True
|
||||
if spec.is_oauth:
|
||||
return False
|
||||
if spec.is_local or spec.is_direct:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
|
||||
if spec.is_oauth:
|
||||
return True
|
||||
if _provider_requires_api_key(spec):
|
||||
return bool(provider_config.api_key)
|
||||
return bool(
|
||||
|
||||
@@ -116,6 +116,55 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
|
||||
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(
|
||||
previous_traces: list[str],
|
||||
lines: list[str],
|
||||
@@ -136,6 +185,7 @@ def replay_transcript_to_ui_messages(
|
||||
lines: list[dict[str, Any]],
|
||||
*,
|
||||
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
||||
augment_assistant_text: Callable[[str], str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI.
|
||||
|
||||
@@ -405,6 +455,9 @@ def replay_transcript_to_ui_messages(
|
||||
row["media"] = 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]
|
||||
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)
|
||||
continue
|
||||
|
||||
@@ -449,6 +502,24 @@ def replay_transcript_to_ui_messages(
|
||||
buffer_message_id = None
|
||||
buffer_parts = []
|
||||
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_parts = []
|
||||
continue
|
||||
@@ -486,6 +557,7 @@ def replay_transcript_to_ui_messages(
|
||||
close_reasoning(messages)
|
||||
continue
|
||||
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"))
|
||||
text = rec.get("text")
|
||||
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")])
|
||||
if structured:
|
||||
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
|
||||
if not added:
|
||||
if not added and not structured_events:
|
||||
continue
|
||||
else:
|
||||
merged_traces = prev_traces + trace_lines
|
||||
@@ -510,6 +582,9 @@ def replay_transcript_to_ui_messages(
|
||||
**last,
|
||||
"traces": merged_traces,
|
||||
"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,
|
||||
}
|
||||
messages[-1] = merged
|
||||
@@ -521,6 +596,7 @@ def replay_transcript_to_ui_messages(
|
||||
"kind": "trace",
|
||||
"content": trace_lines[-1],
|
||||
"traces": trace_lines,
|
||||
**({"toolEvents": structured_events} if structured_events else {}),
|
||||
"activitySegmentId": segment,
|
||||
"createdAt": _ts_base + idx,
|
||||
},
|
||||
@@ -569,7 +645,14 @@ def replay_transcript_to_ui_messages(
|
||||
buffer_parts = []
|
||||
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("reasoningStreaming", None)
|
||||
return messages
|
||||
@@ -579,12 +662,17 @@ def build_webui_thread_response(
|
||||
session_key: str,
|
||||
*,
|
||||
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
||||
augment_assistant_text: Callable[[str], str] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a payload compatible with ``WebuiThreadPersistedPayload``."""
|
||||
lines = read_transcript_lines(session_key)
|
||||
if not lines:
|
||||
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 {
|
||||
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||
"sessionKey": session_key,
|
||||
|
||||
@@ -139,6 +139,13 @@ class TestLoadBootstrapFiles:
|
||||
for name in ContextBuilder.BOOTSTRAP_FILES:
|
||||
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):
|
||||
(tmp_path / "AGENTS.md").write_text("用中文回复", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
@@ -171,6 +178,37 @@ class TestIsTemplateContent:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -324,6 +362,21 @@ class TestBuildMessages:
|
||||
assert "Other chat goal." 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):
|
||||
builder = _builder(tmp_path)
|
||||
history = [{"role": "user", "content": "previous user message"}]
|
||||
|
||||
@@ -346,6 +346,26 @@ class TestSyncWorkspaceTemplates:
|
||||
content = (workspace / "AGENTS.md").read_text()
|
||||
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):
|
||||
"""Should create memory directory structure."""
|
||||
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]"}
|
||||
|
||||
|
||||
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():
|
||||
"""``media`` only ever appears on user entries in practice, but the
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
|
||||
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"]["configured"] is False
|
||||
assert image_providers["openai_codex"]["configured"] is True
|
||||
assert image_providers["gemini"]["label"] == "Gemini"
|
||||
assert body["runtime"]["config_path"] == str(config_path)
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
|
||||
@@ -140,6 +140,75 @@ async def test_sessions_routes_require_bearer_token(
|
||||
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
|
||||
async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
|
||||
@@ -44,6 +44,7 @@ def _ch(
|
||||
bus: Any,
|
||||
*,
|
||||
session_manager: SessionManager | None = None,
|
||||
workspace_path: Path | None = None,
|
||||
port: int,
|
||||
) -> WebSocketChannel:
|
||||
return WebSocketChannel(
|
||||
@@ -57,6 +58,7 @@ def _ch(
|
||||
},
|
||||
bus,
|
||||
session_manager=session_manager,
|
||||
workspace_path=workspace_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +69,15 @@ def bus() -> MagicMock:
|
||||
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(
|
||||
url: str, headers: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
@@ -123,6 +134,45 @@ def test_sign_media_path_round_trips_via_hmac(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
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._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-typing"
|
||||
channel._context_token_at["wx-user"] = time.time()
|
||||
channel._send_text = AsyncMock()
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
@@ -402,6 +404,7 @@ async def test_send_still_sends_text_when_typing_ticket_missing() -> None:
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-no-ticket"
|
||||
channel._context_token_at["wx-user"] = time.time()
|
||||
channel._send_text = AsyncMock()
|
||||
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")
|
||||
|
||||
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()
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
spec = find_by_name("ollama")
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
|
||||
@@ -9,10 +9,13 @@ import pytest
|
||||
|
||||
from nanobot.providers.image_generation import (
|
||||
AIHubMixImageGenerationClient,
|
||||
CodexImageGenerationClient,
|
||||
GeminiImageGenerationClient,
|
||||
GeneratedImageResponse,
|
||||
ImageGenerationError,
|
||||
MiniMaxImageGenerationClient,
|
||||
OllamaImageGenerationClient,
|
||||
OpenAIImageGenerationClient,
|
||||
OpenRouterImageGenerationClient,
|
||||
StepFunImageGenerationClient,
|
||||
)
|
||||
@@ -36,12 +39,14 @@ class FakeResponse:
|
||||
payload: dict[str, Any],
|
||||
status_code: int = 200,
|
||||
content: bytes = b"",
|
||||
sse_lines: list[str] | None = None,
|
||||
) -> None:
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.text = str(payload)
|
||||
self.content = content
|
||||
self.request = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
|
||||
self._sse_lines = sse_lines
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._payload
|
||||
@@ -51,6 +56,15 @@ class FakeResponse:
|
||||
response = httpx.Response(self.status_code, request=self.request, text=self.text)
|
||||
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:
|
||||
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")
|
||||
|
||||
|
||||
@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
|
||||
async def test_aihubmix_image_generation_payload_and_response() -> None:
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
async def test_gemini_no_images_raises() -> None:
|
||||
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,")
|
||||
|
||||
|
||||
@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 (阶跃星辰)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -515,3 +593,437 @@ async def test_stepfun_no_images_raises() -> None:
|
||||
|
||||
with pytest.raises(ImageGenerationError, match="returned no images"):
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
"""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."""
|
||||
@@ -449,6 +458,12 @@ def test_gemma_routes_to_gemini_provider() -> None:
|
||||
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:
|
||||
spec = find_by_name("openrouter")
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
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."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="medium")
|
||||
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:
|
||||
"""reasoning_effort='minimal' maps to thinking disabled for kimi-k2.5."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="minimal")
|
||||
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:
|
||||
@@ -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:
|
||||
"""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")
|
||||
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:
|
||||
"""kimi-k2.6 with reasoning_effort set should opt in to thinking."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort="medium")
|
||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
||||
assert "reasoning_effort" not in kw
|
||||
|
||||
|
||||
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")
|
||||
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:
|
||||
@@ -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."""
|
||||
kw = _build_kwargs_for("moonshot", "k2.6-code-preview", reasoning_effort="high")
|
||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
||||
assert "reasoning_effort" not in kw
|
||||
|
||||
|
||||
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."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="none")
|
||||
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
|
||||
assert "reasoning_effort" not in kw
|
||||
|
||||
|
||||
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]["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):
|
||||
"""Fallback IDs when tool_call.id is missing."""
|
||||
_, items = convert_messages([{
|
||||
|
||||
@@ -32,7 +32,7 @@ def _mimo_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}
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -142,9 +149,11 @@ def test_mimo_reasoning_effort_unset_preserves_provider_default():
|
||||
|
||||
|
||||
def test_mimo_via_openrouter_reasoning_effort_none_disables_thinking():
|
||||
"""OpenRouter routes MiMo as "xiaomi/mimo-v2.5-pro"; the openrouter spec
|
||||
has no thinking_style, so the disable signal must come from the
|
||||
model-name path (#3845)."""
|
||||
"""OpenRouter routes MiMo as "xiaomi/mimo-v2.5-pro" and does NOT forward
|
||||
extra_body.thinking to upstream, so a disable signal must also reach OR
|
||||
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")
|
||||
kwargs = provider._build_kwargs(
|
||||
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,
|
||||
)
|
||||
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():
|
||||
"""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")
|
||||
kwargs = provider._build_kwargs(
|
||||
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,
|
||||
)
|
||||
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():
|
||||
@@ -176,12 +192,16 @@ def test_mimo_via_openrouter_bare_slug_also_matches():
|
||||
tools=None, model=None, max_tokens=100,
|
||||
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():
|
||||
"""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")
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=_simple_messages(),
|
||||
@@ -200,3 +220,18 @@ def test_non_mimo_model_via_openrouter_unaffected():
|
||||
temperature=0.7, reasoning_effort="none", tool_choice=None,
|
||||
)
|
||||
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,
|
||||
.ipynb detection, and create-file semantics."""
|
||||
notebook JSON editing, and create-file semantics."""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -108,22 +108,27 @@ class TestEditCreateFile:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .ipynb detection
|
||||
# .ipynb editing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEditIpynbDetection:
|
||||
"""edit_file should refuse .ipynb and suggest notebook_edit."""
|
||||
class TestEditIpynbFiles:
|
||||
"""edit_file edits notebooks as normal JSON files."""
|
||||
|
||||
@pytest.fixture()
|
||||
def tool(self, tmp_path):
|
||||
return EditFileTool(workspace=tmp_path)
|
||||
|
||||
@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.write_text('{"cells": []}', encoding="utf-8")
|
||||
result = await tool.execute(path=str(f), old_text="x", new_text="y")
|
||||
assert "notebook" in result.lower()
|
||||
result = await tool.execute(
|
||||
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_env = {}
|
||||
|
||||
async def capture_spawn(cmd, cwd, env):
|
||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
|
||||
nonlocal captured_cmd
|
||||
captured_cmd = cmd
|
||||
captured_env.update(env)
|
||||
@@ -190,7 +190,7 @@ class TestPathAppendPlatform:
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
|
||||
@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
|
||||
async def test_generate_image_tool_rejects_reference_outside_workspace(tmp_path: Path) -> None:
|
||||
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.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.bus.queue import MessageBus
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
async def test_grep_respects_glob_filter_and_context(tmp_path: Path) -> None:
|
||||
(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")
|
||||
|
||||
assert "find_files" 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())
|
||||
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"]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
discovered = loader.discover()
|
||||
class_names = {cls.__name__ for cls in discovered}
|
||||
assert "ApplyPatchTool" in class_names
|
||||
assert "ExecTool" in class_names
|
||||
assert "CliAppsTool" in class_names
|
||||
assert "MessageTool" in class_names
|
||||
assert "SpawnTool" in class_names
|
||||
assert "WriteStdinTool" in class_names
|
||||
|
||||
|
||||
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.allow_set 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
|
||||
|
||||
|
||||
@@ -406,7 +410,8 @@ def test_loader_registers_same_tools_as_old_hardcoded():
|
||||
|
||||
expected = {
|
||||
"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",
|
||||
}
|
||||
actual = set(registered)
|
||||
|
||||
@@ -3,6 +3,8 @@ import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools import (
|
||||
ArraySchema,
|
||||
IntegerSchema,
|
||||
@@ -15,6 +17,7 @@ from nanobot.agent.tools import (
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.security.network import configure_ssrf_whitelist
|
||||
|
||||
|
||||
class SampleTool(Tool):
|
||||
@@ -218,6 +221,39 @@ def test_exec_extract_absolute_paths_ignores_relative_posix_segments() -> None:
|
||||
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:
|
||||
cmd = "cat /tmp/data.txt > /tmp/out.txt"
|
||||
paths = ExecTool._extract_absolute_paths(cmd)
|
||||
|
||||
@@ -6,11 +6,15 @@ import json
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools import web as web_module
|
||||
from nanobot.agent.tools.web import WebFetchTool
|
||||
from nanobot.config.schema import WebFetchConfig
|
||||
|
||||
_REAL_GETADDRINFO = socket.getaddrinfo
|
||||
|
||||
|
||||
def _fake_resolve_private(hostname, port, family=0, type_=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"
|
||||
text = fake_html
|
||||
headers = {"content-type": "text/html"}
|
||||
is_redirect = False
|
||||
def raise_for_status(self): pass
|
||||
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")
|
||||
|
||||
class FakeStreamResponse:
|
||||
status_code = 200
|
||||
headers = {"content-type": "text/html"}
|
||||
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):
|
||||
return False
|
||||
|
||||
async def aread(self):
|
||||
raise AssertionError("non-image prefetch body should not be read")
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
url = "https://example.com/page"
|
||||
text = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
|
||||
headers = {"content-type": "text/html"}
|
||||
is_redirect = False
|
||||
|
||||
def raise_for_status(self):
|
||||
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):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, headers=None):
|
||||
def stream(self, method, url, headers=None, **kwargs):
|
||||
seen_headers.append(headers or {})
|
||||
return FakeStreamResponse()
|
||||
|
||||
async def get(self, url, headers=None):
|
||||
async def get(self, url, headers=None, **kwargs):
|
||||
seen_headers.append(headers or {})
|
||||
return FakeResponse()
|
||||
|
||||
@@ -132,13 +142,14 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypatch):
|
||||
tool = WebFetchTool()
|
||||
async def test_web_fetch_blocks_private_redirect_before_readability_request(monkeypatch):
|
||||
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
|
||||
requested: list[str] = []
|
||||
|
||||
class FakeStreamResponse:
|
||||
headers = {"content-type": "image/png"}
|
||||
url = "http://127.0.0.1/secret.png"
|
||||
content = b"\x89PNG\r\n\x1a\n"
|
||||
status_code = 200
|
||||
headers = {"content-type": "text/html"}
|
||||
url = "https://attacker.example/start"
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -147,9 +158,14 @@ async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypa
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, headers=None):
|
||||
def stream(self, method, url, headers=None, **kwargs):
|
||||
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")
|
||||
|
||||
data = json.loads(result)
|
||||
assert "error" in data
|
||||
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 nanobot.utils.file_edit_events import (
|
||||
StreamingFileEditTracker,
|
||||
build_file_edit_end_event,
|
||||
build_file_edit_start_event,
|
||||
line_diff_stats,
|
||||
prepare_file_edit_tracker,
|
||||
prepare_file_edit_trackers,
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
target = tmp_path / "large.txt"
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
events: list[dict] = []
|
||||
|
||||
@@ -308,6 +418,43 @@ def test_streaming_tracker_applies_canonical_call_id_to_final_tool(tmp_path: Pat
|
||||
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:
|
||||
target = tmp_path / "small.py"
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:t-file"
|
||||
@@ -143,6 +167,50 @@ def test_replay_tool_events_dedupes_finish_after_start() -> None:
|
||||
'exec({"cmd": "ls"})',
|
||||
'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:
|
||||
|
||||
+31
-11
@@ -43,6 +43,7 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
|
||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||
const SIDEBAR_WIDTH = 272;
|
||||
const SIDEBAR_RAIL_WIDTH = 56;
|
||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||
type ShellView = "chat" | "settings";
|
||||
@@ -411,6 +412,10 @@ function Shell({
|
||||
setDesktopSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const openDesktopSidebar = useCallback(() => {
|
||||
setDesktopSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeMobileSidebar = useCallback(() => {
|
||||
setMobileSidebarOpen(false);
|
||||
}, []);
|
||||
@@ -560,6 +565,21 @@ function Shell({
|
||||
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(
|
||||
(key: string) => {
|
||||
setSessionSearchOpen(false);
|
||||
@@ -732,17 +752,19 @@ function Shell({
|
||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||
"transition-[width] duration-300 ease-out",
|
||||
)}
|
||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
||||
style={{
|
||||
width: desktopSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"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 }}
|
||||
className="absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar shadow-inner-right"
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
||||
<Sidebar
|
||||
{...sidebarProps}
|
||||
collapsed={!desktopSidebarOpen}
|
||||
onCollapse={closeDesktopSidebar}
|
||||
onExpand={openDesktopSidebar}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
@@ -769,7 +791,6 @@ function Shell({
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
{showMainSidebar ? (
|
||||
<SessionSearchDialog
|
||||
open={sessionSearchOpen}
|
||||
onOpenChange={setSessionSearchOpen}
|
||||
@@ -779,7 +800,6 @@ function Shell({
|
||||
titleOverrides={sidebarState.title_overrides}
|
||||
onSelect={onSelectSearchResult}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
@@ -797,7 +817,7 @@ function Shell({
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
hideSidebarToggleOnDesktop
|
||||
/>
|
||||
</div>
|
||||
{view === "settings" && (
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
@@ -19,6 +25,9 @@ import { deriveTitle, relativeTime } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||
|
||||
const INITIAL_VISIBLE_SESSIONS = 160;
|
||||
const VISIBLE_SESSIONS_INCREMENT = 160;
|
||||
|
||||
interface ChatListProps {
|
||||
sessions: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
@@ -42,7 +51,7 @@ interface ChatListProps {
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
export function ChatList({
|
||||
export const ChatList = memo(function ChatList({
|
||||
sessions,
|
||||
activeKey,
|
||||
onSelect,
|
||||
@@ -65,6 +74,52 @@ export function ChatList({
|
||||
emptyLabel,
|
||||
}: ChatListProps) {
|
||||
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) {
|
||||
return (
|
||||
<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 archived = new Set(archivedKeys);
|
||||
const running = new Set(runningChatIds);
|
||||
@@ -105,7 +145,7 @@ export function ChatList({
|
||||
return (
|
||||
<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">
|
||||
{groups.map((group) => (
|
||||
{limitedGroups.map((group) => (
|
||||
<section key={group.label} aria-label={group.label}>
|
||||
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
||||
{group.label}
|
||||
@@ -228,10 +268,25 @@ export function ChatList({
|
||||
</ul>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function SessionActivityIndicator({
|
||||
state,
|
||||
@@ -366,6 +421,45 @@ function groupSessions(
|
||||
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(
|
||||
sessions: ChatSummary[],
|
||||
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>
|
||||
);
|
||||
},
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
@@ -8,16 +9,18 @@ import {
|
||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CliAppMentionText } from "@/components/CliAppMentionText";
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 {
|
||||
message: UIMessage;
|
||||
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
|
||||
showAssistantCopyAction?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,11 +35,16 @@ interface MessageBubbleProps {
|
||||
export function MessageBubble({
|
||||
message,
|
||||
showAssistantCopyAction = true,
|
||||
cliApps = [],
|
||||
}: MessageBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetRef = useRef<number | null>(null);
|
||||
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(() => {
|
||||
return () => {
|
||||
@@ -88,7 +96,7 @@ export function MessageBubble({
|
||||
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
|
||||
</p>
|
||||
) : null}
|
||||
</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({
|
||||
media,
|
||||
align,
|
||||
|
||||
@@ -37,13 +37,16 @@ export function SessionSearchDialog({
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const results = useMemo(() => {
|
||||
const sessionResults = useMemo(() => {
|
||||
if (!open) return [];
|
||||
if (!normalizedQuery) return sessions;
|
||||
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
return sessions.filter((session) =>
|
||||
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
|
||||
);
|
||||
}, [normalizedQuery, sessions, titleOverrides]);
|
||||
}, [normalizedQuery, open, sessions, titleOverrides]);
|
||||
const itemCount = sessionResults.length;
|
||||
const shortcutLabel = useMemo(getSearchShortcutLabel, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -58,9 +61,9 @@ export function SessionSearchDialog({
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
onOpenChange(false);
|
||||
@@ -71,17 +74,19 @@ export function SessionSearchDialog({
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((index) =>
|
||||
results.length === 0 ? 0 : Math.min(index + 1, results.length - 1),
|
||||
itemCount === 0 ? 0 : (index + 1) % itemCount,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((index) => Math.max(index - 1, 0));
|
||||
setHighlightedIndex((index) =>
|
||||
itemCount === 0 ? 0 : (index - 1 + itemCount) % itemCount,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
const highlighted = results[highlightedIndex];
|
||||
const highlighted = sessionResults[highlightedIndex];
|
||||
if (!highlighted) return;
|
||||
event.preventDefault();
|
||||
handleSelect(highlighted.key);
|
||||
@@ -125,9 +130,13 @@ export function SessionSearchDialog({
|
||||
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"
|
||||
/>
|
||||
<kbd className="hidden h-6 shrink-0 items-center rounded-md border border-border/70 bg-muted/60 px-2 text-[11px] font-medium text-muted-foreground sm:inline-flex">
|
||||
{shortcutLabel}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
|
||||
<section>
|
||||
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
|
||||
{sectionLabel}
|
||||
</div>
|
||||
@@ -136,13 +145,13 @@ export function SessionSearchDialog({
|
||||
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
||||
{t("chat.loading")}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
) : sessionResults.length === 0 ? (
|
||||
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{results.map((session, index) => {
|
||||
{sessionResults.map((session, index) => {
|
||||
const title = titleOverrides[session.key]?.trim() ||
|
||||
session.title?.trim() ||
|
||||
deriveTitle(session.preview, t("chat.newChat"));
|
||||
@@ -189,6 +198,7 @@ export function SessionSearchDialog({
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -211,3 +221,13 @@ function sessionMatchesTerms(
|
||||
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
}
|
||||
|
||||
function getSearchShortcutLabel() {
|
||||
if (typeof navigator === "undefined") return "Ctrl K";
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
const apple =
|
||||
platform.includes("mac") ||
|
||||
platform.includes("iphone") ||
|
||||
platform.includes("ipad");
|
||||
return apple ? "⌘K" : "Ctrl K";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Archive,
|
||||
ListFilter,
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
SidebarSortMode,
|
||||
SidebarViewState,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarProps {
|
||||
sessions: ChatSummary[];
|
||||
@@ -44,7 +45,9 @@ interface SidebarProps {
|
||||
onToggleArchived: () => void;
|
||||
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
||||
onCollapse: () => void;
|
||||
onExpand?: () => void;
|
||||
containActionMenus?: boolean;
|
||||
collapsed?: boolean;
|
||||
pinnedKeys?: string[];
|
||||
archivedKeys?: string[];
|
||||
titleOverrides?: Record<string, string>;
|
||||
@@ -59,6 +62,8 @@ export function Sidebar(props: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [menuPortalContainer, setMenuPortalContainer] =
|
||||
useState<HTMLElement | null>(null);
|
||||
const collapsed = Boolean(props.collapsed);
|
||||
const toggleLabel = t("thread.header.toggleSidebar");
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -66,16 +71,34 @@ export function Sidebar(props: SidebarProps) {
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
|
||||
<picture className="block min-w-0">
|
||||
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
|
||||
<div
|
||||
className={cn(
|
||||
"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
|
||||
src="/brand/nanobot_logo.png"
|
||||
alt="nanobot"
|
||||
className="h-6 w-auto select-none object-contain opacity-95"
|
||||
src="/brand/nanobot_icon.png"
|
||||
alt=""
|
||||
className="h-8 w-8 select-none object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</picture>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -85,43 +108,48 @@ export function Sidebar(props: SidebarProps) {
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 px-2 pb-2">
|
||||
<Button
|
||||
<div
|
||||
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}
|
||||
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"
|
||||
variant="ghost"
|
||||
>
|
||||
<SquarePen className="h-3.5 w-3.5" />
|
||||
{t("sidebar.newChat")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
icon={<SquarePen className="h-4 w-4" />}
|
||||
/>
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.searchAria")}
|
||||
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"
|
||||
variant="ghost"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("sidebar.searchAria")}
|
||||
</Button>
|
||||
icon={<Search className="h-4 w-4" />}
|
||||
/>
|
||||
<SidebarViewMenu
|
||||
compact={collapsed}
|
||||
view={props.viewState}
|
||||
onUpdateView={props.onUpdateView}
|
||||
/>
|
||||
{props.archivedCount ? (
|
||||
<Button
|
||||
type="button"
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
|
||||
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"
|
||||
variant="ghost"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" aria-hidden />
|
||||
{props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
|
||||
</Button>
|
||||
icon={<Archive className="h-4 w-4" />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
|
||||
collapsed && "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
{!collapsed && (
|
||||
<ChatList
|
||||
sessions={props.sessions}
|
||||
activeKey={props.activeKey}
|
||||
@@ -146,28 +174,86 @@ export function Sidebar(props: SidebarProps) {
|
||||
props.containActionMenus ? menuPortalContainer : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="bg-sidebar-border/50" />
|
||||
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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"
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-2.5 text-xs",
|
||||
collapsed && "w-14 flex-col px-0",
|
||||
)}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("sidebar.settings")}
|
||||
</Button>
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.settings")}
|
||||
onClick={props.onOpenSettings}
|
||||
className={collapsed ? undefined : "flex-1"}
|
||||
icon={<Settings className="h-4 w-4" />}
|
||||
/>
|
||||
<ConnectionBadge />
|
||||
</div>
|
||||
</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({
|
||||
compact = false,
|
||||
view,
|
||||
onUpdateView,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
view?: SidebarViewState;
|
||||
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
||||
}) {
|
||||
@@ -182,11 +268,28 @@ function SidebarViewMenu({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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"
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" aria-hidden />
|
||||
<ListFilter className="h-4 w-4 shrink-0" aria-hidden />
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
Loader2,
|
||||
LogOut,
|
||||
Moon,
|
||||
Package,
|
||||
PlayCircle,
|
||||
Orbit,
|
||||
Palette,
|
||||
Pencil,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Triangle,
|
||||
Waves,
|
||||
Zap,
|
||||
@@ -59,14 +62,19 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
fetchSettings,
|
||||
fetchCliApps,
|
||||
runCliAppAction,
|
||||
updateImageGenerationSettings,
|
||||
updateProviderSettings,
|
||||
updateSettings,
|
||||
updateWebSearchSettings,
|
||||
} from "@/lib/api";
|
||||
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
CliAppsPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
SettingsPayload,
|
||||
WebSearchSettingsUpdate,
|
||||
@@ -79,6 +87,7 @@ type SettingsSectionKey =
|
||||
| "providers"
|
||||
| "image"
|
||||
| "web"
|
||||
| "cliApps"
|
||||
| "runtime"
|
||||
| "advanced";
|
||||
|
||||
@@ -89,6 +98,7 @@ interface LocalPreferences {
|
||||
density: LocalDensity;
|
||||
activityMode: LocalActivityMode;
|
||||
codeWrap: boolean;
|
||||
brandLogos: boolean;
|
||||
}
|
||||
|
||||
interface AgentSettingsDraft {
|
||||
@@ -110,6 +120,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
||||
density: "comfortable",
|
||||
activityMode: "auto",
|
||||
codeWrap: true,
|
||||
brandLogos: true,
|
||||
};
|
||||
|
||||
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
|
||||
@@ -146,6 +157,7 @@ function readLocalPreferences(): LocalPreferences {
|
||||
density: parsed.density === "compact" ? "compact" : "comfortable",
|
||||
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
||||
codeWrap: parsed.codeWrap !== false,
|
||||
brandLogos: parsed.brandLogos !== false,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_LOCAL_PREFS;
|
||||
@@ -177,8 +189,11 @@ export function SettingsView({
|
||||
const { t } = useTranslation();
|
||||
const { token } = useClient();
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
||||
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
|
||||
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||
@@ -186,6 +201,12 @@ export function SettingsView({
|
||||
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("overview");
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
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 [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
||||
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
||||
@@ -285,6 +306,27 @@ export function SettingsView({
|
||||
};
|
||||
}, [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(() => {
|
||||
try {
|
||||
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 = () => {
|
||||
if (!settings) return null;
|
||||
switch (activeSection) {
|
||||
@@ -618,6 +683,7 @@ export function SettingsView({
|
||||
editingProviderKeys={editingProviderKeys}
|
||||
providerSaving={providerSaving}
|
||||
query={providerQuery}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onQueryChange={setProviderQuery}
|
||||
onToggleProvider={handleToggleProvider}
|
||||
onToggleProviderKey={toggleProviderKeyVisibility}
|
||||
@@ -677,6 +743,26 @@ export function SettingsView({
|
||||
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":
|
||||
return (
|
||||
<RuntimeSettings
|
||||
@@ -752,6 +838,7 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal
|
||||
{ key: "providers", icon: KeyRound, fallback: "Providers" },
|
||||
{ key: "image", icon: ImageIcon, fallback: "Image" },
|
||||
{ key: "web", icon: Globe2, fallback: "Web" },
|
||||
{ key: "cliApps", icon: Package, fallback: "CLI Apps" },
|
||||
{ key: "runtime", icon: Server, fallback: "Runtime" },
|
||||
{ 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")}
|
||||
/>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1211,6 +1308,7 @@ function ProvidersSettings({
|
||||
editingProviderKeys,
|
||||
providerSaving,
|
||||
query,
|
||||
showBrandLogos,
|
||||
onQueryChange,
|
||||
onToggleProvider,
|
||||
onToggleProviderKey,
|
||||
@@ -1229,6 +1327,7 @@ function ProvidersSettings({
|
||||
editingProviderKeys: Record<string, boolean>;
|
||||
providerSaving: string | null;
|
||||
query: string;
|
||||
showBrandLogos: boolean;
|
||||
onQueryChange: (query: string) => void;
|
||||
onToggleProvider: (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"
|
||||
>
|
||||
<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="block truncate text-[15px] font-semibold leading-5 text-foreground">
|
||||
{provider.label}
|
||||
@@ -1437,6 +1539,7 @@ function ProvidersSettings({
|
||||
>
|
||||
{filteredUnconfigured.map(renderProviderRow)}
|
||||
</ProviderSection>
|
||||
<ThirdPartyBrandNotice />
|
||||
</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({
|
||||
form,
|
||||
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(
|
||||
providers: SettingsPayload["providers"],
|
||||
): SettingsPayload["providers"] {
|
||||
@@ -2126,6 +2618,63 @@ function providerLabel(
|
||||
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> = {
|
||||
custom: Hexagon,
|
||||
openrouter: Sparkles,
|
||||
@@ -2160,8 +2709,44 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||
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;
|
||||
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 (
|
||||
<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 />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { cliAppInitials } from "@/components/CliAppMentionText";
|
||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||
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). */
|
||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||
@@ -24,13 +25,17 @@ export function isAgentActivityMember(m: UIMessage): boolean {
|
||||
interface ActivityCounts {
|
||||
reasoningSteps: number;
|
||||
toolCalls: number;
|
||||
cliCount: number;
|
||||
fileCount: number;
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasDiffStats: boolean;
|
||||
hasEditingFiles: boolean;
|
||||
hasFailedFiles: boolean;
|
||||
primaryFilePath?: string;
|
||||
primaryFileTooltipPath?: string;
|
||||
primaryCliName?: string;
|
||||
primaryCliStatus?: CliRunStatus;
|
||||
}
|
||||
|
||||
interface FileEditSummary {
|
||||
@@ -46,21 +51,46 @@ interface FileEditSummary {
|
||||
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 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) {
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
reasoningSteps += 1;
|
||||
continue;
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
toolCalls += lines;
|
||||
const lines = traceLines(m);
|
||||
for (const line of lines) {
|
||||
if (!isCliRunTraceLine(line)) {
|
||||
toolCalls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let added = 0;
|
||||
let deleted = 0;
|
||||
let hasDiffStats = false;
|
||||
let hasEditingFiles = false;
|
||||
let failedFileCount = 0;
|
||||
let primaryFilePath: string | undefined;
|
||||
@@ -77,19 +107,27 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
|
||||
if (edit.status === "error" || edit.binary) {
|
||||
continue;
|
||||
}
|
||||
if (!hasVisibleDiffStats(edit)) {
|
||||
continue;
|
||||
}
|
||||
hasDiffStats = true;
|
||||
added += edit.added;
|
||||
deleted += edit.deleted;
|
||||
}
|
||||
return {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
cliCount,
|
||||
fileCount: fileEdits.length,
|
||||
added,
|
||||
deleted,
|
||||
hasDiffStats,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
||||
primaryFilePath,
|
||||
primaryFileTooltipPath,
|
||||
primaryCliName,
|
||||
primaryCliStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +136,7 @@ interface AgentActivityClusterProps {
|
||||
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
|
||||
isTurnStreaming: boolean;
|
||||
hasBodyBelow: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,23 +147,33 @@ export function AgentActivityCluster({
|
||||
messages,
|
||||
isTurnStreaming,
|
||||
hasBodyBelow,
|
||||
cliApps = [],
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||
[messages, isTurnStreaming],
|
||||
);
|
||||
const cliRuns = useMemo(() => collectCliRuns(messages), [messages]);
|
||||
const cliAppsByName = useMemo(
|
||||
() => new Map(cliApps.map((app) => [app.name.toLowerCase(), app])),
|
||||
[cliApps],
|
||||
);
|
||||
const {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
cliCount,
|
||||
fileCount,
|
||||
added,
|
||||
deleted,
|
||||
hasDiffStats,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles,
|
||||
primaryFilePath,
|
||||
primaryFileTooltipPath,
|
||||
} = countActivity(messages, fileEdits);
|
||||
primaryCliName,
|
||||
primaryCliStatus,
|
||||
} = countActivity(messages, fileEdits, cliRuns);
|
||||
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
|
||||
|
||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||
@@ -140,6 +189,7 @@ export function AgentActivityCluster({
|
||||
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
|
||||
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || fileCount > 0;
|
||||
|
||||
const fileActivitySummary = fileCount > 0
|
||||
? 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
|
||||
? fileActivitySummary
|
||||
: cliCount > 0
|
||||
? cliActivitySummary
|
||||
: isTurnStreaming
|
||||
? reasoningSteps > 0
|
||||
? t("message.agentActivityLiveSummary", {
|
||||
@@ -243,6 +307,10 @@ export function AgentActivityCluster({
|
||||
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
|
||||
}, []);
|
||||
|
||||
if (!hasVisibleActivity) return null;
|
||||
|
||||
const HeaderIcon = cliCount > 0 && fileCount === 0 && toolCalls === 0 ? Terminal : Layers;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||
<button
|
||||
@@ -255,7 +323,7 @@ export function AgentActivityCluster({
|
||||
aria-expanded={outerExpanded}
|
||||
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">
|
||||
{singleFilePath ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
@@ -282,7 +350,7 @@ export function AgentActivityCluster({
|
||||
{summary}
|
||||
</StreamingLabelSheen>
|
||||
)}
|
||||
{fileCount > 0 && (
|
||||
{fileCount > 0 && hasDiffStats && (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
|
||||
<DiffPair added={added} deleted={deleted} />
|
||||
</span>
|
||||
@@ -326,15 +394,25 @@ export function AgentActivityCluster({
|
||||
);
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
|
||||
return hasTraceLines ? (
|
||||
const normalLines = traceLines(m).filter((line) => !parseCliRunTrace(line));
|
||||
return normalLines.length > 0 ? (
|
||||
<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>
|
||||
) : null;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{cliRuns.length ? (
|
||||
<CliRunGroup runs={cliRuns} active={isTurnStreaming} cliAppsByName={cliAppsByName} />
|
||||
) : null}
|
||||
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,6 +426,181 @@ function shortFileName(path: string): string {
|
||||
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 {
|
||||
if (failed) return "Failed";
|
||||
return editing ? "Editing" : "Edited";
|
||||
@@ -435,6 +688,17 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
||||
summary.absolute_path = edit.absolute_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") {
|
||||
summary.hasActiveEditing = true;
|
||||
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)!;
|
||||
if (
|
||||
!summary.path
|
||||
&& !summary.hasActiveEditing
|
||||
&& !summary.hasSuccessfulChange
|
||||
&& !summary.hasFailed
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const status: UIFileEdit["status"] = summary.hasActiveEditing
|
||||
? "editing"
|
||||
: summary.hasSuccessfulChange
|
||||
@@ -470,7 +742,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
||||
: summary.hasFailed
|
||||
? "error"
|
||||
: "done";
|
||||
return {
|
||||
return [{
|
||||
key: summary.key,
|
||||
path: summary.path,
|
||||
absolute_path: summary.absolute_path,
|
||||
@@ -481,10 +753,128 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
||||
status,
|
||||
pending: summary.pending && !summary.path,
|
||||
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[] }) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
@@ -500,7 +890,7 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
||||
const { t } = useTranslation();
|
||||
const editing = edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
const hasCountedDiff = !failed && !edit.binary;
|
||||
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
|
||||
return (
|
||||
<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">
|
||||
|
||||
@@ -9,9 +9,16 @@ import {
|
||||
} from "react";
|
||||
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import {
|
||||
CliAppMentionToken,
|
||||
cliAppInitials,
|
||||
splitCliAppMentionSegments,
|
||||
type CliAppMentionSegment,
|
||||
} from "@/components/CliAppMentionText";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -41,7 +48,7 @@ import {
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
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";
|
||||
|
||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||
@@ -62,6 +69,7 @@ interface ThreadComposerProps {
|
||||
modelLabel?: string | null;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
cliApps?: CliAppInfo[];
|
||||
imageMode?: boolean;
|
||||
onImageModeChange?: (enabled: boolean) => void;
|
||||
onStop?: () => void;
|
||||
@@ -98,6 +106,12 @@ interface SlashPaletteLayout {
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
interface CliAppMentionQuery {
|
||||
query: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function slashCommandI18nKey(command: string): string {
|
||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
||||
}
|
||||
@@ -167,6 +181,17 @@ function buildGoalMarkdownBody(summary: string, objective: string): string {
|
||||
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({
|
||||
startedAt,
|
||||
goalState,
|
||||
@@ -371,6 +396,7 @@ export function ThreadComposer({
|
||||
modelLabel = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
cliApps = [],
|
||||
imageMode: controlledImageMode,
|
||||
onImageModeChange,
|
||||
onStop,
|
||||
@@ -382,6 +408,9 @@ export function ThreadComposer({
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
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 [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
||||
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
||||
@@ -491,6 +520,52 @@ export function ThreadComposer({
|
||||
}, [slashCommands, slashQuery, t]);
|
||||
|
||||
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>({
|
||||
placement: "above",
|
||||
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
|
||||
@@ -500,6 +575,10 @@ export function ThreadComposer({
|
||||
setSelectedCommandIndex(0);
|
||||
}, [slashQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCliAppIndex(0);
|
||||
}, [cliAppMention?.query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCommandIndex >= filteredSlashCommands.length) {
|
||||
setSelectedCommandIndex(0);
|
||||
@@ -507,22 +586,29 @@ export function ThreadComposer({
|
||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashMenu) return;
|
||||
if (selectedCliAppIndex >= filteredCliApps.length) {
|
||||
setSelectedCliAppIndex(0);
|
||||
}
|
||||
}, [filteredCliApps.length, selectedCliAppIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAnyPalette) return;
|
||||
|
||||
const dismissOnPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && formRef.current?.contains(target)) return;
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(true);
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", dismissOnPointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", dismissOnPointerDown, true);
|
||||
};
|
||||
}, [showSlashMenu]);
|
||||
}, [showAnyPalette]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showSlashMenu) return;
|
||||
if (!showAnyPalette) return;
|
||||
|
||||
const updateLayout = () => {
|
||||
const form = formRef.current;
|
||||
@@ -554,7 +640,7 @@ export function ThreadComposer({
|
||||
window.removeEventListener("resize", updateLayout);
|
||||
document.removeEventListener("scroll", updateLayout, true);
|
||||
};
|
||||
}, [filteredSlashCommands.length, showSlashMenu]);
|
||||
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aspectMenuOpen) return;
|
||||
@@ -602,12 +688,36 @@ export function ThreadComposer({
|
||||
(command: SlashCommand) => {
|
||||
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
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(() => {
|
||||
if (!canSend) return;
|
||||
const trimmed = value.trim();
|
||||
@@ -625,13 +735,20 @@ export function ThreadComposer({
|
||||
preview: { url: img.dataUrl, name: img.file.name },
|
||||
}))
|
||||
: undefined;
|
||||
const options: SendOptions | undefined = imageMode
|
||||
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
|
||||
const options: SendOptions | undefined =
|
||||
imageMode || attachedCliApps.length > 0
|
||||
? {
|
||||
...(imageMode
|
||||
? {
|
||||
imageGeneration: {
|
||||
enabled: true,
|
||||
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
|
||||
}
|
||||
: undefined;
|
||||
onSend(trimmed, payload, options);
|
||||
setValue("");
|
||||
@@ -640,10 +757,36 @@ export function ThreadComposer({
|
||||
// preview here without affecting the rendered message.
|
||||
clear();
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(0);
|
||||
resizeTextarea();
|
||||
}, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
|
||||
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showCliAppMenu) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedCliAppIndex((idx) => (idx + 1) % 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 (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
@@ -719,6 +862,12 @@ export function ThreadComposer({
|
||||
|
||||
const attachButtonDisabled = disabled || full;
|
||||
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 (
|
||||
<form
|
||||
@@ -743,6 +892,16 @@ export function ThreadComposer({
|
||||
onChoose={chooseSlashCommand}
|
||||
/>
|
||||
) : null}
|
||||
{showCliAppMenu ? (
|
||||
<CliAppMentionPalette
|
||||
apps={filteredCliApps}
|
||||
selectedIndex={selectedCliAppIndex}
|
||||
layout={slashPaletteLayout}
|
||||
isHero={isHero}
|
||||
onHover={setSelectedCliAppIndex}
|
||||
onChoose={chooseCliApp}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"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 ? (
|
||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||
) : null}
|
||||
<div className="relative">
|
||||
{hasCliMentionDecorations ? (
|
||||
<ComposerCliMentionOverlay
|
||||
segments={mentionSegments}
|
||||
isHero={isHero}
|
||||
className={inputTextClasses}
|
||||
/>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(e.target.selectionStart ?? e.target.value.length);
|
||||
}}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
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(
|
||||
"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",
|
||||
"placeholder:text-muted-foreground/70",
|
||||
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 ? (
|
||||
<div
|
||||
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 {
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
@@ -971,6 +1176,15 @@ interface SlashCommandPaletteProps {
|
||||
onChoose: (command: SlashCommand) => void;
|
||||
}
|
||||
|
||||
interface CliAppMentionPaletteProps {
|
||||
apps: CliAppInfo[];
|
||||
selectedIndex: number;
|
||||
layout: SlashPaletteLayout;
|
||||
isHero: boolean;
|
||||
onHover: (index: number) => void;
|
||||
onChoose: (app: CliAppInfo) => void;
|
||||
}
|
||||
|
||||
function ImageAspectMenu({
|
||||
selected,
|
||||
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({
|
||||
commands,
|
||||
selectedIndex,
|
||||
|
||||
@@ -32,12 +32,17 @@ export function ThreadHeader({
|
||||
onClick={onToggleSidebar}
|
||||
className={cn(
|
||||
"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" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +57,7 @@ export function ThreadHeader({
|
||||
onClick={onToggleSidebar}
|
||||
className={cn(
|
||||
"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" />
|
||||
@@ -62,7 +67,12 @@ export function ThreadHeader({
|
||||
</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>
|
||||
@@ -73,10 +83,12 @@ function ThemeButton({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
@@ -84,7 +96,10 @@ function ThemeButton({
|
||||
size="icon"
|
||||
aria-label={label}
|
||||
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" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
AgentActivityCluster,
|
||||
isAgentActivityMember,
|
||||
} from "@/components/thread/AgentActivityCluster";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
export type DisplayUnit =
|
||||
@@ -164,6 +165,7 @@ export function ThreadMessages({
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
@@ -208,6 +210,7 @@ export function ThreadMessages({
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={index === liveActivityClusterIndex}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
@@ -217,6 +220,7 @@ export function ThreadMessages({
|
||||
? copyFlags[index]
|
||||
: true
|
||||
}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,13 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { useSessionHistory } from "@/hooks/useSessions";
|
||||
import { listSlashCommands } from "@/lib/api";
|
||||
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import { fetchCliApps, listSlashCommands } from "@/lib/api";
|
||||
import {
|
||||
CLI_APPS_CHANGED_EVENT,
|
||||
installedCliAppsFromPayload,
|
||||
isCliAppsPayload,
|
||||
} from "@/lib/cli-app-events";
|
||||
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
|
||||
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
@@ -97,6 +102,7 @@ export function ThreadShell({
|
||||
const { client, modelName, token } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
|
||||
const [heroImageMode, setHeroImageMode] = useState(false);
|
||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
@@ -247,6 +253,50 @@ export function ThreadShell({
|
||||
};
|
||||
}, [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(
|
||||
async (content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
if (booting) return;
|
||||
@@ -332,6 +382,7 @@ export function ThreadShell({
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
||||
onStop={stop}
|
||||
@@ -351,6 +402,7 @@ export function ThreadShell({
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
imageMode={heroImageMode}
|
||||
onImageModeChange={setHeroImageMode}
|
||||
runStartedAt={runStartedAt}
|
||||
@@ -391,6 +443,7 @@ export function ThreadShell({
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
@@ -24,6 +24,7 @@ interface ThreadViewportProps {
|
||||
scrollToBottomSignal?: number;
|
||||
conversationKey?: string | null;
|
||||
showScrollToBottomButton?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
@@ -53,6 +54,7 @@ export function ThreadViewport({
|
||||
scrollToBottomSignal = 0,
|
||||
conversationKey = null,
|
||||
showScrollToBottomButton = true,
|
||||
cliApps = [],
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -249,6 +251,7 @@ export function ThreadViewport({
|
||||
isStreaming={isStreaming}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
</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. */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -2,10 +2,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
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 {
|
||||
InboundEvent,
|
||||
OutboundCliAppMention,
|
||||
OutboundImageGeneration,
|
||||
OutboundMedia,
|
||||
GoalStateWsPayload,
|
||||
@@ -187,6 +193,15 @@ function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMess
|
||||
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(
|
||||
prev: UIMessage[],
|
||||
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
||||
@@ -297,6 +312,7 @@ export interface SendImage {
|
||||
|
||||
export interface SendOptions {
|
||||
imageGeneration?: OutboundImageGeneration;
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
}
|
||||
|
||||
export function useNanobotStream(
|
||||
@@ -482,23 +498,54 @@ export function useNanobotStream(
|
||||
[appendAnswerChunk, ensureActivitySegmentId],
|
||||
);
|
||||
|
||||
const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => {
|
||||
const flushPendingStreamEvents = useCallback((options?: {
|
||||
closeAnswerSegment?: boolean;
|
||||
finalAnswerText?: string;
|
||||
}) => {
|
||||
if (streamFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(streamFrameRef.current);
|
||||
streamFrameRef.current = null;
|
||||
}
|
||||
const events = pendingStreamEventsRef.current;
|
||||
if (events.length === 0) {
|
||||
const finalAnswerText = options?.finalAnswerText;
|
||||
if (events.length === 0 && finalAnswerText === undefined) {
|
||||
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||
return;
|
||||
}
|
||||
pendingStreamEventsRef.current = [];
|
||||
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();
|
||||
return next;
|
||||
});
|
||||
}, [applyPendingStreamEvents, closeActiveAssistantStream]);
|
||||
}, [applyPendingStreamEvents, closeActiveAssistantStream, resolveActiveAssistantIndex]);
|
||||
|
||||
const schedulePendingStreamFlush = useCallback(() => {
|
||||
if (streamFrameRef.current !== null) return;
|
||||
@@ -576,7 +623,10 @@ export function useNanobotStream(
|
||||
}
|
||||
|
||||
if (ev.event === "stream_end") {
|
||||
flushPendingStreamEvents({ closeAnswerSegment: true });
|
||||
flushPendingStreamEvents({
|
||||
closeAnswerSegment: true,
|
||||
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
|
||||
});
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
// stream_end only means the text segment finished — the model may
|
||||
// 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
|
||||
// so a sequence of calls collapses into one compact trace group.
|
||||
if (ev.kind === "tool_hint" || ev.kind === "progress") {
|
||||
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
|
||||
const structuredLines = toolTraceLinesFromEvents(ev.tool_events);
|
||||
const lines = structuredLines.length > 0
|
||||
? structuredLines
|
||||
@@ -681,13 +732,15 @@ export function useNanobotStream(
|
||||
const mergedLines = structuredLines.length > 0
|
||||
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
|
||||
: null;
|
||||
if (mergedLines && !mergedLines.added) return prev;
|
||||
const merged: UIMessage = {
|
||||
...last,
|
||||
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
|
||||
content: mergedLines
|
||||
? mergedLines.traces[mergedLines.traces.length - 1]
|
||||
: lines[lines.length - 1],
|
||||
toolEvents: structuredEvents.length
|
||||
? mergeToolProgressEvents(last.toolEvents, structuredEvents)
|
||||
: last.toolEvents,
|
||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||
};
|
||||
return [...prev.slice(0, -1), merged];
|
||||
@@ -700,6 +753,7 @@ export function useNanobotStream(
|
||||
kind: "trace",
|
||||
content: lines[lines.length - 1],
|
||||
traces: lines,
|
||||
...(structuredEvents.length ? { toolEvents: structuredEvents } : {}),
|
||||
activitySegmentId: segmentId,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
@@ -836,6 +890,7 @@ export function useNanobotStream(
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -27,13 +27,25 @@ export function useSessions(): {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const tokenRef = useRef(token);
|
||||
const optimisticKeysRef = useRef<Set<string>>(new Set());
|
||||
tokenRef.current = token;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
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);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
@@ -57,6 +69,7 @@ export function useSessions(): {
|
||||
const createChat = useCallback(async (): Promise<string> => {
|
||||
const chatId = await client.newChat();
|
||||
const key = `websocket:${chatId}`;
|
||||
optimisticKeysRef.current.add(key);
|
||||
// Optimistic insert; a subsequent refresh will replace it with the
|
||||
// authoritative row once the server persists the session.
|
||||
setSessions((prev) => [
|
||||
@@ -77,6 +90,7 @@ export function useSessions(): {
|
||||
const deleteChat = useCallback(
|
||||
async (key: string) => {
|
||||
await apiDeleteSession(tokenRef.current, key);
|
||||
optimisticKeysRef.current.delete(key);
|
||||
setSessions((prev) => prev.filter((s) => s.key !== key));
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"providers": "Providers",
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"cliApps": "CLI Apps",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
@@ -94,6 +95,7 @@
|
||||
"imageDefaults": "Defaults",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"cliApps": "CLI Apps",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Capabilities",
|
||||
@@ -115,6 +117,7 @@
|
||||
"density": "Density",
|
||||
"activityMode": "Activity detail",
|
||||
"codeWrap": "Code wrapping",
|
||||
"brandLogos": "Brand logos",
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"jinaReader": "Jina reader",
|
||||
@@ -141,6 +144,8 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"cliAppsCatalog": "Catalog",
|
||||
"cliAppsFilter": "Filter",
|
||||
"configurationDocs": "Configuration docs"
|
||||
},
|
||||
"help": {
|
||||
@@ -154,6 +159,7 @@
|
||||
"density": "Stored only in this browser.",
|
||||
"activityMode": "Choose how much agent activity chrome to show by default.",
|
||||
"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.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||
@@ -168,8 +174,41 @@
|
||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
@@ -259,6 +298,9 @@
|
||||
"searchPlaceholder": "Search providers",
|
||||
"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": {
|
||||
"selectProvider": "Select provider",
|
||||
"selectAspect": "Select aspect",
|
||||
@@ -271,6 +313,7 @@
|
||||
"fallbackTitle": "Chat {{id}}",
|
||||
"loading": "Loading…",
|
||||
"noSessions": "No sessions yet.",
|
||||
"showMore": "Show {{count}} more",
|
||||
"actions": "Chat actions for {{title}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
@@ -461,6 +504,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "CLI Apps",
|
||||
"label": "CLI APPS"
|
||||
},
|
||||
"encoding": "Encoding…",
|
||||
"remove": "Remove attachment",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||
@@ -492,6 +539,15 @@
|
||||
"agentActivityToolsOnly": "{{tools}} tool calls",
|
||||
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
|
||||
"cliActivityRunningOne": "Running CLI @{{name}}",
|
||||
"cliActivityRanOne": "Ran CLI @{{name}}",
|
||||
"cliActivityFailedOne": "CLI failed @{{name}}",
|
||||
"cliActivityRunningMany": "Running {{count}} CLIs",
|
||||
"cliActivityRanMany": "Ran {{count}} CLIs",
|
||||
"cliActivityFailedMany": "{{count}} CLI failed",
|
||||
"cliRunRunning": "Running CLI",
|
||||
"cliRunRan": "Ran CLI",
|
||||
"cliRunFailed": "CLI failed",
|
||||
"imageAttachment": "Image attachment",
|
||||
"copyReply": "Copy reply",
|
||||
"copiedReply": "Copied reply",
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"section": "Sistema",
|
||||
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
|
||||
@@ -27,9 +39,13 @@
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navegación de la barra lateral",
|
||||
"globalActions": "Acciones globales",
|
||||
"collapse": "Contraer barra lateral",
|
||||
"toggleTheme": "Cambiar tema",
|
||||
"home": "Inicio",
|
||||
"newChat": "Nuevo chat",
|
||||
"searchAria": "Buscar",
|
||||
"viewOptions": "View",
|
||||
"compactList": "Compact list",
|
||||
"showPreviews": "Show previews",
|
||||
@@ -38,17 +54,16 @@
|
||||
"sortUpdated": "Recently updated",
|
||||
"sortCreated": "Recently created",
|
||||
"sortTitle": "Title A-Z",
|
||||
"searchPlaceholder": "Buscar",
|
||||
"searchResults": "Resultados",
|
||||
"noSearchResults": "No hay chats coincidentes.",
|
||||
"recent": "Recientes",
|
||||
"refreshSessions": "Actualizar sesiones",
|
||||
"settings": "Configuración",
|
||||
"language": {
|
||||
"label": "Idioma",
|
||||
"ariaLabel": "Cambiar idioma"
|
||||
},
|
||||
"searchAria": "Buscar",
|
||||
"searchPlaceholder": "Buscar",
|
||||
"searchResults": "Resultados",
|
||||
"noSearchResults": "No hay chats coincidentes."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Volver al chat",
|
||||
@@ -75,10 +90,13 @@
|
||||
"status": "Status",
|
||||
"localPreferences": "Local preferences",
|
||||
"presets": "Presets",
|
||||
"imageGeneration": "Generación de imágenes",
|
||||
"imageDefaults": "Valores predeterminados",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Capacidades",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"rows": {
|
||||
@@ -91,6 +109,7 @@
|
||||
"activePreset": "Active preset",
|
||||
"gateway": "Gateway",
|
||||
"restartState": "Restart state",
|
||||
"pendingChanges": "Cambios pendientes",
|
||||
"selectedPreset": "Selected preset",
|
||||
"presetModel": "Preset model",
|
||||
"density": "Density",
|
||||
@@ -99,6 +118,15 @@
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"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",
|
||||
"botIcon": "Bot icon",
|
||||
"timezone": "Timezone",
|
||||
@@ -129,6 +157,13 @@
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"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.",
|
||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||
@@ -141,8 +176,8 @@
|
||||
"notAvailable": "No disponible",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready",
|
||||
"restartPending": "Reinicio pendiente",
|
||||
"ready": "Listo",
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
@@ -150,13 +185,19 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured"
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando configuración...",
|
||||
"loadError": "No se pudo cargar la configuración",
|
||||
"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": {
|
||||
"save": "Guardar",
|
||||
@@ -213,17 +254,26 @@
|
||||
"configuredCount": "{{count}} configured",
|
||||
"totalProviders": "{{count}} available",
|
||||
"webSearch": "Web search",
|
||||
"imageGeneration": "Generación de imágenes",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"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": {
|
||||
"fallbackTitle": "Chat {{id}}",
|
||||
"loading": "Cargando…",
|
||||
"noSessions": "Todavía no hay sesiones.",
|
||||
"showMore": "Mostrar {{count}} más",
|
||||
"actions": "Acciones del chat {{title}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
@@ -268,7 +318,6 @@
|
||||
"thread": {
|
||||
"loadingConversation": "Cargando conversación…",
|
||||
"empty": {
|
||||
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo.",
|
||||
"greeting": "¿Qué puedo hacer por ti?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
@@ -321,10 +370,14 @@
|
||||
"title": "Editar una imagen",
|
||||
"prompt": "Ayúdame a editar una imagen. Primero pídeme que suba o indique la imagen, y luego genera el resultado editado."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Mostrar u ocultar la barra lateral"
|
||||
"toggleSidebar": "Mostrar u ocultar la barra lateral",
|
||||
"newChat": "Iniciar un chat nuevo",
|
||||
"toggleTheme": "Cambiar tema desde el encabezado",
|
||||
"settings": "Abrir configuración"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "Escribe tu mensaje…",
|
||||
@@ -338,6 +391,7 @@
|
||||
"goalStateFallback": "Objetivo",
|
||||
"goalStateExpandAria": "Ver objetivo completo",
|
||||
"goalStateSheetTitle": "Objetivo",
|
||||
"goalStateCloseAria": "Cerrar objetivo",
|
||||
"send": "Enviar mensaje",
|
||||
"stop": "Detener respuesta",
|
||||
"attachImage": "Adjuntar imagen",
|
||||
@@ -356,16 +410,11 @@
|
||||
"16_9": "Panorámico 16:9"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"tools": {
|
||||
"search": "Buscar",
|
||||
"reason": "Razonar",
|
||||
"deepResearch": "Investigación profunda",
|
||||
"voice": "Entrada de voz"
|
||||
},
|
||||
"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",
|
||||
"loadEarlier": "Cargar mensajes anteriores"
|
||||
@@ -438,6 +497,8 @@
|
||||
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
|
||||
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
|
||||
"imageAttachment": "Imagen adjunta",
|
||||
"copyReply": "Copiar respuesta",
|
||||
"copiedReply": "Respuesta copiada",
|
||||
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)"
|
||||
},
|
||||
"lightbox": {
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"section": "Système",
|
||||
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exécution.",
|
||||
@@ -27,9 +39,13 @@
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navigation de la barre latérale",
|
||||
"globalActions": "Actions globales",
|
||||
"collapse": "Réduire la barre latérale",
|
||||
"toggleTheme": "Changer de thème",
|
||||
"home": "Accueil",
|
||||
"newChat": "Nouvelle discussion",
|
||||
"searchAria": "Rechercher",
|
||||
"viewOptions": "View",
|
||||
"compactList": "Compact list",
|
||||
"showPreviews": "Show previews",
|
||||
@@ -38,17 +54,16 @@
|
||||
"sortUpdated": "Recently updated",
|
||||
"sortCreated": "Recently created",
|
||||
"sortTitle": "Title A-Z",
|
||||
"searchPlaceholder": "Rechercher",
|
||||
"searchResults": "Résultats",
|
||||
"noSearchResults": "Aucun chat correspondant.",
|
||||
"recent": "Récentes",
|
||||
"refreshSessions": "Actualiser les sessions",
|
||||
"settings": "Paramètres",
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"ariaLabel": "Changer de langue"
|
||||
},
|
||||
"searchAria": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher",
|
||||
"searchResults": "Résultats",
|
||||
"noSearchResults": "Aucun chat correspondant."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Retour à la discussion",
|
||||
@@ -75,10 +90,13 @@
|
||||
"status": "Status",
|
||||
"localPreferences": "Local preferences",
|
||||
"presets": "Presets",
|
||||
"imageGeneration": "Génération d'images",
|
||||
"imageDefaults": "Valeurs par défaut",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Capacités",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"rows": {
|
||||
@@ -91,6 +109,7 @@
|
||||
"activePreset": "Active preset",
|
||||
"gateway": "Gateway",
|
||||
"restartState": "Restart state",
|
||||
"pendingChanges": "Modifications en attente",
|
||||
"selectedPreset": "Selected preset",
|
||||
"presetModel": "Preset model",
|
||||
"density": "Density",
|
||||
@@ -99,6 +118,15 @@
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"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",
|
||||
"botIcon": "Bot icon",
|
||||
"timezone": "Timezone",
|
||||
@@ -129,6 +157,13 @@
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"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.",
|
||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||
@@ -141,8 +176,8 @@
|
||||
"notAvailable": "Indisponible",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready",
|
||||
"restartPending": "Redémarrage en attente",
|
||||
"ready": "Prêt",
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
@@ -150,13 +185,19 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured"
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement des paramètres...",
|
||||
"loadError": "Impossible de charger les paramètres",
|
||||
"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": {
|
||||
"save": "Enregistrer",
|
||||
@@ -213,17 +254,26 @@
|
||||
"configuredCount": "{{count}} configured",
|
||||
"totalProviders": "{{count}} available",
|
||||
"webSearch": "Web search",
|
||||
"imageGeneration": "Génération d'images",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"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": {
|
||||
"fallbackTitle": "Discussion {{id}}",
|
||||
"loading": "Chargement…",
|
||||
"noSessions": "Aucune session pour le moment.",
|
||||
"showMore": "Afficher {{count}} de plus",
|
||||
"actions": "Actions de la discussion {{title}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
@@ -268,7 +318,6 @@
|
||||
"thread": {
|
||||
"loadingConversation": "Chargement de la conversation…",
|
||||
"empty": {
|
||||
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil.",
|
||||
"greeting": "Que puis-je faire pour vous ?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
@@ -321,10 +370,14 @@
|
||||
"title": "Modifier une image",
|
||||
"prompt": "Aidez-moi à modifier une image. Demandez-moi d’abord de téléverser ou d’indiquer l’image, puis générez le résultat modifié."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Afficher ou masquer la barre latérale"
|
||||
"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": {
|
||||
"placeholderThread": "Saisissez votre message…",
|
||||
@@ -338,6 +391,7 @@
|
||||
"goalStateFallback": "Objectif",
|
||||
"goalStateExpandAria": "Afficher l’objectif complet",
|
||||
"goalStateSheetTitle": "Objectif",
|
||||
"goalStateCloseAria": "Fermer l’objectif",
|
||||
"send": "Envoyer le message",
|
||||
"stop": "Arrêter la réponse",
|
||||
"attachImage": "Joindre une image",
|
||||
@@ -356,16 +410,11 @@
|
||||
"16_9": "Large 16:9"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"tools": {
|
||||
"search": "Rechercher",
|
||||
"reason": "Raisonner",
|
||||
"deepResearch": "Recherche approfondie",
|
||||
"voice": "Entrée vocale"
|
||||
},
|
||||
"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",
|
||||
"loadEarlier": "Charger les messages précédents"
|
||||
@@ -438,6 +497,8 @@
|
||||
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils",
|
||||
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
|
||||
"imageAttachment": "Pièce jointe image",
|
||||
"copyReply": "Copier la réponse",
|
||||
"copiedReply": "Réponse copiée",
|
||||
"turnLatencyTitle": "Temps de réponse (de bout en bout)"
|
||||
},
|
||||
"lightbox": {
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
"title": "Tidak dapat menjangkau nanobot",
|
||||
"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": {
|
||||
"section": "Sistem",
|
||||
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
|
||||
@@ -27,9 +39,13 @@
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navigasi bilah samping",
|
||||
"globalActions": "Aksi global",
|
||||
"collapse": "Ciutkan sidebar",
|
||||
"toggleTheme": "Ganti tema",
|
||||
"home": "Beranda",
|
||||
"newChat": "Obrolan baru",
|
||||
"searchAria": "Cari",
|
||||
"viewOptions": "View",
|
||||
"compactList": "Compact list",
|
||||
"showPreviews": "Show previews",
|
||||
@@ -38,17 +54,16 @@
|
||||
"sortUpdated": "Recently updated",
|
||||
"sortCreated": "Recently created",
|
||||
"sortTitle": "Title A-Z",
|
||||
"searchPlaceholder": "Cari",
|
||||
"searchResults": "Hasil",
|
||||
"noSearchResults": "Tidak ada chat yang cocok.",
|
||||
"recent": "Terbaru",
|
||||
"refreshSessions": "Segarkan sesi",
|
||||
"settings": "Pengaturan",
|
||||
"language": {
|
||||
"label": "Bahasa",
|
||||
"ariaLabel": "Ganti bahasa"
|
||||
},
|
||||
"searchAria": "Cari",
|
||||
"searchPlaceholder": "Cari",
|
||||
"searchResults": "Hasil",
|
||||
"noSearchResults": "Tidak ada chat yang cocok."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Kembali ke obrolan",
|
||||
@@ -75,10 +90,13 @@
|
||||
"status": "Status",
|
||||
"localPreferences": "Local preferences",
|
||||
"presets": "Presets",
|
||||
"imageGeneration": "Pembuatan gambar",
|
||||
"imageDefaults": "Default",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Kapabilitas",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"rows": {
|
||||
@@ -91,6 +109,7 @@
|
||||
"activePreset": "Active preset",
|
||||
"gateway": "Gateway",
|
||||
"restartState": "Restart state",
|
||||
"pendingChanges": "Perubahan tertunda",
|
||||
"selectedPreset": "Selected preset",
|
||||
"presetModel": "Preset model",
|
||||
"density": "Density",
|
||||
@@ -99,6 +118,15 @@
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"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",
|
||||
"botIcon": "Bot icon",
|
||||
"timezone": "Timezone",
|
||||
@@ -129,6 +157,13 @@
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"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.",
|
||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||
@@ -141,8 +176,8 @@
|
||||
"notAvailable": "Tidak tersedia",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready",
|
||||
"restartPending": "Menunggu restart",
|
||||
"ready": "Siap",
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
@@ -150,13 +185,19 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured"
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Memuat pengaturan...",
|
||||
"loadError": "Tidak dapat memuat pengaturan",
|
||||
"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": {
|
||||
"save": "Simpan",
|
||||
@@ -213,17 +254,26 @@
|
||||
"configuredCount": "{{count}} configured",
|
||||
"totalProviders": "{{count}} available",
|
||||
"webSearch": "Web search",
|
||||
"imageGeneration": "Pembuatan gambar",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"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": {
|
||||
"fallbackTitle": "Obrolan {{id}}",
|
||||
"loading": "Memuat…",
|
||||
"noSessions": "Belum ada sesi.",
|
||||
"showMore": "Tampilkan {{count}} lagi",
|
||||
"actions": "Aksi obrolan untuk {{title}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
@@ -268,7 +318,6 @@
|
||||
"thread": {
|
||||
"loadingConversation": "Memuat percakapan…",
|
||||
"empty": {
|
||||
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru.",
|
||||
"greeting": "Apa yang bisa saya bantu?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
@@ -321,10 +370,14 @@
|
||||
"title": "Edit gambar",
|
||||
"prompt": "Bantu saya mengedit gambar. Minta saya mengunggah atau menyebutkan gambar terlebih dahulu, lalu buat hasil editnya."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Tampilkan atau sembunyikan sidebar"
|
||||
"toggleSidebar": "Tampilkan atau sembunyikan sidebar",
|
||||
"newChat": "Mulai chat baru",
|
||||
"toggleTheme": "Alihkan tema dari header",
|
||||
"settings": "Buka pengaturan"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "Ketik pesan Anda…",
|
||||
@@ -338,6 +391,7 @@
|
||||
"goalStateFallback": "Tujuan",
|
||||
"goalStateExpandAria": "Lihat tujuan lengkap",
|
||||
"goalStateSheetTitle": "Tujuan",
|
||||
"goalStateCloseAria": "Tutup tujuan",
|
||||
"send": "Kirim pesan",
|
||||
"stop": "Hentikan respons",
|
||||
"attachImage": "Lampirkan gambar",
|
||||
@@ -356,16 +410,11 @@
|
||||
"16_9": "Lebar 16:9"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"tools": {
|
||||
"search": "Cari",
|
||||
"reason": "Bernalar",
|
||||
"deepResearch": "Riset mendalam",
|
||||
"voice": "Input suara"
|
||||
},
|
||||
"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",
|
||||
"loadEarlier": "Muat pesan sebelumnya"
|
||||
@@ -438,6 +497,8 @@
|
||||
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
|
||||
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
|
||||
"imageAttachment": "Lampiran gambar",
|
||||
"copyReply": "Salin balasan",
|
||||
"copiedReply": "Balasan disalin",
|
||||
"turnLatencyTitle": "Waktu respons (ujung ke ujung)"
|
||||
},
|
||||
"lightbox": {
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
"title": "nanobot に接続できませんでした",
|
||||
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||
},
|
||||
"auth": {
|
||||
"title": "認証が必要です",
|
||||
"hint": "gateway 設定の tokenIssueSecret に指定されたシークレットを入力してください。",
|
||||
"placeholder": "パスワード",
|
||||
"submit": "接続",
|
||||
"invalid": "パスワードが無効です。もう一度お試しください。"
|
||||
},
|
||||
"account": {
|
||||
"section": "アカウント",
|
||||
"logoutHint": "このブラウザーを gateway から切断します。",
|
||||
"logout": "サインアウト"
|
||||
},
|
||||
"system": {
|
||||
"section": "システム",
|
||||
"restartHint": "実行時の変更を適用するには nanobot を再起動します。",
|
||||
@@ -27,9 +39,13 @@
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "サイドバーのナビゲーション",
|
||||
"globalActions": "グローバル操作",
|
||||
"collapse": "サイドバーを閉じる",
|
||||
"toggleTheme": "テーマを切り替える",
|
||||
"home": "ホーム",
|
||||
"newChat": "新しいチャット",
|
||||
"searchAria": "検索",
|
||||
"viewOptions": "View",
|
||||
"compactList": "Compact list",
|
||||
"showPreviews": "Show previews",
|
||||
@@ -38,17 +54,16 @@
|
||||
"sortUpdated": "Recently updated",
|
||||
"sortCreated": "Recently created",
|
||||
"sortTitle": "Title A-Z",
|
||||
"searchPlaceholder": "検索",
|
||||
"searchResults": "検索結果",
|
||||
"noSearchResults": "一致するチャットはありません。",
|
||||
"recent": "最近のチャット",
|
||||
"refreshSessions": "セッションを更新",
|
||||
"settings": "設定",
|
||||
"language": {
|
||||
"label": "言語",
|
||||
"ariaLabel": "言語を変更"
|
||||
},
|
||||
"searchAria": "検索",
|
||||
"searchPlaceholder": "検索",
|
||||
"searchResults": "検索結果",
|
||||
"noSearchResults": "一致するチャットはありません。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "チャットに戻る",
|
||||
@@ -75,10 +90,13 @@
|
||||
"status": "Status",
|
||||
"localPreferences": "Local preferences",
|
||||
"presets": "Presets",
|
||||
"imageGeneration": "画像生成",
|
||||
"imageDefaults": "既定値",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "機能",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"rows": {
|
||||
@@ -91,6 +109,7 @@
|
||||
"activePreset": "Active preset",
|
||||
"gateway": "Gateway",
|
||||
"restartState": "Restart state",
|
||||
"pendingChanges": "保留中の変更",
|
||||
"selectedPreset": "Selected preset",
|
||||
"presetModel": "Preset model",
|
||||
"density": "Density",
|
||||
@@ -99,6 +118,15 @@
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"jinaReader": "Jina reader",
|
||||
"imageGeneration": "画像生成",
|
||||
"imageProvider": "画像プロバイダー",
|
||||
"imageProviderStatus": "プロバイダーの状態",
|
||||
"imageProviderBase": "プロバイダーのベース URL",
|
||||
"imageModel": "画像モデル",
|
||||
"defaultAspectRatio": "既定の比率",
|
||||
"defaultImageSize": "既定のサイズ",
|
||||
"maxImagesPerTurn": "1 ターンあたりの最大画像数",
|
||||
"imageSaveDir": "保存先ディレクトリ",
|
||||
"botName": "Bot name",
|
||||
"botIcon": "Bot icon",
|
||||
"timezone": "Timezone",
|
||||
@@ -129,6 +157,13 @@
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"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.",
|
||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||
@@ -141,8 +176,8 @@
|
||||
"notAvailable": "利用不可",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready",
|
||||
"restartPending": "再起動待ち",
|
||||
"ready": "準備完了",
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
@@ -150,13 +185,19 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured"
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
},
|
||||
"status": {
|
||||
"loading": "設定を読み込んでいます...",
|
||||
"loadError": "設定を読み込めませんでした",
|
||||
"unsaved": "未保存の変更があります。",
|
||||
"savedRestart": "保存しました。反映するには nanobot を再起動してください。"
|
||||
"upToDate": "最新です。",
|
||||
"savedRestart": "保存しました。反映するには nanobot を再起動してください。",
|
||||
"restartAfterSaving": "変更を保存してから、準備ができたら再起動してください。",
|
||||
"savedRestartApply": "保存しました。準備ができたら再起動してください。",
|
||||
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
@@ -213,17 +254,26 @@
|
||||
"configuredCount": "{{count}} configured",
|
||||
"totalProviders": "{{count}} available",
|
||||
"webSearch": "Web search",
|
||||
"imageGeneration": "画像生成",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "プロバイダーを選択",
|
||||
"selectAspect": "比率を選択",
|
||||
"selectSize": "サイズを選択",
|
||||
"configureProvider": "プロバイダーを設定",
|
||||
"missingCredential": "画像生成を有効にする前に、このプロバイダーを設定してください。"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "チャット {{id}}",
|
||||
"loading": "読み込み中…",
|
||||
"noSessions": "まだセッションがありません。",
|
||||
"showMore": "さらに {{count}} 件表示",
|
||||
"actions": "「{{title}}」のチャット操作",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
@@ -268,7 +318,6 @@
|
||||
"thread": {
|
||||
"loadingConversation": "会話を読み込み中…",
|
||||
"empty": {
|
||||
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。",
|
||||
"greeting": "何をお手伝いしましょうか?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
@@ -321,10 +370,14 @@
|
||||
"title": "画像を編集",
|
||||
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "サイドバーを切り替える"
|
||||
"toggleSidebar": "サイドバーを切り替える",
|
||||
"newChat": "新しいチャットを開始",
|
||||
"toggleTheme": "ヘッダーからテーマを切り替える",
|
||||
"settings": "設定を開く"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "メッセージを入力…",
|
||||
@@ -338,6 +391,7 @@
|
||||
"goalStateFallback": "目標",
|
||||
"goalStateExpandAria": "目標の全文を表示",
|
||||
"goalStateSheetTitle": "目標",
|
||||
"goalStateCloseAria": "目標を閉じる",
|
||||
"send": "メッセージを送信",
|
||||
"stop": "応答を停止",
|
||||
"attachImage": "画像を添付",
|
||||
@@ -356,16 +410,11 @@
|
||||
"16_9": "ワイド 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "処理中…",
|
||||
"remove": "添付を削除",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)",
|
||||
"imageRejected": {
|
||||
"unsupported_type": "対応していないファイル形式です",
|
||||
"too_many_images": "1 メッセージにつき最大 {{max}} 枚です",
|
||||
"magic_mismatch": "画像ファイルではないようです",
|
||||
"decode_failed": "この画像をデコードできません",
|
||||
"too_large": "画像が大きすぎます。小さいものを選んでください",
|
||||
"io": "このファイルを読み込めません"
|
||||
"tools": {
|
||||
"search": "検索",
|
||||
"reason": "推論",
|
||||
"deepResearch": "詳細調査",
|
||||
"voice": "音声入力"
|
||||
},
|
||||
"slash": {
|
||||
"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": "一番下へスクロール",
|
||||
"loadEarlier": "以前のメッセージを読み込む"
|
||||
@@ -438,6 +497,8 @@
|
||||
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
|
||||
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
|
||||
"imageAttachment": "画像の添付",
|
||||
"copyReply": "返信をコピー",
|
||||
"copiedReply": "返信をコピーしました",
|
||||
"turnLatencyTitle": "応答時間(全行程)"
|
||||
},
|
||||
"lightbox": {
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
"title": "nanobot에 연결할 수 없습니다",
|
||||
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||
},
|
||||
"auth": {
|
||||
"title": "인증이 필요합니다",
|
||||
"hint": "gateway 설정의 tokenIssueSecret에 구성된 비밀 값을 입력하세요.",
|
||||
"placeholder": "비밀번호",
|
||||
"submit": "연결",
|
||||
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
|
||||
},
|
||||
"account": {
|
||||
"section": "계정",
|
||||
"logoutHint": "이 브라우저를 gateway에서 연결 해제합니다.",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"system": {
|
||||
"section": "시스템",
|
||||
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
|
||||
@@ -27,9 +39,13 @@
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "사이드바 탐색",
|
||||
"globalActions": "전역 작업",
|
||||
"collapse": "사이드바 접기",
|
||||
"toggleTheme": "테마 전환",
|
||||
"home": "홈",
|
||||
"newChat": "새 채팅",
|
||||
"searchAria": "검색",
|
||||
"viewOptions": "View",
|
||||
"compactList": "Compact list",
|
||||
"showPreviews": "Show previews",
|
||||
@@ -38,17 +54,16 @@
|
||||
"sortUpdated": "Recently updated",
|
||||
"sortCreated": "Recently created",
|
||||
"sortTitle": "Title A-Z",
|
||||
"searchPlaceholder": "검색",
|
||||
"searchResults": "결과",
|
||||
"noSearchResults": "일치하는 채팅이 없습니다.",
|
||||
"recent": "최근 대화",
|
||||
"refreshSessions": "세션 새로고침",
|
||||
"settings": "설정",
|
||||
"language": {
|
||||
"label": "언어",
|
||||
"ariaLabel": "언어 변경"
|
||||
},
|
||||
"searchAria": "검색",
|
||||
"searchPlaceholder": "검색",
|
||||
"searchResults": "결과",
|
||||
"noSearchResults": "일치하는 채팅이 없습니다."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "채팅으로 돌아가기",
|
||||
@@ -75,10 +90,13 @@
|
||||
"status": "Status",
|
||||
"localPreferences": "Local preferences",
|
||||
"presets": "Presets",
|
||||
"imageGeneration": "이미지 생성",
|
||||
"imageDefaults": "기본값",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "기능",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"rows": {
|
||||
@@ -91,6 +109,7 @@
|
||||
"activePreset": "Active preset",
|
||||
"gateway": "Gateway",
|
||||
"restartState": "Restart state",
|
||||
"pendingChanges": "대기 중인 변경 사항",
|
||||
"selectedPreset": "Selected preset",
|
||||
"presetModel": "Preset model",
|
||||
"density": "Density",
|
||||
@@ -99,6 +118,15 @@
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"jinaReader": "Jina reader",
|
||||
"imageGeneration": "이미지 생성",
|
||||
"imageProvider": "이미지 제공자",
|
||||
"imageProviderStatus": "제공자 상태",
|
||||
"imageProviderBase": "제공자 기준 주소",
|
||||
"imageModel": "이미지 모델",
|
||||
"defaultAspectRatio": "기본 비율",
|
||||
"defaultImageSize": "기본 크기",
|
||||
"maxImagesPerTurn": "턴당 최대 이미지 수",
|
||||
"imageSaveDir": "저장 디렉터리",
|
||||
"botName": "Bot name",
|
||||
"botIcon": "Bot icon",
|
||||
"timezone": "Timezone",
|
||||
@@ -129,6 +157,13 @@
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"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.",
|
||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||
@@ -141,8 +176,8 @@
|
||||
"notAvailable": "사용할 수 없음",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready",
|
||||
"restartPending": "재시작 대기 중",
|
||||
"ready": "준비됨",
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
@@ -150,13 +185,19 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured"
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
},
|
||||
"status": {
|
||||
"loading": "설정을 불러오는 중...",
|
||||
"loadError": "설정을 불러올 수 없습니다",
|
||||
"unsaved": "저장되지 않은 변경 사항이 있습니다.",
|
||||
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요."
|
||||
"upToDate": "최신 상태입니다.",
|
||||
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요.",
|
||||
"restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.",
|
||||
"savedRestartApply": "저장되었습니다. 준비되면 재시작하세요.",
|
||||
"imageProviderRestart": "이미지 제공자 변경 사항이 저장되었습니다. 준비되면 재시작하세요."
|
||||
},
|
||||
"actions": {
|
||||
"save": "저장",
|
||||
@@ -213,17 +254,26 @@
|
||||
"configuredCount": "{{count}} configured",
|
||||
"totalProviders": "{{count}} available",
|
||||
"webSearch": "Web search",
|
||||
"imageGeneration": "이미지 생성",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "제공자 선택",
|
||||
"selectAspect": "비율 선택",
|
||||
"selectSize": "크기 선택",
|
||||
"configureProvider": "제공자 구성",
|
||||
"missingCredential": "이미지 생성을 활성화하기 전에 이 제공자를 구성하세요."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "채팅 {{id}}",
|
||||
"loading": "불러오는 중…",
|
||||
"noSessions": "아직 세션이 없습니다.",
|
||||
"showMore": "{{count}}개 더 보기",
|
||||
"actions": "{{title}} 채팅 작업",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
@@ -268,7 +318,6 @@
|
||||
"thread": {
|
||||
"loadingConversation": "대화 불러오는 중…",
|
||||
"empty": {
|
||||
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다.",
|
||||
"greeting": "무엇을 도와드릴까요?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
@@ -321,10 +370,14 @@
|
||||
"title": "이미지 편집",
|
||||
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "사이드바 전환"
|
||||
"toggleSidebar": "사이드바 전환",
|
||||
"newChat": "새 채팅 시작",
|
||||
"toggleTheme": "헤더에서 테마 전환",
|
||||
"settings": "설정 열기"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "메시지를 입력하세요…",
|
||||
@@ -338,6 +391,7 @@
|
||||
"goalStateFallback": "목표",
|
||||
"goalStateExpandAria": "전체 목표 보기",
|
||||
"goalStateSheetTitle": "목표",
|
||||
"goalStateCloseAria": "목표 닫기",
|
||||
"send": "메시지 보내기",
|
||||
"stop": "응답 중지",
|
||||
"attachImage": "이미지 첨부",
|
||||
@@ -356,16 +410,11 @@
|
||||
"16_9": "와이드 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "처리 중…",
|
||||
"remove": "첨부 제거",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)",
|
||||
"imageRejected": {
|
||||
"unsupported_type": "지원하지 않는 파일 형식입니다",
|
||||
"too_many_images": "메시지당 최대 {{max}}장까지 가능합니다",
|
||||
"magic_mismatch": "이미지 파일이 아닌 것 같습니다",
|
||||
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
|
||||
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
|
||||
"io": "이 파일을 읽을 수 없습니다"
|
||||
"tools": {
|
||||
"search": "검색",
|
||||
"reason": "추론",
|
||||
"deepResearch": "심층 조사",
|
||||
"voice": "음성 입력"
|
||||
},
|
||||
"slash": {
|
||||
"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": "맨 아래로 스크롤",
|
||||
"loadEarlier": "이전 메시지 불러오기"
|
||||
@@ -438,6 +497,8 @@
|
||||
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
|
||||
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
|
||||
"imageAttachment": "이미지 첨부",
|
||||
"copyReply": "답변 복사",
|
||||
"copiedReply": "답변이 복사됨",
|
||||
"turnLatencyTitle": "응답 시간(엔드투엔드)"
|
||||
},
|
||||
"lightbox": {
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"section": "Hệ thống",
|
||||
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
|
||||
@@ -27,9 +39,13 @@
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Điều hướng thanh bên",
|
||||
"globalActions": "Hành động toàn cục",
|
||||
"collapse": "Thu gọn thanh bên",
|
||||
"toggleTheme": "Chuyển giao diện",
|
||||
"home": "Trang chủ",
|
||||
"newChat": "Cuộc trò chuyện mới",
|
||||
"searchAria": "Tìm kiếm",
|
||||
"viewOptions": "View",
|
||||
"compactList": "Compact list",
|
||||
"showPreviews": "Show previews",
|
||||
@@ -38,17 +54,16 @@
|
||||
"sortUpdated": "Recently updated",
|
||||
"sortCreated": "Recently created",
|
||||
"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",
|
||||
"refreshSessions": "Làm mới phiên",
|
||||
"settings": "Cài đặt",
|
||||
"language": {
|
||||
"label": "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": {
|
||||
"backToChat": "Quay lại trò chuyện",
|
||||
@@ -75,10 +90,13 @@
|
||||
"status": "Status",
|
||||
"localPreferences": "Local preferences",
|
||||
"presets": "Presets",
|
||||
"imageGeneration": "Tạo ảnh",
|
||||
"imageDefaults": "Mặc định",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Khả năng",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"rows": {
|
||||
@@ -91,6 +109,7 @@
|
||||
"activePreset": "Active preset",
|
||||
"gateway": "Gateway",
|
||||
"restartState": "Restart state",
|
||||
"pendingChanges": "Thay đổi đang chờ",
|
||||
"selectedPreset": "Selected preset",
|
||||
"presetModel": "Preset model",
|
||||
"density": "Density",
|
||||
@@ -99,6 +118,15 @@
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"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",
|
||||
"botIcon": "Bot icon",
|
||||
"timezone": "Timezone",
|
||||
@@ -129,6 +157,13 @@
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"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.",
|
||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||
@@ -141,8 +176,8 @@
|
||||
"notAvailable": "Không khả dụng",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready",
|
||||
"restartPending": "Đang chờ khởi động lại",
|
||||
"ready": "Sẵn sàng",
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
@@ -150,13 +185,19 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured"
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Đang tải cài đặt...",
|
||||
"loadError": "Không thể tải cài đặt",
|
||||
"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": {
|
||||
"save": "Lưu",
|
||||
@@ -213,17 +254,26 @@
|
||||
"configuredCount": "{{count}} configured",
|
||||
"totalProviders": "{{count}} available",
|
||||
"webSearch": "Web search",
|
||||
"imageGeneration": "Tạo ảnh",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"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": {
|
||||
"fallbackTitle": "Trò chuyện {{id}}",
|
||||
"loading": "Đang tải…",
|
||||
"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}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
@@ -268,7 +318,6 @@
|
||||
"thread": {
|
||||
"loadingConversation": "Đang tải cuộc trò chuyện…",
|
||||
"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?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
@@ -321,10 +370,14 @@
|
||||
"title": "Chỉnh sửa ảnh",
|
||||
"prompt": "Giúp tôi chỉnh sửa một ảnh. Trước tiên hãy yêu cầu tôi tải lên hoặc chỉ định ảnh, rồi tạo kết quả đã chỉnh sửa."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Bật/tắt thanh bên"
|
||||
"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": {
|
||||
"placeholderThread": "Nhập tin nhắn…",
|
||||
@@ -338,6 +391,7 @@
|
||||
"goalStateFallback": "Mục tiêu",
|
||||
"goalStateExpandAria": "Xem đầy đủ mục tiêu",
|
||||
"goalStateSheetTitle": "Mục tiêu",
|
||||
"goalStateCloseAria": "Đóng mục tiêu",
|
||||
"send": "Gửi tin nhắn",
|
||||
"stop": "Dừng phản hồi",
|
||||
"attachImage": "Đính kèm ảnh",
|
||||
@@ -356,16 +410,11 @@
|
||||
"16_9": "Rộng 16:9"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"tools": {
|
||||
"search": "Tìm kiếm",
|
||||
"reason": "Suy luận",
|
||||
"deepResearch": "Nghiên cứu sâu",
|
||||
"voice": "Nhập bằng giọng nói"
|
||||
},
|
||||
"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",
|
||||
"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ụ",
|
||||
"agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
|
||||
"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)"
|
||||
},
|
||||
"lightbox": {
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"providers": "提供商",
|
||||
"image": "图片",
|
||||
"web": "网页",
|
||||
"cliApps": "CLI 应用",
|
||||
"runtime": "运行时",
|
||||
"advanced": "高级"
|
||||
},
|
||||
@@ -82,6 +83,7 @@
|
||||
"imageDefaults": "默认值",
|
||||
"webSearch": "网页搜索",
|
||||
"webBehavior": "行为",
|
||||
"cliApps": "CLI 应用",
|
||||
"identity": "身份",
|
||||
"safety": "安全",
|
||||
"capabilities": "能力",
|
||||
@@ -103,6 +105,7 @@
|
||||
"density": "密度",
|
||||
"activityMode": "活动细节",
|
||||
"codeWrap": "代码换行",
|
||||
"brandLogos": "品牌 Logo",
|
||||
"maxResults": "最大结果数",
|
||||
"timeout": "超时",
|
||||
"jinaReader": "Jina Reader",
|
||||
@@ -129,6 +132,8 @@
|
||||
"ssrfWhitelist": "SSRF 白名单",
|
||||
"mcpServers": "MCP 服务器",
|
||||
"pathAppend": "PATH 追加",
|
||||
"cliAppsCatalog": "目录",
|
||||
"cliAppsFilter": "筛选",
|
||||
"configurationDocs": "配置文档"
|
||||
},
|
||||
"help": {
|
||||
@@ -142,6 +147,7 @@
|
||||
"density": "仅保存在当前浏览器。",
|
||||
"activityMode": "选择默认显示多少 agent 活动细节。",
|
||||
"codeWrap": "让较小屏幕上的长代码行更易读。",
|
||||
"brandLogos": "在设置页显示第三方服务商和 CLI 的 Logo。",
|
||||
"maxResults": "每次 web_search 返回的结果数量。",
|
||||
"timeout": "搜索服务商请求超时秒数。",
|
||||
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
||||
@@ -156,8 +162,41 @@
|
||||
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
|
||||
"timezone": "运行时上下文和计划任务使用的 IANA 时区。",
|
||||
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
|
||||
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
|
||||
"cliAppsFilter": "按应用、分类或能力搜索。",
|
||||
"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": {
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
@@ -247,6 +286,9 @@
|
||||
"searchPlaceholder": "搜索服务商",
|
||||
"noMatches": "没有匹配的服务商。"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "选择服务商",
|
||||
"selectAspect": "选择比例",
|
||||
@@ -259,6 +301,7 @@
|
||||
"fallbackTitle": "对话 {{id}}",
|
||||
"loading": "加载中…",
|
||||
"noSessions": "还没有会话。",
|
||||
"showMore": "再显示 {{count}} 个",
|
||||
"actions": "“{{title}}” 的会话操作",
|
||||
"activity": {
|
||||
"running": "Agent 正在运行",
|
||||
@@ -448,6 +491,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "CLI 应用",
|
||||
"label": "CLI 应用"
|
||||
},
|
||||
"encoding": "处理中…",
|
||||
"remove": "移除附件",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
|
||||
@@ -480,6 +527,15 @@
|
||||
"agentActivityToolsOnly": "{{tools}} 次工具调用",
|
||||
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
|
||||
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
|
||||
"cliActivityRunningOne": "正在运行 CLI @{{name}}",
|
||||
"cliActivityRanOne": "已运行 CLI @{{name}}",
|
||||
"cliActivityFailedOne": "CLI 调用失败 @{{name}}",
|
||||
"cliActivityRunningMany": "正在运行 {{count}} 个 CLI",
|
||||
"cliActivityRanMany": "已运行 {{count}} 个 CLI",
|
||||
"cliActivityFailedMany": "{{count}} 个 CLI 调用失败",
|
||||
"cliRunRunning": "正在运行 CLI",
|
||||
"cliRunRan": "已运行 CLI",
|
||||
"cliRunFailed": "CLI 调用失败",
|
||||
"imageAttachment": "图片附件",
|
||||
"copyReply": "复制回复",
|
||||
"copiedReply": "已复制回复",
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
"title": "無法連線到 nanobot",
|
||||
"gatewayHint": "請確認 gateway 已啟動(`nanobot gateway`),並且目前頁面與 gateway 在同一台機器上開啟。"
|
||||
},
|
||||
"auth": {
|
||||
"title": "需要驗證",
|
||||
"hint": "輸入 gateway 設定中 tokenIssueSecret 配置的密鑰。",
|
||||
"placeholder": "密碼",
|
||||
"submit": "連線",
|
||||
"invalid": "密碼無效,請再試一次。"
|
||||
},
|
||||
"account": {
|
||||
"section": "帳戶",
|
||||
"logoutHint": "中斷此瀏覽器與 gateway 的連線。",
|
||||
"logout": "登出"
|
||||
},
|
||||
"system": {
|
||||
"section": "系統",
|
||||
"restartHint": "重新啟動 nanobot 以套用執行階段變更。",
|
||||
@@ -27,9 +39,13 @@
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "側邊欄導覽",
|
||||
"globalActions": "全域操作",
|
||||
"collapse": "收合側邊欄",
|
||||
"toggleTheme": "切換主題",
|
||||
"home": "首頁",
|
||||
"newChat": "新增對話",
|
||||
"searchAria": "搜尋",
|
||||
"viewOptions": "檢視",
|
||||
"compactList": "緊湊列表",
|
||||
"showPreviews": "顯示預覽",
|
||||
@@ -38,17 +54,16 @@
|
||||
"sortUpdated": "最近更新",
|
||||
"sortCreated": "最近建立",
|
||||
"sortTitle": "標題 A-Z",
|
||||
"searchPlaceholder": "搜尋",
|
||||
"searchResults": "搜尋結果",
|
||||
"noSearchResults": "沒有符合的對話。",
|
||||
"recent": "最近對話",
|
||||
"refreshSessions": "重新整理會話",
|
||||
"settings": "設定",
|
||||
"language": {
|
||||
"label": "語言",
|
||||
"ariaLabel": "切換語言"
|
||||
},
|
||||
"searchAria": "搜尋",
|
||||
"searchPlaceholder": "搜尋",
|
||||
"searchResults": "搜尋結果",
|
||||
"noSearchResults": "沒有符合的對話。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回對話",
|
||||
@@ -75,10 +90,13 @@
|
||||
"status": "Status",
|
||||
"localPreferences": "Local preferences",
|
||||
"presets": "Presets",
|
||||
"imageGeneration": "圖片生成",
|
||||
"imageDefaults": "預設值",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "功能",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"rows": {
|
||||
@@ -91,6 +109,7 @@
|
||||
"activePreset": "Active preset",
|
||||
"gateway": "Gateway",
|
||||
"restartState": "Restart state",
|
||||
"pendingChanges": "待處理變更",
|
||||
"selectedPreset": "Selected preset",
|
||||
"presetModel": "Preset model",
|
||||
"density": "Density",
|
||||
@@ -99,6 +118,15 @@
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"jinaReader": "Jina reader",
|
||||
"imageGeneration": "圖片生成",
|
||||
"imageProvider": "圖片服務商",
|
||||
"imageProviderStatus": "服務商狀態",
|
||||
"imageProviderBase": "服務商位址",
|
||||
"imageModel": "圖片模型",
|
||||
"defaultAspectRatio": "預設比例",
|
||||
"defaultImageSize": "預設尺寸",
|
||||
"maxImagesPerTurn": "每輪最多圖片數",
|
||||
"imageSaveDir": "儲存目錄",
|
||||
"botName": "Bot name",
|
||||
"botIcon": "Bot icon",
|
||||
"timezone": "Timezone",
|
||||
@@ -129,6 +157,13 @@
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"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.",
|
||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||
@@ -141,8 +176,8 @@
|
||||
"notAvailable": "不可用",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready",
|
||||
"restartPending": "等待重新啟動",
|
||||
"ready": "就緒",
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
@@ -150,13 +185,19 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured"
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
},
|
||||
"status": {
|
||||
"loading": "正在載入設定...",
|
||||
"loadError": "無法載入設定",
|
||||
"unsaved": "有未儲存的變更。",
|
||||
"savedRestart": "已儲存。重新啟動 nanobot 後生效。"
|
||||
"upToDate": "已是最新。",
|
||||
"savedRestart": "已儲存。重新啟動 nanobot 後生效。",
|
||||
"restartAfterSaving": "儲存變更後,可在準備好時重新啟動。",
|
||||
"savedRestartApply": "已儲存,可在準備好時重新啟動。",
|
||||
"imageProviderRestart": "圖片服務商變更已儲存,可在準備好時重新啟動。"
|
||||
},
|
||||
"actions": {
|
||||
"save": "儲存",
|
||||
@@ -213,17 +254,26 @@
|
||||
"configuredCount": "{{count}} configured",
|
||||
"totalProviders": "{{count}} available",
|
||||
"webSearch": "Web search",
|
||||
"imageGeneration": "圖片生成",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "選擇服務商",
|
||||
"selectAspect": "選擇比例",
|
||||
"selectSize": "選擇尺寸",
|
||||
"configureProvider": "設定服務商",
|
||||
"missingCredential": "啟用圖片生成前,請先設定此服務商。"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "對話 {{id}}",
|
||||
"loading": "載入中…",
|
||||
"noSessions": "目前還沒有會話。",
|
||||
"showMore": "再顯示 {{count}} 個",
|
||||
"actions": "「{{title}}」的會話操作",
|
||||
"activity": {
|
||||
"running": "Agent 正在執行",
|
||||
@@ -268,7 +318,6 @@
|
||||
"thread": {
|
||||
"loadingConversation": "正在載入對話…",
|
||||
"empty": {
|
||||
"description": "你可以提問、延續本地工作,或是開始新的執行緒。",
|
||||
"greeting": "我可以幫你做什麼?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
@@ -321,10 +370,14 @@
|
||||
"title": "編輯圖片",
|
||||
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "切換側邊欄"
|
||||
"toggleSidebar": "切換側邊欄",
|
||||
"newChat": "開始新對話",
|
||||
"toggleTheme": "從頂部切換主題",
|
||||
"settings": "開啟設定"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "輸入訊息…",
|
||||
@@ -338,6 +391,7 @@
|
||||
"goalStateFallback": "目標",
|
||||
"goalStateExpandAria": "查看完整目標",
|
||||
"goalStateSheetTitle": "目標",
|
||||
"goalStateCloseAria": "關閉目標",
|
||||
"send": "送出訊息",
|
||||
"stop": "停止回覆",
|
||||
"attachImage": "附加圖片",
|
||||
@@ -356,16 +410,11 @@
|
||||
"16_9": "寬螢幕 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "處理中…",
|
||||
"remove": "移除附件",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)",
|
||||
"imageRejected": {
|
||||
"unsupported_type": "不支援的檔案類型",
|
||||
"too_many_images": "每則訊息最多 {{max}} 張圖片",
|
||||
"magic_mismatch": "檔案看起來不像真正的圖片",
|
||||
"decode_failed": "無法解碼這張圖片",
|
||||
"too_large": "圖片太大,請換一張小一點的",
|
||||
"io": "無法讀取這個檔案"
|
||||
"tools": {
|
||||
"search": "搜尋",
|
||||
"reason": "推理",
|
||||
"deepResearch": "深度研究",
|
||||
"voice": "語音輸入"
|
||||
},
|
||||
"slash": {
|
||||
"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": "捲動到底部",
|
||||
"loadEarlier": "載入更早訊息"
|
||||
@@ -438,6 +497,8 @@
|
||||
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
|
||||
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
|
||||
"imageAttachment": "圖片附件",
|
||||
"copyReply": "複製回覆",
|
||||
"copiedReply": "已複製回覆",
|
||||
"turnLatencyTitle": "本輪耗時(端到端)"
|
||||
},
|
||||
"lightbox": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
ProviderSettingsUpdate,
|
||||
SettingsPayload,
|
||||
@@ -106,6 +107,24 @@ export async function fetchSettings(
|
||||
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(
|
||||
token: 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