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 3390 additions and 1317 deletions
+1
View File
@@ -5,6 +5,7 @@ __pycache__
*.egg-info *.egg-info
dist/ dist/
build/ build/
nanobot/web/dist/
.git .git
.env .env
.assets .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 @AGENTS.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.
+1 -1
View File
@@ -25,7 +25,7 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
COPY nanobot/ nanobot/ COPY nanobot/ nanobot/
COPY bridge/ bridge/ COPY bridge/ bridge/
COPY webui/ webui/ 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 # Build the WhatsApp bridge
WORKDIR /app/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"> <div align="center">
<p> <p>
@@ -31,10 +31,29 @@
</p> </p>
</div> </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 ## 📢 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-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-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. - **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-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-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. - **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-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-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. - **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
@@ -145,12 +160,13 @@
</details> </details>
## 💡 Key Features of nanobot ## 💡 Why nanobot
- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core. - **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend. - **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, and email.
- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in. - **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks.
- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page. - **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 ## 📦 Install
+2
View File
@@ -244,6 +244,7 @@ for reliable encryption, password login is recommended instead. If the
"userId": "@nanobot:matrix.org", "userId": "@nanobot:matrix.org",
"password": "mypasswordhere", "password": "mypasswordhere",
"e2eeEnabled": true, "e2eeEnabled": true,
"sasVerification": true,
"allowFrom": ["@your_user:matrix.org"], "allowFrom": ["@your_user:matrix.org"],
"groupPolicy": "open", "groupPolicy": "open",
"groupAllowFrom": [], "groupAllowFrom": [],
@@ -263,6 +264,7 @@ for reliable encryption, password login is recommended instead. If the
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). | | `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
| `allowRoomMentions` | Accept `@room` mentions in mention mode. | | `allowRoomMentions` | Accept `@room` mentions in mention mode. |
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. | | `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. | | `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 ## 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`): **Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown ```markdown
## Periodic Tasks ## Active Tasks
- [ ] Check weather forecast and send a summary - [ ] Check weather forecast and send a summary
- [ ] Scan inbox for urgent emails - [ ] 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. > **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. > 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] > [!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 > ```json
> { > {
> "gateway": { "host": "0.0.0.0" }, > "gateway": { "host": "0.0.0.0" },
> "channels": { "websocket": { "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 ### 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") return _pkg_version("nanobot-ai")
except PackageNotFoundError: except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info. # 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() __version__ = _resolve_version()
+50 -11
View File
@@ -45,6 +45,7 @@ from nanobot.session.goal_state import (
sustained_goal_active, sustained_goal_active,
) )
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.session import turn_continuation
from nanobot.session.webui_turns import ( from nanobot.session.webui_turns import (
WebuiTurnCoordinator, WebuiTurnCoordinator,
build_bus_progress_callback, build_bus_progress_callback,
@@ -112,6 +113,7 @@ class TurnContext:
save_skip: int = 0 save_skip: int = 0
outbound: OutboundMessage | None = None outbound: OutboundMessage | None = None
suppress_response: bool = False
on_progress: Callable[..., Awaitable[None]] | None = None on_progress: Callable[..., Awaitable[None]] | None = None
on_stream: Callable[[str], 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_queue: asyncio.Queue | None = None
pending_summary: str | None = None pending_summary: str | None = None
turn_wall_started_at: float = field(default_factory=time.time) turn_wall_started_at: float = field(default_factory=time.time)
visible_run_started_at: float | None = None
turn_latency_ms: int | None = None turn_latency_ms: int | None = None
trace: list[StateTraceEntry] = field(default_factory=list) trace: list[StateTraceEntry] = field(default_factory=list)
@@ -565,6 +568,8 @@ class AgentLoop:
Returns True if the message was persisted. 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] media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip() has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths: if has_text or media_paths:
@@ -771,6 +776,7 @@ class AgentLoop:
+ "\n\nPlease continue working toward the objective using your tools, " + "\n\nPlease continue working toward the objective using your tools, "
"or call complete_goal if the work is truly finished." "or call complete_goal if the work is truly finished."
) if _goal_lines else SUSTAINED_GOAL_CONTINUE_PROMPT ) if _goal_lines else SUSTAINED_GOAL_CONTINUE_PROMPT
session_metadata = session.metadata if session is not None else None
try: try:
result = await self.runner.run(AgentRunSpec( result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages, initial_messages=initial_messages,
@@ -796,7 +802,8 @@ class AgentLoop:
llm_timeout_s=runner_wall_llm_timeout_s( llm_timeout_s=runner_wall_llm_timeout_s(
self.sessions, self.sessions,
session.key if session is not None else session_key, session.key if session is not None else session_key,
metadata=(session.metadata if session is not None else None), metadata=session_metadata,
message_metadata=metadata,
), ),
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False, goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
goal_continue_message=_goal_continue, goal_continue_message=_goal_continue,
@@ -808,9 +815,15 @@ class AgentLoop:
self._last_usage = result.usage self._last_usage = result.usage
if result.stop_reason == "max_iterations": if result.stop_reason == "max_iterations":
logger.warning("Max iterations ({}) reached", self.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) # Push final content through stream so streaming channels (e.g. Feishu)
# update the card instead of leaving it empty. # 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(result.final_content or "")
await on_stream_end(resuming=False) await on_stream_end(resuming=False)
elif result.stop_reason == "error": elif result.stop_reason == "error":
@@ -953,7 +966,8 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id, channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=msg.metadata or {}, 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) turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
await self._webui_turns.handle_turn_end( await self._webui_turns.handle_turn_end(
msg, msg,
@@ -1017,9 +1031,10 @@ class AgentLoop:
"Re-published {} leftover message(s) to bus for session {}", "Re-published {} leftover message(s) to bus for session {}",
leftover, session_key, leftover, session_key,
) )
await self._webui_turns.publish_run_status(msg, "idle") if not turn_continuation.internal_continuation_pending(msg.metadata):
self._pending_turn_latency_ms.pop(session_key, None) await self._webui_turns.publish_run_status(msg, "idle")
self._webui_turns.discard(session_key) self._pending_turn_latency_ms.pop(session_key, None)
self._webui_turns.discard(session_key)
finally: finally:
if pending is None: if pending is None:
await self._webui_turns.publish_run_status(msg, "idle") await self._webui_turns.publish_run_status(msg, "idle")
@@ -1167,12 +1182,17 @@ class AgentLoop:
) )
key = session_key or msg.session_key key = session_key or msg.session_key
t0 = time.time()
ctx = TurnContext( ctx = TurnContext(
msg=msg, msg=msg,
session=None, session=None,
session_key=key, session_key=key,
state=TurnState.RESTORE, state=TurnState.RESTORE,
turn_id=f"{key}:{time.time_ns()}", 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_progress=on_progress,
on_stream=on_stream, on_stream=on_stream,
on_stream_end=on_stream_end, on_stream_end=on_stream_end,
@@ -1378,7 +1398,13 @@ class AgentLoop:
return "ok" return "ok"
async def _state_run(self, ctx: TurnContext) -> str: 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( result = await self._run_agent_loop(
ctx.initial_messages, ctx.initial_messages,
on_progress=ctx.on_progress, on_progress=ctx.on_progress,
@@ -1399,15 +1425,25 @@ class AgentLoop:
ctx.all_messages = all_msgs ctx.all_messages = all_msgs
ctx.stop_reason = stop_reason ctx.stop_reason = stop_reason
ctx.had_injections = had_injections ctx.had_injections = had_injections
await turn_continuation.maybe_continue_turn(ctx)
return "ok" return "ok"
async def _state_save(self, ctx: TurnContext) -> str: 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.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0) latency_started_at = (
ctx.visible_run_started_at
ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000)) 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( self._save_turn(
ctx.session, ctx.all_messages, ctx.save_skip, ctx.session, ctx.all_messages, ctx.save_skip,
turn_latency_ms=ctx.turn_latency_ms, turn_latency_ms=ctx.turn_latency_ms,
@@ -1427,6 +1463,9 @@ class AgentLoop:
return "ok" return "ok"
async def _state_respond(self, ctx: TurnContext) -> str: async def _state_respond(self, ctx: TurnContext) -> str:
if ctx.suppress_response:
ctx.outbound = None
return "ok"
ctx.outbound = self._assemble_outbound( ctx.outbound = self._assemble_outbound(
ctx.msg, ctx.msg,
ctx.final_content, ctx.final_content,
+2 -3
View File
@@ -807,10 +807,9 @@ class Consolidator:
metadata={}, metadata={},
last_consolidated=0, last_consolidated=0,
) )
probe.retain_recent_legal_suffix(max_suffix) dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix)
kept = probe.messages kept = probe.messages
cut = len(tail) - len(kept) archive_msgs = dropped[already_consolidated:]
archive_msgs = tail[:cut]
if not archive_msgs and not kept: if not archive_msgs and not kept:
session.updated_at = datetime.now() session.updated_at = datetime.now()
+18
View File
@@ -4,6 +4,8 @@ from contextvars import ContextVar
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path from nanobot.agent.tools.path_utils import resolve_workspace_path
@@ -83,6 +85,10 @@ class MessageTool(Tool, ContextAware):
"message_record_channel_delivery", "message_record_channel_delivery",
default=False, default=False,
) )
self._suppress_delivery_var: ContextVar[bool] = ContextVar(
"message_suppress_delivery",
default=False,
)
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: Any) -> Tool:
@@ -121,6 +127,14 @@ class MessageTool(Tool, ContextAware):
"""Restore previous proactive delivery recording state.""" """Restore previous proactive delivery recording state."""
self._record_channel_delivery_var.reset(token) self._record_channel_delivery_var.reset(token)
def set_suppress_delivery(self, active: bool):
"""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 delivery-suppression state."""
self._suppress_delivery_var.reset(token)
@property @property
def _sent_in_turn(self) -> bool: def _sent_in_turn(self) -> bool:
return self._sent_in_turn_var.get() return self._sent_in_turn_var.get()
@@ -241,6 +255,10 @@ class MessageTool(Tool, ContextAware):
metadata=metadata, 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: try:
await self._send_callback(msg) await self._send_callback(msg)
if channel == default_channel and chat_id == default_chat_id: if channel == default_channel and chat_id == default_chat_id:
+74
View File
@@ -23,6 +23,11 @@ try:
AsyncClientConfig, AsyncClientConfig,
InviteEvent, InviteEvent,
JoinError, JoinError,
KeyVerificationCancel,
KeyVerificationEvent,
KeyVerificationKey,
KeyVerificationMac,
KeyVerificationStart,
LoginResponse, LoginResponse,
MatrixRoom, MatrixRoom,
RoomEncryptedMedia, RoomEncryptedMedia,
@@ -33,6 +38,7 @@ try:
RoomSendResponse, RoomSendResponse,
RoomTypingError, RoomTypingError,
SyncError, SyncError,
ToDeviceError,
UploadError, UploadError,
) )
from nio.crypto.attachments import decrypt_attachment from nio.crypto.attachments import decrypt_attachment
@@ -194,6 +200,7 @@ class MatrixConfig(Base):
access_token: str = "" access_token: str = ""
device_id: str = "" device_id: str = ""
e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled") e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled")
sas_verification: bool = Field(default=False, alias="sasVerification")
sync_stop_grace_seconds: int = 2 sync_stop_grace_seconds: int = 2
max_media_bytes: int = 20 * 1024 * 1024 max_media_bytes: int = 20 * 1024 * 1024
max_concurrent_media_downloads: int = 2 max_concurrent_media_downloads: int = 2
@@ -268,6 +275,7 @@ class MatrixChannel(BaseChannel):
) )
self._register_event_callbacks() self._register_event_callbacks()
self._register_to_device_callbacks()
self._register_response_callbacks() self._register_response_callbacks()
if not self.config.e2ee_enabled: 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_media_message, MATRIX_MEDIA_EVENT_FILTER)
self.client.add_event_callback(self._on_room_invite, InviteEvent) 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: def _register_response_callbacks(self) -> None:
self.client.add_response_callback(self._on_sync_error, SyncError) 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_join_error, JoinError)
self.client.add_response_callback(self._on_send_error, RoomSendError) 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: def _is_fatal_auth_response(self, response: Any) -> bool:
code = getattr(response, "status_code", None) code = getattr(response, "status_code", None)
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"} is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
+3 -3
View File
@@ -649,14 +649,14 @@ class WebSocketChannel(BaseChannel):
return True return True
def _handle_token_issue_http(self, connection: Any, request: Any) -> Any: 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 secret:
if not _issue_route_secret_matches(request.headers, secret): if not _issue_route_secret_matches(request.headers, secret):
return connection.respond(401, "Unauthorized") return connection.respond(401, "Unauthorized")
else: else:
self.logger.warning( self.logger.warning(
"token_issue_path is set but token_issue_secret is empty; " "token_issue_path is set but no token_issue_secret or static token is configured; "
"any client can obtain connection tokens — set token_issue_secret for production." "any client can obtain connection tokens — set a secret for production."
) )
self._purge_expired_issued_tokens() self._purge_expired_issued_tokens()
if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS: if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS:
+43 -14
View File
@@ -1,7 +1,6 @@
"""CLI commands for nanobot.""" """CLI commands for nanobot."""
import asyncio import asyncio
import functools
import os import os
import select import select
import signal import signal
@@ -105,10 +104,29 @@ _HEARTBEAT_PREAMBLE = (
) )
@functools.lru_cache(maxsize=None) def _heartbeat_has_active_tasks(content: str) -> bool:
def _heartbeat_template() -> str | None: """True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments."""
from nanobot.utils.helpers import load_bundled_template in_comment = False
return load_bundled_template("HEARTBEAT.md") 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 # CLI input: prompt_toolkit for editing, paste, history, and display
@@ -978,8 +996,8 @@ def _run_gateway(
except OSError: except OSError:
logger.debug("Heartbeat: HEARTBEAT.md missing") logger.debug("Heartbeat: HEARTBEAT.md missing")
return None return None
if not content or content == _heartbeat_template(): if not _heartbeat_has_active_tasks(content):
logger.debug("Heartbeat: HEARTBEAT.md empty or identical to template") logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
return None return None
channel, chat_id = _pick_heartbeat_target() channel, chat_id = _pick_heartbeat_target()
@@ -991,13 +1009,22 @@ def _run_gateway(
+ f"Review the following HEARTBEAT.md and report any active tasks:\n\n{content}" + f"Review the following HEARTBEAT.md and report any active tasks:\n\n{content}"
) )
resp = await agent.process_direct( # Internal check: funnel all output through the post-run gate so the
prompt, # turn can't deliver directly via the message tool and skip it.
session_key="heartbeat", suppress_token = None
channel=channel, if isinstance(message_tool, MessageTool):
chat_id=chat_id, suppress_token = message_tool.set_suppress_delivery(True)
on_progress=_silent, try:
) resp = await agent.process_direct(
prompt,
session_key="heartbeat",
channel=channel,
chat_id=chat_id,
on_progress=_silent,
)
finally:
if isinstance(message_tool, MessageTool) and suppress_token is not None:
message_tool.reset_suppress_delivery(suppress_token)
response = resp.content if resp else "" response = resp.content if resp else ""
# Keep a small tail of heartbeat history so the loop stays bounded. # Keep a small tail of heartbeat history so the loop stays bounded.
@@ -1008,8 +1035,10 @@ def _run_gateway(
if not response: if not response:
return None return None
# Fail closed: stay silent on evaluator failure instead of notifying.
should_notify = await evaluate_response( should_notify = await evaluate_response(
response, prompt, agent.provider, agent.model, response, prompt, agent.provider, agent.model,
default_notify=False,
) )
if should_notify: if should_notify:
logger.info("Heartbeat: completed, delivering response") 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" 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: def parse_goal_state(blob: Any) -> dict[str, Any] | None:
if blob is None: if blob is None:
return None return None
@@ -98,14 +111,16 @@ def runner_wall_llm_timeout_s(
session_key: str | None, session_key: str | None,
*, *,
metadata: Mapping[str, Any] | None = None, metadata: Mapping[str, Any] | None = None,
message_metadata: Mapping[str, Any] | None = None,
) -> float | None: ) -> float | None:
"""Wall-clock cap for :class:`~nanobot.agent.runner.AgentRunner` when streaming an LLM. """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 Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when this is a
active; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory ``metadata`` when the sustained-goal turn; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory
caller already holds :attr:`~nanobot.session.manager.Session.metadata` for this turn. ``metadata`` when the caller already holds :attr:`~nanobot.session.manager.Session.metadata`
for this turn.
""" """
meta: Mapping[str, Any] | None = metadata meta: Mapping[str, Any] | None = metadata
if meta is None and session_key: if meta is None and session_key:
meta = sessions.get_or_create(session_key).metadata 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.updated_at = datetime.now()
self.metadata.pop("_last_summary", None) self.metadata.pop("_last_summary", None)
def retain_recent_legal_suffix(self, max_messages: int) -> None: def retain_recent_legal_suffix(self, max_messages: int) -> tuple[list[dict], int]:
"""Keep a legal recent suffix constrained by a hard message cap.""" """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: if max_messages <= 0:
dropped = list(self.messages)
lc = self.last_consolidated
self.clear() self.clear()
return return dropped, min(lc, len(dropped))
if len(self.messages) <= max_messages: if len(self.messages) <= max_messages:
return return [], 0
original = list(self.messages)
before_lc = self.last_consolidated
retained = list(self.messages[-max_messages:]) retained = list(self.messages[-max_messages:])
@@ -306,10 +318,32 @@ class Session:
if start: if start:
retained = retained[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.messages = retained
self.last_consolidated = max(0, self.last_consolidated - dropped) self.last_consolidated = new_lc
self.updated_at = datetime.now() self.updated_at = datetime.now()
return dropped, already_consolidated
def enforce_file_cap( def enforce_file_cap(
self, self,
@@ -320,23 +354,17 @@ class Session:
if limit <= 0 or len(self.messages) <= limit: if limit <= 0 or len(self.messages) <= limit:
return return
before = list(self.messages) dropped, already_consolidated = self.retain_recent_legal_suffix(limit)
before_last_consolidated = self.last_consolidated if not dropped:
before_count = len(before)
self.retain_recent_legal_suffix(limit)
dropped_count = before_count - len(self.messages)
if dropped_count <= 0:
return return
dropped = before[:dropped_count]
already_consolidated = min(before_last_consolidated, dropped_count)
archive_chunk = dropped[already_consolidated:] archive_chunk = dropped[already_consolidated:]
if archive_chunk and on_archive: if archive_chunk and on_archive:
on_archive(archive_chunk) on_archive(archive_chunk)
logger.info( logger.info(
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}", "Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
self.key, self.key,
dropped_count, len(dropped),
len(archive_chunk), len(archive_chunk),
len(self.messages), 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) 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).""" """Notify WebSocket clients while a user turn is executing (timing strip)."""
if msg.channel != "websocket": if msg.channel != "websocket":
return return
@@ -189,7 +195,10 @@ async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status:
"goal_status": status, "goal_status": status,
} }
if status == "running": 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 meta["started_at"] = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0 _WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else: else:
@@ -300,8 +309,14 @@ class WebuiTurnCoordinator:
def discard(self, session_key: str) -> None: def discard(self, session_key: str) -> None:
self._title_contexts.pop(session_key, None) self._title_contexts.pop(session_key, None)
async def publish_run_status(self, msg: InboundMessage, status: str) -> None: async def publish_run_status(
await publish_turn_run_status(self.bus, msg, 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( async def handle_turn_end(
self, self,
+3 -5
View File
@@ -1,16 +1,14 @@
# Heartbeat Tasks # Heartbeat Tasks
<!--
This file is checked periodically by your nanobot agent. 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. 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. 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 ## Active Tasks
<!-- Add your periodic tasks below this line --> <!-- Add your periodic tasks below this line -->
## Completed
<!-- Move completed tasks here or delete them -->
+14 -9
View File
@@ -44,12 +44,12 @@ async def evaluate_response(
task_context: str, task_context: str,
provider: LLMProvider, provider: LLMProvider,
model: str, model: str,
default_notify: bool = True,
) -> bool: ) -> bool:
"""Decide whether a background-task result should be delivered to the user. """Decide whether a background-task result should be delivered to the user.
Uses a lightweight tool-call LLM request (same pattern as heartbeat On any failure, falls back to ``default_notify`` (cron reminders fail open;
``_decide()``). Falls back to ``True`` (notify) on any failure so heartbeat passes ``False`` to fail closed).
that important messages are never silently dropped.
""" """
try: try:
llm_response = await provider.chat_with_retry( llm_response = await provider.chat_with_retry(
@@ -71,19 +71,24 @@ async def evaluate_response(
if not llm_response.should_execute_tools: if not llm_response.should_execute_tools:
if llm_response.has_tool_calls: if llm_response.has_tool_calls:
logger.warning( 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, llm_response.finish_reason,
default_notify,
) )
else: else:
logger.warning("evaluate_response: no tool call returned, defaulting to notify") logger.warning(
return True "evaluate_response: no tool call returned, defaulting to notify={}",
default_notify,
)
return default_notify
args = llm_response.tool_calls[0].arguments args = llm_response.tool_calls[0].arguments
should_notify = args.get("should_notify", True) should_notify = args.get("should_notify", default_notify)
reason = args.get("reason", "") reason = args.get("reason", "")
logger.info("evaluate_response: should_notify={}, reason={}", should_notify, reason) logger.info("evaluate_response: should_notify={}, reason={}", should_notify, reason)
return bool(should_notify) return bool(should_notify)
except Exception: except Exception:
logger.exception("evaluate_response failed, defaulting to notify") logger.exception("evaluate_response failed, defaulting to notify={}", default_notify)
return True return default_notify
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nanobot-ai" name = "nanobot-ai"
version = "0.2.0" version = "0.2.1"
description = "A lightweight personal AI assistant framework" description = "A lightweight personal AI assistant framework"
readme = { file = "README.md", content-type = "text/markdown" } readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11" requires-python = ">=3.11"
+2 -3
View File
@@ -76,10 +76,9 @@ def _make_fake_compact(
metadata={}, metadata={},
last_consolidated=0, last_consolidated=0,
) )
probe.retain_recent_legal_suffix(max_suffix) dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix)
kept = probe.messages kept = probe.messages
cut = len(tail) - len(kept) archive_msgs = dropped[already_consolidated:]
archive_msgs = tail[:cut]
if not archive_msgs and not kept: if not archive_msgs and not kept:
session.updated_at = datetime.now() session.updated_at = datetime.now()
+38
View File
@@ -440,6 +440,44 @@ class TestCompactIdleSession:
assert "u0" not in user_content assert "u0" not in user_content
assert "u25" in user_content or "a25" 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 @pytest.mark.asyncio
async def test_acquires_consolidation_lock(self, real_consolidator, mock_provider): async def test_acquires_consolidation_lock(self, real_consolidator, mock_provider):
"""Verify lock is held during execution.""" """Verify lock is held during execution."""
+18
View File
@@ -61,3 +61,21 @@ async def test_no_tool_call_fallback() -> None:
provider = DummyProvider([LLMResponse(content="I think you should notify", tool_calls=[])]) provider = DummyProvider([LLMResponse(content="I think you should notify", tool_calls=[])])
result = await evaluate_response("some response", "some task", provider, "m") result = await evaluate_response("some response", "some task", provider, "m")
assert result is True assert result is True
@pytest.mark.asyncio
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 from __future__ import annotations
import asyncio
import time import time
from unittest.mock import AsyncMock, MagicMock, patch 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 @pytest.mark.asyncio
async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp_path): async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp_path):
loop = _make_loop(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.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager 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 ( from nanobot.session.webui_turns import (
TITLE_GENERATION_MAX_TOKENS, TITLE_GENERATION_MAX_TOKENS,
TITLE_GENERATION_REASONING_EFFORT, 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 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 @pytest.mark.asyncio
async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None: async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path) 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) session.retain_recent_legal_suffix(6)
assert len(session.messages) <= 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.stop_sync_forever_called = False
self.join_calls: list[str] = [] self.join_calls: list[str] = []
self.callbacks: list[tuple[object, object]] = [] self.callbacks: list[tuple[object, object]] = []
self.to_device_callbacks: list[tuple[object, object]] = []
self.response_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.rooms: dict[str, object] = {}
self.room_send_calls: list[dict[str, object]] = [] self.room_send_calls: list[dict[str, object]] = []
self.typing_calls: list[tuple[str, bool, int]] = [] self.typing_calls: list[tuple[str, bool, int]] = []
@@ -70,6 +81,9 @@ class _FakeAsyncClient:
def add_event_callback(self, callback, event_type) -> None: def add_event_callback(self, callback, event_type) -> None:
self.callbacks.append((callback, event_type)) 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: def add_response_callback(self, callback, response_type) -> None:
self.response_callbacks.append((callback, response_type)) self.response_callbacks.append((callback, response_type))
@@ -82,6 +96,26 @@ class _FakeAsyncClient:
async def join(self, room_id: str) -> None: async def join(self, room_id: str) -> None:
self.join_calls.append(room_id) 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( async def room_send(
self, self,
room_id: str, room_id: str,
@@ -166,6 +200,62 @@ class _FakeAsyncClient:
return None 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: def _make_config(**kwargs) -> MatrixConfig:
kwargs.setdefault("allow_from", ["*"]) kwargs.setdefault("allow_from", ["*"])
return MatrixConfig( 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].config.encryption_enabled is True
assert clients[0].load_store_called is False assert clients[0].load_store_called is False
assert len(clients[0].callbacks) == 3 assert len(clients[0].callbacks) == 3
assert clients[0].to_device_callbacks == []
assert len(clients[0].response_callbacks) == 3 assert len(clients[0].response_callbacks) == 3
await channel.stop() 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 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: def test_media_event_filter_does_not_match_text_events() -> None:
assert not issubclass(matrix_module.RoomMessageText, matrix_module.MATRIX_MEDIA_EVENT_FILTER) 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 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 @pytest.mark.asyncio
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None: async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
channel = _ch(bus) channel = _ch(bus)
+27
View File
@@ -952,6 +952,33 @@ def test_heartbeat_retains_recent_messages_by_default():
assert config.gateway.heartbeat.keep_recent_messages == 8 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: def _write_instance_config(tmp_path: Path) -> Path:
config_file = tmp_path / "instance" / "config.json" config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True) config_file.parent.mkdir(parents=True)
+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
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" 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 @pytest.mark.asyncio
async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None: async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None:
sent: list[OutboundMessage] = [] 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 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 @pytest.mark.asyncio
async def test_publish_turn_run_status_idle_clears_wall_clock() -> None: async def test_publish_turn_run_status_idle_clears_wall_clock() -> None:
bus = MagicMock() bus = MagicMock()
+43 -20
View File
@@ -218,7 +218,7 @@ function HostChrome({
)} )}
</Button> </Button>
) : ( ) : (
<div aria-hidden className="h-8 w-8" /> <div aria-hidden className="host-no-drag pointer-events-none h-8 w-8" />
)} )}
</header> </header>
); );
@@ -252,7 +252,19 @@ export default function App() {
refreshed.token, refreshed.token,
refreshed.ws_url, 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); const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
if (refreshedHost.socketFactory) {
client.updateUrl(refreshedUrl, refreshedHost.socketFactory);
} else {
client.updateUrl(refreshedUrl);
}
setState((current) => setState((current) =>
current.status === "ready" && current.client === client current.status === "ready" && current.client === client
? { ? {
@@ -260,10 +272,7 @@ export default function App() {
token: refreshed.token, token: refreshed.token,
tokenExpiresAt, tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName, modelName: refreshed.model_name ?? current.modelName,
runtimeSurface: runtimeSurface: refreshedSurface,
refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: current.runtimeSurface,
} }
: current, : current,
); );
@@ -307,8 +316,16 @@ export default function App() {
try { try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current); const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url); 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); const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
client.updateUrl(url); if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) => setState((current) =>
current.status === "ready" && current.client === client current.status === "ready" && current.client === client
? { ? {
@@ -316,9 +333,7 @@ export default function App() {
token: boot.token, token: boot.token,
tokenExpiresAt, tokenExpiresAt,
modelName: boot.model_name ?? current.modelName, modelName: boot.model_name ?? current.modelName,
runtimeSurface: boot.runtime_surface runtimeSurface,
? toRuntimeSurface(boot.runtime_surface)
: current.runtimeSurface,
} }
: current, : current,
); );
@@ -1058,12 +1073,19 @@ function Shell({
const showHostChrome = isNativeHostSetupSurface; const showHostChrome = isNativeHostSetupSurface;
const showMainSidebar = view !== "settings"; const showMainSidebar = view !== "settings";
useEffect(() => {
document.documentElement.classList.toggle("native-host", showHostChrome);
return () => {
document.documentElement.classList.remove("native-host");
};
}, [showHostChrome]);
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<div <div
className={cn( className={cn(
"relative h-full w-full overflow-hidden", "relative h-full w-full overflow-hidden",
showHostChrome && "bg-sidebar", showHostChrome && "host-window-shell",
)} )}
> >
{showHostChrome ? ( {showHostChrome ? (
@@ -1071,7 +1093,6 @@ function Shell({
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined} onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
showThemeButton={view !== "chat"}
/> />
) : null} ) : null}
<div <div
@@ -1092,8 +1113,10 @@ function Shell({
> >
<div <div
className={cn( className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar", "absolute inset-y-0 left-0 h-full w-full overflow-hidden",
!showHostChrome && "shadow-inner-right", showHostChrome
? "host-sidebar-glass"
: "bg-sidebar shadow-inner-right",
)} )}
> >
<Sidebar <Sidebar
@@ -1138,13 +1161,12 @@ function Shell({
titleOverrides={sidebarState.title_overrides} titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult} onSelect={onSelectSearchResult}
/> />
<main <main
className={cn( className={cn(
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background", "relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome && showHostChrome && "border-l border-border/55",
"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)]", )}
)} >
>
<div <div
className={cn( className={cn(
"absolute inset-0 flex flex-col", "absolute inset-0 flex flex-col",
@@ -1161,6 +1183,7 @@ function Shell({
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
hideSidebarToggleForHostChrome hideSidebarToggleForHostChrome
hideThemeButton={showHostChrome}
hideHeader={false} hideHeader={false}
workspaceScope={activeWorkspaceScope} workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null} workspaceDefaultScope={workspaces?.default_scope ?? null}
+1 -1
View File
@@ -33,7 +33,7 @@ const LazyHighlightedCode = lazy(async () => {
default({ language, code, isDark }: HighlightedCodeProps) { default({ language, code, isDark }: HighlightedCodeProps) {
return ( return (
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language || "text"}
style={isDark ? oneDark : oneLight} style={isDark ? oneDark : oneLight}
customStyle={{ customStyle={{
margin: 0, 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"]); const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
function extensionOf(value: string): string { function extensionOf(value: string): string {
@@ -179,6 +187,119 @@ function nodeText(value: ReactNode): string {
.join(""); .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 { function isRenderedCodeBlock(value: ReactNode): boolean {
if (!isValidElement(value)) return false; if (!isValidElement(value)) return false;
const props = value.props as { code?: unknown }; const props = value.props as { code?: unknown };
@@ -264,7 +385,7 @@ export default function MarkdownTextRenderer({
if (fence) { if (fence) {
return ( return (
<CodeBlock <CodeBlock
language={fence.language} language={fence.language || "text"}
code={fence.code} code={fence.code}
className="my-3" className="my-3"
highlight={highlightCode} highlight={highlightCode}
@@ -296,6 +417,21 @@ export default function MarkdownTextRenderer({
</a> </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 }) { input({ type, checked }) {
if (type !== "checkbox") return null; if (type !== "checkbox") return null;
return ( return (
+2 -1
View File
@@ -67,7 +67,8 @@ export function Sidebar(props: SidebarProps) {
ref={props.containActionMenus ? setMenuPortalContainer : undefined} ref={props.containActionMenus ? setMenuPortalContainer : undefined}
aria-label={t("sidebar.navigation")} aria-label={t("sidebar.navigation")}
className={cn( 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", !props.hostChromeInset && "border-r border-sidebar-border/60",
)} )}
> >
@@ -4277,7 +4277,7 @@ function ProviderPicker({
const disabled = providers.length === 0; const disabled = providers.length === 0;
return ( return (
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={disabled}> <DropdownMenuTrigger asChild disabled={disabled}>
<Button <Button
type="button" type="button"
@@ -5098,11 +5098,12 @@ function ModelPresetPicker({
const selectedPreset = presets.find((preset) => preset.name === value) ?? presets[0] ?? null; const selectedPreset = presets.find((preset) => preset.name === value) ?? presets[0] ?? null;
return ( return (
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={!presets.length}> <DropdownMenuTrigger asChild disabled={!presets.length}>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
aria-label={tx("settings.rows.currentModel", "Current configuration")}
disabled={!presets.length} disabled={!presets.length}
className={cn( 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", "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"> <div className="mt-1 border-t border-border/55 pt-1">
<DropdownMenuItem <DropdownMenuItem
onSelect={onCreateConfiguration} onSelect={() => {
window.setTimeout(onCreateConfiguration, 0);
}}
className={cn( className={cn(
"flex cursor-default items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] font-medium", "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", "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({ function RunElapsedStrip({
startedAt, startedAt,
goalState, goalState,
@@ -586,7 +595,7 @@ function RunElapsedStrip({
aria-label={ariaLabel} aria-label={ariaLabel}
> >
{displayShowTimer ? ( {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 /> <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"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean; hideSidebarToggleForHostChrome?: boolean;
hideThemeButton?: boolean;
minimal?: boolean; minimal?: boolean;
} }
@@ -19,6 +20,7 @@ export function ThreadHeader({
theme, theme,
onToggleTheme, onToggleTheme,
hideSidebarToggleForHostChrome = false, hideSidebarToggleForHostChrome = false,
hideThemeButton = false,
minimal = false, minimal = false,
}: ThreadHeaderProps) { }: ThreadHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -37,12 +39,14 @@ export function ThreadHeader({
> >
<Menu className="h-3.5 w-3.5" /> <Menu className="h-3.5 w-3.5" />
</Button> </Button>
<ThemeButton {!hideThemeButton ? (
theme={theme} <ThemeButton
onToggleTheme={onToggleTheme} theme={theme}
label={t("thread.header.toggleTheme")} onToggleTheme={onToggleTheme}
className="ml-auto" label={t("thread.header.toggleTheme")}
/> className="ml-auto"
/>
) : null}
</div> </div>
); );
} }
@@ -67,12 +71,14 @@ export function ThreadHeader({
</div> </div>
</div> </div>
<ThemeButton {!hideThemeButton ? (
theme={theme} <ThemeButton
onToggleTheme={onToggleTheme} theme={theme}
label={t("thread.header.toggleTheme")} onToggleTheme={onToggleTheme}
className="ml-auto shrink-0" 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 aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
</div> </div>
@@ -97,7 +103,7 @@ function ThemeButton({
aria-label={label} aria-label={label}
onClick={onToggleTheme} onClick={onToggleTheme}
className={cn( 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, className,
)} )}
> >
@@ -137,10 +137,6 @@ function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
if (!markedCurrentActivity) { if (!markedCurrentActivity) {
indices.add(i); indices.add(i);
markedCurrentActivity = true; markedCurrentActivity = true;
continue;
}
if (activityHasLiveFileEdit(unit)) {
indices.add(i);
} }
continue; continue;
} }
@@ -150,13 +146,6 @@ function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
return indices; 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 { function unitKey(unit: DisplayUnit, index: number): string {
if (unit.type === "activity") { if (unit.type === "activity") {
const anchor = unit.messages[0]?.id; const anchor = unit.messages[0]?.id;
@@ -62,6 +62,7 @@ interface ThreadShellProps {
theme?: "light" | "dark"; theme?: "light" | "dark";
onToggleTheme?: () => void; onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean; hideSidebarToggleForHostChrome?: boolean;
hideThemeButton?: boolean;
hideHeader?: boolean; hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null; workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null; workspaceDefaultScope?: WorkspaceScopePayload | null;
@@ -142,6 +143,7 @@ export function ThreadShell({
theme = "light", theme = "light",
onToggleTheme = () => {}, onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false, hideSidebarToggleForHostChrome = false,
hideThemeButton = false,
hideHeader = false, hideHeader = false,
workspaceScope = null, workspaceScope = null,
workspaceDefaultScope = null, workspaceDefaultScope = null,
@@ -567,6 +569,7 @@ export function ThreadShell({
theme={theme} theme={theme}
onToggleTheme={onToggleTheme} onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome} hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hideThemeButton={hideThemeButton}
minimal={!session && !loading} minimal={!session && !loading}
/> />
) : null} ) : null}
@@ -237,7 +237,7 @@ export function ThreadViewport({
<div <div
ref={scrollRef} ref={scrollRef}
className={cn( 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]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full", "[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30", "[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
@@ -38,6 +38,7 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
const editing = edit.status === "editing"; const editing = edit.status === "editing";
const failed = edit.status === "error"; const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit); const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
const failureDetail = failed const failureDetail = failed
? formatFileEditError(edit.error) ? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." }) || t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
@@ -67,8 +68,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
active={editing} active={editing}
tone={failed ? "error" : editing ? "active" : "success"} tone={failed ? "error" : editing ? "active" : "success"}
className="text-xs" className="text-xs"
contentClassName="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3" contentClassName={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
title={failureDetail || edit.absolute_path || edit.path} title={rawFailureDetail || edit.absolute_path || edit.path}
label={edit.pending && !edit.path label={edit.pending && !edit.path
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" }) ? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
: ( : (
@@ -82,13 +83,15 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
testId="activity-file-reference" testId="activity-file-reference"
/> />
)} )}
detail={failed ? ( detail={null}
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75"> 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} {failureDetail}
</span> </span>
) : null} ) : 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; return edit.added > 0 || edit.deleted > 0;
} }
function formatFileEditError(error?: string): string { function cleanFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim(); const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return ""; if (!firstLine) return "";
const cleaned = firstLine return firstLine
.replace(/^Error applying patch:\s*/i, "") .replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "") .replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "") .replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\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 return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.") .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; -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 { .shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02); box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
} }
@@ -232,6 +301,50 @@
.composer-status-strip[data-state="exit"] { .composer-status-strip[data-state="exit"] {
animation: composer-status-strip-exit 180ms ease-in both; 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 { @keyframes queued-prompt-row-enter {
0% { 0% {
opacity: 0; opacity: 0;
@@ -252,6 +365,18 @@
.composer-status-strip[data-state] { .composer-status-strip[data-state] {
animation: none; 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 { .queued-prompt-row {
animation: none; animation: none;
} }
+24 -56
View File
@@ -58,11 +58,10 @@ function findStreamingAssistantIndex(
/** /**
* Append a reasoning chunk to the last open reasoning stream in ``prev``. * 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. * Lookup rule: reasoning can only extend the current reasoning placeholder.
* Most providers emit reasoning before answer text, but some only expose * Once ordinary answer text has appeared, the next reasoning chunk starts a
* ``reasoning_content`` after the answer stream completes. In that post-hoc * fresh Thought block so streamed output stays in arrival order:
* case the reasoning still belongs to the same assistant turn and must render * Thought -> answer -> Thought -> answer.
* above the answer, not as a new row below it.
*/ */
function attachReasoningChunk( function attachReasoningChunk(
prev: UIMessage[], prev: UIMessage[],
@@ -83,10 +82,10 @@ function attachReasoningChunk(
if (candidate.role !== "assistant") continue; if (candidate.role !== "assistant") continue;
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure(); const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
const hasAnswer = candidate.content.length > 0; const hasAnswer = candidate.content.length > 0;
if (hasAnswer) break;
if ( if (
candidate.reasoningStreaming candidate.reasoningStreaming
|| candidate.reasoning !== undefined || candidate.reasoning !== undefined
|| hasAnswer
|| candidate.isStreaming || candidate.isStreaming
) { ) {
const merged: UIMessage = { const merged: UIMessage = {
@@ -97,15 +96,6 @@ function attachReasoningChunk(
}; };
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; 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; break;
} }
const activitySegmentId = segments?.ensure(); 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 { function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null; if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus = const inferredStatus =
@@ -511,6 +469,7 @@ export function useNanobotStream(
if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId); if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId);
buffer.current = null; buffer.current = null;
activeAssistantRef.current = null; activeAssistantRef.current = null;
return !!closedStreamId;
}, []); }, []);
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => { const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
@@ -589,15 +548,18 @@ export function useNanobotStream(
text += events[i].text; text += events[i].text;
i += 1; i += 1;
} }
next = kind === "delta" if (kind === "delta") {
? appendAnswerChunk(next, text) next = appendAnswerChunk(next, text);
: attachReasoningChunk(next, text, { } else {
ensure: ensureActivitySegmentId, if (closeActiveAssistantStream()) clearActivitySegment();
}); next = attachReasoningChunk(next, text, {
ensure: ensureActivitySegmentId,
});
}
} }
return next; return next;
}, },
[appendAnswerChunk, ensureActivitySegmentId], [appendAnswerChunk, clearActivitySegment, closeActiveAssistantStream, ensureActivitySegmentId],
); );
const flushPendingStreamEvents = useCallback((options?: { const flushPendingStreamEvents = useCallback((options?: {
@@ -735,7 +697,13 @@ export function useNanobotStream(
return; 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 (ev.event === "reasoning_end") {
if (suppressStreamUntilTurnEndRef.current) return; if (suppressStreamUntilTurnEndRef.current) return;
@@ -812,7 +780,7 @@ export function useNanobotStream(
const structuredEvents = normalizeToolProgressEvents(ev.tool_events); const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
setMessages((prev) => { setMessages((prev) => {
const segmentId = ensureActivitySegmentId(); const segmentId = ensureActivitySegmentId();
const base = demoteInterruptedAssistantToActivity(prev, segmentId); const base = prev;
const visibleStructuredEvents = filterCoveredFileEditToolEvents(base, structuredEvents); const visibleStructuredEvents = filterCoveredFileEditToolEvents(base, structuredEvents);
const structuredLines = toolTraceLinesFromEvents(visibleStructuredEvents); const structuredLines = toolTraceLinesFromEvents(visibleStructuredEvents);
const lines = structuredLines.length > 0 const lines = structuredLines.length > 0
@@ -914,7 +882,7 @@ export function useNanobotStream(
} }
setMessages((prev) => { setMessages((prev) => {
let segmentId = eventSegmentId; let segmentId = eventSegmentId;
const base = segmentId ? demoteInterruptedAssistantToActivity(prev, segmentId) : prev; const base = prev;
const targetIndex = findFileEditTraceIndex(base, segmentId, normalized); const targetIndex = findFileEditTraceIndex(base, segmentId, normalized);
if (targetIndex !== null) { if (targetIndex !== null) {
const target = base[targetIndex]; const target = base[targetIndex];
+130 -130
View File
@@ -59,43 +59,43 @@
"settings": { "settings": {
"backToChat": "Volver al chat", "backToChat": "Volver al chat",
"sidebar": { "sidebar": {
"title": "Configuración", "title": "Ajustes",
"ariaLabel": "Secciones de configuración" "ariaLabel": "Secciones de ajustes"
}, },
"nav": { "nav": {
"general": "General", "general": "General",
"byok": "BYOK", "byok": "BYOK",
"overview": "Overview", "overview": "Resumen",
"appearance": "Appearance", "appearance": "Apariencia",
"models": "Models", "models": "Modelos",
"providers": "Providers", "providers": "Proveedores",
"image": "Image", "image": "Imagen",
"browser": "Web", "browser": "Internet",
"runtime": "Sistema", "runtime": "Sistema",
"advanced": "Security", "advanced": "Seguridad",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Apps" "apps": "Aplicaciones"
}, },
"sections": { "sections": {
"interface": "Interfaz", "interface": "Interfaz",
"ai": "IA", "ai": "AI",
"system": "Sistema", "system": "Sistema",
"status": "Status", "status": "Estado",
"localPreferences": "Local preferences", "localPreferences": "Preferencias locales",
"presets": "Presets", "presets": "Preajustes",
"imageGeneration": "Generación de imágenes", "imageGeneration": "Generación de imágenes",
"imageDefaults": "Valores predeterminados", "imageDefaults": "Valores predeterminados",
"webSearch": "Web search", "webSearch": "Búsqueda web",
"webBehavior": "Behavior", "webBehavior": "Comportamiento",
"identity": "Identity", "identity": "Identidad",
"webuiSafety": "Web safety", "webuiSafety": "Seguridad de WebUI",
"capabilities": "Capacidades", "capabilities": "Capacidades",
"cliApps": "Apps CLI", "cliApps": "Aplicaciones CLI",
"mcp": "Servicios MCP", "mcp": "Servicios MCP",
"apps": "Apps", "apps": "Aplicaciones",
"nativeHost": "App", "nativeHost": "Host nativo",
"hostSafety": "App safety" "hostSafety": "Seguridad de la app"
}, },
"rows": { "rows": {
"theme": "Tema", "theme": "Tema",
@@ -104,118 +104,118 @@
"model": "Modelo", "model": "Modelo",
"restart": "Reiniciar nanobot", "restart": "Reiniciar nanobot",
"configPath": "Ruta de configuración", "configPath": "Ruta de configuración",
"activePreset": "Active preset", "activePreset": "Preajuste activo",
"gateway": "Gateway", "gateway": "Pasarela",
"restartState": "Restart state", "restartState": "Estado de reinicio",
"pendingChanges": "Cambios pendientes", "pendingChanges": "Cambios pendientes",
"selectedPreset": "Selected preset", "selectedPreset": "Preajuste seleccionado",
"presetModel": "Preset model", "presetModel": "Modelo del preajuste",
"density": "Density", "density": "Densidad",
"activityMode": "Activity detail", "activityMode": "Detalle de actividad",
"codeWrap": "Code wrapping", "codeWrap": "Ajuste de código",
"maxResults": "Max results", "maxResults": "Resultados máximos",
"timeout": "Timeout", "timeout": "Tiempo de espera",
"jinaReader": "Jina reader", "jinaReader": "Lector Jina",
"imageGeneration": "Generación de imágenes", "imageGeneration": "Generación de imágenes",
"imageProvider": "Proveedor de imágenes", "imageProvider": "Proveedor de imágenes",
"imageProviderStatus": "Estado del proveedor", "imageProviderStatus": "Estado del proveedor",
"imageProviderBase": "Base del proveedor", "imageProviderBase": "Base del proveedor",
"imageModel": "Modelo de imagen", "imageModel": "Modelo de imagen",
"defaultAspectRatio": "Relación predeterminada", "defaultAspectRatio": "Proporción predeterminada",
"defaultImageSize": "Tamaño predeterminado", "defaultImageSize": "Tamaño predeterminado",
"maxImagesPerTurn": "Máximo de imágenes por turno", "maxImagesPerTurn": "Máx. imágenes por turno",
"imageSaveDir": "Directorio de guardado", "imageSaveDir": "Directorio de guardado",
"botName": "Bot name", "botName": "Nombre del bot",
"botIcon": "Bot icon", "botIcon": "Icono del bot",
"timezone": "Timezone", "timezone": "Zona horaria",
"workspacePath": "Workspace predeterminado", "workspacePath": "Workspace predeterminado",
"localServiceAccess": "Local services", "localServiceAccess": "Servicios locales",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Acceso predeterminado",
"currentModel": "Configuración actual", "currentModel": "Configuración actual",
"brandLogos": "Logotipos de marca", "brandLogos": "Logos de marca",
"cliAppsCatalog": "Catálogo de apps CLI", "cliAppsCatalog": "Catálogo",
"cliAppsFilter": "Filtro de apps CLI", "cliAppsFilter": "Filtro",
"engine": "Motor", "engine": "Motor",
"logs": "Registros", "logs": "Registros",
"diagnostics": "Diagnóstico", "diagnostics": "Diagnóstico",
"contextWindow": "Context window" "contextWindow": "Ventana de contexto"
}, },
"help": { "help": {
"theme": "Cambia entre apariencia clara y oscura.", "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.", "provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
"model": "Define el nombre del modelo predeterminado que usa nanobot.", "model": "Define el nombre de modelo predeterminado de nanobot.",
"configPath": "El archivo de configuración que usa actualmente el gateway.", "configPath": "Archivo de configuración que usa actualmente el gateway.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.", "selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.", "presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.",
"density": "Stored only in this browser.", "density": "Solo se guarda en este navegador.",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
"maxResults": "Results returned by each web_search call.", "maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Seconds before a search provider request times out.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
"jinaReader": "Use Jina Reader for web_fetch when available.", "jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
"imageGeneration": "Expone generate_image en los chats cuando hay un proveedor de imágenes configurado disponible.", "imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
"imageProvider": "Elige el proveedor del registro que usará generate_image.", "imageProvider": "Elige el proveedor registrado usado por generate_image.",
"imageProviderStatus": "La generación de imágenes reutiliza las credenciales de Proveedores.", "imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.", "imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
"defaultAspectRatio": "Se usa cuando el prompt no elige una relación de aspecto.", "defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
"defaultImageSize": "Sugerencia de tamaño enviada a los proveedores que la admiten.", "defaultImageSize": "Pista de tamaño enviada a proveedores compatibles.",
"maxImagesPerTurn": "Límite superior para una solicitud generate_image.", "maxImagesPerTurn": "Límite superior para una solicitud generate_image.",
"botName": "Se muestra donde nanobot usa un nombre visible.", "botName": "Se muestra donde nanobot usa un nombre visible.",
"botIcon": "Emoji o texto corto mostrado junto al nombre del bot.", "botIcon": "Emoji o texto corto junto al nombre del bot.",
"timezone": "Se usa para programaciones y respuestas sensibles al tiempo.", "timezone": "Se usa para horarios y respuestas con conciencia temporal.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Permite que comandos shell con Full Access alcancen servicios localhost.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Usado por chats web sin permiso específico de proyecto.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
"currentModel": "Elige la configuración de modelo que nanobot usa para las próximas respuestas.", "currentModel": "Se usa para nuevas respuestas.",
"selectedModelProvider": "Lo define el modelo seleccionado.", "selectedModelProvider": "Definido por el modelo seleccionado.",
"selectedModelValue": "Lo define el modelo seleccionado.", "selectedModelValue": "Definido por el modelo seleccionado.",
"brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.", "brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
"cliAppsCatalog": "Explora CLIs de apps que nanobot puede ejecutar localmente.", "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.", "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.", "diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.", "localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.", "webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
"contextWindow": "Choose the default context budget for this model configuration." "contextWindow": "Elige el presupuesto de contexto predeterminado para esta configuración de modelo."
}, },
"values": { "values": {
"light": "Claro", "light": "Claro",
"dark": "Oscuro", "dark": "Oscuro",
"notAvailable": "No disponible", "notAvailable": "No disponible",
"enabled": "Enabled", "enabled": "Activado",
"disabled": "Disabled", "disabled": "Desactivado",
"restartPending": "Reinicio pendiente", "restartPending": "Reinicio pendiente",
"ready": "Listo", "ready": "Listo",
"comfortable": "Comfortable", "comfortable": "Cómodo",
"compact": "Compact", "compact": "Compacto",
"auto": "Auto", "auto": "Automático",
"expanded": "Expanded", "expanded": "Expandido",
"on": "On", "on": "Activado",
"off": "Off", "off": "Desactivado",
"defaultPermission": "Default Permission", "defaultPermission": "Permiso predeterminado",
"fullAccess": "Full Access", "fullAccess": "Acceso completo",
"configured": "Configured", "configured": "Configurado",
"notConfigured": "Not configured", "notConfigured": "Sin configurar",
"pending": "Pendiente", "pending": "Pendiente",
"restartingEngine": "Reiniciando" "restartingEngine": "Reiniciando"
}, },
"status": { "status": {
"loading": "Cargando configuración...", "loading": "Cargando ajustes...",
"loadError": "No se pudo cargar la configuración", "loadError": "No se pudieron cargar los ajustes",
"unsaved": "Hay cambios sin guardar.", "unsaved": "Cambios sin guardar.",
"upToDate": "Actualizado.", "upToDate": "Actualizado.",
"savedRestart": "Guardado. Reinicia nanobot para aplicar.", "savedRestart": "Guardado. Reinicia nanobot para aplicar.",
"restartAfterSaving": "Guarda los cambios y reinicia cuando estés listo.", "restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
"savedRestartApply": "Guardado. Reinicia cuando estés listo.", "savedRestartApply": "Guardado. Reinicia cuando puedas.",
"imageProviderRestart": "Cambios del proveedor de imágenes guardados. Reinicia cuando estés listo.", "imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
"hostRestartAfterSaving": "Guarda los cambios y nanobot reiniciará su motor.", "hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
"hostRestartPending": "Guardado. Reiniciando el motor cuando esté listo.", "hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
"hostApiUnavailable": "Host actions are only available inside the native app.", "hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
"logsOpened": "Opened logs folder.", "logsOpened": "Carpeta de registros abierta.",
"logsOpenFailed": "Could not open logs folder.", "logsOpenFailed": "No se pudo abrir la carpeta de registros.",
"diagnosticsExported": "Diagnostics exported to {{path}}.", "diagnosticsExported": "Diagnóstico exportado a {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics." "diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
}, },
"actions": { "actions": {
"save": "Guardar", "save": "Guardar",
@@ -224,8 +224,8 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"open": "Abrir", "open": "Abrir",
"export": "Exportar", "export": "Exportar",
"opening": "Opening...", "opening": "Abriendo...",
"exporting": "Exporting..." "exporting": "Exportando..."
}, },
"byok": { "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.", "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": { "tabs": {
"ariaLabel": "Tipo de credencial BYOK", "ariaLabel": "Tipo de credencial BYOK",
"llm": "LLM", "llm": "LLM",
"webSearch": "Web Search" "webSearch": "Búsqueda web"
}, },
"webSearch": { "webSearch": {
"provider": "Proveedor de búsqueda", "provider": "Proveedor de búsqueda",
@@ -270,44 +270,44 @@
} }
}, },
"overview": { "overview": {
"model": "Current model", "model": "Modelo actual",
"providers": "Providers", "providers": "Proveedores",
"configuredCount": "{{count}} configured", "configuredCount": "{{count}} configurados",
"totalProviders": "{{count}} available", "totalProviders": "{{count}} disponibles",
"webSearch": "Web search", "webSearch": "Búsqueda web",
"imageGeneration": "Generación de imágenes", "imageGeneration": "Generación de imágenes",
"workspace": "Workspace" "workspace": "Espacio de trabajo"
}, },
"providers": { "providers": {
"searchPlaceholder": "Search providers", "searchPlaceholder": "Buscar proveedores",
"noMatches": "No providers match this search.", "noMatches": "Ningún proveedor coincide con esta búsqueda.",
"saveProvider": "Guardar proveedor" "saveProvider": "Guardar proveedor"
}, },
"image": { "image": {
"selectProvider": "Seleccionar proveedor", "selectProvider": "Seleccionar proveedor",
"selectAspect": "Seleccionar relación", "selectAspect": "Seleccionar proporción",
"selectSize": "Seleccionar tamaño", "selectSize": "Seleccionar tamaño",
"configureProvider": "Configurar proveedor", "configureProvider": "Configurar proveedor",
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes." "missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
}, },
"models": { "models": {
"selectModel": "Seleccionar modelo", "selectModel": "Seleccionar modelo",
"addConfiguration": "Añadir configuración", "addConfiguration": "Agregar configuración",
"newConfiguration": "Nueva configuración de modelo", "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", "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", "configurationNamePlaceholder": "Escritura rápida",
"searchModels": "Buscar o escribir ID de modelo", "searchModels": "Buscar o escribir ID de modelo",
"useCustomModel": "Usar", "useCustomModel": "Usar",
"loadingModels": "Cargando modelos...", "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", "modelsAvailable": "disponibles",
"noModelResults": "No hay modelos coincidentes.", "noModelResults": "No hay modelos coincidentes.",
"loadFailed": "Lista de modelos no disponible.", "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.", "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": { "timezone": {
"select": "Seleccionar zona horaria", "select": "Seleccionar zona horaria",
@@ -316,7 +316,7 @@
}, },
"cliApps": { "cliApps": {
"allCategories": "Todas las categorías", "allCategories": "Todas las categorías",
"availableCount": "{{count}} apps", "availableCount": "{{count}} aplicaciones",
"installedCount": "{{count}} instaladas", "installedCount": "{{count}} instaladas",
"summary": "{{installed}} de {{total}} CLIs instaladas", "summary": "{{installed}} de {{total}} CLIs instaladas",
"filterAll": "Todas", "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." "thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
}, },
"apps": { "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", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"filterAll": "Todo", "filterAll": "Todo",
@@ -416,17 +416,17 @@
"empty": "Ninguna app coincide con este filtro." "empty": "Ninguna app coincide con este filtro."
}, },
"oauth": { "oauth": {
"authentication": "OAuth authentication", "authentication": "Autenticación OAuth",
"signIn": "Sign in", "signIn": "Iniciar sesión",
"signingIn": "Signing in...", "signingIn": "Iniciando sesión...",
"signInAgain": "Sign in again", "signInAgain": "Iniciar sesión de nuevo",
"signOut": "Sign out", "signOut": "Cerrar sesión",
"signedInAs": "Signed in as {{account}}", "signedInAs": "Sesión iniciada como {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.", "signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"signInRequired": "Sign in required", "signInRequired": "Inicio de sesión requerido",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.", "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Signed in", "signedIn": "Sesión iniciada",
"notSignedIn": "Not signed in" "notSignedIn": "Sin sesión"
} }
}, },
"chat": { "chat": {
+144 -144
View File
@@ -57,45 +57,45 @@
"apps": "Apps" "apps": "Apps"
}, },
"settings": { "settings": {
"backToChat": "Retour à la discussion", "backToChat": "Retour au chat",
"sidebar": { "sidebar": {
"title": "Paramètres", "title": "Réglages",
"ariaLabel": "Sections des paramètres" "ariaLabel": "Sections des réglages"
}, },
"nav": { "nav": {
"general": "Général", "general": "Général",
"byok": "BYOK", "byok": "BYOK",
"overview": "Overview", "overview": "Aperçu",
"appearance": "Appearance", "appearance": "Apparence",
"models": "Models", "models": "Modèles",
"providers": "Providers", "providers": "Fournisseurs",
"image": "Image", "image": "Images",
"browser": "Web", "browser": "Internet",
"runtime": "Système", "runtime": "Système",
"advanced": "Security", "advanced": "Sécurité",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Apps" "apps": "Applications"
}, },
"sections": { "sections": {
"interface": "Interface", "interface": "Interface utilisateur",
"ai": "IA", "ai": "AI",
"system": "Système", "system": "Système",
"status": "Status", "status": "État",
"localPreferences": "Local preferences", "localPreferences": "Préférences locales",
"presets": "Presets", "presets": "Préréglages",
"imageGeneration": "Génération d'images", "imageGeneration": "Génération dimages",
"imageDefaults": "Valeurs par défaut", "imageDefaults": "Valeurs par défaut",
"webSearch": "Web search", "webSearch": "Recherche web",
"webBehavior": "Behavior", "webBehavior": "Comportement",
"identity": "Identity", "identity": "Identité",
"webuiSafety": "Web safety", "webuiSafety": "Sécurité WebUI",
"capabilities": "Capacités", "capabilities": "Capacités",
"cliApps": "Apps CLI", "cliApps": "Applications CLI",
"mcp": "Services MCP", "mcp": "Services MCP",
"apps": "Apps", "apps": "Applications",
"nativeHost": "App", "nativeHost": "Hôte natif",
"hostSafety": "App safety" "hostSafety": "Sécurité de lapp"
}, },
"rows": { "rows": {
"theme": "Thème", "theme": "Thème",
@@ -103,119 +103,119 @@
"provider": "Fournisseur", "provider": "Fournisseur",
"model": "Modèle", "model": "Modèle",
"restart": "Redémarrer nanobot", "restart": "Redémarrer nanobot",
"configPath": "Chemin de configuration", "configPath": "Chemin de config",
"activePreset": "Active preset", "activePreset": "Préréglage actif",
"gateway": "Gateway", "gateway": "Passerelle",
"restartState": "Restart state", "restartState": "État du redémarrage",
"pendingChanges": "Modifications en attente", "pendingChanges": "Modifications en attente",
"selectedPreset": "Selected preset", "selectedPreset": "Préréglage sélectionné",
"presetModel": "Preset model", "presetModel": "Modèle du préréglage",
"density": "Density", "density": "Densité",
"activityMode": "Activity detail", "activityMode": "Détail dactivité",
"codeWrap": "Code wrapping", "codeWrap": "Retour à la ligne du code",
"maxResults": "Max results", "maxResults": "Résultats max.",
"timeout": "Timeout", "timeout": "Délai dattente",
"jinaReader": "Jina reader", "jinaReader": "Lecteur Jina",
"imageGeneration": "Génération d'images", "imageGeneration": "Génération dimages",
"imageProvider": "Fournisseur d'images", "imageProvider": "Fournisseur dimages",
"imageProviderStatus": "État du fournisseur", "imageProviderStatus": "État du fournisseur",
"imageProviderBase": "Base du fournisseur", "imageProviderBase": "Base du fournisseur",
"imageModel": "Modèle d'image", "imageModel": "Modèle dimage",
"defaultAspectRatio": "Format par défaut", "defaultAspectRatio": "Ratio par défaut",
"defaultImageSize": "Taille par défaut", "defaultImageSize": "Taille par défaut",
"maxImagesPerTurn": "Nombre max. d'images par tour", "maxImagesPerTurn": "Images max. par tour",
"imageSaveDir": "Répertoire de sauvegarde", "imageSaveDir": "Dossier denregistrement",
"botName": "Bot name", "botName": "Nom du bot",
"botIcon": "Bot icon", "botIcon": "Icône du bot",
"timezone": "Timezone", "timezone": "Fuseau horaire",
"workspacePath": "Espace de travail par défaut", "workspacePath": "Espace de travail par défaut",
"localServiceAccess": "Local services", "localServiceAccess": "Services locaux",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Accès par défaut",
"currentModel": "Configuration actuelle", "currentModel": "Configuration actuelle",
"brandLogos": "Logos de marque", "brandLogos": "Logos de marque",
"cliAppsCatalog": "Catalogue d'apps CLI", "cliAppsCatalog": "Catalogue",
"cliAppsFilter": "Filtre des apps CLI", "cliAppsFilter": "Filtre",
"engine": "Moteur", "engine": "Moteur",
"logs": "Journaux", "logs": "Journaux",
"diagnostics": "Diagnostics", "diagnostics": "Diagnostic",
"contextWindow": "Context window" "contextWindow": "Fenêtre de contexte"
}, },
"help": { "help": {
"theme": "Basculer entre les apparences claire et sombre.", "theme": "Basculer entre lapparence claire et sombre.",
"language": "Choisissez la langue utilisée par le WebUI.", "language": "Choisissez la langue utilisée par WebUI.",
"provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.", "provider": "Sélectionnez le fournisseur à utiliser pour les nouvelles requêtes de modèle.",
"model": "Définissez le nom du modèle par défaut utilisé par nanobot.", "model": "Définissez le nom du modèle utilisé par défaut par nanobot.",
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle.", "configPath": "Le fichier de configuration actuellement utilisé par la passerelle.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.", "selectedPreset": "Les préréglages nommés sont en lecture seule ici ; modifiez-les dans config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.", "presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.",
"density": "Stored only in this browser.", "density": "Enregistré seulement dans ce navigateur.",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "Choisissez le niveau de détail dactivité agent affiché par défaut.",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
"maxResults": "Results returned by each web_search call.", "maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Seconds before a search provider request times out.", "timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
"jinaReader": "Use Jina Reader for web_fetch when available.", "jinaReader": "Utilise Jina Reader pour web_fetch lorsque disponible.",
"imageGeneration": "Expose generate_image dans les chats lorsquun fournisseur dimages configuré est disponible.", "imageGeneration": "Expose generate_image dans les chats lorsquun fournisseur dimage configuré est disponible.",
"imageProvider": "Choisissez le fournisseur du registre utilisé par generate_image.", "imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.",
"imageProviderStatus": "La génération dimages réutilise les identifiants de Fournisseurs.", "imageProviderStatus": "La génération dimages réutilise les identifiants des fournisseurs.",
"imageModel": "Nom du modèle envoyé au fournisseur dimages sélectionné.", "imageModel": "Nom du modèle envoyé au fournisseur dimages sélectionné.",
"defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de format.", "defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de ratio.",
"defaultImageSize": "Indication de taille envoyée aux fournisseurs qui la prennent en charge.", "defaultImageSize": "Indication de taille envoyée aux fournisseurs compatibles.",
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.", "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.", "botIcon": "Emoji ou texte court affiché avec le nom du bot.",
"timezone": "Utilisé pour les planifications et les réponses sensibles à lheure.", "timezone": "Utilisé pour les horaires et les réponses tenant compte du temps.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Autorise les commandes shell Full Access à atteindre les services localhost.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Utilisé par les chats web sans permission propre au projet.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "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": "Choisissez la configuration de modèle que nanobot utilisera pour les prochaines réponses.", "currentModel": "Utilisée pour les nouvelles réponses.",
"selectedModelProvider": "Défini par le modèle sélectionné.", "selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "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.", "brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
"cliAppsCatalog": "Parcourez les CLIs d'apps que nanobot peut exécuter localement.", "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é.", "cliAppsFilter": "Recherchez par app, catégorie ou capacité.",
"logs": "Ouvrir le dossier des journaux du moteur natif.", "logs": "Ouvre le dossier des journaux du moteur natif.",
"diagnostics": "Exporter un petit rapport runtime pour le support.", "diagnostics": "Exporte un petit rapport dexécution pour le support.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.", "localServiceAccessNative": "Autorise les commandes shell Full Access à atteindre les services sur ce Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.", "webuiDefaultAccessNative": "Utilisé par les chats natifs sans permission propre au projet.",
"contextWindow": "Choose the default context budget for this model configuration." "contextWindow": "Choisissez le budget de contexte par défaut pour cette configuration de modèle."
}, },
"values": { "values": {
"light": "Clair", "light": "Clair",
"dark": "Sombre", "dark": "Sombre",
"notAvailable": "Indisponible", "notAvailable": "Indisponible",
"enabled": "Enabled", "enabled": "Activé",
"disabled": "Disabled", "disabled": "Désactivé",
"restartPending": "Redémarrage en attente", "restartPending": "Redémarrage en attente",
"ready": "Prêt", "ready": "Prêt",
"comfortable": "Comfortable", "comfortable": "Confortable",
"compact": "Compact", "compact": "Compacte",
"auto": "Auto", "auto": "Automatique",
"expanded": "Expanded", "expanded": "Développé",
"on": "On", "on": "Activé",
"off": "Off", "off": "Désactivé",
"defaultPermission": "Default Permission", "defaultPermission": "Autorisation par défaut",
"fullAccess": "Full Access", "fullAccess": "Accès complet",
"configured": "Configured", "configured": "Configuré",
"notConfigured": "Not configured", "notConfigured": "Non configuré",
"pending": "En attente", "pending": "En attente",
"restartingEngine": "Redémarrage" "restartingEngine": "Redémarrage"
}, },
"status": { "status": {
"loading": "Chargement des paramètres...", "loading": "Chargement des réglages...",
"loadError": "Impossible de charger les paramètres", "loadError": "Impossible de charger les réglages",
"unsaved": "Modifications non enregistrées.", "unsaved": "Modifications non enregistrées.",
"upToDate": "À jour.", "upToDate": "À jour.",
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer.", "savedRestart": "Enregistré. Redémarrez nanobot pour appliquer.",
"restartAfterSaving": "Enregistrez les modifications, puis redémarrez lorsque vous êtes prêt.", "restartAfterSaving": "Enregistrez les changements, puis redémarrez quand vous êtes prêt.",
"savedRestartApply": "Enregistré. Redémarrez lorsque vous êtes prêt.", "savedRestartApply": "Enregistré. Redémarrez quand vous êtes prêt.",
"imageProviderRestart": "Modifications du fournisseur dimages enregistrées. Redémarrez lorsque vous êtes prêt.", "imageProviderRestart": "Changements du fournisseur dimage enregistrés. Redémarrez quand vous êtes prêt.",
"hostRestartAfterSaving": "Enregistrez les changements et nanobot redémarrera son moteur.", "hostRestartAfterSaving": "En enregistrant, nanobot redémarrera son moteur.",
"hostRestartPending": "Enregistré. Redémarrage du moteur quand il sera prêt.", "hostRestartPending": "Enregistré. Le moteur redémarrera lorsquil sera prêt.",
"hostApiUnavailable": "Host actions are only available inside the native app.", "hostApiUnavailable": "Les actions de lhôte ne sont disponibles que dans lapp native.",
"logsOpened": "Opened logs folder.", "logsOpened": "Dossier des journaux ouvert.",
"logsOpenFailed": "Could not open logs folder.", "logsOpenFailed": "Impossible douvrir le dossier des journaux.",
"diagnosticsExported": "Diagnostics exported to {{path}}.", "diagnosticsExported": "Diagnostic exporté vers {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics." "diagnosticsExportFailed": "Impossible dexporter le diagnostic."
}, },
"actions": { "actions": {
"save": "Enregistrer", "save": "Enregistrer",
@@ -224,8 +224,8 @@
"cancel": "Annuler", "cancel": "Annuler",
"open": "Ouvrir", "open": "Ouvrir",
"export": "Exporter", "export": "Exporter",
"opening": "Opening...", "opening": "Ouverture...",
"exporting": "Exporting..." "exporting": "Exportation..."
}, },
"byok": { "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.", "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": { "tabs": {
"ariaLabel": "Type d'identifiants BYOK", "ariaLabel": "Type d'identifiants BYOK",
"llm": "LLM", "llm": "LLM",
"webSearch": "Web Search" "webSearch": "Recherche web"
}, },
"webSearch": { "webSearch": {
"provider": "Fournisseur de recherche", "provider": "Fournisseur de recherche",
@@ -270,53 +270,53 @@
} }
}, },
"overview": { "overview": {
"model": "Current model", "model": "Modèle actuel",
"providers": "Providers", "providers": "Fournisseurs",
"configuredCount": "{{count}} configured", "configuredCount": "{{count}} configurés",
"totalProviders": "{{count}} available", "totalProviders": "{{count}} disponibles",
"webSearch": "Web search", "webSearch": "Recherche web",
"imageGeneration": "Génération d'images", "imageGeneration": "Génération dimages",
"workspace": "Workspace" "workspace": "Espace de travail"
}, },
"providers": { "providers": {
"searchPlaceholder": "Search providers", "searchPlaceholder": "Rechercher des fournisseurs",
"noMatches": "No providers match this search.", "noMatches": "Aucun fournisseur ne correspond.",
"saveProvider": "Enregistrer le fournisseur" "saveProvider": "Enregistrer le fournisseur"
}, },
"image": { "image": {
"selectProvider": "Sélectionner un fournisseur", "selectProvider": "Choisir un fournisseur",
"selectAspect": "Sélectionner un format", "selectAspect": "Choisir un ratio",
"selectSize": "Sélectionner une taille", "selectSize": "Choisir une taille",
"configureProvider": "Configurer le fournisseur", "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": { "models": {
"selectModel": "Sélectionner un modèle", "selectModel": "Choisir un modèle",
"addConfiguration": "Ajouter une configuration", "addConfiguration": "Ajouter une configuration",
"newConfiguration": "Nouvelle configuration de modèle", "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", "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", "configurationNamePlaceholder": "Rédaction rapide",
"searchModels": "Rechercher ou saisir lID du modèle", "searchModels": "Rechercher ou saisir un ID de modèle",
"useCustomModel": "Utiliser", "useCustomModel": "Utiliser",
"loadingModels": "Chargement des modèles...", "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", "modelsAvailable": "disponibles",
"noModelResults": "Aucun modèle correspondant.", "noModelResults": "Aucun modèle correspondant.",
"loadFailed": "Liste des modèles indisponible.", "loadFailed": "Liste des modèles indisponible.",
"unsupportedModelList": "Saisissez manuellement un ID de modèle.", "unsupportedModelList": "Saisissez manuellement un ID de modèle.",
"providerNotConfigured": "Configurez ce fournisseur avant de charger les modèles.", "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": { "timezone": {
"select": "Sélectionner un fuseau horaire", "select": "Choisir un fuseau horaire",
"search": "Rechercher un fuseau horaire", "search": "Rechercher un fuseau horaire",
"empty": "Aucun fuseau horaire correspondant." "empty": "Aucun fuseau horaire correspondant."
}, },
"cliApps": { "cliApps": {
"allCategories": "Toutes les catégories", "allCategories": "Toutes les catégories",
"availableCount": "{{count}} apps", "availableCount": "{{count}} applications",
"installedCount": "{{count}} installées", "installedCount": "{{count}} installées",
"summary": "{{installed}} CLIs installées sur {{total}}", "summary": "{{installed}} CLIs installées sur {{total}}",
"filterAll": "Tout", "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." "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": { "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", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"filterAll": "Tout", "filterAll": "Tout",
@@ -411,22 +411,22 @@
"enabledSummary": "{{count}} activés", "enabledSummary": "{{count}} activés",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Rechercher des apps", "searchPlaceholder": "Rechercher des apps",
"featured": "En vedette", "featured": "À la une",
"loading": "Chargement des apps...", "loading": "Chargement des apps...",
"empty": "Aucune app ne correspond à ce filtre." "empty": "Aucune app ne correspond."
}, },
"oauth": { "oauth": {
"authentication": "OAuth authentication", "authentication": "Authentification OAuth",
"signIn": "Sign in", "signIn": "Se connecter",
"signingIn": "Signing in...", "signingIn": "Connexion...",
"signInAgain": "Sign in again", "signInAgain": "Se reconnecter",
"signOut": "Sign out", "signOut": "Se déconnecter",
"signedInAs": "Signed in as {{account}}", "signedInAs": "Connecté en tant que {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.", "signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"signInRequired": "Sign in required", "signInRequired": "Connexion requise",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.", "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Signed in", "signedIn": "Connecté",
"notSignedIn": "Not signed in" "notSignedIn": "Non connecté"
} }
}, },
"chat": { "chat": {
+127 -127
View File
@@ -57,7 +57,7 @@
"apps": "Aplikasi" "apps": "Aplikasi"
}, },
"settings": { "settings": {
"backToChat": "Kembali ke obrolan", "backToChat": "Kembali ke chat",
"sidebar": { "sidebar": {
"title": "Pengaturan", "title": "Pengaturan",
"ariaLabel": "Bagian pengaturan" "ariaLabel": "Bagian pengaturan"
@@ -65,14 +65,14 @@
"nav": { "nav": {
"general": "Umum", "general": "Umum",
"byok": "BYOK", "byok": "BYOK",
"overview": "Overview", "overview": "Ikhtisar",
"appearance": "Appearance", "appearance": "Tampilan",
"models": "Models", "models": "Model",
"providers": "Providers", "providers": "Penyedia",
"image": "Image", "image": "Gambar",
"browser": "Web", "browser": "Internet",
"runtime": "Sistem", "runtime": "Sistem",
"advanced": "Security", "advanced": "Keamanan",
"cliApps": "Aplikasi CLI", "cliApps": "Aplikasi CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Aplikasi" "apps": "Aplikasi"
@@ -82,20 +82,20 @@
"ai": "AI", "ai": "AI",
"system": "Sistem", "system": "Sistem",
"status": "Status", "status": "Status",
"localPreferences": "Local preferences", "localPreferences": "Preferensi lokal",
"presets": "Presets", "presets": "Preset",
"imageGeneration": "Pembuatan gambar", "imageGeneration": "Pembuatan gambar",
"imageDefaults": "Default", "imageDefaults": "Default",
"webSearch": "Web search", "webSearch": "Pencarian web",
"webBehavior": "Behavior", "webBehavior": "Perilaku",
"identity": "Identity", "identity": "Identitas",
"webuiSafety": "Web safety", "webuiSafety": "Keamanan WebUI",
"capabilities": "Kapabilitas", "capabilities": "Kemampuan",
"cliApps": "App CLI", "cliApps": "Aplikasi CLI",
"mcp": "Layanan MCP", "mcp": "Layanan MCP",
"apps": "Aplikasi", "apps": "Aplikasi",
"nativeHost": "Native host", "nativeHost": "Host native",
"hostSafety": "App safety" "hostSafety": "Keamanan aplikasi"
}, },
"rows": { "rows": {
"theme": "Tema", "theme": "Tema",
@@ -104,18 +104,18 @@
"model": "Model", "model": "Model",
"restart": "Mulai ulang nanobot", "restart": "Mulai ulang nanobot",
"configPath": "Path konfigurasi", "configPath": "Path konfigurasi",
"activePreset": "Active preset", "activePreset": "Preset aktif",
"gateway": "Gateway", "gateway": "Gerbang",
"restartState": "Restart state", "restartState": "Status mulai ulang",
"pendingChanges": "Perubahan tertunda", "pendingChanges": "Perubahan tertunda",
"selectedPreset": "Selected preset", "selectedPreset": "Preset terpilih",
"presetModel": "Preset model", "presetModel": "Model preset",
"density": "Density", "density": "Kerapatan",
"activityMode": "Activity detail", "activityMode": "Detail aktivitas",
"codeWrap": "Code wrapping", "codeWrap": "Bungkus kode",
"maxResults": "Max results", "maxResults": "Hasil maksimum",
"timeout": "Timeout", "timeout": "Batas waktu",
"jinaReader": "Jina reader", "jinaReader": "Pembaca Jina",
"imageGeneration": "Pembuatan gambar", "imageGeneration": "Pembuatan gambar",
"imageProvider": "Penyedia gambar", "imageProvider": "Penyedia gambar",
"imageProviderStatus": "Status penyedia", "imageProviderStatus": "Status penyedia",
@@ -124,98 +124,98 @@
"defaultAspectRatio": "Rasio default", "defaultAspectRatio": "Rasio default",
"defaultImageSize": "Ukuran default", "defaultImageSize": "Ukuran default",
"maxImagesPerTurn": "Maks. gambar per giliran", "maxImagesPerTurn": "Maks. gambar per giliran",
"imageSaveDir": "Direktori penyimpanan", "imageSaveDir": "Direktori simpan",
"botName": "Bot name", "botName": "Nama bot",
"botIcon": "Bot icon", "botIcon": "Ikon bot",
"timezone": "Timezone", "timezone": "Zona waktu",
"workspacePath": "Workspace default", "workspacePath": "Workspace default",
"localServiceAccess": "Local services", "localServiceAccess": "Layanan lokal",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Akses default",
"currentModel": "Konfigurasi saat ini", "currentModel": "Konfigurasi saat ini",
"brandLogos": "Logo merek", "brandLogos": "Logo merek",
"cliAppsCatalog": "Katalog aplikasi CLI", "cliAppsCatalog": "Katalog",
"cliAppsFilter": "Filter aplikasi CLI", "cliAppsFilter": "Saring",
"engine": "Engine", "engine": "Mesin",
"logs": "Log", "logs": "Log",
"diagnostics": "Diagnostik", "diagnostics": "Diagnostik",
"contextWindow": "Context window" "contextWindow": "Jendela konteks"
}, },
"help": { "help": {
"theme": "Beralih antara tampilan terang dan gelap.", "theme": "Beralih antara tampilan terang dan gelap.",
"language": "Pilih bahasa yang digunakan WebUI.", "language": "Pilih bahasa yang digunakan WebUI.",
"provider": "Pilih penyedia untuk permintaan model baru.", "provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
"model": "Atur nama model default yang digunakan nanobot.", "model": "Define el nombre de modelo predeterminado de nanobot.",
"configPath": "File konfigurasi gateway yang sedang digunakan.", "configPath": "Archivo de configuración que usa actualmente el gateway.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.", "selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.", "presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.",
"density": "Stored only in this browser.", "density": "Hanya disimpan di browser ini.",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
"maxResults": "Results returned by each web_search call.", "maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Seconds before a search provider request times out.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
"jinaReader": "Use Jina Reader for web_fetch when available.", "jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
"imageGeneration": "Tampilkan generate_image di chat saat penyedia gambar yang dikonfigurasi tersedia.", "imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
"imageProvider": "Pilih penyedia registry yang digunakan oleh generate_image.", "imageProvider": "Elige el proveedor registrado usado por generate_image.",
"imageProviderStatus": "Pembuatan gambar menggunakan ulang kredensial penyedia dari Providers.", "imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
"imageModel": "Nama model yang dikirim ke penyedia gambar yang dipilih.", "imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
"defaultAspectRatio": "Digunakan saat prompt tidak memilih rasio aspek.", "defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
"defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.", "defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.",
"maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.", "maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.",
"botName": "Ditampilkan di tempat nanobot memakai nama tampilan.", "botName": "Se muestra donde nanobot usa un nombre visible.",
"botIcon": "Emoji atau teks pendek yang tampil bersama nama bot.", "botIcon": "Emoji o texto corto junto al nombre del bot.",
"timezone": "Dipakai untuk jadwal dan balasan yang peka waktu.", "timezone": "Se usa para horarios y respuestas con conciencia temporal.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Izinkan perintah shell Full Access menjangkau layanan localhost.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Digunakan oleh chat web tanpa izin khusus proyek.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
"currentModel": "Pilih konfigurasi model yang digunakan nanobot untuk balasan berikutnya.", "currentModel": "Digunakan untuk balasan baru.",
"selectedModelProvider": "Ditentukan oleh model yang dipilih.", "selectedModelProvider": "Definido por el modelo seleccionado.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.", "selectedModelValue": "Definido por el modelo seleccionado.",
"brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.", "brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
"cliAppsCatalog": "Jelajahi CLI aplikasi yang dapat dijalankan nanobot secara lokal.", "cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.", "cliAppsFilter": "Busca por app, categoría o capacidad.",
"logs": "Buka folder log native engine.", "logs": "Abre la carpeta de registros del motor nativo.",
"diagnostics": "Ekspor laporan runtime kecil untuk dukungan.", "diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.", "localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.", "webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
"contextWindow": "Choose the default context budget for this model configuration." "contextWindow": "Pilih anggaran konteks default untuk konfigurasi model ini."
}, },
"values": { "values": {
"light": "Terang", "light": "Terang",
"dark": "Gelap", "dark": "Gelap",
"notAvailable": "Tidak tersedia", "notAvailable": "Tidak tersedia",
"enabled": "Enabled", "enabled": "Aktif",
"disabled": "Disabled", "disabled": "Nonaktif",
"restartPending": "Menunggu restart", "restartPending": "Menunggu mulai ulang",
"ready": "Siap", "ready": "Siap",
"comfortable": "Comfortable", "comfortable": "Nyaman",
"compact": "Compact", "compact": "Ringkas",
"auto": "Auto", "auto": "Otomatis",
"expanded": "Expanded", "expanded": "Diperluas",
"on": "On", "on": "Aktif",
"off": "Off", "off": "Nonaktif",
"defaultPermission": "Default Permission", "defaultPermission": "Izin default",
"fullAccess": "Full Access", "fullAccess": "Akses penuh",
"configured": "Configured", "configured": "Terkonfigurasi",
"notConfigured": "Not configured", "notConfigured": "Belum dikonfigurasi",
"pending": "Tertunda", "pending": "Tertunda",
"restartingEngine": "Memulai ulang" "restartingEngine": "Memulai ulang"
}, },
"status": { "status": {
"loading": "Memuat pengaturan...", "loading": "Memuat pengaturan...",
"loadError": "Tidak dapat memuat pengaturan", "loadError": "Tidak dapat memuat pengaturan",
"unsaved": "Ada perubahan yang belum disimpan.", "unsaved": "Perubahan belum disimpan.",
"upToDate": "Sudah terbaru.", "upToDate": "Sudah terbaru.",
"savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.", "savedRestart": "Guardado. Reinicia nanobot para aplicar.",
"restartAfterSaving": "Simpan perubahan, lalu restart saat siap.", "restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
"savedRestartApply": "Tersimpan. Restart saat siap.", "savedRestartApply": "Guardado. Reinicia cuando puedas.",
"imageProviderRestart": "Perubahan penyedia gambar tersimpan. Restart saat siap.", "imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
"hostRestartAfterSaving": "Simpan perubahan dan nanobot akan memulai ulang engine.", "hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
"hostRestartPending": "Tersimpan. Engine akan dimulai ulang saat siap.", "hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
"hostApiUnavailable": "Host actions are only available inside the native app.", "hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
"logsOpened": "Opened logs folder.", "logsOpened": "Carpeta de registros abierta.",
"logsOpenFailed": "Could not open logs folder.", "logsOpenFailed": "No se pudo abrir la carpeta de registros.",
"diagnosticsExported": "Diagnostics exported to {{path}}.", "diagnosticsExported": "Diagnóstico exportado a {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics." "diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
}, },
"actions": { "actions": {
"save": "Simpan", "save": "Simpan",
@@ -224,8 +224,8 @@
"cancel": "Batal", "cancel": "Batal",
"open": "Buka", "open": "Buka",
"export": "Ekspor", "export": "Ekspor",
"opening": "Opening...", "opening": "Membuka...",
"exporting": "Exporting..." "exporting": "Mengekspor..."
}, },
"byok": { "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.", "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": { "tabs": {
"ariaLabel": "Jenis kredensial BYOK", "ariaLabel": "Jenis kredensial BYOK",
"llm": "LLM", "llm": "LLM",
"webSearch": "Web Search" "webSearch": "Pencarian web"
}, },
"webSearch": { "webSearch": {
"provider": "Search provider", "provider": "Penyedia pencarian",
"providerHelp": "Pilih backend yang digunakan alat web search.", "providerHelp": "Pilih backend yang digunakan alat web search.",
"selectProvider": "Pilih provider", "selectProvider": "Pilih provider",
"credentials": "Kredensial", "credentials": "Kredensial",
@@ -270,17 +270,17 @@
} }
}, },
"overview": { "overview": {
"model": "Current model", "model": "Model saat ini",
"providers": "Providers", "providers": "Penyedia",
"configuredCount": "{{count}} configured", "configuredCount": "{{count}} dikonfigurasi",
"totalProviders": "{{count}} available", "totalProviders": "{{count}} tersedia",
"webSearch": "Web search", "webSearch": "Pencarian web",
"imageGeneration": "Pembuatan gambar", "imageGeneration": "Pembuatan gambar",
"workspace": "Workspace" "workspace": "Ruang kerja"
}, },
"providers": { "providers": {
"searchPlaceholder": "Search providers", "searchPlaceholder": "Cari penyedia",
"noMatches": "No providers match this search.", "noMatches": "Tidak ada penyedia yang cocok.",
"saveProvider": "Simpan penyedia" "saveProvider": "Simpan penyedia"
}, },
"image": { "image": {
@@ -288,7 +288,7 @@
"selectAspect": "Pilih rasio", "selectAspect": "Pilih rasio",
"selectSize": "Pilih ukuran", "selectSize": "Pilih ukuran",
"configureProvider": "Konfigurasi penyedia", "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": { "models": {
"selectModel": "Pilih model", "selectModel": "Pilih model",
@@ -296,8 +296,8 @@
"newConfiguration": "Konfigurasi model baru", "newConfiguration": "Konfigurasi model baru",
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.", "newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
"configurationName": "Nama konfigurasi", "configurationName": "Nama konfigurasi",
"configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.", "configurationNameHelp": "Ganti nama konfigurasi model tersimpan ini.",
"configurationNamePlaceholder": "Penulisan cepat", "configurationNamePlaceholder": "Menulis cepat",
"searchModels": "Cari atau ketik ID model", "searchModels": "Cari atau ketik ID model",
"useCustomModel": "Gunakan", "useCustomModel": "Gunakan",
"loadingModels": "Memuat model...", "loadingModels": "Memuat model...",
@@ -307,7 +307,7 @@
"loadFailed": "Daftar model tidak tersedia.", "loadFailed": "Daftar model tidak tersedia.",
"unsupportedModelList": "Ketik ID model secara manual.", "unsupportedModelList": "Ketik ID model secara manual.",
"providerNotConfigured": "Konfigurasikan penyedia ini sebelum memuat model.", "providerNotConfigured": "Konfigurasikan penyedia ini sebelum memuat model.",
"autoProviderCustomOnly": "Mode penyedia otomatis menggunakan ID model khusus." "autoProviderCustomOnly": "Mode penyedia otomatis memakai ID model kustom."
}, },
"timezone": { "timezone": {
"select": "Pilih zona waktu", "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." "thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
}, },
"apps": { "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", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"filterAll": "Semua", "filterAll": "Semua",
"filterCli": "App CLI", "filterCli": "Aplikasi CLI",
"filterMcp": "Layanan MCP", "filterMcp": "Layanan MCP",
"enabledSummary": "{{count}} aktif", "enabledSummary": "{{count}} aktif",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Cari aplikasi", "searchPlaceholder": "Cari aplikasi",
"featured": "Unggulan", "featured": "Unggulan",
"loading": "Memuat aplikasi...", "loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok dengan filter ini." "empty": "Tidak ada aplikasi yang cocok."
}, },
"oauth": { "oauth": {
"authentication": "OAuth authentication", "authentication": "Autentikasi OAuth",
"signIn": "Sign in", "signIn": "Masuk",
"signingIn": "Signing in...", "signingIn": "Masuk...",
"signInAgain": "Sign in again", "signInAgain": "Masuk lagi",
"signOut": "Sign out", "signOut": "Keluar",
"signedInAs": "Signed in as {{account}}", "signedInAs": "Masuk sebagai {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.", "signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"signInRequired": "Sign in required", "signInRequired": "Perlu masuk",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.", "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Signed in", "signedIn": "Sudah masuk",
"notSignedIn": "Not signed in" "notSignedIn": "Belum masuk"
} }
}, },
"chat": { "chat": {
+107 -107
View File
@@ -65,14 +65,14 @@
"nav": { "nav": {
"general": "一般", "general": "一般",
"byok": "BYOK", "byok": "BYOK",
"overview": "Overview", "overview": "概要",
"appearance": "Appearance", "appearance": "外観",
"models": "Models", "models": "モデル",
"providers": "Providers", "providers": "プロバイダー",
"image": "Image", "image": "画像",
"browser": "Web", "browser": "ウェブ",
"runtime": "システム", "runtime": "システム",
"advanced": "Security", "advanced": "セキュリティ",
"cliApps": "CLI アプリ", "cliApps": "CLI アプリ",
"mcp": "MCP", "mcp": "MCP",
"apps": "アプリ" "apps": "アプリ"
@@ -81,21 +81,21 @@
"interface": "インターフェース", "interface": "インターフェース",
"ai": "AI", "ai": "AI",
"system": "システム", "system": "システム",
"status": "Status", "status": "状態",
"localPreferences": "Local preferences", "localPreferences": "ローカル設定",
"presets": "Presets", "presets": "プリセット",
"imageGeneration": "画像生成", "imageGeneration": "画像生成",
"imageDefaults": "既定値", "imageDefaults": "既定値",
"webSearch": "Web search", "webSearch": "ウェブ検索",
"webBehavior": "Behavior", "webBehavior": "動作",
"identity": "Identity", "identity": "ID",
"webuiSafety": "Web safety", "webuiSafety": "WebUI の安全性",
"capabilities": "機能", "capabilities": "機能",
"cliApps": "CLI アプリ", "cliApps": "CLI アプリ",
"mcp": "MCP サービス", "mcp": "MCP サービス",
"apps": "アプリ", "apps": "アプリ",
"nativeHost": "App", "nativeHost": "ネイティブホスト",
"hostSafety": "App safety" "hostSafety": "アプリの安全性"
}, },
"rows": { "rows": {
"theme": "テーマ", "theme": "テーマ",
@@ -104,41 +104,41 @@
"model": "モデル", "model": "モデル",
"restart": "nanobot を再起動", "restart": "nanobot を再起動",
"configPath": "設定パス", "configPath": "設定パス",
"activePreset": "Active preset", "activePreset": "アクティブなプリセット",
"gateway": "Gateway", "gateway": "ゲートウェイ",
"restartState": "Restart state", "restartState": "再起動状態",
"pendingChanges": "保留中の変更", "pendingChanges": "保留中の変更",
"selectedPreset": "Selected preset", "selectedPreset": "選択中のプリセット",
"presetModel": "Preset model", "presetModel": "プリセットモデル",
"density": "Density", "density": "表示密度",
"activityMode": "Activity detail", "activityMode": "アクティビティ詳細",
"codeWrap": "Code wrapping", "codeWrap": "コードの折り返し",
"maxResults": "Max results", "maxResults": "最大結果数",
"timeout": "Timeout", "timeout": "タイムアウト",
"jinaReader": "Jina reader", "jinaReader": "Jina リーダー",
"imageGeneration": "画像生成", "imageGeneration": "画像生成",
"imageProvider": "画像プロバイダー", "imageProvider": "画像プロバイダー",
"imageProviderStatus": "プロバイダー状態", "imageProviderStatus": "プロバイダー状態",
"imageProviderBase": "プロバイダーのベース URL", "imageProviderBase": "プロバイダー URL",
"imageModel": "画像モデル", "imageModel": "画像モデル",
"defaultAspectRatio": "既定の比率", "defaultAspectRatio": "既定の比率",
"defaultImageSize": "既定のサイズ", "defaultImageSize": "既定のサイズ",
"maxImagesPerTurn": "1 ターンあたりの最大画像数", "maxImagesPerTurn": "1 ターンの最大画像数",
"imageSaveDir": "保存先ディレクトリ", "imageSaveDir": "保存先ディレクトリ",
"botName": "Bot name", "botName": "Bot ",
"botIcon": "Bot icon", "botIcon": "Bot アイコン",
"timezone": "Timezone", "timezone": "タイムゾーン",
"workspacePath": "デフォルトワークスペース", "workspacePath": "既定のワークスペース",
"localServiceAccess": "Local services", "localServiceAccess": "ローカルサービス",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "既定の権限",
"currentModel": "現在の設定", "currentModel": "現在の設定",
"brandLogos": "ブランドロゴ", "brandLogos": "ブランドロゴ",
"cliAppsCatalog": "CLI アプリカタログ", "cliAppsCatalog": "カタログ",
"cliAppsFilter": "CLI アプリフィルター", "cliAppsFilter": "フィルター",
"engine": "エンジン", "engine": "エンジン",
"logs": "ログ", "logs": "ログ",
"diagnostics": "診断", "diagnostics": "診断",
"contextWindow": "Context window" "contextWindow": "コンテキストウィンドウ"
}, },
"help": { "help": {
"theme": "ライト表示とダーク表示を切り替えます。", "theme": "ライト表示とダーク表示を切り替えます。",
@@ -146,57 +146,57 @@
"provider": "新しいモデルリクエストに使うプロバイダーを選択します。", "provider": "新しいモデルリクエストに使うプロバイダーを選択します。",
"model": "nanobot が既定で使用するモデル名を設定します。", "model": "nanobot が既定で使用するモデル名を設定します。",
"configPath": "現在ゲートウェイが使用している設定ファイルです。", "configPath": "現在ゲートウェイが使用している設定ファイルです。",
"selectedPreset": "Named presets are read-only here; edit them in config.json.", "selectedPreset": "名前付きプリセットはここでは読み取り専用です。編集するには config.json を変更してください。",
"presetModel": "Switch to Default to edit model and provider from the WebUI.", "presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。",
"density": "Stored only in this browser.", "density": "このブラウザーにのみ保存されます。",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "既定で表示する agent アクティビティの詳細量を選択します。",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "小さな画面でも長いコード行を読みやすくします。",
"maxResults": "Results returned by each web_search call.", "maxResults": "各 web_search 呼び出しで返す結果数です。",
"timeout": "Seconds before a search provider request times out.", "timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
"jinaReader": "Use Jina Reader for web_fetch when available.", "jinaReader": "利用可能な場合、web_fetch に Jina Reader を使います。",
"imageGeneration": "設定済みの画像プロバイダーが利用できる場合、チャットで generate_image を有効にします。", "imageGeneration": "画像プロバイダーが設定済みのとき、チャットで generate_image を有効にします。",
"imageProvider": "generate_image で使用する登録済みプロバイダーを選択します。", "imageProvider": "generate_image で使用する登録済みプロバイダーを選択します。",
"imageProviderStatus": "画像生成は「プロバイダー」の認証情報を再利用します。", "imageProviderStatus": "画像生成は「プロバイダー」の認証情報を再利用します。",
"imageModel": "選択した画像プロバイダーへ送信するモデル名です。", "imageModel": "選択した画像プロバイダーへ送信するモデル名です。",
"defaultAspectRatio": "プロンプトでアスペクト比が指定されていない場合に使用します。", "defaultAspectRatio": "プロンプトで比が指定されていない場合に使用します。",
"defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。", "defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。",
"maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。", "maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。",
"botName": "nanobot が表示名を使う場所に表示されます。", "botName": "nanobot が表示名を使う場所に表示されます。",
"botIcon": "Bot 名の横に表示する短い emoji またはテキストです。", "botIcon": "Bot 名の横に表示する短い emoji またはテキストです。",
"timezone": "スケジュールと時刻を考慮する返信に使用します。", "timezone": "スケジュールと時刻を考慮する返信に使用します。",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Full Access shell コマンドが localhost サービスにアクセスできるようにします。",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "プロジェクト固有の権限がない Web チャットで使用します。",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Web 取得は常にローカル、プライベート、メタデータサービスを保護します。コアチャネルの安全性は config.json で管理されます。",
"currentModel": "今後の返信で nanobot が使用するモデル設定を選択します。", "currentModel": "新しい返信に使用します。",
"selectedModelProvider": "選択したモデルによって設定されます。", "selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。", "selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。", "brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。", "cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。", "cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
"logs": "Appエンジンのログフォルダを開きます。", "logs": "ネイティブエンジンのログフォルダを開きます。",
"diagnostics": "サポート用の小さなランタイムレポートを書き出します。", "diagnostics": "サポート用の小さなランタイムレポートを書き出します。",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.", "localServiceAccessNative": "Full Access shell コマンドがこの Mac 上のサービスにアクセスできるようにします。",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.", "webuiDefaultAccessNative": "プロジェクト固有の権限がないネイティブチャットで使用します。",
"contextWindow": "Choose the default context budget for this model configuration." "contextWindow": "このモデル設定で使う既定のコンテキスト予算を選択します。"
}, },
"values": { "values": {
"light": "ライト", "light": "ライト",
"dark": "ダーク", "dark": "ダーク",
"notAvailable": "利用不可", "notAvailable": "利用不可",
"enabled": "Enabled", "enabled": "有効",
"disabled": "Disabled", "disabled": "無効",
"restartPending": "再起動待ち", "restartPending": "再起動待ち",
"ready": "準備完了", "ready": "準備完了",
"comfortable": "Comfortable", "comfortable": "標準",
"compact": "Compact", "compact": "コンパクト",
"auto": "Auto", "auto": "自動",
"expanded": "Expanded", "expanded": "展開",
"on": "On", "on": "オン",
"off": "Off", "off": "オフ",
"defaultPermission": "Default Permission", "defaultPermission": "既定の権限",
"fullAccess": "Full Access", "fullAccess": "完全アクセス",
"configured": "Configured", "configured": "設定済み",
"notConfigured": "Not configured", "notConfigured": "未設定",
"pending": "保留中", "pending": "保留中",
"restartingEngine": "再起動中" "restartingEngine": "再起動中"
}, },
@@ -211,11 +211,11 @@
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。", "imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。",
"hostRestartAfterSaving": "保存すると nanobot がエンジンを再起動します。", "hostRestartAfterSaving": "保存すると nanobot がエンジンを再起動します。",
"hostRestartPending": "保存しました。準備ができたらエンジンを再起動します。", "hostRestartPending": "保存しました。準備ができたらエンジンを再起動します。",
"hostApiUnavailable": "Host actions are only available inside the native app.", "hostApiUnavailable": "ホスト操作はネイティブアプリ内でのみ利用できます。",
"logsOpened": "Opened logs folder.", "logsOpened": "ログフォルダーを開きました。",
"logsOpenFailed": "Could not open logs folder.", "logsOpenFailed": "ログフォルダーを開けませんでした。",
"diagnosticsExported": "Diagnostics exported to {{path}}.", "diagnosticsExported": "診断を {{path}} に書き出しました。",
"diagnosticsExportFailed": "Could not export diagnostics." "diagnosticsExportFailed": "診断を書き出せませんでした。"
}, },
"actions": { "actions": {
"save": "保存", "save": "保存",
@@ -224,8 +224,8 @@
"cancel": "キャンセル", "cancel": "キャンセル",
"open": "開く", "open": "開く",
"export": "書き出す", "export": "書き出す",
"opening": "Opening...", "opening": "開いています...",
"exporting": "Exporting..." "exporting": "書き出しています..."
}, },
"byok": { "byok": {
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。", "description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
@@ -250,7 +250,7 @@
"tabs": { "tabs": {
"ariaLabel": "BYOK 認証情報タイプ", "ariaLabel": "BYOK 認証情報タイプ",
"llm": "LLM", "llm": "LLM",
"webSearch": "Web Search" "webSearch": "ウェブ検索"
}, },
"webSearch": { "webSearch": {
"provider": "検索 provider", "provider": "検索 provider",
@@ -270,17 +270,17 @@
} }
}, },
"overview": { "overview": {
"model": "Current model", "model": "現在のモデル",
"providers": "Providers", "providers": "プロバイダー",
"configuredCount": "{{count}} configured", "configuredCount": "{{count}} 個設定済み",
"totalProviders": "{{count}} available", "totalProviders": "{{count}} 個利用可能",
"webSearch": "Web search", "webSearch": "Web 検索",
"imageGeneration": "画像生成", "imageGeneration": "画像生成",
"workspace": "Workspace" "workspace": "ワークスペース"
}, },
"providers": { "providers": {
"searchPlaceholder": "Search providers", "searchPlaceholder": "プロバイダーを検索",
"noMatches": "No providers match this search.", "noMatches": "一致するプロバイダーはありません。",
"saveProvider": "プロバイダーを保存" "saveProvider": "プロバイダーを保存"
}, },
"image": { "image": {
@@ -297,12 +297,12 @@
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。", "newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
"configurationName": "設定名", "configurationName": "設定名",
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。", "configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
"configurationNamePlaceholder": "高速ライティング", "configurationNamePlaceholder": "高速執筆",
"searchModels": "検索またはモデル ID を入力", "searchModels": "モデル ID を検索または入力",
"useCustomModel": "使用", "useCustomModel": "使用",
"loadingModels": "モデルを読み込み中...", "loadingModels": "モデルを読み込んでいます...",
"searchCatalog": "プロバイダーのカタログを検索してモデルを選択します。", "searchCatalog": "プロバイダーのカタログからモデルを選択します。",
"modelsAvailable": "利用可能", "modelsAvailable": "利用可能",
"noModelResults": "一致するモデルはありません。", "noModelResults": "一致するモデルはありません。",
"loadFailed": "モデル一覧を利用できません。", "loadFailed": "モデル一覧を利用できません。",
"unsupportedModelList": "モデル ID を手動で入力してください。", "unsupportedModelList": "モデル ID を手動で入力してください。",
@@ -402,31 +402,31 @@
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。" "thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
}, },
"apps": { "apps": {
"description": "チャットから nanobot が使えるアプリ CLI と MCP サービスを追加します。", "description": "nanobot がチャットで使用できる App CLI と MCP サービスを追加します。",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"filterAll": "すべて", "filterAll": "すべて",
"filterCli": "CLI アプリ", "filterCli": "CLI アプリ",
"filterMcp": "MCP サービス", "filterMcp": "MCP サービス",
"enabledSummary": "{{count}} 件有効", "enabledSummary": "{{count}} 件有効",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "CLI {{cli}} 件 · MCP {{mcp}} ",
"searchPlaceholder": "アプリを検索", "searchPlaceholder": "アプリを検索",
"featured": "おすすめ", "featured": "注目",
"loading": "アプリを読み込み中...", "loading": "アプリを読み込み中...",
"empty": "このフィルターに一致するアプリはありません。" "empty": "一致するアプリはありません。"
}, },
"oauth": { "oauth": {
"authentication": "OAuth authentication", "authentication": "OAuth 認証",
"signIn": "Sign in", "signIn": "サインイン",
"signingIn": "Signing in...", "signingIn": "サインイン中...",
"signInAgain": "Sign in again", "signInAgain": "再度サインイン",
"signOut": "Sign out", "signOut": "サインアウト",
"signedInAs": "Signed in as {{account}}", "signedInAs": "{{account}} としてサインイン済み",
"signInHelp": "Sign in from this device; no API key is stored in config.", "signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
"signInRequired": "Sign in required", "signInRequired": "サインインが必要です",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.", "signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。",
"signedIn": "Signed in", "signedIn": "サインイン済み",
"notSignedIn": "Not signed in" "notSignedIn": "未サインイン"
} }
}, },
"chat": { "chat": {
+123 -123
View File
@@ -65,14 +65,14 @@
"nav": { "nav": {
"general": "일반", "general": "일반",
"byok": "BYOK", "byok": "BYOK",
"overview": "Overview", "overview": "개요",
"appearance": "Appearance", "appearance": "외관",
"models": "Models", "models": "모델",
"providers": "Providers", "providers": "제공자",
"image": "Image", "image": "이미지",
"browser": "Web", "browser": "",
"runtime": "시스템", "runtime": "시스템",
"advanced": "Security", "advanced": "보안",
"cliApps": "CLI 앱", "cliApps": "CLI 앱",
"mcp": "MCP", "mcp": "MCP",
"apps": "앱" "apps": "앱"
@@ -81,21 +81,21 @@
"interface": "인터페이스", "interface": "인터페이스",
"ai": "AI", "ai": "AI",
"system": "시스템", "system": "시스템",
"status": "Status", "status": "상태",
"localPreferences": "Local preferences", "localPreferences": "로컬 환경설정",
"presets": "Presets", "presets": "프리셋",
"imageGeneration": "이미지 생성", "imageGeneration": "이미지 생성",
"imageDefaults": "기본값", "imageDefaults": "기본값",
"webSearch": "Web search", "webSearch": "웹 검색",
"webBehavior": "Behavior", "webBehavior": "동작",
"identity": "Identity", "identity": "ID",
"webuiSafety": "Web safety", "webuiSafety": "WebUI 보안",
"capabilities": "기능", "capabilities": "기능",
"cliApps": "CLI 앱", "cliApps": "CLI 앱",
"mcp": "MCP 서비스", "mcp": "MCP 서비스",
"apps": "앱", "apps": "앱",
"nativeHost": "App", "nativeHost": "네이티브 호스트",
"hostSafety": "App safety" "hostSafety": "앱 보안"
}, },
"rows": { "rows": {
"theme": "테마", "theme": "테마",
@@ -104,118 +104,118 @@
"model": "모델", "model": "모델",
"restart": "nanobot 재시작", "restart": "nanobot 재시작",
"configPath": "설정 경로", "configPath": "설정 경로",
"activePreset": "Active preset", "activePreset": "활성 프리셋",
"gateway": "Gateway", "gateway": "게이트웨이",
"restartState": "Restart state", "restartState": "재시작 상태",
"pendingChanges": "대기 중인 변경 사항", "pendingChanges": "대기 중인 변경",
"selectedPreset": "Selected preset", "selectedPreset": "선택한 프리셋",
"presetModel": "Preset model", "presetModel": "프리셋 모델",
"density": "Density", "density": "밀도",
"activityMode": "Activity detail", "activityMode": "활동 상세",
"codeWrap": "Code wrapping", "codeWrap": "코드 줄바꿈",
"maxResults": "Max results", "maxResults": "최대 결과 수",
"timeout": "Timeout", "timeout": "타임아웃",
"jinaReader": "Jina reader", "jinaReader": "Jina 리더",
"imageGeneration": "이미지 생성", "imageGeneration": "이미지 생성",
"imageProvider": "이미지 제공자", "imageProvider": "이미지 제공자",
"imageProviderStatus": "제공자 상태", "imageProviderStatus": "제공자 상태",
"imageProviderBase": "제공자 기준 주소", "imageProviderBase": "제공자 주소",
"imageModel": "이미지 모델", "imageModel": "이미지 모델",
"defaultAspectRatio": "기본 비율", "defaultAspectRatio": "기본 비율",
"defaultImageSize": "기본 크기", "defaultImageSize": "기본 크기",
"maxImagesPerTurn": "턴당 최대 이미지 수", "maxImagesPerTurn": "턴당 최대 이미지 수",
"imageSaveDir": "저장 디렉터리", "imageSaveDir": "저장 디렉터리",
"botName": "Bot name", "botName": "Bot 이름",
"botIcon": "Bot icon", "botIcon": "Bot 아이콘",
"timezone": "Timezone", "timezone": "시간대",
"workspacePath": "기본 작업공간", "workspacePath": "기본 작업공간",
"localServiceAccess": "Local services", "localServiceAccess": "로컬 서비스",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "기본 권한",
"currentModel": "현재 구성", "currentModel": "현재 구성",
"brandLogos": "브랜드 로고", "brandLogos": "브랜드 로고",
"cliAppsCatalog": "CLI 앱 카탈로그", "cliAppsCatalog": "카탈로그",
"cliAppsFilter": "CLI 앱 필터", "cliAppsFilter": "필터",
"engine": "엔진", "engine": "엔진",
"logs": "로그", "logs": "로그",
"diagnostics": "진단", "diagnostics": "진단",
"contextWindow": "Context window" "contextWindow": "컨텍스트 창"
}, },
"help": { "help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.", "theme": "밝은 모드와 어두운 모드를 전환합니다.",
"language": "WebUI에서 사용할 언어를 선택합니다.", "language": "WebUI에서 사용할 언어를 선택합니다.",
"provider": "새 모델 요청에 사용할 제공자를 선택합니다.", "provider": "새 모델 요청을 처리할 제공자를 선택합니다.",
"model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.", "model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.",
"configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.", "configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.", "selectedPreset": "이름 있는 프리셋은 여기서 읽기 전용입니다. config.json에서 편집하세요.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.", "presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.",
"density": "Stored only in this browser.", "density": "이 브라우저에만 저장됩니다.",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "기본으로 표시할 agent 활동 세부 수준을 선택합니다.",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
"maxResults": "Results returned by each web_search call.", "maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
"timeout": "Seconds before a search provider request times out.", "timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
"jinaReader": "Use Jina Reader for web_fetch when available.", "jinaReader": "가능할 때 web_fetch에 Jina Reader를 사용합니다.",
"imageGeneration": "구성된 이미지 제공자를 사용할 수 있을 때 채팅에서 generate_image를 노출합니다.", "imageGeneration": "구성된 이미지 제공자 있을 때 채팅에서 generate_image를 노출합니다.",
"imageProvider": "generate_image 사용할 레지스트리 제공자를 선택합니다.", "imageProvider": "generate_image 사용할 등록 제공자를 선택합니다.",
"imageProviderStatus": "이미지 생성은 Providers의 제공자 자격 증명을 재사용합니다.", "imageProviderStatus": "이미지 생성은 제공자 자격 증명을 재사용합니다.",
"imageModel": "선택한 이미지 제공자에 보낼 모델 이름입니다.", "imageModel": "선택한 이미지 제공자에 보낼 모델 이름입니다.",
"defaultAspectRatio": "프롬프트에서 가로세로 비율을 선택하지 않을 때 사용됩니다.", "defaultAspectRatio": "프롬프트 비율을 선택하지 않을 때 사용됩니다.",
"defaultImageSize": "지원하는 제공자에내는 크기 힌트입니다.", "defaultImageSize": "지원하는 제공자에 보 크기 힌트입니다.",
"maxImagesPerTurn": "한 번의 generate_image 요청에 대한 상한입니다.", "maxImagesPerTurn": "한 번의 generate_image 요청에서 생성할 수 있는 이미지 상한입니다.",
"botName": "nanobot이 표시 이름을 는 곳에 표시됩니다.", "botName": "nanobot이 표시 이름을 사용하는 곳에 표시됩니다.",
"botIcon": "Bot 이름 옆에 표시할 짧은 emoji 또는 텍스트입니다.", "botIcon": "Bot 이름 옆에 표시할 짧은 emoji 또는 텍스트입니다.",
"timezone": "예약과 시간 인식 답변에 사용됩니다.", "timezone": "일정과 시간 인식 답변에 사용됩니다.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Full Access shell 명령이 localhost 서비스에 접근할 수 있게 합니다.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "프로젝트별 권한이 없는 Web 채팅에 사용됩니다.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "웹 가져오기는 항상 로컬, 사설, 메타데이터 서비스를 보호합니다. 핵심 채널 보안은 config.json에서 관리됩니다.",
"currentModel": "nanobot이 새 답변에 사용할 모델 구성을 선택합니다.", "currentModel": "새 응답에 사용됩니다.",
"selectedModelProvider": "선택한 모델에 설정됩니다.", "selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
"selectedModelValue": "선택한 모델에 설정됩니다.", "selectedModelValue": "선택한 모델에 의해 설정됩니다.",
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.", "brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.", "cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.", "cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
"logs": "App 엔진 로그 폴더를 엽니다.", "logs": "네이티브 엔진 로그 폴더를 엽니다.",
"diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.", "diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.", "localServiceAccessNative": "Full Access shell 명령이 이 Mac의 서비스에 접근할 수 있게 합니다.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.", "webuiDefaultAccessNative": "프로젝트별 권한이 없는 네이티브 채팅에 사용됩니다.",
"contextWindow": "Choose the default context budget for this model configuration." "contextWindow": "이 모델 구성의 기본 컨텍스트 예산을 선택합니다."
}, },
"values": { "values": {
"light": "라이트", "light": "라이트",
"dark": "다크", "dark": "다크",
"notAvailable": "사용할 수 없음", "notAvailable": "사용 불가",
"enabled": "Enabled", "enabled": "활성화됨",
"disabled": "Disabled", "disabled": "비활성화됨",
"restartPending": "재시작 대기", "restartPending": "재시작 대기",
"ready": "준비됨", "ready": "준비됨",
"comfortable": "Comfortable", "comfortable": "편안함",
"compact": "Compact", "compact": "컴팩트",
"auto": "Auto", "auto": "자동",
"expanded": "Expanded", "expanded": "펼침",
"on": "On", "on": "켜짐",
"off": "Off", "off": "꺼짐",
"defaultPermission": "Default Permission", "defaultPermission": "기본 권한",
"fullAccess": "Full Access", "fullAccess": "전체 접근",
"configured": "Configured", "configured": "구성됨",
"notConfigured": "Not configured", "notConfigured": "미구성",
"pending": "대기 중", "pending": "대기 중",
"restartingEngine": "다시 시작 중" "restartingEngine": "시작 중"
}, },
"status": { "status": {
"loading": "설정을 불러오는 중...", "loading": "설정을 불러오는 중...",
"loadError": "설정을 불러올 수 없습니다", "loadError": "설정을 불러올 수 없습니다",
"unsaved": "저장되지 않은 변경 사항이 있습니다.", "unsaved": "저장되지 않은 변경 사항이 있습니다.",
"upToDate": "최신 상태입니다.", "upToDate": "최신 상태입니다.",
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요.", "savedRestart": "저장습니다. 적용하려면 nanobot을 재시작하세요.",
"restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.", "restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.",
"savedRestartApply": "저장되었습니다. 준비되면 재시작하세요.", "savedRestartApply": "저장습니다. 준비되면 재시작하세요.",
"imageProviderRestart": "이미지 제공자 변경 사항 저장되었습니다. 준비되면 재시작하세요.", "imageProviderRestart": "이미지 제공자 변경 사항 저장습니다. 준비되면 재시작하세요.",
"hostRestartAfterSaving": "저장하면 nanobot이 엔진을 다시 시작합니다.", "hostRestartAfterSaving": "저장하면 nanobot이 엔진을 시작합니다.",
"hostRestartPending": "저장되었습니다. 준비되면 엔진을 다시 시작합니다.", "hostRestartPending": "저장습니다. 준비되면 엔진을 시작합니다.",
"hostApiUnavailable": "Host actions are only available inside the native app.", "hostApiUnavailable": "호스트 작업은 네이티브 앱 안에서만 사용할 수 있습니다.",
"logsOpened": "Opened logs folder.", "logsOpened": "로그 폴더를 열었습니다.",
"logsOpenFailed": "Could not open logs folder.", "logsOpenFailed": "로그 폴더를 열 수 없습니다.",
"diagnosticsExported": "Diagnostics exported to {{path}}.", "diagnosticsExported": "진단을 {{path}}에 내보냈습니다.",
"diagnosticsExportFailed": "Could not export diagnostics." "diagnosticsExportFailed": "진단을 내보낼 수 없습니다."
}, },
"actions": { "actions": {
"save": "저장", "save": "저장",
@@ -224,8 +224,8 @@
"cancel": "취소", "cancel": "취소",
"open": "열기", "open": "열기",
"export": "내보내기", "export": "내보내기",
"opening": "Opening...", "opening": "여는 중...",
"exporting": "Exporting..." "exporting": "내보내는 중..."
}, },
"byok": { "byok": {
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.", "description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
@@ -250,7 +250,7 @@
"tabs": { "tabs": {
"ariaLabel": "BYOK 자격 증명 유형", "ariaLabel": "BYOK 자격 증명 유형",
"llm": "LLM", "llm": "LLM",
"webSearch": "Web Search" "webSearch": "웹 검색"
}, },
"webSearch": { "webSearch": {
"provider": "검색 provider", "provider": "검색 provider",
@@ -270,17 +270,17 @@
} }
}, },
"overview": { "overview": {
"model": "Current model", "model": "현재 모델",
"providers": "Providers", "providers": "제공자",
"configuredCount": "{{count}} configured", "configuredCount": "{{count}}개 구성됨",
"totalProviders": "{{count}} available", "totalProviders": "{{count}}개 사용 가능",
"webSearch": "Web search", "webSearch": "웹 검색",
"imageGeneration": "이미지 생성", "imageGeneration": "이미지 생성",
"workspace": "Workspace" "workspace": "작업공간"
}, },
"providers": { "providers": {
"searchPlaceholder": "Search providers", "searchPlaceholder": "제공자 검색",
"noMatches": "No providers match this search.", "noMatches": "일치하는 제공자가 없습니다.",
"saveProvider": "제공자 저장" "saveProvider": "제공자 저장"
}, },
"image": { "image": {
@@ -294,15 +294,15 @@
"selectModel": "모델 선택", "selectModel": "모델 선택",
"addConfiguration": "구성 추가", "addConfiguration": "구성 추가",
"newConfiguration": "새 모델 구성", "newConfiguration": "새 모델 구성",
"newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.", "newConfigurationHelp": "제공자와 모델을 원클릭 옵션으로 저장합니다.",
"configurationName": "구성 이름", "configurationName": "구성 이름",
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.", "configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
"configurationNamePlaceholder": "빠른 글쓰기", "configurationNamePlaceholder": "빠른 작성",
"searchModels": "검색하거나 모델 ID 입력", "searchModels": "모델 ID 검색 또는 입력",
"useCustomModel": "사용", "useCustomModel": "사용",
"loadingModels": "모델 불러오는 중...", "loadingModels": "모델 불러오는 중...",
"searchCatalog": "제공자 카탈로그를 검색해 모델을 선택하세요.", "searchCatalog": "제공자 카탈로그에서 모델을 선택합니다.",
"modelsAvailable": "사용 가능", "modelsAvailable": "사용 가능",
"noModelResults": "일치하는 모델이 없습니다.", "noModelResults": "일치하는 모델이 없습니다.",
"loadFailed": "모델 목록을 사용할 수 없습니다.", "loadFailed": "모델 목록을 사용할 수 없습니다.",
"unsupportedModelList": "모델 ID를 직접 입력하세요.", "unsupportedModelList": "모델 ID를 직접 입력하세요.",
@@ -402,31 +402,31 @@
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다." "thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
}, },
"apps": { "apps": {
"description": "채팅에서 nanobot이 사용할 수 있는 CLI와 MCP 서비스를 추가합니다.", "description": "nanobot이 채팅에서 사용할 수 있는 App CLI와 MCP 서비스를 추가합니다.",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"filterAll": "전체", "filterAll": "전체",
"filterCli": "CLI 앱", "filterCli": "CLI 앱",
"filterMcp": "MCP 서비스", "filterMcp": "MCP 서비스",
"enabledSummary": "{{count}}개 활성화됨", "enabledSummary": "{{count}}개 활성화됨",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "CLI {{cli}}개 · MCP {{mcp}}",
"searchPlaceholder": "앱 검색", "searchPlaceholder": "앱 검색",
"featured": "추천", "featured": "추천",
"loading": "앱 불러오는 중...", "loading": "앱 불러오는 중...",
"empty": "이 필터와 일치하는 앱이 없습니다." "empty": "일치하는 앱이 없습니다."
}, },
"oauth": { "oauth": {
"authentication": "OAuth authentication", "authentication": "OAuth 인증",
"signIn": "Sign in", "signIn": "로그인",
"signingIn": "Signing in...", "signingIn": "로그인 중...",
"signInAgain": "Sign in again", "signInAgain": "다시 로그인",
"signOut": "Sign out", "signOut": "로그아웃",
"signedInAs": "Signed in as {{account}}", "signedInAs": "{{account}}로 로그인됨",
"signInHelp": "Sign in from this device; no API key is stored in config.", "signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
"signInRequired": "Sign in required", "signInRequired": "로그인이 필요합니다",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.", "signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.",
"signedIn": "Signed in", "signedIn": "로그인됨",
"notSignedIn": "Not signed in" "notSignedIn": "로그인 안 됨"
} }
}, },
"chat": { "chat": {
+134 -134
View File
@@ -57,22 +57,22 @@
"apps": "Ứng dụng" "apps": "Ứng dụng"
}, },
"settings": { "settings": {
"backToChat": "Quay lại trò chuyện", "backToChat": "Quay lại chat",
"sidebar": { "sidebar": {
"title": "Cài đặt", "title": "Cài đặt",
"ariaLabel": "Các mục cài đặt" "ariaLabel": "Mục cài đặt"
}, },
"nav": { "nav": {
"general": "Chung", "general": "Chung",
"byok": "BYOK", "byok": "BYOK",
"overview": "Overview", "overview": "Tổng quan",
"appearance": "Appearance", "appearance": "Giao diện",
"models": "Models", "models": "Mô hình",
"providers": "Providers", "providers": "Nhà cung cấp",
"image": "Image", "image": "Hình ảnh",
"browser": "Web", "browser": "Trang web",
"runtime": "Hệ thống", "runtime": "Hệ thống",
"advanced": "Security", "advanced": "Bảo mật",
"cliApps": "Ứng dụng CLI", "cliApps": "Ứng dụng CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Ứng dụng" "apps": "Ứng dụng"
@@ -81,122 +81,122 @@
"interface": "Giao diện", "interface": "Giao diện",
"ai": "AI", "ai": "AI",
"system": "Hệ thống", "system": "Hệ thống",
"status": "Status", "status": "Trạng thái",
"localPreferences": "Local preferences", "localPreferences": "Tùy chọn cục bộ",
"presets": "Presets", "presets": "Preset",
"imageGeneration": "Tạo ảnh", "imageGeneration": "Tạo hình ảnh",
"imageDefaults": "Mặc định", "imageDefaults": "Mặc định",
"webSearch": "Web search", "webSearch": "Tìm kiếm web",
"webBehavior": "Behavior", "webBehavior": "Hành vi",
"identity": "Identity", "identity": "Danh tính",
"webuiSafety": "Web safety", "webuiSafety": "An toàn WebUI",
"capabilities": "Khả năng", "capabilities": "Khả năng",
"cliApps": "Ứng dụng CLI", "cliApps": "Ứng dụng CLI",
"mcp": "Dịch vụ MCP", "mcp": "Dịch vụ MCP",
"apps": "Ứng dụng", "apps": "Ứng dụng",
"nativeHost": "Native host", "nativeHost": "Host gốc",
"hostSafety": "App safety" "hostSafety": "An toàn ứng dụng"
}, },
"rows": { "rows": {
"theme": "Giao diện", "theme": "Chủ đề",
"language": "Ngôn ngữ", "language": "Ngôn ngữ",
"provider": "Nhà cung cấp", "provider": "Nhà cung cấp",
"model": "Mô hình", "model": "Mô hình",
"restart": "Khởi động lại nanobot", "restart": "Khởi động lại nanobot",
"configPath": "Đường dẫn cấu hình", "configPath": "Đường dẫn cấu hình",
"activePreset": "Active preset", "activePreset": "Preset đang dùng",
"gateway": "Gateway", "gateway": "Cổng",
"restartState": "Restart state", "restartState": "Trạng thái khởi động lại",
"pendingChanges": "Thay đổi đang chờ", "pendingChanges": "Thay đổi chờ áp dụng",
"selectedPreset": "Selected preset", "selectedPreset": "Preset đã chọn",
"presetModel": "Preset model", "presetModel": "Mô hình preset",
"density": "Density", "density": "Mật độ",
"activityMode": "Activity detail", "activityMode": "Chi tiết hoạt động",
"codeWrap": "Code wrapping", "codeWrap": "Xuống dòng mã",
"maxResults": "Max results", "maxResults": "Kết quả tối đa",
"timeout": "Timeout", "timeout": "Thời gian chờ",
"jinaReader": "Jina reader", "jinaReader": "Trình đọc Jina",
"imageGeneration": "Tạo ảnh", "imageGeneration": "Tạo hình ảnh",
"imageProvider": "Nhà cung cấp ảnh", "imageProvider": "Nhà cung cấp hình ảnh",
"imageProviderStatus": "Trạng thái nhà cung cấp", "imageProviderStatus": "Trạng thái nhà cung cấp",
"imageProviderBase": "Cơ sở nhà cung cấp", "imageProviderBase": "Địa chỉ nhà cung cấp",
"imageModel": "Mô hình ảnh", "imageModel": "Mô hình hình ảnh",
"defaultAspectRatio": "Tỷ lệ mặc định", "defaultAspectRatio": "Tỷ lệ mặc định",
"defaultImageSize": "Kích thước 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", "imageSaveDir": "Thư mục lưu",
"botName": "Bot name", "botName": "Tên bot",
"botIcon": "Bot icon", "botIcon": "Biểu tượng bot",
"timezone": "Timezone", "timezone": "Múi giờ",
"workspacePath": "Workspace mặc định", "workspacePath": "Workspace mặc định",
"localServiceAccess": "Local services", "localServiceAccess": "Dịch vụ cục bộ",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "Quyền mặc định",
"currentModel": "Cấu hình hiện tại", "currentModel": "Cấu hình hiện tại",
"brandLogos": "Logo thương hiệu", "brandLogos": "Logo thương hiệu",
"cliAppsCatalog": "Danh mục ứng dụng CLI", "cliAppsCatalog": "Danh mục",
"cliAppsFilter": "Bộ lọc ứng dụng CLI", "cliAppsFilter": "Bộ lọc",
"engine": "Engine", "engine": "Bộ máy",
"logs": "Nhật ký", "logs": "Nhật ký",
"diagnostics": "Chẩn đoán", "diagnostics": "Chẩn đoán",
"contextWindow": "Context window" "contextWindow": "Cửa sổ ngữ cảnh"
}, },
"help": { "help": {
"theme": "Chuyển giữa giao diện sáng và tối.", "theme": "Chuyển giữa giao diện sáng và tối.",
"language": "Chọn ngôn ngữ dùng trong WebUI.", "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.", "provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
"model": "Đặt tên mô hình mặc định mà nanobot sử dụng.", "model": "Define el nombre de modelo predeterminado de nanobot.",
"configPath": "Tệp cấu hình gateway hiện đang dùng.", "configPath": "Archivo de configuración que usa actualmente el gateway.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.", "selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.", "presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.",
"density": "Stored only in this browser.", "density": "Chỉ lưu trong trình duyệt này.",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "Chọn mức chi tiết hoạt động agent hiển thị mặc định.",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
"maxResults": "Results returned by each web_search call.", "maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Seconds before a search provider request times out.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
"jinaReader": "Use Jina Reader for web_fetch when available.", "jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
"imageGeneration": "Hiển thị generate_image trong chat khi có nhà cung cấp ảnh đã cấu hình.", "imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
"imageProvider": "Chọn nhà cung cấp registry được generate_image sử dụng.", "imageProvider": "Elige el proveedor registrado usado por generate_image.",
"imageProviderStatus": "Tạo ảnh dùng lại thông tin xác thực nhà cung cấp từ Providers.", "imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
"imageModel": "Tên mô hình gửi tới nhà cung cấp ảnh đã chọn.", "imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
"defaultAspectRatio": "Được dùng khi prompt không chọn tỷ lệ khung hình.", "defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
"defaultImageSize": "Gợi ý kích thước gửi tới các nhà cung cấp hỗ trợ.", "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.", "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ị.", "botName": "Se muestra donde nanobot usa un nombre visible.",
"botIcon": "Emoji hoặc văn bản ngắn hiển thị cùng tên bot.", "botIcon": "Emoji o texto corto junto al nombre del bot.",
"timezone": "Dùng cho lịch hẹn và câu trả lời có yếu tố thời gian.", "timezone": "Se usa para horarios y respuestas con conciencia temporal.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.", "localServiceAccess": "Cho phép lệnh shell Full Access truy cập dịch vụ localhost.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.", "webuiDefaultAccess": "Dùng cho chat web không có quyền riêng theo dự án.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en 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.", "currentModel": "Dùng cho các phản hồi mới.",
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.", "selectedModelProvider": "Definido por el modelo seleccionado.",
"selectedModelValue": "Được đặt bởi mô hình đã chọn.", "selectedModelValue": "Definido por el modelo seleccionado.",
"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.", "brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
"cliAppsCatalog": "Duyệt các CLI ứng dụng mà nanobot có thể chạy cục bộ.", "cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.", "cliAppsFilter": "Busca por app, categoría o capacidad.",
"logs": "Mở thư mục nhật ký native engine.", "logs": "Abre la carpeta de registros del motor nativo.",
"diagnostics": "Xuất báo cáo runtime nhỏ để hỗ trợ.", "diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.", "localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.", "webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
"contextWindow": "Choose the default context budget for this model configuration." "contextWindow": "Chọn ngân sách ngữ cảnh mặc định cho cấu hình mô hình này."
}, },
"values": { "values": {
"light": "Sáng", "light": "Sáng",
"dark": "Tối", "dark": "Tối",
"notAvailable": "Không khả dụng", "notAvailable": "Không khả dụng",
"enabled": "Enabled", "enabled": "Đã bật",
"disabled": "Disabled", "disabled": "Đã tắt",
"restartPending": "Đang chờ khởi động lại", "restartPending": "Chờ khởi động lại",
"ready": "Sẵn sàng", "ready": "Sẵn sàng",
"comfortable": "Comfortable", "comfortable": "Thoải mái",
"compact": "Compact", "compact": "Gọn",
"auto": "Auto", "auto": "Tự động",
"expanded": "Expanded", "expanded": "Mở rộng",
"on": "On", "on": "Bật",
"off": "Off", "off": "Tắt",
"defaultPermission": "Default Permission", "defaultPermission": "Quyền mặc định",
"fullAccess": "Full Access", "fullAccess": "Toàn quyền",
"configured": "Configured", "configured": "Đã cấu hình",
"notConfigured": "Not configured", "notConfigured": "Chưa cấu hình",
"pending": "Đang chờ", "pending": "Đang chờ",
"restartingEngine": "Đang khởi động lại" "restartingEngine": "Đang khởi động lại"
}, },
@@ -205,17 +205,17 @@
"loadError": "Không thể tải cài đặt", "loadError": "Không thể tải cài đặt",
"unsaved": "Có thay đổi chưa lưu.", "unsaved": "Có thay đổi chưa lưu.",
"upToDate": "Đã cập nhật.", "upToDate": "Đã cập nhật.",
"savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng.", "savedRestart": "Guardado. Reinicia nanobot para aplicar.",
"restartAfterSaving": "Lưu thay đổi, rồi khởi động lại khi sẵn sàng.", "restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
"savedRestartApply": "Đã lưu. Khởi động lại khi sẵn sàng.", "savedRestartApply": "Guardado. Reinicia cuando puedas.",
"imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng.", "imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
"hostRestartAfterSaving": "Lưu thay đổi và nanobot sẽ khởi động lại engine.", "hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
"hostRestartPending": "Đã lưu. Sẽ khởi động lại engine khi sẵn sàng.", "hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
"hostApiUnavailable": "Host actions are only available inside the native app.", "hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
"logsOpened": "Opened logs folder.", "logsOpened": "Carpeta de registros abierta.",
"logsOpenFailed": "Could not open logs folder.", "logsOpenFailed": "No se pudo abrir la carpeta de registros.",
"diagnosticsExported": "Diagnostics exported to {{path}}.", "diagnosticsExported": "Diagnóstico exportado a {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics." "diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
}, },
"actions": { "actions": {
"save": "Lưu", "save": "Lưu",
@@ -224,8 +224,8 @@
"cancel": "Hủy", "cancel": "Hủy",
"open": "Mở", "open": "Mở",
"export": "Xuất", "export": "Xuất",
"opening": "Opening...", "opening": "Đang mở...",
"exporting": "Exporting..." "exporting": "Đang xuất..."
}, },
"byok": { "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.", "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": { "tabs": {
"ariaLabel": "Loại thông tin xác thực BYOK", "ariaLabel": "Loại thông tin xác thực BYOK",
"llm": "LLM", "llm": "LLM",
"webSearch": "Web Search" "webSearch": "Tìm kiếm web"
}, },
"webSearch": { "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.", "providerHelp": "Chọn backend mà công cụ web search sẽ dùng.",
"selectProvider": "Chọn provider", "selectProvider": "Chọn provider",
"credentials": "Thông tin xác thực", "credentials": "Thông tin xác thực",
@@ -270,17 +270,17 @@
} }
}, },
"overview": { "overview": {
"model": "Current model", "model": "Mô hình hiện tại",
"providers": "Providers", "providers": "Nhà cung cấp",
"configuredCount": "{{count}} configured", "configuredCount": "{{count}} đã cấu hình",
"totalProviders": "{{count}} available", "totalProviders": "{{count}} khả dụng",
"webSearch": "Web search", "webSearch": "Tìm kiếm web",
"imageGeneration": "Tạo ảnh", "imageGeneration": "Tạo hình ảnh",
"workspace": "Workspace" "workspace": "Không gian làm việc"
}, },
"providers": { "providers": {
"searchPlaceholder": "Search providers", "searchPlaceholder": "Tìm nhà cung cấp",
"noMatches": "No providers match this search.", "noMatches": "Không có nhà cung cấp phù hợp.",
"saveProvider": "Lưu nhà cung cấp" "saveProvider": "Lưu nhà cung cấp"
}, },
"image": { "image": {
@@ -288,17 +288,17 @@
"selectAspect": "Chọn tỷ lệ", "selectAspect": "Chọn tỷ lệ",
"selectSize": "Chọn kích thước", "selectSize": "Chọn kích thước",
"configureProvider": "Cấu hình nhà cung cấp", "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": { "models": {
"selectModel": "Chọn mô hình", "selectModel": "Chọn mô hình",
"addConfiguration": "Thêm cấu hình", "addConfiguration": "Thêm cấu hình",
"newConfiguration": "Cấu hình mô hình mới", "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", "configurationName": "Tên cấu hình",
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.", "configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
"configurationNamePlaceholder": "Viết nhanh", "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", "useCustomModel": "Dùng",
"loadingModels": "Đang tải mô hình...", "loadingModels": "Đang tải mô hình...",
"searchCatalog": "Tìm trong danh mục nhà cung cấp để chọn 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.", "noModelResults": "Không có mô hình phù hợp.",
"loadFailed": "Không tải được danh sách mô hình.", "loadFailed": "Không tải được danh sách mô hình.",
"unsupportedModelList": "Nhập ID mô hình thủ công.", "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." "autoProviderCustomOnly": "Chế độ nhà cung cấp tự động dùng ID mô hình tùy chỉnh."
}, },
"timezone": { "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." "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": { "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", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"filterAll": "Tất cả", "filterAll": "Tất cả",
@@ -413,20 +413,20 @@
"searchPlaceholder": "Tìm ứng dụng", "searchPlaceholder": "Tìm ứng dụng",
"featured": "Nổi bật", "featured": "Nổi bật",
"loading": "Đang tải ứng dụng...", "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": { "oauth": {
"authentication": "OAuth authentication", "authentication": "Xác thực OAuth",
"signIn": "Sign in", "signIn": "Đăng nhập",
"signingIn": "Signing in...", "signingIn": "Đang đăng nhập...",
"signInAgain": "Sign in again", "signInAgain": "Đăng nhập lại",
"signOut": "Sign out", "signOut": "Đăng xuất",
"signedInAs": "Signed in as {{account}}", "signedInAs": "Đã đăng nhập bằng {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.", "signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"signInRequired": "Sign in required", "signInRequired": "Cần đăng nhập",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.", "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Signed in", "signedIn": "Đã đăng nhập",
"notSignedIn": "Not signed in" "notSignedIn": "Chưa đăng nhập"
} }
}, },
"chat": { "chat": {
+73 -73
View File
@@ -57,7 +57,7 @@
"apps": "应用" "apps": "应用"
}, },
"settings": { "settings": {
"backToChat": "返回对话", "backToChat": "返回聊天",
"sidebar": { "sidebar": {
"title": "设置", "title": "设置",
"ariaLabel": "设置分区" "ariaLabel": "设置分区"
@@ -91,30 +91,30 @@
"cliApps": "CLI 应用", "cliApps": "CLI 应用",
"mcp": "MCP 服务", "mcp": "MCP 服务",
"identity": "身份", "identity": "身份",
"webuiSafety": "网页端安全", "webuiSafety": "WebUI 安全",
"capabilities": "能力", "capabilities": "能力",
"apps": "应用", "apps": "应用",
"nativeHost": "App", "nativeHost": "原生宿主",
"hostSafety": "App 安全" "hostSafety": "应用安全"
}, },
"models": { "models": {
"selectModel": "选择模型", "selectModel": "选择模型",
"addConfiguration": "添加配置", "addConfiguration": "添加配置",
"newConfiguration": "新建模型配置", "newConfiguration": "新建模型配置",
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。", "newConfigurationHelp": "把提供商和模型保存成一键选项。",
"configurationName": "配置名称", "configurationName": "配置名称",
"configurationNameHelp": "重命名这个已保存的模型配置。", "configurationNameHelp": "重命名这个已保存的模型配置。",
"configurationNamePlaceholder": "快速写作", "configurationNamePlaceholder": "快速写作",
"searchModels": "搜索或输入模型 ID", "searchModels": "搜索或输入模型 ID",
"useCustomModel": "使用", "useCustomModel": "使用",
"loadingModels": "正在加载模型...", "loadingModels": "正在加载模型...",
"searchCatalog": "搜索服务商目录来选择模型。", "searchCatalog": "搜索提供商目录来选择模型。",
"modelsAvailable": "可用", "modelsAvailable": "可用",
"noModelResults": "没有匹配的模型。", "noModelResults": "没有匹配的模型。",
"loadFailed": "模型列表不可用。", "loadFailed": "模型列表不可用。",
"unsupportedModelList": "手动输入模型 ID。", "unsupportedModelList": "手动输入模型 ID。",
"providerNotConfigured": "先配置这个服务商再加载模型。", "providerNotConfigured": "加载模型前请先配置此提供商。",
"autoProviderCustomOnly": "自动服务商模式使用自定义模型 ID。" "autoProviderCustomOnly": "自动提供商模式使用自定义模型 ID。"
}, },
"rows": { "rows": {
"theme": "主题", "theme": "主题",
@@ -126,25 +126,25 @@
"activePreset": "当前预设", "activePreset": "当前预设",
"gateway": "网关", "gateway": "网关",
"restartState": "重启状态", "restartState": "重启状态",
"pendingChanges": "待处理更改", "pendingChanges": "待应用更改",
"currentModel": "当前配置", "currentModel": "当前配置",
"selectedPreset": "选中的预设", "selectedPreset": "选中的预设",
"presetModel": "预设模型", "presetModel": "预设模型",
"density": "密度", "density": "密度",
"activityMode": "活动细节", "activityMode": "活动详情",
"codeWrap": "代码换行", "codeWrap": "代码换行",
"brandLogos": "品牌 Logo", "brandLogos": "品牌 Logo",
"maxResults": "最大结果数", "maxResults": "最大结果数",
"timeout": "超时", "timeout": "超时",
"jinaReader": "Jina Reader", "jinaReader": "Jina 阅读器",
"imageGeneration": "图片生成", "imageGeneration": "图片生成",
"imageProvider": "图片服务商", "imageProvider": "图片提供商",
"imageProviderStatus": "服务商状态", "imageProviderStatus": "提供商状态",
"imageProviderBase": "服务商地址", "imageProviderBase": "提供商地址",
"imageModel": "图片模型", "imageModel": "图片模型",
"defaultAspectRatio": "默认比例", "defaultAspectRatio": "默认比例",
"defaultImageSize": "默认尺寸", "defaultImageSize": "默认尺寸",
"maxImagesPerTurn": "每轮最图片数", "maxImagesPerTurn": "每轮最图片数",
"imageSaveDir": "保存目录", "imageSaveDir": "保存目录",
"botName": "Bot 名称", "botName": "Bot 名称",
"botIcon": "Bot 图标", "botIcon": "Bot 图标",
@@ -162,41 +162,41 @@
"help": { "help": {
"theme": "在浅色和深色外观之间切换。", "theme": "在浅色和深色外观之间切换。",
"language": "选择 WebUI 使用的语言。", "language": "选择 WebUI 使用的语言。",
"provider": "选择新模型请求使用的服务商。", "provider": "选择处理新模型请求的提供商。",
"model": "设置 nanobot 默认使用的模型名称。", "model": "设置 nanobot 默认使用的模型名称。",
"configPath": "当前网关正在使用的配置文件。", "configPath": "当前网关正在使用的配置文件。",
"currentModel": "选择 nanobot 接下来回复时使用的模型配置。", "currentModel": "用于新的回复。",
"selectedModelProvider": "由当前模型决定。", "selectedModelProvider": "由选中的模型决定。",
"selectedModelValue": "由当前模型决定。", "selectedModelValue": "由选中的模型决定。",
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。", "selectedPreset": "命名预设在这里只读;请在 config.json 中编辑。",
"presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。", "presetModel": "切回 Default 后可在 WebUI 编辑模型和提供商。",
"density": "保存在当前浏览器。", "density": "保存在浏览器。",
"activityMode": "选择默认显示多少 agent 活动细节。", "activityMode": "选择默认显示多少 agent 活动细节。",
"codeWrap": "让较小屏幕上的长代码行更易读。", "codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置显示第三方服务商和 CLI 的 Logo。", "brandLogos": "在设置显示第三方提供商和 CLI 图标。",
"maxResults": "每次 web_search 返回的结果数。", "maxResults": "每次 web_search 调用返回的结果数。",
"timeout": "搜索服务商请求超时秒数。", "timeout": "搜索提供商请求超时前的秒数。",
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。", "jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
"imageGeneration": "当已配置图片服务商时,在对话中开放 generate_image。", "imageGeneration": "配置图片提供商后,在聊天中开放 generate_image。",
"imageProvider": "选择 generate_image 使用的服务商。", "imageProvider": "选择 generate_image 使用的注册提供商。",
"imageProviderStatus": "图片生成复用服务商页里的凭证配置。", "imageProviderStatus": "图片生成复用「提供商」里的凭据。",
"imageModel": "发送给所选图片服务商的模型名称。", "imageModel": "发送给所选图片提供商的模型名称。",
"defaultAspectRatio": "当提示词没有选择比例时使用。", "defaultAspectRatio": "当提示词没有指定比例时使用。",
"defaultImageSize": "发送给支持该能力的服务商的尺寸提示。", "defaultImageSize": "发送给支持此选项的提供商的尺寸提示。",
"maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。", "maxImagesPerTurn": "单次 generate_image 请求可生成的图片上限。",
"botName": "显示在 nanobot 使用名称的地方。", "botName": "显示在 nanobot 使用展示名称的地方。",
"botIcon": "显示在 bot 名称旁的短 emoji 或文。", "botIcon": "显示在 Bot 名称旁的短 emoji 或文。",
"timezone": "用于计划任务和需要时间感知的回复。", "timezone": "用于日程和需要时间感知的回复。",
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。", "cliAppsCatalog": "只安装 nanobot 可本地运行的应用 CLI 适配器;不会改动原生应用。",
"cliAppsFilter": "按应用、类或能力搜索。", "cliAppsFilter": "按应用、类或能力搜索。",
"localServiceAccess": "允许完全访问模式下的 shell 命令访问 localhost 服务。", "localServiceAccess": "允许完全访问权限下的 shell 命令访问 localhost 服务。",
"webuiDefaultAccess": "用于没有单独选择权限的网页端对话。", "webuiDefaultAccess": "用于没有单独项目权限的 Web 聊天。",
"securityManagedControls": "网页抓取始终保护本机、内网和元数据服务。核心渠道安全仍由 config.json 管理。", "securityManagedControls": "网页抓取始终保护本机、内网和元数据服务。核心渠道安全仍由 config.json 管理。",
"logs": "打开App引擎日志文件夹。", "logs": "打开原生引擎日志文件夹。",
"diagnostics": "导出一份用于支持排查的运行报告。", "diagnostics": "导出一份用于支持排查的小型运行报告。",
"localServiceAccessNative": "允许完全访问模式下的 shell 命令访问这台 Mac 上的服务。", "localServiceAccessNative": "允许完全访问权限下的 shell 命令访问这台 Mac 上的服务。",
"webuiDefaultAccessNative": "用于没有单独选择权限的原生 App 对话。", "webuiDefaultAccessNative": "用于没有单独项目权限的原生聊天。",
"contextWindow": "选择这个模型配置默认使用的上下文预算。" "contextWindow": "选择模型配置默认上下文预算。"
}, },
"timezone": { "timezone": {
"select": "选择时区", "select": "选择时区",
@@ -251,9 +251,9 @@
"serverUrl": "URL", "serverUrl": "URL",
"transport": "传输方式", "transport": "传输方式",
"command": "命令", "command": "命令",
"args": "参数 JSON", "args": "Args JSON",
"headers": "Headers JSON", "headers": "Headers JSON",
"env": "环境变量 JSON", "env": "Env JSON",
"timeout": "工具超时", "timeout": "工具超时",
"advancedOptions": "高级选项", "advancedOptions": "高级选项",
"hideAdvanced": "收起高级", "hideAdvanced": "收起高级",
@@ -299,13 +299,13 @@
"compact": "紧凑", "compact": "紧凑",
"auto": "自动", "auto": "自动",
"expanded": "展开", "expanded": "展开",
"on": "开", "on": "开",
"off": "关", "off": "关",
"defaultPermission": "默认权限", "defaultPermission": "默认权限",
"fullAccess": "完全访问", "fullAccess": "完全访问权限",
"configured": "已配置", "configured": "已配置",
"notConfigured": "未配置", "notConfigured": "未配置",
"pending": "待应用", "pending": "等待中",
"restartingEngine": "正在重启" "restartingEngine": "正在重启"
}, },
"status": { "status": {
@@ -314,12 +314,12 @@
"unsaved": "有未保存的更改。", "unsaved": "有未保存的更改。",
"upToDate": "已是最新。", "upToDate": "已是最新。",
"savedRestart": "已保存。重启 nanobot 后生效。", "savedRestart": "已保存。重启 nanobot 后生效。",
"restartAfterSaving": "保存后,在合适时重启。", "restartAfterSaving": "保存更改后,在合适时重启。",
"savedRestartApply": "已保存,可稍后重启。", "savedRestartApply": "已保存。准备好后重启。",
"imageProviderRestart": "图片服务商改动已保存,可稍后重启。", "imageProviderRestart": "图片提供商更改已保存。准备好后重启。",
"hostRestartAfterSaving": "保存后nanobot 会自动重启引擎。", "hostRestartAfterSaving": "保存后 nanobot 会重启引擎。",
"hostRestartPending": "已保存,将在合适时重启引擎。", "hostRestartPending": "已保存。准备好后会重启引擎。",
"hostApiUnavailable": "宿主操作只能在原生 App 内使用。", "hostApiUnavailable": "宿主操作在原生应用中可用。",
"logsOpened": "已打开日志文件夹。", "logsOpened": "已打开日志文件夹。",
"logsOpenFailed": "无法打开日志文件夹。", "logsOpenFailed": "无法打开日志文件夹。",
"diagnosticsExported": "诊断已导出到 {{path}}。", "diagnosticsExported": "诊断已导出到 {{path}}。",
@@ -327,13 +327,13 @@
}, },
"actions": { "actions": {
"save": "保存", "save": "保存",
"saving": "保存", "saving": "正在保存",
"edit": "编辑", "edit": "编辑",
"cancel": "取消", "cancel": "取消",
"open": "打开", "open": "打开",
"export": "导出", "export": "导出",
"opening": "打开...", "opening": "正在打开...",
"exporting": "导出..." "exporting": "正在导出..."
}, },
"byok": { "byok": {
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。", "description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
@@ -387,19 +387,19 @@
"workspace": "工作区" "workspace": "工作区"
}, },
"providers": { "providers": {
"searchPlaceholder": "搜索服务商", "searchPlaceholder": "搜索提供商",
"noMatches": "没有匹配的服务商。", "noMatches": "没有匹配的提供商。",
"saveProvider": "保存服务商" "saveProvider": "保存提供商"
}, },
"legal": { "legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。" "thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
}, },
"image": { "image": {
"selectProvider": "选择服务商", "selectProvider": "选择提供商",
"selectAspect": "选择比例", "selectAspect": "选择比例",
"selectSize": "选择尺寸", "selectSize": "选择尺寸",
"configureProvider": "配置服务商", "configureProvider": "配置提供商",
"missingCredential": "启用图片生成前请先配置这个服务商。" "missingCredential": "启用图片生成前请先配置此提供商。"
}, },
"apps": { "apps": {
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。", "description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。",
@@ -409,22 +409,22 @@
"filterCli": "CLI 应用", "filterCli": "CLI 应用",
"filterMcp": "MCP 服务", "filterMcp": "MCP 服务",
"enabledSummary": "已启用 {{count}} 个", "enabledSummary": "已启用 {{count}} 个",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜索应用", "searchPlaceholder": "搜索应用",
"featured": "精选", "featured": "精选",
"loading": "正在加载应用...", "loading": "正在加载应用...",
"empty": "没有符合筛选条件的应用。" "empty": "没有匹配的应用。"
}, },
"oauth": { "oauth": {
"authentication": "OAuth 认证", "authentication": "OAuth 认证",
"signIn": "登录", "signIn": "登录",
"signingIn": "正在登录…", "signingIn": "登录中...",
"signInAgain": "重新登录", "signInAgain": "重新登录",
"signOut": "退出登录", "signOut": "退出登录",
"signedInAs": "已登录为 {{account}}", "signedInAs": "已登录为 {{account}}",
"signInHelp": "这台设备登录;不会 API key 写入配置。", "signInHelp": "这台设备登录;不会在配置中保存 API key。",
"signInRequired": "需要登录", "signInRequired": "需要登录",
"signInBeforeSaving": "先登录这个 OAuth 提供商,然后再保存为当前模型提供商。", "signInBeforeSaving": "将此 OAuth 提供商为当前模型提供商前,请先登录。",
"signedIn": "已登录", "signedIn": "已登录",
"notSignedIn": "未登录" "notSignedIn": "未登录"
} }
+131 -131
View File
@@ -57,7 +57,7 @@
"apps": "應用" "apps": "應用"
}, },
"settings": { "settings": {
"backToChat": "返回對話", "backToChat": "返回聊天",
"sidebar": { "sidebar": {
"title": "設定", "title": "設定",
"ariaLabel": "設定分區" "ariaLabel": "設定分區"
@@ -65,14 +65,14 @@
"nav": { "nav": {
"general": "一般", "general": "一般",
"byok": "BYOK", "byok": "BYOK",
"overview": "Overview", "overview": "概覽",
"appearance": "Appearance", "appearance": "外觀",
"models": "Models", "models": "模型",
"providers": "Providers", "providers": "提供商",
"image": "Image", "image": "圖片",
"browser": "Web", "browser": "網頁",
"runtime": "系統", "runtime": "系統",
"advanced": "Security", "advanced": "安全",
"cliApps": "CLI 應用", "cliApps": "CLI 應用",
"mcp": "MCP", "mcp": "MCP",
"apps": "應用" "apps": "應用"
@@ -81,60 +81,60 @@
"interface": "介面", "interface": "介面",
"ai": "AI", "ai": "AI",
"system": "系統", "system": "系統",
"status": "Status", "status": "狀態",
"localPreferences": "Local preferences", "localPreferences": "本機偏好",
"presets": "Presets", "presets": "預設",
"imageGeneration": "圖片生成", "imageGeneration": "圖片生成",
"imageDefaults": "預設值", "imageDefaults": "預設值",
"webSearch": "Web search", "webSearch": "網頁搜尋",
"webBehavior": "Behavior", "webBehavior": "行為",
"identity": "Identity", "identity": "身分",
"webuiSafety": "網頁端安全", "webuiSafety": "WebUI 安全",
"capabilities": "能", "capabilities": "能",
"cliApps": "CLI 應用", "cliApps": "CLI 應用",
"mcp": "MCP 服務", "mcp": "MCP 服務",
"apps": "應用", "apps": "應用",
"nativeHost": "App", "nativeHost": "原生宿主",
"hostSafety": "App 安全" "hostSafety": "App 安全"
}, },
"rows": { "rows": {
"theme": "主題", "theme": "主題",
"language": "語言", "language": "語言",
"provider": "提供者", "provider": "供應商",
"model": "模型", "model": "模型",
"restart": "重新啟動 nanobot", "restart": "重新啟動 nanobot",
"configPath": "設定檔路徑", "configPath": "配置路徑",
"activePreset": "Active preset", "activePreset": "目前預設",
"gateway": "Gateway", "gateway": "閘道",
"restartState": "Restart state", "restartState": "重啟狀態",
"pendingChanges": "待處理變更", "pendingChanges": "待套用更改",
"selectedPreset": "Selected preset", "selectedPreset": "已選預設",
"presetModel": "Preset model", "presetModel": "預設模型",
"density": "Density", "density": "密度",
"activityMode": "Activity detail", "activityMode": "活動細節",
"codeWrap": "Code wrapping", "codeWrap": "程式碼換行",
"maxResults": "Max results", "maxResults": "最大結果數",
"timeout": "Timeout", "timeout": "逾時",
"jinaReader": "Jina reader", "jinaReader": "Jina 閱讀器",
"imageGeneration": "圖片生成", "imageGeneration": "圖片生成",
"imageProvider": "圖片服務商", "imageProvider": "圖片供應商",
"imageProviderStatus": "服務商狀態", "imageProviderStatus": "供應商狀態",
"imageProviderBase": "服務商位址", "imageProviderBase": "供應商位址",
"imageModel": "圖片模型", "imageModel": "圖片模型",
"defaultAspectRatio": "預設比例", "defaultAspectRatio": "預設比例",
"defaultImageSize": "預設尺寸", "defaultImageSize": "預設尺寸",
"maxImagesPerTurn": "每輪最圖片數", "maxImagesPerTurn": "每輪最圖片數",
"imageSaveDir": "儲存目錄", "imageSaveDir": "儲存目錄",
"botName": "Bot name", "botName": "Bot 名稱",
"botIcon": "Bot icon", "botIcon": "Bot 圖示",
"timezone": "Timezone", "timezone": "時區",
"workspacePath": "預設工作區", "workspacePath": "預設工作區",
"localServiceAccess": "Local services", "localServiceAccess": "本機服務",
"webuiDefaultAccess": "Default access", "webuiDefaultAccess": "預設權限",
"currentModel": "目前設定", "currentModel": "目前設定",
"brandLogos": "品牌標誌", "brandLogos": "品牌 Logo",
"cliAppsCatalog": "CLI 應用目錄", "cliAppsCatalog": "目錄",
"cliAppsFilter": "CLI 應用篩選", "cliAppsFilter": "篩選",
"engine": "引擎", "engine": "引擎",
"logs": "日誌", "logs": "日誌",
"diagnostics": "診斷", "diagnostics": "診斷",
@@ -143,75 +143,75 @@
"help": { "help": {
"theme": "在淺色與深色外觀之間切換。", "theme": "在淺色與深色外觀之間切換。",
"language": "選擇 WebUI 使用的語言。", "language": "選擇 WebUI 使用的語言。",
"provider": "選擇新模型請求使用的服務提供者。", "provider": "選擇處理新模型請求的供應商。",
"model": "設定 nanobot 預設使用的模型名稱。", "model": "設定 nanobot 預設使用的模型名稱。",
"configPath": "目前閘道正在使用的設定檔。", "configPath": "目前閘道使用中的配置檔。",
"selectedPreset": "Named presets are read-only here; edit them in config.json.", "selectedPreset": "命名預設在此為唯讀;請在 config.json 中編輯。",
"presetModel": "Switch to Default to edit model and provider from the WebUI.", "presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。",
"density": "Stored only in this browser.", "density": "只儲存在此瀏覽器中。",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "選擇預設顯示多少 agent 活動細節。",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "讓長程式碼行在小螢幕上也易讀。",
"maxResults": "Results returned by each web_search call.", "maxResults": "每次 web_search 呼叫返回的結果數。",
"timeout": "Seconds before a search provider request times out.", "timeout": "搜尋供應商請求逾時前的秒數。",
"jinaReader": "Use Jina Reader for web_fetch when available.", "jinaReader": "可用時為 web_fetch 使用 Jina Reader。",
"imageGeneration": "當已設定圖片服務商時,在對話中開放 generate_image。", "imageGeneration": "配置圖片供應商後,在聊天中開放 generate_image。",
"imageProvider": "選擇 generate_image 使用的登錄服務商。", "imageProvider": "選擇 generate_image 使用的註冊供應商。",
"imageProviderStatus": "圖片生成會重用「服務商」中的憑證。", "imageProviderStatus": "圖片生成會重用「供應商」中的憑證。",
"imageModel": "傳送給所選圖片服務商的模型名稱。", "imageModel": "傳送給所選圖片供應商的模型名稱。",
"defaultAspectRatio": "提示詞未指定長寬比時使用。", "defaultAspectRatio": "提示詞未指定比時使用。",
"defaultImageSize": "傳送給支援此功能的服務商的尺寸提示。", "defaultImageSize": "傳送給支援此選項的供應商的尺寸提示。",
"maxImagesPerTurn": "單次 generate_image 請求上限。", "maxImagesPerTurn": "單次 generate_image 請求可生成的圖片上限。",
"botName": "顯示在 nanobot 使用名稱的地方。", "botName": "顯示在 nanobot 使用顯示名稱的位置。",
"botIcon": "顯示在 bot 名稱旁的短 emoji 或文字。", "botIcon": "顯示在 Bot 名稱旁的短 emoji 或文字。",
"timezone": "用於排程需要時間感知的回覆。", "timezone": "用於排程需要時間感知的回覆。",
"localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。", "localServiceAccess": "允許完全訪問權限下的 shell 命令訪問 localhost 服務。",
"webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。", "webuiDefaultAccess": "用於沒有單獨專案權限的 Web 聊天。",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.", "securityManagedControls": "網頁抓取始終保護本機、內網和 metadata 服務。核心渠道安全仍由 config.json 管理。",
"currentModel": "選擇 nanobot 接下來回覆時使用的模型設定。", "currentModel": "用於新的回覆。",
"selectedModelProvider": "由目前模型決定。", "selectedModelProvider": "由選取的模型決定。",
"selectedModelValue": "由目前模型決定。", "selectedModelValue": "由選取的模型決定。",
"brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。", "brandLogos": "在設定中顯示第三方提供商和 CLI 圖示。",
"cliAppsCatalog": "瀏覽 nanobot 可在本機執行的應用 CLI。", "cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用 CLI 轉接器;不會改動原生應用。",
"cliAppsFilter": "按應用、類或能力搜尋。", "cliAppsFilter": "按應用、類或能力搜尋。",
"logs": "開啟App引擎日誌資料夾。", "logs": "開啟原生引擎日誌資料夾。",
"diagnostics": "匯出一份支援排查用的執行階段報告。", "diagnostics": "匯出一份用於支援排查的小型執行報告。",
"localServiceAccessNative": "允許完全存取模式下的 shell 命令存取這台 Mac 上的服務。", "localServiceAccessNative": "允許完全訪問權限下的 shell 命令訪問這台 Mac 上的服務。",
"webuiDefaultAccessNative": "用於沒有單獨選擇權限的原生 App 對話。", "webuiDefaultAccessNative": "用於沒有單獨專案權限的原生聊天。",
"contextWindow": "選擇這個模型設定預設使用的上下文預算。" "contextWindow": "選擇此模型配置的預設上下文預算。"
}, },
"values": { "values": {
"light": "淺色", "light": "淺色",
"dark": "深色", "dark": "深色",
"notAvailable": "不可用", "notAvailable": "不可用",
"enabled": "Enabled", "enabled": "已啟用",
"disabled": "Disabled", "disabled": "已停用",
"restartPending": "等待重新啟動", "restartPending": "等待重",
"ready": "就緒", "ready": "就緒",
"comfortable": "Comfortable", "comfortable": "舒適",
"compact": "Compact", "compact": "緊湊",
"auto": "Auto", "auto": "自動",
"expanded": "Expanded", "expanded": "展開",
"on": "On", "on": "開啟",
"off": "Off", "off": "關閉",
"defaultPermission": "Default Permission", "defaultPermission": "預設權限",
"fullAccess": "Full Access", "fullAccess": "完全訪問權限",
"configured": "Configured", "configured": "已配置",
"notConfigured": "Not configured", "notConfigured": "未配置",
"pending": "待套用", "pending": "等待中",
"restartingEngine": "正在重新啟動" "restartingEngine": "正在重"
}, },
"status": { "status": {
"loading": "正在載入設定...", "loading": "正在載入設定...",
"loadError": "無法載入設定", "loadError": "無法載入設定",
"unsaved": "有未儲存的更。", "unsaved": "有未儲存的更。",
"upToDate": "已是最新。", "upToDate": "已是最新。",
"savedRestart": "已儲存。重新啟動 nanobot 後生效。", "savedRestart": "已儲存。重新啟動 nanobot 後生效。",
"restartAfterSaving": "儲存變更後,可在準備好時重新啟動。", "restartAfterSaving": "儲存更改後,在合適時重啟。",
"savedRestartApply": "已儲存,可在準備好時重新啟動。", "savedRestartApply": "已儲存。準備好後重啟。",
"imageProviderRestart": "圖片服務商變更已儲存,可在準備好時重新啟動。", "imageProviderRestart": "圖片供應商更改已儲存。準備好後重啟。",
"hostRestartAfterSaving": "儲存後nanobot 會自動重新啟動引擎。", "hostRestartAfterSaving": "儲存後 nanobot 會重啟引擎。",
"hostRestartPending": "已儲存,將在適當時重新啟動引擎。", "hostRestartPending": "已儲存。準備好後會重啟引擎。",
"hostApiUnavailable": "宿主操作只能在原生 App 內使用。", "hostApiUnavailable": "宿主操作在原生應用中可用。",
"logsOpened": "已開啟日誌資料夾。", "logsOpened": "已開啟日誌資料夾。",
"logsOpenFailed": "無法開啟日誌資料夾。", "logsOpenFailed": "無法開啟日誌資料夾。",
"diagnosticsExported": "診斷已匯出到 {{path}}。", "diagnosticsExported": "診斷已匯出到 {{path}}。",
@@ -219,13 +219,13 @@
}, },
"actions": { "actions": {
"save": "儲存", "save": "儲存",
"saving": "儲存", "saving": "正在儲存",
"edit": "編輯", "edit": "編輯",
"cancel": "取消", "cancel": "取消",
"open": "開啟", "open": "開啟",
"export": "匯出", "export": "匯出",
"opening": "開啟...", "opening": "正在開啟...",
"exporting": "匯出..." "exporting": "正在匯出..."
}, },
"byok": { "byok": {
"description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。", "description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。",
@@ -250,7 +250,7 @@
"tabs": { "tabs": {
"ariaLabel": "BYOK 憑證類型", "ariaLabel": "BYOK 憑證類型",
"llm": "LLM", "llm": "LLM",
"webSearch": "Web Search" "webSearch": "網頁搜尋"
}, },
"webSearch": { "webSearch": {
"provider": "搜尋 provider", "provider": "搜尋 provider",
@@ -270,44 +270,44 @@
} }
}, },
"overview": { "overview": {
"model": "Current model", "model": "目前模型",
"providers": "Providers", "providers": "供應商",
"configuredCount": "{{count}} configured", "configuredCount": "已配置 {{count}} ",
"totalProviders": "{{count}} available", "totalProviders": "{{count}} 個可用",
"webSearch": "Web search", "webSearch": "網頁搜尋",
"imageGeneration": "圖片生成", "imageGeneration": "圖片生成",
"workspace": "Workspace" "workspace": "工作區"
}, },
"providers": { "providers": {
"searchPlaceholder": "Search providers", "searchPlaceholder": "搜尋供應商",
"noMatches": "No providers match this search.", "noMatches": "沒有符合的供應商。",
"saveProvider": "儲存服務商" "saveProvider": "儲存供應商"
}, },
"image": { "image": {
"selectProvider": "選擇服務商", "selectProvider": "選擇供應商",
"selectAspect": "選擇比例", "selectAspect": "選擇比例",
"selectSize": "選擇尺寸", "selectSize": "選擇尺寸",
"configureProvider": "設定服務商", "configureProvider": "配置供應商",
"missingCredential": "啟用圖片生成前請先設定此服務商。" "missingCredential": "啟用圖片生成前請先配置此供應商。"
}, },
"models": { "models": {
"selectModel": "選擇模型", "selectModel": "選擇模型",
"addConfiguration": "新增設定", "addConfiguration": "新增配置",
"newConfiguration": "新增模型設定", "newConfiguration": "新增模型配置",
"newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。", "newConfigurationHelp": "將供應商與模型儲存為一選項。",
"configurationName": "設定名稱", "configurationName": "配置名稱",
"configurationNameHelp": "重新命名這個已儲存的模型配置。", "configurationNameHelp": "重新命名這個已儲存的模型配置。",
"configurationNamePlaceholder": "快速寫作", "configurationNamePlaceholder": "快速寫作",
"searchModels": "搜尋或輸入模型 ID", "searchModels": "搜尋或輸入模型 ID",
"useCustomModel": "使用", "useCustomModel": "使用",
"loadingModels": "正在載入模型...", "loadingModels": "正在載入模型...",
"searchCatalog": "搜尋服務商目錄來選擇模型。", "searchCatalog": "搜尋供應商目錄來選擇模型。",
"modelsAvailable": "可用", "modelsAvailable": "可用",
"noModelResults": "沒有符合的模型。", "noModelResults": "沒有符合的模型。",
"loadFailed": "模型列表不可用。", "loadFailed": "模型列表不可用。",
"unsupportedModelList": "手動輸入模型 ID。", "unsupportedModelList": "手動輸入模型 ID。",
"providerNotConfigured": "先設定這個服務商再載入模型。", "providerNotConfigured": "載入模型前請先配置此供應商。",
"autoProviderCustomOnly": "自動服務商模式使用自訂模型 ID。" "autoProviderCustomOnly": "自動供應商模式使用自訂模型 ID。"
}, },
"timezone": { "timezone": {
"select": "選擇時區", "select": "選擇時區",
@@ -402,29 +402,29 @@
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。" "thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
}, },
"apps": { "apps": {
"description": "新增 nanobot 可在聊天中使用的 App CLI MCP 服務。", "description": "新增 nanobot 可在聊天中使用的 App CLI MCP 服務。",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"filterAll": "全部", "filterAll": "全部",
"filterCli": "CLI 應用", "filterCli": "CLI 應用",
"filterMcp": "MCP 服務", "filterMcp": "MCP 服務",
"enabledSummary": "已啟用 {{count}} 個", "enabledSummary": "已啟用 {{count}} 個",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜尋應用", "searchPlaceholder": "搜尋應用",
"featured": "精選", "featured": "精選",
"loading": "正在載入應用...", "loading": "正在載入應用...",
"empty": "沒有符合篩選條件的應用。" "empty": "沒有符合的應用。"
}, },
"oauth": { "oauth": {
"authentication": "OAuth 證", "authentication": "OAuth 證",
"signIn": "登入", "signIn": "登入",
"signingIn": "正在登入…", "signingIn": "登入中...",
"signInAgain": "重新登入", "signInAgain": "重新登入",
"signOut": "登出", "signOut": "登出",
"signedInAs": "已登入為 {{account}}", "signedInAs": "已登入為 {{account}}",
"signInHelp": "這台裝置登入;不會 API key 寫入設定。", "signInHelp": "這台裝置登入;不會在配置中儲存 API key。",
"signInRequired": "需要登入", "signInRequired": "需要登入",
"signInBeforeSaving": "請先登入這個 OAuth 提供商,再儲存為目前模型提供商。", "signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。",
"signedIn": "已登入", "signedIn": "已登入",
"notSignedIn": "未登入" "notSignedIn": "未登入"
} }
+30 -10
View File
@@ -55,8 +55,15 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
const flushTurn = () => { const flushTurn = () => {
if (turnMessages.length === 0) return; if (turnMessages.length === 0) return;
const activityMessages: UIMessage[] = []; const visibleMessages = visibleMessagesForTurn(turnMessages);
const visibleMessages: UIMessage[] = []; let visibleIndex = 0;
let activityMessages: UIMessage[] = [];
const flushActivityMessages = () => {
if (!activityMessages.length) return;
pushActivityUnits(units, activityMessages, visibleMessages.slice(visibleIndex));
activityMessages = [];
};
for (const message of turnMessages) { for (const message of turnMessages) {
if (isAgentActivityMember(message)) { if (isAgentActivityMember(message)) {
@@ -66,19 +73,18 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
if (assistantHasInlineReasoning(message)) { if (assistantHasInlineReasoning(message)) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message)); activityMessages.push(reasoningOnlyMessageFromAnswer(message));
visibleMessages.push(stripInlineReasoning(message)); flushActivityMessages();
units.push({ type: "message", message: stripInlineReasoning(message) });
visibleIndex += 1;
continue; continue;
} }
visibleMessages.push(message); flushActivityMessages();
}
pushActivityUnits(units, activityMessages, visibleMessages);
for (const message of visibleMessages) {
units.push({ type: "message", message }); units.push({ type: "message", message });
visibleIndex += 1;
} }
flushActivityMessages();
turnMessages = []; turnMessages = [];
}; };
@@ -96,9 +102,19 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
return units; 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[]) { function pushActivityUnits(units: TurnUnit[], activityMessages: UIMessage[], visibleMessages: UIMessage[]) {
let runMessages: UIMessage[] = []; let runMessages: UIMessage[] = [];
let runBucket: "file" | "other" | undefined; let runBucket: "file" | "other" | undefined;
let runSegmentId: string | undefined;
const flushRun = () => { const flushRun = () => {
if (!runMessages.length) return; if (!runMessages.length) return;
@@ -110,14 +126,18 @@ function pushActivityUnits(units: TurnUnit[], activityMessages: UIMessage[], vis
}); });
runMessages = []; runMessages = [];
runBucket = undefined; runBucket = undefined;
runSegmentId = undefined;
}; };
for (const message of activityMessages) { for (const message of activityMessages) {
const bucket = isFileEditActivityMessage(message) ? "file" : "other"; 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(); flushRun();
} }
runBucket = bucket; runBucket = bucket;
if (segmentId) runSegmentId = segmentId;
runMessages.push(message); runMessages.push(message);
} }
+15 -4
View File
@@ -9,11 +9,20 @@ import type {
GoalStateWsPayload, GoalStateWsPayload,
WorkspaceScopePayload, WorkspaceScopePayload,
} from "./types"; } from "./types";
import { createHostWebSocket } from "./runtime";
/** WebSocket readyState constants, referenced by value to stay portable /** WebSocket readyState constants, referenced by value to stay portable
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */ * across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
const WS_OPEN = 1; const WS_OPEN = 1;
const WS_CLOSING = 2; 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``. /** Inbound WebSocket ``console.log`` / parse-failure ``console.warn``.
* *
@@ -129,7 +138,7 @@ export class NanobotClient {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly shouldReconnect: boolean; private readonly shouldReconnect: boolean;
private readonly maxBackoffMs: number; private readonly maxBackoffMs: number;
private readonly socketFactory: (url: string) => WebSocket; private socketFactory: (url: string) => WebSocket;
private currentUrl: string; private currentUrl: string;
private status_: ConnectionStatus = "idle"; private status_: ConnectionStatus = "idle";
private readyChatId: string | null = null; private readyChatId: string | null = null;
@@ -140,8 +149,7 @@ export class NanobotClient {
constructor(private options: NanobotClientOptions) { constructor(private options: NanobotClientOptions) {
this.shouldReconnect = options.reconnect ?? true; this.shouldReconnect = options.reconnect ?? true;
this.maxBackoffMs = options.maxBackoffMs ?? 15_000; this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
this.socketFactory = this.socketFactory = options.socketFactory ?? createDefaultSocket;
options.socketFactory ?? ((url) => new WebSocket(url));
this.currentUrl = options.url; this.currentUrl = options.url;
} }
@@ -154,8 +162,11 @@ export class NanobotClient {
} }
/** Swap the URL (e.g. after fetching a fresh token) then reconnect. */ /** 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; this.currentUrl = url;
if (socketFactory) {
this.socketFactory = socketFactory;
}
} }
onStatus(handler: StatusHandler): Unsubscribe { onStatus(handler: StatusHandler): Unsubscribe {
+13 -8
View File
@@ -51,6 +51,11 @@ type HostSocketBridge = Required<Pick<
"closeSocket" | "onSocketEvent" | "openSocket" | "sendSocket" "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 { declare global {
interface Window { interface Window {
nanobotHost?: NanobotHostApi; nanobotHost?: NanobotHostApi;
@@ -122,7 +127,7 @@ class HostWebSocket {
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null; onerror: ((this: WebSocket, ev: Event) => unknown) | null = null;
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null; onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null;
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null; onopen: ((this: WebSocket, ev: Event) => unknown) | null = null;
readyState: number = WebSocket.CONNECTING; readyState: number = HOST_WS_CONNECTING;
readonly url: string; readonly url: string;
private id: string | null = null; private id: string | null = null;
@@ -140,7 +145,7 @@ class HostWebSocket {
this.id = id; this.id = id;
}, },
() => { () => {
this.readyState = WebSocket.CLOSED; this.readyState = HOST_WS_CLOSED;
this.onerror?.call(this as unknown as WebSocket, new Event("error")); this.onerror?.call(this as unknown as WebSocket, new Event("error"));
this.onclose?.call(this as unknown as WebSocket, closeEvent()); this.onclose?.call(this as unknown as WebSocket, closeEvent());
this.unsubscribe(); this.unsubscribe();
@@ -149,14 +154,14 @@ class HostWebSocket {
} }
close(): void { close(): void {
if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) { if (this.readyState === HOST_WS_CLOSING || this.readyState === HOST_WS_CLOSED) {
return; return;
} }
this.readyState = WebSocket.CLOSING; this.readyState = HOST_WS_CLOSING;
if (this.id) { if (this.id) {
void this.api.closeSocket(this.id); void this.api.closeSocket(this.id);
} else { } else {
this.readyState = WebSocket.CLOSED; this.readyState = HOST_WS_CLOSED;
this.unsubscribe(); this.unsubscribe();
} }
} }
@@ -165,7 +170,7 @@ class HostWebSocket {
if (typeof data !== "string") { if (typeof data !== "string") {
throw new Error("Host WebSocket bridge only supports text frames"); 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); void this.api.sendSocket(this.id, data);
return; return;
} }
@@ -175,7 +180,7 @@ class HostWebSocket {
private handleEvent(event: HostSocketEvent): void { private handleEvent(event: HostSocketEvent): void {
if (!this.id || event.id !== this.id) return; if (!this.id || event.id !== this.id) return;
if (event.type === "open") { if (event.type === "open") {
this.readyState = WebSocket.OPEN; this.readyState = HOST_WS_OPEN;
this.onopen?.call(this as unknown as WebSocket, new Event("open")); this.onopen?.call(this as unknown as WebSocket, new Event("open"));
while (this.queued.length > 0 && this.id) { while (this.queued.length > 0 && this.id) {
const data = this.queued.shift(); const data = this.queued.shift();
@@ -194,7 +199,7 @@ class HostWebSocket {
this.onerror?.call(this as unknown as WebSocket, new Event("error")); this.onerror?.call(this as unknown as WebSocket, new Event("error"));
return; return;
} }
this.readyState = WebSocket.CLOSED; this.readyState = HOST_WS_CLOSED;
this.onclose?.call( this.onclose?.call(
this as unknown as WebSocket, this as unknown as WebSocket,
closeEvent(event.code, event.reason), closeEvent(event.code, event.reason),
@@ -869,6 +869,39 @@ describe("AgentActivityCluster", () => {
expect(screen.getByText("Target text was not found in angry-birds.html.")).toBeInTheDocument(); 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 () => { it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion(); const restoreMotion = installReducedMotion();
try { try {
+2 -2
View File
@@ -891,9 +891,9 @@ describe("App layout", () => {
expect(screen.queryByText("AI")).not.toBeInTheDocument(); expect(screen.queryByText("AI")).not.toBeInTheDocument();
expect(screen.getByText("Current configuration")).toBeInTheDocument(); expect(screen.getByText("Current configuration")).toBeInTheDocument();
expect(screen.queryByText("Presets")).not.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" })); 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(); expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), { fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
target: { value: "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", () => ({ vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => ({
default: ({ default: ({
children, children,
language,
style, style,
}: { }: {
children: string; children: string;
language?: string;
style: Record<string, unknown>; style: Record<string, unknown>;
}) => ( }) => (
<pre <pre
data-testid="highlighted-code" data-testid="highlighted-code"
data-language={language}
data-theme={style === mockedStyles.dark ? "dark" : "light"} data-theme={style === mockedStyles.dark ? "dark" : "light"}
> >
<code>{children}</code> <code>{children}</code>
@@ -48,6 +51,23 @@ describe("CodeBlock", () => {
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90"); 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 () => { it("reads theme from context without creating per-block observers", async () => {
const originalMutationObserver = globalThis.MutationObserver; const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn(); const observer = vi.fn();
+68
View File
@@ -34,6 +34,60 @@ const SETTINGS_NAV_KEYS = [
"runtime", "runtime",
"advanced", "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> { function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value); 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", () => { it("keeps Simplified Chinese settings overview copy localized", () => {
const settings = resources["zh-CN"].common.settings; const settings = resources["zh-CN"].common.settings;
@@ -16,6 +16,18 @@ describe("MarkdownTextRenderer", () => {
expect(container.querySelector("pre div")).toBeNull(); 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", () => { it("keeps streaming unfinished fenced code blocks to a single shell", () => {
const { container } = render( const { container } = render(
<MarkdownTextRenderer highlightCode={false}> <MarkdownTextRenderer highlightCode={false}>
@@ -56,6 +68,47 @@ describe("MarkdownTextRenderer", () => {
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument(); 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", () => { it("renders media attachments without an extra preview/code wrapper", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>); render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
+56
View File
@@ -65,6 +65,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
Reflect.deleteProperty(window, "nanobotHost");
vi.useRealTimers(); 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", () => { it("buffers chat events while no chat handler is registered and replays on subscribe", () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",
+34
View File
@@ -245,6 +245,40 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument(); 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 () => { it("loads provider models and lets users choose one without typing the id manually", async () => {
const payload: SettingsPayload = { const payload: SettingsPayload = {
...settingsPayload(), ...settingsPayload(),
+1
View File
@@ -386,6 +386,7 @@ describe("ThreadComposer", () => {
expect(status).toHaveTextContent(/2:05/); expect(status).toHaveTextContent(/2:05/);
expect(status.parentElement).toHaveClass("composer-status-strip"); expect(status.parentElement).toHaveClass("composer-status-strip");
expect(status.parentElement).toHaveAttribute("data-state", "enter"); expect(status.parentElement).toHaveAttribute("data-state", "enter");
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
vi.useRealTimers(); 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"]); 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[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -142,15 +142,58 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); 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([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1", "r1",
"t1", "t1",
]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
"r2", "r2",
"t2", "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", () => { it("only marks the current activity timeline as live while streaming", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
@@ -194,7 +237,8 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />); 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", () => { it("folds final answer reasoning into the preceding activity timeline", () => {
@@ -253,7 +297,7 @@ describe("ThreadMessages", () => {
expect(screen.getByText("final answer")).toBeInTheDocument(); 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[] = [ const messages: UIMessage[] = [
{ {
id: "t0", id: "t0",
@@ -284,11 +328,8 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2); expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
"t0",
"t1",
]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { message: {
@@ -296,15 +337,16 @@ describe("ThreadMessages", () => {
content: "partial answer", content: "partial answer",
}, },
}); });
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render(<ThreadMessages messages={messages} isStreaming />); render(<ThreadMessages messages={messages} isStreaming />);
const activity = screen.getByRole("button", { name: /working/i });
const answer = screen.getByText("partial answer"); 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[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -334,11 +376,8 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2); expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
"r1",
"t1",
]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { message: {
@@ -346,13 +385,14 @@ describe("ThreadMessages", () => {
content: "Hong Kong is hot today.", 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} />); render(<ThreadMessages messages={messages} isStreaming={false} />);
const activity = screen.getByText("Thought for 2m 41s");
const answer = screen.getByText("Hong Kong is hot today."); const answer = screen.getByText("Hong Kong is hot today.");
expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); const laterActivity = screen.getAllByText(/thought/i).at(-1);
expect(screen.getAllByText(/thought/i)).toHaveLength(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", () => { 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 fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
wrapper: wrap(fake.client), wrapper: wrap(fake.client),
@@ -692,9 +692,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3); expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({ expect(result.current.messages[0]).toMatchObject({
role: "assistant", role: "assistant",
content: "", content: "I created the files.",
reasoning: "I created the files.",
isStreaming: false,
}); });
expect(result.current.messages[1]).toMatchObject({ expect(result.current.messages[1]).toMatchObject({
role: "tool", role: "tool",
@@ -739,9 +737,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3); expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({ expect(result.current.messages[0]).toMatchObject({
role: "assistant", role: "assistant",
content: "", content: "I will inspect the project first.",
reasoning: "I will inspect the project first.",
isStreaming: false,
}); });
expect(result.current.messages[1]).toMatchObject({ expect(result.current.messages[1]).toMatchObject({
role: "tool", 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 () => { it("opens a new activity segment for reasoning after file edit activity", async () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
@@ -967,7 +1008,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false); 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 fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r5", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-r5", EMPTY_MESSAGES), {
wrapper: wrap(fake.client), wrapper: wrap(fake.client),
@@ -988,12 +1029,61 @@ describe("useNanobotStream", () => {
fake.emit("chat-r5", { event: "reasoning_end", chat_id: "chat-r5" }); 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].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.", "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 () => { it("does not attach a new turn's reasoning across the latest user boundary", async () => {