Compare commits

..
Author SHA1 Message Date
Xubin Ren f309982bb0 chore(release): update version to 0.2.1 2026-06-01 16:51:24 +08:00
chengyongruandXubin Ren 0e37024114 fix(session): archive actual idle compact drops 2026-06-01 16:07:08 +08:00
yorkhellenandXubin Ren baffd6ef92 fix(session): correct last_consolidated tracking in non-contiguous retention
The previous fix made retain_recent_legal_suffix return the actual dropped
message list, but already_consolidated was still computed with
min(before_last_consolidated, len(dropped)), which assumes dropped messages
are always a prefix. In the else branch (tail has no user messages), dropped
may include messages from after the consolidated prefix, causing
already_consolidated to skip too many and leaving tail messages neither
retained nor raw-archived.

Fix by having retain_recent_legal_suffix return (dropped,
already_consolidated_count) where already_consolidated_count is computed
against original message indices. Also fix last_consolidated update to count
how many retained messages were inside the old consolidated prefix.
2026-06-01 16:07:08 +08:00
yorkhellenandXubin Ren 72fb642ef7 fix(session): prevent duplicate archive and message loss in enforce_file_cap
When retain_recent_legal_suffix hits the else branch (tail has no user
messages), it takes a non-contiguous slice from the middle of the session.
enforce_file_cap incorrectly assumed dropped messages were always a prefix
(before[:dropped_count]), causing user messages to be both archived and
retained, and some messages to silently disappear.

Fix by having retain_recent_legal_suffix return the actual dropped message
list using identity-based diff, so enforce_file_cap no longer needs to
guess which messages were removed.
2026-06-01 16:07:08 +08:00
JasperandXubin Ren b886b4a566 docs: add AGENTS.md for Codex 2026-06-01 16:06:51 +08:00
Xubin Ren a4bd4befd4 Fix thought activity ordering 2026-06-01 16:05:42 +08:00
Xubin Ren 9ecd25bca1 docs: update nanobot_webui.png for improved visuals 2026-06-01 06:07:10 +08:00
Xubin Ren 503fc83ce2 docs: rename README cover image 2026-06-01 05:47:14 +08:00
Xubin Ren 806176f161 docs: update GitHub README image 2026-06-01 05:41:23 +08:00
Xubin Ren 081482b20f docs: refresh README opening positioning 2026-06-01 05:29:11 +08:00
Xubin Ren ff80998423 docs: tighten README positioning bullets 2026-06-01 05:26:18 +08:00
Xubin Ren b60e507010 docs: sharpen README positioning 2026-06-01 05:19:14 +08:00
Xubin Ren 76e857269d docs: update README news through May 30 2026-06-01 05:14:53 +08:00
Xubin Ren be2e0172d1 fix(agent): extend sustained goal iteration budget 2026-06-01 04:00:15 +08:00
Xubin Ren cba9ff1f57 fix(webui): simplify rendered source links 2026-06-01 00:00:37 +08:00
Xubin Ren 33a13b701b feat(webui): render source links with favicons 2026-06-01 00:00:37 +08:00
Xubin Ren 34386fe676 fix(webui): stabilize streaming output and settings i18n 2026-06-01 00:00:37 +08:00
Xubin Ren 31722120b7 feat(webui): polish native host experience 2026-06-01 00:00:37 +08:00
Xubin Ren 15c6abc991 test(webui): assert code block language fallback 2026-05-31 15:42:40 +08:00
Flinn-XandXubin Ren bdb3a2ded7 fix(webui): handle undefined language in code blocks
When fenced code blocks have no language specifier, react-syntax-highlighter
receives undefined for the language prop, causing a white screen crash.

- CodeBlock.tsx: fallback to 'text' when language is undefined
- MarkdownTextRenderer.tsx: defensive fallback at fence rendering site
- Added test cases for both components

Fixes #4116
2026-05-31 15:42:40 +08:00
hamb1yandXubin Ren a3241c33ba Require auth for WebSocket token issuance 2026-05-31 15:15:54 +08:00
chengyongruandXubin Ren 15c2bd25b3 refactor(heartbeat): remove Completed section and tighten section gating
- Remove ## Completed section from HEARTBEAT.md template; completed
  tasks should be deleted, not accumulated
- Change in_active_section from tri-state (None/True/False) to bool
  (True/False) so stray text before any ## heading no longer triggers
  heartbeat
- Add test cases for stray pre-heading text and ## Notes section
- Update docs/chat-commands.md to reference ## Active Tasks
2026-05-31 15:15:37 +08:00
Xubin Ren 2671c8fe55 fix(heartbeat): ignore completed-only heartbeat entries 2026-05-31 15:15:37 +08:00
04cbandXubin Ren e3df310309 fix(heartbeat): skip when HEARTBEAT.md has no tasks and fail closed on delivery (#4111) 2026-05-31 15:15:37 +08:00
Xubin Ren 2b4c984e9a fix(matrix): align SAS verification message flow 2026-05-31 01:00:14 +08:00
mytechdreamandXubin Ren 68712fc489 fix(matrix): handle SAS device verification 2026-05-31 01:00:14 +08:00
71 changed files with 3359 additions and 1499 deletions
+1
View File
@@ -5,6 +5,7 @@ __pycache__
*.egg-info
dist/
build/
nanobot/web/dist/
.git
.env
.assets
+82
View File
@@ -0,0 +1,82 @@
This file provides guidance to AI coding agents working with this repository.
## Project Overview
nanobot is a lightweight, open-source AI agent framework written in Python with a React/TypeScript WebUI. It centers around a small agent loop that receives messages from chat channels, invokes an LLM provider, executes tools, and manages session memory.
## Development Commands
```bash
# Python: run single test / lint
pytest tests/test_openai_api.py::test_function -v
ruff check nanobot/
# WebUI: dev server (proxies API/WS to gateway :8765), build, test
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
cd webui && bun run build
cd webui && bun run test
# Gateway
nanobot gateway
```
## High-Level Architecture
### Core Data Flow
Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decouples chat channels from the agent core:
1. **Channels** (`nanobot/channels/`) receive messages from external platforms and publish `InboundMessage` events to the bus.
2. **`AgentLoop`** (`nanobot/agent/loop.py`) consumes inbound messages, builds context, and coordinates the turn.
3. **`AgentRunner`** (`nanobot/agent/runner.py`) handles the actual LLM conversation loop: send messages to the provider, receive tool calls, execute tools, and stream responses.
4. Responses are published as `OutboundMessage` events back to the appropriate channel.
### Key Subsystems
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
- **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed).
- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel.
- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context.
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
### Entry Points
- **CLI**: `nanobot/cli/commands.py`
- **Python SDK**: `nanobot/nanobot.py`
## Project-Specific Notes
- Architecture constraints: [`.agent/design.md`](.agent/design.md)
- Security boundaries: [`.agent/security.md`](.agent/security.md)
- Common gotchas: [`.agent/gotchas.md`](.agent/gotchas.md)
## Branching Strategy
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full two-branch model (`main` vs `nightly`) and PR guidelines.
## Code Style
- Python 3.11+, asyncio throughout.
- Line length: 100.
- Linting: `ruff` with rules E, F, I, N, W (E501 ignored).
- pytest with `asyncio_mode = "auto"`.
## Common File Locations
- Config schema: `nanobot/config/schema.py`
- Provider base / new provider template: `nanobot/providers/base.py`
- Channel base / new channel template: `nanobot/channels/base.py`
- Tool registry: `nanobot/agent/tools/registry.py`
- WebUI dev proxy config: `webui/vite.config.ts`
- Tests mirror the `nanobot/` package structure.
+1 -84
View File
@@ -1,84 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
nanobot is a lightweight, open-source AI agent framework written in Python with a React/TypeScript WebUI. It centers around a small agent loop that receives messages from chat channels, invokes an LLM provider, executes tools, and manages session memory.
## Development Commands
```bash
# Python: run single test / lint
pytest tests/test_openai_api.py::test_function -v
ruff check nanobot/
# WebUI: dev server (proxies API/WS to gateway :8765), build, test
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
cd webui && bun run build
cd webui && bun run test
# Gateway
nanobot gateway
```
## High-Level Architecture
### Core Data Flow
Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decouples chat channels from the agent core:
1. **Channels** (`nanobot/channels/`) receive messages from external platforms and publish `InboundMessage` events to the bus.
2. **`AgentLoop`** (`nanobot/agent/loop.py`) consumes inbound messages, builds context, and coordinates the turn.
3. **`AgentRunner`** (`nanobot/agent/runner.py`) handles the actual LLM conversation loop: send messages to the provider, receive tool calls, execute tools, and stream responses.
4. Responses are published as `OutboundMessage` events back to the appropriate channel.
### Key Subsystems
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
- **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed).
- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel.
- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context.
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
### Entry Points
- **CLI**: `nanobot/cli/commands.py`
- **Python SDK**: `nanobot/nanobot.py`
## Project-Specific Notes
- Architecture constraints: [`.agent/design.md`](.agent/design.md)
- Security boundaries: [`.agent/security.md`](.agent/security.md)
- Common gotchas: [`.agent/gotchas.md`](.agent/gotchas.md)
## Branching Strategy
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full two-branch model (`main` vs `nightly`) and PR guidelines.
## Code Style
- Python 3.11+, asyncio throughout.
- Line length: 100.
- Linting: `ruff` with rules E, F, I, N, W (E501 ignored).
- pytest with `asyncio_mode = "auto"`.
## Common File Locations
- Config schema: `nanobot/config/schema.py`
- Provider base / new provider template: `nanobot/providers/base.py`
- Channel base / new channel template: `nanobot/channels/base.py`
- Tool registry: `nanobot/agent/tools/registry.py`
- WebUI dev proxy config: `webui/vite.config.ts`
- Tests mirror the `nanobot/` package structure.
@AGENTS.md
+1 -1
View File
@@ -25,7 +25,7 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
COPY nanobot/ nanobot/
COPY bridge/ bridge/
COPY webui/ webui/
RUN uv pip install --system --no-cache .
RUN NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache .
# Build the WhatsApp bridge
WORKDIR /app/bridge
+27 -11
View File
@@ -1,4 +1,4 @@
![cover-v5-optimized](./images/GitHub_README.png)
![nanobot README cover](./images/readme-cover.png)
<div align="center">
<p>
@@ -31,10 +31,29 @@
</p>
</div>
🐈 **nanobot** is an open-source and ultra-lightweight AI agent in the spirit of [OpenClaw](https://github.com/openclaw/openclaw), [Claude Code](https://www.anthropic.com/claude-code), and [Codex](https://www.openai.com/codex/). It keeps the core agent loop small and readable while still supporting chat channels, memory, MCP and practical deployment paths, so you can go from local setup to a long-running personal agent with minimal overhead.
🐈 **nanobot** is an open-source, ultra-lightweight agent runtime for people who want to own their AI agent stack. It gives you a small, readable core plus the practical pieces for real long-running agents: WebUI, chat channels, tools, memory, MCP, model routing, and deployment.
## 📢 News
- **2026-05-30** 🔐 Safer Matrix verification, bounded media downloads, clearer WebUI model timeline.
- **2026-05-29** 🧩 Extension registry, context-window tuning, document extraction controls.
- **2026-05-28** 🗂️ Project workspaces, access controls, steadier goals and streaming.
- **2026-05-27** ⏱️ Codex streams respect idle timeouts during long runs.
- **2026-05-26** 📡 Telegram webhooks, refreshed Kagi search, cleaner transport errors.
- **2026-05-25** 🔌 Unified CLI Apps and MCP, Step Plan support, steadier sustained goals.
- **2026-05-24** 🧰 MCP presets, richer slash actions, configurable OpenAI-compatible requests.
- **2026-05-23** 🖼️ Zhipu image generation, longer exec windows, cleaner transcription config.
- **2026-05-22** 🛠️ CLI Apps, more image providers, safer web redirects and edits.
- **2026-05-21** ⚡ Novita provider, faster sidebar, smoother coding tools and Weixin replies.
<details>
<summary>Earlier news</summary>
- **2026-05-20** 📶 Signal channel, faster gateway startup, multilingual README links.
- **2026-05-19** 🎨 Image provider registry, StepFun and Skywork, stronger WebUI controls.
- **2026-05-18** 🖌️ Gemini and MiniMax images, Ant Ling, live file-edit activity.
- **2026-05-17** 🌊 Smoother WebUI streaming, AutoCompact fixes, buffered CLI reasoning.
- **2026-05-16** 🧠 Atomic Chat provider, goal-aware timeouts, safer exec URL handling.
- **2026-05-15** 🚀 Released **v0.2.0****`/goal`** holds sustained objectives across turns, WebUI now ships inside the wheel, image generation end to end, 5 new providers with `fallback_models`, and a real agent-loop refactor. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.0) for details.
- **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat.
- **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects.
@@ -45,10 +64,6 @@
- **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses.
- **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick.
- **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
<details>
<summary>Earlier news</summary>
- **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish.
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
@@ -145,12 +160,13 @@
</details>
## 💡 Key Features of nanobot
## 💡 Why nanobot
- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core.
- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend.
- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in.
- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page.
- **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
- **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, and email.
- **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks.
- **Small core**: readable internals with MCP, memory, deployment, and automation built in.
- **Own your stack**: inspect, customize, self-host, and extend without a giant platform.
## 📦 Install
+2
View File
@@ -244,6 +244,7 @@ for reliable encryption, password login is recommended instead. If the
"userId": "@nanobot:matrix.org",
"password": "mypasswordhere",
"e2eeEnabled": true,
"sasVerification": true,
"allowFrom": ["@your_user:matrix.org"],
"groupPolicy": "open",
"groupAllowFrom": [],
@@ -263,6 +264,7 @@ for reliable encryption, password login is recommended instead. If the
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
| `sasVerification` | Auto-complete SAS device verification requests from allowed users (default `false`). Useful for Element X, which does not expose manual trust for third-party devices. |
| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
+3 -3
View File
@@ -56,17 +56,17 @@ Preset names come from the top-level `modelPresets` config. Switching is runtime
## Periodic Tasks
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel.
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks under `## Active Tasks`, the agent executes them and delivers results to your most recently active chat channel. If there are no active tasks, the heartbeat is skipped silently.
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown
## Periodic Tasks
## Active Tasks
- [ ] Check weather forecast and send a summary
- [ ] Scan inbox for urgent emails
```
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
+11 -4
View File
@@ -11,16 +11,23 @@
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
> [!IMPORTANT]
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container:
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, enable the WebSocket channel and protect bootstrap with a secret:
>
> ```json
> {
> "gateway": { "host": "0.0.0.0" },
> "channels": { "websocket": { "host": "0.0.0.0" } }
> "gateway": { "host": "0.0.0.0" },
> "channels": {
> "websocket": {
> "enabled": true,
> "host": "0.0.0.0",
> "port": 8765,
> "tokenIssueSecret": "your-secret-here"
> }
> }
> }
> ```
>
> When `host` is `0.0.0.0`, the gateway refuses to start unless `token` or `tokenIssueSecret` is also configured on the WebSocket channel — see [`webui/README.md`](../webui/README.md) for details.
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured — see [`webui/README.md`](../webui/README.md) for details.
### Docker Compose
Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+1 -1
View File
@@ -22,7 +22,7 @@ def _resolve_version() -> str:
return _pkg_version("nanobot-ai")
except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info.
return _read_pyproject_version() or "0.2.0"
return _read_pyproject_version() or "0.2.1"
__version__ = _resolve_version()
+50 -11
View File
@@ -45,6 +45,7 @@ from nanobot.session.goal_state import (
sustained_goal_active,
)
from nanobot.session.manager import Session, SessionManager
from nanobot.session import turn_continuation
from nanobot.session.webui_turns import (
WebuiTurnCoordinator,
build_bus_progress_callback,
@@ -112,6 +113,7 @@ class TurnContext:
save_skip: int = 0
outbound: OutboundMessage | None = None
suppress_response: bool = False
on_progress: Callable[..., Awaitable[None]] | None = None
on_stream: Callable[[str], Awaitable[None]] | None = None
@@ -121,6 +123,7 @@ class TurnContext:
pending_queue: asyncio.Queue | None = None
pending_summary: str | None = None
turn_wall_started_at: float = field(default_factory=time.time)
visible_run_started_at: float | None = None
turn_latency_ms: int | None = None
trace: list[StateTraceEntry] = field(default_factory=list)
@@ -565,6 +568,8 @@ class AgentLoop:
Returns True if the message was persisted.
"""
if not turn_continuation.should_persist_user_message(msg.metadata):
return False
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths:
@@ -771,6 +776,7 @@ class AgentLoop:
+ "\n\nPlease continue working toward the objective using your tools, "
"or call complete_goal if the work is truly finished."
) if _goal_lines else SUSTAINED_GOAL_CONTINUE_PROMPT
session_metadata = session.metadata if session is not None else None
try:
result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages,
@@ -796,7 +802,8 @@ class AgentLoop:
llm_timeout_s=runner_wall_llm_timeout_s(
self.sessions,
session.key if session is not None else session_key,
metadata=(session.metadata if session is not None else None),
metadata=session_metadata,
message_metadata=metadata,
),
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
goal_continue_message=_goal_continue,
@@ -808,9 +815,15 @@ class AgentLoop:
self._last_usage = result.usage
if result.stop_reason == "max_iterations":
logger.warning("Max iterations ({}) reached", self.max_iterations)
should_stream = turn_continuation.should_stream_budget_response(
stop_reason=result.stop_reason,
pending_queue_available=pending_queue is not None and session is not None,
session_metadata=session_metadata,
message_metadata=metadata,
)
# Push final content through stream so streaming channels (e.g. Feishu)
# update the card instead of leaving it empty.
if on_stream and on_stream_end:
if on_stream and on_stream_end and should_stream:
await on_stream(result.final_content or "")
await on_stream_end(resuming=False)
elif result.stop_reason == "error":
@@ -953,7 +966,8 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=msg.metadata or {},
))
if msg.channel == "websocket":
continuing = turn_continuation.internal_continuation_pending(msg.metadata)
if msg.channel == "websocket" and not continuing:
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
await self._webui_turns.handle_turn_end(
msg,
@@ -1017,9 +1031,10 @@ class AgentLoop:
"Re-published {} leftover message(s) to bus for session {}",
leftover, session_key,
)
await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
self._webui_turns.discard(session_key)
if not turn_continuation.internal_continuation_pending(msg.metadata):
await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
self._webui_turns.discard(session_key)
finally:
if pending is None:
await self._webui_turns.publish_run_status(msg, "idle")
@@ -1167,12 +1182,17 @@ class AgentLoop:
)
key = session_key or msg.session_key
t0 = time.time()
ctx = TurnContext(
msg=msg,
session=None,
session_key=key,
state=TurnState.RESTORE,
turn_id=f"{key}:{time.time_ns()}",
turn_wall_started_at=t0,
visible_run_started_at=turn_continuation.internal_continuation_run_started_at(
msg.metadata,
),
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
@@ -1378,7 +1398,13 @@ class AgentLoop:
return "ok"
async def _state_run(self, ctx: TurnContext) -> str:
await self._webui_turns.publish_run_status(ctx.msg, "running")
if ctx.visible_run_started_at is None:
ctx.visible_run_started_at = time.time()
await self._webui_turns.publish_run_status(
ctx.msg,
"running",
started_at=ctx.visible_run_started_at,
)
result = await self._run_agent_loop(
ctx.initial_messages,
on_progress=ctx.on_progress,
@@ -1399,15 +1425,25 @@ class AgentLoop:
ctx.all_messages = all_msgs
ctx.stop_reason = stop_reason
ctx.had_injections = had_injections
await turn_continuation.maybe_continue_turn(ctx)
return "ok"
async def _state_save(self, ctx: TurnContext) -> str:
if ctx.final_content is None or not ctx.final_content.strip():
turn_continuation.prepare_save_boundary(ctx)
if (
(ctx.final_content is None or not ctx.final_content.strip())
and not ctx.suppress_response
):
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0)
ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000))
latency_started_at = (
ctx.visible_run_started_at
if turn_continuation.internal_continuation_inbound(ctx.msg.metadata)
and ctx.visible_run_started_at is not None
else ctx.turn_wall_started_at
)
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
self._save_turn(
ctx.session, ctx.all_messages, ctx.save_skip,
turn_latency_ms=ctx.turn_latency_ms,
@@ -1427,6 +1463,9 @@ class AgentLoop:
return "ok"
async def _state_respond(self, ctx: TurnContext) -> str:
if ctx.suppress_response:
ctx.outbound = None
return "ok"
ctx.outbound = self._assemble_outbound(
ctx.msg,
ctx.final_content,
+2 -3
View File
@@ -807,10 +807,9 @@ class Consolidator:
metadata={},
last_consolidated=0,
)
probe.retain_recent_legal_suffix(max_suffix)
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix)
kept = probe.messages
cut = len(tail) - len(kept)
archive_msgs = tail[:cut]
archive_msgs = dropped[already_consolidated:]
if not archive_msgs and not kept:
session.updated_at = datetime.now()
+8 -5
View File
@@ -4,6 +4,8 @@ from contextvars import ContextVar
from pathlib import Path
from typing import Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path
@@ -126,11 +128,11 @@ class MessageTool(Tool, ContextAware):
self._record_channel_delivery_var.reset(token)
def set_suppress_delivery(self, active: bool):
"""Temporarily suppress real channel delivery for internal checks."""
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
return self._suppress_delivery_var.set(active)
def reset_suppress_delivery(self, token) -> None:
"""Restore previous channel delivery suppression state."""
"""Restore previous delivery-suppression state."""
self._suppress_delivery_var.reset(token)
@property
@@ -229,9 +231,6 @@ class MessageTool(Tool, ContextAware):
if not channel or not chat_id:
return "Error: No target channel/chat specified"
if self._suppress_delivery_var.get():
return "Message suppressed during internal check"
if not self._send_callback:
return "Error: Message sending not configured"
@@ -256,6 +255,10 @@ class MessageTool(Tool, ContextAware):
metadata=metadata,
)
if self._suppress_delivery_var.get():
logger.debug("MessageTool: delivery suppressed during internal check")
return f"Message acknowledged for {channel}:{chat_id} (not delivered)"
try:
await self._send_callback(msg)
if channel == default_channel and chat_id == default_chat_id:
+74
View File
@@ -23,6 +23,11 @@ try:
AsyncClientConfig,
InviteEvent,
JoinError,
KeyVerificationCancel,
KeyVerificationEvent,
KeyVerificationKey,
KeyVerificationMac,
KeyVerificationStart,
LoginResponse,
MatrixRoom,
RoomEncryptedMedia,
@@ -33,6 +38,7 @@ try:
RoomSendResponse,
RoomTypingError,
SyncError,
ToDeviceError,
UploadError,
)
from nio.crypto.attachments import decrypt_attachment
@@ -194,6 +200,7 @@ class MatrixConfig(Base):
access_token: str = ""
device_id: str = ""
e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled")
sas_verification: bool = Field(default=False, alias="sasVerification")
sync_stop_grace_seconds: int = 2
max_media_bytes: int = 20 * 1024 * 1024
max_concurrent_media_downloads: int = 2
@@ -268,6 +275,7 @@ class MatrixChannel(BaseChannel):
)
self._register_event_callbacks()
self._register_to_device_callbacks()
self._register_response_callbacks()
if not self.config.e2ee_enabled:
@@ -572,11 +580,77 @@ class MatrixChannel(BaseChannel):
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
self.client.add_event_callback(self._on_room_invite, InviteEvent)
def _register_to_device_callbacks(self) -> None:
if self.config.e2ee_enabled and self.config.sas_verification:
self.client.add_to_device_callback(
self._on_key_verification_event,
(KeyVerificationEvent,),
)
def _register_response_callbacks(self) -> None:
self.client.add_response_callback(self._on_sync_error, SyncError)
self.client.add_response_callback(self._on_join_error, JoinError)
self.client.add_response_callback(self._on_send_error, RoomSendError)
def _is_sas_sender_allowed(self, sender: str) -> bool:
return bool(sender and self.is_allowed(sender))
async def _on_key_verification_event(self, event: KeyVerificationEvent) -> None:
try:
await self._handle_key_verification_event(event)
except asyncio.CancelledError:
raise
except Exception:
self.logger.exception("Matrix SAS verification handling failed")
async def _handle_key_verification_event(self, event: KeyVerificationEvent) -> None:
if not (self.config.e2ee_enabled and self.config.sas_verification):
return
if not self.client:
return
sender = str(getattr(event, "sender", "") or "")
transaction_id = str(getattr(event, "transaction_id", "") or "")
if not transaction_id or not self._is_sas_sender_allowed(sender):
return
if isinstance(event, KeyVerificationStart):
if "emoji" not in (getattr(event, "short_authentication_string", None) or []):
self.logger.info(
"Ignoring Matrix SAS verification from {} without emoji support",
sender,
)
return
response = await self.client.accept_key_verification(transaction_id)
if isinstance(response, ToDeviceError):
self.logger.warning("Matrix SAS accept failed for {}: {}", sender, response)
return
if isinstance(event, KeyVerificationKey):
responses = await self.client.send_to_device_messages()
if any(isinstance(response, ToDeviceError) for response in responses):
self.logger.warning("Matrix SAS key share failed for {}", sender)
return
response = await self.client.confirm_short_auth_string(transaction_id)
if isinstance(response, ToDeviceError):
self.logger.warning("Matrix SAS confirm failed for {}: {}", sender, response)
return
if isinstance(event, KeyVerificationMac):
sas = getattr(self.client, "key_verifications", {}).get(transaction_id)
if sas is not None and getattr(sas, "verified", False):
self.logger.info("Matrix SAS verification completed for {}", sender)
return
if isinstance(event, KeyVerificationCancel):
self.logger.info(
"Matrix SAS verification cancelled by {}: {}",
sender,
getattr(event, "reason", ""),
)
def _is_fatal_auth_response(self, response: Any) -> bool:
code = getattr(response, "status_code", None)
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
+3 -3
View File
@@ -649,14 +649,14 @@ class WebSocketChannel(BaseChannel):
return True
def _handle_token_issue_http(self, connection: Any, request: Any) -> Any:
secret = self.config.token_issue_secret.strip()
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
if secret:
if not _issue_route_secret_matches(request.headers, secret):
return connection.respond(401, "Unauthorized")
else:
self.logger.warning(
"token_issue_path is set but token_issue_secret is empty; "
"any client can obtain connection tokens — set token_issue_secret for production."
"token_issue_path is set but no token_issue_secret or static token is configured; "
"any client can obtain connection tokens — set a secret for production."
)
self._purge_expired_issued_tokens()
if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS:
+36 -15
View File
@@ -1,7 +1,6 @@
"""CLI commands for nanobot."""
import asyncio
import functools
import os
import select
import signal
@@ -100,15 +99,34 @@ _HEARTBEAT_PREAMBLE = (
"[Your response will be delivered directly to the user's messaging app. "
"Output ONLY the final user-facing message. Never reference internal "
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
"decision process. If nothing needs reporting, respond with a brief "
"no-op status and nothing else.]\n\n"
"decision process. If nothing needs reporting, respond with just "
"'All clear.' and nothing else.]\n\n"
)
@functools.lru_cache(maxsize=None)
def _heartbeat_template() -> str | None:
from nanobot.utils.helpers import load_bundled_template
return load_bundled_template("HEARTBEAT.md")
def _heartbeat_has_active_tasks(content: str) -> bool:
"""True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments."""
in_comment = False
in_active_section: bool = False
for line in content.splitlines():
stripped = line.strip()
if in_comment:
if "-->" in stripped:
in_comment = False
continue
if not stripped or stripped.startswith("#"):
if stripped.startswith("##") and not stripped.startswith("###"):
heading = stripped.lstrip("#").strip().lower()
in_active_section = heading.startswith("active tasks")
continue
if stripped.startswith("<!--"):
if "-->" not in stripped[4:]:
in_comment = True
continue
if in_active_section is False:
continue
return True
return False
# ---------------------------------------------------------------------------
# CLI input: prompt_toolkit for editing, paste, history, and display
@@ -978,8 +996,8 @@ def _run_gateway(
except OSError:
logger.debug("Heartbeat: HEARTBEAT.md missing")
return None
if not content or content == _heartbeat_template():
logger.debug("Heartbeat: HEARTBEAT.md empty or identical to template")
if not _heartbeat_has_active_tasks(content):
logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
return None
channel, chat_id = _pick_heartbeat_target()
@@ -991,10 +1009,11 @@ def _run_gateway(
+ f"Review the following HEARTBEAT.md and report any active tasks:\n\n{content}"
)
message_suppress_token = None
# Internal check: funnel all output through the post-run gate so the
# turn can't deliver directly via the message tool and skip it.
suppress_token = None
if isinstance(message_tool, MessageTool):
message_suppress_token = message_tool.set_suppress_delivery(True)
suppress_token = message_tool.set_suppress_delivery(True)
try:
resp = await agent.process_direct(
prompt,
@@ -1004,8 +1023,8 @@ def _run_gateway(
on_progress=_silent,
)
finally:
if isinstance(message_tool, MessageTool) and message_suppress_token is not None:
message_tool.reset_suppress_delivery(message_suppress_token)
if isinstance(message_tool, MessageTool) and suppress_token is not None:
message_tool.reset_suppress_delivery(suppress_token)
response = resp.content if resp else ""
# Keep a small tail of heartbeat history so the loop stays bounded.
@@ -1016,8 +1035,10 @@ def _run_gateway(
if not response:
return None
# Fail closed: stay silent on evaluator failure instead of notifying.
should_notify = await evaluate_response(
response, prompt, agent.provider, agent.model, default_notify=False,
response, prompt, agent.provider, agent.model,
default_notify=False,
)
if should_notify:
logger.info("Heartbeat: completed, delivering response")
+19 -4
View File
@@ -43,6 +43,19 @@ def sustained_goal_active(metadata: Mapping[str, Any] | None) -> bool:
return isinstance(goal, dict) and goal.get("status") == "active"
def sustained_goal_turn(
metadata: Mapping[str, Any] | None,
*,
message_metadata: Mapping[str, Any] | None = None,
) -> bool:
"""True when this turn should use sustained-goal runtime limits."""
if sustained_goal_active(metadata):
return True
if not message_metadata:
return False
return str(message_metadata.get("original_command") or "").strip() == "/goal"
def parse_goal_state(blob: Any) -> dict[str, Any] | None:
if blob is None:
return None
@@ -98,14 +111,16 @@ def runner_wall_llm_timeout_s(
session_key: str | None,
*,
metadata: Mapping[str, Any] | None = None,
message_metadata: Mapping[str, Any] | None = None,
) -> float | None:
"""Wall-clock cap for :class:`~nanobot.agent.runner.AgentRunner` when streaming an LLM.
Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when a sustained goal is
active; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory ``metadata`` when the
caller already holds :attr:`~nanobot.session.manager.Session.metadata` for this turn.
Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when this is a
sustained-goal turn; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory
``metadata`` when the caller already holds :attr:`~nanobot.session.manager.Session.metadata`
for this turn.
"""
meta: Mapping[str, Any] | None = metadata
if meta is None and session_key:
meta = sessions.get_or_create(session_key).metadata
return 0.0 if sustained_goal_active(meta) else None
return 0.0 if sustained_goal_turn(meta, message_metadata=message_metadata) else None
+43 -15
View File
@@ -269,13 +269,25 @@ class Session:
self.updated_at = datetime.now()
self.metadata.pop("_last_summary", None)
def retain_recent_legal_suffix(self, max_messages: int) -> None:
"""Keep a legal recent suffix constrained by a hard message cap."""
def retain_recent_legal_suffix(self, max_messages: int) -> tuple[list[dict], int]:
"""Keep a legal recent suffix constrained by a hard message cap.
Returns ``(dropped, already_consolidated_count)`` where *dropped* is
the list of removed messages (in original order) and
*already_consolidated_count* is how many of those were inside the
pre-existing ``last_consolidated`` prefix and therefore do not need
raw archiving.
"""
if max_messages <= 0:
dropped = list(self.messages)
lc = self.last_consolidated
self.clear()
return
return dropped, min(lc, len(dropped))
if len(self.messages) <= max_messages:
return
return [], 0
original = list(self.messages)
before_lc = self.last_consolidated
retained = list(self.messages[-max_messages:])
@@ -306,10 +318,32 @@ class Session:
if start:
retained = retained[start:]
dropped = len(self.messages) - len(retained)
# Compute actually-dropped messages using identity comparison so that
# even when retained is a non-contiguous slice of original (the else
# branch above), we never duplicate or lose messages.
retained_ids = set(id(m) for m in retained)
dropped = [m for m in original if id(m) not in retained_ids]
# Count how many dropped messages were in the already-consolidated
# prefix of the original list. This cannot be a simple min() because
# dropped may include messages from *after* the consolidated prefix
# (e.g. in the else branch).
already_consolidated = sum(
1 for i, m in enumerate(original)
if i < before_lc and id(m) not in retained_ids
)
# New last_consolidated = count of retained messages that were inside
# the old consolidated prefix.
new_lc = sum(
1 for i, m in enumerate(original)
if i < before_lc and id(m) in retained_ids
)
self.messages = retained
self.last_consolidated = max(0, self.last_consolidated - dropped)
self.last_consolidated = new_lc
self.updated_at = datetime.now()
return dropped, already_consolidated
def enforce_file_cap(
self,
@@ -320,23 +354,17 @@ class Session:
if limit <= 0 or len(self.messages) <= limit:
return
before = list(self.messages)
before_last_consolidated = self.last_consolidated
before_count = len(before)
self.retain_recent_legal_suffix(limit)
dropped_count = before_count - len(self.messages)
if dropped_count <= 0:
dropped, already_consolidated = self.retain_recent_legal_suffix(limit)
if not dropped:
return
dropped = before[:dropped_count]
already_consolidated = min(before_last_consolidated, dropped_count)
archive_chunk = dropped[already_consolidated:]
if archive_chunk and on_archive:
on_archive(archive_chunk)
logger.info(
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
self.key,
dropped_count,
len(dropped),
len(archive_chunk),
len(self.messages),
)
+240
View File
@@ -0,0 +1,240 @@
"""Internal turn continuation helpers.
This module keeps budget-boundary continuation policy out of ``AgentLoop``.
The loop calls a small set of helpers; those helpers decide whether an internal
continuation is allowed and, when it is, queue the next turn directly.
"""
from __future__ import annotations
import dataclasses
from typing import Any, Mapping, MutableMapping
from loguru import logger
from nanobot.session.goal_state import (
goal_state_runtime_lines,
sustained_goal_active,
sustained_goal_turn,
)
INTERNAL_CONTINUATION_META = "_internal_continuation"
INTERNAL_CONTINUATION_KIND_META = "_internal_continuation_kind"
INTERNAL_CONTINUATION_PENDING_META = "_internal_continuation_pending"
INTERNAL_CONTINUATION_RUN_STARTED_AT_META = "_internal_continuation_run_started_at"
_GOAL_CONTINUATION_KIND = "sustained_goal"
_GOAL_CONTINUATION_SENDER = "system:continuation"
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
_MAX_GOAL_CONTINUATION_ROUNDS = 12
_STRIPPED_INBOUND_META_KEYS = {
"_stream_id",
"_stream_delta",
"_stream_end",
"_resuming",
INTERNAL_CONTINUATION_PENDING_META,
}
def internal_continuation_inbound(metadata: Mapping[str, Any] | None) -> bool:
"""True for an inbound message created by an internal continuation policy."""
return bool(metadata and metadata.get(INTERNAL_CONTINUATION_META) is True)
def internal_continuation_pending(metadata: Mapping[str, Any] | None) -> bool:
"""True when the current turn scheduled an invisible continuation slice."""
return bool(metadata and metadata.get(INTERNAL_CONTINUATION_PENDING_META) is True)
def internal_continuation_run_started_at(metadata: Mapping[str, Any] | None) -> float | None:
"""Return the user-visible run start propagated across continuation slices."""
if not metadata:
return None
value = metadata.get(INTERNAL_CONTINUATION_RUN_STARTED_AT_META)
if not isinstance(value, int | float):
return None
started_at = float(value)
return started_at if started_at > 0 else None
def should_persist_user_message(metadata: Mapping[str, Any] | None) -> bool:
"""Return whether this inbound message should be persisted as user input."""
return not internal_continuation_inbound(metadata)
def should_stream_budget_response(
*,
stop_reason: str,
pending_queue_available: bool,
session_metadata: Mapping[str, Any] | None,
message_metadata: Mapping[str, Any] | None = None,
) -> bool:
"""Return whether the budget-boundary response should be sent to the user."""
return not _continuation_available(
stop_reason=stop_reason,
pending_queue_available=pending_queue_available,
session_metadata=session_metadata,
message_metadata=message_metadata,
)
async def maybe_continue_turn(ctx: Any) -> bool:
"""Queue an internal continuation for *ctx* when policy allows it."""
if ctx.session is None or ctx.pending_queue is None:
return False
if not _continuation_available(
stop_reason=ctx.stop_reason,
pending_queue_available=True,
session_metadata=ctx.session.metadata,
message_metadata=ctx.msg.metadata,
):
return False
metadata = _internal_continuation_metadata(
ctx.msg.metadata,
run_started_at=getattr(ctx, "visible_run_started_at", None),
)
content = _goal_continuation_prompt(ctx.session.metadata)
messages = _strip_terminal_assistant(ctx.all_messages, ctx.final_content)
_increment_goal_continuation_round(ctx.session.metadata)
logger.info("Turn budget reached; scheduling internal continuation")
ctx.msg.metadata[INTERNAL_CONTINUATION_PENDING_META] = True
ctx.final_content = ""
ctx.all_messages = messages
ctx.suppress_response = True
await ctx.pending_queue.put(
dataclasses.replace(
ctx.msg,
sender_id=_GOAL_CONTINUATION_SENDER,
content=content,
media=[],
metadata=metadata,
session_key_override=ctx.session_key,
)
)
return True
def prepare_save_boundary(ctx: Any) -> None:
"""Prepare continuation bookkeeping and the history append boundary."""
if ctx.session is not None:
clear_internal_continuation_state(ctx.session.metadata)
ctx.save_skip = _save_skip_for_turn(
message_metadata=ctx.msg.metadata,
initial_message_count=len(ctx.initial_messages),
history_count=len(ctx.history),
user_persisted_early=ctx.user_persisted_early,
)
def _continuation_available(
*,
stop_reason: str,
pending_queue_available: bool,
session_metadata: Mapping[str, Any] | None,
message_metadata: Mapping[str, Any] | None = None,
) -> bool:
if stop_reason != "max_iterations" or not pending_queue_available:
return False
return _goal_continuation_available(
session_metadata,
message_metadata=message_metadata,
)
def clear_internal_continuation_state(metadata: MutableMapping[str, Any]) -> None:
"""Reset policy bookkeeping once its owning runtime mode is inactive."""
if not sustained_goal_active(metadata):
metadata.pop(_GOAL_CONTINUATION_ROUNDS_KEY, None)
def _save_skip_for_turn(
*,
message_metadata: Mapping[str, Any] | None,
initial_message_count: int,
history_count: int,
user_persisted_early: bool,
) -> int:
"""Return the persisted-message append boundary for this turn."""
if internal_continuation_inbound(message_metadata):
return initial_message_count
return 1 + history_count + (1 if user_persisted_early else 0)
def _goal_continuation_available(
session_metadata: Mapping[str, Any] | None,
*,
message_metadata: Mapping[str, Any] | None = None,
max_rounds: int = _MAX_GOAL_CONTINUATION_ROUNDS,
) -> bool:
if not sustained_goal_turn(session_metadata, message_metadata=message_metadata):
return False
if not sustained_goal_active(session_metadata):
return False
try:
rounds = int((session_metadata or {}).get(_GOAL_CONTINUATION_ROUNDS_KEY) or 0)
except (TypeError, ValueError):
rounds = 0
return rounds < max(0, max_rounds)
def _increment_goal_continuation_round(session_metadata: MutableMapping[str, Any]) -> None:
try:
rounds = int(session_metadata.get(_GOAL_CONTINUATION_ROUNDS_KEY) or 0)
except (TypeError, ValueError):
rounds = 0
session_metadata[_GOAL_CONTINUATION_ROUNDS_KEY] = rounds + 1
def _internal_continuation_metadata(
message_metadata: Mapping[str, Any] | None,
*,
run_started_at: float | None = None,
) -> dict[str, Any]:
metadata = dict(message_metadata or {})
metadata[INTERNAL_CONTINUATION_META] = True
metadata[INTERNAL_CONTINUATION_KIND_META] = _GOAL_CONTINUATION_KIND
if run_started_at is not None:
metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] = float(run_started_at)
for key in _STRIPPED_INBOUND_META_KEYS:
metadata.pop(key, None)
return metadata
def _goal_continuation_prompt(metadata: Mapping[str, Any] | None) -> str:
lines = goal_state_runtime_lines(metadata)
if lines:
goal = "\n".join(lines)
return (
"Continue the active sustained goal after the previous turn reached "
"its tool-call budget.\n\n"
f"{goal}\n\n"
"Continue from the saved context. Do not mention the continuation "
"boundary to the user. Use tools as needed, and call complete_goal "
"when the objective is truly finished."
)
return (
"Continue the active sustained goal after the previous turn reached "
"its tool-call budget. Continue from the saved context. Do not mention "
"the continuation boundary to the user. Use tools as needed, and call "
"complete_goal when the objective is truly finished."
)
def _strip_terminal_assistant(
messages: list[dict[str, Any]],
final_content: str | None,
) -> list[dict[str, Any]]:
"""Drop the synthetic max-iteration assistant message before saving history."""
if not messages:
return messages
last = messages[-1]
if last.get("role") != "assistant":
return messages
if final_content is None or last.get("content") != final_content:
return messages
if last.get("tool_calls"):
return messages
return messages[:-1]
+19 -4
View File
@@ -178,7 +178,13 @@ def websocket_turn_wall_started_at(chat_id: str) -> float | None:
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status: str) -> None:
async def publish_turn_run_status(
bus: MessageBus,
msg: InboundMessage,
status: str,
*,
started_at: float | None = None,
) -> None:
"""Notify WebSocket clients while a user turn is executing (timing strip)."""
if msg.channel != "websocket":
return
@@ -189,7 +195,10 @@ async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status:
"goal_status": status,
}
if status == "running":
t0 = time.time()
if isinstance(started_at, int | float) and started_at > 0:
t0 = float(started_at)
else:
t0 = time.time()
meta["started_at"] = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else:
@@ -300,8 +309,14 @@ class WebuiTurnCoordinator:
def discard(self, session_key: str) -> None:
self._title_contexts.pop(session_key, None)
async def publish_run_status(self, msg: InboundMessage, status: str) -> None:
await publish_turn_run_status(self.bus, msg, status)
async def publish_run_status(
self,
msg: InboundMessage,
status: str,
*,
started_at: float | None = None,
) -> None:
await publish_turn_run_status(self.bus, msg, status, started_at=started_at)
async def handle_turn_end(
self,
+3 -5
View File
@@ -1,16 +1,14 @@
# Heartbeat Tasks
<!--
This file is checked periodically by your nanobot agent.
Register it as a cron job (e.g. `cron add --name heartbeat --schedule "every 30m" --message "Check HEARTBEAT.md"`) to get the same behavior as the legacy heartbeat service.
If this file has no tasks (only headers and comments), the agent will skip it.
Completed tasks should be deleted, not kept — heartbeat only reads "Active Tasks".
-->
## Active Tasks
<!-- Add your periodic tasks below this line -->
## Completed
<!-- Move completed tasks here or delete them -->
+4 -6
View File
@@ -44,15 +44,12 @@ async def evaluate_response(
task_context: str,
provider: LLMProvider,
model: str,
*,
default_notify: bool = True,
) -> bool:
"""Decide whether a background-task result should be delivered to the user.
Uses a lightweight tool-call LLM request. ``default_notify`` controls
the fallback path when the evaluator cannot produce a valid decision:
user-scheduled reminders stay fail-open, while internal checks such as
heartbeat can fail closed.
On any failure, falls back to ``default_notify`` (cron reminders fail open;
heartbeat passes ``False`` to fail closed).
"""
try:
llm_response = await provider.chat_with_retry(
@@ -74,7 +71,8 @@ async def evaluate_response(
if not llm_response.should_execute_tools:
if llm_response.has_tool_calls:
logger.warning(
"evaluate_response: ignoring tool calls under finish_reason='{}', defaulting to notify={}",
"evaluate_response: ignoring tool calls under finish_reason='{}', "
"defaulting to notify={}",
llm_response.finish_reason,
default_notify,
)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
version = "0.2.0"
version = "0.2.1"
description = "A lightweight personal AI assistant framework"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
+2 -3
View File
@@ -76,10 +76,9 @@ def _make_fake_compact(
metadata={},
last_consolidated=0,
)
probe.retain_recent_legal_suffix(max_suffix)
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix)
kept = probe.messages
cut = len(tail) - len(kept)
archive_msgs = tail[:cut]
archive_msgs = dropped[already_consolidated:]
if not archive_msgs and not kept:
session.updated_at = datetime.now()
+38
View File
@@ -440,6 +440,44 @@ class TestCompactIdleSession:
assert "u0" not in user_content
assert "u25" in user_content or "a25" in user_content
@pytest.mark.asyncio
async def test_non_contiguous_suffix_archives_actual_dropped_messages(
self,
real_consolidator,
mock_provider,
):
"""Assistant-only tails retain a non-contiguous slice, so archive the
actual dropped messages rather than a computed prefix."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="Tail summary.", finish_reason="stop"
)
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:noncontiguous")
for i in range(15):
session.add_message("user", f"user-{i:02d}")
for i in range(10):
session.add_message("assistant", f"assistant-{i:02d}")
sessions.save(session)
result = await real_consolidator.compact_idle_session("cli:noncontiguous", max_suffix=6)
assert result == "Tail summary."
reloaded = sessions.get_or_create("cli:noncontiguous")
assert [m["content"] for m in reloaded.messages] == [
"user-14",
"assistant-00",
"assistant-01",
"assistant-02",
"assistant-03",
"assistant-04",
]
archived_call = mock_provider.chat_with_retry.call_args
user_content = archived_call.kwargs["messages"][1]["content"]
assert "user-14" not in user_content
assert "assistant-00" not in user_content
assert "assistant-09" in user_content
@pytest.mark.asyncio
async def test_acquires_consolidation_lock(self, real_consolidator, mock_provider):
"""Verify lock is held during execution."""
+14 -26
View File
@@ -56,23 +56,6 @@ async def test_fallback_on_error() -> None:
assert result is True
@pytest.mark.asyncio
async def test_fallback_can_fail_closed() -> None:
class FailingProvider(DummyProvider):
async def chat(self, *args, **kwargs) -> LLMResponse:
raise RuntimeError("provider down")
provider = FailingProvider([])
result = await evaluate_response(
"some response",
"some task",
provider,
"m",
default_notify=False,
)
assert result is False
@pytest.mark.asyncio
async def test_no_tool_call_fallback() -> None:
provider = DummyProvider([LLMResponse(content="I think you should notify", tool_calls=[])])
@@ -81,13 +64,18 @@ async def test_no_tool_call_fallback() -> None:
@pytest.mark.asyncio
async def test_no_tool_call_can_fail_closed() -> None:
provider = DummyProvider([LLMResponse(content="I think you should notify", tool_calls=[])])
result = await evaluate_response(
"some response",
"some task",
provider,
"m",
default_notify=False,
)
async def test_fail_closed_on_error() -> None:
class FailingProvider(DummyProvider):
async def chat(self, *args, **kwargs) -> LLMResponse:
raise RuntimeError("provider down")
provider = FailingProvider([])
result = await evaluate_response("some", "task", provider, "m", default_notify=False)
assert result is False
@pytest.mark.asyncio
async def test_fail_closed_on_no_tool_call() -> None:
provider = DummyProvider([LLMResponse(content="text only", tool_calls=[])])
result = await evaluate_response("some", "task", provider, "m", default_notify=False)
assert result is False
+24 -1
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
@@ -48,6 +47,30 @@ async def test_loop_max_iterations_message_stays_stable(tmp_path):
)
@pytest.mark.asyncio
async def test_loop_goal_turn_uses_standard_iteration_budget(tmp_path):
loop = _make_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="working",
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
))
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.execute = AsyncMock(return_value="ok")
loop.max_iterations = 2
final_content, _, _, stop_reason, _ = await loop._run_agent_loop(
[],
metadata={"original_command": "/goal"},
)
assert stop_reason == "max_iterations"
assert loop.provider.chat_with_retry.await_count == 2
assert final_content == (
"I reached the maximum number of tool call iterations (2) "
"without completing the task. You can try breaking the task into smaller steps."
)
@pytest.mark.asyncio
async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp_path):
loop = _make_loop(tmp_path)
+224
View File
@@ -11,6 +11,10 @@ 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.turn_continuation import (
INTERNAL_CONTINUATION_META,
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
)
from nanobot.session.webui_turns import (
TITLE_GENERATION_MAX_TOKENS,
TITLE_GENERATION_REASONING_EFFORT,
@@ -560,6 +564,226 @@ async def test_process_message_does_not_duplicate_early_persisted_user_message(t
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
@pytest.mark.asyncio
async def test_internal_continuation_queues_turn_without_fake_user_history(
tmp_path: Path,
) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
session = loop.sessions.get_or_create("feishu:c-auto")
session.metadata[GOAL_STATE_KEY] = {
"status": "active",
"objective": "Finish the long goal.",
}
loop.sessions.save(session)
calls: list[dict] = []
async def fake_run_agent_loop(initial_messages, *, metadata=None, **_kwargs):
calls.append({"initial_messages": initial_messages, "metadata": metadata})
if len(calls) == 1:
return (
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"completed",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
first = await loop._process_message(
InboundMessage(
channel="feishu",
sender_id="u1",
chat_id="c-auto",
content="start the goal",
),
pending_queue=pending,
)
assert first is None
queued = pending.get_nowait()
assert queued.sender_id == "system:continuation"
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
assert "Finish the long goal." in queued.content
session = loop.sessions.get_or_create("feishu:c-auto")
assert [
{k: v for k, v in m.items() if k in {"role", "content"}}
for m in session.messages
] == [{"role": "user", "content": "start the goal"}]
second = await loop._process_message(queued, pending_queue=asyncio.Queue())
assert second is not None
assert second.content == "done"
session = loop.sessions.get_or_create("feishu:c-auto")
assert [
{k: v for k, v in m.items() if k in {"role", "content"}}
for m in session.messages
] == [
{"role": "user", "content": "start the goal"},
{"role": "assistant", "content": "done"},
]
@pytest.mark.asyncio
async def test_internal_continuation_preserves_streaming_route_metadata(
tmp_path: Path,
) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
session = loop.sessions.get_or_create("feishu:c-stream")
session.metadata[GOAL_STATE_KEY] = {
"status": "active",
"objective": "Finish the streamed long goal.",
}
loop.sessions.save(session)
calls = 0
async def fake_run_agent_loop(initial_messages, *, on_stream=None, on_stream_end=None, **_kwargs):
nonlocal calls
calls += 1
if calls == 1:
return (
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
assert on_stream is not None
assert on_stream_end is not None
await on_stream("done")
await on_stream_end(resuming=False)
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"completed",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="feishu",
sender_id="u1",
chat_id="c-stream",
content="start the goal",
metadata={
"_wants_stream": True,
"message_id": "om_001",
"origin_message_id": "root_001",
"_stream_id": "old-stream",
},
))
assert loop.bus.outbound_size == 0
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
assert queued.metadata["_wants_stream"] is True
assert queued.metadata["message_id"] == "om_001"
assert queued.metadata["origin_message_id"] == "root_001"
assert "_stream_id" not in queued.metadata
await loop._dispatch(queued)
outbound = []
while loop.bus.outbound_size:
outbound.append(await loop.bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
ends = [m for m in outbound if m.metadata.get("_stream_end")]
streamed_markers = [m for m in outbound if m.metadata.get("_streamed")]
assert [m.content for m in deltas] == ["done"]
assert len(ends) == 1
assert ends[0].metadata["_resuming"] is False
assert ends[0].metadata["message_id"] == "om_001"
assert ends[0].metadata["origin_message_id"] == "root_001"
assert isinstance(ends[0].metadata.get("_stream_id"), str)
assert streamed_markers and streamed_markers[-1].content == "done"
@pytest.mark.asyncio
async def test_websocket_internal_continuation_keeps_single_visible_run(
tmp_path: Path,
) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
session = loop.sessions.get_or_create("websocket:c-auto")
session.metadata[GOAL_STATE_KEY] = {
"status": "active",
"objective": "Finish the long goal.",
}
loop.sessions.save(session)
calls = 0
async def fake_run_agent_loop(initial_messages, **_kwargs):
nonlocal calls
calls += 1
if calls == 1:
return (
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"completed",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="c-auto",
content="start the goal",
metadata={"webui": True},
))
first_outbound = []
while loop.bus.outbound_size:
first_outbound.append(await loop.bus.consume_outbound())
first_statuses = [m.metadata for m in first_outbound if m.metadata.get("_goal_status")]
assert [m["goal_status"] for m in first_statuses] == ["running"]
assert not [m for m in first_outbound if m.metadata.get("_turn_end")]
started_at = first_statuses[0]["started_at"]
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
assert queued.metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] == started_at
await loop._dispatch(queued)
second_outbound = []
while loop.bus.outbound_size:
second_outbound.append(await loop.bus.consume_outbound())
second_statuses = [m.metadata for m in second_outbound if m.metadata.get("_goal_status")]
assert [m["goal_status"] for m in second_statuses] == ["running", "idle"]
assert second_statuses[0]["started_at"] == started_at
turn_end = [m for m in second_outbound if m.metadata.get("_turn_end")]
assert len(turn_end) == 1
assert isinstance(turn_end[0].metadata.get("latency_ms"), int)
@pytest.mark.asyncio
async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
+156
View File
@@ -538,3 +538,159 @@ def test_retain_recent_legal_suffix_hard_cap_with_long_non_user_chain():
session.retain_recent_legal_suffix(6)
assert len(session.messages) <= 6
# --- enforce_file_cap archive correctness (issue #4128) ---
def test_retain_recent_legal_suffix_returns_dropped_messages():
"""retain_recent_legal_suffix returns the actually-dropped messages."""
session = Session(key="test:return-dropped")
for i in range(10):
session.messages.append({"role": "user", "content": f"msg{i}"})
dropped, already_cons = session.retain_recent_legal_suffix(4)
assert len(dropped) == 6
assert [m["content"] for m in dropped] == [f"msg{i}" for i in range(6)]
assert len(session.messages) == 4
assert already_cons == 0
def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
"""No messages dropped → empty list returned."""
session = Session(key="test:no-drop")
for i in range(3):
session.messages.append({"role": "user", "content": f"msg{i}"})
dropped, already_cons = session.retain_recent_legal_suffix(4)
assert dropped == []
assert already_cons == 0
assert len(session.messages) == 3
def test_retain_recent_legal_suffix_returns_all_on_zero():
"""max_messages=0 clears session and returns all messages."""
session = Session(key="test:zero-return")
for i in range(5):
session.messages.append({"role": "user", "content": f"msg{i}"})
session.last_consolidated = 3
dropped, already_cons = session.retain_recent_legal_suffix(0)
assert len(dropped) == 5
assert already_cons == 3
assert session.messages == []
def test_enforce_file_cap_no_duplicate_archive_in_else_branch():
"""When the tail is assistant-only, enforce_file_cap must not archive
messages that are also retained (the bug from issue #4128)."""
from unittest.mock import MagicMock
session = Session(key="test:else-archive")
# Build: 15 user messages, then 10 assistant messages (no user in tail)
for i in range(15):
session.messages.append({"role": "user", "content": f"u{i}"})
for i in range(10):
session.messages.append({"role": "assistant", "content": f"a{i}"})
archive_fn = MagicMock()
session.enforce_file_cap(on_archive=archive_fn, limit=6)
# Verify retained messages
retained_contents = [m["content"] for m in session.messages]
assert len(session.messages) <= 6
# Verify archived messages have NO overlap with retained
if archive_fn.called:
archived = archive_fn.call_args.args[0]
archived_ids = set(id(m) for m in archived)
retained_ids = set(id(m) for m in session.messages)
assert not archived_ids & retained_ids, (
f"Duplicate messages in archive and retained: "
f"overlap contents = {[m['content'] for m in archived if id(m) in retained_ids]}"
)
def test_enforce_file_cap_no_message_loss_in_else_branch():
"""In the else branch, no messages should silently disappear — every
message must be either retained or archived."""
from unittest.mock import MagicMock
session = Session(key="test:else-no-loss")
all_messages = []
for i in range(15):
msg = {"role": "user", "content": f"u{i}"}
session.messages.append(msg)
all_messages.append(msg)
for i in range(10):
msg = {"role": "assistant", "content": f"a{i}"}
session.messages.append(msg)
all_messages.append(msg)
archive_fn = MagicMock()
session.enforce_file_cap(on_archive=archive_fn, limit=6)
# Collect all messages accounted for (retained + archived)
accounted = set(id(m) for m in session.messages)
if archive_fn.called:
for m in archive_fn.call_args.args[0]:
accounted.add(id(m))
all_ids = set(id(m) for m in all_messages)
missing = all_ids - accounted
assert not missing, (
f"Lost {len(missing)} message(s) — neither retained nor archived"
)
def test_enforce_file_cap_correct_archive_with_last_consolidated_in_else_branch():
"""When last_consolidated > 0 and the else branch fires, only the
unconsolidated dropped messages should be raw-archived. Messages in the
consolidated prefix that are dropped do NOT need raw archiving."""
from unittest.mock import MagicMock
session = Session(key="test:else-lc-archive")
# 20 messages total: u0..u9 (user), a0..a9 (assistant)
for i in range(10):
session.messages.append({"role": "user", "content": f"u{i}"})
for i in range(10):
session.messages.append({"role": "assistant", "content": f"a{i}"})
# First 8 messages already consolidated
session.last_consolidated = 8
archive_fn = MagicMock()
session.enforce_file_cap(on_archive=archive_fn, limit=4)
if archive_fn.called:
archived = archive_fn.call_args.args[0]
# Archived messages should NOT include any from the consolidated prefix
# (u0..u7). They should only be unconsolidated dropped messages.
archived_contents = [m["content"] for m in archived]
for c in archived_contents:
assert c not in [f"u{i}" for i in range(8)], (
f"Consolidated message {c!r} should not be raw-archived"
)
def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch():
"""last_consolidated after retain_recent_legal_suffix should reflect how
many retained messages were inside the old consolidated prefix."""
session = Session(key="test:else-lc-correct")
# 20 messages: u0..u9, a0..a9
for i in range(10):
session.messages.append({"role": "user", "content": f"u{i}"})
for i in range(10):
session.messages.append({"role": "assistant", "content": f"a{i}"})
session.last_consolidated = 12 # u0..u9, a0, a1 consolidated
dropped, already_cons = session.retain_recent_legal_suffix(4)
# Retained messages start from latest user (u9) + max_messages forward
# so retained = [u9, a0..a9][:4] → but these are from original indices 9..12
# Of those, indices 9,10,11 are < 12 (before_lc), so new_lc = 3
assert session.last_consolidated == 3
# already_cons should count dropped messages with original index < 12
assert already_cons == 9
+206
View File
@@ -50,7 +50,18 @@ class _FakeAsyncClient:
self.stop_sync_forever_called = False
self.join_calls: list[str] = []
self.callbacks: list[tuple[object, object]] = []
self.to_device_callbacks: list[tuple[object, object]] = []
self.response_callbacks: list[tuple[object, object]] = []
self.key_verifications: dict[str, object] = {}
self.operation_calls: list[str] = []
self.accept_key_verification_calls: list[str] = []
self.confirm_short_auth_string_calls: list[str] = []
self.send_to_device_messages_calls = 0
self.to_device_calls: list[object] = []
self.accept_key_verification_response: object | None = None
self.confirm_short_auth_string_response: object | None = None
self.send_to_device_messages_response: list[object] = []
self.to_device_response: object | None = None
self.rooms: dict[str, object] = {}
self.room_send_calls: list[dict[str, object]] = []
self.typing_calls: list[tuple[str, bool, int]] = []
@@ -70,6 +81,9 @@ class _FakeAsyncClient:
def add_event_callback(self, callback, event_type) -> None:
self.callbacks.append((callback, event_type))
def add_to_device_callback(self, callback, event_type) -> None:
self.to_device_callbacks.append((callback, event_type))
def add_response_callback(self, callback, response_type) -> None:
self.response_callbacks.append((callback, response_type))
@@ -82,6 +96,26 @@ class _FakeAsyncClient:
async def join(self, room_id: str) -> None:
self.join_calls.append(room_id)
async def accept_key_verification(self, transaction_id: str):
self.operation_calls.append(f"accept:{transaction_id}")
self.accept_key_verification_calls.append(transaction_id)
return self.accept_key_verification_response
async def confirm_short_auth_string(self, transaction_id: str):
self.operation_calls.append(f"confirm:{transaction_id}")
self.confirm_short_auth_string_calls.append(transaction_id)
return self.confirm_short_auth_string_response
async def send_to_device_messages(self):
self.operation_calls.append("send_pending")
self.send_to_device_messages_calls += 1
return self.send_to_device_messages_response
async def to_device(self, message):
self.operation_calls.append("to_device")
self.to_device_calls.append(message)
return self.to_device_response
async def room_send(
self,
room_id: str,
@@ -166,6 +200,62 @@ class _FakeAsyncClient:
return None
class _FakeSas:
def __init__(self, *, verified: bool = False) -> None:
self.share_key_called = False
self.get_mac_called = False
self.verified = verified
def share_key(self):
self.share_key_called = True
return {"type": "share_key"}
def get_mac(self):
self.get_mac_called = True
return {"type": "mac"}
class _FakeKeyVerificationStart:
def __init__(
self,
*,
sender: str = "@alice:matrix.org",
transaction_id: str = "tx1",
short_authentication_string: list[str] | None = None,
) -> None:
self.sender = sender
self.transaction_id = transaction_id
self.short_authentication_string = short_authentication_string or ["emoji"]
class _FakeKeyVerificationKey:
def __init__(
self,
*,
sender: str = "@alice:matrix.org",
transaction_id: str = "tx1",
) -> None:
self.sender = sender
self.transaction_id = transaction_id
class _FakeKeyVerificationMac:
def __init__(
self,
*,
sender: str = "@alice:matrix.org",
transaction_id: str = "tx1",
) -> None:
self.sender = sender
self.transaction_id = transaction_id
def _patch_key_verification_events(monkeypatch) -> None:
monkeypatch.setattr(matrix_module, "KeyVerificationStart", _FakeKeyVerificationStart)
monkeypatch.setattr(matrix_module, "KeyVerificationKey", _FakeKeyVerificationKey)
monkeypatch.setattr(matrix_module, "KeyVerificationMac", _FakeKeyVerificationMac)
def _make_config(**kwargs) -> MatrixConfig:
kwargs.setdefault("allow_from", ["*"])
return MatrixConfig(
@@ -209,6 +299,7 @@ async def test_start_skips_load_store_when_device_id_missing(
assert clients[0].config.encryption_enabled is True
assert clients[0].load_store_called is False
assert len(clients[0].callbacks) == 3
assert clients[0].to_device_callbacks == []
assert len(clients[0].response_callbacks) == 3
await channel.stop()
@@ -227,6 +318,121 @@ async def test_register_event_callbacks_uses_media_base_filter() -> None:
assert client.callbacks[1][1] == matrix_module.MATRIX_MEDIA_EVENT_FILTER
def test_register_to_device_callbacks_when_sas_verification_enabled() -> None:
channel = MatrixChannel(_make_config(sas_verification=True), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
channel._register_to_device_callbacks()
assert client.to_device_callbacks == [
(channel._on_key_verification_event, (matrix_module.KeyVerificationEvent,))
]
def test_register_to_device_callbacks_skips_when_e2ee_disabled() -> None:
channel = MatrixChannel(
_make_config(e2ee_enabled=False, sas_verification=True),
MessageBus(),
)
client = _FakeAsyncClient("", "", "", None)
channel.client = client
channel._register_to_device_callbacks()
assert client.to_device_callbacks == []
@pytest.mark.asyncio
async def test_sas_verification_start_accepts_allowed_sender(monkeypatch) -> None:
_patch_key_verification_events(monkeypatch)
channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"], sas_verification=True),
MessageBus(),
)
client = _FakeAsyncClient("", "", "", None)
sas = _FakeSas()
client.key_verifications["tx1"] = sas
channel.client = client
await channel._handle_key_verification_event(_FakeKeyVerificationStart())
assert client.accept_key_verification_calls == ["tx1"]
assert sas.share_key_called is False
assert client.to_device_calls == []
@pytest.mark.asyncio
async def test_sas_verification_ignores_denied_sender(monkeypatch) -> None:
_patch_key_verification_events(monkeypatch)
channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"], sas_verification=True),
MessageBus(),
)
client = _FakeAsyncClient("", "", "", None)
client.key_verifications["tx1"] = _FakeSas()
channel.client = client
await channel._handle_key_verification_event(
_FakeKeyVerificationStart(sender="@mallory:matrix.org")
)
assert client.accept_key_verification_calls == []
assert client.to_device_calls == []
@pytest.mark.asyncio
async def test_sas_verification_ignores_when_disabled(monkeypatch) -> None:
_patch_key_verification_events(monkeypatch)
channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"], sas_verification=False),
MessageBus(),
)
client = _FakeAsyncClient("", "", "", None)
client.key_verifications["tx1"] = _FakeSas()
channel.client = client
await channel._handle_key_verification_event(_FakeKeyVerificationStart())
assert client.accept_key_verification_calls == []
assert client.to_device_calls == []
@pytest.mark.asyncio
async def test_sas_verification_key_confirms_allowed_sender(monkeypatch) -> None:
_patch_key_verification_events(monkeypatch)
channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"], sas_verification=True),
MessageBus(),
)
client = _FakeAsyncClient("", "", "", None)
channel.client = client
await channel._handle_key_verification_event(_FakeKeyVerificationKey())
assert client.send_to_device_messages_calls == 1
assert client.confirm_short_auth_string_calls == ["tx1"]
assert client.operation_calls == ["send_pending", "confirm:tx1"]
@pytest.mark.asyncio
async def test_sas_verification_mac_does_not_resend_mac(monkeypatch) -> None:
_patch_key_verification_events(monkeypatch)
channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"], sas_verification=True),
MessageBus(),
)
client = _FakeAsyncClient("", "", "", None)
sas = _FakeSas(verified=True)
client.key_verifications["tx1"] = sas
channel.client = client
await channel._handle_key_verification_event(_FakeKeyVerificationMac())
assert sas.get_mac_called is False
assert client.to_device_calls == []
def test_media_event_filter_does_not_match_text_events() -> None:
assert not issubclass(matrix_module.RoomMessageText, matrix_module.MATRIX_MEDIA_EVENT_FILTER)
+29
View File
@@ -202,6 +202,35 @@ def test_issue_route_secret_matches_empty_secret() -> None:
assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True
@pytest.mark.asyncio
async def test_token_issue_route_requires_secret_when_static_token_configured(bus: MagicMock) -> None:
port = 29882
channel = _ch(
bus,
port=port,
token="static-token",
tokenIssuePath="/auth/token",
websocketRequiresToken=True,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
denied = await _http_get(f"http://127.0.0.1:{port}/auth/token")
assert denied.status_code == 401
allowed = await _http_get(
f"http://127.0.0.1:{port}/auth/token",
headers={"Authorization": "Bearer static-token"},
)
assert allowed.status_code == 200
assert allowed.json()["token"].startswith("nbwt_")
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
channel = _ch(bus)
+27 -132
View File
@@ -952,6 +952,33 @@ def test_heartbeat_retains_recent_messages_by_default():
assert config.gateway.heartbeat.keep_recent_messages == 8
@pytest.mark.parametrize(
"content, expected",
[
("", False),
("# Title\n\n## Active Tasks\n", False),
("<!--\nmulti-line\ncomment\n-->\n", False), # block comment, not tasks
("<!-- single line -->\n", False),
("## Active Tasks\n\n- water the plants\n", True),
("## Active Tasks\n\n### Garden\n\n- water the plants\n", True),
("## Notes\n\nsome random note\n", False),
("stray text before any heading\n## Active Tasks\n\n- task\n", True),
("stray text before any heading\n", False),
],
)
def test_heartbeat_has_active_tasks(content, expected):
from nanobot.cli.commands import _heartbeat_has_active_tasks
assert _heartbeat_has_active_tasks(content) is expected
def test_heartbeat_skips_bundled_template():
from nanobot.cli.commands import _heartbeat_has_active_tasks
from nanobot.utils.helpers import load_bundled_template
assert _heartbeat_has_active_tasks(load_bundled_template("HEARTBEAT.md")) is False
def _write_instance_config(tmp_path: Path) -> Path:
config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True)
@@ -1394,138 +1421,6 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
bus.publish_outbound.assert_not_awaited()
def test_gateway_heartbeat_fails_closed_and_suppresses_message_tool(
monkeypatch, tmp_path: Path
) -> None:
"""Heartbeat only delivers after an explicit positive evaluation, and
internal checks cannot bypass that gate with the proactive message tool."""
from nanobot.agent.tools.message import MessageTool
config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
config.workspace_path.mkdir(parents=True)
(config.workspace_path / "HEARTBEAT.md").write_text(
"Check whether anything needs attention.",
encoding="utf-8",
)
bus = MagicMock()
bus.publish_outbound = AsyncMock()
seen: dict[str, object] = {}
class _FakeSession:
def retain_recent_legal_suffix(self, _keep: int) -> None:
seen["retained"] = True
class _FakeSessionManager:
def __init__(self, _workspace: Path) -> None:
self.session = _FakeSession()
def list_sessions(self) -> list[dict[str, object]]:
return [{"key": "lark:chat-1", "updated_at": "2026-05-30T00:00:00"}]
def get_or_create(self, key: str) -> _FakeSession:
seen["session_key"] = key
return self.session
def save(self, session: _FakeSession) -> None:
seen["saved"] = session
class _FakeCron:
def __init__(self, _store_path: Path) -> None:
self.on_job = None
seen["cron"] = self
def status(self) -> dict[str, int]:
return {"jobs": 0}
def register_system_job(self, job: CronJob) -> CronJob:
if job.name == "heartbeat":
seen["heartbeat_job"] = job
raise _StopGatewayError("stop")
return job
class _FakeDream:
model = None
max_batch_size = 0
max_iterations = 0
annotate_line_ages = False
async def run(self) -> None:
return None
class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(bus=bus, **extra)
def __init__(self, bus=None, **kwargs) -> None:
self.model = "test-model"
self.provider = object()
self.sessions = kwargs["session_manager"]
self.dream = _FakeDream()
self.tools = {
"message": MessageTool(send_callback=bus.publish_outbound),
}
async def process_direct(self, *_args, **_kwargs):
result = await self.tools["message"].execute(
content="All clear.",
channel="lark",
chat_id="chat-1",
)
seen["message_tool_result"] = result
return OutboundMessage(
channel="lark",
chat_id="chat-1",
content="All clear.",
)
async def close_mcp(self) -> None:
return None
def stop(self) -> None:
return None
class _FakeChannels:
enabled_channels = ["lark"]
async def _capture_evaluate(*_args, **kwargs) -> bool:
seen["default_notify"] = kwargs.get("default_notify")
return False
_patch_cli_command_runtime(
monkeypatch,
config,
message_bus=lambda: bus,
session_manager=_FakeSessionManager,
cron_service=_FakeCron,
)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr(
"nanobot.channels.manager.ChannelManager",
lambda *_args, **_kwargs: _FakeChannels(),
)
monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _capture_evaluate)
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
assert isinstance(result.exception, _StopGatewayError)
cron = seen["cron"]
response = asyncio.run(cron.on_job(seen["heartbeat_job"]))
assert response == "All clear."
assert seen["message_tool_result"] == "Message suppressed during internal check"
assert seen["default_notify"] is False
assert seen["session_key"] == "heartbeat"
assert seen["retained"] is True
bus.publish_outbound.assert_not_awaited()
def test_gateway_workspace_override_does_not_migrate_legacy_cron(
monkeypatch, tmp_path: Path
) -> None:
+127
View File
@@ -0,0 +1,127 @@
"""Tests for internal turn continuation policy."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from nanobot.bus.events import InboundMessage
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.turn_continuation import (
INTERNAL_CONTINUATION_KIND_META,
INTERNAL_CONTINUATION_META,
INTERNAL_CONTINUATION_PENDING_META,
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
internal_continuation_pending,
internal_continuation_run_started_at,
maybe_continue_turn,
should_stream_budget_response,
)
@pytest.mark.asyncio
async def test_maybe_continue_turn_queues_internal_message():
meta = {
GOAL_STATE_KEY: {
"status": "active",
"objective": "Finish the migration.",
"ui_summary": "migration",
},
}
messages = [
{"role": "system", "content": "system"},
{"role": "user", "content": "start"},
{"role": "assistant", "content": "paused"},
]
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
ctx = SimpleNamespace(
session=SimpleNamespace(metadata=meta),
msg=InboundMessage(
channel="feishu",
sender_id="u1",
chat_id="c1",
content="start",
metadata={
"message_id": "msg-1",
"origin_message_id": "msg-0",
"_wants_stream": True,
"_stream_id": "stream-1",
"_stream_delta": True,
"_stream_end": True,
"_resuming": True,
"webui": True,
},
),
session_key="feishu:c1",
pending_queue=pending,
stop_reason="max_iterations",
final_content="paused",
all_messages=messages,
suppress_response=False,
visible_run_started_at=1234.5,
)
assert await maybe_continue_turn(ctx) is True
queued = pending.get_nowait()
assert queued.sender_id == "system:continuation"
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
assert queued.metadata[INTERNAL_CONTINUATION_KIND_META] == "sustained_goal"
assert queued.metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] == 1234.5
assert internal_continuation_run_started_at(queued.metadata) == 1234.5
assert internal_continuation_pending(ctx.msg.metadata)
assert queued.metadata["webui"] is True
assert queued.metadata["message_id"] == "msg-1"
assert queued.metadata["origin_message_id"] == "msg-0"
assert queued.metadata["_wants_stream"] is True
assert "_stream_id" not in queued.metadata
assert "_stream_delta" not in queued.metadata
assert "_stream_end" not in queued.metadata
assert "_resuming" not in queued.metadata
assert "Finish the migration." in queued.content
assert ctx.all_messages == messages[:-1]
assert ctx.final_content == ""
assert ctx.suppress_response is True
assert ctx.msg.metadata[INTERNAL_CONTINUATION_PENDING_META] is True
assert meta["_sustained_goal_continuation_rounds"] == 1
@pytest.mark.asyncio
async def test_internal_continuation_respects_round_limit():
meta = {
GOAL_STATE_KEY: {"status": "active", "objective": "x"},
"_sustained_goal_continuation_rounds": 12,
}
ctx = SimpleNamespace(
session=SimpleNamespace(metadata=meta),
msg=InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="start"),
session_key="feishu:c1",
pending_queue=asyncio.Queue(),
stop_reason="max_iterations",
final_content="paused",
all_messages=[],
)
assert should_stream_budget_response(
stop_reason="max_iterations",
pending_queue_available=True,
session_metadata=meta,
)
assert await maybe_continue_turn(ctx) is False
def test_internal_continuation_requires_budget_boundary_and_queue():
meta = {GOAL_STATE_KEY: {"status": "active", "objective": "x"}}
assert should_stream_budget_response(
stop_reason="completed",
pending_queue_available=True,
session_metadata=meta,
)
assert should_stream_budget_response(
stop_reason="max_iterations",
pending_queue_available=False,
session_metadata=meta,
)
+22 -21
View File
@@ -38,6 +38,28 @@ async def test_message_tool_rejects_malformed_buttons(bad) -> None:
assert result == "Error: buttons must be a list of list of strings"
@pytest.mark.asyncio
async def test_message_tool_suppresses_delivery_when_active() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
token = tool.set_suppress_delivery(True)
try:
result = await tool.execute(content="all clear", channel="telegram", chat_id="1")
finally:
tool.reset_suppress_delivery(token)
assert sent == []
assert "not delivered" in result
await tool.execute(content="real", channel="telegram", chat_id="1")
assert len(sent) == 1
assert sent[0].content == "real"
@pytest.mark.asyncio
async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None:
sent: list[OutboundMessage] = []
@@ -58,27 +80,6 @@ async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None:
assert sent[1].metadata == {"_record_channel_delivery": True}
@pytest.mark.asyncio
async def test_message_tool_can_suppress_delivery_for_internal_checks() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
token = tool.set_suppress_delivery(True)
try:
result = await tool.execute(content="All clear.", channel="lark", chat_id="chat-1")
finally:
tool.reset_suppress_delivery(token)
assert result == "Message suppressed during internal check"
assert sent == []
await tool.execute(content="real update", channel="lark", chat_id="chat-1")
assert [msg.content for msg in sent] == ["real update"]
@pytest.mark.asyncio
async def test_message_tool_records_media_deliveries() -> None:
sent: list[OutboundMessage] = []
+13
View File
@@ -31,6 +31,19 @@ async def test_publish_turn_run_status_running_records_wall_clock() -> None:
assert call.metadata.get("started_at") == t0
@pytest.mark.asyncio
async def test_publish_turn_run_status_reuses_explicit_wall_clock() -> None:
bus = MagicMock()
bus.publish_outbound = AsyncMock()
msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-a", content="hi")
await wth.publish_turn_run_status(bus, msg, "running", started_at=1234.5)
assert wth.websocket_turn_wall_started_at("chat-a") == 1234.5
call = bus.publish_outbound.await_args[0][0]
assert call.metadata.get("started_at") == 1234.5
@pytest.mark.asyncio
async def test_publish_turn_run_status_idle_clears_wall_clock() -> None:
bus = MagicMock()
+43 -20
View File
@@ -218,7 +218,7 @@ function HostChrome({
)}
</Button>
) : (
<div aria-hidden className="h-8 w-8" />
<div aria-hidden className="host-no-drag pointer-events-none h-8 w-8" />
)}
</header>
);
@@ -252,7 +252,19 @@ export default function App() {
refreshed.token,
refreshed.ws_url,
);
const refreshedSurface = refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: runtimeSurface;
const refreshedHost = createRuntimeHost(
refreshedSurface,
refreshed.runtime_capabilities,
);
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
if (refreshedHost.socketFactory) {
client.updateUrl(refreshedUrl, refreshedHost.socketFactory);
} else {
client.updateUrl(refreshedUrl);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
@@ -260,10 +272,7 @@ export default function App() {
token: refreshed.token,
tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName,
runtimeSurface:
refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: current.runtimeSurface,
runtimeSurface: refreshedSurface,
}
: current,
);
@@ -307,8 +316,16 @@ export default function App() {
try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: state.runtimeSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
client.updateUrl(url);
if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
@@ -316,9 +333,7 @@ export default function App() {
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface: boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: current.runtimeSurface,
runtimeSurface,
}
: current,
);
@@ -1058,12 +1073,19 @@ function Shell({
const showHostChrome = isNativeHostSetupSurface;
const showMainSidebar = view !== "settings";
useEffect(() => {
document.documentElement.classList.toggle("native-host", showHostChrome);
return () => {
document.documentElement.classList.remove("native-host");
};
}, [showHostChrome]);
return (
<ThemeProvider theme={theme}>
<div
className={cn(
"relative h-full w-full overflow-hidden",
showHostChrome && "bg-sidebar",
showHostChrome && "host-window-shell",
)}
>
{showHostChrome ? (
@@ -1071,7 +1093,6 @@ function Shell({
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
theme={theme}
onToggleTheme={toggle}
showThemeButton={view !== "chat"}
/>
) : null}
<div
@@ -1092,8 +1113,10 @@ function Shell({
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar",
!showHostChrome && "shadow-inner-right",
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
showHostChrome
? "host-sidebar-glass"
: "bg-sidebar shadow-inner-right",
)}
>
<Sidebar
@@ -1138,13 +1161,12 @@ function Shell({
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
<main
className={cn(
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome &&
"rounded-l-[28px] shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.45)] dark:shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.85)]",
)}
>
<main
className={cn(
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome && "border-l border-border/55",
)}
>
<div
className={cn(
"absolute inset-0 flex flex-col",
@@ -1161,6 +1183,7 @@ function Shell({
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleForHostChrome
hideThemeButton={showHostChrome}
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
+1 -1
View File
@@ -33,7 +33,7 @@ const LazyHighlightedCode = lazy(async () => {
default({ language, code, isDark }: HighlightedCodeProps) {
return (
<SyntaxHighlighter
language={language}
language={language || "text"}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
+137 -1
View File
@@ -30,6 +30,14 @@ type MarkdownAstNode = {
};
};
type InlineLinkPreview = {
href: string;
origin: string;
prefix?: string;
title: string;
initials: string;
};
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
function extensionOf(value: string): string {
@@ -179,6 +187,119 @@ function nodeText(value: ReactNode): string {
.join("");
}
function linkPreviewParts(value: ReactNode): { text: string; href?: string } {
let text = "";
let href: string | undefined;
for (const child of Children.toArray(value)) {
if (typeof child === "string" || typeof child === "number") {
text += String(child);
continue;
}
if (!isValidElement(child)) {
continue;
}
const props = child.props as { href?: unknown; children?: ReactNode };
if (!href && typeof props.href === "string" && /^https?:\/\//i.test(props.href)) {
href = props.href;
}
const nested = linkPreviewParts(props.children);
text += nested.text;
href ||= nested.href;
}
return { text, href };
}
function cleanLinkPreviewText(value: string): string {
return value
.replace(/\s+/g, " ")
.replace(/^[\s"'“”‘’]+|[\s"'“”‘’]+$/g, "")
.trim();
}
function linkPreviewInitials(value: string): string {
const clean = value
.replace(/^https?:\/\//i, "")
.replace(/^www\./i, "")
.replace(/\.[a-z]{2,}$/i, "");
const parts = clean.split(/[\s.-]+/).filter(Boolean);
return (parts.length > 1 ? parts.slice(0, 2).map((part) => part[0]).join("") : clean.slice(0, 2))
.toUpperCase();
}
function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null {
const { text: rawText, href } = linkPreviewParts(children);
if (!href) return null;
let url: URL;
try {
url = new URL(href);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
const strippedUrl = rawText
.replace(/\s+/g, " ")
.replace(href, "")
.replace(url.toString(), "")
.replace(/https?:\/\/\S+/i, "")
.trim();
if (!strippedUrl || strippedUrl.length < 4) return null;
const sourceMatch = /^(.*?)\s*(?:[—–]| - |:)\s*(.+)$/.exec(strippedUrl);
const prefix = sourceMatch?.[1] ? cleanLinkPreviewText(sourceMatch[1]) : undefined;
const title = cleanLinkPreviewText(sourceMatch?.[2] ?? strippedUrl);
if (!title || /^https?:\/\//i.test(title)) return null;
return {
href,
origin: url.origin,
prefix,
title,
initials: linkPreviewInitials(prefix || url.hostname),
};
}
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
const label = link.prefix
? `${link.prefix}${link.title}`
: link.title;
return (
<a
href={link.href}
target="_blank"
rel="noreferrer noopener"
aria-label={`Open link: ${label}`}
className={cn(
"not-prose inline-flex max-w-full items-center gap-2 align-baseline",
"text-primary no-underline underline-offset-2 hover:underline",
)}
>
<span
className={cn(
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
"border border-border/65 bg-background text-[0.5rem] font-semibold text-muted-foreground",
)}
aria-hidden
>
{link.initials}
<img
src={`${link.origin}/favicon.ico`}
alt=""
className="absolute h-3 w-3 rounded-[2px] object-contain"
loading="lazy"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
</span>
<span className="min-w-0 truncate leading-normal">
{label}
</span>
</a>
);
}
function isRenderedCodeBlock(value: ReactNode): boolean {
if (!isValidElement(value)) return false;
const props = value.props as { code?: unknown };
@@ -264,7 +385,7 @@ export default function MarkdownTextRenderer({
if (fence) {
return (
<CodeBlock
language={fence.language}
language={fence.language || "text"}
code={fence.code}
className="my-3"
highlight={highlightCode}
@@ -296,6 +417,21 @@ export default function MarkdownTextRenderer({
</a>
);
},
li({ children: markdownChildren, className: itemClassName }) {
const link = inlineLinkPreviewFromChildren(markdownChildren);
if (link) {
return (
<li className={cn("list-none pl-0", itemClassName)}>
<InlineLinkPreviewRow link={link} />
</li>
);
}
return (
<li className={itemClassName}>
{markdownChildren}
</li>
);
},
input({ type, checked }) {
if (type !== "checkbox") return null;
return (
+2 -1
View File
@@ -67,7 +67,8 @@ export function Sidebar(props: SidebarProps) {
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
aria-label={t("sidebar.navigation")}
className={cn(
"flex h-full w-full min-w-0 flex-col bg-sidebar text-sidebar-foreground",
"flex h-full w-full min-w-0 flex-col text-sidebar-foreground",
props.hostChromeInset ? "bg-transparent" : "bg-sidebar",
!props.hostChromeInset && "border-r border-sidebar-border/60",
)}
>
@@ -4277,7 +4277,7 @@ function ProviderPicker({
const disabled = providers.length === 0;
return (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={disabled}>
<Button
type="button"
@@ -5098,11 +5098,12 @@ function ModelPresetPicker({
const selectedPreset = presets.find((preset) => preset.name === value) ?? presets[0] ?? null;
return (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={!presets.length}>
<Button
type="button"
variant="outline"
aria-label={tx("settings.rows.currentModel", "Current configuration")}
disabled={!presets.length}
className={cn(
"h-12 w-[min(430px,72vw)] justify-between rounded-full border-input bg-background px-3.5 text-[13px] font-normal shadow-none",
@@ -5155,7 +5156,9 @@ function ModelPresetPicker({
})}
<div className="mt-1 border-t border-border/55 pt-1">
<DropdownMenuItem
onSelect={onCreateConfiguration}
onSelect={() => {
window.setTimeout(onCreateConfiguration, 0);
}}
className={cn(
"flex cursor-default items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] font-medium",
"text-foreground focus:bg-muted/85 focus:text-foreground",
+10 -1
View File
@@ -393,6 +393,15 @@ function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMentio
};
}
function RunPulseIcon() {
return (
<span className="run-pulse-icon relative flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
<span className="run-pulse-icon__ring" />
<span className="run-pulse-icon__dot" />
</span>
);
}
function RunElapsedStrip({
startedAt,
goalState,
@@ -586,7 +595,7 @@ function RunElapsedStrip({
aria-label={ariaLabel}
>
{displayShowTimer ? (
<Activity className="h-4 w-4 shrink-0 text-primary/80" aria-hidden />
<RunPulseIcon />
) : (
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
)}
+19 -13
View File
@@ -10,6 +10,7 @@ interface ThreadHeaderProps {
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean;
hideThemeButton?: boolean;
minimal?: boolean;
}
@@ -19,6 +20,7 @@ export function ThreadHeader({
theme,
onToggleTheme,
hideSidebarToggleForHostChrome = false,
hideThemeButton = false,
minimal = false,
}: ThreadHeaderProps) {
const { t } = useTranslation();
@@ -37,12 +39,14 @@ export function ThreadHeader({
>
<Menu className="h-3.5 w-3.5" />
</Button>
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto"
/>
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto"
/>
) : null}
</div>
);
}
@@ -67,12 +71,14 @@ export function ThreadHeader({
</div>
</div>
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto shrink-0"
/>
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto shrink-0"
/>
) : null}
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
</div>
@@ -97,7 +103,7 @@ function ThemeButton({
aria-label={label}
onClick={onToggleTheme}
className={cn(
"h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground",
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground",
className,
)}
>
@@ -137,10 +137,6 @@ function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
if (!markedCurrentActivity) {
indices.add(i);
markedCurrentActivity = true;
continue;
}
if (activityHasLiveFileEdit(unit)) {
indices.add(i);
}
continue;
}
@@ -150,13 +146,6 @@ function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
return indices;
}
function activityHasLiveFileEdit(unit: Extract<DisplayUnit, { type: "activity" }>): boolean {
return unit.messages.some((message) => (
message.kind === "trace"
&& message.fileEdits?.some((edit) => edit.status === "editing" || edit.pending || !edit.path)
));
}
function unitKey(unit: DisplayUnit, index: number): string {
if (unit.type === "activity") {
const anchor = unit.messages[0]?.id;
@@ -62,6 +62,7 @@ interface ThreadShellProps {
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean;
hideThemeButton?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
@@ -142,6 +143,7 @@ export function ThreadShell({
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false,
hideThemeButton = false,
hideHeader = false,
workspaceScope = null,
workspaceDefaultScope = null,
@@ -567,6 +569,7 @@ export function ThreadShell({
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
/>
) : null}
@@ -237,7 +237,7 @@ export function ThreadViewport({
<div
ref={scrollRef}
className={cn(
"absolute inset-0 overflow-y-auto scroll-auto scrollbar-thin",
"thread-viewport-scrollbar absolute inset-0 overflow-y-auto scroll-auto scrollbar-thin",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
@@ -38,6 +38,7 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
@@ -67,8 +68,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
active={editing}
tone={failed ? "error" : editing ? "active" : "success"}
className="text-xs"
contentClassName="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"
title={failureDetail || edit.absolute_path || edit.path}
contentClassName={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
title={rawFailureDetail || edit.absolute_path || edit.path}
label={edit.pending && !edit.path
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
: (
@@ -82,13 +83,15 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
testId="activity-file-reference"
/>
)}
detail={failed ? (
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
detail={null}
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
>
{failed ? (
<span className="block max-w-[42rem] truncate text-[11px] leading-4 text-destructive/75">
{failureDetail}
</span>
) : null}
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
/>
</ActivityStep>
);
}
@@ -96,14 +99,23 @@ export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "delet
return edit.added > 0 || edit.deleted > 0;
}
function formatFileEditError(error?: string): string {
function cleanFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
const cleaned = firstLine
return firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
}
function formatFileEditError(error?: string): string {
const cleaned = cleanFileEditError(error);
if (!cleaned) return "";
if (/\bpermission denied\b/i.test(cleaned) || /\boperation not permitted\b/i.test(cleaned)) {
return "No permission to change this location.";
}
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
+125
View File
@@ -89,6 +89,75 @@
-webkit-app-region: no-drag;
}
html.native-host,
html.native-host body,
html.native-host #root {
background: transparent;
}
html.native-host body {
overflow: hidden;
}
.host-window-shell,
.host-sidebar-glass {
--host-glass-spot: hsl(var(--background) / 0.42);
--host-glass-start: hsl(var(--sidebar) / 0.5);
--host-glass-end: hsl(var(--sidebar) / 0.3);
background:
radial-gradient(
circle at 18% 0%,
var(--host-glass-spot),
transparent 34rem
),
linear-gradient(
180deg,
var(--host-glass-start),
var(--host-glass-end)
);
background-attachment: fixed, fixed;
-webkit-backdrop-filter: saturate(185%) blur(34px);
backdrop-filter: saturate(185%) blur(34px);
}
.host-sidebar-glass {
box-shadow:
inset -1px 0 0 hsl(var(--border) / 0.36),
inset 1px 0 0 hsl(var(--background) / 0.34),
18px 0 44px -42px rgb(0 0 0 / 0.42);
}
.dark .host-window-shell,
.dark .host-sidebar-glass {
--host-glass-spot: hsl(var(--foreground) / 0.06);
--host-glass-start: hsl(var(--sidebar) / 0.48);
--host-glass-end: hsl(var(--sidebar) / 0.3);
}
.dark .host-sidebar-glass {
box-shadow:
inset -1px 0 0 hsl(var(--border) / 0.42),
inset 1px 0 0 hsl(var(--foreground) / 0.05),
18px 0 46px -42px rgb(0 0 0 / 0.72);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.host-sidebar-glass {
background: hsl(var(--sidebar) / 0.92);
}
}
html.native-host * {
scrollbar-width: none;
scrollbar-gutter: auto !important;
}
html.native-host *::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
}
@@ -232,6 +301,50 @@
.composer-status-strip[data-state="exit"] {
animation: composer-status-strip-exit 180ms ease-in both;
}
@keyframes run-pulse-dot {
0%,
100% {
transform: scale(0.9);
opacity: 0.76;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
@keyframes run-pulse-ring {
0% {
transform: scale(0.42);
opacity: 0.34;
}
100% {
transform: scale(1.28);
opacity: 0;
}
}
.run-pulse-icon {
color: hsl(204 82% 46%);
}
.run-pulse-icon__ring,
.run-pulse-icon__dot {
display: block;
border-radius: 999px;
pointer-events: none;
}
.run-pulse-icon__ring {
position: absolute;
height: 12px;
width: 12px;
background: hsl(204 82% 46% / 0.22);
animation: run-pulse-ring 1.55s ease-out infinite;
}
.run-pulse-icon__dot {
height: 6px;
width: 6px;
background: currentColor;
box-shadow: 0 0 0 1px hsl(204 82% 46% / 0.14);
animation: run-pulse-dot 1.55s ease-in-out infinite;
}
@keyframes queued-prompt-row-enter {
0% {
opacity: 0;
@@ -252,6 +365,18 @@
.composer-status-strip[data-state] {
animation: none;
}
.run-pulse-icon,
.run-pulse-icon * {
animation: none;
}
.run-pulse-icon__ring {
opacity: 0.18;
transform: scale(1);
}
.run-pulse-icon__dot {
opacity: 1;
transform: scale(1);
}
.queued-prompt-row {
animation: none;
}
+24 -56
View File
@@ -58,11 +58,10 @@ function findStreamingAssistantIndex(
/**
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
*
* Lookup rule: prefer the most recent assistant turn in the active UI tail.
* Most providers emit reasoning before answer text, but some only expose
* ``reasoning_content`` after the answer stream completes. In that post-hoc
* case the reasoning still belongs to the same assistant turn and must render
* above the answer, not as a new row below it.
* Lookup rule: reasoning can only extend the current reasoning placeholder.
* Once ordinary answer text has appeared, the next reasoning chunk starts a
* fresh Thought block so streamed output stays in arrival order:
* Thought -> answer -> Thought -> answer.
*/
function attachReasoningChunk(
prev: UIMessage[],
@@ -83,10 +82,10 @@ function attachReasoningChunk(
if (candidate.role !== "assistant") continue;
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
const hasAnswer = candidate.content.length > 0;
if (hasAnswer) break;
if (
candidate.reasoningStreaming
|| candidate.reasoning !== undefined
|| hasAnswer
|| candidate.isStreaming
) {
const merged: UIMessage = {
@@ -97,15 +96,6 @@ function attachReasoningChunk(
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
if (!hasAnswer && candidate.isStreaming) {
const merged: UIMessage = {
...candidate,
reasoning: chunk,
reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}),
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
break;
}
const activitySegmentId = segments?.ensure();
@@ -293,38 +283,6 @@ function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]):
};
}
function demoteInterruptedAssistantToActivity(
prev: UIMessage[],
segmentId: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const message = prev[i];
if (message.role === "user") break;
if (
message.role !== "assistant"
|| message.kind === "trace"
|| !message.isStreaming
|| !message.content.trim()
|| message.media?.length
) {
continue;
}
const reasoning = [message.reasoning, message.content]
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
.join("\n\n");
const demoted: UIMessage = {
...message,
content: "",
reasoning,
reasoningStreaming: false,
isStreaming: false,
activitySegmentId: message.activitySegmentId ?? segmentId,
};
return replaceMessageAt(prev, i, demoted);
}
return prev;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
@@ -511,6 +469,7 @@ export function useNanobotStream(
if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId);
buffer.current = null;
activeAssistantRef.current = null;
return !!closedStreamId;
}, []);
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
@@ -589,15 +548,18 @@ export function useNanobotStream(
text += events[i].text;
i += 1;
}
next = kind === "delta"
? appendAnswerChunk(next, text)
: attachReasoningChunk(next, text, {
ensure: ensureActivitySegmentId,
});
if (kind === "delta") {
next = appendAnswerChunk(next, text);
} else {
if (closeActiveAssistantStream()) clearActivitySegment();
next = attachReasoningChunk(next, text, {
ensure: ensureActivitySegmentId,
});
}
}
return next;
},
[appendAnswerChunk, ensureActivitySegmentId],
[appendAnswerChunk, clearActivitySegment, closeActiveAssistantStream, ensureActivitySegmentId],
);
const flushPendingStreamEvents = useCallback((options?: {
@@ -735,7 +697,13 @@ export function useNanobotStream(
return;
}
flushPendingStreamEvents();
const shouldCloseAnswerBeforeEvent =
ev.event === "file_edit"
|| (
ev.event === "message"
&& (ev.kind === "tool_hint" || ev.kind === "progress")
);
flushPendingStreamEvents({ closeAnswerSegment: shouldCloseAnswerBeforeEvent });
if (ev.event === "reasoning_end") {
if (suppressStreamUntilTurnEndRef.current) return;
@@ -812,7 +780,7 @@ export function useNanobotStream(
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
setMessages((prev) => {
const segmentId = ensureActivitySegmentId();
const base = demoteInterruptedAssistantToActivity(prev, segmentId);
const base = prev;
const visibleStructuredEvents = filterCoveredFileEditToolEvents(base, structuredEvents);
const structuredLines = toolTraceLinesFromEvents(visibleStructuredEvents);
const lines = structuredLines.length > 0
@@ -914,7 +882,7 @@ export function useNanobotStream(
}
setMessages((prev) => {
let segmentId = eventSegmentId;
const base = segmentId ? demoteInterruptedAssistantToActivity(prev, segmentId) : prev;
const base = prev;
const targetIndex = findFileEditTraceIndex(base, segmentId, normalized);
if (targetIndex !== null) {
const target = base[targetIndex];
+130 -130
View File
@@ -59,43 +59,43 @@
"settings": {
"backToChat": "Volver al chat",
"sidebar": {
"title": "Configuración",
"ariaLabel": "Secciones de configuración"
"title": "Ajustes",
"ariaLabel": "Secciones de ajustes"
},
"nav": {
"general": "General",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"browser": "Web",
"overview": "Resumen",
"appearance": "Apariencia",
"models": "Modelos",
"providers": "Proveedores",
"image": "Imagen",
"browser": "Internet",
"runtime": "Sistema",
"advanced": "Security",
"advanced": "Seguridad",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Apps"
"apps": "Aplicaciones"
},
"sections": {
"interface": "Interfaz",
"ai": "IA",
"ai": "AI",
"system": "Sistema",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"status": "Estado",
"localPreferences": "Preferencias locales",
"presets": "Preajustes",
"imageGeneration": "Generación de imágenes",
"imageDefaults": "Valores predeterminados",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"webuiSafety": "Web safety",
"webSearch": "Búsqueda web",
"webBehavior": "Comportamiento",
"identity": "Identidad",
"webuiSafety": "Seguridad de WebUI",
"capabilities": "Capacidades",
"cliApps": "Apps CLI",
"cliApps": "Aplicaciones CLI",
"mcp": "Servicios MCP",
"apps": "Apps",
"nativeHost": "App",
"hostSafety": "App safety"
"apps": "Aplicaciones",
"nativeHost": "Host nativo",
"hostSafety": "Seguridad de la app"
},
"rows": {
"theme": "Tema",
@@ -104,118 +104,118 @@
"model": "Modelo",
"restart": "Reiniciar nanobot",
"configPath": "Ruta de configuración",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"activePreset": "Preajuste activo",
"gateway": "Pasarela",
"restartState": "Estado de reinicio",
"pendingChanges": "Cambios pendientes",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"selectedPreset": "Preajuste seleccionado",
"presetModel": "Modelo del preajuste",
"density": "Densidad",
"activityMode": "Detalle de actividad",
"codeWrap": "Ajuste de código",
"maxResults": "Resultados máximos",
"timeout": "Tiempo de espera",
"jinaReader": "Lector Jina",
"imageGeneration": "Generación de imágenes",
"imageProvider": "Proveedor de imágenes",
"imageProviderStatus": "Estado del proveedor",
"imageProviderBase": "Base del proveedor",
"imageModel": "Modelo de imagen",
"defaultAspectRatio": "Relación predeterminada",
"defaultAspectRatio": "Proporción predeterminada",
"defaultImageSize": "Tamaño predeterminado",
"maxImagesPerTurn": "Máximo de imágenes por turno",
"maxImagesPerTurn": "Máx. imágenes por turno",
"imageSaveDir": "Directorio de guardado",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"botName": "Nombre del bot",
"botIcon": "Icono del bot",
"timezone": "Zona horaria",
"workspacePath": "Workspace predeterminado",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"localServiceAccess": "Servicios locales",
"webuiDefaultAccess": "Acceso predeterminado",
"currentModel": "Configuración actual",
"brandLogos": "Logotipos de marca",
"cliAppsCatalog": "Catálogo de apps CLI",
"cliAppsFilter": "Filtro de apps CLI",
"brandLogos": "Logos de marca",
"cliAppsCatalog": "Catálogo",
"cliAppsFilter": "Filtro",
"engine": "Motor",
"logs": "Registros",
"diagnostics": "Diagnóstico",
"contextWindow": "Context window"
"contextWindow": "Ventana de contexto"
},
"help": {
"theme": "Cambia entre apariencia clara y oscura.",
"language": "Elige el idioma usado por la WebUI.",
"language": "Elige el idioma usado por 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.",
"imageGeneration": "Expone generate_image en los chats cuando hay un proveedor de imágenes configurado disponible.",
"imageProvider": "Elige el proveedor del registro que usará generate_image.",
"imageProviderStatus": "La generación de imágenes reutiliza las credenciales de Proveedores.",
"model": "Define el nombre de modelo predeterminado de nanobot.",
"configPath": "Archivo de configuración que usa actualmente el gateway.",
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
"presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.",
"density": "Solo se guarda en este navegador.",
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
"maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
"imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
"defaultAspectRatio": "Se usa cuando el prompt no elige una relación de aspecto.",
"defaultImageSize": "Sugerencia de tamaño enviada a los proveedores que la admiten.",
"defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
"defaultImageSize": "Pista de tamaño enviada a proveedores compatibles.",
"maxImagesPerTurn": "Límite superior para una solicitud generate_image.",
"botName": "Se muestra donde nanobot usa un nombre visible.",
"botIcon": "Emoji o texto corto mostrado junto al nombre del bot.",
"timezone": "Se usa para programaciones y respuestas sensibles al tiempo.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Elige la configuración de modelo que nanobot usa para las próximas respuestas.",
"selectedModelProvider": "Lo define el modelo seleccionado.",
"selectedModelValue": "Lo define el modelo seleccionado.",
"brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
"cliAppsCatalog": "Explora CLIs de apps que nanobot puede ejecutar localmente.",
"botIcon": "Emoji o texto corto junto al nombre del bot.",
"timezone": "Se usa para horarios y respuestas con conciencia temporal.",
"localServiceAccess": "Permite que comandos shell con Full Access alcancen servicios localhost.",
"webuiDefaultAccess": "Usado por chats web sin permiso específico de proyecto.",
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
"currentModel": "Se usa para nuevas respuestas.",
"selectedModelProvider": "Definido por el modelo seleccionado.",
"selectedModelValue": "Definido por el modelo seleccionado.",
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
"cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
"cliAppsFilter": "Busca por app, categoría o capacidad.",
"logs": "Abre la carpeta de registros del motor de escritorio.",
"logs": "Abre la carpeta de registros del motor nativo.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
"localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
"contextWindow": "Elige el presupuesto de contexto predeterminado para esta configuración de modelo."
},
"values": {
"light": "Claro",
"dark": "Oscuro",
"notAvailable": "No disponible",
"enabled": "Enabled",
"disabled": "Disabled",
"enabled": "Activado",
"disabled": "Desactivado",
"restartPending": "Reinicio pendiente",
"ready": "Listo",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured",
"comfortable": "Cómodo",
"compact": "Compacto",
"auto": "Automático",
"expanded": "Expandido",
"on": "Activado",
"off": "Desactivado",
"defaultPermission": "Permiso predeterminado",
"fullAccess": "Acceso completo",
"configured": "Configurado",
"notConfigured": "Sin configurar",
"pending": "Pendiente",
"restartingEngine": "Reiniciando"
},
"status": {
"loading": "Cargando configuración...",
"loadError": "No se pudo cargar la configuración",
"unsaved": "Hay cambios sin guardar.",
"loading": "Cargando ajustes...",
"loadError": "No se pudieron cargar los ajustes",
"unsaved": "Cambios sin guardar.",
"upToDate": "Actualizado.",
"savedRestart": "Guardado. Reinicia nanobot para aplicar.",
"restartAfterSaving": "Guarda los cambios y reinicia cuando estés listo.",
"savedRestartApply": "Guardado. Reinicia cuando estés listo.",
"imageProviderRestart": "Cambios del proveedor de imágenes guardados. Reinicia cuando estés listo.",
"hostRestartAfterSaving": "Guarda los cambios y nanobot reiniciará su motor.",
"hostRestartPending": "Guardado. Reiniciando el motor cuando esté listo.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
"restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
"savedRestartApply": "Guardado. Reinicia cuando puedas.",
"imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
"hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
"hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
"hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
"logsOpened": "Carpeta de registros abierta.",
"logsOpenFailed": "No se pudo abrir la carpeta de registros.",
"diagnosticsExported": "Diagnóstico exportado a {{path}}.",
"diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
},
"actions": {
"save": "Guardar",
@@ -224,8 +224,8 @@
"cancel": "Cancelar",
"open": "Abrir",
"export": "Exportar",
"opening": "Opening...",
"exporting": "Exporting..."
"opening": "Abriendo...",
"exporting": "Exportando..."
},
"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.",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "Tipo de credencial BYOK",
"llm": "LLM",
"webSearch": "Web Search"
"webSearch": "Búsqueda web"
},
"webSearch": {
"provider": "Proveedor de búsqueda",
@@ -270,44 +270,44 @@
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"model": "Modelo actual",
"providers": "Proveedores",
"configuredCount": "{{count}} configurados",
"totalProviders": "{{count}} disponibles",
"webSearch": "Búsqueda web",
"imageGeneration": "Generación de imágenes",
"workspace": "Workspace"
"workspace": "Espacio de trabajo"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"searchPlaceholder": "Buscar proveedores",
"noMatches": "Ningún proveedor coincide con esta búsqueda.",
"saveProvider": "Guardar proveedor"
},
"image": {
"selectProvider": "Seleccionar proveedor",
"selectAspect": "Seleccionar relación",
"selectAspect": "Seleccionar proporción",
"selectSize": "Seleccionar tamaño",
"configureProvider": "Configurar proveedor",
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
},
"models": {
"selectModel": "Seleccionar modelo",
"addConfiguration": "Añadir configuración",
"addConfiguration": "Agregar configuración",
"newConfiguration": "Nueva configuración de modelo",
"newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
"newConfigurationHelp": "Guarda un proveedor y modelo como opción de un clic.",
"configurationName": "Nombre de configuración",
"configurationNameHelp": "Cambia el nombre de esta configuración de modelo guardada.",
"configurationNameHelp": "Renombra esta configuración de modelo guardada.",
"configurationNamePlaceholder": "Escritura rápida",
"searchModels": "Buscar o escribir ID de modelo",
"useCustomModel": "Usar",
"loadingModels": "Cargando modelos...",
"searchCatalog": "Busca en el catálogo del proveedor para elegir un modelo.",
"searchCatalog": "Busca el catálogo del proveedor para elegir un modelo.",
"modelsAvailable": "disponibles",
"noModelResults": "No hay modelos coincidentes.",
"loadFailed": "Lista de modelos no disponible.",
"unsupportedModelList": "Escribe manualmente un ID de modelo.",
"unsupportedModelList": "Escribe un ID de modelo manualmente.",
"providerNotConfigured": "Configura este proveedor antes de cargar modelos.",
"autoProviderCustomOnly": "El modo de proveedor automático usa ID de modelo personalizados."
"autoProviderCustomOnly": "El modo de proveedor automático usa IDs de modelo personalizados."
},
"timezone": {
"select": "Seleccionar zona horaria",
@@ -316,7 +316,7 @@
},
"cliApps": {
"allCategories": "Todas las categorías",
"availableCount": "{{count}} apps",
"availableCount": "{{count}} aplicaciones",
"installedCount": "{{count}} instaladas",
"summary": "{{installed}} de {{total}} CLIs instaladas",
"filterAll": "Todas",
@@ -402,7 +402,7 @@
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
},
"apps": {
"description": "Añade CLI de apps y servicios MCP que nanobot puede usar desde el chat.",
"description": "Agrega CLI de apps y servicios MCP que nanobot puede usar desde el chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Todo",
@@ -416,17 +416,17 @@
"empty": "Ninguna app coincide con este filtro."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
"authentication": "Autenticación OAuth",
"signIn": "Iniciar sesión",
"signingIn": "Iniciando sesión...",
"signInAgain": "Iniciar sesión de nuevo",
"signOut": "Cerrar sesión",
"signedInAs": "Sesión iniciada como {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"signInRequired": "Inicio de sesión requerido",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sesión iniciada",
"notSignedIn": "Sin sesión"
}
},
"chat": {
+144 -144
View File
@@ -57,45 +57,45 @@
"apps": "Apps"
},
"settings": {
"backToChat": "Retour à la discussion",
"backToChat": "Retour au chat",
"sidebar": {
"title": "Paramètres",
"ariaLabel": "Sections des paramètres"
"title": "Réglages",
"ariaLabel": "Sections des réglages"
},
"nav": {
"general": "Général",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"browser": "Web",
"overview": "Aperçu",
"appearance": "Apparence",
"models": "Modèles",
"providers": "Fournisseurs",
"image": "Images",
"browser": "Internet",
"runtime": "Système",
"advanced": "Security",
"advanced": "Sécurité",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Apps"
"apps": "Applications"
},
"sections": {
"interface": "Interface",
"ai": "IA",
"interface": "Interface utilisateur",
"ai": "AI",
"system": "Système",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"imageGeneration": "Génération d'images",
"status": "État",
"localPreferences": "Préférences locales",
"presets": "Préréglages",
"imageGeneration": "Génération dimages",
"imageDefaults": "Valeurs par défaut",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"webuiSafety": "Web safety",
"webSearch": "Recherche web",
"webBehavior": "Comportement",
"identity": "Identité",
"webuiSafety": "Sécurité WebUI",
"capabilities": "Capacités",
"cliApps": "Apps CLI",
"cliApps": "Applications CLI",
"mcp": "Services MCP",
"apps": "Apps",
"nativeHost": "App",
"hostSafety": "App safety"
"apps": "Applications",
"nativeHost": "Hôte natif",
"hostSafety": "Sécurité de lapp"
},
"rows": {
"theme": "Thème",
@@ -103,119 +103,119 @@
"provider": "Fournisseur",
"model": "Modèle",
"restart": "Redémarrer nanobot",
"configPath": "Chemin de configuration",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"configPath": "Chemin de config",
"activePreset": "Préréglage actif",
"gateway": "Passerelle",
"restartState": "État du redémarrage",
"pendingChanges": "Modifications en attente",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"imageGeneration": "Génération d'images",
"imageProvider": "Fournisseur d'images",
"selectedPreset": "Préréglage sélectionné",
"presetModel": "Modèle du préréglage",
"density": "Densité",
"activityMode": "Détail dactivité",
"codeWrap": "Retour à la ligne du code",
"maxResults": "Résultats max.",
"timeout": "Délai dattente",
"jinaReader": "Lecteur Jina",
"imageGeneration": "Génération dimages",
"imageProvider": "Fournisseur dimages",
"imageProviderStatus": "État du fournisseur",
"imageProviderBase": "Base du fournisseur",
"imageModel": "Modèle d'image",
"defaultAspectRatio": "Format par défaut",
"imageModel": "Modèle dimage",
"defaultAspectRatio": "Ratio par défaut",
"defaultImageSize": "Taille par défaut",
"maxImagesPerTurn": "Nombre max. d'images par tour",
"imageSaveDir": "Répertoire de sauvegarde",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"maxImagesPerTurn": "Images max. par tour",
"imageSaveDir": "Dossier denregistrement",
"botName": "Nom du bot",
"botIcon": "Icône du bot",
"timezone": "Fuseau horaire",
"workspacePath": "Espace de travail par défaut",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"localServiceAccess": "Services locaux",
"webuiDefaultAccess": "Accès par défaut",
"currentModel": "Configuration actuelle",
"brandLogos": "Logos de marque",
"cliAppsCatalog": "Catalogue d'apps CLI",
"cliAppsFilter": "Filtre des apps CLI",
"cliAppsCatalog": "Catalogue",
"cliAppsFilter": "Filtre",
"engine": "Moteur",
"logs": "Journaux",
"diagnostics": "Diagnostics",
"contextWindow": "Context window"
"diagnostics": "Diagnostic",
"contextWindow": "Fenêtre de contexte"
},
"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.",
"theme": "Basculer entre lapparence claire et sombre.",
"language": "Choisissez la langue utilisée par WebUI.",
"provider": "Sélectionnez le fournisseur à utiliser pour les nouvelles requêtes de modèle.",
"model": "Définissez le nom du modèle utilisé par défaut 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.",
"imageGeneration": "Expose generate_image dans les chats lorsquun fournisseur dimages configuré est disponible.",
"imageProvider": "Choisissez le fournisseur du registre utilisé par generate_image.",
"imageProviderStatus": "La génération dimages réutilise les identifiants de Fournisseurs.",
"selectedPreset": "Les préréglages nommés sont en lecture seule ici ; modifiez-les dans config.json.",
"presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.",
"density": "Enregistré seulement dans ce navigateur.",
"activityMode": "Choisissez le niveau de détail dactivité agent affiché par défaut.",
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
"maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
"jinaReader": "Utilise Jina Reader pour web_fetch lorsque disponible.",
"imageGeneration": "Expose generate_image dans les chats lorsquun fournisseur dimage configuré est disponible.",
"imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.",
"imageProviderStatus": "La génération dimages réutilise les identifiants des fournisseurs.",
"imageModel": "Nom du modèle envoyé au fournisseur dimages sélectionné.",
"defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de format.",
"defaultImageSize": "Indication de taille envoyée aux fournisseurs qui la prennent en charge.",
"defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de ratio.",
"defaultImageSize": "Indication de taille envoyée aux fournisseurs compatibles.",
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.",
"botName": "Affiché partout où nanobot utilise un nom visible.",
"botName": "Affiché où nanobot utilise un nom visible.",
"botIcon": "Emoji ou texte court affiché avec le nom du bot.",
"timezone": "Utilisé pour les planifications et les réponses sensibles à lheure.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Choisissez la configuration de modèle que nanobot utilisera pour les prochaines réponses.",
"timezone": "Utilisé pour les horaires et les réponses tenant compte du temps.",
"localServiceAccess": "Autorise les commandes shell Full Access à atteindre les services localhost.",
"webuiDefaultAccess": "Utilisé par les chats web sans permission propre au projet.",
"securityManagedControls": "Les récupérations web protègent toujours les services locaux, privés et de métadonnées. La sécurité des canaux principaux reste gérée dans config.json.",
"currentModel": "Utilisée pour les nouvelles réponses.",
"selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "Défini par le modèle sélectionné.",
"brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
"cliAppsCatalog": "Parcourez les CLIs d'apps que nanobot peut exécuter localement.",
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI dapps que nanobot peut exécuter localement ; les apps natives restent inchangées.",
"cliAppsFilter": "Recherchez par app, catégorie ou capacité.",
"logs": "Ouvrir le dossier des journaux du moteur natif.",
"diagnostics": "Exporter un petit rapport runtime pour le support.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
"logs": "Ouvre le dossier des journaux du moteur natif.",
"diagnostics": "Exporte un petit rapport dexécution pour le support.",
"localServiceAccessNative": "Autorise les commandes shell Full Access à atteindre les services sur ce Mac.",
"webuiDefaultAccessNative": "Utilisé par les chats natifs sans permission propre au projet.",
"contextWindow": "Choisissez le budget de contexte par défaut pour cette configuration de modèle."
},
"values": {
"light": "Clair",
"dark": "Sombre",
"notAvailable": "Indisponible",
"enabled": "Enabled",
"disabled": "Disabled",
"enabled": "Activé",
"disabled": "Désactivé",
"restartPending": "Redémarrage en attente",
"ready": "Prêt",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured",
"comfortable": "Confortable",
"compact": "Compacte",
"auto": "Automatique",
"expanded": "Développé",
"on": "Activé",
"off": "Désactivé",
"defaultPermission": "Autorisation par défaut",
"fullAccess": "Accès complet",
"configured": "Configuré",
"notConfigured": "Non configuré",
"pending": "En attente",
"restartingEngine": "Redémarrage"
},
"status": {
"loading": "Chargement des paramètres...",
"loadError": "Impossible de charger les paramètres",
"loading": "Chargement des réglages...",
"loadError": "Impossible de charger les réglages",
"unsaved": "Modifications non enregistrées.",
"upToDate": "À jour.",
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer.",
"restartAfterSaving": "Enregistrez les modifications, puis redémarrez lorsque vous êtes prêt.",
"savedRestartApply": "Enregistré. Redémarrez lorsque vous êtes prêt.",
"imageProviderRestart": "Modifications du fournisseur dimages enregistrées. Redémarrez lorsque vous êtes prêt.",
"hostRestartAfterSaving": "Enregistrez les changements et nanobot redémarrera son moteur.",
"hostRestartPending": "Enregistré. Redémarrage du moteur quand il sera prêt.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
"restartAfterSaving": "Enregistrez les changements, puis redémarrez quand vous êtes prêt.",
"savedRestartApply": "Enregistré. Redémarrez quand vous êtes prêt.",
"imageProviderRestart": "Changements du fournisseur dimage enregistrés. Redémarrez quand vous êtes prêt.",
"hostRestartAfterSaving": "En enregistrant, nanobot redémarrera son moteur.",
"hostRestartPending": "Enregistré. Le moteur redémarrera lorsquil sera prêt.",
"hostApiUnavailable": "Les actions de lhôte ne sont disponibles que dans lapp native.",
"logsOpened": "Dossier des journaux ouvert.",
"logsOpenFailed": "Impossible douvrir le dossier des journaux.",
"diagnosticsExported": "Diagnostic exporté vers {{path}}.",
"diagnosticsExportFailed": "Impossible dexporter le diagnostic."
},
"actions": {
"save": "Enregistrer",
@@ -224,8 +224,8 @@
"cancel": "Annuler",
"open": "Ouvrir",
"export": "Exporter",
"opening": "Opening...",
"exporting": "Exporting..."
"opening": "Ouverture...",
"exporting": "Exportation..."
},
"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.",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "Type d'identifiants BYOK",
"llm": "LLM",
"webSearch": "Web Search"
"webSearch": "Recherche web"
},
"webSearch": {
"provider": "Fournisseur de recherche",
@@ -270,53 +270,53 @@
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"imageGeneration": "Génération d'images",
"workspace": "Workspace"
"model": "Modèle actuel",
"providers": "Fournisseurs",
"configuredCount": "{{count}} configurés",
"totalProviders": "{{count}} disponibles",
"webSearch": "Recherche web",
"imageGeneration": "Génération dimages",
"workspace": "Espace de travail"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"searchPlaceholder": "Rechercher des fournisseurs",
"noMatches": "Aucun fournisseur ne correspond.",
"saveProvider": "Enregistrer le fournisseur"
},
"image": {
"selectProvider": "Sélectionner un fournisseur",
"selectAspect": "Sélectionner un format",
"selectSize": "Sélectionner une taille",
"selectProvider": "Choisir un fournisseur",
"selectAspect": "Choisir un ratio",
"selectSize": "Choisir une taille",
"configureProvider": "Configurer le fournisseur",
"missingCredential": "Configurez ce fournisseur avant dactiver la génération dimages."
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
},
"models": {
"selectModel": "Sélectionner un modèle",
"selectModel": "Choisir un modèle",
"addConfiguration": "Ajouter une configuration",
"newConfiguration": "Nouvelle configuration de modèle",
"newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
"newConfigurationHelp": "Enregistre un fournisseur et un modèle comme option en un clic.",
"configurationName": "Nom de la configuration",
"configurationNameHelp": "Renommez cette configuration de modèle enregistrée.",
"configurationNameHelp": "Renomme cette configuration de modèle enregistrée.",
"configurationNamePlaceholder": "Rédaction rapide",
"searchModels": "Rechercher ou saisir lID du modèle",
"searchModels": "Rechercher ou saisir un ID de modèle",
"useCustomModel": "Utiliser",
"loadingModels": "Chargement des modèles...",
"searchCatalog": "Recherchez dans le catalogue du fournisseur pour choisir un modèle.",
"searchCatalog": "Rechercher dans le catalogue du fournisseur pour choisir un modèle.",
"modelsAvailable": "disponibles",
"noModelResults": "Aucun modèle correspondant.",
"loadFailed": "Liste des modèles indisponible.",
"unsupportedModelList": "Saisissez manuellement un ID de modèle.",
"providerNotConfigured": "Configurez ce fournisseur avant de charger les modèles.",
"autoProviderCustomOnly": "Le mode fournisseur automatique utilise des ID de modèle personnalisés."
"autoProviderCustomOnly": "Le mode fournisseur automatique utilise des IDs de modèle personnalisés."
},
"timezone": {
"select": "Sélectionner un fuseau horaire",
"select": "Choisir un fuseau horaire",
"search": "Rechercher un fuseau horaire",
"empty": "Aucun fuseau horaire correspondant."
},
"cliApps": {
"allCategories": "Toutes les catégories",
"availableCount": "{{count}} apps",
"availableCount": "{{count}} applications",
"installedCount": "{{count}} installées",
"summary": "{{installed}} CLIs installées sur {{total}}",
"filterAll": "Tout",
@@ -402,7 +402,7 @@
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
},
"apps": {
"description": "Ajoutez des CLI dapps et des services MCP que nanobot peut utiliser dans le chat.",
"description": "Ajoutez des CLI dapps et services MCP utilisables par nanobot depuis le chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tout",
@@ -411,22 +411,22 @@
"enabledSummary": "{{count}} activés",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Rechercher des apps",
"featured": "En vedette",
"featured": "À la une",
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond à ce filtre."
"empty": "Aucune app ne correspond."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
"authentication": "Authentification OAuth",
"signIn": "Se connecter",
"signingIn": "Connexion...",
"signInAgain": "Se reconnecter",
"signOut": "Se déconnecter",
"signedInAs": "Connecté en tant que {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"signInRequired": "Connexion requise",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Connecté",
"notSignedIn": "Non connecté"
}
},
"chat": {
+127 -127
View File
@@ -57,7 +57,7 @@
"apps": "Aplikasi"
},
"settings": {
"backToChat": "Kembali ke obrolan",
"backToChat": "Kembali ke chat",
"sidebar": {
"title": "Pengaturan",
"ariaLabel": "Bagian pengaturan"
@@ -65,14 +65,14 @@
"nav": {
"general": "Umum",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"browser": "Web",
"overview": "Ikhtisar",
"appearance": "Tampilan",
"models": "Model",
"providers": "Penyedia",
"image": "Gambar",
"browser": "Internet",
"runtime": "Sistem",
"advanced": "Security",
"advanced": "Keamanan",
"cliApps": "Aplikasi CLI",
"mcp": "MCP",
"apps": "Aplikasi"
@@ -82,20 +82,20 @@
"ai": "AI",
"system": "Sistem",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"localPreferences": "Preferensi lokal",
"presets": "Preset",
"imageGeneration": "Pembuatan gambar",
"imageDefaults": "Default",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"webuiSafety": "Web safety",
"capabilities": "Kapabilitas",
"cliApps": "App CLI",
"webSearch": "Pencarian web",
"webBehavior": "Perilaku",
"identity": "Identitas",
"webuiSafety": "Keamanan WebUI",
"capabilities": "Kemampuan",
"cliApps": "Aplikasi CLI",
"mcp": "Layanan MCP",
"apps": "Aplikasi",
"nativeHost": "Native host",
"hostSafety": "App safety"
"nativeHost": "Host native",
"hostSafety": "Keamanan aplikasi"
},
"rows": {
"theme": "Tema",
@@ -104,18 +104,18 @@
"model": "Model",
"restart": "Mulai ulang nanobot",
"configPath": "Path konfigurasi",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"activePreset": "Preset aktif",
"gateway": "Gerbang",
"restartState": "Status mulai ulang",
"pendingChanges": "Perubahan tertunda",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"selectedPreset": "Preset terpilih",
"presetModel": "Model preset",
"density": "Kerapatan",
"activityMode": "Detail aktivitas",
"codeWrap": "Bungkus kode",
"maxResults": "Hasil maksimum",
"timeout": "Batas waktu",
"jinaReader": "Pembaca Jina",
"imageGeneration": "Pembuatan gambar",
"imageProvider": "Penyedia gambar",
"imageProviderStatus": "Status penyedia",
@@ -124,98 +124,98 @@
"defaultAspectRatio": "Rasio default",
"defaultImageSize": "Ukuran default",
"maxImagesPerTurn": "Maks. gambar per giliran",
"imageSaveDir": "Direktori penyimpanan",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"imageSaveDir": "Direktori simpan",
"botName": "Nama bot",
"botIcon": "Ikon bot",
"timezone": "Zona waktu",
"workspacePath": "Workspace default",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"localServiceAccess": "Layanan lokal",
"webuiDefaultAccess": "Akses default",
"currentModel": "Konfigurasi saat ini",
"brandLogos": "Logo merek",
"cliAppsCatalog": "Katalog aplikasi CLI",
"cliAppsFilter": "Filter aplikasi CLI",
"engine": "Engine",
"cliAppsCatalog": "Katalog",
"cliAppsFilter": "Saring",
"engine": "Mesin",
"logs": "Log",
"diagnostics": "Diagnostik",
"contextWindow": "Context window"
"contextWindow": "Jendela konteks"
},
"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.",
"imageGeneration": "Tampilkan generate_image di chat saat penyedia gambar yang dikonfigurasi tersedia.",
"imageProvider": "Pilih penyedia registry yang digunakan oleh generate_image.",
"imageProviderStatus": "Pembuatan gambar menggunakan ulang kredensial penyedia dari Providers.",
"imageModel": "Nama model yang dikirim ke penyedia gambar yang dipilih.",
"defaultAspectRatio": "Digunakan saat prompt tidak memilih rasio aspek.",
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
"model": "Define el nombre de modelo predeterminado de nanobot.",
"configPath": "Archivo de configuración que usa actualmente el gateway.",
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
"presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.",
"density": "Hanya disimpan di browser ini.",
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
"maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
"imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
"defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
"defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.",
"maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.",
"botName": "Ditampilkan di tempat nanobot memakai nama tampilan.",
"botIcon": "Emoji atau teks pendek yang tampil bersama nama bot.",
"timezone": "Dipakai untuk jadwal dan balasan yang peka waktu.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Pilih konfigurasi model yang digunakan nanobot untuk balasan berikutnya.",
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
"cliAppsCatalog": "Jelajahi CLI aplikasi yang dapat dijalankan nanobot secara lokal.",
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
"logs": "Buka folder log native engine.",
"diagnostics": "Ekspor laporan runtime kecil untuk dukungan.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
"botName": "Se muestra donde nanobot usa un nombre visible.",
"botIcon": "Emoji o texto corto junto al nombre del bot.",
"timezone": "Se usa para horarios y respuestas con conciencia temporal.",
"localServiceAccess": "Izinkan perintah shell Full Access menjangkau layanan localhost.",
"webuiDefaultAccess": "Digunakan oleh chat web tanpa izin khusus proyek.",
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
"currentModel": "Digunakan untuk balasan baru.",
"selectedModelProvider": "Definido por el modelo seleccionado.",
"selectedModelValue": "Definido por el modelo seleccionado.",
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
"cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
"cliAppsFilter": "Busca por app, categoría o capacidad.",
"logs": "Abre la carpeta de registros del motor nativo.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
"contextWindow": "Pilih anggaran konteks default untuk konfigurasi model ini."
},
"values": {
"light": "Terang",
"dark": "Gelap",
"notAvailable": "Tidak tersedia",
"enabled": "Enabled",
"disabled": "Disabled",
"restartPending": "Menunggu restart",
"enabled": "Aktif",
"disabled": "Nonaktif",
"restartPending": "Menunggu mulai ulang",
"ready": "Siap",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured",
"comfortable": "Nyaman",
"compact": "Ringkas",
"auto": "Otomatis",
"expanded": "Diperluas",
"on": "Aktif",
"off": "Nonaktif",
"defaultPermission": "Izin default",
"fullAccess": "Akses penuh",
"configured": "Terkonfigurasi",
"notConfigured": "Belum dikonfigurasi",
"pending": "Tertunda",
"restartingEngine": "Memulai ulang"
},
"status": {
"loading": "Memuat pengaturan...",
"loadError": "Tidak dapat memuat pengaturan",
"unsaved": "Ada perubahan yang belum disimpan.",
"unsaved": "Perubahan belum disimpan.",
"upToDate": "Sudah terbaru.",
"savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.",
"restartAfterSaving": "Simpan perubahan, lalu restart saat siap.",
"savedRestartApply": "Tersimpan. Restart saat siap.",
"imageProviderRestart": "Perubahan penyedia gambar tersimpan. Restart saat siap.",
"hostRestartAfterSaving": "Simpan perubahan dan nanobot akan memulai ulang engine.",
"hostRestartPending": "Tersimpan. Engine akan dimulai ulang saat siap.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
"savedRestart": "Guardado. Reinicia nanobot para aplicar.",
"restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
"savedRestartApply": "Guardado. Reinicia cuando puedas.",
"imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
"hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
"hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
"hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
"logsOpened": "Carpeta de registros abierta.",
"logsOpenFailed": "No se pudo abrir la carpeta de registros.",
"diagnosticsExported": "Diagnóstico exportado a {{path}}.",
"diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
},
"actions": {
"save": "Simpan",
@@ -224,8 +224,8 @@
"cancel": "Batal",
"open": "Buka",
"export": "Ekspor",
"opening": "Opening...",
"exporting": "Exporting..."
"opening": "Membuka...",
"exporting": "Mengekspor..."
},
"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.",
@@ -250,10 +250,10 @@
"tabs": {
"ariaLabel": "Jenis kredensial BYOK",
"llm": "LLM",
"webSearch": "Web Search"
"webSearch": "Pencarian web"
},
"webSearch": {
"provider": "Search provider",
"provider": "Penyedia pencarian",
"providerHelp": "Pilih backend yang digunakan alat web search.",
"selectProvider": "Pilih provider",
"credentials": "Kredensial",
@@ -270,17 +270,17 @@
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"model": "Model saat ini",
"providers": "Penyedia",
"configuredCount": "{{count}} dikonfigurasi",
"totalProviders": "{{count}} tersedia",
"webSearch": "Pencarian web",
"imageGeneration": "Pembuatan gambar",
"workspace": "Workspace"
"workspace": "Ruang kerja"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"searchPlaceholder": "Cari penyedia",
"noMatches": "Tidak ada penyedia yang cocok.",
"saveProvider": "Simpan penyedia"
},
"image": {
@@ -288,7 +288,7 @@
"selectAspect": "Pilih rasio",
"selectSize": "Pilih ukuran",
"configureProvider": "Konfigurasi penyedia",
"missingCredential": "Konfigurasikan penyedia ini sebelum mengaktifkan pembuatan gambar."
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
},
"models": {
"selectModel": "Pilih model",
@@ -296,8 +296,8 @@
"newConfiguration": "Konfigurasi model baru",
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
"configurationName": "Nama konfigurasi",
"configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.",
"configurationNamePlaceholder": "Penulisan cepat",
"configurationNameHelp": "Ganti nama konfigurasi model tersimpan ini.",
"configurationNamePlaceholder": "Menulis cepat",
"searchModels": "Cari atau ketik ID model",
"useCustomModel": "Gunakan",
"loadingModels": "Memuat model...",
@@ -307,7 +307,7 @@
"loadFailed": "Daftar model tidak tersedia.",
"unsupportedModelList": "Ketik ID model secara manual.",
"providerNotConfigured": "Konfigurasikan penyedia ini sebelum memuat model.",
"autoProviderCustomOnly": "Mode penyedia otomatis menggunakan ID model khusus."
"autoProviderCustomOnly": "Mode penyedia otomatis memakai ID model kustom."
},
"timezone": {
"select": "Pilih zona waktu",
@@ -402,31 +402,31 @@
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
},
"apps": {
"description": "Tambahkan CLI app dan layanan MCP yang dapat digunakan nanobot dari chat.",
"description": "Tambahkan CLI aplikasi dan layanan MCP yang dapat digunakan nanobot dari chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Semua",
"filterCli": "App CLI",
"filterCli": "Aplikasi CLI",
"filterMcp": "Layanan MCP",
"enabledSummary": "{{count}} aktif",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Cari aplikasi",
"featured": "Unggulan",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok dengan filter ini."
"empty": "Tidak ada aplikasi yang cocok."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
"authentication": "Autentikasi OAuth",
"signIn": "Masuk",
"signingIn": "Masuk...",
"signInAgain": "Masuk lagi",
"signOut": "Keluar",
"signedInAs": "Masuk sebagai {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"signInRequired": "Perlu masuk",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sudah masuk",
"notSignedIn": "Belum masuk"
}
},
"chat": {
+107 -107
View File
@@ -65,14 +65,14 @@
"nav": {
"general": "一般",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"browser": "Web",
"overview": "概要",
"appearance": "外観",
"models": "モデル",
"providers": "プロバイダー",
"image": "画像",
"browser": "ウェブ",
"runtime": "システム",
"advanced": "Security",
"advanced": "セキュリティ",
"cliApps": "CLI アプリ",
"mcp": "MCP",
"apps": "アプリ"
@@ -81,21 +81,21 @@
"interface": "インターフェース",
"ai": "AI",
"system": "システム",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"status": "状態",
"localPreferences": "ローカル設定",
"presets": "プリセット",
"imageGeneration": "画像生成",
"imageDefaults": "既定値",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"webuiSafety": "Web safety",
"webSearch": "ウェブ検索",
"webBehavior": "動作",
"identity": "ID",
"webuiSafety": "WebUI の安全性",
"capabilities": "機能",
"cliApps": "CLI アプリ",
"mcp": "MCP サービス",
"apps": "アプリ",
"nativeHost": "App",
"hostSafety": "App safety"
"nativeHost": "ネイティブホスト",
"hostSafety": "アプリの安全性"
},
"rows": {
"theme": "テーマ",
@@ -104,41 +104,41 @@
"model": "モデル",
"restart": "nanobot を再起動",
"configPath": "設定パス",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"activePreset": "アクティブなプリセット",
"gateway": "ゲートウェイ",
"restartState": "再起動状態",
"pendingChanges": "保留中の変更",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"selectedPreset": "選択中のプリセット",
"presetModel": "プリセットモデル",
"density": "表示密度",
"activityMode": "アクティビティ詳細",
"codeWrap": "コードの折り返し",
"maxResults": "最大結果数",
"timeout": "タイムアウト",
"jinaReader": "Jina リーダー",
"imageGeneration": "画像生成",
"imageProvider": "画像プロバイダー",
"imageProviderStatus": "プロバイダー状態",
"imageProviderBase": "プロバイダーのベース URL",
"imageProviderStatus": "プロバイダー状態",
"imageProviderBase": "プロバイダー URL",
"imageModel": "画像モデル",
"defaultAspectRatio": "既定の比率",
"defaultImageSize": "既定のサイズ",
"maxImagesPerTurn": "1 ターンあたりの最大画像数",
"maxImagesPerTurn": "1 ターンの最大画像数",
"imageSaveDir": "保存先ディレクトリ",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"workspacePath": "デフォルトワークスペース",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"botName": "Bot ",
"botIcon": "Bot アイコン",
"timezone": "タイムゾーン",
"workspacePath": "既定のワークスペース",
"localServiceAccess": "ローカルサービス",
"webuiDefaultAccess": "既定の権限",
"currentModel": "現在の設定",
"brandLogos": "ブランドロゴ",
"cliAppsCatalog": "CLI アプリカタログ",
"cliAppsFilter": "CLI アプリフィルター",
"cliAppsCatalog": "カタログ",
"cliAppsFilter": "フィルター",
"engine": "エンジン",
"logs": "ログ",
"diagnostics": "診断",
"contextWindow": "Context window"
"contextWindow": "コンテキストウィンドウ"
},
"help": {
"theme": "ライト表示とダーク表示を切り替えます。",
@@ -146,57 +146,57 @@
"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.",
"imageGeneration": "設定済みの画像プロバイダーが利用できる場合、チャットで generate_image を有効にします。",
"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": "プロンプトでアスペクト比が指定されていない場合に使用します。",
"defaultAspectRatio": "プロンプトで比が指定されていない場合に使用します。",
"defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。",
"maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。",
"botName": "nanobot が表示名を使う場所に表示されます。",
"botIcon": "Bot 名の横に表示する短い emoji またはテキストです。",
"timezone": "スケジュールと時刻を考慮する返信に使用します。",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "今後の返信で nanobot が使用するモデル設定を選択します。",
"localServiceAccess": "Full Access shell コマンドが localhost サービスにアクセスできるようにします。",
"webuiDefaultAccess": "プロジェクト固有の権限がない Web チャットで使用します。",
"securityManagedControls": "Web 取得は常にローカル、プライベート、メタデータサービスを保護します。コアチャネルの安全性は config.json で管理されます。",
"currentModel": "新しい返信に使用します。",
"selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。",
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
"logs": "Appエンジンのログフォルダを開きます。",
"logs": "ネイティブエンジンのログフォルダを開きます。",
"diagnostics": "サポート用の小さなランタイムレポートを書き出します。",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
"localServiceAccessNative": "Full Access shell コマンドがこの Mac 上のサービスにアクセスできるようにします。",
"webuiDefaultAccessNative": "プロジェクト固有の権限がないネイティブチャットで使用します。",
"contextWindow": "このモデル設定で使う既定のコンテキスト予算を選択します。"
},
"values": {
"light": "ライト",
"dark": "ダーク",
"notAvailable": "利用不可",
"enabled": "Enabled",
"disabled": "Disabled",
"enabled": "有効",
"disabled": "無効",
"restartPending": "再起動待ち",
"ready": "準備完了",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured",
"comfortable": "標準",
"compact": "コンパクト",
"auto": "自動",
"expanded": "展開",
"on": "オン",
"off": "オフ",
"defaultPermission": "既定の権限",
"fullAccess": "完全アクセス",
"configured": "設定済み",
"notConfigured": "未設定",
"pending": "保留中",
"restartingEngine": "再起動中"
},
@@ -211,11 +211,11 @@
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。",
"hostRestartAfterSaving": "保存すると nanobot がエンジンを再起動します。",
"hostRestartPending": "保存しました。準備ができたらエンジンを再起動します。",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
"hostApiUnavailable": "ホスト操作はネイティブアプリ内でのみ利用できます。",
"logsOpened": "ログフォルダーを開きました。",
"logsOpenFailed": "ログフォルダーを開けませんでした。",
"diagnosticsExported": "診断を {{path}} に書き出しました。",
"diagnosticsExportFailed": "診断を書き出せませんでした。"
},
"actions": {
"save": "保存",
@@ -224,8 +224,8 @@
"cancel": "キャンセル",
"open": "開く",
"export": "書き出す",
"opening": "Opening...",
"exporting": "Exporting..."
"opening": "開いています...",
"exporting": "書き出しています..."
},
"byok": {
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "BYOK 認証情報タイプ",
"llm": "LLM",
"webSearch": "Web Search"
"webSearch": "ウェブ検索"
},
"webSearch": {
"provider": "検索 provider",
@@ -270,17 +270,17 @@
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"model": "現在のモデル",
"providers": "プロバイダー",
"configuredCount": "{{count}} 個設定済み",
"totalProviders": "{{count}} 個利用可能",
"webSearch": "Web 検索",
"imageGeneration": "画像生成",
"workspace": "Workspace"
"workspace": "ワークスペース"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"searchPlaceholder": "プロバイダーを検索",
"noMatches": "一致するプロバイダーはありません。",
"saveProvider": "プロバイダーを保存"
},
"image": {
@@ -297,12 +297,12 @@
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
"configurationName": "設定名",
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
"configurationNamePlaceholder": "高速ライティング",
"searchModels": "検索またはモデル ID を入力",
"configurationNamePlaceholder": "高速執筆",
"searchModels": "モデル ID を検索または入力",
"useCustomModel": "使用",
"loadingModels": "モデルを読み込み中...",
"searchCatalog": "プロバイダーのカタログを検索してモデルを選択します。",
"modelsAvailable": "利用可能",
"loadingModels": "モデルを読み込んでいます...",
"searchCatalog": "プロバイダーのカタログからモデルを選択します。",
"modelsAvailable": "利用可能",
"noModelResults": "一致するモデルはありません。",
"loadFailed": "モデル一覧を利用できません。",
"unsupportedModelList": "モデル ID を手動で入力してください。",
@@ -402,31 +402,31 @@
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
},
"apps": {
"description": "チャットから nanobot が使えるアプリ CLI と MCP サービスを追加します。",
"description": "nanobot がチャットで使用できる App CLI と MCP サービスを追加します。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "すべて",
"filterCli": "CLI アプリ",
"filterMcp": "MCP サービス",
"enabledSummary": "{{count}} 件有効",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"caption": "CLI {{cli}} 件 · MCP {{mcp}} ",
"searchPlaceholder": "アプリを検索",
"featured": "おすすめ",
"featured": "注目",
"loading": "アプリを読み込み中...",
"empty": "このフィルターに一致するアプリはありません。"
"empty": "一致するアプリはありません。"
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
"authentication": "OAuth 認証",
"signIn": "サインイン",
"signingIn": "サインイン中...",
"signInAgain": "再度サインイン",
"signOut": "サインアウト",
"signedInAs": "{{account}} としてサインイン済み",
"signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
"signInRequired": "サインインが必要です",
"signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。",
"signedIn": "サインイン済み",
"notSignedIn": "未サインイン"
}
},
"chat": {
+123 -123
View File
@@ -65,14 +65,14 @@
"nav": {
"general": "일반",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"browser": "Web",
"overview": "개요",
"appearance": "외관",
"models": "모델",
"providers": "제공자",
"image": "이미지",
"browser": "",
"runtime": "시스템",
"advanced": "Security",
"advanced": "보안",
"cliApps": "CLI 앱",
"mcp": "MCP",
"apps": "앱"
@@ -81,21 +81,21 @@
"interface": "인터페이스",
"ai": "AI",
"system": "시스템",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"status": "상태",
"localPreferences": "로컬 환경설정",
"presets": "프리셋",
"imageGeneration": "이미지 생성",
"imageDefaults": "기본값",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"webuiSafety": "Web safety",
"webSearch": "웹 검색",
"webBehavior": "동작",
"identity": "ID",
"webuiSafety": "WebUI 보안",
"capabilities": "기능",
"cliApps": "CLI 앱",
"mcp": "MCP 서비스",
"apps": "앱",
"nativeHost": "App",
"hostSafety": "App safety"
"nativeHost": "네이티브 호스트",
"hostSafety": "앱 보안"
},
"rows": {
"theme": "테마",
@@ -104,118 +104,118 @@
"model": "모델",
"restart": "nanobot 재시작",
"configPath": "설정 경로",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"pendingChanges": "대기 중인 변경 사항",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"activePreset": "활성 프리셋",
"gateway": "게이트웨이",
"restartState": "재시작 상태",
"pendingChanges": "대기 중인 변경",
"selectedPreset": "선택한 프리셋",
"presetModel": "프리셋 모델",
"density": "밀도",
"activityMode": "활동 상세",
"codeWrap": "코드 줄바꿈",
"maxResults": "최대 결과 수",
"timeout": "타임아웃",
"jinaReader": "Jina 리더",
"imageGeneration": "이미지 생성",
"imageProvider": "이미지 제공자",
"imageProviderStatus": "제공자 상태",
"imageProviderBase": "제공자 기준 주소",
"imageProviderBase": "제공자 주소",
"imageModel": "이미지 모델",
"defaultAspectRatio": "기본 비율",
"defaultImageSize": "기본 크기",
"maxImagesPerTurn": "턴당 최대 이미지 수",
"imageSaveDir": "저장 디렉터리",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"botName": "Bot 이름",
"botIcon": "Bot 아이콘",
"timezone": "시간대",
"workspacePath": "기본 작업공간",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"localServiceAccess": "로컬 서비스",
"webuiDefaultAccess": "기본 권한",
"currentModel": "현재 구성",
"brandLogos": "브랜드 로고",
"cliAppsCatalog": "CLI 앱 카탈로그",
"cliAppsFilter": "CLI 앱 필터",
"cliAppsCatalog": "카탈로그",
"cliAppsFilter": "필터",
"engine": "엔진",
"logs": "로그",
"diagnostics": "진단",
"contextWindow": "Context window"
"contextWindow": "컨텍스트 창"
},
"help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
"language": "WebUI에서 사용할 언어를 선택합니다.",
"provider": "새 모델 요청에 사용할 제공자를 선택합니다.",
"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.",
"imageGeneration": "구성된 이미지 제공자를 사용할 수 있을 때 채팅에서 generate_image를 노출합니다.",
"imageProvider": "generate_image 사용할 레지스트리 제공자를 선택합니다.",
"imageProviderStatus": "이미지 생성은 Providers의 제공자 자격 증명을 재사용합니다.",
"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": "nanobot이 표시 이름을 는 곳에 표시됩니다.",
"defaultAspectRatio": "프롬프트 비율을 선택하지 않을 때 사용됩니다.",
"defaultImageSize": "지원하는 제공자에 보 크기 힌트입니다.",
"maxImagesPerTurn": "한 번의 generate_image 요청에서 생성할 수 있는 이미지 상한입니다.",
"botName": "nanobot이 표시 이름을 사용하는 곳에 표시됩니다.",
"botIcon": "Bot 이름 옆에 표시할 짧은 emoji 또는 텍스트입니다.",
"timezone": "예약과 시간 인식 답변에 사용됩니다.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "nanobot이 새 답변에 사용할 모델 구성을 선택합니다.",
"selectedModelProvider": "선택한 모델에 설정됩니다.",
"selectedModelValue": "선택한 모델에 설정됩니다.",
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.",
"timezone": "일정과 시간 인식 답변에 사용됩니다.",
"localServiceAccess": "Full Access shell 명령이 localhost 서비스에 접근할 수 있게 합니다.",
"webuiDefaultAccess": "프로젝트별 권한이 없는 Web 채팅에 사용됩니다.",
"securityManagedControls": "웹 가져오기는 항상 로컬, 사설, 메타데이터 서비스를 보호합니다. 핵심 채널 보안은 config.json에서 관리됩니다.",
"currentModel": "새 응답에 사용됩니다.",
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
"logs": "App 엔진 로그 폴더를 엽니다.",
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
"diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
"localServiceAccessNative": "Full Access shell 명령이 이 Mac의 서비스에 접근할 수 있게 합니다.",
"webuiDefaultAccessNative": "프로젝트별 권한이 없는 네이티브 채팅에 사용됩니다.",
"contextWindow": "이 모델 구성의 기본 컨텍스트 예산을 선택합니다."
},
"values": {
"light": "라이트",
"dark": "다크",
"notAvailable": "사용할 수 없음",
"enabled": "Enabled",
"disabled": "Disabled",
"restartPending": "재시작 대기",
"notAvailable": "사용 불가",
"enabled": "활성화됨",
"disabled": "비활성화됨",
"restartPending": "재시작 대기",
"ready": "준비됨",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured",
"comfortable": "편안함",
"compact": "컴팩트",
"auto": "자동",
"expanded": "펼침",
"on": "켜짐",
"off": "꺼짐",
"defaultPermission": "기본 권한",
"fullAccess": "전체 접근",
"configured": "구성됨",
"notConfigured": "미구성",
"pending": "대기 중",
"restartingEngine": "다시 시작 중"
"restartingEngine": "시작 중"
},
"status": {
"loading": "설정을 불러오는 중...",
"loadError": "설정을 불러올 수 없습니다",
"unsaved": "저장되지 않은 변경 사항이 있습니다.",
"upToDate": "최신 상태입니다.",
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요.",
"savedRestart": "저장습니다. 적용하려면 nanobot을 재시작하세요.",
"restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.",
"savedRestartApply": "저장되었습니다. 준비되면 재시작하세요.",
"imageProviderRestart": "이미지 제공자 변경 사항 저장되었습니다. 준비되면 재시작하세요.",
"hostRestartAfterSaving": "저장하면 nanobot이 엔진을 다시 시작합니다.",
"hostRestartPending": "저장되었습니다. 준비되면 엔진을 다시 시작합니다.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
"savedRestartApply": "저장습니다. 준비되면 재시작하세요.",
"imageProviderRestart": "이미지 제공자 변경 사항 저장습니다. 준비되면 재시작하세요.",
"hostRestartAfterSaving": "저장하면 nanobot이 엔진을 시작합니다.",
"hostRestartPending": "저장습니다. 준비되면 엔진을 시작합니다.",
"hostApiUnavailable": "호스트 작업은 네이티브 앱 안에서만 사용할 수 있습니다.",
"logsOpened": "로그 폴더를 열었습니다.",
"logsOpenFailed": "로그 폴더를 열 수 없습니다.",
"diagnosticsExported": "진단을 {{path}}에 내보냈습니다.",
"diagnosticsExportFailed": "진단을 내보낼 수 없습니다."
},
"actions": {
"save": "저장",
@@ -224,8 +224,8 @@
"cancel": "취소",
"open": "열기",
"export": "내보내기",
"opening": "Opening...",
"exporting": "Exporting..."
"opening": "여는 중...",
"exporting": "내보내는 중..."
},
"byok": {
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "BYOK 자격 증명 유형",
"llm": "LLM",
"webSearch": "Web Search"
"webSearch": "웹 검색"
},
"webSearch": {
"provider": "검색 provider",
@@ -270,17 +270,17 @@
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"model": "현재 모델",
"providers": "제공자",
"configuredCount": "{{count}}개 구성됨",
"totalProviders": "{{count}}개 사용 가능",
"webSearch": "웹 검색",
"imageGeneration": "이미지 생성",
"workspace": "Workspace"
"workspace": "작업공간"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"searchPlaceholder": "제공자 검색",
"noMatches": "일치하는 제공자가 없습니다.",
"saveProvider": "제공자 저장"
},
"image": {
@@ -294,15 +294,15 @@
"selectModel": "모델 선택",
"addConfiguration": "구성 추가",
"newConfiguration": "새 모델 구성",
"newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
"newConfigurationHelp": "제공자와 모델을 원클릭 옵션으로 저장합니다.",
"configurationName": "구성 이름",
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
"configurationNamePlaceholder": "빠른 글쓰기",
"searchModels": "검색하거나 모델 ID 입력",
"configurationNamePlaceholder": "빠른 작성",
"searchModels": "모델 ID 검색 또는 입력",
"useCustomModel": "사용",
"loadingModels": "모델 불러오는 중...",
"searchCatalog": "제공자 카탈로그를 검색해 모델을 선택하세요.",
"modelsAvailable": "사용 가능",
"loadingModels": "모델 불러오는 중...",
"searchCatalog": "제공자 카탈로그에서 모델을 선택합니다.",
"modelsAvailable": "사용 가능",
"noModelResults": "일치하는 모델이 없습니다.",
"loadFailed": "모델 목록을 사용할 수 없습니다.",
"unsupportedModelList": "모델 ID를 직접 입력하세요.",
@@ -402,31 +402,31 @@
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
},
"apps": {
"description": "채팅에서 nanobot이 사용할 수 있는 CLI와 MCP 서비스를 추가합니다.",
"description": "nanobot이 채팅에서 사용할 수 있는 App CLI와 MCP 서비스를 추가합니다.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "전체",
"filterCli": "CLI 앱",
"filterMcp": "MCP 서비스",
"enabledSummary": "{{count}}개 활성화됨",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"caption": "CLI {{cli}}개 · MCP {{mcp}}",
"searchPlaceholder": "앱 검색",
"featured": "추천",
"loading": "앱 불러오는 중...",
"empty": "이 필터와 일치하는 앱이 없습니다."
"loading": "앱 불러오는 중...",
"empty": "일치하는 앱이 없습니다."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
"authentication": "OAuth 인증",
"signIn": "로그인",
"signingIn": "로그인 중...",
"signInAgain": "다시 로그인",
"signOut": "로그아웃",
"signedInAs": "{{account}}로 로그인됨",
"signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
"signInRequired": "로그인이 필요합니다",
"signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.",
"signedIn": "로그인됨",
"notSignedIn": "로그인 안 됨"
}
},
"chat": {
+134 -134
View File
@@ -57,22 +57,22 @@
"apps": "Ứng dụng"
},
"settings": {
"backToChat": "Quay lại trò chuyện",
"backToChat": "Quay lại chat",
"sidebar": {
"title": "Cài đặt",
"ariaLabel": "Các mục cài đặt"
"ariaLabel": "Mục cài đặt"
},
"nav": {
"general": "Chung",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"browser": "Web",
"overview": "Tổng quan",
"appearance": "Giao diện",
"models": "Mô hình",
"providers": "Nhà cung cấp",
"image": "Hình ảnh",
"browser": "Trang web",
"runtime": "Hệ thống",
"advanced": "Security",
"advanced": "Bảo mật",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP",
"apps": "Ứng dụng"
@@ -81,122 +81,122 @@
"interface": "Giao diện",
"ai": "AI",
"system": "Hệ thống",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"imageGeneration": "Tạo ảnh",
"status": "Trạng thái",
"localPreferences": "Tùy chọn cục bộ",
"presets": "Preset",
"imageGeneration": "Tạo hình ảnh",
"imageDefaults": "Mặc định",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"webuiSafety": "Web safety",
"webSearch": "Tìm kiếm web",
"webBehavior": "Hành vi",
"identity": "Danh tính",
"webuiSafety": "An toàn WebUI",
"capabilities": "Khả năng",
"cliApps": "Ứng dụng CLI",
"mcp": "Dịch vụ MCP",
"apps": "Ứng dụng",
"nativeHost": "Native host",
"hostSafety": "App safety"
"nativeHost": "Host gốc",
"hostSafety": "An toàn ứng dụng"
},
"rows": {
"theme": "Giao diện",
"theme": "Chủ đề",
"language": "Ngôn ngữ",
"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",
"pendingChanges": "Thay đổi đang chờ",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"imageGeneration": "Tạo ảnh",
"imageProvider": "Nhà cung cấp ảnh",
"activePreset": "Preset đang dùng",
"gateway": "Cổng",
"restartState": "Trạng thái khởi động lại",
"pendingChanges": "Thay đổi chờ áp dụng",
"selectedPreset": "Preset đã chọn",
"presetModel": "Mô hình preset",
"density": "Mật độ",
"activityMode": "Chi tiết hoạt động",
"codeWrap": "Xuống dòng mã",
"maxResults": "Kết quả tối đa",
"timeout": "Thời gian chờ",
"jinaReader": "Trình đọc Jina",
"imageGeneration": "Tạo hình ảnh",
"imageProvider": "Nhà cung cấp hình ảnh",
"imageProviderStatus": "Trạng thái nhà cung cấp",
"imageProviderBase": "Cơ sở nhà cung cấp",
"imageModel": "Mô hình ảnh",
"imageProviderBase": "Địa chỉ nhà cung cấp",
"imageModel": "Mô hình hình ảnh",
"defaultAspectRatio": "Tỷ lệ mặc định",
"defaultImageSize": "Kích thước mặc định",
"maxImagesPerTurn": "Số ảnh tối đa mỗi lượt",
"maxImagesPerTurn": "nh tối đa mỗi lượt",
"imageSaveDir": "Thư mục lưu",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"botName": "Tên bot",
"botIcon": "Biểu tượng bot",
"timezone": "Múi giờ",
"workspacePath": "Workspace mặc định",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"localServiceAccess": "Dịch vụ cục bộ",
"webuiDefaultAccess": "Quyền mặc định",
"currentModel": "Cấu hình hiện tại",
"brandLogos": "Logo thương hiệu",
"cliAppsCatalog": "Danh mục ứng dụng CLI",
"cliAppsFilter": "Bộ lọc ứng dụng CLI",
"engine": "Engine",
"cliAppsCatalog": "Danh mục",
"cliAppsFilter": "Bộ lọc",
"engine": "Bộ máy",
"logs": "Nhật ký",
"diagnostics": "Chẩn đoán",
"contextWindow": "Context window"
"contextWindow": "Cửa sổ ngữ cả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.",
"imageGeneration": "Hiển thị generate_image trong chat khi có nhà cung cấp ảnh đã cấu hình.",
"imageProvider": "Chọn nhà cung cấp registry được generate_image sử dụng.",
"imageProviderStatus": "Tạo ảnh dùng lại thông tin xác thực nhà cung cấp từ Providers.",
"imageModel": "Tên mô hình gửi tới nhà cung cấp ảnh đã chọn.",
"defaultAspectRatio": "Được dùng khi prompt không chọn tỷ lệ khung hình.",
"defaultImageSize": "Gợi ý kích thước gửi tới các nhà cung cấp hỗ trợ.",
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
"model": "Define el nombre de modelo predeterminado de nanobot.",
"configPath": "Archivo de configuración que usa actualmente el gateway.",
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
"presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.",
"density": "Chỉ lưu trong trình duyệt này.",
"activityMode": "Chọn mức chi tiết hoạt động agent hiển thị mặc định.",
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
"maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
"imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
"defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
"defaultImageSize": "Gợi ý kích thước gửi tới nhà cung cấp hỗ trợ.",
"maxImagesPerTurn": "Giới hạn trên cho một yêu cầu generate_image.",
"botName": "Hiển thị ở nơi nanobot dùng tên hiển thị.",
"botIcon": "Emoji hoặc văn bản ngắn hiển thị cùng tên bot.",
"timezone": "Dùng cho lịch hẹn và câu trả lời có yếu tố thời gian.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Chọn cấu hình mô hình nanobot dùng cho các câu trả lời mới.",
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
"brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
"cliAppsCatalog": "Duyệt các CLI ứng dụng mà nanobot có thể chạy cục bộ.",
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
"logs": "Mở thư mục nhật ký native engine.",
"diagnostics": "Xuất báo cáo runtime nhỏ để hỗ trợ.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
"botName": "Se muestra donde nanobot usa un nombre visible.",
"botIcon": "Emoji o texto corto junto al nombre del bot.",
"timezone": "Se usa para horarios y respuestas con conciencia temporal.",
"localServiceAccess": "Cho phép lệnh shell Full Access truy cập dịch vụ localhost.",
"webuiDefaultAccess": "Dùng cho chat web không có quyền riêng theo dự án.",
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
"currentModel": "Dùng cho các phản hồi mới.",
"selectedModelProvider": "Definido por el modelo seleccionado.",
"selectedModelValue": "Definido por el modelo seleccionado.",
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
"cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
"cliAppsFilter": "Busca por app, categoría o capacidad.",
"logs": "Abre la carpeta de registros del motor nativo.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
"contextWindow": "Chọn ngân sách ngữ cảnh mặc định cho cấu hình mô hình này."
},
"values": {
"light": "Sáng",
"dark": "Tối",
"notAvailable": "Không khả dụng",
"enabled": "Enabled",
"disabled": "Disabled",
"restartPending": "Đang chờ khởi động lại",
"enabled": "Đã bật",
"disabled": "Đã tắt",
"restartPending": "Chờ khởi động lại",
"ready": "Sẵn sàng",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured",
"comfortable": "Thoải mái",
"compact": "Gọn",
"auto": "Tự động",
"expanded": "Mở rộng",
"on": "Bật",
"off": "Tắt",
"defaultPermission": "Quyền mặc định",
"fullAccess": "Toàn quyền",
"configured": "Đã cấu hình",
"notConfigured": "Chưa cấu hình",
"pending": "Đang chờ",
"restartingEngine": "Đang khởi động lại"
},
@@ -205,17 +205,17 @@
"loadError": "Không thể tải cài đặt",
"unsaved": "Có thay đổi chưa lưu.",
"upToDate": "Đã cập nhật.",
"savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng.",
"restartAfterSaving": "Lưu thay đổi, rồi khởi động lại khi sẵn sàng.",
"savedRestartApply": "Đã lưu. Khởi động lại khi sẵn sàng.",
"imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng.",
"hostRestartAfterSaving": "Lưu thay đổi và nanobot sẽ khởi động lại engine.",
"hostRestartPending": "Đã lưu. Sẽ khởi động lại engine khi sẵn sàng.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
"savedRestart": "Guardado. Reinicia nanobot para aplicar.",
"restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
"savedRestartApply": "Guardado. Reinicia cuando puedas.",
"imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
"hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
"hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
"hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
"logsOpened": "Carpeta de registros abierta.",
"logsOpenFailed": "No se pudo abrir la carpeta de registros.",
"diagnosticsExported": "Diagnóstico exportado a {{path}}.",
"diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
},
"actions": {
"save": "Lưu",
@@ -224,8 +224,8 @@
"cancel": "Hủy",
"open": "Mở",
"export": "Xuất",
"opening": "Opening...",
"exporting": "Exporting..."
"opening": "Đang mở...",
"exporting": "Đang xuất..."
},
"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.",
@@ -250,10 +250,10 @@
"tabs": {
"ariaLabel": "Loại thông tin xác thực BYOK",
"llm": "LLM",
"webSearch": "Web Search"
"webSearch": "Tìm kiếm web"
},
"webSearch": {
"provider": "Search provider",
"provider": "Nhà cung cấp tìm kiếm",
"providerHelp": "Chọn backend mà công cụ web search sẽ dùng.",
"selectProvider": "Chọn provider",
"credentials": "Thông tin xác thực",
@@ -270,17 +270,17 @@
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"imageGeneration": "Tạo ảnh",
"workspace": "Workspace"
"model": "Mô hình hiện tại",
"providers": "Nhà cung cấp",
"configuredCount": "{{count}} đã cấu hình",
"totalProviders": "{{count}} khả dụng",
"webSearch": "Tìm kiếm web",
"imageGeneration": "Tạo hình ảnh",
"workspace": "Không gian làm việc"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"searchPlaceholder": "Tìm nhà cung cấp",
"noMatches": "Không có nhà cung cấp phù hợp.",
"saveProvider": "Lưu nhà cung cấp"
},
"image": {
@@ -288,17 +288,17 @@
"selectAspect": "Chọn tỷ lệ",
"selectSize": "Chọn kích thước",
"configureProvider": "Cấu hình nhà cung cấp",
"missingCredential": "Cấu hình nhà cung cấp này trước khi bật tạo ảnh."
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
},
"models": {
"selectModel": "Chọn mô hình",
"addConfiguration": "Thêm cấu hình",
"newConfiguration": "Cấu hình mô hình mới",
"newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
"newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn nhanh.",
"configurationName": "Tên cấu hình",
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
"configurationNamePlaceholder": "Viết nhanh",
"searchModels": "Tìm hoặc nhập ID mô hình",
"searchModels": "Tìm kiếm hoặc nhập ID mô hình",
"useCustomModel": "Dùng",
"loadingModels": "Đang tải mô hình...",
"searchCatalog": "Tìm trong danh mục nhà cung cấp để chọn mô hình.",
@@ -306,7 +306,7 @@
"noModelResults": "Không có mô hình phù hợp.",
"loadFailed": "Không tải được danh sách mô hình.",
"unsupportedModelList": "Nhập ID mô hình thủ công.",
"providerNotConfigured": "Cấu hình nhà cung cấp này trước khi tải mô hình.",
"providerNotConfigured": "Hãy cấu hình nhà cung cấp này trước khi tải mô hình.",
"autoProviderCustomOnly": "Chế độ nhà cung cấp tự động dùng ID mô hình tùy chỉnh."
},
"timezone": {
@@ -402,7 +402,7 @@
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
},
"apps": {
"description": "Thêm CLI ứng dụng và dịch vụ MCP để nanobot dùng trong trò chuyện.",
"description": "Thêm CLI ứng dụng và dịch vụ MCP nanobot có thể dùng trong chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tất cả",
@@ -413,20 +413,20 @@
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Nổi bật",
"loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng nào khớp với bộ lọc này."
"empty": "Không có ứng dụng phù hợp."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
"authentication": "Xác thực OAuth",
"signIn": "Đăng nhập",
"signingIn": "Đang đăng nhập...",
"signInAgain": "Đăng nhập lại",
"signOut": "Đăng xuất",
"signedInAs": "Đã đăng nhập bằng {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"signInRequired": "Cần đăng nhập",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Đã đăng nhập",
"notSignedIn": "Chưa đăng nhập"
}
},
"chat": {
+73 -73
View File
@@ -57,7 +57,7 @@
"apps": "应用"
},
"settings": {
"backToChat": "返回对话",
"backToChat": "返回聊天",
"sidebar": {
"title": "设置",
"ariaLabel": "设置分区"
@@ -91,30 +91,30 @@
"cliApps": "CLI 应用",
"mcp": "MCP 服务",
"identity": "身份",
"webuiSafety": "网页端安全",
"webuiSafety": "WebUI 安全",
"capabilities": "能力",
"apps": "应用",
"nativeHost": "App",
"hostSafety": "App 安全"
"nativeHost": "原生宿主",
"hostSafety": "应用安全"
},
"models": {
"selectModel": "选择模型",
"addConfiguration": "添加配置",
"newConfiguration": "新建模型配置",
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
"newConfigurationHelp": "把提供商和模型保存成一键选项。",
"configurationName": "配置名称",
"configurationNameHelp": "重命名这个已保存的模型配置。",
"configurationNamePlaceholder": "快速写作",
"searchModels": "搜索或输入模型 ID",
"useCustomModel": "使用",
"loadingModels": "正在加载模型...",
"searchCatalog": "搜索服务商目录来选择模型。",
"modelsAvailable": "可用",
"searchCatalog": "搜索提供商目录来选择模型。",
"modelsAvailable": "可用",
"noModelResults": "没有匹配的模型。",
"loadFailed": "模型列表不可用。",
"loadFailed": "模型列表不可用。",
"unsupportedModelList": "手动输入模型 ID。",
"providerNotConfigured": "先配置这个服务商再加载模型。",
"autoProviderCustomOnly": "自动服务商模式使用自定义模型 ID。"
"providerNotConfigured": "加载模型前请先配置此提供商。",
"autoProviderCustomOnly": "自动提供商模式使用自定义模型 ID。"
},
"rows": {
"theme": "主题",
@@ -126,25 +126,25 @@
"activePreset": "当前预设",
"gateway": "网关",
"restartState": "重启状态",
"pendingChanges": "待处理更改",
"pendingChanges": "待应用更改",
"currentModel": "当前配置",
"selectedPreset": "选中的预设",
"presetModel": "预设模型",
"density": "密度",
"activityMode": "活动细节",
"activityMode": "活动详情",
"codeWrap": "代码换行",
"brandLogos": "品牌 Logo",
"maxResults": "最大结果数",
"timeout": "超时",
"jinaReader": "Jina Reader",
"jinaReader": "Jina 阅读器",
"imageGeneration": "图片生成",
"imageProvider": "图片服务商",
"imageProviderStatus": "服务商状态",
"imageProviderBase": "服务商地址",
"imageProvider": "图片提供商",
"imageProviderStatus": "提供商状态",
"imageProviderBase": "提供商地址",
"imageModel": "图片模型",
"defaultAspectRatio": "默认比例",
"defaultImageSize": "默认尺寸",
"maxImagesPerTurn": "每轮最图片数",
"maxImagesPerTurn": "每轮最图片数",
"imageSaveDir": "保存目录",
"botName": "Bot 名称",
"botIcon": "Bot 图标",
@@ -162,41 +162,41 @@
"help": {
"theme": "在浅色和深色外观之间切换。",
"language": "选择 WebUI 使用的语言。",
"provider": "选择新模型请求使用的服务商。",
"provider": "选择处理新模型请求的提供商。",
"model": "设置 nanobot 默认使用的模型名称。",
"configPath": "当前网关正在使用的配置文件。",
"currentModel": "选择 nanobot 接下来回复时使用的模型配置。",
"selectedModelProvider": "由当前模型决定。",
"selectedModelValue": "由当前模型决定。",
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
"presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。",
"density": "保存在当前浏览器。",
"currentModel": "用于新的回复。",
"selectedModelProvider": "由选中的模型决定。",
"selectedModelValue": "由选中的模型决定。",
"selectedPreset": "命名预设在这里只读;请在 config.json 中编辑。",
"presetModel": "切回 Default 后可在 WebUI 编辑模型和提供商。",
"density": "保存在浏览器。",
"activityMode": "选择默认显示多少 agent 活动细节。",
"codeWrap": "让较小屏幕上的长代码行更易读。",
"brandLogos": "在设置显示第三方服务商和 CLI 的 Logo。",
"maxResults": "每次 web_search 返回的结果数。",
"timeout": "搜索服务商请求超时秒数。",
"codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置显示第三方提供商和 CLI 图标。",
"maxResults": "每次 web_search 调用返回的结果数。",
"timeout": "搜索提供商请求超时前的秒数。",
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
"imageGeneration": "当已配置图片服务商时,在对话中开放 generate_image。",
"imageProvider": "选择 generate_image 使用的服务商。",
"imageProviderStatus": "图片生成复用服务商页里的凭证配置。",
"imageModel": "发送给所选图片服务商的模型名称。",
"defaultAspectRatio": "当提示词没有选择比例时使用。",
"defaultImageSize": "发送给支持该能力的服务商的尺寸提示。",
"maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。",
"botName": "显示在 nanobot 使用名称的地方。",
"botIcon": "显示在 bot 名称旁的短 emoji 或文。",
"timezone": "用于计划任务和需要时间感知的回复。",
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
"cliAppsFilter": "按应用、类或能力搜索。",
"localServiceAccess": "允许完全访问模式下的 shell 命令访问 localhost 服务。",
"webuiDefaultAccess": "用于没有单独选择权限的网页端对话。",
"imageGeneration": "配置图片提供商后,在聊天中开放 generate_image。",
"imageProvider": "选择 generate_image 使用的注册提供商。",
"imageProviderStatus": "图片生成复用「提供商」里的凭据。",
"imageModel": "发送给所选图片提供商的模型名称。",
"defaultAspectRatio": "当提示词没有指定比例时使用。",
"defaultImageSize": "发送给支持此选项的提供商的尺寸提示。",
"maxImagesPerTurn": "单次 generate_image 请求可生成的图片上限。",
"botName": "显示在 nanobot 使用展示名称的地方。",
"botIcon": "显示在 Bot 名称旁的短 emoji 或文。",
"timezone": "用于日程和需要时间感知的回复。",
"cliAppsCatalog": "只安装 nanobot 可本地运行的应用 CLI 适配器;不会改动原生应用。",
"cliAppsFilter": "按应用、类或能力搜索。",
"localServiceAccess": "允许完全访问权限下的 shell 命令访问 localhost 服务。",
"webuiDefaultAccess": "用于没有单独项目权限的 Web 聊天。",
"securityManagedControls": "网页抓取始终保护本机、内网和元数据服务。核心渠道安全仍由 config.json 管理。",
"logs": "打开App引擎日志文件夹。",
"diagnostics": "导出一份用于支持排查的运行报告。",
"localServiceAccessNative": "允许完全访问模式下的 shell 命令访问这台 Mac 上的服务。",
"webuiDefaultAccessNative": "用于没有单独选择权限的原生 App 对话。",
"contextWindow": "选择这个模型配置默认使用的上下文预算。"
"logs": "打开原生引擎日志文件夹。",
"diagnostics": "导出一份用于支持排查的小型运行报告。",
"localServiceAccessNative": "允许完全访问权限下的 shell 命令访问这台 Mac 上的服务。",
"webuiDefaultAccessNative": "用于没有单独项目权限的原生聊天。",
"contextWindow": "选择模型配置默认上下文预算。"
},
"timezone": {
"select": "选择时区",
@@ -251,9 +251,9 @@
"serverUrl": "URL",
"transport": "传输方式",
"command": "命令",
"args": "参数 JSON",
"args": "Args JSON",
"headers": "Headers JSON",
"env": "环境变量 JSON",
"env": "Env JSON",
"timeout": "工具超时",
"advancedOptions": "高级选项",
"hideAdvanced": "收起高级",
@@ -299,13 +299,13 @@
"compact": "紧凑",
"auto": "自动",
"expanded": "展开",
"on": "开",
"off": "关",
"on": "开",
"off": "关",
"defaultPermission": "默认权限",
"fullAccess": "完全访问",
"fullAccess": "完全访问权限",
"configured": "已配置",
"notConfigured": "未配置",
"pending": "待应用",
"pending": "等待中",
"restartingEngine": "正在重启"
},
"status": {
@@ -314,12 +314,12 @@
"unsaved": "有未保存的更改。",
"upToDate": "已是最新。",
"savedRestart": "已保存。重启 nanobot 后生效。",
"restartAfterSaving": "保存后,在合适时重启。",
"savedRestartApply": "已保存,可稍后重启。",
"imageProviderRestart": "图片服务商改动已保存,可稍后重启。",
"hostRestartAfterSaving": "保存后nanobot 会自动重启引擎。",
"hostRestartPending": "已保存,将在合适时重启引擎。",
"hostApiUnavailable": "宿主操作只能在原生 App 内使用。",
"restartAfterSaving": "保存更改后,在合适时重启。",
"savedRestartApply": "已保存。准备好后重启。",
"imageProviderRestart": "图片提供商更改已保存。准备好后重启。",
"hostRestartAfterSaving": "保存后 nanobot 会重启引擎。",
"hostRestartPending": "已保存。准备好后会重启引擎。",
"hostApiUnavailable": "宿主操作在原生应用中可用。",
"logsOpened": "已打开日志文件夹。",
"logsOpenFailed": "无法打开日志文件夹。",
"diagnosticsExported": "诊断已导出到 {{path}}。",
@@ -327,13 +327,13 @@
},
"actions": {
"save": "保存",
"saving": "保存",
"saving": "正在保存",
"edit": "编辑",
"cancel": "取消",
"open": "打开",
"export": "导出",
"opening": "打开...",
"exporting": "导出..."
"opening": "正在打开...",
"exporting": "正在导出..."
},
"byok": {
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
@@ -387,19 +387,19 @@
"workspace": "工作区"
},
"providers": {
"searchPlaceholder": "搜索服务商",
"noMatches": "没有匹配的服务商。",
"saveProvider": "保存服务商"
"searchPlaceholder": "搜索提供商",
"noMatches": "没有匹配的提供商。",
"saveProvider": "保存提供商"
},
"legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
},
"image": {
"selectProvider": "选择服务商",
"selectProvider": "选择提供商",
"selectAspect": "选择比例",
"selectSize": "选择尺寸",
"configureProvider": "配置服务商",
"missingCredential": "启用图片生成前请先配置这个服务商。"
"configureProvider": "配置提供商",
"missingCredential": "启用图片生成前请先配置此提供商。"
},
"apps": {
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。",
@@ -409,22 +409,22 @@
"filterCli": "CLI 应用",
"filterMcp": "MCP 服务",
"enabledSummary": "已启用 {{count}} 个",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜索应用",
"featured": "精选",
"loading": "正在加载应用...",
"empty": "没有符合筛选条件的应用。"
"empty": "没有匹配的应用。"
},
"oauth": {
"authentication": "OAuth 认证",
"signIn": "登录",
"signingIn": "正在登录…",
"signingIn": "登录中...",
"signInAgain": "重新登录",
"signOut": "退出登录",
"signedInAs": "已登录为 {{account}}",
"signInHelp": "这台设备登录;不会 API key 写入配置。",
"signInHelp": "这台设备登录;不会在配置中保存 API key。",
"signInRequired": "需要登录",
"signInBeforeSaving": "先登录这个 OAuth 提供商,然后再保存为当前模型提供商。",
"signInBeforeSaving": "将此 OAuth 提供商为当前模型提供商前,请先登录。",
"signedIn": "已登录",
"notSignedIn": "未登录"
}
+131 -131
View File
@@ -57,7 +57,7 @@
"apps": "應用"
},
"settings": {
"backToChat": "返回對話",
"backToChat": "返回聊天",
"sidebar": {
"title": "設定",
"ariaLabel": "設定分區"
@@ -65,14 +65,14 @@
"nav": {
"general": "一般",
"byok": "BYOK",
"overview": "Overview",
"appearance": "Appearance",
"models": "Models",
"providers": "Providers",
"image": "Image",
"browser": "Web",
"overview": "概覽",
"appearance": "外觀",
"models": "模型",
"providers": "提供商",
"image": "圖片",
"browser": "網頁",
"runtime": "系統",
"advanced": "Security",
"advanced": "安全",
"cliApps": "CLI 應用",
"mcp": "MCP",
"apps": "應用"
@@ -81,60 +81,60 @@
"interface": "介面",
"ai": "AI",
"system": "系統",
"status": "Status",
"localPreferences": "Local preferences",
"presets": "Presets",
"status": "狀態",
"localPreferences": "本機偏好",
"presets": "預設",
"imageGeneration": "圖片生成",
"imageDefaults": "預設值",
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"webuiSafety": "網頁端安全",
"capabilities": "能",
"webSearch": "網頁搜尋",
"webBehavior": "行為",
"identity": "身分",
"webuiSafety": "WebUI 安全",
"capabilities": "能",
"cliApps": "CLI 應用",
"mcp": "MCP 服務",
"apps": "應用",
"nativeHost": "App",
"nativeHost": "原生宿主",
"hostSafety": "App 安全"
},
"rows": {
"theme": "主題",
"language": "語言",
"provider": "提供者",
"provider": "供應商",
"model": "模型",
"restart": "重新啟動 nanobot",
"configPath": "設定檔路徑",
"activePreset": "Active preset",
"gateway": "Gateway",
"restartState": "Restart state",
"pendingChanges": "待處理變更",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"configPath": "配置路徑",
"activePreset": "目前預設",
"gateway": "閘道",
"restartState": "重啟狀態",
"pendingChanges": "待套用更改",
"selectedPreset": "已選預設",
"presetModel": "預設模型",
"density": "密度",
"activityMode": "活動細節",
"codeWrap": "程式碼換行",
"maxResults": "最大結果數",
"timeout": "逾時",
"jinaReader": "Jina 閱讀器",
"imageGeneration": "圖片生成",
"imageProvider": "圖片服務商",
"imageProviderStatus": "服務商狀態",
"imageProviderBase": "服務商位址",
"imageProvider": "圖片供應商",
"imageProviderStatus": "供應商狀態",
"imageProviderBase": "供應商位址",
"imageModel": "圖片模型",
"defaultAspectRatio": "預設比例",
"defaultImageSize": "預設尺寸",
"maxImagesPerTurn": "每輪最圖片數",
"maxImagesPerTurn": "每輪最圖片數",
"imageSaveDir": "儲存目錄",
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"botName": "Bot 名稱",
"botIcon": "Bot 圖示",
"timezone": "時區",
"workspacePath": "預設工作區",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"localServiceAccess": "本機服務",
"webuiDefaultAccess": "預設權限",
"currentModel": "目前設定",
"brandLogos": "品牌標誌",
"cliAppsCatalog": "CLI 應用目錄",
"cliAppsFilter": "CLI 應用篩選",
"brandLogos": "品牌 Logo",
"cliAppsCatalog": "目錄",
"cliAppsFilter": "篩選",
"engine": "引擎",
"logs": "日誌",
"diagnostics": "診斷",
@@ -143,75 +143,75 @@
"help": {
"theme": "在淺色與深色外觀之間切換。",
"language": "選擇 WebUI 使用的語言。",
"provider": "選擇新模型請求使用的服務提供者。",
"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.",
"imageGeneration": "當已設定圖片服務商時,在對話中開放 generate_image。",
"imageProvider": "選擇 generate_image 使用的登錄服務商。",
"imageProviderStatus": "圖片生成會重用「服務商」中的憑證。",
"imageModel": "傳送給所選圖片服務商的模型名稱。",
"defaultAspectRatio": "提示詞未指定長寬比時使用。",
"defaultImageSize": "傳送給支援此功能的服務商的尺寸提示。",
"maxImagesPerTurn": "單次 generate_image 請求上限。",
"botName": "顯示在 nanobot 使用名稱的地方。",
"botIcon": "顯示在 bot 名稱旁的短 emoji 或文字。",
"timezone": "用於排程需要時間感知的回覆。",
"localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。",
"webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "選擇 nanobot 接下來回覆時使用的模型設定。",
"selectedModelProvider": "由目前模型決定。",
"selectedModelValue": "由目前模型決定。",
"brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
"cliAppsCatalog": "瀏覽 nanobot 可在本機執行的應用 CLI。",
"cliAppsFilter": "按應用、類或能力搜尋。",
"logs": "開啟App引擎日誌資料夾。",
"diagnostics": "匯出一份支援排查用的執行階段報告。",
"localServiceAccessNative": "允許完全存取模式下的 shell 命令存取這台 Mac 上的服務。",
"webuiDefaultAccessNative": "用於沒有單獨選擇權限的原生 App 對話。",
"contextWindow": "選擇這個模型設定預設使用的上下文預算。"
"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": "顯示在 nanobot 使用顯示名稱的位置。",
"botIcon": "顯示在 Bot 名稱旁的短 emoji 或文字。",
"timezone": "用於排程需要時間感知的回覆。",
"localServiceAccess": "允許完全訪問權限下的 shell 命令訪問 localhost 服務。",
"webuiDefaultAccess": "用於沒有單獨專案權限的 Web 聊天。",
"securityManagedControls": "網頁抓取始終保護本機、內網和 metadata 服務。核心渠道安全仍由 config.json 管理。",
"currentModel": "用於新的回覆。",
"selectedModelProvider": "由選取的模型決定。",
"selectedModelValue": "由選取的模型決定。",
"brandLogos": "在設定中顯示第三方提供商和 CLI 圖示。",
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用 CLI 轉接器;不會改動原生應用。",
"cliAppsFilter": "按應用、類或能力搜尋。",
"logs": "開啟原生引擎日誌資料夾。",
"diagnostics": "匯出一份用於支援排查的小型執行報告。",
"localServiceAccessNative": "允許完全訪問權限下的 shell 命令訪問這台 Mac 上的服務。",
"webuiDefaultAccessNative": "用於沒有單獨專案權限的原生聊天。",
"contextWindow": "選擇此模型配置的預設上下文預算。"
},
"values": {
"light": "淺色",
"dark": "深色",
"notAvailable": "不可用",
"enabled": "Enabled",
"disabled": "Disabled",
"restartPending": "等待重新啟動",
"enabled": "已啟用",
"disabled": "已停用",
"restartPending": "等待重",
"ready": "就緒",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured",
"pending": "待套用",
"restartingEngine": "正在重新啟動"
"comfortable": "舒適",
"compact": "緊湊",
"auto": "自動",
"expanded": "展開",
"on": "開啟",
"off": "關閉",
"defaultPermission": "預設權限",
"fullAccess": "完全訪問權限",
"configured": "已配置",
"notConfigured": "未配置",
"pending": "等待中",
"restartingEngine": "正在重"
},
"status": {
"loading": "正在載入設定...",
"loadError": "無法載入設定",
"unsaved": "有未儲存的更。",
"unsaved": "有未儲存的更。",
"upToDate": "已是最新。",
"savedRestart": "已儲存。重新啟動 nanobot 後生效。",
"restartAfterSaving": "儲存變更後,可在準備好時重新啟動。",
"savedRestartApply": "已儲存,可在準備好時重新啟動。",
"imageProviderRestart": "圖片服務商變更已儲存,可在準備好時重新啟動。",
"hostRestartAfterSaving": "儲存後nanobot 會自動重新啟動引擎。",
"hostRestartPending": "已儲存,將在適當時重新啟動引擎。",
"hostApiUnavailable": "宿主操作只能在原生 App 內使用。",
"restartAfterSaving": "儲存更改後,在合適時重啟。",
"savedRestartApply": "已儲存。準備好後重啟。",
"imageProviderRestart": "圖片供應商更改已儲存。準備好後重啟。",
"hostRestartAfterSaving": "儲存後 nanobot 會重啟引擎。",
"hostRestartPending": "已儲存。準備好後會重啟引擎。",
"hostApiUnavailable": "宿主操作在原生應用中可用。",
"logsOpened": "已開啟日誌資料夾。",
"logsOpenFailed": "無法開啟日誌資料夾。",
"diagnosticsExported": "診斷已匯出到 {{path}}。",
@@ -219,13 +219,13 @@
},
"actions": {
"save": "儲存",
"saving": "儲存",
"saving": "正在儲存",
"edit": "編輯",
"cancel": "取消",
"open": "開啟",
"export": "匯出",
"opening": "開啟...",
"exporting": "匯出..."
"opening": "正在開啟...",
"exporting": "正在匯出..."
},
"byok": {
"description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "BYOK 憑證類型",
"llm": "LLM",
"webSearch": "Web Search"
"webSearch": "網頁搜尋"
},
"webSearch": {
"provider": "搜尋 provider",
@@ -270,44 +270,44 @@
}
},
"overview": {
"model": "Current model",
"providers": "Providers",
"configuredCount": "{{count}} configured",
"totalProviders": "{{count}} available",
"webSearch": "Web search",
"model": "目前模型",
"providers": "供應商",
"configuredCount": "已配置 {{count}} ",
"totalProviders": "{{count}} 個可用",
"webSearch": "網頁搜尋",
"imageGeneration": "圖片生成",
"workspace": "Workspace"
"workspace": "工作區"
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
"saveProvider": "儲存服務商"
"searchPlaceholder": "搜尋供應商",
"noMatches": "沒有符合的供應商。",
"saveProvider": "儲存供應商"
},
"image": {
"selectProvider": "選擇服務商",
"selectProvider": "選擇供應商",
"selectAspect": "選擇比例",
"selectSize": "選擇尺寸",
"configureProvider": "設定服務商",
"missingCredential": "啟用圖片生成前請先設定此服務商。"
"configureProvider": "配置供應商",
"missingCredential": "啟用圖片生成前請先配置此供應商。"
},
"models": {
"selectModel": "選擇模型",
"addConfiguration": "新增設定",
"newConfiguration": "新增模型設定",
"newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。",
"configurationName": "設定名稱",
"addConfiguration": "新增配置",
"newConfiguration": "新增模型配置",
"newConfigurationHelp": "將供應商與模型儲存為一選項。",
"configurationName": "配置名稱",
"configurationNameHelp": "重新命名這個已儲存的模型配置。",
"configurationNamePlaceholder": "快速寫作",
"searchModels": "搜尋或輸入模型 ID",
"useCustomModel": "使用",
"loadingModels": "正在載入模型...",
"searchCatalog": "搜尋服務商目錄來選擇模型。",
"modelsAvailable": "可用",
"searchCatalog": "搜尋供應商目錄來選擇模型。",
"modelsAvailable": "可用",
"noModelResults": "沒有符合的模型。",
"loadFailed": "模型列表不可用。",
"loadFailed": "模型列表不可用。",
"unsupportedModelList": "手動輸入模型 ID。",
"providerNotConfigured": "先設定這個服務商再載入模型。",
"autoProviderCustomOnly": "自動服務商模式使用自訂模型 ID。"
"providerNotConfigured": "載入模型前請先配置此供應商。",
"autoProviderCustomOnly": "自動供應商模式使用自訂模型 ID。"
},
"timezone": {
"select": "選擇時區",
@@ -402,29 +402,29 @@
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
},
"apps": {
"description": "新增 nanobot 可在聊天中使用的 App CLI MCP 服務。",
"description": "新增 nanobot 可在聊天中使用的 App CLI MCP 服務。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "全部",
"filterCli": "CLI 應用",
"filterMcp": "MCP 服務",
"enabledSummary": "已啟用 {{count}} 個",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜尋應用",
"featured": "精選",
"loading": "正在載入應用...",
"empty": "沒有符合篩選條件的應用。"
"empty": "沒有符合的應用。"
},
"oauth": {
"authentication": "OAuth 證",
"authentication": "OAuth 證",
"signIn": "登入",
"signingIn": "正在登入…",
"signingIn": "登入中...",
"signInAgain": "重新登入",
"signOut": "登出",
"signedInAs": "已登入為 {{account}}",
"signInHelp": "這台裝置登入;不會 API key 寫入設定。",
"signInHelp": "這台裝置登入;不會在配置中儲存 API key。",
"signInRequired": "需要登入",
"signInBeforeSaving": "請先登入這個 OAuth 提供商,再儲存為目前模型提供商。",
"signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。",
"signedIn": "已登入",
"notSignedIn": "未登入"
}
+30 -10
View File
@@ -55,8 +55,15 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
const flushTurn = () => {
if (turnMessages.length === 0) return;
const activityMessages: UIMessage[] = [];
const visibleMessages: UIMessage[] = [];
const visibleMessages = visibleMessagesForTurn(turnMessages);
let visibleIndex = 0;
let activityMessages: UIMessage[] = [];
const flushActivityMessages = () => {
if (!activityMessages.length) return;
pushActivityUnits(units, activityMessages, visibleMessages.slice(visibleIndex));
activityMessages = [];
};
for (const message of turnMessages) {
if (isAgentActivityMember(message)) {
@@ -66,19 +73,18 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
if (assistantHasInlineReasoning(message)) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
visibleMessages.push(stripInlineReasoning(message));
flushActivityMessages();
units.push({ type: "message", message: stripInlineReasoning(message) });
visibleIndex += 1;
continue;
}
visibleMessages.push(message);
}
pushActivityUnits(units, activityMessages, visibleMessages);
for (const message of visibleMessages) {
flushActivityMessages();
units.push({ type: "message", message });
visibleIndex += 1;
}
flushActivityMessages();
turnMessages = [];
};
@@ -96,9 +102,19 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
return units;
}
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
const visibleMessages: UIMessage[] = [];
for (const message of messages) {
if (isAgentActivityMember(message)) continue;
visibleMessages.push(assistantHasInlineReasoning(message) ? stripInlineReasoning(message) : message);
}
return visibleMessages;
}
function pushActivityUnits(units: TurnUnit[], activityMessages: UIMessage[], visibleMessages: UIMessage[]) {
let runMessages: UIMessage[] = [];
let runBucket: "file" | "other" | undefined;
let runSegmentId: string | undefined;
const flushRun = () => {
if (!runMessages.length) return;
@@ -110,14 +126,18 @@ function pushActivityUnits(units: TurnUnit[], activityMessages: UIMessage[], vis
});
runMessages = [];
runBucket = undefined;
runSegmentId = undefined;
};
for (const message of activityMessages) {
const bucket = isFileEditActivityMessage(message) ? "file" : "other";
if (runBucket && bucket !== runBucket) {
const segmentId = message.activitySegmentId;
const segmentChanged = !!runSegmentId && !!segmentId && runSegmentId !== segmentId;
if ((runBucket && bucket !== runBucket) || segmentChanged) {
flushRun();
}
runBucket = bucket;
if (segmentId) runSegmentId = segmentId;
runMessages.push(message);
}
+15 -4
View File
@@ -9,11 +9,20 @@ import type {
GoalStateWsPayload,
WorkspaceScopePayload,
} from "./types";
import { createHostWebSocket } from "./runtime";
/** WebSocket readyState constants, referenced by value to stay portable
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
const WS_OPEN = 1;
const WS_CLOSING = 2;
const HOST_SOCKET_URL_PREFIX = "nanobot-host://";
function createDefaultSocket(url: string): WebSocket {
if (url.startsWith(HOST_SOCKET_URL_PREFIX)) {
return createHostWebSocket(url);
}
return new WebSocket(url);
}
/** Inbound WebSocket ``console.log`` / parse-failure ``console.warn``.
*
@@ -129,7 +138,7 @@ export class NanobotClient {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly shouldReconnect: boolean;
private readonly maxBackoffMs: number;
private readonly socketFactory: (url: string) => WebSocket;
private socketFactory: (url: string) => WebSocket;
private currentUrl: string;
private status_: ConnectionStatus = "idle";
private readyChatId: string | null = null;
@@ -140,8 +149,7 @@ export class NanobotClient {
constructor(private options: NanobotClientOptions) {
this.shouldReconnect = options.reconnect ?? true;
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
this.socketFactory =
options.socketFactory ?? ((url) => new WebSocket(url));
this.socketFactory = options.socketFactory ?? createDefaultSocket;
this.currentUrl = options.url;
}
@@ -154,8 +162,11 @@ export class NanobotClient {
}
/** Swap the URL (e.g. after fetching a fresh token) then reconnect. */
updateUrl(url: string): void {
updateUrl(url: string, socketFactory?: (url: string) => WebSocket): void {
this.currentUrl = url;
if (socketFactory) {
this.socketFactory = socketFactory;
}
}
onStatus(handler: StatusHandler): Unsubscribe {
+13 -8
View File
@@ -51,6 +51,11 @@ type HostSocketBridge = Required<Pick<
"closeSocket" | "onSocketEvent" | "openSocket" | "sendSocket"
>>;
const HOST_WS_CONNECTING = 0;
const HOST_WS_OPEN = 1;
const HOST_WS_CLOSING = 2;
const HOST_WS_CLOSED = 3;
declare global {
interface Window {
nanobotHost?: NanobotHostApi;
@@ -122,7 +127,7 @@ class HostWebSocket {
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null;
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null;
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null;
readyState: number = WebSocket.CONNECTING;
readyState: number = HOST_WS_CONNECTING;
readonly url: string;
private id: string | null = null;
@@ -140,7 +145,7 @@ class HostWebSocket {
this.id = id;
},
() => {
this.readyState = WebSocket.CLOSED;
this.readyState = HOST_WS_CLOSED;
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
this.onclose?.call(this as unknown as WebSocket, closeEvent());
this.unsubscribe();
@@ -149,14 +154,14 @@ class HostWebSocket {
}
close(): void {
if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) {
if (this.readyState === HOST_WS_CLOSING || this.readyState === HOST_WS_CLOSED) {
return;
}
this.readyState = WebSocket.CLOSING;
this.readyState = HOST_WS_CLOSING;
if (this.id) {
void this.api.closeSocket(this.id);
} else {
this.readyState = WebSocket.CLOSED;
this.readyState = HOST_WS_CLOSED;
this.unsubscribe();
}
}
@@ -165,7 +170,7 @@ class HostWebSocket {
if (typeof data !== "string") {
throw new Error("Host WebSocket bridge only supports text frames");
}
if (this.readyState === WebSocket.OPEN && this.id) {
if (this.readyState === HOST_WS_OPEN && this.id) {
void this.api.sendSocket(this.id, data);
return;
}
@@ -175,7 +180,7 @@ class HostWebSocket {
private handleEvent(event: HostSocketEvent): void {
if (!this.id || event.id !== this.id) return;
if (event.type === "open") {
this.readyState = WebSocket.OPEN;
this.readyState = HOST_WS_OPEN;
this.onopen?.call(this as unknown as WebSocket, new Event("open"));
while (this.queued.length > 0 && this.id) {
const data = this.queued.shift();
@@ -194,7 +199,7 @@ class HostWebSocket {
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
return;
}
this.readyState = WebSocket.CLOSED;
this.readyState = HOST_WS_CLOSED;
this.onclose?.call(
this as unknown as WebSocket,
closeEvent(event.code, event.reason),
@@ -869,6 +869,39 @@ describe("AgentActivityCluster", () => {
expect(screen.getByText("Target text was not found in angry-birds.html.")).toBeInTheDocument();
});
it("keeps permission errors readable for failed file edits", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "write_file()",
traces: ["write_file()"],
fileEdits: [{
call_id: "call-write",
tool: "write_file",
path: "/Users/renxubin/.nanobot/workspace/agent-research-video/composition.html",
phase: "error",
added: 0,
deleted: 0,
approximate: false,
status: "error",
error: "Error writing file: [Errno 13] Permission denied: '/Users/renxubin'",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /failed composition\.html/i }));
expect(screen.getByText("No permission to change this location.")).toBeInTheDocument();
expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {
+2 -2
View File
@@ -891,9 +891,9 @@ describe("App layout", () => {
expect(screen.queryByText("AI")).not.toBeInTheDocument();
expect(screen.getByText("Current configuration")).toBeInTheDocument();
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getAllByRole("button", { name: /openai\/gpt-4o/ })[0]);
fireEvent.pointerDown(screen.getByRole("button", { name: "Current configuration" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
const modelDialog = screen.getByRole("dialog", { name: "New model configuration" });
const modelDialog = await screen.findByRole("dialog", { name: "New model configuration" });
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
target: { value: "Fast writing" },
+20
View File
@@ -12,13 +12,16 @@ const mockedStyles = vi.hoisted(() => ({
vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => ({
default: ({
children,
language,
style,
}: {
children: string;
language?: string;
style: Record<string, unknown>;
}) => (
<pre
data-testid="highlighted-code"
data-language={language}
data-theme={style === mockedStyles.dark ? "dark" : "light"}
>
<code>{children}</code>
@@ -48,6 +51,23 @@ describe("CodeBlock", () => {
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
});
it("falls back to 'text' language when language is undefined", async () => {
render(
<ThemeProvider theme="dark">
<CodeBlock language={undefined} code="const value = 1;" />
</ThemeProvider>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("highlighted-code")).toBeInTheDocument();
expect(screen.getByTestId("highlighted-code")).toHaveAttribute("data-language", "text");
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
});
it("reads theme from context without creating per-block observers", async () => {
const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn();
+68
View File
@@ -34,6 +34,60 @@ const SETTINGS_NAV_KEYS = [
"runtime",
"advanced",
];
const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.backToChat",
"settings.sidebar.title",
"settings.sidebar.ariaLabel",
"settings.nav.overview",
"settings.nav.appearance",
"settings.nav.models",
"settings.nav.providers",
"settings.nav.apps",
"settings.nav.runtime",
"settings.nav.advanced",
"settings.sections.interface",
"settings.sections.localPreferences",
"settings.sections.webSearch",
"settings.sections.webBehavior",
"settings.sections.webuiSafety",
"settings.sections.capabilities",
"settings.sections.apps",
"settings.rows.theme",
"settings.rows.language",
"settings.rows.density",
"settings.rows.activityMode",
"settings.rows.codeWrap",
"settings.rows.brandLogos",
"settings.rows.currentModel",
"settings.rows.localServiceAccess",
"settings.rows.webuiDefaultAccess",
"settings.rows.contextWindow",
"settings.help.theme",
"settings.help.language",
"settings.help.density",
"settings.help.activityMode",
"settings.help.codeWrap",
"settings.help.brandLogos",
"settings.help.currentModel",
"settings.help.localServiceAccess",
"settings.help.webuiDefaultAccess",
"settings.values.light",
"settings.values.dark",
"settings.values.comfortable",
"settings.values.compact",
"settings.values.expanded",
"settings.values.enabled",
"settings.values.disabled",
"settings.values.defaultPermission",
"settings.values.fullAccess",
"settings.values.configured",
"settings.values.notConfigured",
"settings.status.loading",
"settings.status.unsaved",
"settings.status.upToDate",
"settings.actions.save",
"settings.actions.saving",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -190,6 +244,20 @@ describe("webui i18n", () => {
}
});
it("does not leak English settings chrome into localized locales", () => {
const english = flattenResource(resources.en.common);
for (const [locale, resource] of Object.entries(resources)) {
if (locale === "en") continue;
const current = flattenResource(resource.common);
const leaked = LOCALIZED_SETTINGS_COPY_KEYS.filter(
(key) => current.get(key) === english.get(key),
);
expect({ locale, leaked }).toEqual({ locale, leaked: [] });
}
});
it("keeps Simplified Chinese settings overview copy localized", () => {
const settings = resources["zh-CN"].common.settings;
@@ -16,6 +16,18 @@ describe("MarkdownTextRenderer", () => {
expect(container.querySelector("pre div")).toBeNull();
});
it("renders bare fenced code blocks without crashing", () => {
const { container } = render(
<MarkdownTextRenderer highlightCode={false}>
{"Some text\n\n```\ncode without language\n```"}
</MarkdownTextRenderer>,
);
expect(screen.getByText("code without language")).toBeInTheDocument();
expect(screen.getByText("text")).toBeInTheDocument();
expect(container.querySelectorAll("pre")).toHaveLength(1);
});
it("keeps streaming unfinished fenced code blocks to a single shell", () => {
const { container } = render(
<MarkdownTextRenderer highlightCode={false}>
@@ -56,6 +68,47 @@ describe("MarkdownTextRenderer", () => {
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument();
});
it("renders title plus url list items as compact link rows", () => {
render(
<MarkdownTextRenderer>
{
"Sources:\n\n- Polymarket — “When will GPT-5.6 be released?”\n https://polymarket.com/event/when-will-gpt-5pt6-be-released\n- Polymarket — “GPT-5.6 released by...?”\n https://polymarket.com/event/gpt-5pt6-released-by"
}
</MarkdownTextRenderer>,
);
expect(
screen.getByRole("link", {
name: "Open link: Polymarket — When will GPT-5.6 be released?",
}),
).toHaveAttribute(
"href",
"https://polymarket.com/event/when-will-gpt-5pt6-be-released",
);
expect(
screen.getByRole("link", {
name: "Open link: Polymarket — GPT-5.6 released by...?",
}),
).toHaveAttribute("href", "https://polymarket.com/event/gpt-5pt6-released-by");
expect(screen.queryByText("Polymarket · polymarket.com")).not.toBeInTheDocument();
});
it("does not require a source heading for compact link rows", () => {
render(
<MarkdownTextRenderer>
{
"Useful links:\n\n- Polymarket — “When will GPT-5.6 be released?”\n https://polymarket.com/event/when-will-gpt-5pt6-be-released"
}
</MarkdownTextRenderer>,
);
expect(
screen.getByRole("link", {
name: "Open link: Polymarket — When will GPT-5.6 be released?",
}),
).toHaveAttribute("href", "https://polymarket.com/event/when-will-gpt-5pt6-be-released");
});
it("renders media attachments without an extra preview/code wrapper", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
+56
View File
@@ -65,6 +65,7 @@ beforeEach(() => {
});
afterEach(() => {
Reflect.deleteProperty(window, "nanobotHost");
vi.useRealTimers();
});
@@ -89,6 +90,61 @@ describe("NanobotClient", () => {
});
});
it("can swap the socket factory when the runtime URL changes", () => {
const browserFactory = vi.fn(
(url: string) => new FakeSocket(`browser:${url}`) as unknown as WebSocket,
);
const hostFactory = vi.fn(
(url: string) => new FakeSocket(`host:${url}`) as unknown as WebSocket,
);
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: browserFactory,
});
client.connect();
expect(lastSocket().url).toBe("browser:ws://test");
client.close();
client.updateUrl("nanobot-host://engine/", hostFactory);
client.connect();
expect(hostFactory).toHaveBeenCalledWith("nanobot-host://engine/");
expect(lastSocket().url).toBe("host:nanobot-host://engine/");
});
it("uses the host socket bridge for native host URLs", async () => {
let socketEventHandler:
| ((event: { id: string; type: "open" | "close" | "error"; message?: string }) => void)
| null = null;
const openSocket = vi.fn(async () => "host-socket-1");
Object.defineProperty(window, "nanobotHost", {
configurable: true,
value: {
openSocket,
sendSocket: vi.fn(async () => undefined),
closeSocket: vi.fn(async () => undefined),
onSocketEvent: vi.fn((handler) => {
socketEventHandler = handler;
return vi.fn();
}),
},
});
const client = new NanobotClient({
url: "nanobot-host://engine/",
reconnect: false,
});
const status = vi.fn();
client.onStatus(status);
client.connect();
await Promise.resolve();
socketEventHandler?.({ id: "host-socket-1", type: "open" });
expect(openSocket).toHaveBeenCalledWith("nanobot-host://engine/");
expect(status).toHaveBeenLastCalledWith("open");
});
it("buffers chat events while no chat handler is registered and replays on subscribe", () => {
const client = new NanobotClient({
url: "ws://test",
+34
View File
@@ -245,6 +245,40 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
});
it("can close the new configuration dialog without trapping the settings page", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
const configurationButton = await screen.findByRole("button", { name: "Current configuration" });
fireEvent.pointerDown(configurationButton!);
fireEvent.click(await screen.findByText("Add configuration"));
expect(await screen.findByRole("heading", { name: "New model configuration" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "New model configuration" })).not.toBeInTheDocument(),
);
expect(document.body.style.pointerEvents).not.toBe("none");
fireEvent.pointerDown(configurationButton!);
expect(await screen.findByText("Add configuration")).toBeInTheDocument();
});
it("loads provider models and lets users choose one without typing the id manually", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
+1
View File
@@ -386,6 +386,7 @@ describe("ThreadComposer", () => {
expect(status).toHaveTextContent(/2:05/);
expect(status.parentElement).toHaveClass("composer-status-strip");
expect(status.parentElement).toHaveAttribute("data-state", "enter");
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
vi.useRealTimers();
});
+60 -20
View File
@@ -102,7 +102,7 @@ describe("ThreadMessages", () => {
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
});
it("does not split ordinary tool activity just because segment ids changed", () => {
it("splits ordinary tool activity when segment ids changed", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -142,15 +142,58 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(1);
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
"r2",
"t2",
]);
});
it("renders a later tool segment after the visible answer that preceded it", () => {
const messages: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: "I should do a fresh search.",
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "a1",
role: "assistant",
content: "Let me search the latest data.",
createdAt: 2,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "Searching query: HKUDS/nanobot GitHub stars",
traces: ["Searching query: HKUDS/nanobot GitHub stars"],
activitySegmentId: "seg-2",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({
type: "message",
message: {
id: "a1",
content: "Let me search the latest data.",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
});
it("only marks the current activity timeline as live while streaming", () => {
const messages: UIMessage[] = [
{
@@ -194,7 +237,8 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument();
});
it("folds final answer reasoning into the preceding activity timeline", () => {
@@ -253,7 +297,7 @@ describe("ThreadMessages", () => {
expect(screen.getByText("final answer")).toBeInTheDocument();
});
it("keeps late activity above the live assistant answer while streaming", () => {
it("keeps late activity after the live assistant answer while streaming", () => {
const messages: UIMessage[] = [
{
id: "t0",
@@ -284,11 +328,8 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0",
"t1",
]);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
expect(units[1]).toMatchObject({
type: "message",
message: {
@@ -296,15 +337,16 @@ describe("ThreadMessages", () => {
content: "partial answer",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render(<ThreadMessages messages={messages} isStreaming />);
const activity = screen.getByRole("button", { name: /working/i });
const answer = screen.getByText("partial answer");
expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const liveActivity = screen.getByRole("button", { name: /working/i });
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("keeps late activity above a completed assistant answer", () => {
it("keeps late activity after a completed assistant answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -334,11 +376,8 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({
type: "message",
message: {
@@ -346,13 +385,14 @@ describe("ThreadMessages", () => {
content: "Hong Kong is hot today.",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render(<ThreadMessages messages={messages} isStreaming={false} />);
const activity = screen.getByText("Thought for 2m 41s");
const answer = screen.getByText("Hong Kong is hot today.");
expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getAllByText(/thought/i)).toHaveLength(1);
const laterActivity = screen.getAllByText(/thought/i).at(-1);
expect(laterActivity).toBeTruthy();
expect(answer.compareDocumentPosition(laterActivity!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("renders interrupted pre-tool text as activity before the final answer", () => {
+101 -11
View File
@@ -658,7 +658,7 @@ describe("useNanobotStream", () => {
}]);
});
it("keeps interrupted pre-tool text inside activity before the final answer", async () => {
it("keeps interrupted pre-tool text as assistant output before activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -692,9 +692,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "",
reasoning: "I created the files.",
isStreaming: false,
content: "I created the files.",
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
@@ -739,9 +737,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "",
reasoning: "I will inspect the project first.",
isStreaming: false,
content: "I will inspect the project first.",
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
@@ -755,6 +751,51 @@ describe("useNanobotStream", () => {
});
});
it("splits live assistant output around tool hints without moving it into reasoning", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-live-segments", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-live-segments", {
event: "delta",
chat_id: "chat-live-segments",
text: "Lint passed; now rendering the video.",
});
fake.emit("chat-live-segments", {
event: "message",
chat_id: "chat-live-segments",
text: 'exec({"cmd":"hyperframes render"})',
kind: "tool_hint",
});
fake.emit("chat-live-segments", {
event: "delta",
chat_id: "chat-live-segments",
text: "Rendered successfully.",
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Lint passed; now rendering the video.",
});
expect(result.current.messages[0].reasoning).toBeUndefined();
expect(result.current.messages[1]).toMatchObject({
role: "tool",
kind: "trace",
traces: ['exec({"cmd":"hyperframes render"})'],
});
expect(result.current.messages[2]).toMatchObject({
role: "assistant",
content: "Rendered successfully.",
});
expect(result.current.messages[2].reasoning).toBeUndefined();
});
it("opens a new activity segment for reasoning after file edit activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
@@ -967,7 +1008,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false);
});
it("attaches post-hoc reasoning to the same assistant turn above the answer", () => {
it("starts a new Thought block when reasoning arrives after visible output", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r5", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -988,12 +1029,61 @@ describe("useNanobotStream", () => {
fake.emit("chat-r5", { event: "reasoning_end", chat_id: "chat-r5" });
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0].content).toBe("hi~");
expect(result.current.messages[0].reasoning).toBe(
expect(result.current.messages[0].reasoning).toBeUndefined();
expect(result.current.messages[1].content).toBe("");
expect(result.current.messages[1].reasoning).toBe(
"This reasoning arrived after the answer stream.",
);
expect(result.current.messages[0].reasoningStreaming).toBe(false);
expect(result.current.messages[1].reasoningStreaming).toBe(false);
});
it("keeps alternating reasoning and answer deltas in separate ordered blocks", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r5b", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-r5b", {
event: "reasoning_delta",
chat_id: "chat-r5b",
text: "Plan first.",
});
fake.emit("chat-r5b", {
event: "delta",
chat_id: "chat-r5b",
text: "Visible progress.",
});
fake.emit("chat-r5b", {
event: "reasoning_delta",
chat_id: "chat-r5b",
text: "Think again.",
});
fake.emit("chat-r5b", {
event: "delta",
chat_id: "chat-r5b",
text: "Final visible text.",
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
reasoning: "Plan first.",
content: "Visible progress.",
});
expect(result.current.messages[1]).toMatchObject({
role: "assistant",
reasoning: "Think again.",
content: "Final visible text.",
});
expect(result.current.messages[1].activitySegmentId).not.toBe(
result.current.messages[0].activitySegmentId,
);
});
it("does not attach a new turn's reasoning across the latest user boundary", async () => {