mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
362f9629e2 |
@@ -5,7 +5,6 @@ __pycache__
|
||||
*.egg-info
|
||||
dist/
|
||||
build/
|
||||
nanobot/web/dist/
|
||||
.git
|
||||
.env
|
||||
.assets
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
<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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 usará 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": {
|
||||
|
||||
@@ -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 d’images",
|
||||
"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 l’app"
|
||||
"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 d’activité",
|
||||
"codeWrap": "Retour à la ligne du code",
|
||||
"maxResults": "Résultats max.",
|
||||
"timeout": "Délai d’attente",
|
||||
"jinaReader": "Lecteur Jina",
|
||||
"imageGeneration": "Génération d’images",
|
||||
"imageProvider": "Fournisseur d’images",
|
||||
"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 d’image",
|
||||
"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 d’enregistrement",
|
||||
"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 l’apparence 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 d’activité 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 l’expiration d’une requête de recherche.",
|
||||
"jinaReader": "Utilise Jina Reader pour web_fetch lorsque disponible.",
|
||||
"imageGeneration": "Expose generate_image dans les chats lorsqu’un fournisseur d’image configuré est disponible.",
|
||||
"imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.",
|
||||
"imageProviderStatus": "La génération d’images 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 lorsqu’un fournisseur d’images configuré est disponible.",
|
||||
"imageProvider": "Choisissez le fournisseur du registre utilisé par generate_image.",
|
||||
"imageProviderStatus": "La génération d’images réutilise les identifiants de Fournisseurs.",
|
||||
"imageModel": "Nom du modèle envoyé au fournisseur d’images 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é là 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 à l’heure.",
|
||||
"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 d’apps 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 d’exé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 d’image enregistrés. Redémarrez quand vous êtes prêt.",
|
||||
"hostRestartAfterSaving": "En enregistrant, nanobot redémarrera son moteur.",
|
||||
"hostRestartPending": "Enregistré. Le moteur redémarrera lorsqu’il sera prêt.",
|
||||
"hostApiUnavailable": "Les actions de l’hôte ne sont disponibles que dans l’app native.",
|
||||
"logsOpened": "Dossier des journaux ouvert.",
|
||||
"logsOpenFailed": "Impossible d’ouvrir le dossier des journaux.",
|
||||
"diagnosticsExported": "Diagnostic exporté vers {{path}}.",
|
||||
"diagnosticsExportFailed": "Impossible d’exporter 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 d’images 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 d’images",
|
||||
"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 d’activer la génération d’images."
|
||||
},
|
||||
"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 l’ID 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 d’apps et services MCP utilisables par nanobot depuis le chat.",
|
||||
"description": "Ajoutez des CLI d’apps 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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 mà 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": {
|
||||
|
||||
@@ -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": "未登录"
|
||||
}
|
||||
|
||||
@@ -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": "未登入"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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></MarkdownTextRenderer>);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user