Compare commits

..
Author SHA1 Message Date
chengyongruandchengyongru f44ee6cb27 fix(signal): bypass base is_allowed for policy-approved messages
Override _handle_message to publish directly to the bus for messages
that have already passed _check_inbound_policy. The denied DM pairing
path calls super()._handle_message() to issue pairing codes via the
base class. This avoids cross-policy leakage where e.g. group open
policy would cause is_allowed to incorrectly allow denied DM senders.

Also includes:
- SSE: strip one optional leading space after 'data:' per spec
- Convert 20+ f-string log calls to loguru lazy formatting
- Add end-to-end tests for DM/group routing through the full chain
- Add cross-policy test (dm allowlist + group open) for pairing
- Add Signal channel documentation to docs/chat-apps.md
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 5be3df1d6f fix(signal): consult pairing store in is_allowed
BaseChannel.is_allowed ORs is_approved (the pairing store) into the
allow decision; the signal override dropped that step and only looked
at config.allow_from. With the new DM-pairing flow in place, an
approved-via-pairing sender's next message would have failed the
allow check and triggered another pairing code in a loop.

OR in a normalized check against the pairing store: walk each part of
the pipe-joined sender_id through _normalize_signal_id and call
is_approved for each variant, so an approval stored under one form
(phone with/without "+", UUID/ACI) still matches when the next inbound
uses a different form. Mirrors how slack.py:643 handles it.

Also tightens the empty-allowlist warning to only fire when nothing
else granted access, since pairing-store hits are now a valid path.

Not part of the original review, but Comments 2 and 3 turn this latent
gap into a broken round-trip — included so the pairing UX actually
works.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 79c23787f6 fix(signal): join multi-line SSE data with newline per spec
Per the SSE spec, multiple data: lines within a single event must be
joined with \n before parsing. signal-cli emits single-line JSON so
this was latent, but the joining was wrong.

Addresses review comment on PR #3852.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru b647aa5f47 fix(signal): route denied DMs through _handle_message for pairing code
Previously _check_inbound_policy returned (False, chat_id) for DMs
that failed the allowlist and the caller dropped them — so unapproved
DM senders never saw a pairing code. Mirror Slack: when the policy
gate denies a DM but dm.enabled is true, still call
_handle_message(content="", is_dm=True) so BaseChannel can issue the
pairing reply. Group denials stay a hard drop.

Combined with the previous is_dm forwarding, unapproved DM senders
now receive a pairing code through the standard flow.

Addresses review comment on PR #3852.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru a9a8bdcef6 fix(signal): pass is_dm to _handle_message so DM pairing flow runs
BaseChannel._handle_message uses is_dm to decide whether to issue a
pairing code when is_allowed rejects the sender. Without it the base
class treats every denied message as a group message and silently
drops it. Forward is_dm=not is_group_message so unapproved DM users
get a pairing code through the standard flow.

This change only takes effect once denied DMs actually reach
_handle_message (next commit); on its own it is a no-op since the
policy gate still short-circuits before this call.

Addresses review comment on PR #3852.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 2d81cc0ae1 fix(signal): raise on signal-cli error response so send is retriable
_send_http_request collapses every exception path into a {"error": ...}
dict, so the if "error" in response branch inside send() is the only
place where send failures surface. Logging-only there meant the
ChannelManager retry mechanism never fired. Raise RuntimeError so the
base-class retry path is exercised; the outer try/except already
re-raises into the caller.

Addresses review comment on PR #3852.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru aed6b6967c Cleanup 2026-05-20 22:57:49 +08:00
3874b3acf4 fix(signal): normalize composite sender_ids in is_allowed too
The base BaseChannel.is_allowed() does a literal ``sender_id in allow_from``
check, but Signal's sender_id is a pipe-joined composite of phone/UUID
parts. After splitting an allowlist entry like ``+phone|uuid`` into two
separate entries, the per-DM gate accepted it but the base gate still
denied because the composite sender string wasn't literally in the list.

Override is_allowed on SignalChannel to delegate to
_sender_matches_allowlist, which already splits both sides on ``|`` and
normalizes each part. _sender_matches_allowlist itself now also splits
allowlist entries on ``|`` so legacy composite entries keep working too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
01725bab11 test(signal): cover markdown adjacency, nesting, and malformed input
The existing markdown suite was strong on UTF-16 offsets and chunk
redistribution but had no coverage for nested or adjacent styles, no test
that an unmatched opener round-trips as plain text, and no test for the
blockquote/inline-code interaction. Add six cases including the
documented contiguous-BOLD output for `# **wrap** me`, which Signal
renders as one visual span.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
a786e3d225 test(signal): consolidate channel-capture setup into one factory
Two test classes (TestHandleDataMessageDM, TestHandleDataMessageGroup)
plus three TestCommandHandling tests each repeated the same handful of
lines: build a channel, mock _handle_message to record kwargs, replace
_start_typing with a no-op, paper over the assignment with type: ignore.

Hoist the pattern into _make_channel_with_capture and call it from all
five sites. Drops 30+ lines of duplication and 7 type: ignore comments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
626f262121 test(signal): cover SSE receive loop and the empty-phone start guard
Previously the SSE loop and the empty-phone-number short-circuit in start()
had zero coverage. Both now have tests: a fake httpx stream feeds canned
SSE lines, exercising the valid-frame, invalid-JSON, non-200, and
no-http-client paths; start() with an empty phone number is asserted to
return without entering the HTTP loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
7caf492ae2 refactor(signal): split _handle_data_message into policy and assembly helpers
The receive-path handler was ~165 lines deep into nested DM/group policy
checks, buffer mutations, mention stripping, attachment downloads, and
final bus forwarding. Pull the policy gate out into _check_inbound_policy
(returns (allow, chat_id), still appends to the group buffer once allowed)
and the text+media construction into _assemble_inbound_content. The
top-level method now reads as orchestration only.

Add TestCheckInboundPolicy that exercises the helper directly across the
DM/group policy permutations, including the buffer side effect, so the
new seam is locked in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
9aa2ab1657 feat(signal): make signal-cli attachments directory configurable
The inbound attachment loop hardcoded ~/.local/share/signal-cli/attachments
as the source path. That is the daemon's default on Linux but not on macOS
or Windows, and breaks if the daemon was launched with XDG_DATA_HOME set.

Add SignalConfig.attachments_dir as an optional override. When unset the
behavior is unchanged; when set the value is run through Path.expanduser()
so ~ is honored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
971b774282 refactor(signal): wrap top-level receive handler with _safe_handle
Replace the inline try/except at the end of _handle_receive_notification
with a small async context manager that swallows the exception, logs
self.logger.error with the offending payload's repr (bounded to 200 chars),
and attaches the traceback via logger.opt(exception=True).

The previous log line only carried `e`, so diagnosing a bad envelope from
production logs required correlating timestamps. The wrapper is generic so
future receive/dispatch sites can adopt it; for now only this site uses it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
1377759705 fix(signal): normalize identifiers when matching DM allowlist
The DM allowlist check split sender_id on '|' and looked for raw membership
in the allow_from list. Senders carry their phone number with a leading
'+' but admins routinely write allowlist entries without it (or vice
versa), and UUID/ACI matches were case-sensitive. Both forms now flow
through _normalize_signal_id, so an entry like 19995550001 matches a
sender +19995550001 and a UUID matches case-insensitively.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
d56bafa6d0 refactor(signal): hygiene cleanups around constants, typing, and config
- Hoist the cell-strip patterns to module level so they match the rest of
  the module's regex style and aren't reparsed on every call.
- Type the markdown transform callback and the mention id walker so the
  inline Callable signature is no longer an untyped Any.
- Add _HTTP_TIMEOUT_SECONDS alongside the other class-level tunables.
- Reject group_message_buffer_size <= 0 in a Pydantic field_validator
  rather than silently disabling the buffer at write time.
- Mark SignalConfig.allow_from as a computed_field so it shows up in
  model_dump() instead of being invisible to serialization.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
6ec6c9bb83 fix(signal): redistribute textStyle ranges across split message chunks
split_message can break a long Signal payload into multiple JSON-RPC sends,
but the previous code attached the full textStyle list only to chunk 0.
Style ranges in later chunks were dropped, and ranges whose offsets pointed
past chunk 0's end were sent as invalid metadata against chunk 0.

Add _partition_styles, which rebases each range against the chunk it lives
in (in UTF-16 code units, matching the markdown converter) and splits
boundary-spanning ranges across the chunks they touch. Whitespace trimmed
by split_message's lstrip is skipped so offsets stay aligned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
8a2a5eecdd fix(signal): emit textStyle offsets in UTF-16 code units
Signal's BodyRange (via signal-cli's textStyle) interprets start/length as
UTF-16 code units, but the Phase-3 assembly used Python's len(), which counts
code points. A single non-BMP character (e.g. an emoji) earlier in a message
shifted every subsequent styled span left by one unit, dropping the last
letter of bold/italic words.

Track a running UTF-16 offset in the assembly loop and add regression tests
covering emojis, supplementary CJK, ZWJ sequences, and a multi-section
message that mirrors the reported failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 08154b4374 fix(signal): drop duplicate self in unconfigured-account log call
Addresses review feedback on HKUDS/nanobot#3852: self.self.logger.error
would crash if the phone_number guard ever fired.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 880097acd5 feat(signal): add Signal channel support
Integrates signal-cli daemon via HTTP JSON-RPC as a nanobot channel.
Supports DMs and group chats with open/allowlist access policies,
markdown→Signal text style conversion, typing indicators, attachment
handling, group message context buffering, and automatic reconnect
with exponential backoff.

Includes unit tests for channel lifecycle, message routing, mention
detection, markdown conversion, and message splitting.

Originally based on https://github.com/HKUDS/nanobot/pull/601.
2026-05-20 22:57:49 +08:00
chengyongru e02615c93d Merge branch 'main' into nightly 2026-05-18 18:05:29 +08:00
e9259e680e feat(image-generation): add Gemini provider support
Adds GeminiImageGenerationClient covering both Imagen 4 (:predict) and
Gemini Flash (:generateContent), wires the gemini ProviderConfig through
the SDK, API server, and gateway entry points, and updates the
image-generation docs and skill. Errors from the Gemini endpoints are
logged and surface with the HTTP status and parsed message instead of an
empty string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:25:03 +08:00
yaotutuandchengyongru a5b85a3d6b feat: add MiniMax image generation provider support
Add MiniMaxImageGenerationClient with support for:
- Text-to-image generation via MiniMax image-01 model
- Reference image support (subject_reference)
- Aspect ratio selection
- Proper error handling aligned with existing providers

Wire up MiniMax provider config in ImageGenerationTool, gateway,
serve, and Nanobot class.
2026-05-18 15:14:45 +08:00
chengyongru 82c323c2d9 fix(providers): recognize Chinese rate-limit marker '访问量过大' as transient error 2026-05-16 22:06:54 +08:00
103 changed files with 5237 additions and 10358 deletions
+1 -2
View File
@@ -212,7 +212,6 @@ nanobot agent
- Want different LLM providers, web search, MCP, security settings, or more config options? See [Configuration](./docs/configuration.md)
- Want to run locally? Use [Atomic Chat](./docs/configuration.md#atomic-chat-local), [vLLM](./docs/configuration.md#vllm-local-openai-compatible), [Ollama](./docs/configuration.md#ollama-local), and [others](./docs/configuration.md#local-providers).
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
@@ -330,4 +329,4 @@ This project was started by [Xubin Ren](https://github.com/re-bin) as a personal
<p align="center">
<em> Thanks for visiting ✨ nanobot!</em><br><br>
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.nanobot&style=for-the-badge&color=00d4ff" alt="Views">
</p>
</p>
+67
View File
@@ -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>
+4 -74
View File
@@ -134,7 +134,6 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
| `custom` | Any OpenAI-compatible endpoint | — |
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) |
| `skywork` | LLM (Skywork / APIFree API gateway) | [apifree.ai](https://www.apifree.ai) |
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) |
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
@@ -153,7 +152,6 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
| `ant_ling` | LLM (Ant Ling / 蚂蚁百灵) | [developer.ant-ling.com](https://developer.ant-ling.com/en/docs/api-reference/openai/) |
| `ollama` | LLM (local, Ollama) | — |
| `lm_studio` | LLM (local, LM Studio) | — |
| `atomic_chat` | LLM (local, [Atomic Chat](https://atomic.chat/)) | — |
@@ -165,36 +163,6 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
<details>
<summary><b>Skywork / APIFree</b></summary>
Skywork uses the OpenAI-compatible APIFree API endpoint. Configure the provider
once, then use Skywork model IDs such as `skywork-ai/skyclaw-v1`.
```json
{
"providers": {
"skywork": {
"apiKey": "${SKYWORK_API_KEY}",
"apiBase": "https://api.apifree.ai/v1"
}
},
"agents": {
"defaults": {
"provider": "skywork",
"model": "skywork-ai/skyclaw-v1",
"maxTokens": 32768,
"contextWindowTokens": 131072
}
}
}
```
You can also reference `${APIFREE_API_KEY}` in `apiKey` if that is how your
environment names the credential.
</details>
<details>
<summary><b>AWS Bedrock (Converse API)</b></summary>
@@ -476,34 +444,6 @@ Official model names include `LongCat-Flash-Chat`, `LongCat-Flash-Thinking`,
</details>
<details>
<summary><b>Ant Ling (OpenAI-compatible)</b></summary>
Ant Ling is available through nanobot's built-in OpenAI-compatible provider flow.
The default API base points to `https://api.ant-ling.com/v1`, so you usually
only need to set `apiKey`.
```json
{
"providers": {
"antLing": {
"apiKey": "${ANT_LING_API_KEY}"
}
},
"agents": {
"defaults": {
"provider": "ant_ling",
"model": "Ling-2.6-flash"
}
}
}
```
Official OpenAI-compatible model names include `Ling-2.6-1T`,
`Ling-2.6-flash`, `Ling-2.5-1T`, `Ling-1T`, `Ring-2.5-1T`, and `Ring-1T`.
</details>
<details>
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
@@ -572,8 +512,6 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
</details>
<a id="local-providers"></a>
<a id="ollama-local"></a>
<details>
<summary><b>Ollama (local)</b></summary>
@@ -639,19 +577,12 @@ ollama run llama3.2
</details>
<a id="atomic-chat-local"></a>
<details>
<summary><b>Atomic Chat (local)</b></summary>
[Atomic Chat](https://atomic.chat/) is a local-first desktop app that exposes an **OpenAI-compatible** HTTP API (default `http://localhost:1337/v1`). Use it when you want to run nanobot against a model on your own machine instead of a hosted API provider.
[Atomic Chat](https://atomic.chat/) is a local-first desktop app that exposes an **OpenAI-compatible** HTTP API (default `http://localhost:1337/v1`). Start Atomic Chat and enable the local API server, then point nanobot at it.
**1. Start Atomic Chat**
- Install [Atomic Chat](https://atomic.chat/) on your machine.
- Open Atomic Chat, download a model, and keep the app running. The local API is enabled by default.
- Copy the model ID exposed by the local API. For example, the model ID for `Qwen 3 32B` might be `qwen3-32b`.
**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
**1. Add to config** (partial — merge into `~/.nanobot/config.json`):
```json
{
@@ -664,13 +595,13 @@ ollama run llama3.2
"agents": {
"defaults": {
"provider": "atomic_chat",
"model": "qwen3-32b"
"model": "your-model-id-from-atomic-chat"
}
}
}
```
> **Note:** Replace `qwen3-32b` with the model ID from Atomic Chat. Set `apiKey` to `null` if your Atomic Chat server does not require a key. If it does, set `apiKey` (or the `ATOMIC_CHAT_API_KEY` environment variable) to the value Atomic Chat expects.
> **Note:** Set `apiKey` to `null` if your Atomic Chat server does not require a key. If it does, set `apiKey` (or the `ATOMIC_CHAT_API_KEY` environment variable) to the value Atomic Chat expects. The `model` string must match the model id Atomic Chat exposes on its OpenAI-compatible endpoint.
> `provider: "auto"` also works when `providers.atomic_chat.apiBase` is configured, but setting `"provider": "atomic_chat"` is the clearest option.
@@ -751,7 +682,6 @@ docker run -d \
> See the [official OVMS docs](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) for more details.
</details>
<a id="vllm-local-openai-compatible"></a>
<details>
<summary><b>vLLM (local / OpenAI-compatible)</b></summary>
+49 -78
View File
@@ -6,6 +6,8 @@ The feature is disabled by default. Enable it in `~/.nanobot/config.json`, confi
## Quick Setup
OpenRouter example:
```json
{
"providers": {
@@ -17,13 +19,56 @@ The feature is disabled by default. Enable it in `~/.nanobot/config.json`, confi
"imageGeneration": {
"enabled": true,
"provider": "openrouter",
"model": "openai/gpt-5.4-image-2"
"model": "openai/gpt-5.4-image-2",
"defaultAspectRatio": "1:1",
"defaultImageSize": "1K"
}
}
}
```
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, and Gemini configuration examples.
AIHubMix example:
```json
{
"providers": {
"aihubmix": {
"apiKey": "${AIHUBMIX_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "aihubmix",
"model": "gpt-image-2-free",
"defaultAspectRatio": "1:1",
"defaultImageSize": "1K"
}
}
}
```
Gemini example (Imagen 4):
```json
{
"providers": {
"gemini": {
"apiKey": "${GEMINI_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "gemini",
"model": "imagen-4.0-generate-001",
"defaultAspectRatio": "1:1"
}
}
}
```
For Gemini Flash (which supports reference-image edits) see the [Gemini](#gemini) section below.
> [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
@@ -46,7 +91,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`, `gemini` |
| `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` |
@@ -116,28 +161,6 @@ Configure:
`quality: low` is optional. It can make free image models faster and less likely to time out, but it is not required for correctness.
### MiniMax
MiniMax `image-01` supports text-to-image and reference-image (subject reference) edits. Supported aspect ratios are `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, and `21:9`.
```json
{
"providers": {
"minimax": {
"apiKey": "${MINIMAX_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "minimax",
"model": "image-01",
"defaultAspectRatio": "1:1"
}
}
}
```
### Gemini
nanobot supports two Gemini image generation model families via Google's Generative Language API:
@@ -168,58 +191,6 @@ 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).
### 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.
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes are specified as `WIDTHxHEIGHT` (e.g. `1024x1024`, `1280x800`, `800x1280`).
```json
{
"providers": {
"stepfun": {
"apiKey": "${STEPFUN_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "stepfun",
"model": "step-image-edit-2"
}
}
}
```
> [!NOTE]
> The StepFun provider reuses the existing `providers.stepfun` config block (the same one used for StepFun's LLM API). Set `providers.stepfun.apiKey` once and it is shared between text and image generation.
>
> When `step-image-edit-2` is used, `reference_images` are ignored (the model does not support style reference). Switch to `step-1x-medium` to use reference-image-guided generation.
#### StepPlan (Subscription)
StepPlan is StepFun's subscription tier and uses a different API base URL. The image generation endpoint path is the same — just override `apiBase`:
```json
{
"providers": {
"stepfun": {
"apiKey": "${STEPFUN_API_KEY}",
"apiBase": "https://api.stepfun.com/step_plan/v1"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "stepfun",
"model": "step-image-edit-2"
}
}
}
```
`apiBase` takes precedence over the registry default, so with the StepPlan base URL configured, image requests are sent to `https://api.stepfun.com/step_plan/v1/images/generations` — the same path prefix used for LLM calls. The API key is shared with the standard StepFun provider.
## Artifacts
Generated images are stored under the active nanobot instance's media directory:
@@ -274,7 +245,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`, or `gemini` |
| 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 |
+17 -197
View File
@@ -2,7 +2,6 @@
import base64
import mimetypes
import os
import platform
from contextlib import suppress
from importlib.resources import files as pkg_files
@@ -11,16 +10,11 @@ from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
from nanobot.config.schema import InputLimitsConfig
from nanobot.session.goal_state import goal_state_runtime_lines
from nanobot.utils.helpers import (
audio_format_for_api,
audio_mime_compat,
current_time_str,
detect_audio_mime,
detect_image_mime,
truncate_text,
video_mime_compat,
)
from nanobot.utils.prompt_templates import render_template
@@ -34,12 +28,11 @@ class ContextBuilder:
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None, input_limits: InputLimitsConfig | None = None):
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
self.workspace = workspace
self.timezone = timezone
self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None)
self.input_limits = input_limits or InputLimitsConfig()
def build_system_prompt(
self,
@@ -149,28 +142,6 @@ class ContextBuilder:
return content.strip() == tpl.read_text(encoding="utf-8").strip()
return False
@staticmethod
def _file_size_ok(p: Path, max_bytes: int) -> bool | None:
"""Check file size via stat without reading into memory.
Returns True if size is within limit, False if oversized,
None if file cannot be stat'd (caller should try read_bytes instead).
"""
try:
return os.stat(p).st_size <= max_bytes
except OSError:
return None
@staticmethod
def _encode_image_block(raw: bytes, mime: str, path: Path) -> dict[str, Any]:
"""Base64-encode file bytes into an image_url content block."""
b64 = base64.b64encode(raw).decode()
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(path)},
}
def build_messages(
self,
history: list[dict[str, Any]],
@@ -183,9 +154,6 @@ class ContextBuilder:
sender_id: str | None = None,
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
supports_vision: bool | None = None,
supports_audio: bool | None = None,
supports_video: bool | None = None,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
extra = goal_state_runtime_lines(session_metadata)
@@ -196,12 +164,7 @@ class ContextBuilder:
sender_id=sender_id,
supplemental_lines=extra or None,
)
user_content = self._build_user_content(
current_message, media,
supports_vision=supports_vision,
supports_audio=supports_audio,
supports_video=supports_video,
)
user_content = self._build_user_content(current_message, media)
# Merge runtime context and user content into a single user message
# to avoid consecutive same-role messages that some providers reject.
@@ -223,171 +186,28 @@ class ContextBuilder:
messages.append({"role": current_role, "content": merged})
return messages
def _build_user_content(
self,
text: str,
media: list[str] | None,
*,
supports_vision: bool | None = None,
supports_audio: bool | None = None,
supports_video: bool | None = None,
) -> str | list[dict[str, Any]]:
"""Build user message content with optional media blocks.
Args:
text: The user text message.
media: List of file paths to media files.
supports_vision: True=model supports images, False=use placeholder,
None=unconfigured (send images as before, let
provider/retry handle degradation).
supports_audio: True=model supports native audio, False/None=skip
(channel layer already transcribed).
supports_video: True=model supports native video, False/None=use
[file: path] placeholder.
"""
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
"""Build user message content with optional base64-encoded images."""
if not media:
return text
blocks: list[dict[str, Any]] = []
notes: list[str] = []
limits = self.input_limits
# Enforce image count limit
max_images = limits.max_input_images
image_count = 0
image_media = []
non_image_media = []
images = []
for path in media:
p = Path(path)
guessed_mime = mimetypes.guess_type(path)[0] or ""
if guessed_mime.startswith("image/"):
image_count += 1
if image_count <= max_images:
image_media.append(path)
else:
non_image_media.append(path)
if image_count > max_images:
extra = image_count - max_images
noun = "image" if extra == 1 else "images"
notes.append(
f"[Skipped {extra} {noun}: "
f"only the first {max_images} images are included]"
)
# Process images
for path in image_media:
p = Path(path)
if not p.is_file():
continue
# When explicitly marked as non-vision, downgrade to text placeholder
if supports_vision is False:
blocks.append({"type": "text", "text": f"[image: {p}]"})
raw = p.read_bytes()
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if not mime or not mime.startswith("image/"):
continue
b64 = base64.b64encode(raw).decode()
images.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
size_ok = self._file_size_ok(p, limits.max_input_image_bytes)
if size_ok is False:
size_mb = limits.max_input_image_bytes // (1024 * 1024)
notes.append(f"[Skipped image: file too large ({p.name}, limit {size_mb} MB)]")
continue
try:
raw = p.read_bytes()
except OSError:
notes.append(f"[Skipped image: unable to read ({p.name or path})]")
continue
img_mime = detect_image_mime(raw[:32]) or mimetypes.guess_type(path)[0]
if not img_mime or not img_mime.startswith("image/"):
notes.append(f"[Skipped image: unsupported or invalid image format ({p.name})]")
continue
blocks.append(self._encode_image_block(raw, img_mime, p))
# Process non-image media (audio, video, unknown)
audio_count = 0
video_count = 0
for path in non_image_media:
p = Path(path)
if not p.is_file():
continue
guessed_mime = mimetypes.guess_type(path)[0] or ""
is_audio = guessed_mime.startswith("audio/")
is_video = guessed_mime.startswith("video/")
# Pre-check file size via stat to avoid reading oversized files into memory.
# Determine the relevant byte limit based on detected media type.
_size_limit = 0
if is_audio or is_video:
_size_limit = limits.max_input_audio_bytes if is_audio else limits.max_input_video_bytes
_stat_size_ok = self._file_size_ok(p, _size_limit) if _size_limit else None
if _stat_size_ok is False:
size_mb = _size_limit // (1024 * 1024)
label = "audio" if is_audio else "video"
notes.append(f"[Skipped {label}: file too large ({p.name}, limit {size_mb} MB)]")
continue
try:
raw = p.read_bytes()
except OSError:
notes.append(f"[Skipped file: unable to read ({p.name or path})]")
continue
# Audio detection: by magic bytes or by filename
# Always pass filename so fallback can match when magic bytes fail
audio_mime = detect_audio_mime(raw[:32], filename=path)
if audio_mime or is_audio:
if supports_audio is True and audio_mime_compat(audio_mime):
audio_count += 1
if audio_count > limits.max_input_audios:
if audio_count == limits.max_input_audios + 1:
notes.append(
f"[Skipped audio: only {limits.max_input_audios} audio file(s) allowed]"
)
continue
if len(raw) > limits.max_input_audio_bytes:
size_mb = limits.max_input_audio_bytes // (1024 * 1024)
notes.append(f"[Skipped audio: file too large ({p.name}, limit {size_mb} MB)]")
continue
b64 = base64.b64encode(raw).decode()
blocks.append({
"type": "input_audio",
"input_audio": {"data": b64, "format": audio_format_for_api(audio_mime)},
"_meta": {"path": str(p)},
})
else:
blocks.append({"type": "text", "text": f"[audio: {p}]"})
continue
# Video detection (already classified above)
if is_video:
if supports_video is True and video_mime_compat(guessed_mime):
video_count += 1
if video_count > limits.max_input_videos:
if video_count == limits.max_input_videos + 1:
notes.append(
f"[Skipped video: only {limits.max_input_videos} video file(s) allowed]"
)
continue
if len(raw) > limits.max_input_video_bytes:
size_mb = limits.max_input_video_bytes // (1024 * 1024)
notes.append(f"[Skipped video: file too large ({p.name}, limit {size_mb} MB)]")
continue
b64 = base64.b64encode(raw).decode()
blocks.append({
"type": "video_url",
"video_url": {"url": f"data:{guessed_mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
else:
blocks.append({"type": "text", "text": f"[video: {p}]"})
continue
# Unknown files are silently ignored (preserves pre-multimodal behaviour)
continue
note_text = "\n".join(notes).strip()
text_block = text if not note_text else (f"{note_text}\n\n{text}" if text else note_text)
if not blocks:
return text_block
return blocks + [{"type": "text", "text": text_block}]
if not images:
return text
return images + [{"type": "text", "text": text}]
+17 -28
View File
@@ -36,17 +36,19 @@ from nanobot.session.goal_state import (
runner_wall_llm_timeout_s,
)
from nanobot.session.manager import Session, SessionManager
from nanobot.session.webui_turns import (
WebuiTurnCoordinator,
build_bus_progress_callback,
mark_webui_session,
)
from nanobot.utils.artifacts import generated_image_paths_from_messages
from nanobot.utils.document import extract_documents
from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
from nanobot.utils.webui_turn_helpers import (
WebuiTurnCoordinator,
build_bus_progress_callback,
mark_webui_session,
)
if TYPE_CHECKING:
from nanobot.config.schema import (
@@ -101,6 +103,7 @@ class TurnContext:
save_skip: int = 0
outbound: OutboundMessage | None = None
generated_media: list[str] = field(default_factory=list)
on_progress: Callable[..., Awaitable[None]] | None = None
on_stream: Callable[[str], Awaitable[None]] | None = None
@@ -190,10 +193,6 @@ class AgentLoop:
model_preset: str | None = None,
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
input_limits: Any = None,
supports_vision: bool | None = None,
supports_audio: bool | None = None,
supports_video: bool | None = None,
):
from nanobot.config.schema import ToolsConfig
@@ -231,10 +230,6 @@ class AgentLoop:
self.tools_config = _tc
self.web_config = _tc.web
self.exec_config = _tc.exec
self.input_limits = input_limits or _tc.input_limits
self._supports_vision = supports_vision
self._supports_audio = supports_audio
self._supports_video = supports_video
self._image_generation_provider_configs = dict(image_generation_provider_configs or {})
if (
image_generation_provider_config is not None
@@ -248,7 +243,7 @@ class AgentLoop:
self._pending_turn_latency_ms: dict[str, int] = {}
self._extra_hooks: list[AgentHook] = hooks or []
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills, input_limits=self.input_limits)
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace)
self._webui_turns = WebuiTurnCoordinator(
bus=self.bus,
@@ -374,10 +369,6 @@ class AgentLoop:
model_preset=defaults.model_preset,
provider_snapshot_loader=provider_snapshot_loader,
preset_snapshot_loader=preset_snapshot_loader,
input_limits=config.tools.input_limits,
supports_vision=defaults.supports_vision(defaults.model),
supports_audio=defaults.supports_audio(defaults.model),
supports_video=defaults.supports_video(defaults.model),
**extra,
)
@@ -606,9 +597,6 @@ class AgentLoop:
sender_id=msg.sender_id,
session_summary=pending_summary,
session_metadata=session.metadata,
supports_vision=self._supports_vision,
supports_audio=self._supports_audio,
supports_video=self._supports_video,
)
async def _dispatch_command_inline(
@@ -1074,9 +1062,6 @@ class AgentLoop:
sender_id=msg.sender_id,
session_summary=pending,
session_metadata=session.metadata,
supports_vision=self._supports_vision,
supports_audio=self._supports_audio,
supports_video=self._supports_video,
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
@@ -1209,6 +1194,7 @@ class AgentLoop:
all_msgs: list[dict[str, Any]],
stop_reason: str,
had_injections: bool,
generated_media: list[str],
on_stream: Callable[[str], Awaitable[None]] | None,
*,
turn_latency_ms: int | None = None,
@@ -1232,6 +1218,7 @@ class AgentLoop:
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
media=generated_media,
metadata=meta,
)
@@ -1361,6 +1348,11 @@ class AgentLoop:
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0)
skip_msgs = ctx.all_messages[ctx.save_skip:]
ctx.generated_media = generated_image_paths_from_messages(skip_msgs)
mt = self.tools.get("message")
extra = getattr(mt, "turn_delivered_media_paths", lambda: [])() if mt else []
merge_turn_media_into_last_assistant(ctx.all_messages, ctx.generated_media, extra)
ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000))
self._save_turn(
@@ -1388,6 +1380,7 @@ class AgentLoop:
ctx.all_messages,
ctx.stop_reason,
ctx.had_injections,
ctx.generated_media,
ctx.on_stream,
turn_latency_ms=ctx.turn_latency_ms,
)
@@ -1422,10 +1415,6 @@ class AgentLoop:
filtered.append({"type": "text", "text": image_placeholder_text(path)})
continue
if block.get("type") in ("input_audio", "video_url"):
filtered.append(LLMProvider._media_placeholder(block["type"], block))
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
text = block["text"]
if should_truncate_text and len(text) > self.max_tool_result_chars:
+1 -33
View File
@@ -20,7 +20,6 @@ from nanobot.utils.file_edit_events import (
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_tracker,
StreamingFileEditTracker,
)
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
@@ -630,24 +629,6 @@ class AgentRunner:
)
progress_state: dict[str, bool] | None = None
live_file_edits: StreamingFileEditTracker | None = None
if (
spec.progress_callback is not None
and on_progress_accepts_file_edit_events(spec.progress_callback)
):
async def _emit_live_file_edits(events: list[dict[str, Any]]) -> None:
await invoke_file_edit_progress(spec.progress_callback, events)
live_file_edits = StreamingFileEditTracker(
workspace=spec.workspace,
tools=spec.tools,
emit=_emit_live_file_edits,
)
async def _tool_call_delta(delta: dict[str, Any]) -> None:
if live_file_edits is not None:
await live_file_edits.update(delta)
if wants_streaming:
async def _stream(delta: str) -> None:
@@ -665,7 +646,6 @@ class AgentRunner:
**kwargs,
on_content_delta=_stream,
on_thinking_delta=_thinking,
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
)
elif wants_progress_streaming:
stream_buf = ""
@@ -695,7 +675,6 @@ class AgentRunner:
coro = self.provider.chat_stream_with_retry(
**kwargs,
on_content_delta=_stream_progress,
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
)
else:
coro = self.provider.chat_with_retry(**kwargs)
@@ -710,14 +689,6 @@ class AgentRunner:
await coro if outer_timeout_s is None
else await asyncio.wait_for(coro, timeout=outer_timeout_s)
)
if live_file_edits is not None:
await live_file_edits.flush()
if response.should_execute_tools:
live_file_edits.apply_final_call_ids(response.tool_calls)
await live_file_edits.error_unmatched(
response.tool_calls if response.should_execute_tools else [],
"Tool call did not complete.",
)
except asyncio.TimeoutError:
if outer_timeout_s is None:
return LLMResponse(
@@ -936,10 +907,7 @@ class AgentRunner:
if file_edit_tracker is not None 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,
)],
[build_file_edit_end_event(file_edit_tracker)],
)
detail = "" if result is None else str(result)
+26 -11
View File
@@ -17,9 +17,11 @@ from nanobot.agent.tools.schema import (
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.providers.image_generation import (
AIHubMixImageGenerationClient,
GeminiImageGenerationClient,
ImageGenerationError,
ImageGenerationProvider,
get_image_gen_provider,
MiniMaxImageGenerationClient,
OpenRouterImageGenerationClient,
)
from nanobot.utils.artifacts import (
ArtifactError,
@@ -117,24 +119,37 @@ class ImageGenerationTool(Tool):
def _provider_config(self) -> ProviderConfig | None:
return self.provider_configs.get(self.config.provider)
def _provider_client(self) -> ImageGenerationProvider | None:
def _provider_client(
self,
) -> OpenRouterImageGenerationClient | AIHubMixImageGenerationClient | MiniMaxImageGenerationClient | GeminiImageGenerationClient | None:
provider = self._provider_config()
cls = get_image_gen_provider(self.config.provider)
if cls is None:
return None
kwargs = {
"api_key": provider.api_key if provider else None,
"api_base": provider.api_base if provider else None,
"extra_headers": provider.extra_headers if provider else None,
"extra_body": provider.extra_body if provider else None,
}
return cls(**kwargs)
if self.config.provider == "openrouter":
return OpenRouterImageGenerationClient(**kwargs)
if self.config.provider == "aihubmix":
return AIHubMixImageGenerationClient(**kwargs)
if self.config.provider == "minimax":
return MiniMaxImageGenerationClient(**kwargs)
if self.config.provider == "gemini":
return GeminiImageGenerationClient(**kwargs)
return None
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."
provider = self.config.provider
if provider == "openrouter":
return "Error: OpenRouter API key is not configured. Set providers.openrouter.apiKey."
if provider == "aihubmix":
return "Error: AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
if provider == "minimax":
return "Error: MiniMax API key is not configured. Set providers.minimax.apiKey."
if provider == "gemini":
return "Error: Gemini API key is not configured. Set providers.gemini.apiKey."
return f"Error: {provider} API key is not configured."
def _resolve_reference_image(self, value: str) -> str:
raw_path = Path(value).expanduser()
+4 -4
View File
@@ -31,8 +31,8 @@ from nanobot.config.paths import get_workspace_path
media=ArraySchema(
StringSchema(""),
description=(
"Optional list of existing file paths to attach. "
"Use artifact paths returned by generate_image here when delivering generated images."
"Optional list of existing file paths to attach for proactive or cross-channel delivery. "
"Do not use this to resend generate_image outputs in the current chat."
),
),
buttons=ArraySchema(
@@ -140,8 +140,8 @@ class MessageTool(Tool, ContextAware):
"Do not use this for the normal reply in the current chat: answer naturally instead. "
"If channel/chat_id would target the current runtime conversation, do not call this tool "
"unless the user explicitly asked you to proactively send an existing file attachment. "
"When generate_image creates images in the current chat, use the message tool "
"with the artifact paths in the media parameter to deliver the images to the user. "
"When generate_image creates images in the current chat, the final assistant reply "
"automatically attaches them; do not call message just to announce or resend them. "
"For proactive attachment delivery, use the 'media' parameter with file paths. "
"Do NOT use read_file to send files — that only reads content for your own analysis."
)
+28 -32
View File
@@ -172,22 +172,19 @@ def _extract_element_content(element: dict) -> list[str]:
return parts
def _extract_post_content(content_json: dict) -> tuple[str, list[str], list[dict]]:
"""Extract text and media info from Feishu post (rich text) message.
def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
"""Extract text and image keys from Feishu post (rich text) message.
Handles three payload shapes:
- Direct: {"title": "...", "content": [[...]]}
- Localized: {"zh_cn": {"title": "...", "content": [...]}}
- Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}}
Returns (text, image_keys, media_items) where media_items is a list of
{"tag": "media", "file_key": "..."} dicts for video/file attachments.
"""
def _parse_block(block: dict) -> tuple[str | None, list[str], list[dict]]:
def _parse_block(block: dict) -> tuple[str | None, list[str]]:
if not isinstance(block, dict) or not isinstance(block.get("content"), list):
return None, [], []
texts, images, medias = [], [], []
return None, []
texts, images = [], []
if title := block.get("title"):
texts.append(title)
for row in block["content"]:
@@ -207,36 +204,43 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str], list[dict
texts.append(f"\n```{lang}\n{code_text}\n```\n")
elif tag == "img" and (key := el.get("image_key")):
images.append(key)
elif tag == "media" and el.get("file_key"):
medias.append({"tag": "media", "file_key": el["file_key"]})
return (" ".join(texts).strip() or None), images, medias
return (" ".join(texts).strip() or None), images
# Unwrap optional {"post": ...} envelope
root = content_json
if isinstance(root, dict) and isinstance(root.get("post"), dict):
root = root["post"]
if not isinstance(root, dict):
return "", [], []
return "", []
# Direct format
if "content" in root:
text, imgs, medias = _parse_block(root)
if text or imgs or medias:
return text or "", imgs, medias
text, imgs = _parse_block(root)
if text or imgs:
return text or "", imgs
# Localized: prefer known locales, then fall back to any dict child
for key in ("zh_cn", "en_us", "ja_jp"):
if key in root:
text, imgs, medias = _parse_block(root[key])
if text or imgs or medias:
return text or "", imgs, medias
text, imgs = _parse_block(root[key])
if text or imgs:
return text or "", imgs
for val in root.values():
if isinstance(val, dict):
text, imgs, medias = _parse_block(val)
if text or imgs or medias:
return text or "", imgs, medias
text, imgs = _parse_block(val)
if text or imgs:
return text or "", imgs
return "", [], []
return "", []
def _extract_post_text(content_json: dict) -> str:
"""Extract plain text from Feishu post (rich text) message content.
Legacy wrapper for _extract_post_content, returns only text.
"""
text, _ = _extract_post_content(content_json)
return text
class FeishuConfig(Base):
@@ -1152,7 +1156,7 @@ class FeishuChannel(BaseChannel):
if msg_type == "text":
text = content_json.get("text", "").strip()
elif msg_type == "post":
text, _, _ = _extract_post_content(content_json)
text, _ = _extract_post_content(content_json)
text = text.strip()
else:
text = ""
@@ -1747,7 +1751,7 @@ class FeishuChannel(BaseChannel):
content_parts.append(text)
elif msg_type == "post":
text, image_keys, media_items = _extract_post_content(content_json)
text, image_keys = _extract_post_content(content_json)
if text:
content_parts.append(text)
# Download images embedded in post
@@ -1758,14 +1762,6 @@ class FeishuChannel(BaseChannel):
if file_path:
media_paths.append(file_path)
content_parts.append(content_text)
# Download media (video/file) embedded in post
for media_item in media_items:
file_path, content_text = await self._download_and_save_media(
"media", media_item, message_id
)
if file_path:
media_paths.append(file_path)
content_parts.append(content_text)
elif msg_type in ("image", "audio", "file", "media"):
file_path, content_text = await self._download_and_save_media(
File diff suppressed because it is too large Load Diff
+233 -108
View File
@@ -37,27 +37,15 @@ from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir
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
from nanobot.utils.helpers import safe_filename
from nanobot.utils.media_decode import (
FileSizeExceeded,
save_base64_data_url,
)
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.settings_api import (
WebUISettingsError,
settings_payload,
update_agent_settings,
update_image_generation_settings,
update_provider_settings,
update_web_search_settings,
)
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
)
from nanobot.webui.thread_disk import delete_webui_thread
from nanobot.webui.transcript import append_transcript_object, build_webui_thread_response
from nanobot.utils.webui_thread_disk import delete_webui_thread
from nanobot.utils.webui_transcript import append_transcript_object, build_webui_thread_response
from nanobot.utils.webui_turn_helpers import websocket_turn_wall_started_at
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -234,6 +222,47 @@ def _query_first(query: dict[str, list[str]], key: str) -> str | None:
return values[0] if values else None
def _mask_secret_hint(secret: str | None) -> str | None:
if not secret:
return None
if len(secret) <= 8:
return "••••"
return f"{secret[:4]}••••{secret[-4:]}"
def _provider_requires_api_key(spec: Any) -> bool:
if spec.backend == "azure_openai":
return True
if spec.is_local or spec.is_direct:
return False
return True
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if _provider_requires_api_key(spec):
return bool(provider_config.api_key)
return bool(
provider_config.api_key
or provider_config.api_base
or getattr(provider_config, "region", None)
or getattr(provider_config, "profile", None)
)
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
{"name": "jina", "label": "Jina", "credential": "api_key"},
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
)
_WEB_SEARCH_PROVIDER_BY_NAME = {
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
}
def _parse_inbound_payload(raw: str) -> str | None:
"""Parse a client frame into text; return None for empty or unrecognized content."""
text = raw.strip()
@@ -472,7 +501,6 @@ class WebSocketChannel(BaseChannel):
static_dist_path.resolve() if static_dist_path is not None else None
)
self._runtime_model_name = runtime_model_name
self._settings_restart_sections: set[str] = set()
# 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
@@ -635,12 +663,6 @@ class WebSocketChannel(BaseChannel):
if got == "/api/commands":
return self._handle_commands(request)
if got == "/api/webui/sidebar-state":
return self._handle_webui_sidebar_state(request)
if got == "/api/webui/sidebar-state/update":
return self._handle_webui_sidebar_state_update(request)
if got == "/api/settings/update":
return self._handle_settings_update(request)
@@ -650,9 +672,6 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if got == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
@@ -764,115 +783,221 @@ class WebSocketChannel(BaseChannel):
sessions = self._session_manager.list_sessions()
# Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc.
# keys are not intended for resume over this HTTP surface.
cleaned = []
for s in sessions:
key = s.get("key")
if not (isinstance(key, str) and key.startswith("websocket:")):
continue
row = {k: v for k, v in s.items() if k != "path"}
chat_id = key.split(":", 1)[1]
started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None:
row["run_started_at"] = started_at
cleaned.append(row)
cleaned = [
{k: v for k, v in s.items() if k != "path"}
for s in sessions
if isinstance(s.get("key"), str) and s["key"].startswith("websocket:")
]
return _http_json_response({"sessions": cleaned})
def _settings_payload(self, *, requires_restart: bool = False) -> dict[str, Any]:
from nanobot.config.loader import get_config_path, load_config
from nanobot.providers.registry import PROVIDERS, find_by_name
config = load_config()
defaults = config.agents.defaults
provider_name = config.get_provider_name(defaults.model) or defaults.provider
provider = config.get_provider(defaults.model)
selected_provider = provider_name
if defaults.provider != "auto":
spec = find_by_name(defaults.provider)
selected_provider = spec.name if spec else provider_name
providers = []
for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth:
continue
providers.append(
{
"name": spec.name,
"label": spec.label,
"configured": _provider_configured_for_settings(spec, provider_config),
"api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
}
)
search_config = config.tools.web.search
search_provider = (
search_config.provider
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
else "duckduckgo"
)
return {
"agent": {
"model": defaults.model,
"provider": selected_provider,
"resolved_provider": provider_name,
"has_api_key": bool(provider and provider.api_key),
},
"providers": providers,
"web_search": {
"provider": search_provider,
"api_key_hint": _mask_secret_hint(search_config.api_key),
"base_url": search_config.base_url or None,
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
},
"runtime": {
"config_path": str(get_config_path().expanduser()),
},
"requires_restart": requires_restart,
}
def _handle_settings(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(self._with_settings_restart_state(settings_payload()))
def _with_settings_restart_state(
self,
payload: dict[str, Any],
*,
section: str | None = None,
) -> dict[str, Any]:
"""Keep restart-required state alive for this gateway process."""
if section and payload.get("requires_restart"):
self._settings_restart_sections.add(section)
if self._settings_restart_sections:
payload = dict(payload)
payload["requires_restart"] = True
payload["restart_required_sections"] = sorted(self._settings_restart_sections)
else:
payload = dict(payload)
payload["restart_required_sections"] = []
return payload
return _http_json_response(self._settings_payload())
def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response({"commands": builtin_command_palette()})
def _handle_webui_sidebar_state(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(read_webui_sidebar_state())
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
raw_state = _query_first(query, "state")
if raw_state is None:
return _http_error(400, "missing state")
try:
decoded = json.loads(raw_state)
except json.JSONDecodeError:
return _http_error(400, "state must be JSON")
if not isinstance(decoded, dict):
return _http_error(400, "state must be an object")
try:
state = write_webui_sidebar_state(decoded)
except ValueError as e:
return _http_error(400, str(e))
except OSError:
self.logger.exception("failed to write webui sidebar state")
return _http_error(500, "failed to write sidebar state")
return _http_json_response(state)
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
from nanobot.config.loader import load_config, save_config
from nanobot.providers.registry import find_by_name
query = _parse_query(request.path)
try:
payload = update_agent_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(
self._with_settings_restart_state(payload, section="runtime")
)
config = load_config()
defaults = config.agents.defaults
changed = False
model = _query_first(query, "model")
if model is not None:
model = model.strip()
if not model:
return _http_error(400, "model is required")
if defaults.model != model:
defaults.model = model
changed = True
provider = _query_first(query, "provider")
if provider is not None:
provider = provider.strip()
if not provider:
return _http_error(400, "provider is required")
if find_by_name(provider) is None:
return _http_error(400, "unknown provider")
provider_config = getattr(config.providers, provider, None)
spec = find_by_name(provider)
if (
provider_config is None
or spec is None
or not _provider_configured_for_settings(spec, provider_config)
):
return _http_error(400, "provider is not configured")
if defaults.provider != provider:
defaults.provider = provider
changed = True
if changed:
save_config(config)
# LLM provider/model changes are hot-reloaded by AgentLoop before each
# new turn via the provider snapshot loader, so a restart is unnecessary.
return _http_json_response(self._settings_payload(requires_restart=False))
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
from nanobot.config.loader import load_config, save_config
from nanobot.providers.registry import find_by_name
query = _parse_query(request.path)
try:
payload = update_provider_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
return _http_error(400, "provider is required")
spec = find_by_name(provider_name)
if spec is None or spec.is_oauth:
return _http_error(400, "unknown provider")
config = load_config()
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None:
return _http_error(400, "unknown provider")
changed = False
if "api_key" in query or "apiKey" in query:
api_key = _query_first(query, "api_key")
if api_key is None:
api_key = _query_first(query, "apiKey")
api_key = (api_key or "").strip() or None
if provider_config.api_key != api_key:
provider_config.api_key = api_key
changed = True
if "api_base" in query or "apiBase" in query:
api_base = _query_first(query, "api_base")
if api_base is None:
api_base = _query_first(query, "apiBase")
api_base = (api_base or "").strip() or None
if provider_config.api_base != api_base:
provider_config.api_base = api_base
changed = True
if changed:
save_config(config)
# API key/base changes are picked up by the next provider snapshot refresh.
return _http_json_response(self._settings_payload(requires_restart=False))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_web_search_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="web"))
from nanobot.config.loader import load_config, save_config
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_image_generation_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
provider_name = (_query_first(query, "provider") or "").strip().lower()
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
if provider_option is None:
return _http_error(400, "unknown web search provider")
config = load_config()
search_config = config.tools.web.search
previous_provider = search_config.provider
changed = False
def set_value(attr: str, value: str | None) -> None:
nonlocal changed
if getattr(search_config, attr) != value:
setattr(search_config, attr, value)
changed = True
if search_config.provider != provider_name:
search_config.provider = provider_name
changed = True
credential = provider_option["credential"]
if credential == "none":
set_value("api_key", "")
set_value("base_url", "")
elif credential == "base_url":
base_url = _query_first(query, "base_url")
if base_url is None:
base_url = _query_first(query, "baseUrl")
base_url = base_url.strip() if base_url is not None else None
if not base_url and previous_provider == provider_name and search_config.base_url:
base_url = search_config.base_url
if not base_url:
return _http_error(400, "base_url is required")
set_value("base_url", base_url)
set_value("api_key", "")
else:
api_key = _query_first(query, "api_key")
if api_key is None:
api_key = _query_first(query, "apiKey")
api_key = api_key.strip() if api_key is not None else None
if not api_key and previous_provider == provider_name and search_config.api_key:
api_key = search_config.api_key
if not api_key:
return _http_error(400, "api_key is required")
set_value("api_key", api_key)
set_value("base_url", "")
if changed:
save_config(config)
return _http_json_response(self._settings_payload(requires_restart=False))
@staticmethod
def _is_websocket_channel_session_key(key: str) -> bool:
+12 -6
View File
@@ -620,7 +620,6 @@ def serve(
from nanobot.api.server import create_app
from nanobot.bus.queue import MessageBus
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
if verbose:
@@ -640,7 +639,12 @@ def serve(
agent_loop = AgentLoop.from_config(
runtime_config, bus,
session_manager=session_manager,
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
image_generation_provider_configs={
"openrouter": runtime_config.providers.openrouter,
"aihubmix": runtime_config.providers.aihubmix,
"minimax": runtime_config.providers.minimax,
"gemini": runtime_config.providers.gemini,
},
)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
@@ -720,7 +724,6 @@ def _run_gateway(
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
port = port if port is not None else config.gateway.port
@@ -751,7 +754,12 @@ def _run_gateway(
context_window_tokens=provider_snapshot.context_window_tokens,
cron_service=cron,
session_manager=session_manager,
image_generation_provider_configs=image_gen_provider_configs(config),
image_generation_provider_configs={
"openrouter": config.providers.openrouter,
"aihubmix": config.providers.aihubmix,
"minimax": config.providers.minimax,
"gemini": config.providers.gemini,
},
provider_snapshot_loader=load_provider_snapshot,
runtime_model_publisher=lambda model, preset: publish_runtime_model_update(
bus,
@@ -1118,7 +1126,6 @@ def agent(
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.providers.image_generation import image_gen_provider_configs
config = _load_runtime_config(config, workspace)
sync_workspace_templates(config.workspace_path)
@@ -1142,7 +1149,6 @@ def agent(
agent_loop = AgentLoop.from_config(
config, bus,
cron_service=cron,
image_generation_provider_configs=image_gen_provider_configs(config),
)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
-41
View File
@@ -155,35 +155,8 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("consolidationRatio"),
serialization_alias="consolidationRatio",
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
vision_models: list[str] = Field(default_factory=list) # Models that support image input
audio_models: list[str] = Field(default_factory=list) # Models that support native audio input
video_models: list[str] = Field(default_factory=list) # Models that support native video input
dream: DreamConfig = Field(default_factory=DreamConfig)
@staticmethod
def _bare_model(model: str) -> str:
"""Strip provider prefix, e.g. 'openai/gpt-4o' -> 'gpt-4o'."""
return model.split("/", 1)[-1].lower() if "/" in model else model.lower()
def _supports_capability(self, model: str, patterns: list[str]) -> bool | None:
"""Check if model matches any pattern. Returns None if patterns is empty."""
if not patterns:
return None
bare = self._bare_model(model)
return any(p.lower() in bare for p in patterns)
def supports_vision(self, model: str) -> bool | None:
"""Check if model supports vision. None if unconfigured."""
return self._supports_capability(model, self.vision_models)
def supports_audio(self, model: str) -> bool | None:
"""Check if model supports native audio. None if unconfigured."""
return self._supports_capability(model, self.audio_models)
def supports_video(self, model: str) -> bool | None:
"""Check if model supports native video. None if unconfigured."""
return self._supports_capability(model, self.video_models)
class AgentsConfig(Base):
"""Agent configuration."""
@@ -217,7 +190,6 @@ class ProvidersConfig(Base):
openai: ProviderConfig = Field(default_factory=ProviderConfig)
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
huggingface: ProviderConfig = Field(default_factory=ProviderConfig)
skywork: ProviderConfig = Field(default_factory=ProviderConfig) # Skywork / APIFree API gateway
deepseek: ProviderConfig = Field(default_factory=ProviderConfig)
groq: ProviderConfig = Field(default_factory=ProviderConfig)
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
@@ -235,7 +207,6 @@ class ProvidersConfig(Base):
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat
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 (硅基流动)
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
@@ -285,17 +256,6 @@ class MCPServerConfig(Base):
enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools
class InputLimitsConfig(Base):
"""Limits for user-provided multimodal inputs."""
max_input_images: int = 3
max_input_image_bytes: int = 10 * 1024 * 1024 # 10 MB
max_input_audios: int = 1
max_input_audio_bytes: int = 10 * 1024 * 1024 # 10 MB
max_input_videos: int = 1
max_input_video_bytes: int = 20 * 1024 * 1024 # 20 MB
def _lazy_default(module_path: str, class_name: str) -> Any:
"""Deferred import helper for ToolsConfig default factories."""
import importlib
@@ -317,7 +277,6 @@ class ToolsConfig(Base):
image_generation: ImageGenerationToolConfig = Field(
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
)
input_limits: InputLimitsConfig = Field(default_factory=InputLimitsConfig)
restrict_to_workspace: bool = False # restrict all tool access to workspace directory
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
+6 -2
View File
@@ -8,7 +8,6 @@ from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.loop import AgentLoop
from nanobot.providers.image_generation import image_gen_provider_configs
@dataclass(slots=True)
@@ -64,7 +63,12 @@ class Nanobot:
loop = AgentLoop.from_config(
config,
image_generation_provider_configs=image_gen_provider_configs(config),
image_generation_provider_configs={
"openrouter": config.providers.openrouter,
"aihubmix": config.providers.aihubmix,
"minimax": config.providers.minimax,
"gemini": config.providers.gemini,
},
)
return cls(loop)
+3 -42
View File
@@ -212,7 +212,7 @@ class AnthropicProvider(LLMProvider):
@staticmethod
def _convert_user_content(content: Any) -> Any:
"""Convert user message content, translating image_url and input_audio blocks."""
"""Convert user message content, translating image_url blocks."""
if isinstance(content, str) or content is None:
return content or "(empty)"
if not isinstance(content, list):
@@ -228,14 +228,6 @@ class AnthropicProvider(LLMProvider):
if converted:
result.append(converted)
continue
if item.get("type") == "input_audio":
# Anthropic doesn't support native audio → text placeholder
result.append(LLMProvider._media_placeholder("input_audio", item))
continue
if item.get("type") == "video_url":
# Anthropic doesn't support native video → text placeholder
result.append(LLMProvider._media_placeholder("video_url", item))
continue
result.append(item)
return result or "(empty)"
@@ -598,7 +590,6 @@ class AnthropicProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
@@ -607,12 +598,11 @@ class AnthropicProvider(LLMProvider):
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try:
async with self._client.messages.stream(**kwargs) as stream:
if on_content_delta or on_thinking_delta or on_tool_call_delta:
if on_content_delta or on_thinking_delta:
# Idle timeout must track *any* SSE chunk (thinking_delta,
# tool JSON deltas, etc.), not only text_stream tokens.
# Otherwise extended thinking can stall text_stream for minutes
# while the connection is healthy (e.g. MiniMax Anthropic).
tool_blocks: dict[int, dict[str, str]] = {}
while True:
try:
chunk = await asyncio.wait_for(
@@ -621,22 +611,7 @@ class AnthropicProvider(LLMProvider):
)
except StopAsyncIteration:
break
if chunk.type == "content_block_start":
block = getattr(chunk, "content_block", None)
if getattr(block, "type", None) == "tool_use":
index = int(getattr(chunk, "index", 0) or 0)
state = {
"call_id": str(getattr(block, "id", "") or ""),
"name": str(getattr(block, "name", "") or ""),
}
tool_blocks[index] = state
if on_tool_call_delta:
await on_tool_call_delta({
"index": index,
**state,
"arguments_delta": "",
})
elif (
if (
chunk.type == "content_block_delta"
and getattr(chunk.delta, "type", None) == "thinking_delta"
):
@@ -650,20 +625,6 @@ class AnthropicProvider(LLMProvider):
text = getattr(chunk.delta, "text", None) or ""
if text and on_content_delta:
await on_content_delta(text)
elif (
chunk.type == "content_block_delta"
and getattr(chunk.delta, "type", None) == "input_json_delta"
):
partial = getattr(chunk.delta, "partial_json", None) or ""
if partial and on_tool_call_delta:
index = int(getattr(chunk, "index", 0) or 0)
state = tool_blocks.get(index, {})
await on_tool_call_delta({
"index": index,
"call_id": state.get("call_id", ""),
"name": state.get("name", ""),
"arguments_delta": partial,
})
response = await asyncio.wait_for(
stream.get_final_message(),
timeout=idle_timeout_s,
+1 -2
View File
@@ -158,7 +158,6 @@ class AzureOpenAIProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
body = self._build_body(
@@ -170,7 +169,7 @@ class AzureOpenAIProvider(LLMProvider):
try:
stream = await self._client.responses.create(**body)
content, tool_calls, finish_reason, usage, reasoning_content = (
await consume_sdk_stream(stream, on_content_delta, on_tool_call_delta)
await consume_sdk_stream(stream, on_content_delta)
)
return LLMResponse(
content=content or None,
+21 -42
View File
@@ -13,6 +13,8 @@ from typing import Any
from loguru import logger
from nanobot.utils.helpers import image_placeholder_text
@dataclass
class ToolCallRequest:
@@ -68,11 +70,11 @@ class LLMResponse:
@property
def should_execute_tools(self) -> bool:
"""Tools execute only when has_tool_calls AND finish_reason is a tool-capable stop.
"""Tools execute only when has_tool_calls AND finish_reason is ``tool_calls`` / ``stop``.
Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220)."""
if not self.has_tool_calls:
return False
return self.finish_reason in ("tool_calls", "function_call", "stop")
return self.finish_reason in ("tool_calls", "stop")
@dataclass(frozen=True)
@@ -437,23 +439,9 @@ class LLMProvider(ABC):
return merged
_MEDIA_LABEL_MAP = {"image_url": "image", "input_audio": "audio", "video_url": "video"}
_STRIP_MEDIA_TYPES = frozenset({"image_url", "input_audio", "video_url"})
@staticmethod
def _media_placeholder(btype: str, block: dict[str, Any]) -> dict[str, str]:
"""Build a text placeholder for a media block."""
path = (block.get("_meta") or {}).get("path", "")
label = LLMProvider._MEDIA_LABEL_MAP.get(btype, "media")
text = f"[{label}: {path}]" if path else f"[{label}]"
return {"type": "text", "text": text}
@staticmethod
def _strip_media_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url, input_audio, and video_url blocks with text placeholders.
Returns None if no media blocks were found (no changes needed).
"""
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url blocks with text placeholder. Returns None if no images found."""
found = False
result = []
for msg in messages:
@@ -461,8 +449,10 @@ class LLMProvider(ABC):
if isinstance(content, list):
new_content = []
for b in content:
if isinstance(b, dict) and b.get("type") in LLMProvider._STRIP_MEDIA_TYPES:
new_content.append(LLMProvider._media_placeholder(b["type"], b))
if isinstance(b, dict) and b.get("type") == "image_url":
path = (b.get("_meta") or {}).get("path", "")
placeholder = image_placeholder_text(path, empty="[image omitted]")
new_content.append({"type": "text", "text": placeholder})
found = True
else:
new_content.append(b)
@@ -472,13 +462,8 @@ class LLMProvider(ABC):
return result if found else None
@staticmethod
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url blocks with text placeholder. Returns None if no images found."""
return LLMProvider._strip_media_content(messages)
@staticmethod
def _strip_media_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace media blocks with text placeholder *in-place*.
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace image_url blocks with text placeholder *in-place*.
Mutates the content lists of the original message dicts so that
callers holding references to those dicts also see the stripped
@@ -489,16 +474,13 @@ class LLMProvider(ABC):
content = msg.get("content")
if isinstance(content, list):
for i, b in enumerate(content):
if isinstance(b, dict) and b.get("type") in LLMProvider._STRIP_MEDIA_TYPES:
content[i] = LLMProvider._media_placeholder(b["type"], b)
if isinstance(b, dict) and b.get("type") == "image_url":
path = (b.get("_meta") or {}).get("path", "")
placeholder = image_placeholder_text(path, empty="[image omitted]")
content[i] = {"type": "text", "text": placeholder}
found = True
return found
@staticmethod
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace image_url blocks with text placeholder *in-place*."""
return LLMProvider._strip_media_content_inplace(messages)
async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
"""Call chat() and convert unexpected exceptions to error responses."""
try:
@@ -519,7 +501,6 @@ class LLMProvider(ABC):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
"""Stream a chat completion, calling *on_content_delta* for each text chunk.
@@ -533,7 +514,7 @@ class LLMProvider(ABC):
full content as a single delta. Providers that support native
streaming should override this method.
"""
_ = on_thinking_delta, on_tool_call_delta
_ = on_thinking_delta
response = await self.chat(
messages=messages, tools=tools, model=model,
max_tokens=max_tokens, temperature=temperature,
@@ -563,7 +544,6 @@ class LLMProvider(ABC):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
retry_mode: str = "standard",
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
@@ -581,7 +561,6 @@ class LLMProvider(ABC):
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
return await self._run_with_retry(
self._safe_chat_stream,
@@ -756,18 +735,18 @@ class LLMProvider(ABC):
identical_error_count = 1 if error_key else 0
if not self._is_transient_response(response):
stripped = self._strip_media_content(original_messages)
stripped = self._strip_image_content(original_messages)
if stripped is not None and stripped != kw["messages"]:
logger.warning(
"Non-transient LLM error with media content, retrying without media"
"Non-transient LLM error with image content, retrying without images"
)
retry_kw = dict(kw)
retry_kw["messages"] = stripped
result = await call(**retry_kw)
# Permanently strip media from the original messages so
# Permanently strip images from the original messages so
# subsequent iterations do not repeat the error-retry cycle.
if result.finish_reason != "error":
self._strip_media_content_inplace(original_messages)
self._strip_image_content_inplace(original_messages)
return result
return response
+1 -2
View File
@@ -704,9 +704,8 @@ class BedrockProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta, on_tool_call_delta
_ = on_thinking_delta
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
content_parts: list[str] = []
reasoning_parts: list[str] = []
@@ -243,7 +243,6 @@ class GitHubCopilotProvider(OpenAICompatProvider):
tool_choice: str | dict[str, object] | None = None,
on_content_delta: Callable[[str], None] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, object]], Awaitable[None]] | None = None,
):
await self._refresh_client_api_key()
return await super().chat_stream(
@@ -256,5 +255,4 @@ class GitHubCopilotProvider(OpenAICompatProvider):
tool_choice=tool_choice,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
+155 -306
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import base64
import binascii
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -46,6 +44,15 @@ class GeneratedImageResponse:
raw: dict[str, Any]
def _provider_base_url(provider: str, api_base: str | None, fallback: str) -> str:
if api_base:
return api_base.rstrip("/")
spec = find_by_name(provider)
if spec and spec.default_api_base:
return spec.default_api_base.rstrip("/")
return fallback
def _read_image_b64(path: str | Path) -> tuple[str, str]:
"""Return ``(mime, base64)`` for the image at ``path``."""
p = Path(path).expanduser()
@@ -68,16 +75,8 @@ def image_path_to_inline_data(path: str | Path) -> dict[str, str]:
return {"mimeType": mime, "data": encoded}
def _b64_image_data_url(value: str) -> str:
encoded = "".join(value.split())
try:
raw = base64.b64decode(encoded, validate=True)
except binascii.Error as exc:
raise ImageGenerationError("generated image payload was not valid base64") from exc
mime = detect_image_mime(raw)
if mime is None:
raise ImageGenerationError("generated image payload was not a supported image")
return f"data:{mime};base64,{encoded}"
def _b64_png_data_url(value: str) -> str:
return f"data:image/png;base64,{value}"
def _aihubmix_size(aspect_ratio: str | None, image_size: str | None) -> str:
@@ -121,49 +120,8 @@ async def _download_image_data_url(
return f"data:{mime};base64,{encoded}"
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_IMAGE_GEN_PROVIDERS: dict[str, type[ImageGenerationProvider]] = {}
def register_image_gen_provider(cls: type[ImageGenerationProvider]) -> None:
name = cls.provider_name
if not name:
raise ValueError(f"{cls.__name__} must set provider_name")
_IMAGE_GEN_PROVIDERS[name] = cls
def get_image_gen_provider(name: str) -> type[ImageGenerationProvider] | None:
return _IMAGE_GEN_PROVIDERS.get(name)
def image_gen_provider_names() -> tuple[str, ...]:
"""Return registered image generation provider names in registry order."""
return tuple(_IMAGE_GEN_PROVIDERS)
def image_gen_provider_configs(config: Any) -> dict[str, Any]:
providers_cfg = config.providers
return {
name: pc
for name in _IMAGE_GEN_PROVIDERS
if (pc := getattr(providers_cfg, name, None)) is not None
}
# ---------------------------------------------------------------------------
# Base class
# ---------------------------------------------------------------------------
class ImageGenerationProvider(ABC):
"""Base class for image generation provider clients."""
provider_name: str = ""
missing_key_message: str = ""
default_timeout: float = _DEFAULT_TIMEOUT_S
class OpenRouterImageGenerationClient:
"""Small async client for OpenRouter Chat Completions image generation."""
def __init__(
self,
@@ -172,71 +130,20 @@ class ImageGenerationProvider(ABC):
api_base: str | None = None,
extra_headers: dict[str, str] | None = None,
extra_body: dict[str, Any] | None = None,
timeout: float | None = None,
timeout: float = _DEFAULT_TIMEOUT_S,
client: httpx.AsyncClient | None = None,
) -> None:
self.api_key = api_key
self.api_base = self._resolve_base_url(api_base)
self.api_base = _provider_base_url(
"openrouter",
api_base,
"https://openrouter.ai/api/v1",
)
self.extra_headers = extra_headers or {}
self.extra_body = extra_body or {}
self.timeout = timeout if timeout is not None else self.default_timeout
self.timeout = timeout
self._client = client
def _resolve_base_url(self, api_base: str | None) -> str:
if api_base:
return api_base.rstrip("/")
spec = find_by_name(self.provider_name)
if spec and spec.default_api_base:
return spec.default_api_base.rstrip("/")
return self._default_base_url()
def _default_base_url(self) -> str:
return ""
@abstractmethod
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: ...
def _require_images(self, images: list[str], data: dict[str, Any]) -> None:
if images:
return
provider_error = data.get("error") if isinstance(data, dict) else None
label = self.provider_name
if provider_error:
raise ImageGenerationError(f"{label} returned no images: {provider_error}")
raise ImageGenerationError(f"{label} returned no images for this request")
async def _http_post(
self,
url: str,
*,
headers: dict[str, str],
body: dict[str, Any],
) -> httpx.Response:
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:
return await c.post(url, headers=headers, json=body)
class OpenRouterImageGenerationClient(ImageGenerationProvider):
"""Small async client for OpenRouter Chat Completions image generation."""
provider_name = "openrouter"
missing_key_message = (
"OpenRouter API key is not configured. Set providers.openrouter.apiKey."
)
def _default_base_url(self) -> str:
return "https://openrouter.ai/api/v1"
async def generate(
self,
*,
@@ -247,7 +154,9 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider):
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
raise ImageGenerationError(self.missing_key_message)
raise ImageGenerationError(
"OpenRouter API key is not configured. Set providers.openrouter.apiKey."
)
content: str | list[dict[str, Any]]
references = list(reference_images or [])
@@ -283,7 +192,12 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider):
**self.extra_headers,
}
url = f"{self.api_base}/chat/completions"
response = await self._http_post(url, headers=headers, body=body)
if self._client is not None:
response = await self._client.post(url, headers=headers, json=body)
else:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(url, headers=headers, json=body)
try:
response.raise_for_status()
@@ -308,7 +222,11 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider):
if isinstance(url_value, str) and url_value.startswith("data:image/"):
images.append(url_value)
self._require_images(images, data)
if not images:
provider_error = data.get("error") if isinstance(data, dict) else None
if provider_error:
raise ImageGenerationError(f"OpenRouter returned no images: {provider_error}")
raise ImageGenerationError("OpenRouter returned no images for this request")
return GeneratedImageResponse(
images=images,
@@ -317,17 +235,29 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider):
)
class AIHubMixImageGenerationClient(ImageGenerationProvider):
class AIHubMixImageGenerationClient:
"""Small async client for AIHubMix unified image generation."""
provider_name = "aihubmix"
missing_key_message = (
"AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
)
default_timeout = _AIHUBMIX_TIMEOUT_S
def _default_base_url(self) -> str:
return "https://aihubmix.com/v1"
def __init__(
self,
*,
api_key: str | None,
api_base: str | None = None,
extra_headers: dict[str, str] | None = None,
extra_body: dict[str, Any] | None = None,
timeout: float = _AIHUBMIX_TIMEOUT_S,
client: httpx.AsyncClient | None = None,
) -> None:
self.api_key = api_key
self.api_base = _provider_base_url(
"aihubmix",
api_base,
"https://aihubmix.com/v1",
)
self.extra_headers = extra_headers or {}
self.extra_body = extra_body or {}
self.timeout = timeout
self._client = client
async def generate(
self,
@@ -339,7 +269,9 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
raise ImageGenerationError(self.missing_key_message)
raise ImageGenerationError(
"AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
)
refs = list(reference_images or [])
headers = {
@@ -348,8 +280,16 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
}
size = _aihubmix_size(aspect_ratio, image_size)
client = self._client or httpx.AsyncClient(timeout=self.timeout)
try:
if self._client is not None:
return await self._generate_with_client(
self._client,
prompt=prompt,
model=model,
reference_images=refs,
size=size,
headers=headers,
)
async with httpx.AsyncClient(timeout=self.timeout) as client:
return await self._generate_with_client(
client,
prompt=prompt,
@@ -358,9 +298,6 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
size=size,
headers=headers,
)
finally:
if self._client is None:
await client.aclose()
async def _generate_with_client(
self,
@@ -409,7 +346,11 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
payload = response.json()
images = await _aihubmix_images_from_payload(client, payload)
self._require_images(images, payload)
if not images:
provider_error = payload.get("error") if isinstance(payload, dict) else None
if provider_error:
raise ImageGenerationError(f"AIHubMix returned no images: {provider_error}")
raise ImageGenerationError("AIHubMix returned no images for this request")
return GeneratedImageResponse(images=images, content="", raw=payload)
@@ -429,25 +370,31 @@ def _http_error_detail(response: httpx.Response) -> str:
return response.text[:500] or "<empty response body>"
class GeminiImageGenerationClient(ImageGenerationProvider):
class GeminiImageGenerationClient:
"""Async client for Gemini/Imagen image generation via the Generative Language API."""
provider_name = "gemini"
missing_key_message = (
"Gemini API key is not configured. Set providers.gemini.apiKey."
)
default_timeout = _GEMINI_DEFAULT_TIMEOUT_S
def _default_base_url(self) -> str:
return "https://generativelanguage.googleapis.com/v1beta"
def _resolve_base_url(self, api_base: str | None) -> str:
def __init__(
self,
*,
api_key: str | None,
api_base: str | None = None,
extra_headers: dict[str, str] | None = None,
extra_body: dict[str, Any] | None = None,
timeout: float = _GEMINI_DEFAULT_TIMEOUT_S,
client: httpx.AsyncClient | None = None,
) -> None:
self.api_key = api_key
# 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.
if api_base:
return api_base.rstrip("/")
return self._default_base_url()
# shim (.../v1beta/openai/), which has no image endpoints. Image
# generation needs the native Generative Language API base, so we don't
# use _provider_base_url() here.
self.api_base = (
api_base or "https://generativelanguage.googleapis.com/v1beta"
).rstrip("/")
self.extra_headers = extra_headers or {}
self.extra_body = extra_body or {}
self.timeout = timeout
self._client = client
async def generate(
self,
@@ -459,7 +406,9 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
raise ImageGenerationError(self.missing_key_message)
raise ImageGenerationError(
"Gemini API key is not configured. Set providers.gemini.apiKey."
)
if "imagen" in model.lower():
if reference_images:
logger.warning(
@@ -497,7 +446,12 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
"Content-Type": "application/json",
**self.extra_headers,
}
response = await self._http_post(url, headers=headers, body=body)
if self._client is not None:
response = await self._client.post(url, headers=headers, json=body)
else:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(url, headers=headers, json=body)
try:
response.raise_for_status()
@@ -518,7 +472,11 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
if isinstance(b64, str) and b64:
images.append(f"data:{mime};base64,{b64}")
self._require_images(images, data)
if not images:
provider_error = data.get("error") if isinstance(data, dict) else None
if provider_error:
raise ImageGenerationError(f"Gemini Imagen returned no images: {provider_error}")
raise ImageGenerationError("Gemini Imagen returned no images for this request")
return GeneratedImageResponse(images=images, content="", raw=data)
@@ -546,7 +504,12 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
"Content-Type": "application/json",
**self.extra_headers,
}
response = await self._http_post(url, headers=headers, body=body)
if self._client is not None:
response = await self._client.post(url, headers=headers, json=body)
else:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(url, headers=headers, json=body)
try:
response.raise_for_status()
@@ -576,7 +539,11 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
if b64:
images.append(f"data:{mime};base64,{b64}")
self._require_images(images, data)
if not images:
provider_error = data.get("error") if isinstance(data, dict) else None
if provider_error:
raise ImageGenerationError(f"Gemini returned no images: {provider_error}")
raise ImageGenerationError("Gemini returned no images for this request")
return GeneratedImageResponse(
images=images,
@@ -612,13 +579,13 @@ async def _aihubmix_images_from_payload(
b64_json = value.get("b64_json")
if isinstance(b64_json, str) and b64_json:
images.append(_b64_image_data_url(b64_json))
images.append(_b64_png_data_url(b64_json))
elif b64_json is not None:
await collect(b64_json)
bytes_base64 = value.get("bytesBase64") or value.get("bytes_base64") or value.get("base64")
if isinstance(bytes_base64, str) and bytes_base64:
images.append(_b64_image_data_url(bytes_base64))
images.append(_b64_png_data_url(bytes_base64))
image_url = value.get("image_url") or value.get("imageUrl")
if isinstance(image_url, dict):
@@ -653,17 +620,29 @@ _MINIMAX_ASPECT_RATIO_SIZES = {
}
class MiniMaxImageGenerationClient(ImageGenerationProvider):
class MiniMaxImageGenerationClient:
"""Async client for MiniMax image generation API."""
provider_name = "minimax"
missing_key_message = (
"MiniMax API key is not configured. Set providers.minimax.apiKey."
)
default_timeout = _MINIMAX_TIMEOUT_S
def _default_base_url(self) -> str:
return "https://api.minimaxi.com/v1"
def __init__(
self,
*,
api_key: str | None,
api_base: str | None = None,
extra_headers: dict[str, str] | None = None,
extra_body: dict[str, Any] | None = None,
timeout: float = _MINIMAX_TIMEOUT_S,
client: httpx.AsyncClient | None = None,
) -> None:
self.api_key = api_key
self.api_base = _provider_base_url(
"minimax",
api_base,
"https://api.minimaxi.com/v1",
)
self.extra_headers = extra_headers or {}
self.extra_body = extra_body or {}
self.timeout = timeout
self._client = client
def _resolve_aspect_ratio(self, aspect_ratio: str | None) -> str:
if aspect_ratio and aspect_ratio in _MINIMAX_ASPECT_RATIO_SIZES:
@@ -680,7 +659,9 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider):
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
raise ImageGenerationError(self.missing_key_message)
raise ImageGenerationError(
"MiniMax API key is not configured. Set providers.minimax.apiKey."
)
headers = {
"Authorization": f"Bearer {self.api_key}",
@@ -706,12 +687,10 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider):
body.update(self.extra_body)
client = self._client or httpx.AsyncClient(timeout=self.timeout)
try:
if self._client is not None:
return await self._generate_with_client(self._client, body, headers)
async with httpx.AsyncClient(timeout=self.timeout) as client:
return await self._generate_with_client(client, body, headers)
finally:
if self._client is None:
await client.aclose()
async def _generate_with_client(
self,
@@ -736,7 +715,11 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider):
payload = response.json()
images = _minimax_images_from_payload(payload)
self._require_images(images, payload)
if not images:
provider_error = payload.get("error") if isinstance(payload, dict) else None
if provider_error:
raise ImageGenerationError(f"MiniMax returned no images: {provider_error}")
raise ImageGenerationError("MiniMax returned no images for this request")
return GeneratedImageResponse(images=images, content="", raw=payload)
@@ -752,139 +735,5 @@ def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]:
return images
for b64 in data.get("image_base64") or []:
if isinstance(b64, str) and b64:
images.append(_b64_image_data_url(b64))
images.append(_b64_png_data_url(b64))
return images
# ---------------------------------------------------------------------------
# StepFun (阶跃星辰) image generation
# ---------------------------------------------------------------------------
_STEPFUN_ASPECT_RATIO_SIZES = {
"1:1": "1024x1024",
"16:9": "1280x800",
"9:16": "800x1280",
"3:4": "768x1360",
"4:3": "1360x768",
}
class StepFunImageGenerationClient(ImageGenerationProvider):
"""Async client for StepFun (阶跃星辰) image generation.
Supports:
- Text-to-image via step-image-edit-2 (default model)
- Reference-image-guided generation via style_reference (step-1x-medium)
"""
provider_name = "stepfun"
missing_key_message = (
"StepFun API key is not configured. Set providers.stepfun.apiKey."
)
default_timeout = 120.0
def _default_base_url(self) -> str:
return "https://api.stepfun.com/v1"
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)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
**self.extra_headers,
}
body: dict[str, Any] = {
"model": model,
"prompt": prompt,
"response_format": "b64_json",
"n": 1,
}
# Map aspect ratio / image_size to StepFun size string
size = _stepfun_size(aspect_ratio, image_size)
if size:
body["size"] = size
# step-1x-medium supports style_reference for reference-image-guided generation
refs = list(reference_images or [])
if refs and "1x" in model:
body["style_reference"] = {
"source_url": image_path_to_data_url(refs[0]),
}
body.update(self.extra_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[:500]
raise ImageGenerationError(
f"StepFun image generation failed: {detail}"
) from exc
payload = response.json()
images = _stepfun_images_from_payload(payload)
self._require_images(images, payload)
return GeneratedImageResponse(images=images, content="", raw=payload)
def _stepfun_size(
aspect_ratio: str | None,
image_size: str | None,
) -> str:
"""Resolve aspect ratio / image_size to StepFun size string.
StepFun expects ``WIDTHxHEIGHT`` (note: width x height, not the more
common ``HxW`` order used by other providers). The accepted sizes are
``1024x1024``, ``768x1360``, ``896x1184``, ``1360x768``, ``1184x896``.
"""
if image_size and "x" in image_size.lower():
return image_size
if aspect_ratio and aspect_ratio in _STEPFUN_ASPECT_RATIO_SIZES:
return _STEPFUN_ASPECT_RATIO_SIZES[aspect_ratio]
return "1024x1024"
def _stepfun_images_from_payload(payload: dict[str, Any]) -> list[str]:
"""Extract base64 images from StepFun API response.
StepFun returns images in ``data[].b64_json`` (base64 strings).
"""
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))
return images
# ---------------------------------------------------------------------------
# Provider registration
# ---------------------------------------------------------------------------
register_image_gen_provider(OpenRouterImageGenerationClient)
register_image_gen_provider(AIHubMixImageGenerationClient)
register_image_gen_provider(GeminiImageGenerationClient)
register_image_gen_provider(MiniMaxImageGenerationClient)
register_image_gen_provider(StepFunImageGenerationClient)
+2 -15
View File
@@ -40,7 +40,6 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
"""Shared request logic for both chat() and chat_stream()."""
model = model or self.default_model
@@ -71,7 +70,6 @@ class OpenAICodexProvider(LLMProvider):
content, tool_calls, finish_reason = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=True,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
)
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
@@ -80,7 +78,6 @@ class OpenAICodexProvider(LLMProvider):
content, tool_calls, finish_reason = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=False,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
)
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
except Exception as e:
@@ -103,18 +100,9 @@ class OpenAICodexProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
return await self._call_codex(
messages,
tools,
model,
reasoning_effort,
tool_choice,
on_content_delta,
on_tool_call_delta,
)
return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta)
def get_default_model(self) -> str:
return self.default_model
@@ -150,7 +138,6 @@ async def _request_codex(
body: dict[str, Any],
verify: bool,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:
@@ -161,7 +148,7 @@ async def _request_codex(
_friendly_error(response.status_code, text.decode("utf-8", "ignore")),
retry_after=retry_after,
)
return await consume_sse(response, on_content_delta, on_tool_call_delta)
return await consume_sse(response, on_content_delta)
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
+2 -56
View File
@@ -999,21 +999,6 @@ class OpenAICompatProvider(LLMProvider):
if fn_prov:
buf["fn_prov"] = fn_prov
def _accum_legacy_function_call(function_call: Any) -> None:
"""Accumulate legacy ``delta.function_call`` streaming chunks."""
if not function_call:
return
buf = tc_bufs.setdefault(0, {
"id": "", "name": "", "arguments": "",
"extra_content": None, "prov": None, "fn_prov": None,
})
fn_name = _get(function_call, "name")
if fn_name:
buf["name"] = str(fn_name)
fn_args = _get(function_call, "arguments")
if fn_args:
buf["arguments"] += str(fn_args)
for chunk in chunks:
if isinstance(chunk, str):
content_parts.append(chunk)
@@ -1044,7 +1029,6 @@ class OpenAICompatProvider(LLMProvider):
reasoning_parts.append(text)
for idx, tc in enumerate(delta.get("tool_calls") or []):
_accum_tc(tc, idx)
_accum_legacy_function_call(delta.get("function_call"))
usage = cls._extract_usage(chunk_map) or usage
continue
@@ -1063,10 +1047,8 @@ class OpenAICompatProvider(LLMProvider):
reasoning = getattr(delta, "reasoning", None)
if reasoning:
reasoning_parts.append(reasoning)
for tc in (getattr(delta, "tool_calls", None) or []) if delta else []:
for tc in (delta.tool_calls or []) if delta else []:
_accum_tc(tc, getattr(tc, "index", 0))
if delta:
_accum_legacy_function_call(getattr(delta, "function_call", None))
return LLMResponse(
content="".join(content_parts) or None,
@@ -1221,7 +1203,6 @@ class OpenAICompatProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try:
@@ -1245,16 +1226,9 @@ class OpenAICompatProvider(LLMProvider):
except StopAsyncIteration:
break
(
content,
tool_calls,
finish_reason,
usage,
reasoning_content,
) = await consume_sdk_stream(
content, tool_calls, finish_reason, usage, reasoning_content = await consume_sdk_stream(
_timed_stream(),
on_content_delta,
on_tool_call_delta=on_tool_call_delta,
)
self._record_responses_success(model, reasoning_effort)
return LLMResponse(
@@ -1278,12 +1252,6 @@ class OpenAICompatProvider(LLMProvider):
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
if self._spec and self._spec.name == "zhipu" and tools and on_tool_call_delta:
# Z.AI/GLM keeps streaming tool-call arguments behind an
# explicit provider flag. Pass it through the OpenAI SDK's
# extra_body escape hatch so the usual delta.tool_calls path
# can surface live file-edit progress.
kwargs.setdefault("extra_body", {})["tool_stream"] = True
kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True}
stream = await self._client.chat.completions.create(**kwargs)
@@ -1311,28 +1279,6 @@ class OpenAICompatProvider(LLMProvider):
r_text = self._extract_text_content(reasoning)
if r_text:
await on_thinking_delta(r_text)
if on_tool_call_delta:
for idx, tool_delta in enumerate(
getattr(delta_obj, "tool_calls", None) or []
):
fn = _get(tool_delta, "function")
tool_index = _get(tool_delta, "index")
await on_tool_call_delta({
"index": tool_index if tool_index is not None else idx,
"call_id": str(_get(tool_delta, "id") or ""),
"name": str(_get(fn, "name") or "") if fn is not None else "",
"arguments_delta": (
str(_get(fn, "arguments") or "") if fn is not None else ""
),
})
function_call = getattr(delta_obj, "function_call", None)
if function_call:
await on_tool_call_delta({
"index": 0,
"call_id": "",
"name": str(_get(function_call, "name") or ""),
"arguments_delta": str(_get(function_call, "arguments") or ""),
})
return self._parse_chunks(chunks)
except asyncio.TimeoutError:
return LLMResponse(
@@ -5,8 +5,6 @@ from __future__ import annotations
import json
from typing import Any
from nanobot.providers.base import LLMProvider
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items.
@@ -60,10 +58,8 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
def convert_user_message(content: Any) -> dict[str, Any]:
"""Convert a user message's content to Responses API format.
Handles plain strings, ``text`` blocks -> ``input_text``,
``image_url`` blocks -> ``input_image``, and ``input_audio`` blocks.
``video_url`` is downgraded to a text placeholder because Codex does
not support native video.
Handles plain strings, ``text`` blocks -> ``input_text``, and
``image_url`` blocks -> ``input_image``.
"""
if isinstance(content, str):
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
@@ -78,18 +74,6 @@ def convert_user_message(content: Any) -> dict[str, Any]:
url = (item.get("image_url") or {}).get("url")
if url:
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
elif item.get("type") == "input_audio":
audio_info = item.get("input_audio") or {}
audio_data = audio_info.get("data")
if audio_data:
converted.append({
"type": "input_audio",
"input_audio": {"data": audio_data, "format": audio_info.get("format", "wav")},
})
elif item.get("type") == "video_url":
# Codex doesn't support native video → text placeholder
placeholder = LLMProvider._media_placeholder("video_url", item)
converted.append({"type": "input_text", "text": placeholder["text"]})
if converted:
return {"role": "user", "content": converted}
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
+2 -30
View File
@@ -62,7 +62,6 @@ async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], N
async def consume_sse(
response: httpx.Response,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
content = ""
@@ -83,12 +82,6 @@ async def consume_sse(
"name": item.get("name"),
"arguments": item.get("arguments") or "",
}
if on_tool_call_delta:
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(item.get("name") or ""),
"arguments_delta": "",
})
elif event_type == "response.output_text.delta":
delta_text = event.get("delta") or ""
content += delta_text
@@ -97,14 +90,7 @@ async def consume_sse(
elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
delta = event.get("delta") or ""
tool_call_buffers[call_id]["arguments"] += delta
if on_tool_call_delta and delta:
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments_delta": str(delta),
})
tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
@@ -224,7 +210,6 @@ def parse_response_output(response: Any) -> LLMResponse:
async def consume_sdk_stream(
stream: Any,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
content = ""
@@ -247,12 +232,6 @@ async def consume_sdk_stream(
"name": getattr(item, "name", None),
"arguments": getattr(item, "arguments", None) or "",
}
if on_tool_call_delta:
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(getattr(item, "name", None) or ""),
"arguments_delta": "",
})
elif event_type == "response.output_text.delta":
delta_text = getattr(event, "delta", "") or ""
content += delta_text
@@ -261,14 +240,7 @@ async def consume_sdk_stream(
elif event_type == "response.function_call_arguments.delta":
call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers:
delta = getattr(event, "delta", "") or ""
tool_call_buffers[call_id]["arguments"] += delta
if on_tool_call_delta and delta:
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments_delta": str(delta),
})
tool_call_buffers[call_id]["arguments"] += getattr(event, "delta", "") or ""
elif event_type == "response.function_call_arguments.done":
call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers:
-22
View File
@@ -155,18 +155,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
detect_by_base_keyword="huggingface",
default_api_base="https://router.huggingface.co/v1",
),
# Skywork API platform (APIFree): OpenAI-compatible MaaS gateway.
ProviderSpec(
name="skywork",
keywords=("skywork", "skyclaw", "apifree"),
env_key="SKYWORK_API_KEY",
display_name="Skywork",
backend="openai_compat",
env_extras=(("APIFREE_API_KEY", "{api_key}"),),
is_gateway=True,
detect_by_base_keyword="apifree.ai",
default_api_base="https://api.apifree.ai/v1",
),
# AiHubMix: global gateway, OpenAI-compatible interface.
# strip_model_prefix=True: doesn't understand "anthropic/claude-3",
# strips to bare "claude-3".
@@ -402,16 +390,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
default_api_base="https://api.longcat.chat/openai/v1",
),
# Ant Ling: OpenAI-compatible API for Ling/Ring model families.
ProviderSpec(
name="ant_ling",
keywords=("ant_ling", "ant-ling", "ling-", "ring-"),
env_key="ANT_LING_API_KEY",
display_name="Ant Ling",
backend="openai_compat",
detect_by_base_keyword="ant-ling.com",
default_api_base="https://api.ant-ling.com/v1",
),
# === Local deployment (matched by config key, NOT by api_base) =========
# vLLM / any OpenAI-compatible local server
ProviderSpec(
+68 -1
View File
@@ -15,7 +15,7 @@ If the `generate_image` tool is not available in the current tool list, tell the
- Image editing: pass the saved artifact path or user image path in `reference_images`.
- Iterative edits in the same conversation: prefer the most recent generated image artifact if the user says things like "make it brighter", "change the background", or "try another version".
- Ambiguous edits: ask a short clarifying question if multiple recent images could be the target.
- After generating images, call the `message` tool with the artifact paths in the `media` parameter to deliver them to the user.
- In the current chat, do not call `message` just to announce or resend generated images. The runtime attaches images from `generate_image` to the final assistant reply automatically.
## Prompt Rules
@@ -42,6 +42,73 @@ For follow-up edits, pass the prior artifact `path` to `reference_images`. If th
Do not include internal replay markers such as `[Message Time: ...]`, `[image: /local/path]`, `generate_image(...)`, or `message(...)` in user-facing replies.
## Provider Notes
Do not ask users to paste API keys into chat. If configuration is needed, describe the fields; LLM provider and BYOK changes are hot-reloaded for new turns.
For OpenRouter, the image tool expects:
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-..."
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "openrouter",
"model": "openai/gpt-5.4-image-2"
}
}
}
```
For AIHubMix, the image tool expects:
```json
{
"providers": {
"aihubmix": {
"apiKey": "sk-..."
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "aihubmix",
"model": "gpt-image-2-free"
}
}
}
```
AIHubMix `gpt-image-2-free` uses AIHubMix's unified predictions endpoint internally (`/v1/models/openai/gpt-image-2-free/predictions`), not the OpenAI Images `/v1/images/generations` endpoint. If it fails with "Incorrect model ID", do not assume the key lacks permission until the provider config, model name, and gateway restart have been checked.
`providers.aihubmix.extraBody` can be used for provider-specific options. For example, `"extraBody": {"quality": "low"}` is optional but can make `gpt-image-2-free` faster and less likely to time out.
For Gemini, the image tool supports two model families. Imagen 4 (`imagen-4.0-generate-001`) supports text-to-image only. Gemini Flash (`gemini-2.5-flash-image`) also supports reference-image edits. Configuration:
```json
{
"providers": {
"gemini": {
"apiKey": "AIza..."
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "gemini",
"model": "imagen-4.0-generate-001"
}
}
}
```
For Gemini models, `defaultImageSize` has no effect; use `defaultAspectRatio` instead. Imagen 4 supports `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`.
## Examples
Generate a new image:
+1 -1
View File
@@ -30,5 +30,5 @@ Output is rendered in a terminal. Avoid markdown headings and tables. Use plain
Reply directly with text for the current conversation. Do not use the 'message' tool for normal replies in the current chat.
When you need to call tools before answering, do not include the final user-visible answer in the same assistant message as the tool calls. Wait for the tool results, then answer once.
Use the 'message' tool only for proactive sends, cross-channel delivery, or explicitly sending existing local files as attachments. When 'generate_image' creates images, call 'message' with the artifact paths in the 'media' parameter to deliver them to the user.
Use the 'message' tool only for proactive sends, cross-channel delivery, or explicitly sending existing local files as attachments. When a tool such as 'generate_image' creates user-visible media, the runtime attaches those artifacts to the final assistant reply automatically, so do not call 'message' just to announce or resend them.
To send an existing local file that was not automatically attached by another tool, call 'message' with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Example: message(content="Here is the document", channel="telegram", chat_id="...", media=["/path/to/file.pdf"])
-36
View File
@@ -1,42 +1,6 @@
"""Utility functions for nanobot."""
from __future__ import annotations
import sys
from importlib import import_module
from types import ModuleType
from nanobot.utils.helpers import ensure_dir
from nanobot.utils.path import abbreviate_path
__all__ = ["ensure_dir", "abbreviate_path"]
class _LazyModuleAlias(ModuleType):
def __init__(self, name: str, target: str) -> None:
super().__init__(name)
self.__dict__["_target"] = target
def _load(self) -> ModuleType:
module = import_module(self.__dict__["_target"])
sys.modules[self.__name__] = module
return module
def __getattr__(self, name: str) -> object:
return getattr(self._load(), name)
def __dir__(self) -> list[str]:
return sorted(set(super().__dir__()) | set(dir(self._load())))
_LEGACY_MODULE_ALIASES = {
"webui_thread_disk": "nanobot.webui.thread_disk",
"webui_transcript": "nanobot.webui.transcript",
"webui_turn_helpers": "nanobot.session.webui_turns",
}
for _legacy_name, _target_name in _LEGACY_MODULE_ALIASES.items():
sys.modules.setdefault(
f"{__name__}.{_legacy_name}",
_LazyModuleAlias(f"{__name__}.{_legacy_name}", _target_name),
)
+43 -3
View File
@@ -21,6 +21,8 @@ _MIME_EXTENSIONS = {
"image/webp": ".webp",
"image/gif": ".gif",
}
_GENERATE_IMAGE_TOOL_NAME = "generate_image"
class ArtifactError(ValueError):
"""Raised when an artifact cannot be safely decoded or stored."""
@@ -113,10 +115,48 @@ def generated_image_tool_result(artifacts: list[dict[str, Any]]) -> str:
"artifacts": artifacts,
"next_step": (
"Use these artifact paths as reference_images for follow-up edits. "
"Call the message tool with the artifact paths in the media parameter "
"to deliver the images to the user. Keep raw paths internal unless the "
"user asks for debug details."
"For the current chat, reply naturally; the runtime attaches generated images automatically. "
"Do not call message just to announce or resend them. Keep raw paths internal unless the user asks for debug details."
),
},
ensure_ascii=False,
)
def _extract_text_payload(content: Any) -> str | None:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, dict) and isinstance(block.get("text"), str):
parts.append(block["text"])
return "\n".join(parts) if parts else None
return None
def generated_image_paths_from_messages(messages: list[dict[str, Any]]) -> list[str]:
"""Collect generated image artifact paths from generate_image tool results."""
paths: list[str] = []
seen: set[str] = set()
for message in messages:
if message.get("role") != "tool" or message.get("name") != _GENERATE_IMAGE_TOOL_NAME:
continue
payload = _extract_text_payload(message.get("content"))
if not payload:
continue
try:
data = json.loads(payload)
except json.JSONDecodeError:
continue
artifacts = data.get("artifacts") if isinstance(data, dict) else None
if not isinstance(artifacts, list):
continue
for artifact in artifacts:
if not isinstance(artifact, dict):
continue
path = artifact.get("path")
if isinstance(path, str) and path and path not in seen:
paths.append(path)
seen.add(path)
return paths
+8 -477
View File
@@ -4,17 +4,13 @@ from __future__ import annotations
import difflib
import json
import re
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable
from typing import Any
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
_LIVE_EMIT_INTERVAL_S = 0.18
_LIVE_EMIT_LINE_STEP = 24
@dataclass(slots=True)
@@ -107,8 +103,6 @@ def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
"""Return ``(added, deleted)`` for a UTF-8 text line-level diff."""
if before is None or after is None:
return 0, 0
if before == "":
return _text_line_count(after), 0
before_lines = before.replace("\r\n", "\n").splitlines()
after_lines = after.replace("\r\n", "\n").splitlines()
added = 0
@@ -124,28 +118,6 @@ def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
return added, deleted
def _text_line_count(text: str) -> int:
if not text:
return 0
line_count = 0
last_was_newline = False
last_was_cr = False
for ch in text:
if ch == "\r":
line_count += 1
last_was_newline = True
last_was_cr = True
elif ch == "\n":
if not last_was_cr:
line_count += 1
last_was_newline = True
last_was_cr = False
else:
last_was_newline = False
last_was_cr = False
return line_count if last_was_newline else line_count + 1
def prepare_file_edit_tracker(
*,
call_id: str,
@@ -188,22 +160,12 @@ def build_file_edit_start_event(
)
def build_file_edit_end_event(
tracker: FileEditTracker,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
after = read_file_snapshot(tracker.path)
counted = False
if tracker.before.countable and after.countable:
added, deleted = line_diff_stats(tracker.before.text, after.text)
counted = True
else:
predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before)
if tracker.before.countable and predicted_after is not None:
added, deleted = line_diff_stats(tracker.before.text, predicted_after)
counted = True
else:
added, deleted = 0, 0
added, deleted = 0, 0
return _event_payload(
tracker,
phase="end",
@@ -211,14 +173,11 @@ def build_file_edit_end_event(
added=added,
deleted=deleted,
approximate=False,
binary=(after.binary or after.oversized or after.unreadable) and not counted,
binary=after.binary or after.oversized or after.unreadable,
)
def build_file_edit_error_event(
tracker: FileEditTracker,
error: str | None = None,
) -> dict[str, Any]:
def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]:
payload = _event_payload(
tracker,
phase="error",
@@ -232,427 +191,6 @@ def build_file_edit_error_event(
return payload
def build_file_edit_live_event(
tracker: FileEditTracker,
*,
added: int,
deleted: int = 0,
) -> dict[str, Any]:
"""Build an approximate in-progress event while tool-call arguments stream."""
return _event_payload(
tracker,
phase="start",
status="editing",
added=added,
deleted=deleted,
approximate=True,
)
def build_file_edit_pending_event(
*,
call_id: str,
tool_name: str,
added: int = 0,
deleted: int = 0,
) -> dict[str, Any]:
"""Build an early placeholder before the streamed JSON path is available."""
return {
"version": 1,
"call_id": str(call_id or ""),
"tool": tool_name,
"path": "",
"phase": "start",
"added": max(0, int(added)),
"deleted": max(0, int(deleted)),
"approximate": True,
"status": "editing",
"pending": True,
}
class StreamingFileEditTracker:
"""Track file-edit tool arguments while the model is still streaming them.
Tool execution events only begin after the provider has completed the full
function call. For large ``write_file`` calls, the long wait is usually the
model producing the JSON ``content`` argument. Large ``edit_file`` calls
can have the same wait while ``old_text`` / ``new_text`` stream in. This
tracker converts those argument deltas into approximate WebUI file-edit
events before the final exact diff is available.
"""
def __init__(
self,
*,
workspace: Path | None,
tools: Any,
emit: Callable[[list[dict[str, Any]]], Awaitable[None]],
) -> None:
self._workspace = workspace
self._tools = tools
self._emit = emit
self._states: dict[str, _StreamingFileEditState] = {}
async def update(self, payload: dict[str, Any]) -> None:
key = _stream_key(payload)
if not key:
return
state = self._states.get(key)
if state is None:
state = _StreamingFileEditState(key=key)
self._states[key] = state
state.apply_delta(payload)
if state.name not in {"write_file", "edit_file"}:
return
if state.path is None:
state.path = _extract_complete_json_string(state.arguments, "path")
if state.path is None:
added, deleted = state.live_diff_counts()
now = time.monotonic()
if state.should_emit_pending(added, deleted, now):
state.mark_pending_emitted(added, deleted, now)
await self._emit([build_file_edit_pending_event(
call_id=state.call_id or state.key,
tool_name=state.name,
added=added,
deleted=deleted,
)])
return
if state.tracker is None:
tool = self._tools.get(state.name) if hasattr(self._tools, "get") else None
state.tracker = prepare_file_edit_tracker(
call_id=state.call_id or state.key,
tool_name=state.name,
tool=tool,
workspace=self._workspace,
params={"path": state.path},
)
if state.tracker is None:
return
added, deleted = state.live_diff_counts()
now = time.monotonic()
if not state.should_emit(added, deleted, now):
return
state.mark_emitted(added, deleted, now)
await self._emit([build_file_edit_live_event(
state.tracker,
added=added,
deleted=deleted,
)])
async def flush(self) -> None:
events: list[dict[str, Any]] = []
now = time.monotonic()
for state in self._states.values():
if state.tracker is None:
continue
added, deleted = state.live_diff_counts()
if (
state.last_emitted_added == added
and state.last_emitted_deleted == deleted
and state.emitted_once
):
continue
state.mark_emitted(added, deleted, now)
events.append(build_file_edit_live_event(
state.tracker,
added=added,
deleted=deleted,
))
if events:
await self._emit(events)
def apply_final_call_ids(self, final_tool_calls: list[Any]) -> None:
"""Keep final start/end events keyed to any earlier streamed placeholder."""
for tool_call in final_tool_calls:
canonical = self.canonical_call_id_for(tool_call)
if canonical:
try:
tool_call.id = canonical
except Exception:
pass
def canonical_call_id_for(self, tool_call: Any) -> str | None:
for state in self._states.values():
if state.matches_final_tool_call(tool_call):
return state.call_id or (state.tracker.call_id if state.tracker else None) or state.key
return None
async def error_unmatched(
self,
final_tool_calls: list[Any],
error: str,
) -> None:
"""Mark streamed edits as failed when no final tool call will run."""
events: list[dict[str, Any]] = []
for state in self._states.values():
if state.tracker is None:
continue
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
continue
events.append(build_file_edit_error_event(state.tracker, error))
if events:
await self._emit(events)
@dataclass(slots=True)
class _StreamingJsonStringField:
key: str
scan_pos: int | None = None
closed: bool = False
escape: bool = False
unicode_remaining: int = 0
unicode_buffer: str = ""
newline_count: int = 0
has_chars: bool = False
last_char_newline: bool = False
last_char_cr: bool = False
@property
def line_count(self) -> int:
if not self.has_chars:
return 0
return self.newline_count + (0 if self.last_char_newline else 1)
def reset(self) -> None:
self.scan_pos = None
self.closed = False
self.escape = False
self.unicode_remaining = 0
self.unicode_buffer = ""
self.newline_count = 0
self.has_chars = False
self.last_char_newline = False
self.last_char_cr = False
def scan(self, source: str) -> None:
if self.closed:
return
if self.scan_pos is None:
match = re.search(rf'"{re.escape(self.key)}"\s*:\s*"', source)
if match is None:
return
self.scan_pos = match.end()
i = self.scan_pos
while i < len(source):
ch = source[i]
if self.unicode_remaining > 0:
self.unicode_buffer += ch
self.unicode_remaining -= 1
if self.unicode_remaining == 0:
try:
decoded = chr(int(self.unicode_buffer, 16))
except ValueError:
decoded = "x"
self.unicode_buffer = ""
self._mark_char(decoded)
i += 1
continue
if self.escape:
self.escape = False
if ch == "u":
self.unicode_remaining = 4
self.unicode_buffer = ""
elif ch == "n":
self._mark_char("\n")
elif ch == "r":
self._mark_char("\r")
else:
self._mark_char(ch)
i += 1
continue
if ch == "\\":
self.escape = True
i += 1
continue
if ch == '"':
self.closed = True
i += 1
break
self._mark_char(ch)
i += 1
self.scan_pos = i
def _mark_char(self, ch: str) -> None:
self.has_chars = True
if ch == "\r":
self.newline_count += 1
self.last_char_newline = True
self.last_char_cr = True
elif ch == "\n":
if not self.last_char_cr:
self.newline_count += 1
self.last_char_newline = True
self.last_char_cr = False
else:
self.last_char_newline = False
self.last_char_cr = False
@dataclass(slots=True)
class _StreamingFileEditState:
key: str
call_id: str = ""
name: str = ""
arguments: str = ""
path: str | None = None
tracker: FileEditTracker | None = None
content: _StreamingJsonStringField = field(
default_factory=lambda: _StreamingJsonStringField("content")
)
old_text: _StreamingJsonStringField = field(
default_factory=lambda: _StreamingJsonStringField("old_text")
)
new_text: _StreamingJsonStringField = field(
default_factory=lambda: _StreamingJsonStringField("new_text")
)
emitted_once: bool = False
last_emitted_added: int = -1
last_emitted_deleted: int = -1
last_emit_at: float = 0.0
pending_emitted: bool = False
last_pending_added: int = -1
last_pending_deleted: int = -1
last_pending_at: float = 0.0
def apply_delta(self, payload: dict[str, Any]) -> None:
call_id = payload.get("call_id")
if isinstance(call_id, str) and call_id:
self.call_id = call_id
name = payload.get("name")
if isinstance(name, str) and name:
self.name = name
args = payload.get("arguments")
if isinstance(args, str):
self.arguments = args
self.content.reset()
self.old_text.reset()
self.new_text.reset()
return
delta = payload.get("arguments_delta")
if isinstance(delta, str) and delta:
self.arguments += delta
def live_diff_counts(self) -> tuple[int, int]:
if self.name == "write_file":
self.content.scan(self.arguments)
return self.content.line_count, 0
if self.name == "edit_file":
self.old_text.scan(self.arguments)
self.new_text.scan(self.arguments)
return self.new_text.line_count, self.old_text.line_count
return 0, 0
def should_emit(self, added: int, deleted: int, now: float) -> bool:
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_emitted_added = added
self.last_emitted_deleted = deleted
self.last_emit_at = now
def should_emit_pending(self, added: int, deleted: int, now: float) -> bool:
if not self.pending_emitted:
return True
if added == self.last_pending_added and deleted == self.last_pending_deleted:
return False
if max(
abs(added - self.last_pending_added),
abs(deleted - self.last_pending_deleted),
) >= _LIVE_EMIT_LINE_STEP:
return True
return now - self.last_pending_at >= _LIVE_EMIT_INTERVAL_S
def mark_pending_emitted(self, added: int, deleted: int, now: float) -> None:
self.pending_emitted = True
self.last_pending_added = added
self.last_pending_deleted = deleted
self.last_pending_at = now
def matches_final_tool_call(self, tool_call: Any) -> bool:
call_id = getattr(tool_call, "id", None)
canonical = self.call_id or (self.tracker.call_id if self.tracker else "")
if isinstance(call_id, str) and call_id and canonical and call_id == canonical:
return True
name = getattr(tool_call, "name", None)
if name != self.name:
return False
arguments = getattr(tool_call, "arguments", None)
if not isinstance(arguments, dict):
return False
path = arguments.get("path")
if self.path is None and isinstance(path, str) and path:
self.path = path
return True
return isinstance(path, str) and path == self.path
def _stream_key(payload: dict[str, Any]) -> str:
index = payload.get("index")
if isinstance(index, int):
return f"idx:{index}"
if isinstance(index, str) and index:
return f"idx:{index}"
call_id = payload.get("call_id")
if isinstance(call_id, str) and call_id:
return f"id:{call_id}"
return ""
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:
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:
return None
try:
out.append(chr(int(digits, 16)))
except ValueError:
return None
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 None
def _event_payload(
tracker: FileEditTracker,
*,
@@ -668,7 +206,6 @@ def _event_payload(
"call_id": tracker.call_id,
"tool": tracker.tool,
"path": tracker.display_path,
"absolute_path": tracker.path.as_posix(),
"phase": phase,
"added": max(0, int(added)),
"deleted": max(0, int(deleted)),
@@ -723,14 +260,8 @@ def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> st
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"
)
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)
-73
View File
@@ -171,79 +171,6 @@ def detect_image_mime(data: bytes) -> str | None:
return None
# Audio formats supported by OpenAI input_audio block
_AUDIO_MIME_COMPAT = {"audio/wav", "audio/mpeg", "audio/mp3", "audio/aac",
"audio/ogg", "audio/flac", "audio/x-m4a", "audio/mp4"}
# Map MIME types to the format token expected by OpenAI-compatible input_audio APIs.
_AUDIO_FORMAT_MAP: dict[str, str] = {
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/aac": "aac",
"audio/ogg": "ogg",
"audio/flac": "flac",
"audio/x-m4a": "m4a",
"audio/mp4": "m4a",
}
def detect_audio_mime(data: bytes, filename: str = "") -> str | None:
"""Detect audio MIME type from magic bytes; fallback to filename guess."""
if data[:4] == b"RIFF" and data[8:12] == b"WAVE":
return "audio/wav"
if data[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2", b"\xff\xfa"):
return "audio/mpeg"
if data[:4] == b"fLaC":
return "audio/flac"
if data[:4] == b"OggS":
return "audio/ogg"
if len(data) > 8 and data[4:8] == b"ftyp":
# Only claim audio for M4A-specific brands; avoid matching MP4 video.
brand = data[8:12]
if brand in (b"M4A ", b"M4AB", b"M4AC"):
return "audio/x-m4a"
if filename:
import mimetypes as _mt
guessed = _mt.guess_type(filename)[0]
if guessed and guessed.startswith("audio/"):
return guessed
return None
def audio_mime_compat(mime: str | None) -> bool:
"""Check if the audio MIME is compatible with OpenAI input_audio block."""
if not mime:
return False
return mime in _AUDIO_MIME_COMPAT
def audio_format_for_api(mime: str) -> str:
"""Convert an audio MIME type to the format token expected by the API.
Falls back to the subtype portion of the MIME (e.g. "x-m4a" from
"audio/x-m4a") when no explicit mapping exists.
"""
if not mime:
return "wav"
return _AUDIO_FORMAT_MAP.get(mime, mime.split("/")[-1])
# Video formats commonly supported by LLM APIs (data URI inline)
_VIDEO_MIME_COMPAT = {
"video/mp4", "video/quicktime", "video/x-m4v",
"video/webm", "video/x-matroska",
}
def video_mime_compat(mime: str | None) -> bool:
"""Check if the video MIME is in the commonly-supported set."""
if not mime:
return False
return mime in _VIDEO_MIME_COMPAT
def build_image_content_blocks(
raw: bytes, mime: str, path: str, label: str
) -> list[dict[str, Any]]:
+74
View File
@@ -0,0 +1,74 @@
"""Session replay: ensure assistant ``media`` paths are under the media root.
WebUI history signing (``/api/.../messages``) only works for files inside
``get_media_dir``. Tool-driven attachments may live in the workspace; stage
copies into the websocket media bucket before persisting message JSON.
"""
from __future__ import annotations
import shutil
import uuid
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.config.paths import get_media_dir
from nanobot.utils.helpers import safe_filename
def stage_media_paths_for_session_replay(paths: list[str]) -> list[str]:
"""Keep local files only; copy anything outside the media root into ``media/websocket``."""
root = get_media_dir().resolve()
out: list[str] = []
seen: set[str] = set()
for raw in paths:
if not isinstance(raw, str) or not raw.strip():
continue
if raw.startswith(("http://", "https://")):
continue
try:
p = Path(raw).expanduser().resolve()
except OSError:
continue
if not p.is_file():
continue
try:
p.relative_to(root)
key = str(p)
except ValueError:
try:
media_dir = get_media_dir("websocket")
staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_filename(p.name) or 'attachment'}"
shutil.copyfile(p, staged)
key = str(staged.resolve())
except OSError as exc:
logger.warning("failed to stage session media from {}: {}", raw, exc)
continue
if key not in seen:
out.append(key)
seen.add(key)
return out
def merge_turn_media_into_last_assistant(
all_messages: list[dict[str, Any]],
generated_image_paths: list[str],
extra_attachment_paths: list[str],
) -> None:
"""Attach staged paths to the last assistant row in *all_messages* (in-place)."""
merged = list(
dict.fromkeys(
[
*stage_media_paths_for_session_replay(generated_image_paths),
*stage_media_paths_for_session_replay(extra_attachment_paths),
]
)
)
last = all_messages[-1] if all_messages else None
if not merged or not last or last.get("role") != "assistant":
return
existing = last.get("media")
base = existing if isinstance(existing, list) else []
last["media"] = list(dict.fromkeys([*base, *merged]))
@@ -1,4 +1,4 @@
"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use transcript."""
"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use webui_transcript."""
from __future__ import annotations
@@ -8,7 +8,7 @@ from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.session.manager import SessionManager
from nanobot.webui.transcript import delete_webui_transcript
from nanobot.utils.webui_transcript import delete_webui_transcript
def webui_thread_file_path(session_key: str) -> Path:
@@ -99,39 +99,17 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
if not isinstance(events, list):
return []
lines: list[str] = []
seen: set[str] = set()
for event in events:
if not event or not isinstance(event, dict):
continue
if event.get("phase") not in {"start", "end", "error"}:
if event.get("phase") != "start":
continue
call_id = event.get("call_id")
if isinstance(call_id, str) and call_id:
if call_id in seen:
continue
seen.add(call_id)
t = _format_tool_call_trace(event)
if t:
lines.append(t)
return lines
def _merge_unique_tool_trace_lines(
previous_traces: list[str],
lines: list[str],
) -> tuple[list[str], bool]:
seen_lines = set(previous_traces)
traces = list(previous_traces)
added = False
for line in lines:
if line in seen_lines:
continue
seen_lines.add(line)
traces.append(line)
added = True
return traces, added
def replay_transcript_to_ui_messages(
lines: list[dict[str, Any]],
*,
@@ -166,17 +144,6 @@ def replay_transcript_to_ui_messages(
def _ensure_activity_segment() -> str:
return active_activity_segment_id or _new_activity_segment()
def close_activity_for_answer() -> None:
nonlocal active_activity_segment_id, active_file_edit_segment_id
active_activity_segment_id = None
active_file_edit_segment_id = None
def close_file_edit_phase_before_activity() -> None:
nonlocal active_activity_segment_id, active_file_edit_segment_id
if active_file_edit_segment_id:
active_activity_segment_id = None
active_file_edit_segment_id = None
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
for i in range(len(prev) - 1, -1, -1):
candidate = prev[i]
@@ -276,7 +243,7 @@ def replay_transcript_to_ui_messages(
return
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
nonlocal active_activity_segment_id, active_file_edit_segment_id
nonlocal active_activity_segment_id
last = messages[-1] if messages else None
if last and is_reasoning_only_placeholder(last):
messages[-1] = {
@@ -295,50 +262,35 @@ def replay_transcript_to_ui_messages(
},
)
active_activity_segment_id = None
active_file_edit_segment_id = None
def _file_edit_key(edit: dict[str, Any]) -> str:
call_id = str(edit.get("call_id") or "")
tool = str(edit.get("tool") or "")
if call_id:
return f"{call_id}|{tool}"
return f"{tool}|{edit.get('path') or ''}"
def find_file_edit_trace_index(
segment: str | None,
edits: list[dict[str, Any]],
) -> int | None:
incoming_keys = {_file_edit_key(edit) for edit in edits if isinstance(edit, dict)}
for i in range(len(messages) - 1, -1, -1):
candidate = messages[i]
if candidate.get("role") == "user":
break
if candidate.get("kind") != "trace" or not candidate.get("fileEdits"):
continue
if segment and candidate.get("activitySegmentId") == segment:
return i
existing_edits = candidate.get("fileEdits")
if not isinstance(existing_edits, list):
continue
for existing in existing_edits:
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
return i
return None
return "|".join(
str(edit.get(k) or "")
for k in ("call_id", "tool", "path")
)
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
nonlocal active_file_edit_segment_id
if not edits:
return
segment = active_file_edit_segment_id
target_index = find_file_edit_trace_index(segment, edits)
if target_index is not None:
last = messages[target_index]
segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
active_file_edit_segment_id = segment
last = messages[-1] if messages else None
if (
active_file_edit_segment_id
and last
and last.get("kind") == "trace"
and last.get("fileEdits")
):
segment = active_file_edit_segment_id
else:
if not segment:
segment = _new_activity_segment(activate=False)
segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
if not (
last
and last.get("kind") == "trace"
and not last.get("isStreaming")
and last.get("fileEdits")
and last.get("activitySegmentId") == segment
):
messages.append(
{
"id": _new_id("tr", idx),
@@ -351,11 +303,7 @@ def replay_transcript_to_ui_messages(
"createdAt": _ts_base + idx,
},
)
target_index = len(messages) - 1
last = messages[target_index]
if not segment:
segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
last = messages[-1]
existing = list(last.get("fileEdits") or [])
index_by_key = {
_file_edit_key(edit): pos
@@ -368,14 +316,11 @@ def replay_transcript_to_ui_messages(
key = _file_edit_key(edit)
if key in index_by_key:
pos = index_by_key[key]
merged = {**existing[pos], **edit}
if edit.get("path") and not edit.get("pending"):
merged.pop("pending", None)
existing[pos] = merged
existing[pos] = {**existing[pos], **edit}
else:
index_by_key[key] = len(existing)
existing.append(dict(edit))
messages[target_index] = {
messages[-1] = {
**last,
"fileEdits": existing,
"activitySegmentId": last.get("activitySegmentId") or segment,
@@ -420,7 +365,6 @@ def replay_transcript_to_ui_messages(
chunk = rec.get("text")
if not isinstance(chunk, str):
continue
close_activity_for_answer()
adopted = find_active_placeholder(messages) if buffer_message_id is None else None
if buffer_message_id is None:
if adopted:
@@ -459,7 +403,6 @@ def replay_transcript_to_ui_messages(
chunk = rec.get("text")
if not isinstance(chunk, str) or not chunk:
continue
close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, chunk, idx)
continue
@@ -481,7 +424,6 @@ def replay_transcript_to_ui_messages(
line = rec.get("text")
if not isinstance(line, str) or not line:
continue
close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, line, idx)
close_reasoning(messages)
continue
@@ -500,19 +442,13 @@ def replay_transcript_to_ui_messages(
and (last.get("activitySegmentId") in (None, segment))
):
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:
continue
else:
merged_traces = prev_traces + trace_lines
merged = {
merged_traces = prev_traces + trace_lines
messages[-1] = {
**last,
"traces": merged_traces,
"content": merged_traces[-1],
"content": trace_lines[-1],
"activitySegmentId": last.get("activitySegmentId") or segment,
}
messages[-1] = merged
else:
messages.append(
{
@@ -1,4 +1,4 @@
"""Session turn helpers for WebUI-capable WebSocket sessions.
"""Outbound helpers for the WebSocket/WebUI wire contract.
AgentLoop uses these without importing a concrete channel plugin; only
``channel == "websocket"`` messages are affected.
-2
View File
@@ -1,2 +0,0 @@
"""Backend helpers for the bundled WebUI surface."""
-609
View File
@@ -1,609 +0,0 @@
"""Settings REST helpers for the WebUI HTTP surface.
The WebSocket channel owns transport/authentication. This module owns the
settings payload shape and the allowlisted config mutations exposed to WebUI.
"""
from __future__ import annotations
from typing import Any
from zoneinfo import ZoneInfo
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.providers.image_generation import (
get_image_gen_provider,
image_gen_provider_names,
)
from nanobot.providers.registry import PROVIDERS, find_by_name
QueryParams = dict[str, list[str]]
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
{"name": "jina", "label": "Jina", "credential": "api_key"},
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
)
_WEB_SEARCH_PROVIDER_BY_NAME = {
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
}
_IMAGE_GENERATION_ASPECT_RATIOS = {
"1:1",
"3:4",
"9:16",
"4:3",
"16:9",
"3:2",
"2:3",
"21:9",
}
class WebUISettingsError(ValueError):
"""User-facing settings validation failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
def _query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def _query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
value = _query_first(query, snake)
return _query_first(query, camel) if value is None else value
def _mask_secret_hint(secret: str | None) -> str | None:
if not secret:
return None
if len(secret) <= 8:
return "••••"
return f"{secret[:4]}••••{secret[-4:]}"
def _provider_requires_api_key(spec: Any) -> bool:
if spec.backend == "azure_openai":
return True
if spec.is_local or spec.is_direct:
return False
return True
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if _provider_requires_api_key(spec):
return bool(provider_config.api_key)
return bool(
provider_config.api_key
or provider_config.api_base
or getattr(provider_config, "region", None)
or getattr(provider_config, "profile", None)
)
def _parse_bool(value: str, field: str) -> bool:
normalized = value.strip().lower()
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
raise WebUISettingsError(f"{field} must be boolean")
return normalized in {"1", "true", "yes"}
def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for name in image_gen_provider_names():
spec = find_by_name(name)
provider_config = getattr(config.providers, name, None)
configured = (
_provider_configured_for_settings(spec, provider_config)
if spec is not None and provider_config is not None
else bool(getattr(provider_config, "api_key", None))
)
rows.append(
{
"name": name,
"label": spec.label if spec is not None else name,
"configured": configured,
"api_key_hint": _mask_secret_hint(
getattr(provider_config, "api_key", None)
),
"api_base": getattr(provider_config, "api_base", None),
"default_api_base": (
spec.default_api_base if spec and spec.default_api_base else None
),
}
)
return rows
def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
config = load_config()
defaults = config.agents.defaults
active_preset_name = defaults.model_preset or "default"
try:
effective_preset = config.resolve_preset()
except Exception:
effective_preset = config.resolve_default_preset()
active_preset_name = "default"
provider_name = (
config.get_provider_name(effective_preset.model, preset=effective_preset)
or effective_preset.provider
)
provider = config.get_provider(effective_preset.model, preset=effective_preset)
selected_provider = provider_name
if effective_preset.provider != "auto":
spec = find_by_name(effective_preset.provider)
selected_provider = spec.name if spec else provider_name
providers = []
for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth:
continue
providers.append(
{
"name": spec.name,
"label": spec.label,
"configured": _provider_configured_for_settings(spec, provider_config),
"api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
}
)
search_config = config.tools.web.search
image_config = config.tools.image_generation
search_provider = (
search_config.provider
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
else "duckduckgo"
)
image_providers = _image_generation_provider_rows(config)
selected_image_provider = next(
(
provider
for provider in image_providers
if provider["name"] == image_config.provider
),
None,
)
model_presets = [
{
"name": "default",
"label": "Default",
"active": active_preset_name == "default",
"is_default": True,
"model": defaults.model,
"provider": defaults.provider,
"max_tokens": defaults.max_tokens,
"context_window_tokens": defaults.context_window_tokens,
"temperature": defaults.temperature,
"reasoning_effort": defaults.reasoning_effort,
}
]
for name, preset in config.model_presets.items():
model_presets.append(
{
"name": name,
"label": name,
"active": active_preset_name == name,
"is_default": False,
"model": preset.model,
"provider": preset.provider,
"max_tokens": preset.max_tokens,
"context_window_tokens": preset.context_window_tokens,
"temperature": preset.temperature,
"reasoning_effort": preset.reasoning_effort,
}
)
exec_config = config.tools.exec
return {
"agent": {
"model": effective_preset.model,
"provider": selected_provider,
"resolved_provider": provider_name,
"has_api_key": bool(provider and provider.api_key),
"model_preset": active_preset_name,
"max_tokens": effective_preset.max_tokens,
"context_window_tokens": effective_preset.context_window_tokens,
"temperature": effective_preset.temperature,
"reasoning_effort": effective_preset.reasoning_effort,
"timezone": defaults.timezone,
"bot_name": defaults.bot_name,
"bot_icon": defaults.bot_icon,
"tool_hint_max_length": defaults.tool_hint_max_length,
},
"model_presets": model_presets,
"providers": providers,
"web_search": {
"provider": search_provider,
"api_key_hint": _mask_secret_hint(search_config.api_key),
"base_url": search_config.base_url or None,
"max_results": search_config.max_results,
"timeout": search_config.timeout,
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
},
"web": {
"enable": config.tools.web.enable,
"proxy": config.tools.web.proxy,
"user_agent": config.tools.web.user_agent,
"search": {
"max_results": search_config.max_results,
"timeout": search_config.timeout,
},
"fetch": {
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
},
},
"image_generation": {
"enabled": image_config.enabled,
"provider": image_config.provider,
"provider_configured": bool(
selected_image_provider and selected_image_provider["configured"]
),
"model": image_config.model,
"default_aspect_ratio": image_config.default_aspect_ratio,
"default_image_size": image_config.default_image_size,
"max_images_per_turn": image_config.max_images_per_turn,
"save_dir": image_config.save_dir,
"providers": image_providers,
},
"runtime": {
"config_path": str(get_config_path().expanduser()),
"workspace_path": str(config.workspace_path),
"gateway_host": config.gateway.host,
"gateway_port": config.gateway.port,
"heartbeat": {
"enabled": config.gateway.heartbeat.enabled,
"interval_s": config.gateway.heartbeat.interval_s,
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
},
"dream": {
"schedule": defaults.dream.describe_schedule(),
"max_batch_size": defaults.dream.max_batch_size,
"max_iterations": defaults.dream.max_iterations,
"annotate_line_ages": defaults.dream.annotate_line_ages,
},
"unified_session": defaults.unified_session,
},
"advanced": {
"restrict_to_workspace": config.tools.restrict_to_workspace,
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
"mcp_server_count": len(config.tools.mcp_servers),
"exec_enabled": exec_config.enable,
"exec_sandbox": exec_config.sandbox or None,
"exec_path_append_set": bool(exec_config.path_append),
},
"requires_restart": requires_restart,
}
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
defaults = config.agents.defaults
changed = False
restart_required = False
if "model_preset" in query or "modelPreset" in query:
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
preset_value = None if not preset or preset == "default" else preset
if preset_value is not None and preset_value not in config.model_presets:
raise WebUISettingsError("unknown model preset")
if defaults.model_preset != preset_value:
defaults.model_preset = preset_value
changed = True
model = _query_first(query, "model")
if model is not None:
model = model.strip()
if not model:
raise WebUISettingsError("model is required")
if defaults.model != model:
defaults.model = model
changed = True
provider = _query_first(query, "provider")
if provider is not None:
provider = provider.strip()
if not provider:
raise WebUISettingsError("provider is required")
spec = find_by_name(provider)
if spec is None:
raise WebUISettingsError("unknown provider")
provider_config = getattr(config.providers, provider, None)
if (
provider_config is None
or not _provider_configured_for_settings(spec, provider_config)
):
raise WebUISettingsError("provider is not configured")
if defaults.provider != provider:
defaults.provider = provider
changed = True
timezone = _query_first(query, "timezone")
if timezone is not None:
timezone = timezone.strip()
if not timezone:
raise WebUISettingsError("timezone is required")
try:
ZoneInfo(timezone)
except Exception:
raise WebUISettingsError("invalid timezone") from None
if defaults.timezone != timezone:
defaults.timezone = timezone
changed = True
restart_required = True
bot_name = _query_first_alias(query, "bot_name", "botName")
if bot_name is not None:
bot_name = bot_name.strip()
if not bot_name:
raise WebUISettingsError("bot_name is required")
if defaults.bot_name != bot_name:
defaults.bot_name = bot_name
changed = True
restart_required = True
bot_icon = _query_first_alias(query, "bot_icon", "botIcon")
if bot_icon is not None:
bot_icon = bot_icon.strip()
if defaults.bot_icon != bot_icon:
defaults.bot_icon = bot_icon
changed = True
restart_required = True
tool_hint_max_length = _query_first_alias(
query,
"tool_hint_max_length",
"toolHintMaxLength",
)
if tool_hint_max_length is not None:
try:
parsed = int(tool_hint_max_length)
except ValueError:
raise WebUISettingsError("tool_hint_max_length must be an integer") from None
if parsed < 20 or parsed > 500:
raise WebUISettingsError("tool_hint_max_length must be between 20 and 500")
if defaults.tool_hint_max_length != parsed:
defaults.tool_hint_max_length = parsed
changed = True
restart_required = True
if changed:
save_config(config)
return settings_payload(requires_restart=restart_required)
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
raise WebUISettingsError("provider is required")
spec = find_by_name(provider_name)
if spec is None or spec.is_oauth:
raise WebUISettingsError("unknown provider")
config = load_config()
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None:
raise WebUISettingsError("unknown provider")
changed = False
if "api_key" in query or "apiKey" in query:
api_key = _query_first_alias(query, "api_key", "apiKey")
api_key = (api_key or "").strip() or None
if provider_config.api_key != api_key:
provider_config.api_key = api_key
changed = True
if "api_base" in query or "apiBase" in query:
api_base = _query_first_alias(query, "api_base", "apiBase")
api_base = (api_base or "").strip() or None
if provider_config.api_base != api_base:
provider_config.api_base = api_base
changed = True
if changed:
save_config(config)
image_config = config.tools.image_generation
restart_required = (
changed
and image_config.enabled
and image_config.provider == spec.name
and get_image_gen_provider(spec.name) is not None
)
return settings_payload(requires_restart=restart_required)
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip().lower()
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
if provider_option is None:
raise WebUISettingsError("unknown web search provider")
config = load_config()
search_config = config.tools.web.search
web_config = config.tools.web
previous_provider = search_config.provider
changed = False
restart_required = False
def set_search_value(attr: str, value: object) -> None:
nonlocal changed
if getattr(search_config, attr) != value:
setattr(search_config, attr, value)
changed = True
def set_fetch_value(attr: str, value: object) -> None:
nonlocal changed
if getattr(web_config.fetch, attr) != value:
setattr(web_config.fetch, attr, value)
changed = True
if search_config.provider != provider_name:
search_config.provider = provider_name
changed = True
credential = provider_option["credential"]
if credential == "none":
set_search_value("api_key", "")
set_search_value("base_url", "")
elif credential == "base_url":
base_url = _query_first_alias(query, "base_url", "baseUrl")
base_url = base_url.strip() if base_url is not None else None
if not base_url and previous_provider == provider_name and search_config.base_url:
base_url = search_config.base_url
if not base_url:
raise WebUISettingsError("base_url is required")
set_search_value("base_url", base_url)
set_search_value("api_key", "")
else:
api_key = _query_first_alias(query, "api_key", "apiKey")
api_key = api_key.strip() if api_key is not None else None
if not api_key and previous_provider == provider_name and search_config.api_key:
api_key = search_config.api_key
if not api_key:
raise WebUISettingsError("api_key is required")
set_search_value("api_key", api_key)
set_search_value("base_url", "")
max_results = _query_first_alias(query, "max_results", "maxResults")
if max_results is not None:
try:
parsed = int(max_results)
except ValueError:
raise WebUISettingsError("max_results must be an integer") from None
if parsed < 1 or parsed > 10:
raise WebUISettingsError("max_results must be between 1 and 10")
set_search_value("max_results", parsed)
timeout = _query_first(query, "timeout")
if timeout is not None:
try:
parsed_timeout = int(timeout)
except ValueError:
raise WebUISettingsError("timeout must be an integer") from None
if parsed_timeout < 1 or parsed_timeout > 120:
raise WebUISettingsError("timeout must be between 1 and 120")
set_search_value("timeout", parsed_timeout)
use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader")
if use_jina_reader is not None:
normalized = use_jina_reader.strip().lower()
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
raise WebUISettingsError("use_jina_reader must be boolean")
previous_jina_reader = web_config.fetch.use_jina_reader
set_fetch_value("use_jina_reader", normalized in {"1", "true", "yes"})
if web_config.fetch.use_jina_reader != previous_jina_reader:
restart_required = True
if changed:
save_config(config)
return settings_payload(requires_restart=restart_required)
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
image_config = config.tools.image_generation
changed = False
provider_name = _query_first(query, "provider")
if provider_name is not None:
provider_name = provider_name.strip().lower()
if not provider_name:
raise WebUISettingsError("image generation provider is required")
if get_image_gen_provider(provider_name) is None:
raise WebUISettingsError("unknown image generation provider")
if image_config.provider != provider_name:
image_config.provider = provider_name
changed = True
enabled = _query_first(query, "enabled")
if enabled is not None:
parsed_enabled = _parse_bool(enabled, "enabled")
if image_config.enabled != parsed_enabled:
image_config.enabled = parsed_enabled
changed = True
model = _query_first(query, "model")
if model is not None:
model = model.strip()
if not model:
raise WebUISettingsError("image generation model is required")
if len(model) > 200:
raise WebUISettingsError("image generation model is too long")
if image_config.model != model:
image_config.model = model
changed = True
default_aspect_ratio = _query_first_alias(
query,
"default_aspect_ratio",
"defaultAspectRatio",
)
if default_aspect_ratio is not None:
default_aspect_ratio = default_aspect_ratio.strip()
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
raise WebUISettingsError("unsupported image generation aspect ratio")
if image_config.default_aspect_ratio != default_aspect_ratio:
image_config.default_aspect_ratio = default_aspect_ratio
changed = True
default_image_size = _query_first_alias(
query,
"default_image_size",
"defaultImageSize",
)
if default_image_size is not None:
default_image_size = default_image_size.strip()
if not default_image_size:
raise WebUISettingsError("default image size is required")
if len(default_image_size) > 32 or not all(
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
for char in default_image_size
):
raise WebUISettingsError("unsupported image generation size")
if image_config.default_image_size != default_image_size:
image_config.default_image_size = default_image_size
changed = True
max_images_per_turn = _query_first_alias(
query,
"max_images_per_turn",
"maxImagesPerTurn",
)
if max_images_per_turn is not None:
try:
parsed_max = int(max_images_per_turn)
except ValueError:
raise WebUISettingsError("max_images_per_turn must be an integer") from None
if parsed_max < 1 or parsed_max > 8:
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
if image_config.max_images_per_turn != parsed_max:
image_config.max_images_per_turn = parsed_max
changed = True
if image_config.enabled:
selected_provider = next(
(
provider
for provider in _image_generation_provider_rows(config)
if provider["name"] == image_config.provider
),
None,
)
if not selected_provider or not selected_provider["configured"]:
raise WebUISettingsError("image generation provider is not configured")
if changed:
save_config(config)
return settings_payload(requires_restart=changed)
-193
View File
@@ -1,193 +0,0 @@
"""Persisted WebUI sidebar workspace state.
This state is UI-only metadata, scoped to the active nanobot instance data
directory (the directory containing the current config.json). It deliberately
does not modify agent sessions.
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.config.paths import get_webui_dir
WEBUI_SIDEBAR_STATE_SCHEMA_VERSION = 1
_MAX_STATE_FILE_BYTES = 256 * 1024
_MAX_LIST_ITEMS = 2_000
_MAX_MAP_ITEMS = 2_000
_MAX_KEY_LEN = 512
_MAX_TITLE_LEN = 160
_MAX_TAG_LEN = 40
_ALLOWED_DENSITIES = {"comfortable", "compact"}
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc"}
def webui_sidebar_state_path() -> Path:
return get_webui_dir() / "sidebar-state.json"
def default_webui_sidebar_state() -> dict[str, Any]:
return {
"schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
"pinned_keys": [],
"archived_keys": [],
"title_overrides": {},
"tags_by_key": {},
"collapsed_groups": {},
"view": {
"density": "comfortable",
"show_previews": False,
"show_timestamps": False,
"show_archived": False,
"sort": "updated_desc",
},
"updated_at": None,
}
def _clean_string(value: Any, *, max_len: int = _MAX_KEY_LEN) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
if not cleaned:
return None
return cleaned[:max_len]
def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
if not isinstance(value, list):
return []
out: list[str] = []
seen: set[str] = set()
for item in value[:_MAX_LIST_ITEMS]:
cleaned = _clean_string(item, max_len=max_len)
if cleaned is None or cleaned in seen:
continue
seen.add(cleaned)
out.append(cleaned)
return out
def _clean_bool_map(value: Any) -> dict[str, bool]:
if not isinstance(value, dict):
return {}
out: dict[str, bool] = {}
for key, raw in list(value.items())[:_MAX_MAP_ITEMS]:
cleaned_key = _clean_string(key)
if cleaned_key is None:
continue
out[cleaned_key] = bool(raw)
return out
def _clean_title_overrides(value: Any) -> dict[str, str]:
if not isinstance(value, dict):
return {}
out: dict[str, str] = {}
for key, raw_title in list(value.items())[:_MAX_MAP_ITEMS]:
cleaned_key = _clean_string(key)
cleaned_title = _clean_string(raw_title, max_len=_MAX_TITLE_LEN)
if cleaned_key is None or cleaned_title is None:
continue
out[cleaned_key] = cleaned_title
return out
def _clean_tags_by_key(value: Any) -> dict[str, list[str]]:
if not isinstance(value, dict):
return {}
out: dict[str, list[str]] = {}
for key, raw_tags in list(value.items())[:_MAX_MAP_ITEMS]:
cleaned_key = _clean_string(key)
if cleaned_key is None:
continue
tags = _clean_string_list(raw_tags, max_len=_MAX_TAG_LEN)[:12]
if tags:
out[cleaned_key] = tags
return out
def _clean_view(value: Any) -> dict[str, Any]:
default = default_webui_sidebar_state()["view"]
if not isinstance(value, dict):
return dict(default)
density = value.get("density")
sort = value.get("sort")
return {
"density": density if density in _ALLOWED_DENSITIES else default["density"],
"show_previews": bool(value.get("show_previews", default["show_previews"])),
"show_timestamps": bool(value.get("show_timestamps", default["show_timestamps"])),
"show_archived": bool(value.get("show_archived", default["show_archived"])),
"sort": sort if sort in _ALLOWED_SORTS else default["sort"],
}
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
"""Return a schema-v1 sidebar state from any older/partial input."""
if not isinstance(raw, dict):
raw = {}
state = default_webui_sidebar_state()
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
state["view"] = _clean_view(raw.get("view"))
updated_at = raw.get("updated_at")
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
return state
def read_webui_sidebar_state() -> dict[str, Any]:
path = webui_sidebar_state_path()
if not path.is_file():
return default_webui_sidebar_state()
try:
if path.stat().st_size > _MAX_STATE_FILE_BYTES:
logger.warning("webui sidebar state too large, ignoring: {}", path)
return default_webui_sidebar_state()
with open(path, encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning("read webui sidebar state failed {}: {}", path, e)
return default_webui_sidebar_state()
return normalize_webui_sidebar_state(raw)
def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
state = normalize_webui_sidebar_state(raw)
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
encoded = json.dumps(
state,
ensure_ascii=False,
indent=2,
sort_keys=True,
).encode("utf-8")
if len(encoded) > _MAX_STATE_FILE_BYTES:
raise ValueError("sidebar state is too large")
path = webui_sidebar_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "wb") as f:
f.write(encoded)
f.write(b"\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
try:
dir_fd = os.open(path.parent, os.O_RDONLY)
except OSError:
return state
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
return state
+2 -2
View File
@@ -314,8 +314,8 @@ def test_system_prompt_keeps_message_tool_out_of_current_chat_replies(tmp_path)
prompt = builder.build_system_prompt(channel="slack")
assert "Do not use the 'message' tool for normal replies in the current chat" in prompt
assert "When 'generate_image' creates images" in prompt
assert "call 'message' with the artifact paths in the 'media' parameter" in prompt
assert "the runtime attaches those artifacts to the final assistant reply automatically" in prompt
assert "do not call 'message' just to announce or resend them" in prompt
assert "Wait for the tool results, then answer once" in prompt
@@ -29,15 +29,14 @@ class FakeImageClient:
@pytest.mark.asyncio
async def test_outbound_no_longer_carries_generated_media(
async def test_generated_image_media_is_attached_to_final_assistant_message(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Media delivery is now the LLM's responsibility via the message tool."""
set_config_path(tmp_path / "config.json")
monkeypatch.setattr(
"nanobot.agent.tools.image_generation.get_image_gen_provider",
lambda name: FakeImageClient if name == "openrouter" else None,
"nanobot.agent.tools.image_generation.OpenRouterImageGenerationClient",
FakeImageClient,
)
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
@@ -82,6 +81,9 @@ async def test_outbound_no_longer_carries_generated_media(
assert result is not None
assert result.content == "Done"
# OutboundMessage no longer carries generated media —
# the LLM sends images via the message tool instead.
assert result.media == []
assert len(result.media) == 1
assert Path(result.media[0]).is_file()
session = loop.sessions.get_or_create("websocket:chat-image")
assert session.messages[-1]["role"] == "assistant"
assert session.messages[-1]["media"] == result.media
+2 -97
View File
@@ -133,7 +133,6 @@ class TestToolEventProgress:
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"absolute_path": (tmp_path / "foo.txt").resolve().as_posix(),
"phase": "start",
"added": 2,
"deleted": 1,
@@ -310,100 +309,6 @@ class TestToolEventProgress:
await invoke_file_edit_progress(telegram_progress, edit_events)
assert bus.outbound_size == 0
@pytest.mark.asyncio
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
"""The /goal command rewrites the prompt but must not bypass WebUI file-edit progress."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model"
call_count = 0
target = tmp_path / "goal.txt"
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
assert on_tool_call_delta is not None
await on_tool_call_delta({
"index": 0,
"call_id": "call-goal-write",
"name": "write_file",
"arguments_delta": '{"path":"goal.txt","content":"',
})
await on_tool_call_delta({
"index": 0,
"arguments_delta": "one\\ntwo\\nthree\\n",
})
await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
return LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call-goal-write",
name="write_file",
arguments={
"path": "goal.txt",
"content": "one\ntwo\nthree\n",
},
)
],
usage={},
)
return LLMResponse(content="Done", tool_calls=[], usage={})
async def execute(name: str, params: dict) -> str:
assert name == "write_file"
target.write_text(params["content"], encoding="utf-8")
return "ok"
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[
{"type": "function", "function": {"name": "write_file"}},
])
loop.tools.prepare_call = MagicMock(
return_value=(
None,
{"path": "goal.txt", "content": "one\ntwo\nthree\n"},
None,
),
)
loop.tools.execute = AsyncMock(side_effect=execute)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="/goal create goal file",
metadata={"_wants_stream": True},
))
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
edit_events = [
event
for msg in outbound
for event in msg.metadata.get("_file_edit_events", [])
]
assert any(
event["status"] == "editing"
and event["approximate"]
and event["added"] == 3
for event in edit_events
)
assert any(
event["status"] == "done"
and not event["approximate"]
and event["added"] == 3
for event in edit_events
)
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
self,
@@ -651,7 +556,7 @@ class TestToolEventProgress:
return False
monkeypatch.setattr(
"nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
fake_title_after_turn,
)
scheduled_title: list[object] = []
@@ -698,7 +603,7 @@ class TestToolEventProgress:
raise AssertionError("command-only turns should not generate titles")
monkeypatch.setattr(
"nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
fake_title_after_turn,
)
scheduled: list[object] = []
+2 -2
View File
@@ -11,7 +11,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.session.webui_turns import (
from nanobot.utils.webui_turn_helpers import (
TITLE_GENERATION_MAX_TOKENS,
TITLE_GENERATION_REASONING_EFFORT,
WEBUI_SESSION_METADATA_KEY,
@@ -143,7 +143,7 @@ def test_webui_title_update_uses_captured_llm_runtime(
return False
monkeypatch.setattr(
"nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
fake_title_after_turn,
)
coordinator = WebuiTurnCoordinator(
-178
View File
@@ -1,178 +0,0 @@
from pathlib import Path
from nanobot.agent.context import ContextBuilder
from nanobot.config.schema import InputLimitsConfig
from nanobot.utils.helpers import detect_audio_mime, video_mime_compat
PNG_BYTES = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
b"\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc``\x00\x00\x00\x04\x00\x01"
b"\x0b\x0e-\xb4"
b"\x00\x00\x00\x00IEND\xaeB`\x82"
)
WAV_BYTES = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
def _builder(tmp_path: Path, input_limits: InputLimitsConfig | None = None) -> ContextBuilder:
return ContextBuilder(tmp_path, input_limits=input_limits)
class TestAudioDetection:
def test_detect_wav_from_magic_bytes(self) -> None:
assert detect_audio_mime(WAV_BYTES) == "audio/wav"
def test_detect_mp3_from_magic_bytes(self) -> None:
mp3 = b"\xff\xfb\x90\x00"
assert detect_audio_mime(mp3) == "audio/mpeg"
def test_detect_fallback_to_filename(self) -> None:
assert detect_audio_mime(b"unknown", filename="song.mp3") == "audio/mpeg"
def test_returns_none_for_non_audio(self) -> None:
assert detect_audio_mime(PNG_BYTES) is None
class TestVideoMimeCompat:
def test_mp4_is_compatible(self) -> None:
assert video_mime_compat("video/mp4") is True
def test_unknown_is_not_compatible(self) -> None:
assert video_mime_compat("video/avi") is False
def test_none_is_not_compatible(self) -> None:
assert video_mime_compat(None) is False
class TestBuildUserContentMultimodal:
def test_audio_block_when_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "voice.wav"
path.write_bytes(WAV_BYTES)
content = builder._build_user_content("transcribe", [str(path)], supports_audio=True)
assert isinstance(content, list)
audio_blocks = [b for b in content if b.get("type") == "input_audio"]
assert len(audio_blocks) == 1
assert audio_blocks[0]["input_audio"]["format"] == "wav"
def test_audio_placeholder_when_not_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "voice.wav"
path.write_bytes(WAV_BYTES)
content = builder._build_user_content("transcribe", [str(path)], supports_audio=False)
assert isinstance(content, list)
assert any("[audio:" in b.get("text", "") for b in content)
def test_video_block_when_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "clip.mp4"
path.write_bytes(b"\x00" * 64)
content = builder._build_user_content("describe", [str(path)], supports_video=True)
assert isinstance(content, list)
video_blocks = [b for b in content if b.get("type") == "video_url"]
assert len(video_blocks) == 1
assert video_blocks[0]["video_url"]["url"].startswith("data:video/mp4;base64,")
def test_video_placeholder_when_not_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "clip.mp4"
path.write_bytes(b"\x00" * 64)
content = builder._build_user_content("describe", [str(path)], supports_video=False)
assert isinstance(content, list)
assert any("[video:" in b.get("text", "") for b in content)
def test_vision_fallback_downgrades_image(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "pic.png"
path.write_bytes(PNG_BYTES)
content = builder._build_user_content("look", [str(path)], supports_vision=False)
assert isinstance(content, list)
assert any("[image:" in b.get("text", "") for b in content)
assert not any(b.get("type") == "image_url" for b in content)
def test_image_limit_count(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
paths = []
for i in range(5):
path = tmp_path / f"img{i}.png"
path.write_bytes(PNG_BYTES)
paths.append(str(path))
content = builder._build_user_content("describe", paths)
assert isinstance(content, list)
image_count = sum(1 for b in content if b.get("type") == "image_url")
assert image_count == 3 # default max_input_images
assert any("only the first 3 images" in b.get("text", "") for b in content)
def test_image_limit_bytes(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
big = tmp_path / "big.png"
big.write_bytes(PNG_BYTES + b"x" * builder.input_limits.max_input_image_bytes)
content = builder._build_user_content("analyze", [str(big)])
assert isinstance(content, str)
assert "file too large" in content
def test_audio_limit_count(self, tmp_path: Path) -> None:
limits = InputLimitsConfig(max_input_audios=1)
builder = _builder(tmp_path, input_limits=limits)
for i in range(2):
path = tmp_path / f"snd{i}.wav"
path.write_bytes(WAV_BYTES)
content = builder._build_user_content(
"compare", [str(tmp_path / "snd0.wav"), str(tmp_path / "snd1.wav")], supports_audio=True
)
assert isinstance(content, list)
audio_count = sum(1 for b in content if b.get("type") == "input_audio")
assert audio_count == 1
assert any("only 1 audio" in b.get("text", "") for b in content)
def test_audio_limit_bytes(self, tmp_path: Path) -> None:
limits = InputLimitsConfig(max_input_audio_bytes=32)
builder = _builder(tmp_path, input_limits=limits)
path = tmp_path / "big.wav"
path.write_bytes(WAV_BYTES + b"x" * 64)
content = builder._build_user_content("analyze", [str(path)], supports_audio=True)
assert isinstance(content, str)
assert "file too large" in content
def test_mixed_media_types(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
img = tmp_path / "pic.png"
img.write_bytes(PNG_BYTES)
snd = tmp_path / "voice.wav"
snd.write_bytes(WAV_BYTES)
vid = tmp_path / "clip.mp4"
vid.write_bytes(b"\x00" * 64)
content = builder._build_user_content(
"analyze",
[str(img), str(snd), str(vid)],
supports_vision=True,
supports_audio=True,
supports_video=True,
)
assert isinstance(content, list)
assert any(b.get("type") == "image_url" for b in content)
assert any(b.get("type") == "input_audio" for b in content)
assert any(b.get("type") == "video_url" for b in content)
+1 -218
View File
@@ -6,7 +6,7 @@ import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.providers.base import LLMResponse
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@@ -77,220 +77,3 @@ async def test_runner_streams_provider_progress_deltas_by_default():
assert result.final_content == "hello"
assert [call.args[0] for call in progress_cb.await_args_list] == ["he", "llo"]
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_streams_live_write_file_activity_from_tool_argument_deltas(tmp_path):
provider = MagicMock()
provider.supports_progress_deltas = True
call_count = 0
progress_events: list[dict] = []
async def progress_cb(content, *, file_edit_events=None, **kwargs):
if file_edit_events:
progress_events.extend(file_edit_events)
class Tools:
def get_definitions(self):
return [{"type": "function", "function": {"name": "write_file"}}]
def get(self, name):
return None
async def execute(self, name, params):
assert name == "write_file"
assert any(event["approximate"] and event["added"] == 24 for event in progress_events)
target = tmp_path / params["path"]
target.write_text(params["content"], encoding="utf-8")
return "ok"
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
assert on_tool_call_delta is not None
await on_tool_call_delta({
"index": 0,
"call_id": "call-write",
"name": "write_file",
"arguments_delta": '{"path":"big.txt","content":"',
})
await on_tool_call_delta({"index": 0, "arguments_delta": "line\\n" * 24})
return LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call-write",
name="write_file",
arguments={"path": "big.txt", "content": "line\n" * 24},
)
],
usage={},
)
return LLMResponse(content="done", tool_calls=[], usage={})
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "write a large file"}],
tools=Tools(),
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path,
))
assert result.final_content == "done"
assert any(event["approximate"] and event["added"] == 24 for event in progress_events)
assert any(
not event["approximate"] and event["phase"] == "end" and event["added"] == 24
for event in progress_events
)
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(tmp_path):
provider = MagicMock()
provider.supports_progress_deltas = True
call_count = 0
progress_events: list[dict] = []
target = tmp_path / "notes.txt"
target.write_text("old\nkeep\n", encoding="utf-8")
async def progress_cb(content, *, file_edit_events=None, **kwargs):
if file_edit_events:
progress_events.extend(file_edit_events)
class Tools:
def get_definitions(self):
return [{"type": "function", "function": {"name": "edit_file"}}]
def get(self, name):
return None
async def execute(self, name, params):
assert name == "edit_file"
assert any(
event["tool"] == "edit_file"
and event["approximate"]
and event["added"] == 3
and event["deleted"] == 2
for event in progress_events
)
target.write_text(params["new_text"], encoding="utf-8")
return "ok"
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
assert on_tool_call_delta is not None
await on_tool_call_delta({
"index": 0,
"call_id": "call-edit",
"name": "edit_file",
"arguments_delta": (
'{"path":"notes.txt","old_text":"old\\nkeep\\n","new_text":"'
),
})
await on_tool_call_delta({
"index": 0,
"arguments_delta": "new\\nkeep\\nextra\\n",
})
await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
return LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call-edit",
name="edit_file",
arguments={
"path": "notes.txt",
"old_text": "old\nkeep\n",
"new_text": "new\nkeep\nextra\n",
},
)
],
usage={},
)
return LLMResponse(content="done", tool_calls=[], usage={})
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "edit a file"}],
tools=Tools(),
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path,
))
assert result.final_content == "done"
assert any(
event["tool"] == "edit_file"
and event["approximate"]
and event["added"] == 3
and event["deleted"] == 2
for event in progress_events
)
assert any(
event["tool"] == "edit_file"
and not event["approximate"]
and event["phase"] == "end"
and event["added"] == 2
and event["deleted"] == 1
for event in progress_events
)
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_marks_unfinished_live_write_file_activity_failed(tmp_path):
provider = MagicMock()
provider.supports_progress_deltas = True
progress_events: list[dict] = []
async def progress_cb(content, *, file_edit_events=None, **kwargs):
if file_edit_events:
progress_events.extend(file_edit_events)
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
assert on_tool_call_delta is not None
await on_tool_call_delta({
"index": 0,
"call_id": "call-write",
"name": "write_file",
"arguments_delta": '{"path":"aborted.txt","content":"partial\\n',
})
return LLMResponse(content="stopped", tool_calls=[], finish_reason="stop", usage={})
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = [{"type": "function", "function": {"name": "write_file"}}]
tools.get.return_value = None
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "write a large file"}],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path,
))
assert result.final_content == "stopped"
assert progress_events[-1]["path"] == "aborted.txt"
assert progress_events[-1]["phase"] == "error"
assert progress_events[-1]["status"] == "error"
provider.chat_with_retry.assert_not_awaited()
+34
View File
@@ -0,0 +1,34 @@
"""Tests for staging attachment paths into the media bucket for session replay."""
from pathlib import Path
from nanobot.config.loader import set_config_path
from nanobot.config.paths import get_media_dir
from nanobot.utils.session_attachments import stage_media_paths_for_session_replay
def test_persist_media_stages_workspace_file(tmp_path: Path) -> None:
set_config_path(tmp_path / "config.json")
outside = tmp_path / "workspace" / "report.md"
outside.parent.mkdir(parents=True)
outside.write_text("body", encoding="utf-8")
out = stage_media_paths_for_session_replay([str(outside)])
assert len(out) == 1
staged = Path(out[0])
assert staged.is_file()
assert staged.read_text(encoding="utf-8") == "body"
assert staged.resolve().is_relative_to(get_media_dir().resolve())
def test_persist_media_keeps_files_already_under_media_root(tmp_path: Path) -> None:
set_config_path(tmp_path / "config.json")
media = get_media_dir("websocket")
media.mkdir(parents=True, exist_ok=True)
inside = media / "keep-me.txt"
inside.write_text("x", encoding="utf-8")
out = stage_media_paths_for_session_replay([str(inside.resolve())])
assert out == [str(inside.resolve())]
+2 -22
View File
@@ -27,11 +27,10 @@ def test_extract_post_content_supports_post_wrapper_shape() -> None:
}
}
text, image_keys, media_items = _extract_post_content(payload)
text, image_keys = _extract_post_content(payload)
assert text == "日报 完成"
assert image_keys == ["img_1"]
assert media_items == []
def test_extract_post_content_keeps_direct_shape_behavior() -> None:
@@ -46,29 +45,10 @@ def test_extract_post_content_keeps_direct_shape_behavior() -> None:
],
}
text, image_keys, media_items = _extract_post_content(payload)
text, image_keys = _extract_post_content(payload)
assert text == "Daily report"
assert image_keys == ["img_a", "img_b"]
assert media_items == []
def test_extract_post_content_extracts_media_tags() -> None:
payload = {
"title": "Video",
"content": [
[
{"tag": "text", "text": "see this"},
{"tag": "media", "file_key": "vid_1"},
]
],
}
text, image_keys, media_items = _extract_post_content(payload)
assert text == "Video see this"
assert image_keys == []
assert media_items == [{"tag": "media", "file_key": "vid_1"}]
def test_register_optional_event_keeps_builder_when_method_missing() -> None:
File diff suppressed because it is too large Load Diff
+525
View File
@@ -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)))
+10 -179
View File
@@ -29,8 +29,7 @@ from nanobot.channels.websocket import (
publish_runtime_model_update,
)
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.webui.settings_api import settings_payload
from nanobot.config.schema import Config
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
@@ -757,7 +756,7 @@ async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> Non
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
from nanobot.session import webui_turns as wth
from nanobot.utils import webui_turn_helpers as wth
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
await channel._maybe_push_turn_run_wall_clock("chat-1")
@@ -770,7 +769,7 @@ async def test_maybe_push_turn_run_wall_clock_replays_running() -> None:
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
from nanobot.session import webui_turns as wth
from nanobot.utils import webui_turn_helpers as wth
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
try:
@@ -992,11 +991,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
config = Config()
config.agents.defaults.model = "openai/gpt-4o"
config.providers.openai.api_key = "secret-key"
config.model_presets["deep"] = ModelPresetConfig(
model="anthropic/claude-opus-4-5",
provider="anthropic",
reasoning_effort="high",
)
config.tools.web.search.provider = "brave"
config.tools.web.search.api_key = "brave-secret"
save_config(config, config_path)
@@ -1017,52 +1011,21 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
body = settings.json()
assert body["agent"]["model"] == "openai/gpt-4o"
assert body["agent"]["provider"] == "openai"
assert body["agent"]["model_preset"] == "default"
assert body["agent"]["max_tokens"] == 8192
assert body["agent"]["timezone"] == "UTC"
assert body["agent"]["tool_hint_max_length"] == 40
presets = {preset["name"]: preset for preset in body["model_presets"]}
assert presets["default"]["active"] is True
assert presets["deep"]["reasoning_effort"] == "high"
providers = {provider["name"]: provider for provider in body["providers"]}
assert providers["openai"]["configured"] is True
assert providers["openai"]["api_key_hint"] == "secr••••-key"
assert providers["azure_openai"]["api_key_required"] is True
assert providers["openrouter"]["configured"] is False
assert providers["openrouter"]["api_key_required"] is True
assert providers["skywork"]["label"] == "Skywork"
assert providers["skywork"]["default_api_base"] == "https://api.apifree.ai/v1"
assert providers["ant_ling"]["label"] == "Ant Ling"
assert providers["ant_ling"]["default_api_base"] == "https://api.ant-ling.com/v1"
assert providers["atomic_chat"]["configured"] is False
assert providers["atomic_chat"]["api_key_required"] is False
assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1"
assert body["agent"]["has_api_key"] is True
assert body["web_search"]["provider"] == "brave"
assert body["web_search"]["api_key_hint"] == "brav••••cret"
assert body["web_search"]["max_results"] == 5
assert body["web"]["fetch"]["use_jina_reader"] is True
search_providers = {provider["name"]: provider for provider in body["web_search"]["providers"]}
assert search_providers["duckduckgo"]["credential"] == "none"
assert search_providers["searxng"]["credential"] == "base_url"
assert body["image_generation"]["enabled"] is False
assert body["image_generation"]["provider"] == "openrouter"
assert body["image_generation"]["provider_configured"] is False
assert body["image_generation"]["default_aspect_ratio"] == "1:1"
image_providers = {
provider["name"]: provider
for provider in body["image_generation"]["providers"]
}
assert image_providers["openrouter"]["label"] == "OpenRouter"
assert image_providers["openrouter"]["configured"] is False
assert image_providers["gemini"]["label"] == "Gemini"
assert body["runtime"]["config_path"] == str(config_path)
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
assert workspace_path.endswith("/.nanobot/workspace")
assert body["runtime"]["gateway_port"] == 18790
assert body["advanced"]["exec_enabled"] is True
assert body["advanced"]["mcp_server_count"] == 0
assert body["restart_required_sections"] == []
assert "secret-key" not in settings.text
assert "brave-secret" not in settings.text
@@ -1077,7 +1040,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert provider_body["requires_restart"] is False
provider_rows = {provider["name"]: provider for provider in provider_body["providers"]}
assert provider_rows["openrouter"]["configured"] is True
assert provider_body["image_generation"]["provider_configured"] is True
assert "sk-or-test" not in provider_updated.text
local_provider_updated = await _http_get(
@@ -1097,117 +1059,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model=atomic_chat/test"
"&provider=atomic_chat&timezone=Asia%2FShanghai"
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
"&provider=atomic_chat",
headers={"Authorization": "Bearer tok"},
)
assert updated.status_code == 200
updated_body = updated.json()
assert updated_body["requires_restart"] is True
assert updated_body["restart_required_sections"] == ["runtime"]
preset_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model_preset=deep",
headers={"Authorization": "Bearer tok"},
)
assert preset_updated.status_code == 200
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
bad_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model_preset=missing",
headers={"Authorization": "Bearer tok"},
)
assert bad_preset.status_code == 400
assert updated.json()["requires_restart"] is False
search_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=searxng"
"&base_url=https%3A%2F%2Fsearch.example.com"
"&max_results=8&timeout=45&use_jina_reader=false",
"&base_url=https%3A%2F%2Fsearch.example.com",
headers={"Authorization": "Bearer tok"},
)
assert search_updated.status_code == 200
search_body = search_updated.json()
assert search_body["requires_restart"] is True
assert search_body["restart_required_sections"] == ["runtime", "web"]
assert search_body["requires_restart"] is False
assert search_body["web_search"]["provider"] == "searxng"
assert search_body["web_search"]["api_key_hint"] is None
assert search_body["web_search"]["base_url"] == "https://search.example.com"
assert search_body["web_search"]["max_results"] == 8
assert search_body["web"]["fetch"]["use_jina_reader"] is False
image_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/image-generation/update?enabled=true"
"&provider=openrouter&model=openai%2Fgpt-image-1"
"&default_aspect_ratio=16%3A9&default_image_size=2K"
"&max_images_per_turn=3",
headers={"Authorization": "Bearer tok"},
)
assert image_updated.status_code == 200
image_body = image_updated.json()
assert image_body["requires_restart"] is True
assert image_body["restart_required_sections"] == ["image", "runtime", "web"]
assert image_body["image_generation"]["enabled"] is True
assert image_body["image_generation"]["model"] == "openai/gpt-image-1"
assert image_body["image_generation"]["default_aspect_ratio"] == "16:9"
assert image_body["image_generation"]["default_image_size"] == "2K"
assert image_body["image_generation"]["max_images_per_turn"] == 3
image_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=openrouter"
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
headers={"Authorization": "Bearer tok"},
)
assert image_provider_updated.status_code == 200
assert image_provider_updated.json()["requires_restart"] is True
assert image_provider_updated.json()["restart_required_sections"] == [
"image",
"runtime",
"web",
]
assert "sk-or-next" not in image_provider_updated.text
bad_web = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
headers={"Authorization": "Bearer tok"},
)
assert bad_web.status_code == 400
bad_image = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/image-generation/update?provider=missing",
headers={"Authorization": "Bearer tok"},
)
assert bad_image.status_code == 400
saved = load_config(config_path)
assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat"
assert saved.agents.defaults.model_preset == "deep"
assert saved.agents.defaults.timezone == "Asia/Shanghai"
assert saved.agents.defaults.bot_name == "Nano"
assert saved.agents.defaults.bot_icon == "N"
assert saved.agents.defaults.tool_hint_max_length == 120
assert saved.providers.openrouter.api_key == "sk-or-next"
assert saved.providers.openrouter.api_key == "sk-or-test"
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
assert saved.tools.web.search.provider == "searxng"
assert saved.tools.web.search.api_key == ""
assert saved.tools.web.search.base_url == "https://search.example.com"
assert saved.tools.web.search.max_results == 8
assert saved.tools.web.search.timeout == 45
assert saved.tools.web.fetch.use_jina_reader is False
assert saved.tools.image_generation.enabled is True
assert saved.tools.image_generation.provider == "openrouter"
assert saved.tools.image_generation.model == "openai/gpt-image-1"
assert saved.tools.image_generation.default_aspect_ratio == "16:9"
assert saved.tools.image_generation.default_image_size == "2K"
assert saved.tools.image_generation.max_images_per_turn == 3
finally:
await channel.stop()
await server_task
@@ -1252,7 +1131,7 @@ def test_settings_payload_normalizes_camel_case_provider(
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
body = settings_payload()
body = _ch(bus)._settings_payload()
assert body["agent"]["provider"] == "minimax_anthropic"
@@ -1669,54 +1548,6 @@ def test_parse_envelope_rejects_legacy_and_garbage() -> None:
assert _parse_envelope('{"type":123}') is None
def test_sessions_list_includes_active_run_started_at() -> None:
from websockets.datastructures import Headers
from websockets.http11 import Request
from nanobot.session import webui_turns as wth
bus = MagicMock()
channel = _ch(bus)
channel._api_tokens["tok"] = time.monotonic() + 300.0
channel._session_manager = MagicMock()
channel._session_manager.list_sessions.return_value = [
{
"key": "websocket:chat-1",
"created_at": "2026-05-19T10:00:00Z",
"updated_at": "2026-05-19T10:01:00Z",
"title": "Running",
"preview": "work",
"path": "/private/path",
},
{
"key": "cli:chat-2",
"created_at": "2026-05-19T10:00:00Z",
"updated_at": "2026-05-19T10:01:00Z",
},
]
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
try:
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0
req = Request("/api/sessions", Headers([("Authorization", "Bearer tok")]))
resp = channel._handle_sessions_list(req)
finally:
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
assert resp.status_code == 200
body = json.loads(resp.body.decode())
assert body["sessions"] == [
{
"key": "websocket:chat-1",
"created_at": "2026-05-19T10:00:00Z",
"updated_at": "2026-05-19T10:01:00Z",
"title": "Running",
"preview": "work",
"run_started_at": 1_700_000_000.0,
}
]
@pytest.mark.parametrize(
("value", "expected"),
[
@@ -1743,7 +1574,7 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
from websockets.datastructures import Headers
from websockets.http11 import Request
from nanobot.webui.transcript import append_transcript_object
from nanobot.utils.webui_transcript import append_transcript_object
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:c1"
+1 -51
View File
@@ -6,7 +6,6 @@ import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import urlencode
import httpx
import pytest
@@ -177,62 +176,13 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
await server_task
@pytest.mark.asyncio
async def test_webui_sidebar_state_routes_are_config_dir_scoped(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:sidebar")
channel = _ch(bus, session_manager=sm, port=29911)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29911/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
initial = await _http_get(
"http://127.0.0.1:29911/api/webui/sidebar-state",
headers=auth,
)
assert initial.status_code == 200
assert initial.json()["schema_version"] == 1
assert initial.json()["pinned_keys"] == []
payload = {
"pinned_keys": ["websocket:sidebar"],
"archived_keys": ["websocket:old"],
"title_overrides": {"websocket:sidebar": "Pinned work"},
"view": {"density": "compact", "show_archived": True},
}
query = urlencode({"state": json.dumps(payload)})
updated = await _http_get(
f"http://127.0.0.1:29911/api/webui/sidebar-state/update?{query}",
headers=auth,
)
assert updated.status_code == 200
body = updated.json()
assert body["pinned_keys"] == ["websocket:sidebar"]
assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
assert body["view"]["density"] == "compact"
state_path = tmp_path / "webui" / "sidebar-state.json"
assert state_path.is_file()
assert json.loads(state_path.read_text(encoding="utf-8"))["pinned_keys"] == [
"websocket:sidebar"
]
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_delete_removes_file(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
from nanobot.webui.transcript import append_transcript_object
from nanobot.utils.webui_transcript import append_transcript_object
append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"})
channel = _ch(bus, session_manager=sm, port=29903)
-73
View File
@@ -1,73 +0,0 @@
"""Tests for the Ant Ling 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_ant_ling_config_field_exists() -> None:
config = ProvidersConfig()
assert hasattr(config, "ant_ling")
def test_ant_ling_provider_in_registry() -> None:
specs = {spec.name: spec for spec in PROVIDERS}
assert "ant_ling" in specs
ant_ling = specs["ant_ling"]
assert ant_ling.backend == "openai_compat"
assert ant_ling.env_key == "ANT_LING_API_KEY"
assert ant_ling.display_name == "Ant Ling"
assert ant_ling.default_api_base == "https://api.ant-ling.com/v1"
def test_find_by_name_accepts_ant_ling_spellings() -> None:
spec = find_by_name("ant_ling")
assert spec is not None
assert find_by_name("ant-ling") is spec
assert find_by_name("antLing") is spec
def test_ant_ling_model_auto_matches_with_default_api_base() -> None:
config = Config.model_validate({
"providers": {
"antLing": {
"apiKey": "ling-key",
},
},
"agents": {
"defaults": {
"model": "Ling-2.6-flash",
},
},
})
assert config.get_provider_name("Ling-2.6-flash") == "ant_ling"
assert config.get_api_key("Ling-2.6-flash") == "ling-key"
assert config.get_api_base("Ling-2.6-flash") == "https://api.ant-ling.com/v1"
def test_ant_ling_preserves_official_model_name() -> None:
spec = find_by_name("ant_ling")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider(
api_key="ling-key",
default_model="Ling-2.6-flash",
spec=spec,
)
kwargs = provider._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="Ling-2.6-flash",
max_tokens=1024,
temperature=0.7,
reasoning_effort=None,
tool_choice=None,
)
assert kwargs["model"] == "Ling-2.6-flash"
@@ -129,74 +129,6 @@ async def test_chat_stream_invokes_on_thinking_delta_for_thinking_delta() -> Non
assert text_parts == ["X"]
@pytest.mark.asyncio
async def test_chat_stream_invokes_tool_call_delta_for_input_json_delta() -> None:
provider = AnthropicProvider(api_key="sk-test")
provider._client = MagicMock()
chunks = [
SimpleNamespace(
type="content_block_start",
index=1,
content_block=SimpleNamespace(
type="tool_use",
id="toolu_1",
name="write_file",
),
),
SimpleNamespace(
type="content_block_delta",
index=1,
delta=SimpleNamespace(
type="input_json_delta",
partial_json='{"path":"notes.md","content":"',
),
),
SimpleNamespace(
type="content_block_delta",
index=1,
delta=SimpleNamespace(type="input_json_delta", partial_json="line\\n"),
),
]
fake = _FakeAsyncStream(chunks)
stream_cm = MagicMock()
stream_cm.__aenter__ = AsyncMock(return_value=fake)
stream_cm.__aexit__ = AsyncMock(return_value=None)
provider._client.messages.stream = MagicMock(return_value=stream_cm)
deltas: list[dict] = []
async def on_tool_delta(delta: dict) -> None:
deltas.append(delta)
await provider.chat_stream(
messages=[{"role": "user", "content": "write"}],
on_tool_call_delta=on_tool_delta,
)
assert deltas == [
{
"index": 1,
"call_id": "toolu_1",
"name": "write_file",
"arguments_delta": "",
},
{
"index": 1,
"call_id": "toolu_1",
"name": "write_file",
"arguments_delta": '{"path":"notes.md","content":"',
},
{
"index": 1,
"call_id": "toolu_1",
"name": "write_file",
"arguments_delta": "line\\n",
},
]
fake.get_final_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_chat_stream_without_callback_still_finalizes() -> None:
provider = AnthropicProvider(api_key="sk-test")
-178
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import base64
from pathlib import Path
from typing import Any
@@ -12,9 +11,7 @@ from nanobot.providers.image_generation import (
GeminiImageGenerationClient,
GeneratedImageResponse,
ImageGenerationError,
MiniMaxImageGenerationClient,
OpenRouterImageGenerationClient,
StepFunImageGenerationClient,
)
PNG_BYTES = (
@@ -27,7 +24,6 @@ PNG_DATA_URL = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
JPEG_BYTES = b"\xff\xd8\xff\xe0" + b"0" * 12
class FakeResponse:
@@ -209,20 +205,6 @@ async def test_aihubmix_image_generation_downloads_url_response() -> None:
assert fake.get_calls[0]["url"] == "https://cdn.example/image.png"
@pytest.mark.asyncio
async def test_aihubmix_base64_response_uses_detected_mime() -> None:
raw_b64 = base64.b64encode(JPEG_BYTES).decode("ascii")
fake = FakeClient(FakeResponse({"output": {"b64_json": raw_b64}}))
client = AIHubMixImageGenerationClient(
api_key="sk-ahm-test",
client=fake, # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="gpt-image-2-free")
assert response.images == [f"data:image/jpeg;base64,{raw_b64}"]
RAW_B64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
@@ -355,163 +337,3 @@ async def test_gemini_no_images_raises() -> None:
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="gemini-2.0-flash-preview-image-generation")
@pytest.mark.asyncio
async def test_minimax_payload_and_response_with_reference_image(tmp_path: Path) -> None:
ref = tmp_path / "ref.png"
ref.write_bytes(PNG_BYTES)
fake = FakeClient(FakeResponse({"data": {"image_base64": [RAW_B64]}}))
client = MiniMaxImageGenerationClient(
api_key="sk-mm-test",
api_base="https://api.minimaxi.com/v1/",
extra_headers={"X-Test": "1"},
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="draw a character",
model="image-01",
reference_images=[str(ref)],
aspect_ratio="21:9",
)
assert response.images == [PNG_DATA_URL]
call = fake.calls[0]
assert call["url"] == "https://api.minimaxi.com/v1/image_generation"
assert call["headers"]["Authorization"] == "Bearer sk-mm-test"
assert call["headers"]["X-Test"] == "1"
body = call["json"]
assert body["model"] == "image-01"
assert body["prompt"] == "draw a character"
assert body["response_format"] == "base64"
assert body["aspect_ratio"] == "21:9"
assert body["subject_reference"][0]["type"] == "character"
assert body["subject_reference"][0]["image_file"].startswith("data:image/png;base64,")
# ---------------------------------------------------------------------------
# StepFun (阶跃星辰)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stepfun_payload_and_response_with_aspect_ratio() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = StepFunImageGenerationClient(
api_key="sk-sf-test",
api_base="https://api.stepfun.com/v1",
extra_headers={"X-Test": "1"},
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="a cat on the moon",
model="step-image-edit-2",
aspect_ratio="16:9",
)
assert response.images == [PNG_DATA_URL]
call = fake.calls[0]
assert call["url"] == "https://api.stepfun.com/v1/images/generations"
assert call["headers"]["Authorization"] == "Bearer sk-sf-test"
assert call["headers"]["X-Test"] == "1"
body = call["json"]
assert body["model"] == "step-image-edit-2"
assert body["prompt"] == "a cat on the moon"
assert body["response_format"] == "b64_json"
assert body["n"] == 1
assert body["size"] == "1280x800"
@pytest.mark.asyncio
async def test_stepfun_default_size_when_no_aspect_ratio() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = StepFunImageGenerationClient(
api_key="sk-sf-test",
api_base="https://api.stepfun.com/v1",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="a dog", model="step-image-edit-2")
body = fake.calls[0]["json"]
assert body["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_stepfun_uses_explicit_image_size() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = StepFunImageGenerationClient(
api_key="sk-sf-test",
api_base="https://api.stepfun.com/v1",
client=fake, # type: ignore[arg-type]
)
await client.generate(
prompt="a bird",
model="step-image-edit-2",
image_size="1024x1024",
)
body = fake.calls[0]["json"]
assert body["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_stepfun_style_reference_on_1x_model(tmp_path: Path) -> None:
"""step-1x-medium supports style_reference for reference-image generation."""
ref = tmp_path / "ref.png"
ref.write_bytes(PNG_BYTES)
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = StepFunImageGenerationClient(
api_key="sk-sf-test",
api_base="https://api.stepfun.com/v1",
client=fake, # type: ignore[arg-type]
)
await client.generate(
prompt="in this style",
model="step-1x-medium",
reference_images=[str(ref)],
)
body = fake.calls[0]["json"]
assert "style_reference" in body
assert body["style_reference"]["source_url"].startswith("data:image/png;base64,")
@pytest.mark.asyncio
async def test_stepfun_no_style_reference_on_non_1x_model() -> None:
"""step-image-edit-2 does not use style_reference; reference images are ignored."""
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = StepFunImageGenerationClient(
api_key="sk-sf-test",
api_base="https://api.stepfun.com/v1",
client=fake, # type: ignore[arg-type]
)
await client.generate(
prompt="a flower",
model="step-image-edit-2",
reference_images=["/tmp/ref.png"],
)
body = fake.calls[0]["json"]
assert "style_reference" not in body
@pytest.mark.asyncio
async def test_stepfun_requires_api_key() -> None:
client = StepFunImageGenerationClient(api_key=None)
with pytest.raises(ImageGenerationError, match="API key"):
await client.generate(prompt="draw", model="step-image-edit-2")
@pytest.mark.asyncio
async def test_stepfun_no_images_raises() -> None:
fake = FakeClient(FakeResponse({"data": [{"text": "sorry"}]}))
client = StepFunImageGenerationClient(api_key="sk-sf-test", client=fake) # type: ignore[arg-type]
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="step-image-edit-2")
-216
View File
@@ -164,130 +164,6 @@ def _fake_chat_stream_reasoning_chunks():
return _stream()
def _fake_chat_stream_tool_call_chunks():
"""Mimic OpenAI-compatible streaming tool-call argument deltas."""
async def _stream():
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason=None,
delta=SimpleNamespace(
content=None,
reasoning_content=None,
reasoning=None,
tool_calls=[
SimpleNamespace(
index=0,
id="call_write",
function=SimpleNamespace(
name="write_file",
arguments='{"path":"notes.md","content":"',
),
)
],
),
),
],
usage=None,
)
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason=None,
delta=SimpleNamespace(
content=None,
reasoning_content=None,
reasoning=None,
tool_calls=[
SimpleNamespace(
index=0,
id=None,
function=SimpleNamespace(name=None, arguments='line\\n"}'),
)
],
),
),
],
usage=None,
)
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="tool_calls",
delta=SimpleNamespace(
content=None,
reasoning_content=None,
reasoning=None,
tool_calls=None,
),
),
],
usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15),
)
return _stream()
def _fake_chat_stream_legacy_function_call_chunks():
"""Mimic older OpenAI-compatible ``delta.function_call`` chunks."""
async def _stream():
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason=None,
delta=SimpleNamespace(
content=None,
reasoning_content=None,
reasoning=None,
tool_calls=None,
function_call=SimpleNamespace(
name="write_file",
arguments='{"path":"notes.md","content":"',
),
),
),
],
usage=None,
)
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason=None,
delta=SimpleNamespace(
content=None,
reasoning_content=None,
reasoning=None,
tool_calls=None,
function_call=SimpleNamespace(
name=None,
arguments='line\\n"}',
),
),
),
],
usage=None,
)
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="function_call",
delta=SimpleNamespace(
content=None,
reasoning_content=None,
reasoning=None,
tool_calls=None,
function_call=None,
),
),
],
usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15),
)
return _stream()
@pytest.mark.asyncio
async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -> None:
"""Regression: DeepSeek-V4 / reasoner expose ``delta.reasoning_content`` during streaming."""
@@ -326,98 +202,6 @@ async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -
mock_chat.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("provider_name", "model"),
[
("openai", "gpt-4o"),
("deepseek", "deepseek-chat"),
("minimax", "MiniMax-M2.7"),
("zhipu", "glm-4.6"),
],
)
async def test_openai_compat_stream_forwards_tool_call_argument_deltas(
provider_name: str,
model: str,
) -> None:
mock_chat = AsyncMock(return_value=_fake_chat_stream_tool_call_chunks())
spec = find_by_name(provider_name)
deltas: list[dict] = []
async def on_tool_delta(delta: dict) -> None:
deltas.append(delta)
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_openai:
client_instance = mock_openai.return_value
client_instance.chat.completions.create = mock_chat
provider = OpenAICompatProvider(
api_key="sk-test",
default_model=model,
spec=spec,
)
result = await provider.chat_stream(
messages=[{"role": "user", "content": "write"}],
tools=[{"type": "function", "function": {"name": "write_file"}}],
model=model,
on_tool_call_delta=on_tool_delta,
)
assert deltas == [
{
"index": 0,
"call_id": "call_write",
"name": "write_file",
"arguments_delta": '{"path":"notes.md","content":"',
},
{"index": 0, "call_id": "", "name": "", "arguments_delta": 'line\\n"}'},
]
assert result.tool_calls[0].name == "write_file"
assert result.tool_calls[0].arguments == {"path": "notes.md", "content": "line\n"}
kwargs = mock_chat.await_args.kwargs
if provider_name == "zhipu":
assert kwargs["extra_body"]["tool_stream"] is True
else:
assert kwargs.get("extra_body", {}).get("tool_stream") is None
@pytest.mark.asyncio
async def test_openai_compat_stream_forwards_legacy_function_call_argument_deltas() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_stream_legacy_function_call_chunks())
deltas: list[dict] = []
async def on_tool_delta(delta: dict) -> None:
deltas.append(delta)
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_openai:
client_instance = mock_openai.return_value
client_instance.chat.completions.create = mock_chat
provider = OpenAICompatProvider(
api_key="sk-test",
default_model="deepseek-chat",
spec=find_by_name("deepseek"),
)
result = await provider.chat_stream(
messages=[{"role": "user", "content": "write"}],
tools=[{"type": "function", "function": {"name": "write_file"}}],
model="deepseek-chat",
on_tool_call_delta=on_tool_delta,
)
assert deltas == [
{
"index": 0,
"call_id": "",
"name": "write_file",
"arguments_delta": '{"path":"notes.md","content":"',
},
{"index": 0, "call_id": "", "name": "", "arguments_delta": 'line\\n"}'},
]
assert result.tool_calls[0].name == "write_file"
assert result.tool_calls[0].arguments == {"path": "notes.md", "content": "line\n"}
class _FakeResponsesError(Exception):
def __init__(self, status_code: int, text: str):
super().__init__(text)
+1 -7
View File
@@ -44,15 +44,9 @@ class TestShouldExecuteTools:
resp = _response("stop")
assert resp.should_execute_tools is True
def test_legacy_function_call_reason_executes(self) -> None:
# Older OpenAI-compatible streaming APIs can still use the singular
# function_call finish reason while carrying a tool-call-shaped payload.
resp = _response("function_call")
assert resp.should_execute_tools is True
@pytest.mark.parametrize(
"anomalous_reason",
["refusal", "content_filter", "error", "length", ""],
["refusal", "content_filter", "error", "length", "function_call", ""],
)
def test_tool_calls_under_anomalous_reason_blocked(self, anomalous_reason: str) -> None:
# This is the #3220 bug: gateways injecting tool_calls under any of these
@@ -16,15 +16,7 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
lambda: SimpleNamespace(account_id="acct", access="token"),
)
async def fake_request(
url,
headers,
body,
verify,
on_content_delta=None,
on_tool_call_delta=None,
):
_ = on_tool_call_delta
async def fake_request(url, headers, body, verify, on_content_delta=None):
bodies.append(body)
return "ok", [], "stop"
-50
View File
@@ -453,56 +453,6 @@ class TestConsumeSdkStream:
assert tool_calls[0].name == "get_weather"
assert tool_calls[0].arguments == {"city": "SF"}
@pytest.mark.asyncio
async def test_tool_call_argument_delta_callback(self):
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
item_added.name = "write_file"
ev1 = MagicMock(type="response.output_item.added", item=item_added)
ev2 = MagicMock(
type="response.function_call_arguments.delta",
call_id="c1",
delta='{"path":"a.txt","content":"',
)
ev3 = MagicMock(
type="response.function_call_arguments.delta",
call_id="c1",
delta='hello\\n',
)
ev4 = MagicMock(
type="response.function_call_arguments.done",
call_id="c1",
arguments='{"path":"a.txt","content":"hello\\n"}',
)
item_done = MagicMock(
type="function_call",
call_id="c1",
id="fc1",
arguments='{"path":"a.txt","content":"hello\\n"}',
)
item_done.name = "write_file"
ev5 = MagicMock(type="response.output_item.done", item=item_done)
resp_obj = MagicMock(status="completed", usage=None, output=[])
ev6 = MagicMock(type="response.completed", response=resp_obj)
deltas: list[dict] = []
async def cb(delta: dict) -> None:
deltas.append(delta)
async def stream():
for e in [ev1, ev2, ev3, ev4, ev5, ev6]:
yield e
await consume_sdk_stream(stream(), on_tool_call_delta=cb)
assert deltas == [
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
{
"call_id": "c1",
"name": "write_file",
"arguments_delta": '{"path":"a.txt","content":"',
},
{"call_id": "c1", "name": "write_file", "arguments_delta": "hello\\n"},
]
@pytest.mark.asyncio
async def test_usage_extracted(self):
usage_obj = MagicMock(input_tokens=10, output_tokens=5, total_tokens=15)
+2 -2
View File
@@ -242,7 +242,7 @@ async def test_image_fallback_returns_error_on_second_failure() -> None:
@pytest.mark.asyncio
async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
"""When _meta is absent, fallback placeholder is '[image]'."""
"""When _meta is absent, fallback placeholder is '[image omitted]'."""
provider = ScriptedProvider([
LLMResponse(content="error", finish_reason="error"),
LLMResponse(content="ok"),
@@ -256,7 +256,7 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
for msg in msgs_on_retry:
content = msg.get("content")
if isinstance(content, list):
assert any("[image]" in (b.get("text") or "") for b in content)
assert any("[image omitted]" in (b.get("text") or "") for b in content)
@pytest.mark.asyncio
-80
View File
@@ -1,80 +0,0 @@
"""Tests for the Skywork 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_skywork_config_field_exists() -> None:
config = ProvidersConfig()
assert hasattr(config, "skywork")
def test_skywork_provider_in_registry() -> None:
specs = {spec.name: spec for spec in PROVIDERS}
assert "skywork" in specs
skywork = specs["skywork"]
assert skywork.backend == "openai_compat"
assert skywork.env_key == "SKYWORK_API_KEY"
assert ("APIFREE_API_KEY", "{api_key}") in skywork.env_extras
assert skywork.display_name == "Skywork"
assert skywork.is_gateway is True
assert skywork.detect_by_base_keyword == "apifree.ai"
assert skywork.default_api_base == "https://api.apifree.ai/v1"
assert skywork.supports_max_completion_tokens is False
def test_find_by_name_skywork() -> None:
spec = find_by_name("skywork")
assert spec is not None
assert spec.name == "skywork"
def test_skywork_model_auto_matches_with_default_api_base() -> None:
config = Config.model_validate(
{
"providers": {
"skywork": {
"apiKey": "sky-key",
},
},
"agents": {
"defaults": {
"model": "skywork-ai/skyclaw-v1",
},
},
}
)
assert config.get_provider_name("skywork-ai/skyclaw-v1") == "skywork"
assert config.get_api_key("skywork-ai/skyclaw-v1") == "sky-key"
assert config.get_api_base("skywork-ai/skyclaw-v1") == "https://api.apifree.ai/v1"
def test_skywork_preserves_model_id_and_uses_chat_completion_max_tokens() -> None:
spec = find_by_name("skywork")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider(
api_key="sky-key",
default_model="skywork-ai/skyclaw-v1",
spec=spec,
)
kwargs = provider._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="skywork-ai/skyclaw-v1",
max_tokens=1024,
temperature=0.7,
reasoning_effort=None,
tool_choice=None,
)
assert kwargs["model"] == "skywork-ai/skyclaw-v1"
assert kwargs["max_tokens"] == 1024
assert "max_completion_tokens" not in kwargs
-44
View File
@@ -29,47 +29,3 @@ def test_sanitize_persisted_blocks_truncate_text_shadowing_regression() -> None:
assert isinstance(out[0]["text"], str)
assert out[0]["text"] != content[0]["text"]
def test_sanitize_persisted_blocks_strips_audio_and_video() -> None:
"""Audio and video blocks with base64 payloads must be replaced with placeholders."""
from nanobot.agent.loop import AgentLoop
dummy = SimpleNamespace(max_tool_result_chars=1000)
content = [
{"type": "text", "text": "analyze this"},
{
"type": "input_audio",
"input_audio": {"data": "aGVsbG8=", "format": "wav"},
"_meta": {"path": "/tmp/voice.wav"},
},
{
"type": "video_url",
"video_url": {"url": "data:video/mp4;base64,aGVsbG8="},
"_meta": {"path": "/tmp/clip.mp4"},
},
]
out = AgentLoop._sanitize_persisted_blocks(dummy, content)
assert len(out) == 3
assert out[0] == content[0]
assert out[1] == {"type": "text", "text": "[audio: /tmp/voice.wav]"}
assert out[2] == {"type": "text", "text": "[video: /tmp/clip.mp4]"}
def test_sanitize_persisted_blocks_strips_audio_video_without_meta() -> None:
"""When _meta is absent, fallback placeholders use bare label."""
from nanobot.agent.loop import AgentLoop
dummy = SimpleNamespace(max_tool_result_chars=1000)
content = [
{"type": "input_audio", "input_audio": {"data": "aGVsbG8=", "format": "wav"}},
{"type": "video_url", "video_url": {"url": "data:video/mp4;base64,aGVsbG8="}},
]
out = AgentLoop._sanitize_persisted_blocks(dummy, content)
assert len(out) == 2
assert out[0] == {"type": "text", "text": "[audio]"}
assert out[1] == {"type": "text", "text": "[video]"}
+4 -4
View File
@@ -44,8 +44,8 @@ async def test_generate_image_tool_stores_artifact_and_source_images(
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 == "openrouter" else None,
"nanobot.agent.tools.image_generation.OpenRouterImageGenerationClient",
FakeImageClient,
)
ref = tmp_path / "ref.png"
ref.write_bytes(PNG_BYTES)
@@ -98,8 +98,8 @@ async def test_generate_image_tool_selects_aihubmix_provider(
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 == "aihubmix" else None,
"nanobot.agent.tools.image_generation.AIHubMixImageGenerationClient",
FakeImageClient,
)
tool = ImageGenerationTool(
workspace=tmp_path,
+21
View File
@@ -10,6 +10,8 @@ from nanobot.config.loader import set_config_path
from nanobot.utils.artifacts import (
ArtifactError,
decode_image_data_url,
generated_image_paths_from_messages,
generated_image_tool_result,
store_generated_image_artifact,
)
@@ -64,3 +66,22 @@ def test_store_generated_image_artifact_rejects_unsafe_save_dir(tmp_path: Path)
model="m",
save_dir="../outside",
)
def test_generated_image_paths_from_tool_results() -> None:
result = generated_image_tool_result(
[
{"id": "img_1", "path": "/tmp/one.png"},
{"id": "img_2", "path": "/tmp/two.png"},
]
)
payload = json.loads(result)
assert generated_image_paths_from_messages(
[
{"role": "tool", "name": "generate_image", "content": result},
{"role": "tool", "name": "other", "content": result},
]
) == ["/tmp/one.png", "/tmp/two.png"]
assert "runtime attaches generated images automatically" in payload["next_step"]
assert "Do not call message" in payload["next_step"]
-309
View File
@@ -1,8 +1,6 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from nanobot.utils.file_edit_events import (
build_file_edit_end_event,
@@ -10,7 +8,6 @@ from nanobot.utils.file_edit_events import (
line_diff_stats,
prepare_file_edit_tracker,
read_file_snapshot,
StreamingFileEditTracker,
)
@@ -23,10 +20,6 @@ def test_line_diff_stats_normalizes_crlf() -> None:
assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0)
def test_line_diff_stats_counts_new_file_crlf_lines_once() -> None:
assert line_diff_stats("", "a\r\nb\r\n") == (2, 0)
def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None:
target = tmp_path / "notes.txt"
target.write_text("old\nkeep\n", encoding="utf-8")
@@ -46,7 +39,6 @@ def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path)
"call_id": "call-write",
"tool": "write_file",
"path": "notes.txt",
"absolute_path": (tmp_path / "notes.txt").resolve().as_posix(),
"phase": "start",
"added": 2,
"deleted": 1,
@@ -81,307 +73,6 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
assert (event["added"], event["deleted"]) == (0, 0)
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)}
tracker = prepare_file_edit_tracker(
call_id="call-large",
tool_name="write_file",
tool=None,
workspace=tmp_path,
params=params,
)
assert tracker is not None
target.write_text(params["content"], encoding="utf-8")
event = build_file_edit_end_event(tracker, params)
assert event.get("binary") is not True
assert event["added"] == 1
assert event["deleted"] == 0
def test_streaming_write_file_tracker_emits_live_line_counts(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-live",
"name": "write_file",
"arguments_delta": '{"path":"notes.md","content":"',
})
await tracker.update({
"index": 0,
"arguments_delta": "line\\n" * 24,
})
asyncio.run(run())
assert events[0] == {
"version": 1,
"call_id": "call-live",
"tool": "write_file",
"path": "notes.md",
"absolute_path": (tmp_path / "notes.md").resolve().as_posix(),
"phase": "start",
"added": 0,
"deleted": 0,
"approximate": True,
"status": "editing",
}
assert events[-1]["path"] == "notes.md"
assert events[-1]["status"] == "editing"
assert events[-1]["approximate"] is True
assert events[-1]["added"] == 24
assert events[-1]["deleted"] == 0
def test_streaming_write_file_tracker_emits_pending_before_path(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-live",
"name": "write_file",
"arguments_delta": '{"content":"line\\n',
})
await tracker.update({
"index": 0,
"arguments_delta": 'more\\n","path":"late.md"',
})
asyncio.run(run())
assert events[0] == {
"version": 1,
"call_id": "call-live",
"tool": "write_file",
"path": "",
"phase": "start",
"added": 1,
"deleted": 0,
"approximate": True,
"status": "editing",
"pending": True,
}
assert events[-1]["path"] == "late.md"
assert events[-1].get("pending") is not True
assert events[-1]["added"] == 2
def test_streaming_write_file_tracker_flushes_small_pending_count(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-live",
"name": "write_file",
"arguments_delta": '{"path":"small.md","content":"one\\n',
})
await tracker.flush()
asyncio.run(run())
assert events
assert events[-1]["path"] == "small.md"
assert events[-1]["added"] == 1
def test_streaming_write_file_tracker_normalizes_crlf_line_counts(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-live",
"name": "write_file",
"arguments_delta": '{"path":"windows.txt","content":"one\\r\\ntwo\\r\\n',
})
await tracker.flush()
asyncio.run(run())
assert events[-1]["path"] == "windows.txt"
assert events[-1]["added"] == 2
def test_streaming_write_file_tracker_counts_unicode_escaped_newlines(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-live",
"name": "write_file",
"arguments_delta": '{"path":"unicode.txt","content":"one\\u000atwo',
})
await tracker.flush()
asyncio.run(run())
assert events[-1]["path"] == "unicode.txt"
assert events[-1]["added"] == 2
def test_streaming_edit_file_tracker_emits_live_line_counts(tmp_path: Path) -> None:
target = tmp_path / "notes.md"
target.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-edit",
"name": "edit_file",
"arguments_delta": '{"path":"notes.md","old_text":"old\\nkeep","new_text":"',
})
await tracker.update({
"index": 0,
"arguments_delta": "new\\nkeep\\nextra\\n" * 8,
})
asyncio.run(run())
assert events[0] == {
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "notes.md",
"absolute_path": (tmp_path / "notes.md").resolve().as_posix(),
"phase": "start",
"added": 0,
"deleted": 2,
"approximate": True,
"status": "editing",
}
assert events[-1]["path"] == "notes.md"
assert events[-1]["status"] == "editing"
assert events[-1]["approximate"] is True
assert events[-1]["added"] == 24
assert events[-1]["deleted"] == 2
def test_streaming_tracker_applies_canonical_call_id_to_final_tool(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,
"name": "write_file",
"arguments_delta": '{"path":"matched.md","content":"one\\n',
})
final = SimpleNamespace(
id="provider-final-id",
name="write_file",
arguments={"path": "matched.md", "content": "one\n"},
)
tracker.apply_final_call_ids([final])
assert final.id == "idx:0"
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")
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-edit",
"name": "edit_file",
"arguments_delta": '{"path":"small.py","old_text":"old\\n","new_text":"new\\nextra',
})
await tracker.flush()
asyncio.run(run())
assert events
assert events[-1]["path"] == "small.py"
assert events[-1]["added"] == 2
assert events[-1]["deleted"] == 1
def test_streaming_write_file_tracker_errors_unmatched_live_edits(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-live",
"name": "write_file",
"arguments_delta": '{"path":"aborted.md","content":"one\\n',
})
await tracker.error_unmatched([], "Tool call did not complete.")
asyncio.run(run())
assert events[-1]["path"] == "aborted.md"
assert events[-1]["phase"] == "error"
assert events[-1]["status"] == "error"
def test_streaming_write_file_tracker_keeps_matched_final_tool_call(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": "idx-only",
"name": "write_file",
"arguments_delta": '{"path":"matched.md","content":"one\\n',
})
await tracker.error_unmatched([
SimpleNamespace(
id="final-call",
name="write_file",
arguments={"path": "matched.md", "content": "one\n"},
)
], "Tool call did not complete.")
asyncio.run(run())
assert events
assert all(event["status"] == "editing" for event in events)
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
assert prepare_file_edit_tracker(
call_id="call-exec",
-14
View File
@@ -1,14 +0,0 @@
import importlib
from nanobot.session import webui_turns
from nanobot.webui import thread_disk, transcript
def test_legacy_webui_utils_imports_resolve_to_new_modules() -> None:
legacy_thread_disk = importlib.import_module("nanobot.utils.webui_thread_disk")
legacy_transcript = importlib.import_module("nanobot.utils.webui_transcript")
legacy_turn_helpers = importlib.import_module("nanobot.utils.webui_turn_helpers")
assert legacy_thread_disk.delete_webui_thread is thread_disk.delete_webui_thread
assert legacy_transcript.append_transcript_object is transcript.append_transcript_object
assert legacy_turn_helpers.mark_webui_session is webui_turns.mark_webui_session
-73
View File
@@ -1,73 +0,0 @@
import json
from nanobot.webui.sidebar_state import (
default_webui_sidebar_state,
read_webui_sidebar_state,
webui_sidebar_state_path,
write_webui_sidebar_state,
)
def test_sidebar_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
state = read_webui_sidebar_state()
assert state == default_webui_sidebar_state()
assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json"
def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
path = webui_sidebar_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"pinned_keys": ["websocket:a", "websocket:a", "", 123],
"archived_keys": ["websocket:b"],
"title_overrides": {"websocket:a": " Release notes ", "bad": ""},
"tags_by_key": {"websocket:a": ["work", "work", ""]},
"collapsed_groups": {"Earlier": 1},
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
}
),
encoding="utf-8",
)
state = read_webui_sidebar_state()
assert state["schema_version"] == 1
assert state["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"]
assert state["title_overrides"] == {"websocket:a": "Release notes"}
assert state["tags_by_key"] == {"websocket:a": ["work"]}
assert state["collapsed_groups"] == {"Earlier": True}
assert state["view"] == {
"density": "comfortable",
"show_previews": False,
"show_timestamps": False,
"show_archived": True,
"sort": "updated_desc",
}
def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
state = write_webui_sidebar_state(
{
"pinned_keys": ["websocket:a"],
"archived_keys": ["websocket:b"],
"title_overrides": {"websocket:a": "Release"},
"view": {"density": "compact", "show_previews": True},
}
)
assert state["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"]
assert state["title_overrides"] == {"websocket:a": "Release"}
assert state["view"]["density"] == "compact"
assert state["view"]["show_previews"] is True
assert webui_sidebar_state_path().is_file()
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
+2 -2
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
from nanobot.webui.thread_disk import delete_webui_thread, webui_thread_file_path
from nanobot.webui.transcript import append_transcript_object, webui_transcript_path
from nanobot.utils.webui_thread_disk import delete_webui_thread, webui_thread_file_path
from nanobot.utils.webui_transcript import append_transcript_object, webui_transcript_path
def test_delete_webui_thread_removes_legacy_json_and_transcript(tmp_path, monkeypatch) -> None:
+2 -244
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from nanobot.webui.transcript import (
from nanobot.utils.webui_transcript import (
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
append_transcript_object,
read_transcript_lines,
@@ -98,250 +98,8 @@ def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) ->
assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
def test_replay_tool_events_dedupes_finish_after_start() -> None:
msgs = replay_transcript_to_ui_messages([
{
"event": "message",
"chat_id": "t-tool",
"text": 'exec({"cmd":"ls"})',
"kind": "tool_hint",
"tool_events": [
{
"phase": "start",
"call_id": "call-exec",
"name": "exec",
"arguments": {"cmd": "ls"},
},
],
},
{
"event": "message",
"chat_id": "t-tool",
"text": "",
"kind": "progress",
"tool_events": [
{
"phase": "end",
"call_id": "call-exec",
"name": "exec",
"arguments": {"cmd": "ls"},
"result": "ok",
},
{
"phase": "end",
"call_id": "call-read",
"name": "read_file",
"arguments": {"path": "notes.md"},
"result": "done",
},
],
},
])
assert len(msgs) == 1
assert msgs[0]["traces"] == [
'exec({"cmd": "ls"})',
'read_file({"path": "notes.md"})',
]
def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-file-progress"
for ev in (
{"event": "user", "chat_id": "t-file-progress", "text": "edit"},
{
"event": "message",
"chat_id": "t-file-progress",
"text": 'write_file({"path":"foo.txt"})',
"kind": "tool_hint",
},
{
"event": "file_edit",
"chat_id": "t-file-progress",
"edits": [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "start",
"added": 12,
"deleted": 0,
"approximate": True,
"status": "editing",
},
],
},
{
"event": "message",
"chat_id": "t-file-progress",
"text": "still working",
"kind": "progress",
},
{
"event": "file_edit",
"chat_id": "t-file-progress",
"edits": [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "end",
"added": 30,
"deleted": 0,
"approximate": False,
"status": "done",
},
],
},
):
append_transcript_object(key, ev)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
file_edit_messages = [msg for msg in msgs if msg.get("fileEdits")]
assert len(file_edit_messages) == 1
assert file_edit_messages[0]["fileEdits"] == [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "end",
"added": 30,
"deleted": 0,
"approximate": False,
"status": "done",
},
]
def test_replay_file_edit_pending_placeholder_upgrades_to_path(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-file-pending"
for ev in (
{"event": "user", "chat_id": "t-file-pending", "text": "write"},
{
"event": "file_edit",
"chat_id": "t-file-pending",
"edits": [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "",
"phase": "start",
"added": 1,
"deleted": 0,
"approximate": True,
"status": "editing",
"pending": True,
},
],
},
{
"event": "file_edit",
"chat_id": "t-file-pending",
"edits": [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "start",
"added": 12,
"deleted": 0,
"approximate": True,
"status": "editing",
},
],
},
):
append_transcript_object(key, ev)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
file_edit_messages = [msg for msg in msgs if msg.get("fileEdits")]
assert len(file_edit_messages) == 1
assert file_edit_messages[0]["fileEdits"] == [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "start",
"added": 12,
"deleted": 0,
"approximate": True,
"status": "editing",
},
]
def test_replay_keeps_new_file_edit_after_reasoning_in_order(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-file-order"
for ev in (
{"event": "user", "chat_id": "t-file-order", "text": "edit"},
{
"event": "file_edit",
"chat_id": "t-file-order",
"edits": [
{
"version": 1,
"call_id": "call-one",
"tool": "write_file",
"path": "one.txt",
"phase": "start",
"added": 10,
"deleted": 0,
"approximate": True,
"status": "editing",
},
],
},
{"event": "reasoning_delta", "chat_id": "t-file-order", "text": "Check next."},
{"event": "reasoning_end", "chat_id": "t-file-order"},
{
"event": "file_edit",
"chat_id": "t-file-order",
"edits": [
{
"version": 1,
"call_id": "call-two",
"tool": "write_file",
"path": "two.txt",
"phase": "start",
"added": 20,
"deleted": 0,
"approximate": True,
"status": "editing",
},
],
},
):
append_transcript_object(key, ev)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert [msg.get("fileEdits", [{}])[0].get("path") if msg.get("fileEdits") else msg.get("reasoning") for msg in msgs[1:]] == [
"one.txt",
"Check next.",
"two.txt",
]
file_edit_segments = [
msg.get("activitySegmentId")
for msg in msgs
if msg.get("fileEdits")
]
assert len(file_edit_segments) == 2
assert file_edit_segments[0] != file_edit_segments[1]
def test_build_response_schema(monkeypatch, tmp_path) -> None:
from nanobot.webui.transcript import build_webui_thread_response
from nanobot.utils.webui_transcript import build_webui_thread_response
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t3"
+1 -1
View File
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.bus.events import InboundMessage
from nanobot.session import webui_turns as wth
from nanobot.utils import webui_turn_helpers as wth
@pytest.fixture(autouse=True)
-30
View File
@@ -26,7 +26,6 @@
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"tailwind-merge": "^2.6.0"
@@ -3923,20 +3922,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-newline-to-break": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz",
"integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-find-and-replace": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-phrasing": {
"version": "4.1.0",
"license": "MIT",
@@ -5156,21 +5141,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-breaks": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
"integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-newline-to-break": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"license": "MIT",
+5 -271
View File
@@ -1,16 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
import { Sidebar } from "@/components/Sidebar";
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
import { SettingsView } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useSessions } from "@/hooks/useSessions";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { useSidebarState } from "@/hooks/useSidebarState";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
@@ -40,7 +37,6 @@ type BootState =
};
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 TOKEN_REFRESH_MARGIN_MS = 30_000;
@@ -125,29 +121,6 @@ function readSidebarOpen(): boolean {
}
}
function readCompletedRunChatIds(): Set<string> {
if (typeof window === "undefined") return new Set();
try {
const raw = window.localStorage.getItem(COMPLETED_RUNS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((item): item is string => typeof item === "string"));
} catch {
return new Set();
}
}
function writeCompletedRunChatIds(chatIds: Set<string>): void {
try {
window.localStorage.setItem(
COMPLETED_RUNS_STORAGE_KEY,
JSON.stringify(Array.from(chatIds)),
);
} catch {
// ignore storage errors (private mode, etc.)
}
}
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
@@ -320,28 +293,18 @@ function Shell({
const { client } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat");
const [desktopSidebarOpen, setDesktopSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{
key: string;
label: string;
} | null>(null);
const [pendingRename, setPendingRename] = useState<{
key: string;
label: string;
} | null>(null);
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const runningChatIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
try {
@@ -354,58 +317,12 @@ function Shell({
}
}, [desktopSidebarOpen]);
useEffect(() => {
writeCompletedRunChatIds(completedChatIds);
}, [completedChatIds]);
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
useEffect(() => {
if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId));
setCompletedChatIds((current) => {
const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
);
return next.size === current.size ? current : next;
});
}, [loading, sessions]);
useEffect(() => {
if (loading) return;
const activeRunIds = sessions
.filter((session) => typeof session.runStartedAt === "number")
.map((session) => session.chatId);
if (activeRunIds.length === 0) return;
for (const chatId of activeRunIds) {
client.attach(chatId);
}
setRunningChatIds((current) => {
let changed = false;
const next = new Set(current);
for (const chatId of activeRunIds) {
if (!next.has(chatId)) changed = true;
next.add(chatId);
}
if (!changed) return current;
runningChatIdsRef.current = next;
return next;
});
setCompletedChatIds((current) => {
let changed = false;
const next = new Set(current);
for (const chatId of activeRunIds) {
if (next.delete(chatId)) changed = true;
}
return changed ? next : current;
});
}, [client, loading, sessions]);
const closeDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(false);
@@ -447,129 +364,14 @@ function Shell({
const onSelectChat = useCallback(
(key: string) => {
const selectedChatId = sessions.find((session) => session.key === key)?.chatId;
if (selectedChatId) {
setCompletedChatIds((current) => {
if (!current.has(selectedChatId)) return current;
const next = new Set(current);
next.delete(selectedChatId);
return next;
});
}
setActiveKey(key);
setView("chat");
setMobileSidebarOpen(false);
},
[sessions],
);
const onTogglePin = useCallback(
(key: string) => {
void updateSidebarState((current) => {
const pinned = new Set(current.pinned_keys);
if (pinned.has(key)) {
pinned.delete(key);
} else {
pinned.add(key);
}
return {
...current,
pinned_keys: Array.from(pinned),
};
});
},
[updateSidebarState],
);
const onRequestRename = useCallback((key: string, label: string) => {
setPendingRename({ key, label });
}, []);
const onConfirmRename = useCallback(
(title: string) => {
if (!pendingRename) return;
const key = pendingRename.key;
setPendingRename(null);
void updateSidebarState((current) => {
const titleOverrides = { ...current.title_overrides };
const cleaned = title.trim();
if (cleaned) {
titleOverrides[key] = cleaned;
} else {
delete titleOverrides[key];
}
return {
...current,
title_overrides: titleOverrides,
};
});
},
[pendingRename, updateSidebarState],
);
const onToggleArchive = useCallback(
(key: string) => {
void updateSidebarState((current) => {
const archived = new Set(current.archived_keys);
const pinned = current.pinned_keys.filter((item) => item !== key);
if (archived.has(key)) {
archived.delete(key);
} else {
archived.add(key);
}
return {
...current,
pinned_keys: pinned,
archived_keys: Array.from(archived),
};
});
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
const archived = new Set([...sidebarState.archived_keys, key]);
const next = sessions.find((session) => !archived.has(session.key));
setActiveKey(next?.key ?? null);
}
},
[activeKey, sessions, sidebarState.archived_keys, updateSidebarState],
);
const onToggleArchived = useCallback(() => {
void updateSidebarState((current) => ({
...current,
view: {
...current.view,
show_archived: !current.view.show_archived,
},
}));
}, [updateSidebarState]);
const onUpdateSidebarView = useCallback(
(viewUpdate: Partial<typeof sidebarState.view>) => {
void updateSidebarState((current) => ({
...current,
view: {
...current.view,
...viewUpdate,
},
}));
},
[updateSidebarState],
);
const onOpenSessionSearch = useCallback(() => {
setMobileSidebarOpen(false);
setSessionSearchOpen(true);
}, []);
const onSelectSearchResult = useCallback(
(key: string) => {
setSessionSearchOpen(false);
onSelectChat(key);
},
[onSelectChat],
[],
);
const onOpenSettings = useCallback(() => {
setSessionSearchOpen(false);
setView("settings");
setMobileSidebarOpen(false);
}, []);
@@ -603,35 +405,6 @@ function Shell({
});
}, [client, onModelNameChange]);
useEffect(() => {
return client.onRunStatus((chatId, startedAt) => {
if (startedAt != null) {
const nextRunning = new Set(runningChatIdsRef.current);
nextRunning.add(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
setCompletedChatIds((current) => {
if (!current.has(chatId)) return current;
const next = new Set(current);
next.delete(chatId);
return next;
});
return;
}
if (!runningChatIdsRef.current.has(chatId)) return;
const nextRunning = new Set(runningChatIdsRef.current);
nextRunning.delete(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
setCompletedChatIds((current) => {
const next = new Set(current);
next.add(chatId);
return next;
});
});
}, [client]);
useEffect(() => {
return client.onStatus((status) => {
let startedAt = 0;
@@ -679,8 +452,7 @@ function Shell({
}, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
? activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand");
@@ -704,21 +476,7 @@ function Shell({
onSelect: onSelectChat,
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
onTogglePin,
onRequestRename,
onToggleArchive,
onOpenSettings,
onOpenSearch: onOpenSessionSearch,
onToggleArchived,
onUpdateView: onUpdateSidebarView,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
titleOverrides: sidebarState.title_overrides,
runningChatIds: runningChatIdList,
completedChatIds: completedChatIdList,
viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length,
};
const showMainSidebar = view !== "settings";
@@ -755,32 +513,14 @@ function Shell({
<SheetContent
side="left"
showCloseButton={false}
aria-describedby={undefined}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
<Sidebar
{...sidebarProps}
onCollapse={closeMobileSidebar}
containActionMenus
/>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
</Sheet>
) : null}
{showMainSidebar ? (
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
) : null}
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
className={cn(
@@ -821,12 +561,6 @@ function Shell({
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
<RenameChatDialog
open={!!pendingRename}
title={pendingRename?.label ?? ""}
onCancel={() => setPendingRename(null)}
onConfirm={onConfirmRename}
/>
{restartToast ? (
<div
role="status"
+11 -280
View File
@@ -1,12 +1,4 @@
import {
Archive,
ArchiveRestore,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Trash2,
} from "lucide-react";
import { MoreHorizontal, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
@@ -15,29 +7,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { deriveTitle, relativeTime } from "@/lib/format";
import { deriveTitle } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
import type { ChatSummary } from "@/lib/types";
interface ChatListProps {
sessions: ChatSummary[];
activeKey: string | null;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
runningChatIds?: string[];
completedChatIds?: string[];
density?: SidebarDensity;
showPreviews?: boolean;
showTimestamps?: boolean;
sort?: SidebarSortMode;
showArchived?: boolean;
actionMenuPortalContainer?: HTMLElement | null;
loading?: boolean;
emptyLabel?: string;
}
@@ -47,20 +25,6 @@ export function ChatList({
activeKey,
onSelect,
onRequestDelete,
onTogglePin,
onRequestRename,
onToggleArchive,
pinnedKeys = [],
archivedKeys = [],
titleOverrides = {},
runningChatIds = [],
completedChatIds = [],
density = "comfortable",
showPreviews = false,
showTimestamps = false,
sort = "updated_desc",
showArchived = false,
actionMenuPortalContainer,
loading,
emptyLabel,
}: ChatListProps) {
@@ -82,25 +46,10 @@ 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);
const completed = new Set(completedChatIds);
const compact = density === "compact";
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
@@ -117,29 +66,15 @@ export function ChatList({
id: s.chatId.slice(0, 6),
});
const generatedTitle = s.title?.trim() || "";
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
const title =
generatedTitle || deriveTitle(s.preview, t("chat.newChat"));
const tooltipTitle =
titleOverrides[s.key]?.trim() ||
generatedTitle ||
deriveTitle(s.preview, fallbackTitle);
const isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key);
const preview = s.preview.trim();
const showPreview = showPreviews && preview && preview !== title;
const timestamp = showTimestamps
? relativeTime(s.updatedAt ?? s.createdAt)
: "";
const activityState = running.has(s.chatId)
? "running"
: completed.has(s.chatId)
? "complete"
: null;
generatedTitle || deriveTitle(s.preview, fallbackTitle);
return (
<li key={s.key} className="min-w-0">
<div
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
compact ? "min-h-7" : "min-h-8",
"group flex min-h-8 min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
@@ -149,24 +84,10 @@ export function ChatList({
type="button"
onClick={() => onSelect(s.key)}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
compact ? "py-1" : "py-1.5",
)}
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
>
<span className="block w-full truncate font-medium leading-5">{title}</span>
{showPreview ? (
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
{preview}
</span>
) : null}
{timestamp ? (
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
{timestamp}
</span>
) : null}
</button>
<SessionActivityIndicator state={activityState} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
@@ -181,35 +102,8 @@ export function ChatList({
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => onTogglePin(s.key)}
>
{isPinned ? (
<PinOff className="mr-2 h-4 w-4" />
) : (
<Pin className="mr-2 h-4 w-4" />
)}
{isPinned ? t("chat.unpin") : t("chat.pin")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)}
>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onToggleArchive(s.key)}
>
{isArchived ? (
<ArchiveRestore className="mr-2 h-4 w-4" />
) : (
<Archive className="mr-2 h-4 w-4" />
)}
{isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
@@ -233,85 +127,16 @@ export function ChatList({
);
}
function SessionActivityIndicator({
state,
}: {
state: "running" | "complete" | null;
}) {
const { t } = useTranslation();
if (state === "running") {
const label = t("chat.activity.running");
return (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-3 w-3 animate-spin rounded-full border border-blue-500/25 border-t-blue-500 [animation-duration:1.4s] motion-reduce:animate-none dark:border-blue-400/25 dark:border-t-blue-400" />
</span>
);
}
if (state === "complete") {
const label = t("chat.activity.complete");
return (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.14)] dark:bg-blue-400 dark:shadow-[0_0_0_3px_rgba(96,165,250,0.18)]" />
</span>
);
}
return <span className="h-4 w-4 shrink-0" aria-hidden="true" />;
}
function groupSessions(
sessions: ChatSummary[],
labels: {
pinned: string;
all: string;
today: string;
yesterday: string;
earlier: string;
archived: string;
fallbackTitle: string;
},
options: {
pinnedKeys: string[];
archivedKeys: string[];
titleOverrides: Record<string, string>;
showArchived: boolean;
sort: SidebarSortMode;
},
labels: { today: string; yesterday: string; earlier: string },
): Array<{ label: string; sessions: ChatSummary[] }> {
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>();
const pinned = new Set(options.pinnedKeys);
const archived = new Set(options.archivedKeys);
const pinnedSessions: ChatSummary[] = [];
const archivedSessions: ChatSummary[] = [];
const normalSessions: ChatSummary[] = [];
for (const session of sessions) {
if (archived.has(session.key)) {
if (options.showArchived) archivedSessions.push(session);
continue;
}
if (pinned.has(session.key)) {
pinnedSessions.push(session);
continue;
}
if (options.sort === "title_asc") {
normalSessions.push(session);
continue;
}
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today
@@ -323,101 +148,7 @@ function groupSessions(
buckets.set(label, bucket);
}
const groups = [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({
label,
sessions: sortSessions(
buckets.get(label) ?? [],
options.sort,
options.titleOverrides,
),
}))
return [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({ label, sessions: buckets.get(label) ?? [] }))
.filter((group) => group.sessions.length > 0);
if (options.sort === "title_asc" && normalSessions.length) {
groups.push({
label: labels.all,
sessions: sortSessions(
normalSessions,
options.sort,
options.titleOverrides,
),
});
}
if (pinnedSessions.length) {
groups.unshift({
label: labels.pinned,
sessions: sortSessions(
pinnedSessions,
options.sort,
options.titleOverrides,
),
});
}
if (archivedSessions.length) {
groups.push({
label: labels.archived,
sessions: sortSessions(
archivedSessions,
options.sort,
options.titleOverrides,
),
});
}
return groups;
}
function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
): ChatSummary[] {
const copy = [...sessions];
copy.sort((a, b) => {
if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides),
"en",
{ numeric: true, sensitivity: "base" },
);
if (titleOrder !== 0) return titleOrder;
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
}
const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt");
const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt");
return bTime - aTime;
});
return copy;
}
function titleForSort(
session: ChatSummary,
titleOverrides: Record<string, string>,
): string {
return (
titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, "new chat")
).toLocaleLowerCase("en");
}
function displayTitle(
session: ChatSummary,
titleOverrides: Record<string, string>,
fallbackTitle: string,
): string {
return (
titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, fallbackTitle)
);
}
function sessionTime(
session: ChatSummary,
field: "createdAt" | "updatedAt",
): number {
const primary = Date.parse(session[field] ?? "");
if (Number.isFinite(primary)) return primary;
const fallback = Date.parse(session.updatedAt ?? session.createdAt ?? "");
return Number.isFinite(fallback) ? fallback : 0;
}
+8 -18
View File
@@ -19,7 +19,6 @@ type FileReferenceKind =
interface FileReferenceChipProps {
path: string;
tooltipPath?: string;
display?: "name" | "path";
active?: boolean;
className?: string;
@@ -29,29 +28,27 @@ interface FileReferenceChipProps {
export function FileReferenceChip({
path,
tooltipPath,
display = "name",
active = false,
className,
textClassName,
testId = "inline-file-path",
}: FileReferenceChipProps) {
const { directory, name } = splitFilePath(path);
const { name } = splitFilePath(path);
const kind = fileKindForPath(path);
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
const fullPath = tooltipPath || path;
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("not-prose inline-flex max-w-full align-baseline leading-[inherit]", className)}
className={cn("not-prose inline-flex max-w-full align-[0.14em]", className)}
>
<span
data-testid={testId}
aria-label={fullPath}
aria-label={path}
className={cn(
"inline-flex max-w-full items-center gap-1 font-medium leading-[inherit]",
"inline-flex max-w-full items-center gap-1 font-medium leading-[1.1]",
"text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200",
)}
@@ -60,19 +57,12 @@ export function FileReferenceChip({
<span
data-sheen-text={active ? displayText : undefined}
className={cn(
"min-w-0 max-w-full truncate",
active && "streaming-text-sheen file-reference-sheen",
"min-w-0 truncate",
active && "streaming-text-sheen",
textClassName,
)}
>
{display === "path" && directory ? (
<>
<span className="text-muted-foreground/65">{directory}</span>
<span className="font-semibold text-sky-700 dark:text-sky-200">{name}</span>
</>
) : (
displayText
)}
{displayText}
</span>
</span>
</span>
@@ -89,7 +79,7 @@ export function FileReferenceChip({
"shadow-lg backdrop-blur",
)}
>
{fullPath}
{path}
</TooltipContent>
</Tooltip>
</TooltipProvider>
-75
View File
@@ -1,75 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
interface RenameChatDialogProps {
open: boolean;
title: string;
onCancel: () => void;
onConfirm: (title: string) => void;
}
export function RenameChatDialog({
open,
title,
onCancel,
onConfirm,
}: RenameChatDialogProps) {
const { t } = useTranslation();
const [value, setValue] = useState(title);
useEffect(() => {
if (open) setValue(title);
}, [open, title]);
const trimmed = value.trim();
return (
<Dialog open={open} onOpenChange={(next) => {
if (!next) onCancel();
}}>
<DialogContent className="max-w-sm rounded-[22px] border-border/70 bg-popover p-5 shadow-2xl">
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
if (!trimmed) return;
onConfirm(trimmed);
}}
>
<DialogHeader className="text-left">
<DialogTitle>{t("chat.renameTitle")}</DialogTitle>
<DialogDescription>
{t("chat.renameDescription")}
</DialogDescription>
</DialogHeader>
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={t("chat.renamePlaceholder")}
autoFocus
maxLength={160}
/>
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" onClick={onCancel}>
{t("deleteConfirm.cancel")}
</Button>
<Button type="submit" disabled={!trimmed}>
{t("chat.renameSave")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,213 +0,0 @@
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { deriveTitle } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
interface SessionSearchDialogProps {
open: boolean;
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
titleOverrides?: Record<string, string>;
onOpenChange: (open: boolean) => void;
onSelect: (key: string) => void;
}
export function SessionSearchDialog({
open,
sessions,
activeKey,
loading,
titleOverrides = {},
onOpenChange,
onSelect,
}: SessionSearchDialogProps) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const normalizedQuery = query.trim().toLowerCase();
const results = useMemo(() => {
if (!normalizedQuery) return sessions;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return sessions.filter((session) =>
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
);
}, [normalizedQuery, sessions, titleOverrides]);
useEffect(() => {
if (!open) return;
setQuery("");
setHighlightedIndex(0);
window.setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
setHighlightedIndex(0);
}, [normalizedQuery]);
useEffect(() => {
setHighlightedIndex((index) =>
results.length === 0 ? 0 : Math.min(index, results.length - 1),
);
}, [results.length]);
const handleSelect = (key: string) => {
onOpenChange(false);
onSelect(key);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((index) =>
results.length === 0 ? 0 : Math.min(index + 1, results.length - 1),
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((index) => Math.max(index - 1, 0));
return;
}
if (event.key === "Enter") {
const highlighted = results[highlightedIndex];
if (!highlighted) return;
event.preventDefault();
handleSelect(highlighted.key);
}
};
const emptyLabel = normalizedQuery
? t("sidebar.noSearchResults")
: t("chat.noSessions");
const sectionLabel = normalizedQuery
? t("sidebar.searchResults")
: t("sidebar.recent");
if (!open) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
"max-h-[min(34rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] gap-0 overflow-hidden p-0",
"rounded-2xl border border-border/70 bg-popover/95 text-popover-foreground shadow-2xl backdrop-blur-xl",
"sm:rounded-2xl",
)}
>
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
<DialogDescription className="sr-only">
{t("sidebar.searchPlaceholder")}
</DialogDescription>
<div className="flex h-14 items-center gap-3 border-b border-border/60 px-5">
<Search
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
/>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
{sectionLabel}
</div>
{loading && sessions.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{t("chat.loading")}
</div>
) : results.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{emptyLabel}
</div>
) : (
<ul className="space-y-1">
{results.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, t("chat.newChat"));
const preview = session.preview.trim();
const showPreview =
preview.length > 0 &&
preview.toLowerCase() !== title.trim().toLowerCase();
const highlighted = index === highlightedIndex;
const active = session.key === activeKey;
return (
<li key={session.key}>
<button
type="button"
onClick={() => handleSelect(session.key)}
onMouseEnter={() => setHighlightedIndex(index)}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
highlighted
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium leading-5">
{title}
</span>
{showPreview ? (
<span
className={cn(
"block truncate text-[12px] leading-4",
highlighted
? "text-accent-foreground/70"
: "text-muted-foreground",
)}
>
{preview}
</span>
) : null}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</DialogContent>
</Dialog>
);
}
function sessionMatchesTerms(
session: ChatSummary,
terms: string[],
titleOverride?: string,
) {
const haystack = [
titleOverride,
session.title,
session.preview,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return terms.every((term) => haystack.includes(term));
}
+47 -158
View File
@@ -1,7 +1,5 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Archive,
ListFilter,
Menu,
Search,
Settings,
@@ -12,22 +10,9 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import type {
ChatSummary,
SidebarSortMode,
SidebarViewState,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
interface SidebarProps {
sessions: ChatSummary[];
@@ -36,33 +21,34 @@ interface SidebarProps {
onNewChat: () => void;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onOpenSettings: () => void;
onOpenSearch: () => void;
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void;
containActionMenus?: boolean;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
runningChatIds?: string[];
completedChatIds?: string[];
viewState?: SidebarViewState;
showArchived?: boolean;
archivedCount?: number;
}
export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
const [menuPortalContainer, setMenuPortalContainer] =
useState<HTMLElement | null>(null);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredSessions = useMemo(() => {
if (!normalizedQuery) return props.sessions;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return props.sessions.filter((session) => {
const haystack = [
session.title,
session.preview,
session.chatId,
session.channel,
session.key,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return terms.every((term) => haystack.includes(term));
});
}, [normalizedQuery, props.sessions]);
return (
<nav
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
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"
>
@@ -88,6 +74,27 @@ export function Sidebar(props: SidebarProps) {
</div>
<div className="space-y-1.5 px-2 pb-2">
<label className="relative block">
<span className="sr-only">{t("sidebar.searchAria")}</span>
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70"
aria-hidden
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className={cn(
"h-8 w-full rounded-full border border-transparent bg-sidebar-accent/45",
"pl-8 pr-3 text-[12.5px] text-sidebar-foreground outline-none",
"placeholder:text-muted-foreground/75",
"transition-colors hover:bg-sidebar-accent/65",
"focus:border-sidebar-border/80 focus:bg-sidebar-accent/70",
"focus:ring-1 focus:ring-sidebar-border/70",
)}
/>
</label>
<Button
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"
@@ -96,55 +103,17 @@ export function Sidebar(props: SidebarProps) {
<SquarePen className="h-3.5 w-3.5" />
{t("sidebar.newChat")}
</Button>
<Button
type="button"
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>
<SidebarViewMenu
view={props.viewState}
onUpdateView={props.onUpdateView}
/>
{props.archivedCount ? (
<Button
type="button"
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>
) : null}
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatList
sessions={props.sessions}
sessions={filteredSessions}
activeKey={props.activeKey}
loading={props.loading}
emptyLabel={t("chat.noSessions")}
emptyLabel={
normalizedQuery ? t("sidebar.noSearchResults") : t("chat.noSessions")
}
onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete}
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides}
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
density={props.viewState?.density}
showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort}
showArchived={props.showArchived}
actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined
}
/>
</div>
<Separator className="bg-sidebar-border/50" />
@@ -163,83 +132,3 @@ export function Sidebar(props: SidebarProps) {
</nav>
);
}
function SidebarViewMenu({
view,
onUpdateView,
}: {
view?: SidebarViewState;
onUpdateView: (view: Partial<SidebarViewState>) => void;
}) {
const { t } = useTranslation();
const sort = view?.sort ?? "updated_desc";
const setSort = (value: string) => {
if (isSidebarSortMode(value)) onUpdateView({ sort: value });
};
return (
<DropdownMenu modal={false}>
<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"
variant="ghost"
>
<ListFilter className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.viewOptions")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.viewOptions")}
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={view?.density === "compact"}
onCheckedChange={(checked) =>
onUpdateView({ density: checked ? "compact" : "comfortable" })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.compactList")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_previews)}
onCheckedChange={(checked) =>
onUpdateView({ show_previews: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showPreviews")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_timestamps)}
onCheckedChange={(checked) =>
onUpdateView({ show_timestamps: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showTimestamps")}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.sortLabel")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="updated_desc">
{t("sidebar.sortUpdated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_desc">
{t("sidebar.sortCreated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title_asc">
{t("sidebar.sortTitle")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
function isSidebarSortMode(value: string): value is SidebarSortMode {
return value === "updated_desc" || value === "created_desc" || value === "title_asc";
}
File diff suppressed because it is too large Load Diff
@@ -30,19 +30,16 @@ interface ActivityCounts {
hasEditingFiles: boolean;
hasFailedFiles: boolean;
primaryFilePath?: string;
primaryFileTooltipPath?: string;
}
interface FileEditSummary {
key: string;
path: string;
absolute_path?: string;
added: number;
deleted: number;
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
pending: boolean;
error?: string;
}
@@ -64,10 +61,8 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
let hasEditingFiles = false;
let failedFileCount = 0;
let primaryFilePath: string | undefined;
let primaryFileTooltipPath: string | undefined;
for (const edit of fileEdits) {
primaryFilePath = edit.path;
primaryFileTooltipPath = edit.absolute_path || edit.path;
if (edit.status === "editing") {
hasEditingFiles = true;
}
@@ -89,7 +84,6 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
hasEditingFiles,
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
primaryFilePath,
primaryFileTooltipPath,
};
}
@@ -123,9 +117,7 @@ export function AgentActivityCluster({
hasEditingFiles,
hasFailedFiles,
primaryFilePath,
primaryFileTooltipPath,
} = countActivity(messages, fileEdits);
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
const [userToggledOuter, setUserToggledOuter] = useState(false);
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
@@ -138,15 +130,11 @@ export function AgentActivityCluster({
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const fileActivitySummary = fileCount > 0
? hasPendingFileEdit && !singleFilePath
? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" })
: singleFilePath
? fileCount === 1 && primaryFilePath
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
file: shortFileName(singleFilePath),
file: shortFileName(primaryFilePath),
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
})
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
@@ -253,35 +241,15 @@ export function AgentActivityCluster({
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
)}
aria-expanded={outerExpanded}
aria-label={summary}
>
<Layers 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">
<StreamingLabelSheen
active={headerBusy}
className="shrink-0"
>
{fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)}
</StreamingLabelSheen>
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
testId="activity-header-file-reference"
/>
</span>
) : (
<StreamingLabelSheen
active={headerBusy}
className="min-w-0"
>
{summary}
</StreamingLabelSheen>
)}
<StreamingLabelSheen
active={headerBusy}
className="min-w-0"
>
{summary}
</StreamingLabelSheen>
{fileCount > 0 && (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
<DiffPair added={added} deleted={deleted} />
@@ -364,8 +332,7 @@ function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
}
function fileEditCallKey(edit: UIFileEdit): string {
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return `${edit.tool}|${edit.path}`;
return `${edit.call_id}|${edit.tool}|${edit.path}`;
}
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
@@ -393,12 +360,10 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
interface MutableSummary {
key: string;
path: string;
absolute_path?: string;
added: number;
deleted: number;
approximate: boolean;
binary: boolean;
pending: boolean;
hasSuccessfulChange: boolean;
hasActiveEditing: boolean;
hasFailed: boolean;
@@ -408,18 +373,16 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
const order: string[] = [];
const byPath = new Map<string, MutableSummary>();
for (const edit of latestFileEditEvents(edits)) {
const key = edit.path || edit.call_id || edit.tool;
const key = edit.path;
let summary = byPath.get(key);
if (!summary) {
summary = {
key,
path: edit.path || "",
absolute_path: edit.absolute_path,
path: edit.path,
added: 0,
deleted: 0,
approximate: false,
binary: false,
pending: false,
hasSuccessfulChange: false,
hasActiveEditing: false,
hasFailed: false,
@@ -428,13 +391,6 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
order.push(key);
}
if (edit.path && !summary.path) {
summary.path = edit.path;
}
if (edit.absolute_path) {
summary.absolute_path = edit.absolute_path;
}
summary.pending = summary.pending || !!edit.pending || !edit.path;
if (active && edit.status === "editing") {
summary.hasActiveEditing = true;
summary.binary = summary.binary || !!edit.binary;
@@ -473,13 +429,11 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
return {
key: summary.key,
path: summary.path,
absolute_path: summary.absolute_path,
added: summary.added,
deleted: summary.deleted,
approximate: summary.approximate,
binary: summary.binary,
status,
pending: summary.pending && !summary.path,
error: summary.error,
};
});
@@ -504,24 +458,14 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
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">
{edit.pending && !edit.path ? (
<StreamingLabelSheen
active={editing}
className="min-w-0 text-[12px] font-medium text-muted-foreground"
>
{t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })}
</StreamingLabelSheen>
) : (
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
)}
<FileReferenceChip
path={edit.path}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
{failed ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
<AlertCircle className="h-3 w-3" aria-hidden />
@@ -543,30 +487,13 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return (
<span className="inline-flex shrink-0 translate-y-[0.055em] items-center gap-1.5 tabular-nums">
<DiffValue
sign="+"
value={added}
className="text-emerald-600/75 dark:text-emerald-300/75"
/>
<DiffValue
sign="-"
value={deleted}
className="text-rose-600/70 dark:text-rose-300/75"
/>
</span>
);
}
function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return (
<span className={cn("inline-flex", className)} aria-label={`${sign}${safeValue}`}>
<span className="inline-flex" aria-hidden>
{sign}
<AnimatedNumber value={safeValue} />
<span className="inline-flex shrink-0 items-center gap-1.5 tabular-nums">
<span className="text-emerald-600/75 dark:text-emerald-300/75">
+<AnimatedNumber value={added} />
</span>
<span className="text-rose-600/70 dark:text-rose-300/75">
-<AnimatedNumber value={deleted} />
</span>
<span className="sr-only">{sign}{safeValue}</span>
</span>
);
}
@@ -610,37 +537,5 @@ function AnimatedNumber({ value }: { value: number }) {
return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]);
return <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex h-[1em] overflow-hidden align-[-0.13em]" aria-hidden>
{digits.map((digit, index) => (
<RollingDigit
key={`${digits.length}-${index}`}
digit={Number(digit)}
/>
))}
</span>
);
}
function RollingDigit({ digit }: { digit: number }) {
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden">
<span
className="flex flex-col transition-transform duration-200 ease-out will-change-transform"
style={{ transform: `translateY(-${safeDigit}em)` }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n}
</span>
))}
</span>
</span>
);
return <>{display}</>;
}
+16 -25
View File
@@ -24,35 +24,26 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
showCloseButton?: boolean;
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, showCloseButton = true, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<DialogPrimitive.Content
ref={ref}
className={cn(
"grid w-full max-w-lg origin-center gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
</div>
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
+3 -8
View File
@@ -47,16 +47,11 @@ const DropdownMenuSubContent = React.forwardRef<
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
interface DropdownMenuContentProps
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
portalContainer?: HTMLElement | null;
}
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
DropdownMenuContentProps
>(({ className, sideOffset = 4, portalContainer, ...props }, ref) => (
<DropdownMenuPrimitive.Portal container={portalContainer ?? undefined}>
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
-3
View File
@@ -131,9 +131,6 @@
position: relative;
color: hsl(var(--muted-foreground));
}
.file-reference-sheen {
color: inherit;
}
.streaming-text-sheen::after {
content: attr(data-sheen-text);
position: absolute;
+25 -63
View File
@@ -2,7 +2,7 @@ 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 { toolTraceLinesFromEvents } from "@/lib/tool-traces";
import type { StreamError } from "@/lib/nanobot-client";
import type {
InboundEvent,
@@ -215,19 +215,18 @@ function absorbCompleteAssistantMessage(
}
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return `${edit.tool}|${edit.path}`;
return `${edit.call_id}|${edit.tool}|${edit.path}`;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
if (!edit || !edit.path || !edit.tool) return null;
const inferredStatus =
edit.phase === "error"
? "error"
: edit.phase === "end"
? "done"
: "editing";
const normalized: UIFileEdit = {
return {
...edit,
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
@@ -236,8 +235,6 @@ function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
? edit.status
: inferredStatus,
};
if (edit.pending && !edit.path) normalized.pending = true;
return normalized;
}
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
@@ -253,31 +250,11 @@ function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit
next.push(edit);
continue;
}
const merged = { ...next[existingIndex], ...edit };
if (edit.path && !edit.pending) delete merged.pending;
next[existingIndex] = merged;
next[existingIndex] = { ...next[existingIndex], ...edit };
}
return next;
}
function findFileEditTraceIndex(
prev: UIMessage[],
segmentId: string | null,
incoming: UIFileEdit[],
): number | null {
const incomingKeys = new Set(incoming.map(fileEditKey));
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace" || !candidate.fileEdits?.length) continue;
if (segmentId && candidate.activitySegmentId === segmentId) return i;
for (const existing of candidate.fileEdits) {
if (incomingKeys.has(fileEditKey(existing))) return i;
}
}
return null;
}
/**
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
* a streaming flag, and a ``send`` function. Initial history must be seeded
@@ -557,7 +534,6 @@ export function useNanobotStream(
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = typeof ev.text === "string" ? ev.text : "";
if (!chunk) return;
clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
schedulePendingStreamFlush();
@@ -568,7 +544,6 @@ export function useNanobotStream(
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = ev.text;
if (!chunk) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
schedulePendingStreamFlush();
@@ -647,7 +622,6 @@ export function useNanobotStream(
if (ev.kind === "reasoning") {
const line = ev.text;
if (!line) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
ensure: ensureActivitySegmentId,
})));
@@ -678,16 +652,10 @@ export function useNanobotStream(
: last.content
? [last.content]
: [];
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],
traces: [...previousTraces, ...lines],
content: lines[lines.length - 1],
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];
@@ -717,7 +685,6 @@ export function useNanobotStream(
// flight, drop the placeholder so we don't render the text twice.
// Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete.
clearActivitySegment();
setMessages((prev) => {
const activeId = buffer.current?.messageId;
buffer.current = null;
@@ -742,32 +709,27 @@ export function useNanobotStream(
if (ev.event === "file_edit") {
const edits = Array.isArray(ev.edits) ? ev.edits : [];
if (edits.length === 0) return;
const normalized = mergeFileEdits(undefined, edits);
if (normalized.length === 0) return;
const opensFileEditPhase = normalized.some(
(edit) => edit.status === "editing" || edit.phase === "start",
);
let eventSegmentId = fileEditSegmentRef.current;
if (!eventSegmentId && opensFileEditPhase) {
eventSegmentId = detachedActivitySegmentId();
fileEditSegmentRef.current = eventSegmentId;
}
setMessages((prev) => {
let segmentId = eventSegmentId;
const targetIndex = findFileEditTraceIndex(prev, segmentId, normalized);
if (targetIndex !== null) {
const target = prev[targetIndex];
segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
const last = prev[prev.length - 1];
let segmentId = fileEditSegmentRef.current;
if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) {
segmentId = detachedActivitySegmentId();
fileEditSegmentRef.current = segmentId;
}
if (
last
&& last.kind === "trace"
&& !last.isStreaming
&& !!last.fileEdits?.length
&& last.activitySegmentId === segmentId
) {
const merged: UIMessage = {
...target,
fileEdits: mergeFileEdits(target.fileEdits, normalized),
activitySegmentId: segmentId,
...last,
fileEdits: mergeFileEdits(last.fileEdits, edits),
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return replaceMessageAt(prev, targetIndex, merged);
return [...prev.slice(0, -1), merged];
}
segmentId = segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
return [
...prev,
{
@@ -776,7 +738,7 @@ export function useNanobotStream(
kind: "trace",
content: "",
traces: [],
fileEdits: normalized,
fileEdits: mergeFileEdits(undefined, edits),
activitySegmentId: segmentId,
createdAt: Date.now(),
},
-206
View File
@@ -1,206 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import {
fetchSidebarState,
updateSidebarState as persistSidebarState,
} from "@/lib/api";
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
schema_version: 1,
pinned_keys: [],
archived_keys: [],
title_overrides: {},
tags_by_key: {},
collapsed_groups: {},
view: {
density: "comfortable",
show_previews: false,
show_timestamps: false,
show_archived: false,
sort: "updated_desc",
},
updated_at: null,
};
function uniqueStrings(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string") continue;
const cleaned = item.trim();
if (!cleaned || seen.has(cleaned)) continue;
seen.add(cleaned);
out.push(cleaned);
}
return out;
}
function stringMap(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, string> = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw !== "string") continue;
const cleanedKey = key.trim();
const cleanedValue = raw.trim();
if (!cleanedKey || !cleanedValue) continue;
out[cleanedKey] = cleanedValue;
}
return out;
}
function tagsMap(value: unknown): Record<string, string[]> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, string[]> = {};
for (const [key, raw] of Object.entries(value)) {
const cleanedKey = key.trim();
if (!cleanedKey) continue;
const tags = uniqueStrings(raw).slice(0, 12);
if (tags.length) out[cleanedKey] = tags;
}
return out;
}
function boolMap(value: unknown): Record<string, boolean> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, boolean> = {};
for (const [key, raw] of Object.entries(value)) {
const cleanedKey = key.trim();
if (cleanedKey) out[cleanedKey] = Boolean(raw);
}
return out;
}
export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } };
}
const value = raw as Partial<SidebarStatePayload>;
const view = value.view && typeof value.view === "object"
? value.view
: DEFAULT_SIDEBAR_STATE.view;
const density = view.density === "compact" ? "compact" : "comfortable";
const sort = ["updated_desc", "created_desc", "title_asc"].includes(view.sort)
? view.sort
: "updated_desc";
return {
schema_version: 1,
pinned_keys: uniqueStrings(value.pinned_keys),
archived_keys: uniqueStrings(value.archived_keys),
title_overrides: stringMap(value.title_overrides),
tags_by_key: tagsMap(value.tags_by_key),
collapsed_groups: boolMap(value.collapsed_groups),
view: {
density,
show_previews: Boolean(view.show_previews),
show_timestamps: Boolean(view.show_timestamps),
show_archived: Boolean(view.show_archived),
sort,
},
updated_at: typeof value.updated_at === "string" ? value.updated_at : null,
};
}
function pruneMissingSessions(
state: SidebarStatePayload,
sessions: ChatSummary[],
): SidebarStatePayload {
const valid = new Set(sessions.map((session) => session.key));
const filterKeys = (keys: string[]) => keys.filter((key) => valid.has(key));
const filterMap = <T,>(map: Record<string, T>): Record<string, T> => {
const out: Record<string, T> = {};
for (const [key, value] of Object.entries(map)) {
if (valid.has(key)) out[key] = value;
}
return out;
};
return {
...state,
pinned_keys: filterKeys(state.pinned_keys),
archived_keys: filterKeys(state.archived_keys),
title_overrides: filterMap(state.title_overrides),
tags_by_key: filterMap(state.tags_by_key),
};
}
function sameState(a: SidebarStatePayload, b: SidebarStatePayload): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
export function useSidebarState(
sessions: ChatSummary[],
sessionsLoaded: boolean,
): {
state: SidebarStatePayload;
loading: boolean;
update: (
updater: (state: SidebarStatePayload) => SidebarStatePayload,
) => Promise<void>;
} {
const { token } = useClient();
const tokenRef = useRef(token);
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const persistVersionRef = useRef(0);
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true);
tokenRef.current = token;
stateRef.current = state;
useEffect(() => {
let cancelled = false;
setLoading(true);
(async () => {
try {
const loaded = normalizeSidebarState(await fetchSidebarState(tokenRef.current));
if (cancelled) return;
stateRef.current = loaded;
setState(loaded);
} catch {
if (cancelled) return;
stateRef.current = DEFAULT_SIDEBAR_STATE;
setState(DEFAULT_SIDEBAR_STATE);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current));
const version = persistVersionRef.current + 1;
persistVersionRef.current = version;
stateRef.current = next;
setState(next);
try {
const persisted = normalizeSidebarState(
await persistSidebarState(tokenRef.current, next),
);
if (persistVersionRef.current !== version) return;
stateRef.current = persisted;
setState(persisted);
} catch {
// Keep the optimistic UI state. Older gateways or transient auth expiry
// should not break the chat list; the next refresh can try again.
}
},
[],
);
const pruned = useMemo(() => {
if (!sessionsLoaded || loading) return state;
return pruneMissingSessions(state, sessions);
}, [loading, sessions, sessionsLoaded, state]);
useEffect(() => {
if (!sessionsLoaded || loading || sameState(pruned, state)) return;
void update(() => pruned);
}, [loading, pruned, sessionsLoaded, state, update]);
return { state, loading, update };
}
+10 -148
View File
@@ -45,16 +45,8 @@
"toggleTheme": "Toggle theme",
"home": "Home",
"newChat": "New chat",
"searchAria": "Search",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Search",
"searchAria": "Search chats",
"searchPlaceholder": "Search chats",
"searchResults": "Results",
"noSearchResults": "No matching chats.",
"recent": "Recent",
@@ -73,31 +65,12 @@
},
"nav": {
"general": "General",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
"byok": "BYOK"
},
"sections": {
"interface": "Interface",
"ai": "AI",
"system": "System",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"imageGeneration": "Image generation",
"imageDefaults": "Defaults",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capabilities",
"integrations": "Integrations"
"system": "System"
},
"rows": {
"theme": "Theme",
@@ -105,104 +78,31 @@
"provider": "Provider",
"model": "Model",
"restart": "Restart nanobot",
"configPath": "Config path",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"pendingChanges": "Pending changes",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"imageGeneration": "Image generation",
"imageProvider": "Image provider",
"imageProviderStatus": "Provider status",
"imageProviderBase": "Provider base",
"imageModel": "Image model",
"defaultAspectRatio": "Default aspect",
"defaultImageSize": "Default size",
"maxImagesPerTurn": "Max images per turn",
"imageSaveDir": "Save directory",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs"
"configPath": "Config path"
},
"help": {
"theme": "Switch between light and dark appearance.",
"language": "Choose the language used by the WebUI.",
"provider": "Select the provider that should serve new model requests.",
"model": "Set the default model name used by nanobot.",
"configPath": "The gateway configuration file currently in use.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"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 in chats when a configured image provider is available.",
"imageProvider": "Choose the registry provider used by generate_image.",
"imageProviderStatus": "Image generation reuses provider credentials from Providers.",
"imageModel": "Model name sent to the selected image provider.",
"defaultAspectRatio": "Used when the prompt does not choose an aspect ratio.",
"defaultImageSize": "Size hint sent to providers that support it.",
"maxImagesPerTurn": "Upper bound for one generate_image request.",
"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.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"configPath": "The gateway configuration file currently in use."
},
"values": {
"light": "Light",
"dark": "Dark",
"notAvailable": "Not available",
"enabled": "Enabled",
"disabled": "Disabled",
"restartPending": "Restart pending",
"ready": "Ready",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notAvailable": "Not available"
},
"status": {
"loading": "Loading settings...",
"loadError": "Could not load settings",
"unsaved": "Unsaved changes.",
"upToDate": "Up to date.",
"savedRestart": "Saved. Restart nanobot to apply.",
"restartAfterSaving": "Save changes, then restart when ready.",
"savedRestartApply": "Saved. Restart when ready.",
"imageProviderRestart": "Image provider changes saved. Restart when ready."
"savedRestart": "Saved. Restart nanobot to apply."
},
"actions": {
"save": "Save",
"saving": "Saving",
"edit": "Edit",
"cancel": "Cancel",
"openDocs": "Open docs"
"cancel": "Cancel"
},
"byok": {
"description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.",
@@ -245,26 +145,6 @@
"missingCredential": "Add the required credential before saving.",
"saveHint": "Changes apply to new web search requests."
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"imageGeneration": "Image generation",
"workspace": "Workspace"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
},
"image": {
"selectProvider": "Select provider",
"selectAspect": "Select aspect",
"selectSize": "Select size",
"configureProvider": "Configure provider",
"missingCredential": "Configure this provider before enabling image generation."
}
},
"chat": {
@@ -272,30 +152,12 @@
"loading": "Loading…",
"noSessions": "No sessions yet.",
"actions": "Chat actions for {{title}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
},
"pin": "Pin",
"unpin": "Unpin",
"rename": "Rename",
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
"showArchived": "Show archived",
"hideArchived": "Hide archived",
"delete": "Delete",
"newChat": "New chat",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
"earlier": "Earlier"
}
},
"deleteConfirm": {
+8 -123
View File
@@ -30,25 +30,13 @@
"collapse": "Contraer barra lateral",
"toggleTheme": "Cambiar tema",
"newChat": "Nuevo chat",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"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",
@@ -58,28 +46,12 @@
},
"nav": {
"general": "General",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
"byok": "BYOK"
},
"sections": {
"interface": "Interfaz",
"ai": "IA",
"system": "Sistema",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
"system": "Sistema"
},
"rows": {
"theme": "Tema",
@@ -87,70 +59,19 @@
"provider": "Proveedor",
"model": "Modelo",
"restart": "Reiniciar nanobot",
"configPath": "Ruta de configuración",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs"
"configPath": "Ruta de configuración"
},
"help": {
"theme": "Cambia entre apariencia clara y oscura.",
"language": "Elige el idioma usado por la WebUI.",
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
"model": "Define el nombre del modelo predeterminado que usa nanobot.",
"configPath": "El archivo de configuración que usa actualmente el gateway.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"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.",
"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.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"configPath": "El archivo de configuración que usa actualmente el gateway."
},
"values": {
"light": "Claro",
"dark": "Oscuro",
"notAvailable": "No disponible",
"enabled": "Enabled",
"disabled": "Disabled",
"restartRequired": "Restart required",
"liveReload": "Live reload ready",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notAvailable": "No disponible"
},
"status": {
"loading": "Cargando configuración...",
@@ -162,8 +83,7 @@
"save": "Guardar",
"saving": "Guardando",
"edit": "Editar",
"cancel": "Cancelar",
"openDocs": "Open docs"
"cancel": "Cancelar"
},
"byok": {
"description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.",
@@ -206,18 +126,6 @@
"missingCredential": "Añade la credencial requerida antes de guardar.",
"saveHint": "Los cambios se aplican a nuevas solicitudes de web search."
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"workspace": "Workspace"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
}
},
"chat": {
@@ -225,31 +133,8 @@
"loading": "Cargando…",
"noSessions": "Todavía no hay sesiones.",
"actions": "Acciones del chat {{title}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
},
"pin": "Pin",
"unpin": "Unpin",
"rename": "Rename",
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
"showArchived": "Show archived",
"hideArchived": "Hide archived",
"delete": "Eliminar",
"newChat": "Nuevo chat",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
"newChat": "Nuevo chat"
},
"deleteConfirm": {
"title": "¿Eliminar este chat?",
+8 -123
View File
@@ -30,25 +30,13 @@
"collapse": "Réduire la barre latérale",
"toggleTheme": "Changer de thème",
"newChat": "Nouvelle discussion",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"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",
@@ -58,28 +46,12 @@
},
"nav": {
"general": "Général",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
"byok": "BYOK"
},
"sections": {
"interface": "Interface",
"ai": "IA",
"system": "Système",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
"system": "Système"
},
"rows": {
"theme": "Thème",
@@ -87,70 +59,19 @@
"provider": "Fournisseur",
"model": "Modèle",
"restart": "Redémarrer nanobot",
"configPath": "Chemin de configuration",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs"
"configPath": "Chemin de configuration"
},
"help": {
"theme": "Basculer entre les apparences claire et sombre.",
"language": "Choisissez la langue utilisée par le WebUI.",
"provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.",
"model": "Définissez le nom du modèle par défaut utilisé par nanobot.",
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"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.",
"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.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle."
},
"values": {
"light": "Clair",
"dark": "Sombre",
"notAvailable": "Indisponible",
"enabled": "Enabled",
"disabled": "Disabled",
"restartRequired": "Restart required",
"liveReload": "Live reload ready",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notAvailable": "Indisponible"
},
"status": {
"loading": "Chargement des paramètres...",
@@ -162,8 +83,7 @@
"save": "Enregistrer",
"saving": "Enregistrement",
"edit": "Modifier",
"cancel": "Annuler",
"openDocs": "Open docs"
"cancel": "Annuler"
},
"byok": {
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.",
@@ -206,18 +126,6 @@
"missingCredential": "Ajoutez l'identifiant requis avant d'enregistrer.",
"saveHint": "Les changements s'appliquent aux nouvelles requêtes web search."
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"workspace": "Workspace"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
}
},
"chat": {
@@ -225,31 +133,8 @@
"loading": "Chargement…",
"noSessions": "Aucune session pour le moment.",
"actions": "Actions de la discussion {{title}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
},
"pin": "Pin",
"unpin": "Unpin",
"rename": "Rename",
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
"showArchived": "Show archived",
"hideArchived": "Hide archived",
"delete": "Supprimer",
"newChat": "Nouvelle discussion",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
"newChat": "Nouvelle discussion"
},
"deleteConfirm": {
"title": "Supprimer cette discussion ?",
+8 -123
View File
@@ -30,25 +30,13 @@
"collapse": "Ciutkan sidebar",
"toggleTheme": "Ganti tema",
"newChat": "Obrolan baru",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"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",
@@ -58,28 +46,12 @@
},
"nav": {
"general": "Umum",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
"byok": "BYOK"
},
"sections": {
"interface": "Antarmuka",
"ai": "AI",
"system": "Sistem",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
"system": "Sistem"
},
"rows": {
"theme": "Tema",
@@ -87,70 +59,19 @@
"provider": "Penyedia",
"model": "Model",
"restart": "Mulai ulang nanobot",
"configPath": "Path konfigurasi",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs"
"configPath": "Path konfigurasi"
},
"help": {
"theme": "Beralih antara tampilan terang dan gelap.",
"language": "Pilih bahasa yang digunakan WebUI.",
"provider": "Pilih penyedia untuk permintaan model baru.",
"model": "Atur nama model default yang digunakan nanobot.",
"configPath": "File konfigurasi gateway yang sedang digunakan.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"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.",
"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.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"configPath": "File konfigurasi gateway yang sedang digunakan."
},
"values": {
"light": "Terang",
"dark": "Gelap",
"notAvailable": "Tidak tersedia",
"enabled": "Enabled",
"disabled": "Disabled",
"restartRequired": "Restart required",
"liveReload": "Live reload ready",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notAvailable": "Tidak tersedia"
},
"status": {
"loading": "Memuat pengaturan...",
@@ -162,8 +83,7 @@
"save": "Simpan",
"saving": "Menyimpan",
"edit": "Edit",
"cancel": "Batal",
"openDocs": "Open docs"
"cancel": "Batal"
},
"byok": {
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.",
@@ -206,18 +126,6 @@
"missingCredential": "Tambahkan kredensial yang diperlukan sebelum menyimpan.",
"saveHint": "Perubahan berlaku untuk permintaan web search baru."
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"workspace": "Workspace"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
}
},
"chat": {
@@ -225,31 +133,8 @@
"loading": "Memuat…",
"noSessions": "Belum ada sesi.",
"actions": "Aksi obrolan untuk {{title}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
},
"pin": "Pin",
"unpin": "Unpin",
"rename": "Rename",
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
"showArchived": "Show archived",
"hideArchived": "Hide archived",
"delete": "Hapus",
"newChat": "Obrolan baru",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
"newChat": "Obrolan baru"
},
"deleteConfirm": {
"title": "Hapus obrolan ini?",
+8 -123
View File
@@ -30,25 +30,13 @@
"collapse": "サイドバーを閉じる",
"toggleTheme": "テーマを切り替える",
"newChat": "新しいチャット",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"recent": "最近のチャット",
"refreshSessions": "セッションを更新",
"settings": "設定",
"language": {
"label": "言語",
"ariaLabel": "言語を変更"
},
"searchAria": "検索",
"searchPlaceholder": "検索",
"searchResults": "検索結果",
"noSearchResults": "一致するチャットはありません。"
}
},
"settings": {
"backToChat": "チャットに戻る",
@@ -58,28 +46,12 @@
},
"nav": {
"general": "一般",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
"byok": "BYOK"
},
"sections": {
"interface": "インターフェース",
"ai": "AI",
"system": "システム",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
"system": "システム"
},
"rows": {
"theme": "テーマ",
@@ -87,70 +59,19 @@
"provider": "プロバイダー",
"model": "モデル",
"restart": "nanobot を再起動",
"configPath": "設定パス",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs"
"configPath": "設定パス"
},
"help": {
"theme": "ライト表示とダーク表示を切り替えます。",
"language": "WebUI で使用する言語を選択します。",
"provider": "新しいモデルリクエストに使うプロバイダーを選択します。",
"model": "nanobot が既定で使用するモデル名を設定します。",
"configPath": "現在ゲートウェイが使用している設定ファイルです。",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"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.",
"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.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"configPath": "現在ゲートウェイが使用している設定ファイルです。"
},
"values": {
"light": "ライト",
"dark": "ダーク",
"notAvailable": "利用不可",
"enabled": "Enabled",
"disabled": "Disabled",
"restartRequired": "Restart required",
"liveReload": "Live reload ready",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notAvailable": "利用不可"
},
"status": {
"loading": "設定を読み込んでいます...",
@@ -162,8 +83,7 @@
"save": "保存",
"saving": "保存中",
"edit": "編集",
"cancel": "キャンセル",
"openDocs": "Open docs"
"cancel": "キャンセル"
},
"byok": {
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
@@ -206,18 +126,6 @@
"missingCredential": "保存する前に必要な認証情報を入力してください。",
"saveHint": "変更は新しい web search リクエストに適用されます。"
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"workspace": "Workspace"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
}
},
"chat": {
@@ -225,31 +133,8 @@
"loading": "読み込み中…",
"noSessions": "まだセッションがありません。",
"actions": "「{{title}}」のチャット操作",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
},
"pin": "Pin",
"unpin": "Unpin",
"rename": "Rename",
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
"showArchived": "Show archived",
"hideArchived": "Hide archived",
"delete": "削除",
"newChat": "新しいチャット",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
"newChat": "新しいチャット"
},
"deleteConfirm": {
"title": "このチャットを削除しますか?",
+8 -123
View File
@@ -30,25 +30,13 @@
"collapse": "사이드바 접기",
"toggleTheme": "테마 전환",
"newChat": "새 채팅",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"recent": "최근 대화",
"refreshSessions": "세션 새로고침",
"settings": "설정",
"language": {
"label": "언어",
"ariaLabel": "언어 변경"
},
"searchAria": "검색",
"searchPlaceholder": "검색",
"searchResults": "결과",
"noSearchResults": "일치하는 채팅이 없습니다."
}
},
"settings": {
"backToChat": "채팅으로 돌아가기",
@@ -58,28 +46,12 @@
},
"nav": {
"general": "일반",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
"byok": "BYOK"
},
"sections": {
"interface": "인터페이스",
"ai": "AI",
"system": "시스템",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
"system": "시스템"
},
"rows": {
"theme": "테마",
@@ -87,70 +59,19 @@
"provider": "제공자",
"model": "모델",
"restart": "nanobot 재시작",
"configPath": "설정 경로",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs"
"configPath": "설정 경로"
},
"help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
"language": "WebUI에서 사용할 언어를 선택합니다.",
"provider": "새 모델 요청에 사용할 제공자를 선택합니다.",
"model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.",
"configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"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.",
"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.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"configPath": "현재 게이트웨이가 사용하는 설정 파일입니다."
},
"values": {
"light": "라이트",
"dark": "다크",
"notAvailable": "사용할 수 없음",
"enabled": "Enabled",
"disabled": "Disabled",
"restartRequired": "Restart required",
"liveReload": "Live reload ready",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notAvailable": "사용할 수 없음"
},
"status": {
"loading": "설정을 불러오는 중...",
@@ -162,8 +83,7 @@
"save": "저장",
"saving": "저장 중",
"edit": "편집",
"cancel": "취소",
"openDocs": "Open docs"
"cancel": "취소"
},
"byok": {
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
@@ -206,18 +126,6 @@
"missingCredential": "저장하기 전에 필요한 자격 증명을 입력하세요.",
"saveHint": "변경 사항은 새 web search 요청에 적용됩니다."
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"workspace": "Workspace"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
}
},
"chat": {
@@ -225,31 +133,8 @@
"loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.",
"actions": "{{title}} 채팅 작업",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
},
"pin": "Pin",
"unpin": "Unpin",
"rename": "Rename",
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
"showArchived": "Show archived",
"hideArchived": "Hide archived",
"delete": "삭제",
"newChat": "새 채팅",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
"newChat": "새 채팅"
},
"deleteConfirm": {
"title": "이 채팅을 삭제할까요?",
+8 -123
View File
@@ -30,25 +30,13 @@
"collapse": "Thu gọn thanh bên",
"toggleTheme": "Chuyển giao diện",
"newChat": "Cuộc trò chuyện mới",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"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",
@@ -58,28 +46,12 @@
},
"nav": {
"general": "Chung",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
"byok": "BYOK"
},
"sections": {
"interface": "Giao diện",
"ai": "AI",
"system": "Hệ thống",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
"system": "Hệ thống"
},
"rows": {
"theme": "Giao diện",
@@ -87,70 +59,19 @@
"provider": "Nhà cung cấp",
"model": "Mô hình",
"restart": "Khởi động lại nanobot",
"configPath": "Đường dẫn cấu hình",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs"
"configPath": "Đường dẫn cấu hình"
},
"help": {
"theme": "Chuyển giữa giao diện sáng và tối.",
"language": "Chọn ngôn ngữ dùng trong WebUI.",
"provider": "Chọn nhà cung cấp cho các yêu cầu mô hình mới.",
"model": "Đặt tên mô hình mặc định mà nanobot sử dụng.",
"configPath": "Tệp cấu hình gateway hiện đang dùng.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"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.",
"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.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"configPath": "Tệp cấu hình gateway hiện đang dùng."
},
"values": {
"light": "Sáng",
"dark": "Tối",
"notAvailable": "Không khả dụng",
"enabled": "Enabled",
"disabled": "Disabled",
"restartRequired": "Restart required",
"liveReload": "Live reload ready",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notAvailable": "Không khả dụng"
},
"status": {
"loading": "Đang tải cài đặt...",
@@ -162,8 +83,7 @@
"save": "Lưu",
"saving": "Đang lưu",
"edit": "Sửa",
"cancel": "Hủy",
"openDocs": "Open docs"
"cancel": "Hủy"
},
"byok": {
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.",
@@ -206,18 +126,6 @@
"missingCredential": "Thêm thông tin bắt buộc trước khi lưu.",
"saveHint": "Thay đổi áp dụng cho các yêu cầu web search mới."
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"workspace": "Workspace"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
}
},
"chat": {
@@ -225,31 +133,8 @@
"loading": "Đang tải…",
"noSessions": "Chưa có phiên nào.",
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
},
"pin": "Pin",
"unpin": "Unpin",
"rename": "Rename",
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
"showArchived": "Show archived",
"hideArchived": "Hide archived",
"delete": "Xóa",
"newChat": "Cuộc trò chuyện mới",
"groups": {
"pinned": "Pinned",
"all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
"archived": "Archived"
}
"newChat": "Cuộc trò chuyện mới"
},
"deleteConfirm": {
"title": "Xóa cuộc trò chuyện này?",
+23 -161
View File
@@ -33,16 +33,8 @@
"toggleTheme": "切换主题",
"home": "首页",
"newChat": "新建对话",
"searchAria": "搜索",
"viewOptions": "视图",
"compactList": "紧凑列表",
"showPreviews": "显示预览",
"showTimestamps": "显示时间",
"sortLabel": "排序",
"sortUpdated": "最近更新",
"sortCreated": "最近创建",
"sortTitle": "标题 A-Z",
"searchPlaceholder": "搜索",
"searchAria": "搜索会话",
"searchPlaceholder": "搜索会话",
"searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。",
"recent": "最近对话",
@@ -61,31 +53,12 @@
},
"nav": {
"general": "通用",
"byok": "BYOK",
"overview": "概览",
"appearance": "外观",
"models": "模型",
"providers": "提供商",
"image": "图片",
"web": "网页",
"runtime": "运行时",
"advanced": "高级"
"byok": "BYOK"
},
"sections": {
"interface": "界面",
"ai": "AI",
"system": "系统",
"status": "状态",
"localPreferences": "本地偏好",
"presets": "预设",
"imageGeneration": "图片生成",
"imageDefaults": "默认值",
"webSearch": "网页搜索",
"webBehavior": "行为",
"identity": "身份",
"safety": "安全",
"capabilities": "能力",
"integrations": "集成"
"system": "系统"
},
"rows": {
"theme": "主题",
@@ -93,107 +66,34 @@
"provider": "提供商",
"model": "模型",
"restart": "重启 nanobot",
"configPath": "配置路径",
"activePreset": "当前预设",
"gateway": "网关",
"restartState": "重启状态",
"pendingChanges": "待处理更改",
"selectedPreset": "选中的预设",
"presetModel": "预设模型",
"density": "密度",
"activityMode": "活动细节",
"codeWrap": "代码换行",
"maxResults": "最大结果数",
"timeout": "超时",
"jinaReader": "Jina Reader",
"imageGeneration": "图片生成",
"imageProvider": "图片服务商",
"imageProviderStatus": "服务商状态",
"imageProviderBase": "服务商地址",
"imageModel": "图片模型",
"defaultAspectRatio": "默认比例",
"defaultImageSize": "默认尺寸",
"maxImagesPerTurn": "每轮最多图片数",
"imageSaveDir": "保存目录",
"botName": "Bot 名称",
"botIcon": "Bot 图标",
"timezone": "时区",
"toolHintMaxLength": "工具提示长度",
"workspacePath": "工作区路径",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "统一会话",
"restrictWorkspace": "限制在工作区内",
"execTool": "Exec 工具",
"execSandbox": "Exec 沙箱",
"ssrfWhitelist": "SSRF 白名单",
"mcpServers": "MCP 服务器",
"pathAppend": "PATH 追加",
"configurationDocs": "配置文档"
"configPath": "配置路径"
},
"help": {
"theme": "在浅色和深色外观之间切换。",
"language": "选择 WebUI 使用的语言。",
"provider": "选择新模型请求使用的服务商。",
"provider": "选择新模型请求使用的服务提供商。",
"model": "设置 nanobot 默认使用的模型名称。",
"configPath": "当前网关正在使用的配置文件。",
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
"presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。",
"density": "仅保存在当前浏览器。",
"activityMode": "选择默认显示多少 agent 活动细节。",
"codeWrap": "让较小屏幕上的长代码行更易读。",
"maxResults": "每次 web_search 返回的结果数量。",
"timeout": "搜索服务商请求超时秒数。",
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
"imageGeneration": "当已配置图片服务商时,在对话中开放 generate_image。",
"imageProvider": "选择 generate_image 使用的服务商。",
"imageProviderStatus": "图片生成复用服务商页里的凭证配置。",
"imageModel": "发送给所选图片服务商的模型名称。",
"defaultAspectRatio": "当提示词没有选择比例时使用。",
"defaultImageSize": "发送给支持该能力的服务商的尺寸提示。",
"maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。",
"botName": "显示在使用 bot 身份的运行时界面里。",
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
"timezone": "运行时上下文和计划任务使用的 IANA 时区。",
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
"configPath": "当前网关正在使用的配置文件。"
},
"values": {
"light": "浅色",
"dark": "深色",
"notAvailable": "不可用",
"enabled": "已启用",
"disabled": "已禁用",
"restartPending": "等待重启",
"ready": "就绪",
"comfortable": "舒适",
"compact": "紧凑",
"auto": "自动",
"expanded": "展开",
"on": "开",
"off": "关",
"configured": "已配置",
"notConfigured": "未配置"
"notAvailable": "不可用"
},
"status": {
"loading": "正在加载设置...",
"loadError": "无法加载设置",
"unsaved": "有未保存的更改。",
"upToDate": "已是最新。",
"savedRestart": "已保存。重启 nanobot 后生效。",
"restartAfterSaving": "保存后,可在合适时重启。",
"savedRestartApply": "已保存,可稍后重启。",
"imageProviderRestart": "图片服务商改动已保存,可稍后重启。"
"savedRestart": "已保存。重启 nanobot 后生效。"
},
"actions": {
"save": "保存",
"saving": "保存中",
"edit": "编辑",
"cancel": "取消",
"openDocs": "打开文档"
"cancel": "取消"
},
"byok": {
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
"description": "自带 provider key。Nanobot 会从当前 config 读取这些值,只有已配置的 provider 才能在通用设置里选择。",
"configured": "已配置",
"notConfigured": "未配置",
"configuredSection": "已配置",
@@ -205,22 +105,22 @@
"apiKeyPlaceholder": "输入 API key",
"apiKeyConfiguredPlaceholder": "留空则保留当前 key",
"configuredKeyHint": "已配置的 key",
"apiBasePlaceholder": "使用服务商默认地址",
"apiKeyRequired": "需要 API key 才能配置此服务商。",
"apiBasePlaceholder": "使用 provider 默认地址",
"apiKeyRequired": "需要 API key 才能配置此 provider。",
"showApiKey": "显示 API key",
"hideApiKey": "隐藏 API key",
"noConfiguredProviders": "没有已配置的服务商",
"configureFirst": "请先在 BYOK 里配置服务商。",
"noConfiguredProviders": "没有已配置的 provider",
"configureFirst": "请先在 BYOK 里配置 provider。",
"openByok": "打开 BYOK",
"tabs": {
"ariaLabel": "BYOK 凭证类型",
"llm": "LLM",
"webSearch": "网页搜索"
"webSearch": "Web Search"
},
"webSearch": {
"provider": "搜索服务商",
"providerHelp": "选择网页搜索工具使用的后端。",
"selectProvider": "选择服务商",
"provider": "搜索 provider",
"providerHelp": "选择 web search 工具使用的后端。",
"selectProvider": "选择 provider",
"credentials": "凭证",
"noCredentialRequired": "无需 key",
"noCredentialHelp": "DuckDuckGo 不需要保存 API key。",
@@ -228,31 +128,11 @@
"baseUrl": "Base URL",
"baseUrlHelp": "SearXNG 需要你自己的实例地址。",
"baseUrlPlaceholder": "https://search.example.com",
"apiKeyRequired": "这个搜索服务商需要 API key。",
"apiKeyRequired": "这个搜索 provider 需要 API key。",
"baseUrlRequired": "SearXNG 需要 Base URL。",
"missingCredential": "填写所需凭证后才能保存。",
"saveHint": "改动会应用到新的网页搜索请求。"
"saveHint": "改动会应用到新的 web search 请求。"
}
},
"overview": {
"model": "当前模型",
"providers": "提供商",
"configuredCount": "已配置 {{count}} 个",
"totalProviders": "共 {{count}} 个可用",
"webSearch": "网页搜索",
"imageGeneration": "图片生成",
"workspace": "工作区"
},
"providers": {
"searchPlaceholder": "搜索服务商",
"noMatches": "没有匹配的服务商。"
},
"image": {
"selectProvider": "选择服务商",
"selectAspect": "选择比例",
"selectSize": "选择尺寸",
"configureProvider": "配置服务商",
"missingCredential": "启用图片生成前,请先配置这个服务商。"
}
},
"chat": {
@@ -260,30 +140,12 @@
"loading": "加载中…",
"noSessions": "还没有会话。",
"actions": "“{{title}}” 的会话操作",
"activity": {
"running": "Agent 正在运行",
"complete": "Agent 已完成"
},
"pin": "置顶",
"unpin": "取消置顶",
"rename": "重命名",
"renameTitle": "重命名对话",
"renameDescription": "为这个对话设置一个仅用于 WebUI 侧边栏的名称。",
"renamePlaceholder": "对话名称",
"renameSave": "保存",
"archive": "归档",
"unarchive": "取消归档",
"showArchived": "显示归档",
"hideArchived": "隐藏归档",
"delete": "删除",
"newChat": "新建对话",
"groups": {
"pinned": "置顶",
"all": "对话",
"today": "今天",
"yesterday": "昨天",
"earlier": "更早",
"archived": "已归档"
"earlier": "更早"
}
},
"deleteConfirm": {
@@ -420,7 +282,7 @@
},
"status": {
"title": "查看状态",
"description": "显示运行时、服务商和通道状态。"
"description": "显示运行时、provider 和 channel 状态。"
},
"history": {
"title": "查看对话历史",
+8 -123
View File
@@ -30,25 +30,13 @@
"collapse": "收合側邊欄",
"toggleTheme": "切換主題",
"newChat": "新增對話",
"viewOptions": "檢視",
"compactList": "緊湊列表",
"showPreviews": "顯示預覽",
"showTimestamps": "顯示時間",
"sortLabel": "排序",
"sortUpdated": "最近更新",
"sortCreated": "最近建立",
"sortTitle": "標題 A-Z",
"recent": "最近對話",
"refreshSessions": "重新整理會話",
"settings": "設定",
"language": {
"label": "語言",
"ariaLabel": "切換語言"
},
"searchAria": "搜尋",
"searchPlaceholder": "搜尋",
"searchResults": "搜尋結果",
"noSearchResults": "沒有符合的對話。"
}
},
"settings": {
"backToChat": "返回對話",
@@ -58,28 +46,12 @@
},
"nav": {
"general": "一般",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced"
"byok": "BYOK"
},
"sections": {
"interface": "介面",
"ai": "AI",
"system": "系統",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"integrations": "Integrations"
"system": "系統"
},
"rows": {
"theme": "主題",
@@ -87,70 +59,19 @@
"provider": "提供者",
"model": "模型",
"restart": "重新啟動 nanobot",
"configPath": "設定檔路徑",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs"
"configPath": "設定檔路徑"
},
"help": {
"theme": "在淺色與深色外觀之間切換。",
"language": "選擇 WebUI 使用的語言。",
"provider": "選擇新模型請求使用的服務提供者。",
"model": "設定 nanobot 預設使用的模型名稱。",
"configPath": "目前閘道正在使用的設定檔。",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"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.",
"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.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"configPath": "目前閘道正在使用的設定檔。"
},
"values": {
"light": "淺色",
"dark": "深色",
"notAvailable": "不可用",
"enabled": "Enabled",
"disabled": "Disabled",
"restartRequired": "Restart required",
"liveReload": "Live reload ready",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"configured": "Configured",
"notConfigured": "Not configured"
"notAvailable": "不可用"
},
"status": {
"loading": "正在載入設定...",
@@ -162,8 +83,7 @@
"save": "儲存",
"saving": "儲存中",
"edit": "編輯",
"cancel": "取消",
"openDocs": "Open docs"
"cancel": "取消"
},
"byok": {
"description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。",
@@ -206,18 +126,6 @@
"missingCredential": "填寫必要憑證後才能儲存。",
"saveHint": "變更會套用到新的 web search 請求。"
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"workspace": "Workspace"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
}
},
"chat": {
@@ -225,31 +133,8 @@
"loading": "載入中…",
"noSessions": "目前還沒有會話。",
"actions": "「{{title}}」的會話操作",
"activity": {
"running": "Agent 正在執行",
"complete": "Agent 已完成"
},
"pin": "置頂",
"unpin": "取消置頂",
"rename": "重新命名",
"renameTitle": "重新命名對話",
"renameDescription": "為這個對話設定僅用於 WebUI 側邊欄的名稱。",
"renamePlaceholder": "對話名稱",
"renameSave": "儲存",
"archive": "封存",
"unarchive": "取消封存",
"showArchived": "顯示封存",
"hideArchived": "隱藏封存",
"delete": "刪除",
"newChat": "新增對話",
"groups": {
"pinned": "置頂",
"all": "對話",
"today": "今天",
"yesterday": "昨天",
"earlier": "更早",
"archived": "已封存"
}
"newChat": "新增對話"
},
"deleteConfirm": {
"title": "刪除這個對話?",
-56
View File
@@ -1,10 +1,8 @@
import type {
ChatSummary,
ImageGenerationSettingsUpdate,
ProviderSettingsUpdate,
SettingsPayload,
SettingsUpdate,
SidebarStatePayload,
SlashCommand,
WebSearchSettingsUpdate,
WebuiThreadPersistedPayload,
@@ -54,7 +52,6 @@ export async function listSessions(
updated_at: string | null;
title?: string;
preview?: string;
run_started_at?: number | null;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
@@ -67,7 +64,6 @@ export async function listSessions(
updatedAt: s.updated_at,
title: s.title ?? "",
preview: s.preview ?? "",
runStartedAt: s.run_started_at ?? null,
}));
}
@@ -129,43 +125,14 @@ export async function listSlashCommands(
}));
}
export async function fetchSidebarState(
token: string,
base: string = "",
): Promise<SidebarStatePayload> {
return request<SidebarStatePayload>(`${base}/api/webui/sidebar-state`, token);
}
export async function updateSidebarState(
token: string,
state: SidebarStatePayload,
base: string = "",
): Promise<SidebarStatePayload> {
const query = new URLSearchParams();
query.set("state", JSON.stringify(state));
return request<SidebarStatePayload>(
`${base}/api/webui/sidebar-state/update?${query}`,
token,
);
}
export async function updateSettings(
token: string,
update: SettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
if (update.modelPreset !== undefined) {
query.set("model_preset", update.modelPreset ?? "default");
}
if (update.model !== undefined) query.set("model", update.model);
if (update.provider !== undefined) query.set("provider", update.provider);
if (update.timezone !== undefined) query.set("timezone", update.timezone);
if (update.botName !== undefined) query.set("bot_name", update.botName);
if (update.botIcon !== undefined) query.set("bot_icon", update.botIcon);
if (update.toolHintMaxLength !== undefined) {
query.set("tool_hint_max_length", String(update.toolHintMaxLength));
}
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
}
@@ -193,31 +160,8 @@ export async function updateWebSearchSettings(
query.set("provider", update.provider);
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl);
if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults));
if (update.timeout !== undefined) query.set("timeout", String(update.timeout));
if (update.useJinaReader !== undefined) {
query.set("use_jina_reader", String(update.useJinaReader));
}
return request<SettingsPayload>(
`${base}/api/settings/web-search/update?${query}`,
token,
);
}
export async function updateImageGenerationSettings(
token: string,
update: ImageGenerationSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("enabled", String(update.enabled));
query.set("provider", update.provider);
query.set("model", update.model);
query.set("default_aspect_ratio", update.defaultAspectRatio);
query.set("default_image_size", update.defaultImageSize);
query.set("max_images_per_turn", String(update.maxImagesPerTurn));
return request<SettingsPayload>(
`${base}/api/settings/image-generation/update?${query}`,
token,
);
}
+1 -22
View File
@@ -56,7 +56,6 @@ type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateScope = "metadata" | "thread" | string;
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
/** Structured connection-level errors surfaced to the UI.
*
@@ -103,7 +102,6 @@ export class NanobotClient {
private statusHandlers = new Set<StatusHandler>();
private runtimeModelHandlers = new Set<RuntimeModelHandler>();
private sessionUpdateHandlers = new Set<SessionUpdateHandler>();
private runStatusHandlers = new Set<RunStatusHandler>();
private errorHandlers = new Set<ErrorHandler>();
// chat_id -> handlers listening on it
private chatHandlers = new Map<string, Set<EventHandler>>();
@@ -174,16 +172,6 @@ export class NanobotClient {
};
}
onRunStatus(handler: RunStatusHandler): Unsubscribe {
this.runStatusHandlers.add(handler);
for (const [chatId, startedAt] of this.runStartedAtByChatId) {
handler(chatId, startedAt);
}
return () => {
this.runStatusHandlers.delete(handler);
};
}
/** Subscribe to transport-level faults (see :type:`StreamError`). */
onError(handler: ErrorHandler): Unsubscribe {
this.errorHandlers.add(handler);
@@ -206,12 +194,9 @@ export class NanobotClient {
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
const previous = this.runStartedAtByChatId.get(chatId);
this.runStartedAtByChatId.set(chatId, ev.started_at);
if (previous !== ev.started_at) this.emitRunStatus(chatId, ev.started_at);
} else if (this.runStartedAtByChatId.has(chatId)) {
} else {
this.runStartedAtByChatId.delete(chatId);
this.emitRunStatus(chatId, null);
}
}
@@ -404,12 +389,6 @@ export class NanobotClient {
}
}
private emitRunStatus(chatId: string, startedAt: number | null): void {
for (const handler of this.runStatusHandlers) {
handler(chatId, startedAt);
}
}
private dispatch(chatId: string, ev: InboundEvent): void {
const handlers = this.chatHandlers.get(chatId);
if (handlers !== undefined && handlers.size > 0) {
+7 -34
View File
@@ -39,40 +39,13 @@ export function formatToolCallTrace(call: unknown): string | null {
return `${name}()`;
}
const VALID_PHASES = new Set(["start", "end", "error"]);
export function toolTraceLinesFromEvents(events: unknown): string[] {
if (!Array.isArray(events)) return [];
const seen = new Set<string>();
const lines: string[] = [];
for (const event of events) {
if (!event || typeof event !== "object") continue;
const phase = (event as { phase?: unknown }).phase;
if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) continue;
const callId = (event as { call_id?: unknown }).call_id;
if (callId && typeof callId === "string") {
if (seen.has(callId)) continue;
seen.add(callId);
}
const line = formatToolCallTrace(event);
if (!line) continue;
lines.push(line);
}
return lines;
}
export function mergeUniqueToolTraceLines(
previousTraces: string[],
lines: string[],
): { traces: string[]; added: boolean } {
const seen = new Set(previousTraces);
const traces = [...previousTraces];
let added = false;
for (const line of lines) {
if (seen.has(line)) continue;
seen.add(line);
traces.push(line);
added = true;
}
return { traces, added };
return events
.filter((event) => {
if (!event || typeof event !== "object") return false;
return (event as { phase?: unknown }).phase === "start";
})
.map(formatToolCallTrace)
.filter((trace): trace is string => !!trace);
}
-120
View File
@@ -89,7 +89,6 @@ export interface UIFileEdit {
call_id: string;
tool: string;
path: string;
absolute_path?: string;
phase?: "start" | "end" | "error" | string;
added: number;
deleted: number;
@@ -97,7 +96,6 @@ export interface UIFileEdit {
status: "editing" | "done" | "error";
binary?: boolean;
error?: string;
pending?: boolean;
}
export interface ChatSummary {
@@ -110,30 +108,6 @@ export interface ChatSummary {
updatedAt: string | null;
title?: string;
preview: string;
/** Unix epoch seconds when this session currently has a turn in flight. */
runStartedAt?: number | null;
}
export type SidebarDensity = "comfortable" | "compact";
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc";
export interface SidebarViewState {
density: SidebarDensity;
show_previews: boolean;
show_timestamps: boolean;
show_archived: boolean;
sort: SidebarSortMode;
}
export interface SidebarStatePayload {
schema_version: number;
pinned_keys: string[];
archived_keys: string[];
title_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>;
collapsed_groups: Record<string, boolean>;
view: SidebarViewState;
updated_at?: string | null;
}
export interface BootstrapResponse {
@@ -149,28 +123,7 @@ export interface SettingsPayload {
provider: string;
resolved_provider: string | null;
has_api_key: boolean;
model_preset: string | null;
max_tokens: number;
context_window_tokens: number;
temperature: number;
reasoning_effort: string | null;
timezone: string;
bot_name: string;
bot_icon: string;
tool_hint_max_length: number;
};
model_presets: Array<{
name: string;
label: string;
active: boolean;
is_default: boolean;
model: string;
provider: string;
max_tokens: number;
context_window_tokens: number;
temperature: number;
reasoning_effort: string | null;
}>;
providers: Array<{
name: string;
label: string;
@@ -184,82 +137,21 @@ export interface SettingsPayload {
provider: string;
api_key_hint?: string | null;
base_url?: string | null;
max_results: number;
timeout: number;
providers: Array<{
name: string;
label: string;
credential: "none" | "api_key" | "base_url";
}>;
};
web: {
enable: boolean;
proxy?: string | null;
user_agent?: string | null;
search: {
max_results: number;
timeout: number;
};
fetch: {
use_jina_reader: boolean;
};
};
image_generation: {
enabled: boolean;
provider: string;
provider_configured: boolean;
model: string;
default_aspect_ratio: string;
default_image_size: string;
max_images_per_turn: number;
save_dir: string;
providers: Array<{
name: string;
label: string;
configured: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
}>;
};
runtime: {
config_path: string;
workspace_path: string;
gateway_host: string;
gateway_port: number;
heartbeat: {
enabled: boolean;
interval_s: number;
keep_recent_messages: number;
};
dream: {
schedule: string;
max_batch_size: number;
max_iterations: number;
annotate_line_ages: boolean;
};
unified_session: boolean;
};
advanced: {
restrict_to_workspace: boolean;
ssrf_whitelist_count: number;
mcp_server_count: number;
exec_enabled: boolean;
exec_sandbox?: string | null;
exec_path_append_set: boolean;
};
requires_restart: boolean;
restart_required_sections?: Array<"runtime" | "web" | "image">;
}
export interface SettingsUpdate {
model?: string;
provider?: string;
modelPreset?: string | null;
timezone?: string;
botName?: string;
botIcon?: string;
toolHintMaxLength?: number;
}
export interface ProviderSettingsUpdate {
@@ -272,18 +164,6 @@ export interface WebSearchSettingsUpdate {
provider: string;
apiKey?: string;
baseUrl?: string;
maxResults?: number;
timeout?: number;
useJinaReader?: boolean;
}
export interface ImageGenerationSettingsUpdate {
enabled: boolean;
provider: string;
model: string;
defaultAspectRatio: string;
defaultImageSize: string;
maxImagesPerTurn: number;
}
export interface SlashCommand {
@@ -236,7 +236,6 @@ describe("AgentActivityCluster", () => {
call_id: "call-edit",
tool: "edit_file",
path: "src/app.tsx",
absolute_path: "/Users/renxubin/project/src/app.tsx",
phase: "end",
added: 12,
deleted: 3,
@@ -251,17 +250,13 @@ describe("AgentActivityCluster", () => {
);
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx");
expect(screen.getByTestId("activity-header-file-reference")).toHaveAttribute(
"aria-label",
"/Users/renxubin/project/src/app.tsx",
);
fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
expect(screen.queryByText("Edited")).not.toBeInTheDocument();
const fileRef = screen.getByTestId("activity-file-reference");
expect(fileRef).toHaveTextContent("src/app.tsx");
expect(fileRef).toHaveAttribute("aria-label", "/Users/renxubin/project/src/app.tsx");
expect(fileRef).toHaveAttribute("aria-label", "src/app.tsx");
await waitFor(() => {
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
@@ -271,38 +266,6 @@ describe("AgentActivityCluster", () => {
}
});
it("renders pending file edit placeholders before the path is known", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "",
traces: [],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "",
phase: "start",
added: 0,
deleted: 0,
approximate: true,
status: "editing",
pending: true,
}],
createdAt: 3,
})}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /preparing edit/i }));
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {
+2 -77
View File
@@ -2,12 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deleteSession,
fetchSidebarState,
fetchWebuiThread,
listSessions,
listSlashCommands,
updateSidebarState,
updateImageGenerationSettings,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
@@ -49,17 +46,12 @@ describe("webui API helpers", () => {
it("serializes settings updates as a narrow query string", async () => {
await updateSettings("tok", {
modelPreset: "default",
model: "openrouter/test",
provider: "openrouter",
timezone: "Asia/Shanghai",
botName: "nanobot",
botIcon: "nb",
toolHintMaxLength: 120,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120",
"/api/settings/update?model=openrouter%2Ftest&provider=openrouter",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
@@ -85,81 +77,16 @@ describe("webui API helpers", () => {
await updateWebSearchSettings("tok", {
provider: "searxng",
baseUrl: "https://search.example.com",
maxResults: 8,
timeout: 45,
useJinaReader: false,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false",
"/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes image generation settings updates", async () => {
await updateImageGenerationSettings("tok", {
enabled: true,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "16:9",
defaultImageSize: "2K",
maxImagesPerTurn: 3,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/image-generation/update?enabled=true&provider=openrouter&model=openai%2Fgpt-5.4-image-2&default_aspect_ratio=16%3A9&default_image_size=2K&max_images_per_turn=3",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("reads and writes persisted sidebar state", async () => {
const state = {
schema_version: 1,
pinned_keys: ["websocket:chat-1"],
archived_keys: ["websocket:old"],
title_overrides: { "websocket:chat-1": "Release" },
tags_by_key: {},
collapsed_groups: {},
view: {
density: "compact" as const,
show_previews: false,
show_timestamps: false,
show_archived: true,
sort: "updated_desc" as const,
},
updated_at: null,
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => state,
} as Response);
await expect(fetchSidebarState("tok")).resolves.toEqual(state);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/sidebar-state",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await updateSidebarState("tok", state);
const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!;
expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true);
expect(init).toEqual(expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}));
const encodedState = new URLSearchParams(String(url).split("?", 2)[1]).get("state");
expect(encodedState).toBeTruthy();
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
pinned_keys: ["websocket:chat-1"],
title_overrides: { "websocket:chat-1": "Release" },
});
});
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
@@ -170,7 +97,6 @@ describe("webui API helpers", () => {
created_at: "2026-05-01T10:00:00",
updated_at: "2026-05-01T10:01:00",
title: "优化 WebUI 标题",
run_started_at: 1_700_000_000,
},
],
}),
@@ -181,7 +107,6 @@ describe("webui API helpers", () => {
key: "websocket:chat-1",
title: "优化 WebUI 标题",
preview: "",
runStartedAt: 1_700_000_000,
},
]);
});
+17 -566
View File
@@ -9,8 +9,6 @@ const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => {
@@ -69,16 +67,9 @@ vi.mock("@/lib/nanobot-client", () => {
onRuntimeModelUpdate = () => () => {};
onError = () => () => {};
onChat = () => () => {};
onSessionUpdate = () => () => {};
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
runStatusHandlers.add(handler);
return () => runStatusHandlers.delete(handler);
};
getRunStartedAt = () => null;
getGoalState = () => undefined;
sendMessage = vi.fn();
newChat = vi.fn();
attach = attachSpy;
attach = vi.fn();
close = vi.fn();
updateUrl = updateUrlSpy;
}
@@ -98,9 +89,6 @@ describe("App layout", () => {
createChatSpy.mockClear();
deleteChatSpy.mockReset();
toggleThemeSpy.mockReset();
attachSpy.mockReset();
runStatusHandlers.clear();
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
ws_path: "/",
@@ -187,318 +175,6 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
it("keeps the mobile session action menu inside the sidebar sheet", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Existing chat",
},
];
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: !query.includes("1024px"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }));
const sheet = await screen.findByRole("dialog");
const mobileSidebar = within(sheet).getByRole("navigation", {
name: "Sidebar navigation",
});
await waitFor(() =>
expect(
within(mobileSidebar).getByRole("button", { name: /^Existing chat$/ }),
).toBeInTheDocument(),
);
fireEvent.pointerDown(
within(mobileSidebar).getByLabelText("Chat actions for Existing chat"),
{ button: 0 },
);
const deleteItem = await within(sheet).findByRole("menuitem", {
name: "Delete",
});
expect(deleteItem).toBeInTheDocument();
fireEvent.click(deleteItem);
await waitFor(() =>
expect(screen.getByText("Delete this chat?")).toBeInTheDocument(),
);
}, 15_000);
it("applies persisted sidebar workspace state from the gateway", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "First chat",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Second chat",
},
];
const initialState = {
schema_version: 1,
pinned_keys: ["websocket:chat-b"],
archived_keys: ["websocket:chat-a"],
title_overrides: { "websocket:chat-b": "Roadmap" },
tags_by_key: {},
collapsed_groups: {},
view: {
density: "comfortable",
show_previews: false,
show_timestamps: false,
show_archived: false,
sort: "updated_desc",
},
updated_at: null,
};
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (url: string | URL | Request) => {
const href = String(url);
if (href === "/api/webui/sidebar-state") {
return { ok: true, json: async () => initialState };
}
if (href.startsWith("/api/webui/sidebar-state/update?")) {
const encoded = new URLSearchParams(href.split("?", 2)[1]).get("state");
return {
ok: true,
json: async () => JSON.parse(encoded ?? "{}"),
};
}
return { ok: false, status: 404 };
}),
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
);
expect(within(sidebar).getByRole("button", { name: /^Roadmap$/ })).toBeInTheDocument();
expect(within(sidebar).queryByRole("button", { name: /^First chat$/ })).not.toBeInTheDocument();
fireEvent.click(within(sidebar).getByRole("button", { name: "Show archived" }));
await waitFor(() =>
expect(within(sidebar).getByText("Archived")).toBeInTheDocument(),
);
expect(within(sidebar).getByRole("button", { name: /^First chat$/ })).toBeInTheDocument();
const updateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.find((url) => url.startsWith("/api/webui/sidebar-state/update?"));
expect(updateUrl).toBeTruthy();
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
fireEvent.pointerDown(within(sidebar).getByRole("button", { name: "View" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(await screen.findByText("Compact list"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.density).toBe("compact");
});
fireEvent.click(screen.getByText("Title A-Z"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.sort).toBe("title_asc");
});
});
it("sorts chats by displayed title when A-Z is persisted", async () => {
mockSessions = [
{
key: "websocket:zulu",
channel: "websocket",
chatId: "zulu",
createdAt: "2026-04-16T12:00:00Z",
updatedAt: "2026-04-16T12:00:00Z",
title: "Zulu work",
preview: "later",
},
{
key: "websocket:new",
channel: "websocket",
chatId: "new",
createdAt: "2026-04-15T12:00:00Z",
updatedAt: "2026-04-15T12:00:00Z",
preview: "hi nanobot",
},
{
key: "websocket:alpha",
channel: "websocket",
chatId: "alpha",
createdAt: "2026-04-14T12:00:00Z",
updatedAt: "2026-04-14T12:00:00Z",
title: "Alpha plan",
preview: "earlier",
},
];
const initialState = {
schema_version: 1,
pinned_keys: [],
archived_keys: [],
title_overrides: {},
tags_by_key: {},
collapsed_groups: {},
view: {
density: "comfortable",
show_previews: false,
show_timestamps: false,
show_archived: false,
sort: "title_asc",
},
updated_at: null,
};
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (url: string | URL | Request) => {
const href = String(url);
if (href === "/api/webui/sidebar-state") {
return { ok: true, json: async () => initialState };
}
return { ok: false, status: 404 };
}),
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(within(sidebar).getByText("Chats")).toBeInTheDocument(),
);
const group = within(sidebar).getByText("Chats").closest("section");
expect(group).toBeTruthy();
const labels = within(group as HTMLElement)
.getAllByRole("button")
.map((button) => button.textContent?.trim())
.filter(Boolean);
expect(labels).toEqual(["Alpha plan", "New chat", "Zulu work"]);
});
it("shows running and completed session indicators in the sidebar", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Working chat",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Quiet chat",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(
within(sidebar).getByRole("button", { name: /^Working chat$/ }),
).toBeInTheDocument(),
);
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
});
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", null);
});
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
});
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
});
it("restores sidebar run indicators after a page reload", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Running after reload",
runStartedAt: 12_345,
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Completed after reload",
},
];
localStorage.setItem(
"nanobot-webui.sidebar.completed-runs.v1",
JSON.stringify(["chat-b"]),
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(),
);
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
expect(attachSpy).toHaveBeenCalledWith("chat-a");
});
it("opens the settings view from the sidebar footer", async () => {
mockSessions = [
{
@@ -523,42 +199,7 @@ describe("App layout", () => {
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
model_preset: "default",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
timezone: "UTC",
bot_name: "nanobot",
bot_icon: "nb",
tool_hint_max_length: 40,
},
model_presets: [
{
name: "default",
label: "Default",
active: true,
is_default: true,
model: "openai/gpt-4o",
provider: "auto",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
},
{
name: "deep",
label: "deep",
active: false,
is_default: false,
model: "anthropic/claude-opus-4-5",
provider: "anthropic",
max_tokens: 8192,
context_window_tokens: 200000,
temperature: 0.1,
reasoning_effort: "high",
},
],
providers: [
{
name: "openai",
@@ -573,13 +214,6 @@ describe("App layout", () => {
api_key_required: true,
default_api_base: "https://openrouter.ai/api/v1",
},
{
name: "ant_ling",
label: "Ant Ling",
configured: false,
api_key_required: true,
default_api_base: "https://api.ant-ling.com/v1",
},
{
name: "azure_openai",
label: "Azure OpenAI",
@@ -628,74 +262,14 @@ describe("App layout", () => {
provider: "brave",
api_key_hint: "BSAo••••ew20",
base_url: null,
max_results: 5,
timeout: 30,
providers: [
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" },
{ name: "brave", label: "Brave Search", credential: "api_key" },
{ name: "tavily", label: "Tavily", credential: "api_key" },
],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: true,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [
{
name: "openrouter",
label: "OpenRouter",
configured: true,
api_key_hint: "sk-o••••test",
api_base: "https://openrouter.ai/api/v1",
default_api_base: "https://openrouter.ai/api/v1",
},
{
name: "gemini",
label: "Gemini",
configured: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://generativelanguage.googleapis.com/v1beta/openai/",
},
],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_append_set: false,
},
requires_restart: false,
}),
@@ -711,34 +285,22 @@ describe("App layout", () => {
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
expect(document.title).toBe("Settings · nanobot");
expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument();
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
expect(settingsNav.className).toContain("overflow-x-auto");
expect(settingsNav.className).not.toContain("grid-cols-2");
expect(within(settingsNav).getByRole("button", { name: "Overview" })).toHaveAttribute(
expect(within(settingsNav).getByRole("button", { name: "General" })).toHaveAttribute(
"aria-current",
"page",
);
expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Providers" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "BYOK" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
expect(screen.getByText("AI")).toBeInTheDocument();
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
expect(modelInput).toBeInTheDocument();
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } });
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
"text-blue-600",
);
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o" } });
fireEvent.click(within(settingsNav).getByRole("button", { name: "Providers" }));
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "BYOK" }));
expect(screen.getByRole("tab", { name: "LLM" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Web Search" })).toBeInTheDocument();
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
fireEvent.click(screen.getByText("OpenAI"));
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
@@ -749,20 +311,11 @@ describe("App layout", () => {
fireEvent.click(screen.getByText("OpenAI"));
expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Ant Ling"));
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
fireEvent.click(screen.getByText("Atomic Chat"));
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Image" }));
expect(screen.getByRole("heading", { name: "Image" })).toBeInTheDocument();
expect(screen.getByText("Provider status")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-5.4-image-2")).toBeInTheDocument();
expect(screen.getByText("Save directory")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Web" }));
fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
expect(screen.getByText("Search provider")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument();
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
@@ -776,10 +329,6 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" }));
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Runtime" }));
expect(screen.getByText("Bot name")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
it("returns from settings to the blank start page when no session was active", async () => {
@@ -814,94 +363,19 @@ describe("App layout", () => {
provider: "openai",
resolved_provider: "openai",
has_api_key: true,
model_preset: "default",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
timezone: "UTC",
bot_name: "nanobot",
bot_icon: "nb",
tool_hint_max_length: 40,
},
model_presets: [
{
name: "default",
label: "Default",
active: true,
is_default: true,
model: "openai/gpt-4o",
provider: "openai",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
},
],
providers: [{ name: "openai", label: "OpenAI", configured: true }],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" },
{ name: "brave", label: "Brave Search", credential: "api_key" },
],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [
{
name: "openrouter",
label: "OpenRouter",
configured: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://openrouter.ai/api/v1",
},
],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_append_set: false,
},
requires_restart: false,
}),
@@ -919,14 +393,14 @@ describe("App layout", () => {
await waitFor(() => expect(document.title).toBe("nanobot"));
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
await waitFor(() => expect(document.title).toBe("nanobot"));
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
});
it("filters sessions in the centered search dialog", async () => {
it("filters sidebar sessions through the lightweight search row", async () => {
mockSessions = [
{
key: "websocket:chat-alpha",
@@ -953,43 +427,20 @@ describe("App layout", () => {
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
const newChatButton = within(sidebar).getByRole("button", { name: "New chat" });
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
expect(
newChatButton.compareDocumentPosition(searchButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
fireEvent.click(searchButton);
const dialog = await screen.findByRole("dialog", { name: "Search" });
expect(dialog).toHaveClass("origin-center");
expect(dialog.className).not.toContain("translate-x");
expect(dialog.className).not.toContain("translate-y");
expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(dialog).getByText("Travel ideas")).toBeInTheDocument();
expect(within(dialog).queryByText("websocket")).not.toBeInTheDocument();
expect(within(dialog).queryByText("#1")).not.toBeInTheDocument();
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), {
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
target: { value: "planning" },
});
expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(dialog).queryByText("Travel ideas")).not.toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument();
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), {
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
target: { value: "road q2" },
});
expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(dialog).queryByText("Travel ideas")).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: /Q2 roadmap/ }));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument(),
);
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument();
});
it("opens a blank start page without creating an empty chat", async () => {
+1 -20
View File
@@ -8,16 +8,7 @@ import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
const SETTINGS_NAV_KEYS = [
"overview",
"appearance",
"models",
"providers",
"image",
"web",
"runtime",
"advanced",
];
const SETTINGS_NAV_KEYS = ["general", "byok"];
describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => {
@@ -96,14 +87,4 @@ describe("webui i18n", () => {
expect(common.settings.byok.configuredKeyHint).toBeTruthy();
}
});
it("keeps Simplified Chinese settings overview copy localized", () => {
const settings = resources["zh-CN"].common.settings;
expect(settings.nav.web).toBe("网页");
expect(settings.sections.webSearch).toBe("网页搜索");
expect(settings.byok.tabs.webSearch).toBe("网页搜索");
expect(settings.overview.webSearch).toBe("网页搜索");
expect(settings.overview.workspace).toBe("工作区");
});
});

Some files were not shown because too many files have changed in this diff Show More