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