Compare commits

..
Author SHA1 Message Date
Xubin Ren 362f9629e2 fix(heartbeat): fail closed on internal checks 2026-05-31 01:07:04 +08:00
71 changed files with 1499 additions and 3359 deletions
-1
View File
@@ -5,7 +5,6 @@ __pycache__
*.egg-info *.egg-info
dist/ dist/
build/ build/
nanobot/web/dist/
.git .git
.env .env
.assets .assets
-82
View File
@@ -1,82 +0,0 @@
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.
+84 -1
View File
@@ -1 +1,84 @@
@AGENTS.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
nanobot is a lightweight, open-source AI agent framework written in Python with a React/TypeScript WebUI. It centers around a small agent loop that receives messages from chat channels, invokes an LLM provider, executes tools, and manages session memory.
## Development Commands
```bash
# Python: run single test / lint
pytest tests/test_openai_api.py::test_function -v
ruff check nanobot/
# WebUI: dev server (proxies API/WS to gateway :8765), build, test
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
cd webui && bun run build
cd webui && bun run test
# Gateway
nanobot gateway
```
## High-Level Architecture
### Core Data Flow
Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decouples chat channels from the agent core:
1. **Channels** (`nanobot/channels/`) receive messages from external platforms and publish `InboundMessage` events to the bus.
2. **`AgentLoop`** (`nanobot/agent/loop.py`) consumes inbound messages, builds context, and coordinates the turn.
3. **`AgentRunner`** (`nanobot/agent/runner.py`) handles the actual LLM conversation loop: send messages to the provider, receive tool calls, execute tools, and stream responses.
4. Responses are published as `OutboundMessage` events back to the appropriate channel.
### Key Subsystems
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
- **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed).
- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel.
- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context.
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
### Entry Points
- **CLI**: `nanobot/cli/commands.py`
- **Python SDK**: `nanobot/nanobot.py`
## Project-Specific Notes
- Architecture constraints: [`.agent/design.md`](.agent/design.md)
- Security boundaries: [`.agent/security.md`](.agent/security.md)
- Common gotchas: [`.agent/gotchas.md`](.agent/gotchas.md)
## Branching Strategy
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full two-branch model (`main` vs `nightly`) and PR guidelines.
## Code Style
- Python 3.11+, asyncio throughout.
- Line length: 100.
- Linting: `ruff` with rules E, F, I, N, W (E501 ignored).
- pytest with `asyncio_mode = "auto"`.
## Common File Locations
- Config schema: `nanobot/config/schema.py`
- Provider base / new provider template: `nanobot/providers/base.py`
- Channel base / new channel template: `nanobot/channels/base.py`
- Tool registry: `nanobot/agent/tools/registry.py`
- WebUI dev proxy config: `webui/vite.config.ts`
- Tests mirror the `nanobot/` package structure.
+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 NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache . RUN uv pip install --system --no-cache .
# Build the WhatsApp bridge # Build the WhatsApp bridge
WORKDIR /app/bridge WORKDIR /app/bridge
+11 -27
View File
@@ -1,4 +1,4 @@
![nanobot README cover](./images/readme-cover.png) ![cover-v5-optimized](./images/GitHub_README.png)
<div align="center"> <div align="center">
<p> <p>
@@ -31,29 +31,10 @@
</p> </p>
</div> </div>
🐈 **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. 🐈 **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.
## 📢 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.
@@ -64,6 +45,10 @@
- **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.
@@ -160,13 +145,12 @@
</details> </details>
## 💡 Why nanobot ## 💡 Key Features of nanobot
- **Persistent workflows**: goals, memory, tools, and chat context survive long-running work. - **Ultra-lightweight**: stable long-running agent behavior with a small, readable core.
- **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, and email. - **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend.
- **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks. - **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in.
- **Small core**: readable internals with MCP, memory, deployment, and automation built in. - **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page.
- **Own your stack**: inspect, customize, self-host, and extend without a giant platform.
## 📦 Install ## 📦 Install
-2
View File
@@ -244,7 +244,6 @@ 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": [],
@@ -264,7 +263,6 @@ 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 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. 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.
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`): **Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown ```markdown
## Active Tasks ## Periodic 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. Completed tasks should be deleted from the file, not moved to another section. The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
> **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.
+4 -11
View File
@@ -11,23 +11,16 @@
> 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. To serve the bundled WebUI from Docker, enable the WebSocket channel and protect bootstrap with a secret: > 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:
> >
> ```json > ```json
> { > {
> "gateway": { "host": "0.0.0.0" }, > "gateway": { "host": "0.0.0.0" },
> "channels": { > "channels": { "websocket": { "host": "0.0.0.0" } }
> "websocket": {
> "enabled": true,
> "host": "0.0.0.0",
> "port": 8765,
> "tokenIssueSecret": "your-secret-here"
> }
> }
> } > }
> ``` > ```
> >
> 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. > 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.
### Docker Compose ### Docker Compose
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

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.1" return _read_pyproject_version() or "0.2.0"
__version__ = _resolve_version() __version__ = _resolve_version()
+11 -50
View File
@@ -45,7 +45,6 @@ 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,
@@ -113,7 +112,6 @@ 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
@@ -123,7 +121,6 @@ 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)
@@ -568,8 +565,6 @@ 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:
@@ -776,7 +771,6 @@ 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,
@@ -802,8 +796,7 @@ 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, metadata=(session.metadata if session is not None else None),
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,
@@ -815,15 +808,9 @@ 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 and should_stream: if on_stream and on_stream_end:
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":
@@ -966,8 +953,7 @@ 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 {},
)) ))
continuing = turn_continuation.internal_continuation_pending(msg.metadata) if msg.channel == "websocket":
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,
@@ -1031,10 +1017,9 @@ 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,
) )
if not turn_continuation.internal_continuation_pending(msg.metadata): await self._webui_turns.publish_run_status(msg, "idle")
await self._webui_turns.publish_run_status(msg, "idle") self._pending_turn_latency_ms.pop(session_key, None)
self._pending_turn_latency_ms.pop(session_key, None) self._webui_turns.discard(session_key)
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")
@@ -1182,17 +1167,12 @@ 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,
@@ -1398,13 +1378,7 @@ class AgentLoop:
return "ok" return "ok"
async def _state_run(self, ctx: TurnContext) -> str: async def _state_run(self, ctx: TurnContext) -> str:
if ctx.visible_run_started_at is None: await self._webui_turns.publish_run_status(ctx.msg, "running")
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,
@@ -1425,25 +1399,15 @@ 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:
turn_continuation.prepare_save_boundary(ctx) if ctx.final_content is None or not ctx.final_content.strip():
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
latency_started_at = ( ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0)
ctx.visible_run_started_at
if turn_continuation.internal_continuation_inbound(ctx.msg.metadata) ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000))
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,
@@ -1463,9 +1427,6 @@ 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,
+3 -2
View File
@@ -807,9 +807,10 @@ class Consolidator:
metadata={}, metadata={},
last_consolidated=0, last_consolidated=0,
) )
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix) probe.retain_recent_legal_suffix(max_suffix)
kept = probe.messages kept = probe.messages
archive_msgs = dropped[already_consolidated:] cut = len(tail) - len(kept)
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()
+5 -8
View File
@@ -4,8 +4,6 @@ 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
@@ -128,11 +126,11 @@ class MessageTool(Tool, ContextAware):
self._record_channel_delivery_var.reset(token) self._record_channel_delivery_var.reset(token)
def set_suppress_delivery(self, active: bool): def set_suppress_delivery(self, active: bool):
"""Acknowledge but don't deliver tool sends (heartbeat internal check).""" """Temporarily suppress real channel delivery for internal checks."""
return self._suppress_delivery_var.set(active) return self._suppress_delivery_var.set(active)
def reset_suppress_delivery(self, token) -> None: def reset_suppress_delivery(self, token) -> None:
"""Restore previous delivery-suppression state.""" """Restore previous channel delivery suppression state."""
self._suppress_delivery_var.reset(token) self._suppress_delivery_var.reset(token)
@property @property
@@ -231,6 +229,9 @@ class MessageTool(Tool, ContextAware):
if not channel or not chat_id: if not channel or not chat_id:
return "Error: No target channel/chat specified" return "Error: No target channel/chat specified"
if self._suppress_delivery_var.get():
return "Message suppressed during internal check"
if not self._send_callback: if not self._send_callback:
return "Error: Message sending not configured" return "Error: Message sending not configured"
@@ -255,10 +256,6 @@ 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,11 +23,6 @@ try:
AsyncClientConfig, AsyncClientConfig,
InviteEvent, InviteEvent,
JoinError, JoinError,
KeyVerificationCancel,
KeyVerificationEvent,
KeyVerificationKey,
KeyVerificationMac,
KeyVerificationStart,
LoginResponse, LoginResponse,
MatrixRoom, MatrixRoom,
RoomEncryptedMedia, RoomEncryptedMedia,
@@ -38,7 +33,6 @@ try:
RoomSendResponse, RoomSendResponse,
RoomTypingError, RoomTypingError,
SyncError, SyncError,
ToDeviceError,
UploadError, UploadError,
) )
from nio.crypto.attachments import decrypt_attachment from nio.crypto.attachments import decrypt_attachment
@@ -200,7 +194,6 @@ 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
@@ -275,7 +268,6 @@ 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:
@@ -580,77 +572,11 @@ 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() or self.config.token.strip() secret = self.config.token_issue_secret.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 no token_issue_secret or static token is configured; " "token_issue_path is set but token_issue_secret is empty; "
"any client can obtain connection tokens — set a secret for production." "any client can obtain connection tokens — set token_issue_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:
+15 -36
View File
@@ -1,6 +1,7 @@
"""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
@@ -99,34 +100,15 @@ _HEARTBEAT_PREAMBLE = (
"[Your response will be delivered directly to the user's messaging app. " "[Your response will be delivered directly to the user's messaging app. "
"Output ONLY the final user-facing message. Never reference internal " "Output ONLY the final user-facing message. Never reference internal "
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your " "files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
"decision process. If nothing needs reporting, respond with just " "decision process. If nothing needs reporting, respond with a brief "
"'All clear.' and nothing else.]\n\n" "no-op status and nothing else.]\n\n"
) )
def _heartbeat_has_active_tasks(content: str) -> bool: @functools.lru_cache(maxsize=None)
"""True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments.""" def _heartbeat_template() -> str | None:
in_comment = False from nanobot.utils.helpers import load_bundled_template
in_active_section: bool = False return load_bundled_template("HEARTBEAT.md")
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
@@ -996,8 +978,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 _heartbeat_has_active_tasks(content): if not content or content == _heartbeat_template():
logger.debug("Heartbeat: HEARTBEAT.md has no active tasks") logger.debug("Heartbeat: HEARTBEAT.md empty or identical to template")
return None return None
channel, chat_id = _pick_heartbeat_target() channel, chat_id = _pick_heartbeat_target()
@@ -1009,11 +991,10 @@ 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}"
) )
# Internal check: funnel all output through the post-run gate so the message_suppress_token = None
# turn can't deliver directly via the message tool and skip it.
suppress_token = None
if isinstance(message_tool, MessageTool): if isinstance(message_tool, MessageTool):
suppress_token = message_tool.set_suppress_delivery(True) message_suppress_token = message_tool.set_suppress_delivery(True)
try: try:
resp = await agent.process_direct( resp = await agent.process_direct(
prompt, prompt,
@@ -1023,8 +1004,8 @@ def _run_gateway(
on_progress=_silent, on_progress=_silent,
) )
finally: finally:
if isinstance(message_tool, MessageTool) and suppress_token is not None: if isinstance(message_tool, MessageTool) and message_suppress_token is not None:
message_tool.reset_suppress_delivery(suppress_token) message_tool.reset_suppress_delivery(message_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.
@@ -1035,10 +1016,8 @@ 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,
default_notify=False,
) )
if should_notify: if should_notify:
logger.info("Heartbeat: completed, delivering response") logger.info("Heartbeat: completed, delivering response")
+4 -19
View File
@@ -43,19 +43,6 @@ 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
@@ -111,16 +98,14 @@ 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 this is a Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when a sustained goal is
sustained-goal turn; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory active; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory ``metadata`` when the
``metadata`` when the caller already holds :attr:`~nanobot.session.manager.Session.metadata` caller already holds :attr:`~nanobot.session.manager.Session.metadata` for this turn.
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_turn(meta, message_metadata=message_metadata) else None return 0.0 if sustained_goal_active(meta) else None
+15 -43
View File
@@ -269,25 +269,13 @@ 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) -> tuple[list[dict], int]: def retain_recent_legal_suffix(self, max_messages: int) -> None:
"""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 dropped, min(lc, len(dropped)) return
if len(self.messages) <= max_messages: if len(self.messages) <= max_messages:
return [], 0 return
original = list(self.messages)
before_lc = self.last_consolidated
retained = list(self.messages[-max_messages:]) retained = list(self.messages[-max_messages:])
@@ -318,32 +306,10 @@ class Session:
if start: if start:
retained = retained[start:] retained = retained[start:]
# Compute actually-dropped messages using identity comparison so that dropped = len(self.messages) - len(retained)
# 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 = new_lc self.last_consolidated = max(0, self.last_consolidated - dropped)
self.updated_at = datetime.now() self.updated_at = datetime.now()
return dropped, already_consolidated
def enforce_file_cap( def enforce_file_cap(
self, self,
@@ -354,17 +320,23 @@ class Session:
if limit <= 0 or len(self.messages) <= limit: if limit <= 0 or len(self.messages) <= limit:
return return
dropped, already_consolidated = self.retain_recent_legal_suffix(limit) before = list(self.messages)
if not dropped: before_last_consolidated = self.last_consolidated
before_count = len(before)
self.retain_recent_legal_suffix(limit)
dropped_count = before_count - len(self.messages)
if dropped_count <= 0:
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,
len(dropped), dropped_count,
len(archive_chunk), len(archive_chunk),
len(self.messages), len(self.messages),
) )
-240
View File
@@ -1,240 +0,0 @@
"""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]
+4 -19
View File
@@ -178,13 +178,7 @@ 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( async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status: str) -> None:
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
@@ -195,10 +189,7 @@ async def publish_turn_run_status(
"goal_status": status, "goal_status": status,
} }
if status == "running": if status == "running":
if isinstance(started_at, int | float) and started_at > 0: t0 = time.time()
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:
@@ -309,14 +300,8 @@ 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( async def publish_run_status(self, msg: InboundMessage, status: str) -> None:
self, await publish_turn_run_status(self.bus, msg, status)
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,
+5 -3
View File
@@ -1,14 +1,16 @@
# 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 -->
+6 -4
View File
@@ -44,12 +44,15 @@ async def evaluate_response(
task_context: str, task_context: str,
provider: LLMProvider, provider: LLMProvider,
model: str, model: str,
*,
default_notify: bool = True, 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.
On any failure, falls back to ``default_notify`` (cron reminders fail open; Uses a lightweight tool-call LLM request. ``default_notify`` controls
heartbeat passes ``False`` to fail closed). the fallback path when the evaluator cannot produce a valid decision:
user-scheduled reminders stay fail-open, while internal checks such as
heartbeat can fail closed.
""" """
try: try:
llm_response = await provider.chat_with_retry( llm_response = await provider.chat_with_retry(
@@ -71,8 +74,7 @@ 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='{}', " "evaluate_response: ignoring tool calls under finish_reason='{}', defaulting to notify={}",
"defaulting to notify={}",
llm_response.finish_reason, llm_response.finish_reason,
default_notify, default_notify,
) )
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nanobot-ai" name = "nanobot-ai"
version = "0.2.1" version = "0.2.0"
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"
+3 -2
View File
@@ -76,9 +76,10 @@ def _make_fake_compact(
metadata={}, metadata={},
last_consolidated=0, last_consolidated=0,
) )
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix) probe.retain_recent_legal_suffix(max_suffix)
kept = probe.messages kept = probe.messages
archive_msgs = dropped[already_consolidated:] cut = len(tail) - len(kept)
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,44 +440,6 @@ 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."""
+26 -14
View File
@@ -56,6 +56,23 @@ async def test_fallback_on_error() -> None:
assert result is True assert result is True
@pytest.mark.asyncio
async def test_fallback_can_fail_closed() -> None:
class FailingProvider(DummyProvider):
async def chat(self, *args, **kwargs) -> LLMResponse:
raise RuntimeError("provider down")
provider = FailingProvider([])
result = await evaluate_response(
"some response",
"some task",
provider,
"m",
default_notify=False,
)
assert result is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_tool_call_fallback() -> None: 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=[])])
@@ -64,18 +81,13 @@ async def test_no_tool_call_fallback() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fail_closed_on_error() -> None: async def test_no_tool_call_can_fail_closed() -> None:
class FailingProvider(DummyProvider): provider = DummyProvider([LLMResponse(content="I think you should notify", tool_calls=[])])
async def chat(self, *args, **kwargs) -> LLMResponse: result = await evaluate_response(
raise RuntimeError("provider down") "some response",
"some task",
provider = FailingProvider([]) provider,
result = await evaluate_response("some", "task", provider, "m", default_notify=False) "m",
assert result is False default_notify=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 assert result is False
+1 -24
View File
@@ -2,6 +2,7 @@
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
@@ -47,30 +48,6 @@ 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,10 +11,6 @@ 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,
@@ -564,226 +560,6 @@ 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,159 +538,3 @@ 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,18 +50,7 @@ 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]] = []
@@ -81,9 +70,6 @@ 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))
@@ -96,26 +82,6 @@ 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,
@@ -200,62 +166,6 @@ 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(
@@ -299,7 +209,6 @@ 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()
@@ -318,121 +227,6 @@ 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,35 +202,6 @@ 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)
+132 -27
View File
@@ -952,33 +952,6 @@ 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)
@@ -1421,6 +1394,138 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
bus.publish_outbound.assert_not_awaited() bus.publish_outbound.assert_not_awaited()
def test_gateway_heartbeat_fails_closed_and_suppresses_message_tool(
monkeypatch, tmp_path: Path
) -> None:
"""Heartbeat only delivers after an explicit positive evaluation, and
internal checks cannot bypass that gate with the proactive message tool."""
from nanobot.agent.tools.message import MessageTool
config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
config.workspace_path.mkdir(parents=True)
(config.workspace_path / "HEARTBEAT.md").write_text(
"Check whether anything needs attention.",
encoding="utf-8",
)
bus = MagicMock()
bus.publish_outbound = AsyncMock()
seen: dict[str, object] = {}
class _FakeSession:
def retain_recent_legal_suffix(self, _keep: int) -> None:
seen["retained"] = True
class _FakeSessionManager:
def __init__(self, _workspace: Path) -> None:
self.session = _FakeSession()
def list_sessions(self) -> list[dict[str, object]]:
return [{"key": "lark:chat-1", "updated_at": "2026-05-30T00:00:00"}]
def get_or_create(self, key: str) -> _FakeSession:
seen["session_key"] = key
return self.session
def save(self, session: _FakeSession) -> None:
seen["saved"] = session
class _FakeCron:
def __init__(self, _store_path: Path) -> None:
self.on_job = None
seen["cron"] = self
def status(self) -> dict[str, int]:
return {"jobs": 0}
def register_system_job(self, job: CronJob) -> CronJob:
if job.name == "heartbeat":
seen["heartbeat_job"] = job
raise _StopGatewayError("stop")
return job
class _FakeDream:
model = None
max_batch_size = 0
max_iterations = 0
annotate_line_ages = False
async def run(self) -> None:
return None
class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(bus=bus, **extra)
def __init__(self, bus=None, **kwargs) -> None:
self.model = "test-model"
self.provider = object()
self.sessions = kwargs["session_manager"]
self.dream = _FakeDream()
self.tools = {
"message": MessageTool(send_callback=bus.publish_outbound),
}
async def process_direct(self, *_args, **_kwargs):
result = await self.tools["message"].execute(
content="All clear.",
channel="lark",
chat_id="chat-1",
)
seen["message_tool_result"] = result
return OutboundMessage(
channel="lark",
chat_id="chat-1",
content="All clear.",
)
async def close_mcp(self) -> None:
return None
def stop(self) -> None:
return None
class _FakeChannels:
enabled_channels = ["lark"]
async def _capture_evaluate(*_args, **kwargs) -> bool:
seen["default_notify"] = kwargs.get("default_notify")
return False
_patch_cli_command_runtime(
monkeypatch,
config,
message_bus=lambda: bus,
session_manager=_FakeSessionManager,
cron_service=_FakeCron,
)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr(
"nanobot.channels.manager.ChannelManager",
lambda *_args, **_kwargs: _FakeChannels(),
)
monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _capture_evaluate)
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
assert isinstance(result.exception, _StopGatewayError)
cron = seen["cron"]
response = asyncio.run(cron.on_job(seen["heartbeat_job"]))
assert response == "All clear."
assert seen["message_tool_result"] == "Message suppressed during internal check"
assert seen["default_notify"] is False
assert seen["session_key"] == "heartbeat"
assert seen["retained"] is True
bus.publish_outbound.assert_not_awaited()
def test_gateway_workspace_override_does_not_migrate_legacy_cron( def test_gateway_workspace_override_does_not_migrate_legacy_cron(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None:
-127
View File
@@ -1,127 +0,0 @@
"""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,
)
+21 -22
View File
@@ -38,28 +38,6 @@ 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] = []
@@ -80,6 +58,27 @@ async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None:
assert sent[1].metadata == {"_record_channel_delivery": True} assert sent[1].metadata == {"_record_channel_delivery": True}
@pytest.mark.asyncio
async def test_message_tool_can_suppress_delivery_for_internal_checks() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
token = tool.set_suppress_delivery(True)
try:
result = await tool.execute(content="All clear.", channel="lark", chat_id="chat-1")
finally:
tool.reset_suppress_delivery(token)
assert result == "Message suppressed during internal check"
assert sent == []
await tool.execute(content="real update", channel="lark", chat_id="chat-1")
assert [msg.content for msg in sent] == ["real update"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_message_tool_records_media_deliveries() -> None: async def test_message_tool_records_media_deliveries() -> None:
sent: list[OutboundMessage] = [] sent: list[OutboundMessage] = []
-13
View File
@@ -31,19 +31,6 @@ 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()
+20 -43
View File
@@ -218,7 +218,7 @@ function HostChrome({
)} )}
</Button> </Button>
) : ( ) : (
<div aria-hidden className="host-no-drag pointer-events-none h-8 w-8" /> <div aria-hidden className="h-8 w-8" />
)} )}
</header> </header>
); );
@@ -252,19 +252,7 @@ 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
? { ? {
@@ -272,7 +260,10 @@ 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: refreshedSurface, runtimeSurface:
refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: current.runtimeSurface,
} }
: current, : current,
); );
@@ -316,16 +307,8 @@ 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);
if (runtimeHost.socketFactory) { client.updateUrl(url);
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
? { ? {
@@ -333,7 +316,9 @@ 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, runtimeSurface: boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: current.runtimeSurface,
} }
: current, : current,
); );
@@ -1073,19 +1058,12 @@ 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 && "host-window-shell", showHostChrome && "bg-sidebar",
)} )}
> >
{showHostChrome ? ( {showHostChrome ? (
@@ -1093,6 +1071,7 @@ 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
@@ -1113,10 +1092,8 @@ function Shell({
> >
<div <div
className={cn( className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden", "absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar",
showHostChrome !showHostChrome && "shadow-inner-right",
? "host-sidebar-glass"
: "bg-sidebar shadow-inner-right",
)} )}
> >
<Sidebar <Sidebar
@@ -1161,12 +1138,13 @@ 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 && "border-l border-border/55", showHostChrome &&
)} "rounded-l-[28px] shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.45)] dark:shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.85)]",
> )}
>
<div <div
className={cn( className={cn(
"absolute inset-0 flex flex-col", "absolute inset-0 flex flex-col",
@@ -1183,7 +1161,6 @@ 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 || "text"} language={language}
style={isDark ? oneDark : oneLight} style={isDark ? oneDark : oneLight}
customStyle={{ customStyle={{
margin: 0, margin: 0,
+1 -137
View File
@@ -30,14 +30,6 @@ 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 {
@@ -187,119 +179,6 @@ 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 };
@@ -385,7 +264,7 @@ export default function MarkdownTextRenderer({
if (fence) { if (fence) {
return ( return (
<CodeBlock <CodeBlock
language={fence.language || "text"} language={fence.language}
code={fence.code} code={fence.code}
className="my-3" className="my-3"
highlight={highlightCode} highlight={highlightCode}
@@ -417,21 +296,6 @@ 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 (
+1 -2
View File
@@ -67,8 +67,7 @@ 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 text-sidebar-foreground", "flex h-full w-full min-w-0 flex-col bg-sidebar 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 modal={false}> <DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled}> <DropdownMenuTrigger asChild disabled={disabled}>
<Button <Button
type="button" type="button"
@@ -5098,12 +5098,11 @@ 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 modal={false}> <DropdownMenu>
<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",
@@ -5156,9 +5155,7 @@ 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={() => { onSelect={onCreateConfiguration}
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",
+1 -10
View File
@@ -393,15 +393,6 @@ 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,
@@ -595,7 +586,7 @@ function RunElapsedStrip({
aria-label={ariaLabel} aria-label={ariaLabel}
> >
{displayShowTimer ? ( {displayShowTimer ? (
<RunPulseIcon /> <Activity className="h-4 w-4 shrink-0 text-primary/80" aria-hidden />
) : ( ) : (
<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 />
)} )}
+13 -19
View File
@@ -10,7 +10,6 @@ interface ThreadHeaderProps {
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean; hideSidebarToggleForHostChrome?: boolean;
hideThemeButton?: boolean;
minimal?: boolean; minimal?: boolean;
} }
@@ -20,7 +19,6 @@ 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();
@@ -39,14 +37,12 @@ export function ThreadHeader({
> >
<Menu className="h-3.5 w-3.5" /> <Menu className="h-3.5 w-3.5" />
</Button> </Button>
{!hideThemeButton ? ( <ThemeButton
<ThemeButton theme={theme}
theme={theme} onToggleTheme={onToggleTheme}
onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")}
label={t("thread.header.toggleTheme")} className="ml-auto"
className="ml-auto" />
/>
) : null}
</div> </div>
); );
} }
@@ -71,14 +67,12 @@ export function ThreadHeader({
</div> </div>
</div> </div>
{!hideThemeButton ? ( <ThemeButton
<ThemeButton theme={theme}
theme={theme} onToggleTheme={onToggleTheme}
onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")}
label={t("thread.header.toggleTheme")} className="ml-auto shrink-0"
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>
@@ -103,7 +97,7 @@ function ThemeButton({
aria-label={label} aria-label={label}
onClick={onToggleTheme} onClick={onToggleTheme}
className={cn( className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground", "h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground",
className, className,
)} )}
> >
@@ -137,6 +137,10 @@ 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;
} }
@@ -146,6 +150,13 @@ 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,7 +62,6 @@ 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;
@@ -143,7 +142,6 @@ 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,
@@ -569,7 +567,6 @@ 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(
"thread-viewport-scrollbar absolute inset-0 overflow-y-auto scroll-auto scrollbar-thin", "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,7 +38,6 @@ 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." })
@@ -68,8 +67,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={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"} contentClassName="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"
title={rawFailureDetail || edit.absolute_path || edit.path} title={failureDetail || 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…" })
: ( : (
@@ -83,15 +82,13 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
testId="activity-file-reference" testId="activity-file-reference"
/> />
)} )}
detail={null} detail={failed ? (
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null} <span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
>
{failed ? (
<span className="block max-w-[42rem] truncate text-[11px] leading-4 text-destructive/75">
{failureDetail} {failureDetail}
</span> </span>
) : null} ) : null}
</ActivityStep> aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
/>
); );
} }
@@ -99,23 +96,14 @@ export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "delet
return edit.added > 0 || edit.deleted > 0; return edit.added > 0 || edit.deleted > 0;
} }
function cleanFileEditError(error?: string): string { function formatFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim(); const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return ""; if (!firstLine) return "";
return firstLine const cleaned = 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,75 +89,6 @@
-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);
} }
@@ -301,50 +232,6 @@
.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;
@@ -365,18 +252,6 @@
.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;
} }
+56 -24
View File
@@ -58,10 +58,11 @@ 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: reasoning can only extend the current reasoning placeholder. * Lookup rule: prefer the most recent assistant turn in the active UI tail.
* Once ordinary answer text has appeared, the next reasoning chunk starts a * Most providers emit reasoning before answer text, but some only expose
* fresh Thought block so streamed output stays in arrival order: * ``reasoning_content`` after the answer stream completes. In that post-hoc
* Thought -> answer -> Thought -> answer. * case the reasoning still belongs to the same assistant turn and must render
* above the answer, not as a new row below it.
*/ */
function attachReasoningChunk( function attachReasoningChunk(
prev: UIMessage[], prev: UIMessage[],
@@ -82,10 +83,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 = {
@@ -96,6 +97,15 @@ 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();
@@ -283,6 +293,38 @@ 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 =
@@ -469,7 +511,6 @@ 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 => {
@@ -548,18 +589,15 @@ export function useNanobotStream(
text += events[i].text; text += events[i].text;
i += 1; i += 1;
} }
if (kind === "delta") { next = kind === "delta"
next = appendAnswerChunk(next, text); ? appendAnswerChunk(next, text)
} else { : attachReasoningChunk(next, text, {
if (closeActiveAssistantStream()) clearActivitySegment(); ensure: ensureActivitySegmentId,
next = attachReasoningChunk(next, text, { });
ensure: ensureActivitySegmentId,
});
}
} }
return next; return next;
}, },
[appendAnswerChunk, clearActivitySegment, closeActiveAssistantStream, ensureActivitySegmentId], [appendAnswerChunk, ensureActivitySegmentId],
); );
const flushPendingStreamEvents = useCallback((options?: { const flushPendingStreamEvents = useCallback((options?: {
@@ -697,13 +735,7 @@ export function useNanobotStream(
return; return;
} }
const shouldCloseAnswerBeforeEvent = flushPendingStreamEvents();
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;
@@ -780,7 +812,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 = prev; const base = demoteInterruptedAssistantToActivity(prev, segmentId);
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
@@ -882,7 +914,7 @@ export function useNanobotStream(
} }
setMessages((prev) => { setMessages((prev) => {
let segmentId = eventSegmentId; let segmentId = eventSegmentId;
const base = prev; const base = segmentId ? demoteInterruptedAssistantToActivity(prev, segmentId) : 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": "Ajustes", "title": "Configuración",
"ariaLabel": "Secciones de ajustes" "ariaLabel": "Secciones de configuración"
}, },
"nav": { "nav": {
"general": "General", "general": "General",
"byok": "BYOK", "byok": "BYOK",
"overview": "Resumen", "overview": "Overview",
"appearance": "Apariencia", "appearance": "Appearance",
"models": "Modelos", "models": "Models",
"providers": "Proveedores", "providers": "Providers",
"image": "Imagen", "image": "Image",
"browser": "Internet", "browser": "Web",
"runtime": "Sistema", "runtime": "Sistema",
"advanced": "Seguridad", "advanced": "Security",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Aplicaciones" "apps": "Apps"
}, },
"sections": { "sections": {
"interface": "Interfaz", "interface": "Interfaz",
"ai": "AI", "ai": "IA",
"system": "Sistema", "system": "Sistema",
"status": "Estado", "status": "Status",
"localPreferences": "Preferencias locales", "localPreferences": "Local preferences",
"presets": "Preajustes", "presets": "Presets",
"imageGeneration": "Generación de imágenes", "imageGeneration": "Generación de imágenes",
"imageDefaults": "Valores predeterminados", "imageDefaults": "Valores predeterminados",
"webSearch": "Búsqueda web", "webSearch": "Web search",
"webBehavior": "Comportamiento", "webBehavior": "Behavior",
"identity": "Identidad", "identity": "Identity",
"webuiSafety": "Seguridad de WebUI", "webuiSafety": "Web safety",
"capabilities": "Capacidades", "capabilities": "Capacidades",
"cliApps": "Aplicaciones CLI", "cliApps": "Apps CLI",
"mcp": "Servicios MCP", "mcp": "Servicios MCP",
"apps": "Aplicaciones", "apps": "Apps",
"nativeHost": "Host nativo", "nativeHost": "App",
"hostSafety": "Seguridad de la app" "hostSafety": "App safety"
}, },
"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": "Preajuste activo", "activePreset": "Active preset",
"gateway": "Pasarela", "gateway": "Gateway",
"restartState": "Estado de reinicio", "restartState": "Restart state",
"pendingChanges": "Cambios pendientes", "pendingChanges": "Cambios pendientes",
"selectedPreset": "Preajuste seleccionado", "selectedPreset": "Selected preset",
"presetModel": "Modelo del preajuste", "presetModel": "Preset model",
"density": "Densidad", "density": "Density",
"activityMode": "Detalle de actividad", "activityMode": "Activity detail",
"codeWrap": "Ajuste de código", "codeWrap": "Code wrapping",
"maxResults": "Resultados máximos", "maxResults": "Max results",
"timeout": "Tiempo de espera", "timeout": "Timeout",
"jinaReader": "Lector Jina", "jinaReader": "Jina reader",
"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": "Proporción predeterminada", "defaultAspectRatio": "Relación predeterminada",
"defaultImageSize": "Tamaño predeterminado", "defaultImageSize": "Tamaño predeterminado",
"maxImagesPerTurn": "Máx. imágenes por turno", "maxImagesPerTurn": "Máximo de imágenes por turno",
"imageSaveDir": "Directorio de guardado", "imageSaveDir": "Directorio de guardado",
"botName": "Nombre del bot", "botName": "Bot name",
"botIcon": "Icono del bot", "botIcon": "Bot icon",
"timezone": "Zona horaria", "timezone": "Timezone",
"workspacePath": "Workspace predeterminado", "workspacePath": "Workspace predeterminado",
"localServiceAccess": "Servicios locales", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Acceso predeterminado", "webuiDefaultAccess": "Default access",
"currentModel": "Configuración actual", "currentModel": "Configuración actual",
"brandLogos": "Logos de marca", "brandLogos": "Logotipos de marca",
"cliAppsCatalog": "Catálogo", "cliAppsCatalog": "Catálogo de apps CLI",
"cliAppsFilter": "Filtro", "cliAppsFilter": "Filtro de apps CLI",
"engine": "Motor", "engine": "Motor",
"logs": "Registros", "logs": "Registros",
"diagnostics": "Diagnóstico", "diagnostics": "Diagnóstico",
"contextWindow": "Ventana de contexto" "contextWindow": "Context window"
}, },
"help": { "help": {
"theme": "Cambia entre apariencia clara y oscura.", "theme": "Cambia entre apariencia clara y oscura.",
"language": "Elige el idioma usado por WebUI.", "language": "Elige el idioma usado por la WebUI.",
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.", "provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
"model": "Define el nombre de modelo predeterminado de nanobot.", "model": "Define el nombre del modelo predeterminado que usa nanobot.",
"configPath": "Archivo de configuración que usa actualmente el gateway.", "configPath": "El archivo de configuración que usa actualmente el gateway.",
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.", "selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.", "presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Solo se guarda en este navegador.", "density": "Stored only in this browser.",
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.", "activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.", "codeWrap": "Keep long code lines readable on smaller screens.",
"maxResults": "Resultados devueltos por cada llamada web_search.", "maxResults": "Results returned by each web_search call.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.", "timeout": "Seconds before a search provider request times out.",
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.", "jinaReader": "Use Jina Reader for web_fetch when available.",
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.", "imageGeneration": "Expone generate_image en los chats cuando hay un proveedor de imágenes configurado disponible.",
"imageProvider": "Elige el proveedor registrado usado por generate_image.", "imageProvider": "Elige el proveedor del registro que usará generate_image.",
"imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.", "imageProviderStatus": "La generación de imágenes reutiliza las 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 proporción.", "defaultAspectRatio": "Se usa cuando el prompt no elige una relación de aspecto.",
"defaultImageSize": "Pista de tamaño enviada a proveedores compatibles.", "defaultImageSize": "Sugerencia de tamaño enviada a los proveedores que la admiten.",
"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 junto al nombre del bot.", "botIcon": "Emoji o texto corto mostrado junto al nombre del bot.",
"timezone": "Se usa para horarios y respuestas con conciencia temporal.", "timezone": "Se usa para programaciones y respuestas sensibles al tiempo.",
"localServiceAccess": "Permite que comandos shell con Full Access alcancen servicios localhost.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Usado por chats web sin permiso específico de proyecto.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Se usa para nuevas respuestas.", "currentModel": "Elige la configuración de modelo que nanobot usa para las próximas respuestas.",
"selectedModelProvider": "Definido por el modelo seleccionado.", "selectedModelProvider": "Lo define el modelo seleccionado.",
"selectedModelValue": "Definido por el modelo seleccionado.", "selectedModelValue": "Lo define el modelo seleccionado.",
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.", "brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
"cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.", "cliAppsCatalog": "Explora CLIs de apps que nanobot puede ejecutar localmente.",
"cliAppsFilter": "Busca por app, categoría o capacidad.", "cliAppsFilter": "Busca por app, categoría o capacidad.",
"logs": "Abre la carpeta de registros del motor nativo.", "logs": "Abre la carpeta de registros del motor de escritorio.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.", "diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.", "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.", "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Elige el presupuesto de contexto predeterminado para esta configuración de modelo." "contextWindow": "Choose the default context budget for this model configuration."
}, },
"values": { "values": {
"light": "Claro", "light": "Claro",
"dark": "Oscuro", "dark": "Oscuro",
"notAvailable": "No disponible", "notAvailable": "No disponible",
"enabled": "Activado", "enabled": "Enabled",
"disabled": "Desactivado", "disabled": "Disabled",
"restartPending": "Reinicio pendiente", "restartPending": "Reinicio pendiente",
"ready": "Listo", "ready": "Listo",
"comfortable": "Cómodo", "comfortable": "Comfortable",
"compact": "Compacto", "compact": "Compact",
"auto": "Automático", "auto": "Auto",
"expanded": "Expandido", "expanded": "Expanded",
"on": "Activado", "on": "On",
"off": "Desactivado", "off": "Off",
"defaultPermission": "Permiso predeterminado", "defaultPermission": "Default Permission",
"fullAccess": "Acceso completo", "fullAccess": "Full Access",
"configured": "Configurado", "configured": "Configured",
"notConfigured": "Sin configurar", "notConfigured": "Not configured",
"pending": "Pendiente", "pending": "Pendiente",
"restartingEngine": "Reiniciando" "restartingEngine": "Reiniciando"
}, },
"status": { "status": {
"loading": "Cargando ajustes...", "loading": "Cargando configuración...",
"loadError": "No se pudieron cargar los ajustes", "loadError": "No se pudo cargar la configuración",
"unsaved": "Cambios sin guardar.", "unsaved": "Hay 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 puedas.", "restartAfterSaving": "Guarda los cambios y reinicia cuando estés listo.",
"savedRestartApply": "Guardado. Reinicia cuando puedas.", "savedRestartApply": "Guardado. Reinicia cuando estés listo.",
"imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.", "imageProviderRestart": "Cambios del proveedor de imágenes guardados. Reinicia cuando estés listo.",
"hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.", "hostRestartAfterSaving": "Guarda los cambios y nanobot reiniciará su motor.",
"hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.", "hostRestartPending": "Guardado. Reiniciando el motor cuando esté listo.",
"hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.", "hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Carpeta de registros abierta.", "logsOpened": "Opened logs folder.",
"logsOpenFailed": "No se pudo abrir la carpeta de registros.", "logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnóstico exportado a {{path}}.", "diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "No se pudo exportar el diagnóstico." "diagnosticsExportFailed": "Could not export diagnostics."
}, },
"actions": { "actions": {
"save": "Guardar", "save": "Guardar",
@@ -224,8 +224,8 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"open": "Abrir", "open": "Abrir",
"export": "Exportar", "export": "Exportar",
"opening": "Abriendo...", "opening": "Opening...",
"exporting": "Exportando..." "exporting": "Exporting..."
}, },
"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": "Búsqueda web" "webSearch": "Web Search"
}, },
"webSearch": { "webSearch": {
"provider": "Proveedor de búsqueda", "provider": "Proveedor de búsqueda",
@@ -270,44 +270,44 @@
} }
}, },
"overview": { "overview": {
"model": "Modelo actual", "model": "Current model",
"providers": "Proveedores", "providers": "Providers",
"configuredCount": "{{count}} configurados", "configuredCount": "{{count}} configured",
"totalProviders": "{{count}} disponibles", "totalProviders": "{{count}} available",
"webSearch": "Búsqueda web", "webSearch": "Web search",
"imageGeneration": "Generación de imágenes", "imageGeneration": "Generación de imágenes",
"workspace": "Espacio de trabajo" "workspace": "Workspace"
}, },
"providers": { "providers": {
"searchPlaceholder": "Buscar proveedores", "searchPlaceholder": "Search providers",
"noMatches": "Ningún proveedor coincide con esta búsqueda.", "noMatches": "No providers match this search.",
"saveProvider": "Guardar proveedor" "saveProvider": "Guardar proveedor"
}, },
"image": { "image": {
"selectProvider": "Seleccionar proveedor", "selectProvider": "Seleccionar proveedor",
"selectAspect": "Seleccionar proporción", "selectAspect": "Seleccionar relació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": "Agregar configuración", "addConfiguration": "Añadir configuración",
"newConfiguration": "Nueva configuración de modelo", "newConfiguration": "Nueva configuración de modelo",
"newConfigurationHelp": "Guarda un proveedor y modelo como opción de un clic.", "newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
"configurationName": "Nombre de configuración", "configurationName": "Nombre de configuración",
"configurationNameHelp": "Renombra esta configuración de modelo guardada.", "configurationNameHelp": "Cambia el nombre de 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 el catálogo del proveedor para elegir un modelo.", "searchCatalog": "Busca en 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 un ID de modelo manualmente.", "unsupportedModelList": "Escribe manualmente un ID de modelo.",
"providerNotConfigured": "Configura este proveedor antes de cargar modelos.", "providerNotConfigured": "Configura este proveedor antes de cargar modelos.",
"autoProviderCustomOnly": "El modo de proveedor automático usa IDs de modelo personalizados." "autoProviderCustomOnly": "El modo de proveedor automático usa ID 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}} aplicaciones", "availableCount": "{{count}} apps",
"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": "Agrega CLI de apps y servicios MCP que nanobot puede usar desde el chat.", "description": "Añade 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": "Autenticación OAuth", "authentication": "OAuth authentication",
"signIn": "Iniciar sesión", "signIn": "Sign in",
"signingIn": "Iniciando sesión...", "signingIn": "Signing in...",
"signInAgain": "Iniciar sesión de nuevo", "signInAgain": "Sign in again",
"signOut": "Cerrar sesión", "signOut": "Sign out",
"signedInAs": "Sesión iniciada como {{account}}", "signedInAs": "Signed in as {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.", "signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Inicio de sesión requerido", "signInRequired": "Sign in required",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Sesión iniciada", "signedIn": "Signed in",
"notSignedIn": "Sin sesión" "notSignedIn": "Not signed in"
} }
}, },
"chat": { "chat": {
+144 -144
View File
@@ -57,45 +57,45 @@
"apps": "Apps" "apps": "Apps"
}, },
"settings": { "settings": {
"backToChat": "Retour au chat", "backToChat": "Retour à la discussion",
"sidebar": { "sidebar": {
"title": "Réglages", "title": "Paramètres",
"ariaLabel": "Sections des réglages" "ariaLabel": "Sections des paramètres"
}, },
"nav": { "nav": {
"general": "Général", "general": "Général",
"byok": "BYOK", "byok": "BYOK",
"overview": "Aperçu", "overview": "Overview",
"appearance": "Apparence", "appearance": "Appearance",
"models": "Modèles", "models": "Models",
"providers": "Fournisseurs", "providers": "Providers",
"image": "Images", "image": "Image",
"browser": "Internet", "browser": "Web",
"runtime": "Système", "runtime": "Système",
"advanced": "Sécurité", "advanced": "Security",
"cliApps": "Apps CLI", "cliApps": "Apps CLI",
"mcp": "MCP", "mcp": "MCP",
"apps": "Applications" "apps": "Apps"
}, },
"sections": { "sections": {
"interface": "Interface utilisateur", "interface": "Interface",
"ai": "AI", "ai": "IA",
"system": "Système", "system": "Système",
"status": "État", "status": "Status",
"localPreferences": "Préférences locales", "localPreferences": "Local preferences",
"presets": "Préréglages", "presets": "Presets",
"imageGeneration": "Génération dimages", "imageGeneration": "Génération d'images",
"imageDefaults": "Valeurs par défaut", "imageDefaults": "Valeurs par défaut",
"webSearch": "Recherche web", "webSearch": "Web search",
"webBehavior": "Comportement", "webBehavior": "Behavior",
"identity": "Identité", "identity": "Identity",
"webuiSafety": "Sécurité WebUI", "webuiSafety": "Web safety",
"capabilities": "Capacités", "capabilities": "Capacités",
"cliApps": "Applications CLI", "cliApps": "Apps CLI",
"mcp": "Services MCP", "mcp": "Services MCP",
"apps": "Applications", "apps": "Apps",
"nativeHost": "Hôte natif", "nativeHost": "App",
"hostSafety": "Sécurité de lapp" "hostSafety": "App safety"
}, },
"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 config", "configPath": "Chemin de configuration",
"activePreset": "Préréglage actif", "activePreset": "Active preset",
"gateway": "Passerelle", "gateway": "Gateway",
"restartState": "État du redémarrage", "restartState": "Restart state",
"pendingChanges": "Modifications en attente", "pendingChanges": "Modifications en attente",
"selectedPreset": "Préréglage sélectionné", "selectedPreset": "Selected preset",
"presetModel": "Modèle du préréglage", "presetModel": "Preset model",
"density": "Densité", "density": "Density",
"activityMode": "Détail dactivité", "activityMode": "Activity detail",
"codeWrap": "Retour à la ligne du code", "codeWrap": "Code wrapping",
"maxResults": "Résultats max.", "maxResults": "Max results",
"timeout": "Délai dattente", "timeout": "Timeout",
"jinaReader": "Lecteur Jina", "jinaReader": "Jina reader",
"imageGeneration": "Génération dimages", "imageGeneration": "Génération d'images",
"imageProvider": "Fournisseur dimages", "imageProvider": "Fournisseur d'images",
"imageProviderStatus": "État du fournisseur", "imageProviderStatus": "État du fournisseur",
"imageProviderBase": "Base du fournisseur", "imageProviderBase": "Base du fournisseur",
"imageModel": "Modèle dimage", "imageModel": "Modèle d'image",
"defaultAspectRatio": "Ratio par défaut", "defaultAspectRatio": "Format par défaut",
"defaultImageSize": "Taille par défaut", "defaultImageSize": "Taille par défaut",
"maxImagesPerTurn": "Images max. par tour", "maxImagesPerTurn": "Nombre max. d'images par tour",
"imageSaveDir": "Dossier denregistrement", "imageSaveDir": "Répertoire de sauvegarde",
"botName": "Nom du bot", "botName": "Bot name",
"botIcon": "Icône du bot", "botIcon": "Bot icon",
"timezone": "Fuseau horaire", "timezone": "Timezone",
"workspacePath": "Espace de travail par défaut", "workspacePath": "Espace de travail par défaut",
"localServiceAccess": "Services locaux", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Accès par défaut", "webuiDefaultAccess": "Default access",
"currentModel": "Configuration actuelle", "currentModel": "Configuration actuelle",
"brandLogos": "Logos de marque", "brandLogos": "Logos de marque",
"cliAppsCatalog": "Catalogue", "cliAppsCatalog": "Catalogue d'apps CLI",
"cliAppsFilter": "Filtre", "cliAppsFilter": "Filtre des apps CLI",
"engine": "Moteur", "engine": "Moteur",
"logs": "Journaux", "logs": "Journaux",
"diagnostics": "Diagnostic", "diagnostics": "Diagnostics",
"contextWindow": "Fenêtre de contexte" "contextWindow": "Context window"
}, },
"help": { "help": {
"theme": "Basculer entre lapparence claire et sombre.", "theme": "Basculer entre les apparences claire et sombre.",
"language": "Choisissez la langue utilisée par WebUI.", "language": "Choisissez la langue utilisée par le WebUI.",
"provider": "Sélectionnez le fournisseur à utiliser pour les nouvelles requêtes de modèle.", "provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.",
"model": "Définissez le nom du modèle utilisé par défaut par nanobot.", "model": "Définissez le nom du modèle par défaut utilisé par nanobot.",
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle.", "configPath": "Le fichier de configuration actuellement utilisé par la passerelle.",
"selectedPreset": "Les préréglages nommés sont en lecture seule ici ; modifiez-les dans config.json.", "selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.", "presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Enregistré seulement dans ce navigateur.", "density": "Stored only in this browser.",
"activityMode": "Choisissez le niveau de détail dactivité agent affiché par défaut.", "activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.", "codeWrap": "Keep long code lines readable on smaller screens.",
"maxResults": "Résultats renvoyés par chaque appel web_search.", "maxResults": "Results returned by each web_search call.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.", "timeout": "Seconds before a search provider request times out.",
"jinaReader": "Utilise Jina Reader pour web_fetch lorsque disponible.", "jinaReader": "Use Jina Reader for web_fetch when available.",
"imageGeneration": "Expose generate_image dans les chats lorsquun fournisseur dimage configuré est disponible.", "imageGeneration": "Expose generate_image dans les chats lorsquun fournisseur dimages configuré est disponible.",
"imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.", "imageProvider": "Choisissez le fournisseur du registre utilisé par generate_image.",
"imageProviderStatus": "La génération dimages réutilise les identifiants des fournisseurs.", "imageProviderStatus": "La génération dimages réutilise les identifiants de 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 ratio.", "defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de format.",
"defaultImageSize": "Indication de taille envoyée aux fournisseurs compatibles.", "defaultImageSize": "Indication de taille envoyée aux fournisseurs qui la prennent en charge.",
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.", "maxImagesPerTurn": "Limite supérieure pour une requête generate_image.",
"botName": "Affiché où nanobot utilise un nom visible.", "botName": "Affiché partout 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 horaires et les réponses tenant compte du temps.", "timezone": "Utilisé pour les planifications et les réponses sensibles à lheure.",
"localServiceAccess": "Autorise les commandes shell Full Access à atteindre les services localhost.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Utilisé par les chats web sans permission propre au projet.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"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.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Utilisée pour les nouvelles réponses.", "currentModel": "Choisissez la configuration de modèle que nanobot utilisera pour les prochaines 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": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.", "brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI dapps que nanobot peut exécuter localement ; les apps natives restent inchangées.", "cliAppsCatalog": "Parcourez les CLIs d'apps que nanobot peut exécuter localement.",
"cliAppsFilter": "Recherchez par app, catégorie ou capacité.", "cliAppsFilter": "Recherchez par app, catégorie ou capacité.",
"logs": "Ouvre le dossier des journaux du moteur natif.", "logs": "Ouvrir le dossier des journaux du moteur natif.",
"diagnostics": "Exporte un petit rapport dexécution pour le support.", "diagnostics": "Exporter un petit rapport runtime pour le support.",
"localServiceAccessNative": "Autorise les commandes shell Full Access à atteindre les services sur ce Mac.", "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Utilisé par les chats natifs sans permission propre au projet.", "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choisissez le budget de contexte par défaut pour cette configuration de modèle." "contextWindow": "Choose the default context budget for this model configuration."
}, },
"values": { "values": {
"light": "Clair", "light": "Clair",
"dark": "Sombre", "dark": "Sombre",
"notAvailable": "Indisponible", "notAvailable": "Indisponible",
"enabled": "Activé", "enabled": "Enabled",
"disabled": "Désactivé", "disabled": "Disabled",
"restartPending": "Redémarrage en attente", "restartPending": "Redémarrage en attente",
"ready": "Prêt", "ready": "Prêt",
"comfortable": "Confortable", "comfortable": "Comfortable",
"compact": "Compacte", "compact": "Compact",
"auto": "Automatique", "auto": "Auto",
"expanded": "Développé", "expanded": "Expanded",
"on": "Activé", "on": "On",
"off": "Désactivé", "off": "Off",
"defaultPermission": "Autorisation par défaut", "defaultPermission": "Default Permission",
"fullAccess": "Accès complet", "fullAccess": "Full Access",
"configured": "Configuré", "configured": "Configured",
"notConfigured": "Non configuré", "notConfigured": "Not configured",
"pending": "En attente", "pending": "En attente",
"restartingEngine": "Redémarrage" "restartingEngine": "Redémarrage"
}, },
"status": { "status": {
"loading": "Chargement des réglages...", "loading": "Chargement des paramètres...",
"loadError": "Impossible de charger les réglages", "loadError": "Impossible de charger les paramètres",
"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 changements, puis redémarrez quand vous êtes prêt.", "restartAfterSaving": "Enregistrez les modifications, puis redémarrez lorsque vous êtes prêt.",
"savedRestartApply": "Enregistré. Redémarrez quand vous êtes prêt.", "savedRestartApply": "Enregistré. Redémarrez lorsque vous êtes prêt.",
"imageProviderRestart": "Changements du fournisseur dimage enregistrés. Redémarrez quand vous êtes prêt.", "imageProviderRestart": "Modifications du fournisseur dimages enregistrées. Redémarrez lorsque vous êtes prêt.",
"hostRestartAfterSaving": "En enregistrant, nanobot redémarrera son moteur.", "hostRestartAfterSaving": "Enregistrez les changements et nanobot redémarrera son moteur.",
"hostRestartPending": "Enregistré. Le moteur redémarrera lorsquil sera prêt.", "hostRestartPending": "Enregistré. Redémarrage du moteur quand il sera prêt.",
"hostApiUnavailable": "Les actions de lhôte ne sont disponibles que dans lapp native.", "hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Dossier des journaux ouvert.", "logsOpened": "Opened logs folder.",
"logsOpenFailed": "Impossible douvrir le dossier des journaux.", "logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostic exporté vers {{path}}.", "diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Impossible dexporter le diagnostic." "diagnosticsExportFailed": "Could not export diagnostics."
}, },
"actions": { "actions": {
"save": "Enregistrer", "save": "Enregistrer",
@@ -224,8 +224,8 @@
"cancel": "Annuler", "cancel": "Annuler",
"open": "Ouvrir", "open": "Ouvrir",
"export": "Exporter", "export": "Exporter",
"opening": "Ouverture...", "opening": "Opening...",
"exporting": "Exportation..." "exporting": "Exporting..."
}, },
"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": "Recherche web" "webSearch": "Web Search"
}, },
"webSearch": { "webSearch": {
"provider": "Fournisseur de recherche", "provider": "Fournisseur de recherche",
@@ -270,53 +270,53 @@
} }
}, },
"overview": { "overview": {
"model": "Modèle actuel", "model": "Current model",
"providers": "Fournisseurs", "providers": "Providers",
"configuredCount": "{{count}} configurés", "configuredCount": "{{count}} configured",
"totalProviders": "{{count}} disponibles", "totalProviders": "{{count}} available",
"webSearch": "Recherche web", "webSearch": "Web search",
"imageGeneration": "Génération dimages", "imageGeneration": "Génération d'images",
"workspace": "Espace de travail" "workspace": "Workspace"
}, },
"providers": { "providers": {
"searchPlaceholder": "Rechercher des fournisseurs", "searchPlaceholder": "Search providers",
"noMatches": "Aucun fournisseur ne correspond.", "noMatches": "No providers match this search.",
"saveProvider": "Enregistrer le fournisseur" "saveProvider": "Enregistrer le fournisseur"
}, },
"image": { "image": {
"selectProvider": "Choisir un fournisseur", "selectProvider": "Sélectionner un fournisseur",
"selectAspect": "Choisir un ratio", "selectAspect": "Sélectionner un format",
"selectSize": "Choisir une taille", "selectSize": "Sélectionner une taille",
"configureProvider": "Configurer le fournisseur", "configureProvider": "Configurer le fournisseur",
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes." "missingCredential": "Configurez ce fournisseur avant dactiver la génération dimages."
}, },
"models": { "models": {
"selectModel": "Choisir un modèle", "selectModel": "Sélectionner un modèle",
"addConfiguration": "Ajouter une configuration", "addConfiguration": "Ajouter une configuration",
"newConfiguration": "Nouvelle configuration de modèle", "newConfiguration": "Nouvelle configuration de modèle",
"newConfigurationHelp": "Enregistre un fournisseur et un modèle comme option en un clic.", "newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
"configurationName": "Nom de la configuration", "configurationName": "Nom de la configuration",
"configurationNameHelp": "Renomme cette configuration de modèle enregistrée.", "configurationNameHelp": "Renommez cette configuration de modèle enregistrée.",
"configurationNamePlaceholder": "Rédaction rapide", "configurationNamePlaceholder": "Rédaction rapide",
"searchModels": "Rechercher ou saisir un ID de modèle", "searchModels": "Rechercher ou saisir lID du modèle",
"useCustomModel": "Utiliser", "useCustomModel": "Utiliser",
"loadingModels": "Chargement des modèles...", "loadingModels": "Chargement des modèles...",
"searchCatalog": "Rechercher dans le catalogue du fournisseur pour choisir un modèle.", "searchCatalog": "Recherchez 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 IDs de modèle personnalisés." "autoProviderCustomOnly": "Le mode fournisseur automatique utilise des ID de modèle personnalisés."
}, },
"timezone": { "timezone": {
"select": "Choisir un fuseau horaire", "select": "Sélectionner 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}} applications", "availableCount": "{{count}} apps",
"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 services MCP utilisables par nanobot depuis le chat.", "description": "Ajoutez des CLI dapps et des services MCP que nanobot peut utiliser dans 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": "À la une", "featured": "En vedette",
"loading": "Chargement des apps...", "loading": "Chargement des apps...",
"empty": "Aucune app ne correspond." "empty": "Aucune app ne correspond à ce filtre."
}, },
"oauth": { "oauth": {
"authentication": "Authentification OAuth", "authentication": "OAuth authentication",
"signIn": "Se connecter", "signIn": "Sign in",
"signingIn": "Connexion...", "signingIn": "Signing in...",
"signInAgain": "Se reconnecter", "signInAgain": "Sign in again",
"signOut": "Se déconnecter", "signOut": "Sign out",
"signedInAs": "Connecté en tant que {{account}}", "signedInAs": "Signed in as {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.", "signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Connexion requise", "signInRequired": "Sign in required",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Connecté", "signedIn": "Signed in",
"notSignedIn": "Non connecté" "notSignedIn": "Not signed in"
} }
}, },
"chat": { "chat": {
+127 -127
View File
@@ -57,7 +57,7 @@
"apps": "Aplikasi" "apps": "Aplikasi"
}, },
"settings": { "settings": {
"backToChat": "Kembali ke chat", "backToChat": "Kembali ke obrolan",
"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": "Ikhtisar", "overview": "Overview",
"appearance": "Tampilan", "appearance": "Appearance",
"models": "Model", "models": "Models",
"providers": "Penyedia", "providers": "Providers",
"image": "Gambar", "image": "Image",
"browser": "Internet", "browser": "Web",
"runtime": "Sistem", "runtime": "Sistem",
"advanced": "Keamanan", "advanced": "Security",
"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": "Preferensi lokal", "localPreferences": "Local preferences",
"presets": "Preset", "presets": "Presets",
"imageGeneration": "Pembuatan gambar", "imageGeneration": "Pembuatan gambar",
"imageDefaults": "Default", "imageDefaults": "Default",
"webSearch": "Pencarian web", "webSearch": "Web search",
"webBehavior": "Perilaku", "webBehavior": "Behavior",
"identity": "Identitas", "identity": "Identity",
"webuiSafety": "Keamanan WebUI", "webuiSafety": "Web safety",
"capabilities": "Kemampuan", "capabilities": "Kapabilitas",
"cliApps": "Aplikasi CLI", "cliApps": "App CLI",
"mcp": "Layanan MCP", "mcp": "Layanan MCP",
"apps": "Aplikasi", "apps": "Aplikasi",
"nativeHost": "Host native", "nativeHost": "Native host",
"hostSafety": "Keamanan aplikasi" "hostSafety": "App safety"
}, },
"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": "Preset aktif", "activePreset": "Active preset",
"gateway": "Gerbang", "gateway": "Gateway",
"restartState": "Status mulai ulang", "restartState": "Restart state",
"pendingChanges": "Perubahan tertunda", "pendingChanges": "Perubahan tertunda",
"selectedPreset": "Preset terpilih", "selectedPreset": "Selected preset",
"presetModel": "Model preset", "presetModel": "Preset model",
"density": "Kerapatan", "density": "Density",
"activityMode": "Detail aktivitas", "activityMode": "Activity detail",
"codeWrap": "Bungkus kode", "codeWrap": "Code wrapping",
"maxResults": "Hasil maksimum", "maxResults": "Max results",
"timeout": "Batas waktu", "timeout": "Timeout",
"jinaReader": "Pembaca Jina", "jinaReader": "Jina reader",
"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 simpan", "imageSaveDir": "Direktori penyimpanan",
"botName": "Nama bot", "botName": "Bot name",
"botIcon": "Ikon bot", "botIcon": "Bot icon",
"timezone": "Zona waktu", "timezone": "Timezone",
"workspacePath": "Workspace default", "workspacePath": "Workspace default",
"localServiceAccess": "Layanan lokal", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Akses default", "webuiDefaultAccess": "Default access",
"currentModel": "Konfigurasi saat ini", "currentModel": "Konfigurasi saat ini",
"brandLogos": "Logo merek", "brandLogos": "Logo merek",
"cliAppsCatalog": "Katalog", "cliAppsCatalog": "Katalog aplikasi CLI",
"cliAppsFilter": "Saring", "cliAppsFilter": "Filter aplikasi CLI",
"engine": "Mesin", "engine": "Engine",
"logs": "Log", "logs": "Log",
"diagnostics": "Diagnostik", "diagnostics": "Diagnostik",
"contextWindow": "Jendela konteks" "contextWindow": "Context window"
}, },
"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": "Selecciona el proveedor para nuevas solicitudes de modelo.", "provider": "Pilih penyedia untuk permintaan model baru.",
"model": "Define el nombre de modelo predeterminado de nanobot.", "model": "Atur nama model default yang digunakan nanobot.",
"configPath": "Archivo de configuración que usa actualmente el gateway.", "configPath": "File konfigurasi gateway yang sedang digunakan.",
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.", "selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.", "presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Hanya disimpan di browser ini.", "density": "Stored only in this browser.",
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.", "activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.", "codeWrap": "Keep long code lines readable on smaller screens.",
"maxResults": "Resultados devueltos por cada llamada web_search.", "maxResults": "Results returned by each web_search call.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.", "timeout": "Seconds before a search provider request times out.",
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.", "jinaReader": "Use Jina Reader for web_fetch when available.",
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.", "imageGeneration": "Tampilkan generate_image di chat saat penyedia gambar yang dikonfigurasi tersedia.",
"imageProvider": "Elige el proveedor registrado usado por generate_image.", "imageProvider": "Pilih penyedia registry yang digunakan oleh generate_image.",
"imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.", "imageProviderStatus": "Pembuatan gambar menggunakan ulang kredensial penyedia dari Providers.",
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.", "imageModel": "Nama model yang dikirim ke penyedia gambar yang dipilih.",
"defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.", "defaultAspectRatio": "Digunakan saat prompt tidak memilih rasio aspek.",
"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": "Se muestra donde nanobot usa un nombre visible.", "botName": "Ditampilkan di tempat nanobot memakai nama tampilan.",
"botIcon": "Emoji o texto corto junto al nombre del bot.", "botIcon": "Emoji atau teks pendek yang tampil bersama nama bot.",
"timezone": "Se usa para horarios y respuestas con conciencia temporal.", "timezone": "Dipakai untuk jadwal dan balasan yang peka waktu.",
"localServiceAccess": "Izinkan perintah shell Full Access menjangkau layanan localhost.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Digunakan oleh chat web tanpa izin khusus proyek.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Digunakan untuk balasan baru.", "currentModel": "Pilih konfigurasi model yang digunakan nanobot untuk balasan berikutnya.",
"selectedModelProvider": "Definido por el modelo seleccionado.", "selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Definido por el modelo seleccionado.", "selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.", "brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
"cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.", "cliAppsCatalog": "Jelajahi CLI aplikasi yang dapat dijalankan nanobot secara lokal.",
"cliAppsFilter": "Busca por app, categoría o capacidad.", "cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
"logs": "Abre la carpeta de registros del motor nativo.", "logs": "Buka folder log native engine.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.", "diagnostics": "Ekspor laporan runtime kecil untuk dukungan.",
"localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.", "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.", "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Pilih anggaran konteks default untuk konfigurasi model ini." "contextWindow": "Choose the default context budget for this model configuration."
}, },
"values": { "values": {
"light": "Terang", "light": "Terang",
"dark": "Gelap", "dark": "Gelap",
"notAvailable": "Tidak tersedia", "notAvailable": "Tidak tersedia",
"enabled": "Aktif", "enabled": "Enabled",
"disabled": "Nonaktif", "disabled": "Disabled",
"restartPending": "Menunggu mulai ulang", "restartPending": "Menunggu restart",
"ready": "Siap", "ready": "Siap",
"comfortable": "Nyaman", "comfortable": "Comfortable",
"compact": "Ringkas", "compact": "Compact",
"auto": "Otomatis", "auto": "Auto",
"expanded": "Diperluas", "expanded": "Expanded",
"on": "Aktif", "on": "On",
"off": "Nonaktif", "off": "Off",
"defaultPermission": "Izin default", "defaultPermission": "Default Permission",
"fullAccess": "Akses penuh", "fullAccess": "Full Access",
"configured": "Terkonfigurasi", "configured": "Configured",
"notConfigured": "Belum dikonfigurasi", "notConfigured": "Not configured",
"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": "Perubahan belum disimpan.", "unsaved": "Ada perubahan yang belum disimpan.",
"upToDate": "Sudah terbaru.", "upToDate": "Sudah terbaru.",
"savedRestart": "Guardado. Reinicia nanobot para aplicar.", "savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.",
"restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.", "restartAfterSaving": "Simpan perubahan, lalu restart saat siap.",
"savedRestartApply": "Guardado. Reinicia cuando puedas.", "savedRestartApply": "Tersimpan. Restart saat siap.",
"imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.", "imageProviderRestart": "Perubahan penyedia gambar tersimpan. Restart saat siap.",
"hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.", "hostRestartAfterSaving": "Simpan perubahan dan nanobot akan memulai ulang engine.",
"hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.", "hostRestartPending": "Tersimpan. Engine akan dimulai ulang saat siap.",
"hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.", "hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Carpeta de registros abierta.", "logsOpened": "Opened logs folder.",
"logsOpenFailed": "No se pudo abrir la carpeta de registros.", "logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnóstico exportado a {{path}}.", "diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "No se pudo exportar el diagnóstico." "diagnosticsExportFailed": "Could not export diagnostics."
}, },
"actions": { "actions": {
"save": "Simpan", "save": "Simpan",
@@ -224,8 +224,8 @@
"cancel": "Batal", "cancel": "Batal",
"open": "Buka", "open": "Buka",
"export": "Ekspor", "export": "Ekspor",
"opening": "Membuka...", "opening": "Opening...",
"exporting": "Mengekspor..." "exporting": "Exporting..."
}, },
"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": "Pencarian web" "webSearch": "Web Search"
}, },
"webSearch": { "webSearch": {
"provider": "Penyedia pencarian", "provider": "Search provider",
"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": "Model saat ini", "model": "Current model",
"providers": "Penyedia", "providers": "Providers",
"configuredCount": "{{count}} dikonfigurasi", "configuredCount": "{{count}} configured",
"totalProviders": "{{count}} tersedia", "totalProviders": "{{count}} available",
"webSearch": "Pencarian web", "webSearch": "Web search",
"imageGeneration": "Pembuatan gambar", "imageGeneration": "Pembuatan gambar",
"workspace": "Ruang kerja" "workspace": "Workspace"
}, },
"providers": { "providers": {
"searchPlaceholder": "Cari penyedia", "searchPlaceholder": "Search providers",
"noMatches": "Tidak ada penyedia yang cocok.", "noMatches": "No providers match this search.",
"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": "Configura este proveedor antes de activar la generación de imágenes." "missingCredential": "Konfigurasikan penyedia ini sebelum mengaktifkan pembuatan gambar."
}, },
"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 tersimpan ini.", "configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.",
"configurationNamePlaceholder": "Menulis cepat", "configurationNamePlaceholder": "Penulisan 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 memakai ID model kustom." "autoProviderCustomOnly": "Mode penyedia otomatis menggunakan ID model khusus."
}, },
"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 aplikasi dan layanan MCP yang dapat digunakan nanobot dari chat.", "description": "Tambahkan CLI app dan layanan MCP yang dapat digunakan nanobot dari chat.",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"filterAll": "Semua", "filterAll": "Semua",
"filterCli": "Aplikasi CLI", "filterCli": "App 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." "empty": "Tidak ada aplikasi yang cocok dengan filter ini."
}, },
"oauth": { "oauth": {
"authentication": "Autentikasi OAuth", "authentication": "OAuth authentication",
"signIn": "Masuk", "signIn": "Sign in",
"signingIn": "Masuk...", "signingIn": "Signing in...",
"signInAgain": "Masuk lagi", "signInAgain": "Sign in again",
"signOut": "Keluar", "signOut": "Sign out",
"signedInAs": "Masuk sebagai {{account}}", "signedInAs": "Signed in as {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.", "signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Perlu masuk", "signInRequired": "Sign in required",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Sudah masuk", "signedIn": "Signed in",
"notSignedIn": "Belum masuk" "notSignedIn": "Not signed in"
} }
}, },
"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": "ウェブ", "browser": "Web",
"runtime": "システム", "runtime": "システム",
"advanced": "セキュリティ", "advanced": "Security",
"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": "ローカル設定", "localPreferences": "Local preferences",
"presets": "プリセット", "presets": "Presets",
"imageGeneration": "画像生成", "imageGeneration": "画像生成",
"imageDefaults": "既定値", "imageDefaults": "既定値",
"webSearch": "ウェブ検索", "webSearch": "Web search",
"webBehavior": "動作", "webBehavior": "Behavior",
"identity": "ID", "identity": "Identity",
"webuiSafety": "WebUI の安全性", "webuiSafety": "Web safety",
"capabilities": "機能", "capabilities": "機能",
"cliApps": "CLI アプリ", "cliApps": "CLI アプリ",
"mcp": "MCP サービス", "mcp": "MCP サービス",
"apps": "アプリ", "apps": "アプリ",
"nativeHost": "ネイティブホスト", "nativeHost": "App",
"hostSafety": "アプリの安全性" "hostSafety": "App safety"
}, },
"rows": { "rows": {
"theme": "テーマ", "theme": "テーマ",
@@ -104,41 +104,41 @@
"model": "モデル", "model": "モデル",
"restart": "nanobot を再起動", "restart": "nanobot を再起動",
"configPath": "設定パス", "configPath": "設定パス",
"activePreset": "アクティブなプリセット", "activePreset": "Active preset",
"gateway": "ゲートウェイ", "gateway": "Gateway",
"restartState": "再起動状態", "restartState": "Restart state",
"pendingChanges": "保留中の変更", "pendingChanges": "保留中の変更",
"selectedPreset": "選択中のプリセット", "selectedPreset": "Selected preset",
"presetModel": "プリセットモデル", "presetModel": "Preset model",
"density": "表示密度", "density": "Density",
"activityMode": "アクティビティ詳細", "activityMode": "Activity detail",
"codeWrap": "コードの折り返し", "codeWrap": "Code wrapping",
"maxResults": "最大結果数", "maxResults": "Max results",
"timeout": "タイムアウト", "timeout": "Timeout",
"jinaReader": "Jina リーダー", "jinaReader": "Jina reader",
"imageGeneration": "画像生成", "imageGeneration": "画像生成",
"imageProvider": "画像プロバイダー", "imageProvider": "画像プロバイダー",
"imageProviderStatus": "プロバイダー状態", "imageProviderStatus": "プロバイダー状態",
"imageProviderBase": "プロバイダー URL", "imageProviderBase": "プロバイダーのベース URL",
"imageModel": "画像モデル", "imageModel": "画像モデル",
"defaultAspectRatio": "既定の比率", "defaultAspectRatio": "既定の比率",
"defaultImageSize": "既定のサイズ", "defaultImageSize": "既定のサイズ",
"maxImagesPerTurn": "1 ターンの最大画像数", "maxImagesPerTurn": "1 ターンあたりの最大画像数",
"imageSaveDir": "保存先ディレクトリ", "imageSaveDir": "保存先ディレクトリ",
"botName": "Bot ", "botName": "Bot name",
"botIcon": "Bot アイコン", "botIcon": "Bot icon",
"timezone": "タイムゾーン", "timezone": "Timezone",
"workspacePath": "既定のワークスペース", "workspacePath": "デフォルトワークスペース",
"localServiceAccess": "ローカルサービス", "localServiceAccess": "Local services",
"webuiDefaultAccess": "既定の権限", "webuiDefaultAccess": "Default access",
"currentModel": "現在の設定", "currentModel": "現在の設定",
"brandLogos": "ブランドロゴ", "brandLogos": "ブランドロゴ",
"cliAppsCatalog": "カタログ", "cliAppsCatalog": "CLI アプリカタログ",
"cliAppsFilter": "フィルター", "cliAppsFilter": "CLI アプリフィルター",
"engine": "エンジン", "engine": "エンジン",
"logs": "ログ", "logs": "ログ",
"diagnostics": "診断", "diagnostics": "診断",
"contextWindow": "コンテキストウィンドウ" "contextWindow": "Context window"
}, },
"help": { "help": {
"theme": "ライト表示とダーク表示を切り替えます。", "theme": "ライト表示とダーク表示を切り替えます。",
@@ -146,57 +146,57 @@
"provider": "新しいモデルリクエストに使うプロバイダーを選択します。", "provider": "新しいモデルリクエストに使うプロバイダーを選択します。",
"model": "nanobot が既定で使用するモデル名を設定します。", "model": "nanobot が既定で使用するモデル名を設定します。",
"configPath": "現在ゲートウェイが使用している設定ファイルです。", "configPath": "現在ゲートウェイが使用している設定ファイルです。",
"selectedPreset": "名前付きプリセットはここでは読み取り専用です。編集するには config.json を変更してください。", "selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。", "presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "このブラウザーにのみ保存されます。", "density": "Stored only in this browser.",
"activityMode": "既定で表示する agent アクティビティの詳細量を選択します。", "activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "小さな画面でも長いコード行を読みやすくします。", "codeWrap": "Keep long code lines readable on smaller screens.",
"maxResults": "各 web_search 呼び出しで返す結果数です。", "maxResults": "Results returned by each web_search call.",
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。", "timeout": "Seconds before a search provider request times out.",
"jinaReader": "利用可能な場合、web_fetch に Jina Reader を使います。", "jinaReader": "Use Jina Reader for web_fetch when available.",
"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": "Full Access shell コマンドが localhost サービスにアクセスできるようにします。", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "プロジェクト固有の権限がない Web チャットで使用します。", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web 取得は常にローカル、プライベート、メタデータサービスを保護します。コアチャネルの安全性は config.json で管理されます。", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "新しい返信に使用します。", "currentModel": "今後の返信で nanobot が使用するモデル設定を選択します。",
"selectedModelProvider": "選択したモデルによって設定されます。", "selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。", "selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。", "brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。", "cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。", "cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
"logs": "ネイティブエンジンのログフォルダを開きます。", "logs": "Appエンジンのログフォルダを開きます。",
"diagnostics": "サポート用の小さなランタイムレポートを書き出します。", "diagnostics": "サポート用の小さなランタイムレポートを書き出します。",
"localServiceAccessNative": "Full Access shell コマンドがこの Mac 上のサービスにアクセスできるようにします。", "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "プロジェクト固有の権限がないネイティブチャットで使用します。", "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "このモデル設定で使う既定のコンテキスト予算を選択します。" "contextWindow": "Choose the default context budget for this model configuration."
}, },
"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": "既定の権限", "defaultPermission": "Default Permission",
"fullAccess": "完全アクセス", "fullAccess": "Full Access",
"configured": "設定済み", "configured": "Configured",
"notConfigured": "未設定", "notConfigured": "Not configured",
"pending": "保留中", "pending": "保留中",
"restartingEngine": "再起動中" "restartingEngine": "再起動中"
}, },
@@ -211,11 +211,11 @@
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。", "imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。",
"hostRestartAfterSaving": "保存すると nanobot がエンジンを再起動します。", "hostRestartAfterSaving": "保存すると nanobot がエンジンを再起動します。",
"hostRestartPending": "保存しました。準備ができたらエンジンを再起動します。", "hostRestartPending": "保存しました。準備ができたらエンジンを再起動します。",
"hostApiUnavailable": "ホスト操作はネイティブアプリ内でのみ利用できます。", "hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "ログフォルダーを開きました。", "logsOpened": "Opened logs folder.",
"logsOpenFailed": "ログフォルダーを開けませんでした。", "logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "診断を {{path}} に書き出しました。", "diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "診断を書き出せませんでした。" "diagnosticsExportFailed": "Could not export diagnostics."
}, },
"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": "ウェブ検索" "webSearch": "Web Search"
}, },
"webSearch": { "webSearch": {
"provider": "検索 provider", "provider": "検索 provider",
@@ -270,17 +270,17 @@
} }
}, },
"overview": { "overview": {
"model": "現在のモデル", "model": "Current model",
"providers": "プロバイダー", "providers": "Providers",
"configuredCount": "{{count}} 個設定済み", "configuredCount": "{{count}} configured",
"totalProviders": "{{count}} 個利用可能", "totalProviders": "{{count}} available",
"webSearch": "Web 検索", "webSearch": "Web search",
"imageGeneration": "画像生成", "imageGeneration": "画像生成",
"workspace": "ワークスペース" "workspace": "Workspace"
}, },
"providers": { "providers": {
"searchPlaceholder": "プロバイダーを検索", "searchPlaceholder": "Search providers",
"noMatches": "一致するプロバイダーはありません。", "noMatches": "No providers match this search.",
"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 がチャットで使用できる App CLI と MCP サービスを追加します。", "description": "チャットから nanobot が使えるアプリ 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 authentication",
"signIn": "サインイン", "signIn": "Sign in",
"signingIn": "サインイン中...", "signingIn": "Signing in...",
"signInAgain": "再度サインイン", "signInAgain": "Sign in again",
"signOut": "サインアウト", "signOut": "Sign out",
"signedInAs": "{{account}} としてサインイン済み", "signedInAs": "Signed in as {{account}}",
"signInHelp": "このデバイスからサインインします。API key は config に保存されません。", "signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "サインインが必要です", "signInRequired": "Sign in required",
"signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。", "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "サインイン済み", "signedIn": "Signed in",
"notSignedIn": "未サインイン" "notSignedIn": "Not signed in"
} }
}, },
"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": "", "browser": "Web",
"runtime": "시스템", "runtime": "시스템",
"advanced": "보안", "advanced": "Security",
"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": "로컬 환경설정", "localPreferences": "Local preferences",
"presets": "프리셋", "presets": "Presets",
"imageGeneration": "이미지 생성", "imageGeneration": "이미지 생성",
"imageDefaults": "기본값", "imageDefaults": "기본값",
"webSearch": "웹 검색", "webSearch": "Web search",
"webBehavior": "동작", "webBehavior": "Behavior",
"identity": "ID", "identity": "Identity",
"webuiSafety": "WebUI 보안", "webuiSafety": "Web safety",
"capabilities": "기능", "capabilities": "기능",
"cliApps": "CLI 앱", "cliApps": "CLI 앱",
"mcp": "MCP 서비스", "mcp": "MCP 서비스",
"apps": "앱", "apps": "앱",
"nativeHost": "네이티브 호스트", "nativeHost": "App",
"hostSafety": "앱 보안" "hostSafety": "App safety"
}, },
"rows": { "rows": {
"theme": "테마", "theme": "테마",
@@ -104,118 +104,118 @@
"model": "모델", "model": "모델",
"restart": "nanobot 재시작", "restart": "nanobot 재시작",
"configPath": "설정 경로", "configPath": "설정 경로",
"activePreset": "활성 프리셋", "activePreset": "Active preset",
"gateway": "게이트웨이", "gateway": "Gateway",
"restartState": "재시작 상태", "restartState": "Restart state",
"pendingChanges": "대기 중인 변경", "pendingChanges": "대기 중인 변경 사항",
"selectedPreset": "선택한 프리셋", "selectedPreset": "Selected preset",
"presetModel": "프리셋 모델", "presetModel": "Preset model",
"density": "밀도", "density": "Density",
"activityMode": "활동 상세", "activityMode": "Activity detail",
"codeWrap": "코드 줄바꿈", "codeWrap": "Code wrapping",
"maxResults": "최대 결과 수", "maxResults": "Max results",
"timeout": "타임아웃", "timeout": "Timeout",
"jinaReader": "Jina 리더", "jinaReader": "Jina reader",
"imageGeneration": "이미지 생성", "imageGeneration": "이미지 생성",
"imageProvider": "이미지 제공자", "imageProvider": "이미지 제공자",
"imageProviderStatus": "제공자 상태", "imageProviderStatus": "제공자 상태",
"imageProviderBase": "제공자 주소", "imageProviderBase": "제공자 기준 주소",
"imageModel": "이미지 모델", "imageModel": "이미지 모델",
"defaultAspectRatio": "기본 비율", "defaultAspectRatio": "기본 비율",
"defaultImageSize": "기본 크기", "defaultImageSize": "기본 크기",
"maxImagesPerTurn": "턴당 최대 이미지 수", "maxImagesPerTurn": "턴당 최대 이미지 수",
"imageSaveDir": "저장 디렉터리", "imageSaveDir": "저장 디렉터리",
"botName": "Bot 이름", "botName": "Bot name",
"botIcon": "Bot 아이콘", "botIcon": "Bot icon",
"timezone": "시간대", "timezone": "Timezone",
"workspacePath": "기본 작업공간", "workspacePath": "기본 작업공간",
"localServiceAccess": "로컬 서비스", "localServiceAccess": "Local services",
"webuiDefaultAccess": "기본 권한", "webuiDefaultAccess": "Default access",
"currentModel": "현재 구성", "currentModel": "현재 구성",
"brandLogos": "브랜드 로고", "brandLogos": "브랜드 로고",
"cliAppsCatalog": "카탈로그", "cliAppsCatalog": "CLI 앱 카탈로그",
"cliAppsFilter": "필터", "cliAppsFilter": "CLI 앱 필터",
"engine": "엔진", "engine": "엔진",
"logs": "로그", "logs": "로그",
"diagnostics": "진단", "diagnostics": "진단",
"contextWindow": "컨텍스트 창" "contextWindow": "Context window"
}, },
"help": { "help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.", "theme": "밝은 모드와 어두운 모드를 전환합니다.",
"language": "WebUI에서 사용할 언어를 선택합니다.", "language": "WebUI에서 사용할 언어를 선택합니다.",
"provider": "새 모델 요청을 처리할 제공자를 선택합니다.", "provider": "새 모델 요청에 사용할 제공자를 선택합니다.",
"model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.", "model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.",
"configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.", "configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.",
"selectedPreset": "이름 있는 프리셋은 여기서 읽기 전용입니다. config.json에서 편집하세요.", "selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.", "presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "이 브라우저에만 저장됩니다.", "density": "Stored only in this browser.",
"activityMode": "기본으로 표시할 agent 활동 세부 수준을 선택합니다.", "activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.", "codeWrap": "Keep long code lines readable on smaller screens.",
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.", "maxResults": "Results returned by each web_search call.",
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.", "timeout": "Seconds before a search provider request times out.",
"jinaReader": "가능할 때 web_fetch에 Jina Reader를 사용합니다.", "jinaReader": "Use Jina Reader for web_fetch when available.",
"imageGeneration": "구성된 이미지 제공자 있을 때 채팅에서 generate_image를 노출합니다.", "imageGeneration": "구성된 이미지 제공자를 사용할 수 있을 때 채팅에서 generate_image를 노출합니다.",
"imageProvider": "generate_image 사용할 등록 제공자를 선택합니다.", "imageProvider": "generate_image 사용할 레지스트리 제공자를 선택합니다.",
"imageProviderStatus": "이미지 생성은 제공자 자격 증명을 재사용합니다.", "imageProviderStatus": "이미지 생성은 Providers의 제공자 자격 증명을 재사용합니다.",
"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": "Full Access shell 명령이 localhost 서비스에 접근할 수 있게 합니다.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "프로젝트별 권한이 없는 Web 채팅에 사용됩니다.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "웹 가져오기는 항상 로컬, 사설, 메타데이터 서비스를 보호합니다. 핵심 채널 보안은 config.json에서 관리됩니다.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "새 응답에 사용됩니다.", "currentModel": "nanobot이 새 답변에 사용할 모델 구성을 선택합니다.",
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.", "selectedModelProvider": "선택한 모델에 설정됩니다.",
"selectedModelValue": "선택한 모델에 의해 설정됩니다.", "selectedModelValue": "선택한 모델에 설정됩니다.",
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.", "brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.", "cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.", "cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
"logs": "네이티브 엔진 로그 폴더를 엽니다.", "logs": "App 엔진 로그 폴더를 엽니다.",
"diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.", "diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.",
"localServiceAccessNative": "Full Access shell 명령이 이 Mac의 서비스에 접근할 수 있게 합니다.", "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "프로젝트별 권한이 없는 네이티브 채팅에 사용됩니다.", "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "이 모델 구성의 기본 컨텍스트 예산을 선택합니다." "contextWindow": "Choose the default context budget for this model configuration."
}, },
"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": "기본 권한", "defaultPermission": "Default Permission",
"fullAccess": "전체 접근", "fullAccess": "Full Access",
"configured": "구성됨", "configured": "Configured",
"notConfigured": "미구성", "notConfigured": "Not configured",
"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": "호스트 작업은 네이티브 앱 안에서만 사용할 수 있습니다.", "hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "로그 폴더를 열었습니다.", "logsOpened": "Opened logs folder.",
"logsOpenFailed": "로그 폴더를 열 수 없습니다.", "logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "진단을 {{path}}에 내보냈습니다.", "diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "진단을 내보낼 수 없습니다." "diagnosticsExportFailed": "Could not export diagnostics."
}, },
"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": "웹 검색" "webSearch": "Web Search"
}, },
"webSearch": { "webSearch": {
"provider": "검색 provider", "provider": "검색 provider",
@@ -270,17 +270,17 @@
} }
}, },
"overview": { "overview": {
"model": "현재 모델", "model": "Current model",
"providers": "제공자", "providers": "Providers",
"configuredCount": "{{count}}개 구성됨", "configuredCount": "{{count}} configured",
"totalProviders": "{{count}}개 사용 가능", "totalProviders": "{{count}} available",
"webSearch": "웹 검색", "webSearch": "Web search",
"imageGeneration": "이미지 생성", "imageGeneration": "이미지 생성",
"workspace": "작업공간" "workspace": "Workspace"
}, },
"providers": { "providers": {
"searchPlaceholder": "제공자 검색", "searchPlaceholder": "Search providers",
"noMatches": "일치하는 제공자가 없습니다.", "noMatches": "No providers match this search.",
"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이 채팅에서 사용할 수 있는 App CLI와 MCP 서비스를 추가합니다.", "description": "채팅에서 nanobot이 사용할 수 있는 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 authentication",
"signIn": "로그인", "signIn": "Sign in",
"signingIn": "로그인 중...", "signingIn": "Signing in...",
"signInAgain": "다시 로그인", "signInAgain": "Sign in again",
"signOut": "로그아웃", "signOut": "Sign out",
"signedInAs": "{{account}}로 로그인됨", "signedInAs": "Signed in as {{account}}",
"signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.", "signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "로그인이 필요합니다", "signInRequired": "Sign in required",
"signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.", "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "로그인됨", "signedIn": "Signed in",
"notSignedIn": "로그인 안 됨" "notSignedIn": "Not signed in"
} }
}, },
"chat": { "chat": {
+134 -134
View File
@@ -57,22 +57,22 @@
"apps": "Ứng dụng" "apps": "Ứng dụng"
}, },
"settings": { "settings": {
"backToChat": "Quay lại chat", "backToChat": "Quay lại trò chuyện",
"sidebar": { "sidebar": {
"title": "Cài đặt", "title": "Cài đặt",
"ariaLabel": "Mục cài đặt" "ariaLabel": "Các mục cài đặt"
}, },
"nav": { "nav": {
"general": "Chung", "general": "Chung",
"byok": "BYOK", "byok": "BYOK",
"overview": "Tổng quan", "overview": "Overview",
"appearance": "Giao diện", "appearance": "Appearance",
"models": "Mô hình", "models": "Models",
"providers": "Nhà cung cấp", "providers": "Providers",
"image": "Hình ảnh", "image": "Image",
"browser": "Trang web", "browser": "Web",
"runtime": "Hệ thống", "runtime": "Hệ thống",
"advanced": "Bảo mật", "advanced": "Security",
"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": "Trạng thái", "status": "Status",
"localPreferences": "Tùy chọn cục bộ", "localPreferences": "Local preferences",
"presets": "Preset", "presets": "Presets",
"imageGeneration": "Tạo hình ảnh", "imageGeneration": "Tạo ảnh",
"imageDefaults": "Mặc định", "imageDefaults": "Mặc định",
"webSearch": "Tìm kiếm web", "webSearch": "Web search",
"webBehavior": "Hành vi", "webBehavior": "Behavior",
"identity": "Danh tính", "identity": "Identity",
"webuiSafety": "An toàn WebUI", "webuiSafety": "Web safety",
"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": "Host gốc", "nativeHost": "Native host",
"hostSafety": "An toàn ứng dụng" "hostSafety": "App safety"
}, },
"rows": { "rows": {
"theme": "Chủ đề", "theme": "Giao diện",
"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": "Preset đang dùng", "activePreset": "Active preset",
"gateway": "Cổng", "gateway": "Gateway",
"restartState": "Trạng thái khởi động lại", "restartState": "Restart state",
"pendingChanges": "Thay đổi chờ áp dụng", "pendingChanges": "Thay đổi đang chờ",
"selectedPreset": "Preset đã chọn", "selectedPreset": "Selected preset",
"presetModel": "Mô hình preset", "presetModel": "Preset model",
"density": "Mật độ", "density": "Density",
"activityMode": "Chi tiết hoạt động", "activityMode": "Activity detail",
"codeWrap": "Xuống dòng mã", "codeWrap": "Code wrapping",
"maxResults": "Kết quả tối đa", "maxResults": "Max results",
"timeout": "Thời gian chờ", "timeout": "Timeout",
"jinaReader": "Trình đọc Jina", "jinaReader": "Jina reader",
"imageGeneration": "Tạo hình ảnh", "imageGeneration": "Tạo ảnh",
"imageProvider": "Nhà cung cấp hình ảnh", "imageProvider": "Nhà cung cấp ảnh",
"imageProviderStatus": "Trạng thái nhà cung cấp", "imageProviderStatus": "Trạng thái nhà cung cấp",
"imageProviderBase": "Địa chỉ nhà cung cấp", "imageProviderBase": "Cơ sở nhà cung cấp",
"imageModel": "Mô hình hình ảnh", "imageModel": "Mô 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": "nh tối đa mỗi lượt", "maxImagesPerTurn": "Số ảnh tối đa mỗi lượt",
"imageSaveDir": "Thư mục lưu", "imageSaveDir": "Thư mục lưu",
"botName": "Tên bot", "botName": "Bot name",
"botIcon": "Biểu tượng bot", "botIcon": "Bot icon",
"timezone": "Múi giờ", "timezone": "Timezone",
"workspacePath": "Workspace mặc định", "workspacePath": "Workspace mặc định",
"localServiceAccess": "Dịch vụ cục bộ", "localServiceAccess": "Local services",
"webuiDefaultAccess": "Quyền mặc định", "webuiDefaultAccess": "Default access",
"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", "cliAppsCatalog": "Danh mục ứng dụng CLI",
"cliAppsFilter": "Bộ lọc", "cliAppsFilter": "Bộ lọc ứng dụng CLI",
"engine": "Bộ máy", "engine": "Engine",
"logs": "Nhật ký", "logs": "Nhật ký",
"diagnostics": "Chẩn đoán", "diagnostics": "Chẩn đoán",
"contextWindow": "Cửa sổ ngữ cảnh" "contextWindow": "Context window"
}, },
"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": "Selecciona el proveedor para nuevas solicitudes de modelo.", "provider": "Chọn nhà cung cấp cho các yêu cầu mô hình mới.",
"model": "Define el nombre de modelo predeterminado de nanobot.", "model": "Đặt tên mô hình mặc định mà nanobot sử dụng.",
"configPath": "Archivo de configuración que usa actualmente el gateway.", "configPath": "Tệp cấu hình gateway hiện đang dùng.",
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.", "selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.", "presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Chỉ lưu trong trình duyệt này.", "density": "Stored only in this browser.",
"activityMode": "Chọn mức chi tiết hoạt động agent hiển thị mặc định.", "activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.", "codeWrap": "Keep long code lines readable on smaller screens.",
"maxResults": "Resultados devueltos por cada llamada web_search.", "maxResults": "Results returned by each web_search call.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.", "timeout": "Seconds before a search provider request times out.",
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.", "jinaReader": "Use Jina Reader for web_fetch when available.",
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.", "imageGeneration": "Hiển thị generate_image trong chat khi có nhà cung cấp ảnh đã cấu hình.",
"imageProvider": "Elige el proveedor registrado usado por generate_image.", "imageProvider": "Chọn nhà cung cấp registry được generate_image sử dụng.",
"imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.", "imageProviderStatus": "Tạo ảnh dùng lại thông tin xác thực nhà cung cấp từ Providers.",
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.", "imageModel": "Tên mô hình gửi tới nhà cung cấp ảnh đã chọn.",
"defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.", "defaultAspectRatio": "Được dùng khi prompt không chọn tỷ lệ khung hình.",
"defaultImageSize": "Gợi ý kích thước gửi tới nhà cung cấp hỗ trợ.", "defaultImageSize": "Gợi ý kích thước gửi tới các nhà cung cấp hỗ trợ.",
"maxImagesPerTurn": "Giới hạn trên cho một yêu cầu generate_image.", "maxImagesPerTurn": "Giới hạn trên cho một yêu cầu generate_image.",
"botName": "Se muestra donde nanobot usa un nombre visible.", "botName": "Hiển thị ở nơi nanobot dùng tên hiển thị.",
"botIcon": "Emoji o texto corto junto al nombre del bot.", "botIcon": "Emoji hoặc văn bản ngắn hiển thị cùng tên bot.",
"timezone": "Se usa para horarios y respuestas con conciencia temporal.", "timezone": "Dùng cho lịch hẹn và câu trả lời có yếu tố thời gian.",
"localServiceAccess": "Cho phép lệnh shell Full Access truy cập dịch vụ localhost.", "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Dùng cho chat web không có quyền riêng theo dự án.", "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Dùng cho các phản hồi mới.", "currentModel": "Chọn cấu hình mô hình nanobot dùng cho các câu trả lời mới.",
"selectedModelProvider": "Definido por el modelo seleccionado.", "selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Definido por el modelo seleccionado.", "selectedModelValue": "Được đặt bởi mô hình đã chọn.",
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.", "brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
"cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.", "cliAppsCatalog": "Duyệt các CLI ứng dụng mà nanobot có thể chạy cục bộ.",
"cliAppsFilter": "Busca por app, categoría o capacidad.", "cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
"logs": "Abre la carpeta de registros del motor nativo.", "logs": "Mở thư mục nhật ký native engine.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.", "diagnostics": "Xuất báo cáo runtime nhỏ để hỗ trợ.",
"localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.", "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.", "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Chọn ngân sách ngữ cảnh mặc định cho cấu hình mô hình này." "contextWindow": "Choose the default context budget for this model configuration."
}, },
"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": "Đã bật", "enabled": "Enabled",
"disabled": "Đã tắt", "disabled": "Disabled",
"restartPending": "Chờ khởi động lại", "restartPending": "Đang chờ khởi động lại",
"ready": "Sẵn sàng", "ready": "Sẵn sàng",
"comfortable": "Thoải mái", "comfortable": "Comfortable",
"compact": "Gọn", "compact": "Compact",
"auto": "Tự động", "auto": "Auto",
"expanded": "Mở rộng", "expanded": "Expanded",
"on": "Bật", "on": "On",
"off": "Tắt", "off": "Off",
"defaultPermission": "Quyền mặc định", "defaultPermission": "Default Permission",
"fullAccess": "Toàn quyền", "fullAccess": "Full Access",
"configured": "Đã cấu hình", "configured": "Configured",
"notConfigured": "Chưa cấu hình", "notConfigured": "Not configured",
"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": "Guardado. Reinicia nanobot para aplicar.", "savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng.",
"restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.", "restartAfterSaving": "Lưu thay đổi, rồi khởi động lại khi sẵn sàng.",
"savedRestartApply": "Guardado. Reinicia cuando puedas.", "savedRestartApply": "Đã lưu. Khởi động lại khi sẵn sàng.",
"imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.", "imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng.",
"hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.", "hostRestartAfterSaving": "Lưu thay đổi và nanobot sẽ khởi động lại engine.",
"hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.", "hostRestartPending": "Đã lưu. Sẽ khởi động lại engine khi sẵn sàng.",
"hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.", "hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Carpeta de registros abierta.", "logsOpened": "Opened logs folder.",
"logsOpenFailed": "No se pudo abrir la carpeta de registros.", "logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnóstico exportado a {{path}}.", "diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "No se pudo exportar el diagnóstico." "diagnosticsExportFailed": "Could not export diagnostics."
}, },
"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": "Đang mở...", "opening": "Opening...",
"exporting": "Đang xuất..." "exporting": "Exporting..."
}, },
"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": "Tìm kiếm web" "webSearch": "Web Search"
}, },
"webSearch": { "webSearch": {
"provider": "Nhà cung cấp tìm kiếm", "provider": "Search provider",
"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": "Mô hình hiện tại", "model": "Current model",
"providers": "Nhà cung cấp", "providers": "Providers",
"configuredCount": "{{count}} đã cấu hình", "configuredCount": "{{count}} configured",
"totalProviders": "{{count}} khả dụng", "totalProviders": "{{count}} available",
"webSearch": "Tìm kiếm web", "webSearch": "Web search",
"imageGeneration": "Tạo hình ảnh", "imageGeneration": "Tạo ảnh",
"workspace": "Không gian làm việc" "workspace": "Workspace"
}, },
"providers": { "providers": {
"searchPlaceholder": "Tìm nhà cung cấp", "searchPlaceholder": "Search providers",
"noMatches": "Không có nhà cung cấp phù hợp.", "noMatches": "No providers match this search.",
"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": "Configura este proveedor antes de activar la generación de imágenes." "missingCredential": "Cấu hình nhà cung cấp này trước khi bật tạo ảnh."
}, },
"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 nhanh.", "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.",
"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 kiếm hoặc nhập ID mô hình", "searchModels": "Tì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": "Hãy cấu hình nhà cung cấp này trước khi tải mô hình.", "providerNotConfigured": "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 có thể dùng trong chat.", "description": "Thêm CLI ứng dụng và dịch vụ MCP để nanobot dùng trong trò chuyện.",
"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 phù hợp." "empty": "Không có ứng dụng nào khớp với bộ lọc này."
}, },
"oauth": { "oauth": {
"authentication": "Xác thực OAuth", "authentication": "OAuth authentication",
"signIn": "Đăng nhập", "signIn": "Sign in",
"signingIn": "Đang đăng nhập...", "signingIn": "Signing in...",
"signInAgain": "Đăng nhập lại", "signInAgain": "Sign in again",
"signOut": "Đăng xuất", "signOut": "Sign out",
"signedInAs": "Đã đăng nhập bằng {{account}}", "signedInAs": "Signed in as {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.", "signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Cần đăng nhập", "signInRequired": "Sign in required",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.", "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Đã đăng nhập", "signedIn": "Signed in",
"notSignedIn": "Chưa đăng nhập" "notSignedIn": "Not signed in"
} }
}, },
"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": "WebUI 安全", "webuiSafety": "网页端安全",
"capabilities": "能力", "capabilities": "能力",
"apps": "应用", "apps": "应用",
"nativeHost": "原生宿主", "nativeHost": "App",
"hostSafety": "应用安全" "hostSafety": "App 安全"
}, },
"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 阅读器", "jinaReader": "Jina Reader",
"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": "用于新的回复。", "currentModel": "选择 nanobot 接下来回复时使用的模型配置。",
"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 图标。", "brandLogos": "在设置显示第三方服务商和 CLI 的 Logo。",
"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": "用于没有单独项目权限的 Web 聊天。", "webuiDefaultAccess": "用于没有单独选择权限的网页端对话。",
"securityManagedControls": "网页抓取始终保护本机、内网和元数据服务。核心渠道安全仍由 config.json 管理。", "securityManagedControls": "网页抓取始终保护本机、内网和元数据服务。核心渠道安全仍由 config.json 管理。",
"logs": "打开原生引擎日志文件夹。", "logs": "打开App引擎日志文件夹。",
"diagnostics": "导出一份用于支持排查的小型运行报告。", "diagnostics": "导出一份用于支持排查的运行报告。",
"localServiceAccessNative": "允许完全访问权限下的 shell 命令访问这台 Mac 上的服务。", "localServiceAccessNative": "允许完全访问模式下的 shell 命令访问这台 Mac 上的服务。",
"webuiDefaultAccessNative": "用于没有单独项目权限的原生聊天。", "webuiDefaultAccessNative": "用于没有单独选择权限的原生 App 对话。",
"contextWindow": "选择模型配置默认上下文预算。" "contextWindow": "选择这个模型配置默认使用的上下文预算。"
}, },
"timezone": { "timezone": {
"select": "选择时区", "select": "选择时区",
@@ -251,9 +251,9 @@
"serverUrl": "URL", "serverUrl": "URL",
"transport": "传输方式", "transport": "传输方式",
"command": "命令", "command": "命令",
"args": "Args JSON", "args": "参数 JSON",
"headers": "Headers JSON", "headers": "Headers JSON",
"env": "Env JSON", "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": "宿主操作在原生应用中可用。", "hostApiUnavailable": "宿主操作只能在原生 App 内使用。",
"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": "網頁", "browser": "Web",
"runtime": "系統", "runtime": "系統",
"advanced": "安全", "advanced": "Security",
"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": "本機偏好", "localPreferences": "Local preferences",
"presets": "預設", "presets": "Presets",
"imageGeneration": "圖片生成", "imageGeneration": "圖片生成",
"imageDefaults": "預設值", "imageDefaults": "預設值",
"webSearch": "網頁搜尋", "webSearch": "Web search",
"webBehavior": "行為", "webBehavior": "Behavior",
"identity": "身分", "identity": "Identity",
"webuiSafety": "WebUI 安全", "webuiSafety": "網頁端安全",
"capabilities": "能", "capabilities": "能",
"cliApps": "CLI 應用", "cliApps": "CLI 應用",
"mcp": "MCP 服務", "mcp": "MCP 服務",
"apps": "應用", "apps": "應用",
"nativeHost": "原生宿主", "nativeHost": "App",
"hostSafety": "App 安全" "hostSafety": "App 安全"
}, },
"rows": { "rows": {
"theme": "主題", "theme": "主題",
"language": "語言", "language": "語言",
"provider": "供應商", "provider": "提供者",
"model": "模型", "model": "模型",
"restart": "重新啟動 nanobot", "restart": "重新啟動 nanobot",
"configPath": "配置路徑", "configPath": "設定檔路徑",
"activePreset": "目前預設", "activePreset": "Active preset",
"gateway": "閘道", "gateway": "Gateway",
"restartState": "重啟狀態", "restartState": "Restart state",
"pendingChanges": "待套用更改", "pendingChanges": "待處理變更",
"selectedPreset": "已選預設", "selectedPreset": "Selected preset",
"presetModel": "預設模型", "presetModel": "Preset model",
"density": "密度", "density": "Density",
"activityMode": "活動細節", "activityMode": "Activity detail",
"codeWrap": "程式碼換行", "codeWrap": "Code wrapping",
"maxResults": "最大結果數", "maxResults": "Max results",
"timeout": "逾時", "timeout": "Timeout",
"jinaReader": "Jina 閱讀器", "jinaReader": "Jina reader",
"imageGeneration": "圖片生成", "imageGeneration": "圖片生成",
"imageProvider": "圖片供應商", "imageProvider": "圖片服務商",
"imageProviderStatus": "供應商狀態", "imageProviderStatus": "服務商狀態",
"imageProviderBase": "供應商位址", "imageProviderBase": "服務商位址",
"imageModel": "圖片模型", "imageModel": "圖片模型",
"defaultAspectRatio": "預設比例", "defaultAspectRatio": "預設比例",
"defaultImageSize": "預設尺寸", "defaultImageSize": "預設尺寸",
"maxImagesPerTurn": "每輪最圖片數", "maxImagesPerTurn": "每輪最圖片數",
"imageSaveDir": "儲存目錄", "imageSaveDir": "儲存目錄",
"botName": "Bot 名稱", "botName": "Bot name",
"botIcon": "Bot 圖示", "botIcon": "Bot icon",
"timezone": "時區", "timezone": "Timezone",
"workspacePath": "預設工作區", "workspacePath": "預設工作區",
"localServiceAccess": "本機服務", "localServiceAccess": "Local services",
"webuiDefaultAccess": "預設權限", "webuiDefaultAccess": "Default access",
"currentModel": "目前設定", "currentModel": "目前設定",
"brandLogos": "品牌 Logo", "brandLogos": "品牌標誌",
"cliAppsCatalog": "目錄", "cliAppsCatalog": "CLI 應用目錄",
"cliAppsFilter": "篩選", "cliAppsFilter": "CLI 應用篩選",
"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": "命名預設在此為唯讀;請在 config.json 中編輯。", "selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。", "presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "只儲存在此瀏覽器中。", "density": "Stored only in this browser.",
"activityMode": "選擇預設顯示多少 agent 活動細節。", "activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "讓長程式碼行在小螢幕上也易讀。", "codeWrap": "Keep long code lines readable on smaller screens.",
"maxResults": "每次 web_search 呼叫返回的結果數。", "maxResults": "Results returned by each web_search call.",
"timeout": "搜尋供應商請求逾時前的秒數。", "timeout": "Seconds before a search provider request times out.",
"jinaReader": "可用時為 web_fetch 使用 Jina Reader。", "jinaReader": "Use Jina Reader for web_fetch when available.",
"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": "用於沒有單獨專案權限的 Web 聊天。", "webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。",
"securityManagedControls": "網頁抓取始終保護本機、內網和 metadata 服務。核心渠道安全仍由 config.json 管理。", "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "用於新的回覆。", "currentModel": "選擇 nanobot 接下來回覆時使用的模型設定。",
"selectedModelProvider": "由選取的模型決定。", "selectedModelProvider": "由目前模型決定。",
"selectedModelValue": "由選取的模型決定。", "selectedModelValue": "由目前模型決定。",
"brandLogos": "在設定中顯示第三方提供商和 CLI 圖示。", "brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用 CLI 轉接器;不會改動原生應用。", "cliAppsCatalog": "瀏覽 nanobot 可在本機執行的應用 CLI。",
"cliAppsFilter": "按應用、類或能力搜尋。", "cliAppsFilter": "按應用、類或能力搜尋。",
"logs": "開啟原生引擎日誌資料夾。", "logs": "開啟App引擎日誌資料夾。",
"diagnostics": "匯出一份用於支援排查的小型執行報告。", "diagnostics": "匯出一份支援排查用的執行階段報告。",
"localServiceAccessNative": "允許完全訪問權限下的 shell 命令訪問這台 Mac 上的服務。", "localServiceAccessNative": "允許完全存取模式下的 shell 命令存取這台 Mac 上的服務。",
"webuiDefaultAccessNative": "用於沒有單獨專案權限的原生聊天。", "webuiDefaultAccessNative": "用於沒有單獨選擇權限的原生 App 對話。",
"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": "預設權限", "defaultPermission": "Default Permission",
"fullAccess": "完全訪問權限", "fullAccess": "Full Access",
"configured": "已配置", "configured": "Configured",
"notConfigured": "未配置", "notConfigured": "Not configured",
"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": "宿主操作在原生應用中可用。", "hostApiUnavailable": "宿主操作只能在原生 App 內使用。",
"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": "網頁搜尋" "webSearch": "Web Search"
}, },
"webSearch": { "webSearch": {
"provider": "搜尋 provider", "provider": "搜尋 provider",
@@ -270,44 +270,44 @@
} }
}, },
"overview": { "overview": {
"model": "目前模型", "model": "Current model",
"providers": "供應商", "providers": "Providers",
"configuredCount": "已配置 {{count}} ", "configuredCount": "{{count}} configured",
"totalProviders": "{{count}} 個可用", "totalProviders": "{{count}} available",
"webSearch": "網頁搜尋", "webSearch": "Web search",
"imageGeneration": "圖片生成", "imageGeneration": "圖片生成",
"workspace": "工作區" "workspace": "Workspace"
}, },
"providers": { "providers": {
"searchPlaceholder": "搜尋供應商", "searchPlaceholder": "Search providers",
"noMatches": "沒有符合的供應商。", "noMatches": "No providers match this search.",
"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": "未登入"
} }
+10 -30
View File
@@ -55,15 +55,8 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
const flushTurn = () => { const flushTurn = () => {
if (turnMessages.length === 0) return; if (turnMessages.length === 0) return;
const visibleMessages = visibleMessagesForTurn(turnMessages); const activityMessages: UIMessage[] = [];
let visibleIndex = 0; const visibleMessages: UIMessage[] = [];
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)) {
@@ -73,18 +66,19 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
if (assistantHasInlineReasoning(message)) { if (assistantHasInlineReasoning(message)) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message)); activityMessages.push(reasoningOnlyMessageFromAnswer(message));
flushActivityMessages(); visibleMessages.push(stripInlineReasoning(message));
units.push({ type: "message", message: stripInlineReasoning(message) });
visibleIndex += 1;
continue; continue;
} }
flushActivityMessages(); visibleMessages.push(message);
}
pushActivityUnits(units, activityMessages, visibleMessages);
for (const message of visibleMessages) {
units.push({ type: "message", message }); units.push({ type: "message", message });
visibleIndex += 1;
} }
flushActivityMessages();
turnMessages = []; turnMessages = [];
}; };
@@ -102,19 +96,9 @@ 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;
@@ -126,18 +110,14 @@ 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";
const segmentId = message.activitySegmentId; if (runBucket && bucket !== runBucket) {
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);
} }
+4 -15
View File
@@ -9,20 +9,11 @@ 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``.
* *
@@ -138,7 +129,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 socketFactory: (url: string) => WebSocket; private readonly 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;
@@ -149,7 +140,8 @@ 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 = options.socketFactory ?? createDefaultSocket; this.socketFactory =
options.socketFactory ?? ((url) => new WebSocket(url));
this.currentUrl = options.url; this.currentUrl = options.url;
} }
@@ -162,11 +154,8 @@ 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, socketFactory?: (url: string) => WebSocket): void { updateUrl(url: string): void {
this.currentUrl = url; this.currentUrl = url;
if (socketFactory) {
this.socketFactory = socketFactory;
}
} }
onStatus(handler: StatusHandler): Unsubscribe { onStatus(handler: StatusHandler): Unsubscribe {
+8 -13
View File
@@ -51,11 +51,6 @@ 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;
@@ -127,7 +122,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 = HOST_WS_CONNECTING; readyState: number = WebSocket.CONNECTING;
readonly url: string; readonly url: string;
private id: string | null = null; private id: string | null = null;
@@ -145,7 +140,7 @@ class HostWebSocket {
this.id = id; this.id = id;
}, },
() => { () => {
this.readyState = HOST_WS_CLOSED; this.readyState = WebSocket.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();
@@ -154,14 +149,14 @@ class HostWebSocket {
} }
close(): void { close(): void {
if (this.readyState === HOST_WS_CLOSING || this.readyState === HOST_WS_CLOSED) { if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) {
return; return;
} }
this.readyState = HOST_WS_CLOSING; this.readyState = WebSocket.CLOSING;
if (this.id) { if (this.id) {
void this.api.closeSocket(this.id); void this.api.closeSocket(this.id);
} else { } else {
this.readyState = HOST_WS_CLOSED; this.readyState = WebSocket.CLOSED;
this.unsubscribe(); this.unsubscribe();
} }
} }
@@ -170,7 +165,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 === HOST_WS_OPEN && this.id) { if (this.readyState === WebSocket.OPEN && this.id) {
void this.api.sendSocket(this.id, data); void this.api.sendSocket(this.id, data);
return; return;
} }
@@ -180,7 +175,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 = HOST_WS_OPEN; this.readyState = WebSocket.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();
@@ -199,7 +194,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 = HOST_WS_CLOSED; this.readyState = WebSocket.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,39 +869,6 @@ 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.getByRole("button", { name: "Current configuration" })); fireEvent.pointerDown(screen.getAllByRole("button", { name: /openai\/gpt-4o/ })[0]);
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" })); fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
const modelDialog = await screen.findByRole("dialog", { name: "New model configuration" }); const modelDialog = screen.getByRole("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,16 +12,13 @@ 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>
@@ -51,23 +48,6 @@ 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,60 +34,6 @@ 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);
} }
@@ -244,20 +190,6 @@ 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,18 +16,6 @@ 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}>
@@ -68,47 +56,6 @@ 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,7 +65,6 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
Reflect.deleteProperty(window, "nanobotHost");
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -90,61 +89,6 @@ 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,40 +245,6 @@ 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,7 +386,6 @@ 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();
}); });
+20 -60
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("splits ordinary tool activity when segment ids changed", () => { it("does not split ordinary tool activity just because segment ids changed", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -142,58 +142,15 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2); expect(units).toHaveLength(1);
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[] = [
{ {
@@ -237,8 +194,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />); render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument(); expect(screen.getByLabelText(/editing 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", () => {
@@ -297,7 +253,7 @@ describe("ThreadMessages", () => {
expect(screen.getByText("final answer")).toBeInTheDocument(); expect(screen.getByText("final answer")).toBeInTheDocument();
}); });
it("keeps late activity after the live assistant answer while streaming", () => { it("keeps late activity above the live assistant answer while streaming", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "t0", id: "t0",
@@ -328,8 +284,11 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3); expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0",
"t1",
]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { message: {
@@ -337,16 +296,15 @@ 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");
const liveActivity = screen.getByRole("button", { name: /working/i }); expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}); });
it("keeps late activity after a completed assistant answer", () => { it("keeps late activity above a completed assistant answer", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -376,8 +334,11 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3); expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { message: {
@@ -385,14 +346,13 @@ 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.");
const laterActivity = screen.getAllByText(/thought/i).at(-1); expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(laterActivity).toBeTruthy(); expect(screen.getAllByText(/thought/i)).toHaveLength(1);
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", () => {
+11 -101
View File
@@ -658,7 +658,7 @@ describe("useNanobotStream", () => {
}]); }]);
}); });
it("keeps interrupted pre-tool text as assistant output before activity", async () => { it("keeps interrupted pre-tool text inside activity before the final answer", 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,7 +692,9 @@ 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: "I created the files.", content: "",
reasoning: "I created the files.",
isStreaming: false,
}); });
expect(result.current.messages[1]).toMatchObject({ expect(result.current.messages[1]).toMatchObject({
role: "tool", role: "tool",
@@ -737,7 +739,9 @@ 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: "I will inspect the project first.", content: "",
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",
@@ -751,51 +755,6 @@ 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), {
@@ -1008,7 +967,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false); expect(result.current.messages[0].reasoningStreaming).toBe(false);
}); });
it("starts a new Thought block when reasoning arrives after visible output", () => { it("attaches post-hoc reasoning to the same assistant turn above the answer", () => {
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),
@@ -1029,61 +988,12 @@ 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(2); expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe("hi~"); expect(result.current.messages[0].content).toBe("hi~");
expect(result.current.messages[0].reasoning).toBeUndefined(); expect(result.current.messages[0].reasoning).toBe(
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[1].reasoningStreaming).toBe(false); expect(result.current.messages[0].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 () => {