Merge branch 'main' into nightly

This commit is contained in:
chengyongru 2026-05-18 18:05:29 +08:00
commit e02615c93d
91 changed files with 7340 additions and 1076 deletions

View File

@ -49,7 +49,7 @@ body:
attributes:
label: nanobot Version
description: Run `nanobot --version` or `pip show nanobot-ai`
placeholder: e.g., 0.1.5
placeholder: e.g., 0.2.0
validations:
required: true

View File

@ -14,8 +14,9 @@ RUN apt-get update && \
WORKDIR /app
# Install Python dependencies first (cached layer)
COPY pyproject.toml README.md LICENSE ./
# Install Python dependencies first (cached layer). Hatch reads the custom build
# hook from hatch_build.py even for this metadata-only install.
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
uv pip install --system --no-cache . && \
rm -rf nanobot bridge
@ -23,6 +24,7 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
# Copy the full source and install
COPY nanobot/ nanobot/
COPY bridge/ bridge/
COPY webui/ webui/
RUN uv pip install --system --no-cache .
# Build the WhatsApp bridge
@ -43,8 +45,8 @@ RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/ent
USER nanobot
ENV HOME=/home/nanobot
# Gateway default port
EXPOSE 18790
# Gateway health endpoint and optional WebUI/WebSocket channel ports
EXPOSE 18790 8765
ENTRYPOINT ["entrypoint.sh"]
CMD ["status"]

View File

@ -23,6 +23,7 @@
## 📢 News
- **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.
- **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads.
@ -214,10 +215,9 @@ nanobot agent
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
## 🧪 WebUI (Development)
## 🌐 WebUI
> [!NOTE]
> The WebUI development workflow currently requires a source checkout and is not yet shipped together with the official packaged release. See [WebUI Document](./webui/README.md) for full WebUI development docs and build steps.
The WebUI ships **inside the published wheel** — no extra build step. Just enable the WebSocket channel and open it in your browser.
<p align="center">
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
@ -235,13 +235,12 @@ nanobot agent
nanobot gateway
```
**3. Start the webui dev server**
**3. Open the WebUI**
```bash
cd webui
bun install
bun run dev
```
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs → LAN access](./webui/README.md#access-from-another-device-lan).
> [!TIP]
> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the Vite dev server (HMR) workflow.
## 🏗️ Architecture

View File

@ -20,6 +20,7 @@ services:
restart: unless-stopped
ports:
- 18790:18790
- 8765:8765
deploy:
resources:
limits:

View File

@ -15,6 +15,7 @@ Start here for setup, everyday usage, and deployment.
| Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot |
| Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings |
| Image generation | [`image-generation.md`](./image-generation.md) | Configure image providers, WebUI image mode, and generated artifacts |
| WebUI | [`../webui/README.md`](../webui/README.md) | Open the bundled browser UI; LAN access; Vite dev server for contributors |
| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces |
| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |

View File

@ -26,7 +26,52 @@ Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}`
}
```
For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder.
If a referenced variable is unset, nanobot fails fast at startup with `ValueError: Environment variable 'NAME' referenced in config is not set`.
### More examples
**MCP servers** — both stdio `env` and HTTP `headers`:
```json
{
"tools": {
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" }
},
"remote": {
"url": "https://example.com/mcp/",
"headers": { "Authorization": "Bearer ${REMOTE_MCP_TOKEN}" }
}
}
}
}
```
**Web search providers:**
```json
{
"tools": {
"web": {
"search": {
"provider": "brave",
"apiKey": "${BRAVE_API_KEY}"
}
}
}
}
```
### Loading variables at startup
Pick whatever fits your deployment — nanobot only reads `os.environ` at startup, so any mechanism that populates the process environment works.
**systemd** — use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
```ini
# /etc/systemd/system/nanobot.service (excerpt)
@ -42,6 +87,35 @@ TELEGRAM_TOKEN=your-token-here
IMAP_PASSWORD=your-password-here
```
**Docker** — pass an env file to the locally built image (one `KEY=VALUE` per line), or use `-e KEY=value`:
```bash
docker run --rm --env-file=./nanobot.env \
-v ~/.nanobot:/home/nanobot/.nanobot \
nanobot agent -m "Hello"
```
**direnv** — drop a `.envrc` in your working directory and run `direnv allow`:
```bash
# .envrc (auto-loaded by direnv)
export TELEGRAM_TOKEN=your-token-here
export ANTHROPIC_API_KEY=...
```
**Secret managers (1Password, Bitwarden, pass)** — wrap the process so secrets only exist as env vars for the lifetime of the run, never on disk:
```bash
# 1Password — references in .env.tpl look like `op://Vault/Item/field`
op run --env-file=.env.tpl -- nanobot agent
# pass (passwordstore.org)
ANTHROPIC_API_KEY="$(pass show api/anthropic)" nanobot agent
# Bitwarden
ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
```
## Providers
> [!TIP]
@ -917,7 +991,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "brave",
"apiKey": "BSA..."
"apiKey": "${BRAVE_API_KEY}"
}
}
}
@ -931,7 +1005,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "tavily",
"apiKey": "tvly-..."
"apiKey": "${TAVILY_API_KEY}"
}
}
}
@ -945,7 +1019,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "jina",
"apiKey": "jina_..."
"apiKey": "${JINA_API_KEY}"
}
}
}
@ -959,7 +1033,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "kagi",
"apiKey": "your-kagi-api-key"
"apiKey": "${KAGI_API_KEY}"
}
}
}
@ -973,7 +1047,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "olostep",
"apiKey": "YOUR_OLOSTEP_API_KEY"
"apiKey": "${OLOSTEP_API_KEY}"
}
}
}
@ -1136,6 +1210,8 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
| Option | Default | Description |
|--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |

View File

@ -10,6 +10,18 @@
> [!IMPORTANT]
> 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:
>
> ```json
> {
> "gateway": { "host": "0.0.0.0" },
> "channels": { "websocket": { "host": "0.0.0.0" } }
> }
> ```
>
> 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
```bash
@ -36,8 +48,20 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
# Edit config on host to add API keys
vim ~/.nanobot/config.json
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat)
docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
# Mirrors the security caps and port mappings declared in docker-compose.yml:
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
# endpoint on 18790.
docker run \
--cap-drop ALL --cap-add SYS_ADMIN \
--security-opt apparmor=unconfined \
--security-opt seccomp=unconfined \
-v ~/.nanobot:/home/nanobot/.nanobot \
-p 18790:18790 -p 8765:8765 \
nanobot gateway
# Or run a single command
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"

101
hatch_build.py Normal file
View File

@ -0,0 +1,101 @@
"""Hatch build hook that bundles the webui (Vite) into nanobot/web/dist.
Triggered automatically by `python -m build` (and any other hatch-driven build)
so published wheels and sdists ship a fresh webui without requiring developers
to remember `cd webui && bun run build` beforehand.
Behaviour:
- Skips for editable installs (`pip install -e .`). Editable mode is for Python
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
do not need a packaged `dist/`.
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
already contains a prebuilt `nanobot/web/dist/`).
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
- Skips when `nanobot/web/dist/index.html` already exists, unless
`NANOBOT_FORCE_WEBUI_BUILD=1` is set.
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
performs `install` followed by `run build`.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class WebUIBuildHook(BuildHookInterface):
PLUGIN_NAME = "webui-build"
def initialize(self, version: str, build_data: dict) -> None: # noqa: D401
root = Path(self.root)
webui_dir = root / "webui"
package_json = webui_dir / "package.json"
dist_dir = root / "nanobot" / "web" / "dist"
index_html = dist_dir / "index.html"
# `pip install -e .` builds an editable wheel; skip the (slow) webui
# bundle since editable installs target Python development and webui
# work uses `bun run dev` instead.
if self.target_name == "wheel" and version == "editable":
self.app.display_info(
"[webui-build] skipped for editable install "
"(use `cd webui && bun run build` to bundle webui manually)"
)
return
if os.environ.get("NANOBOT_SKIP_WEBUI_BUILD") == "1":
self.app.display_info("[webui-build] skipped via NANOBOT_SKIP_WEBUI_BUILD=1")
return
if not package_json.is_file():
self.app.display_info(
"[webui-build] no webui/ source tree, assuming prebuilt nanobot/web/dist/"
)
return
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
if index_html.is_file() and not force:
self.app.display_info(
f"[webui-build] reusing existing build at {dist_dir} "
"(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
)
return
runner = self._pick_runner()
if runner is None:
raise RuntimeError(
"[webui-build] neither `bun` nor `npm` is available on PATH; "
"install one or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
)
self.app.display_info(f"[webui-build] using {runner} to build webui")
self._run([runner, "install"], cwd=webui_dir)
self._run([runner, "run", "build"], cwd=webui_dir)
if not index_html.is_file():
raise RuntimeError(
f"[webui-build] build finished but {index_html} is missing; "
"check webui/vite.config.ts outDir."
)
self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
@staticmethod
def _pick_runner() -> str | None:
for candidate in ("bun", "npm"):
if shutil.which(candidate):
return candidate
return None
def _run(self, cmd: list[str], *, cwd: Path) -> None:
self.app.display_info(f"[webui-build] $ {' '.join(cmd)} (cwd={cwd})")
try:
subprocess.run(cmd, cwd=cwd, check=True)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"[webui-build] command failed ({exc.returncode}): {' '.join(cmd)}"
) from exc

View File

@ -21,7 +21,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.1.5.post3"
return _read_pyproject_version() or "0.2.0"
__version__ = _resolve_version()

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Collection
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Coroutine
from typing import TYPE_CHECKING, Callable, Coroutine
from loguru import logger
@ -37,27 +37,6 @@ class AutoCompact:
def _format_summary(text: str, last_active: datetime) -> str:
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
def _split_unconsolidated(
self, session: Session,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Split live session tail into archiveable prefix and retained recent suffix."""
tail = list(session.messages[session.last_consolidated:])
if not tail:
return [], []
probe = Session(
key=session.key,
messages=tail.copy(),
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
probe.retain_recent_legal_suffix(self._RECENT_SUFFIX_MESSAGES)
kept = probe.messages
cut = len(tail) - len(kept)
return tail[:cut], kept
def check_expired(self, schedule_background: Callable[[Coroutine], None],
active_session_keys: Collection[str] = ()) -> None:
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
@ -74,33 +53,17 @@ class AutoCompact:
async def _archive(self, key: str) -> None:
try:
self.sessions.invalidate(key)
session = self.sessions.get_or_create(key)
archive_msgs, kept_msgs = self._split_unconsolidated(session)
if not archive_msgs and not kept_msgs:
session.updated_at = datetime.now()
self.sessions.save(session)
return
last_active = session.updated_at
summary = ""
if archive_msgs:
summary = await self.consolidator.archive(archive_msgs) or ""
summary = await self.consolidator.compact_idle_session(
key, self._RECENT_SUFFIX_MESSAGES,
)
if summary and summary != "(nothing)":
self._summaries[key] = (summary, last_active)
session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()}
session.messages = kept_msgs
session.last_consolidated = 0
session.updated_at = datetime.now()
self.sessions.save(session)
if archive_msgs:
logger.info(
"Auto-compact: archived {} (archived={}, kept={}, summary={})",
key,
len(archive_msgs),
len(kept_msgs),
bool(summary),
)
session = self.sessions.get_or_create(key)
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
self._summaries[key] = (
meta["text"],
datetime.fromisoformat(meta["last_active"]),
)
except Exception:
logger.exception("Auto-compact: failed for {}", key)
finally:

View File

@ -33,7 +33,6 @@ from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.goal_state import (
goal_state_ws_blob,
runner_wall_llm_timeout_s,
)
from nanobot.session.manager import Session, SessionManager
@ -42,10 +41,14 @@ from nanobot.utils.document import extract_documents
from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
from nanobot.utils.webui_turn_helpers import publish_turn_run_status
from nanobot.utils.webui_turn_helpers import (
WebuiTurnCoordinator,
build_bus_progress_callback,
mark_webui_session,
)
if TYPE_CHECKING:
from nanobot.config.schema import (
@ -136,6 +139,11 @@ class AgentLoop:
def tool_names(self) -> list[str]:
return self.tools.tool_names
def llm_runtime(self) -> LLMRuntime:
"""Return the current provider/model pair owned by this loop."""
self._refresh_provider_snapshot()
return LLMRuntime(self.provider, self.model)
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_PENDING_USER_TURN_KEY = "pending_user_turn"
@ -237,6 +245,11 @@ class AgentLoop:
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace)
self._webui_turns = WebuiTurnCoordinator(
bus=self.bus,
sessions=self.sessions,
schedule_background=lambda coro: self._schedule_background(coro),
)
self.tools = ToolRegistry()
# One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars.
@ -524,34 +537,7 @@ class AgentLoop:
self, msg: InboundMessage
) -> Callable[..., Awaitable[None]]:
"""Build a progress callback that publishes to the message bus."""
async def _bus_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
if reasoning:
meta["_reasoning_delta"] = True
if reasoning_end:
meta["_reasoning_end"] = True
if tool_events:
meta["_tool_events"] = tool_events
await self.bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
)
)
return _bus_progress
return build_bus_progress_callback(self.bus, msg)
async def _build_retry_wait_callback(
self, msg: InboundMessage
@ -938,38 +924,12 @@ class AgentLoop:
content="", metadata=msg.metadata or {},
))
if msg.channel == "websocket":
# Signal that the turn is fully complete (all tools executed,
# final text streamed). This lets WS clients know when to
# definitively stop the loading indicator.
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
if turn_lat is not None:
turn_metadata["latency_ms"] = int(turn_lat)
sess_turn = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(sess_turn.metadata)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=turn_metadata,
))
if msg.metadata.get("webui") is True:
async def _generate_title_and_notify() -> None:
generated = await maybe_generate_webui_title_after_turn(
channel=msg.channel,
metadata=msg.metadata,
sessions=self.sessions,
session_key=session_key,
provider=self.provider,
model=self.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={**msg.metadata, "_session_updated": True},
))
self._schedule_background(_generate_title_and_notify())
await self._webui_turns.handle_turn_end(
msg,
session_key=session_key,
latency_ms=turn_lat,
)
except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so
@ -1021,8 +981,9 @@ class AgentLoop:
"Re-published {} leftover message(s) to bus for session {}",
leftover, session_key,
)
await publish_turn_run_status(self.bus, msg, "idle")
await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
self._webui_turns.discard(session_key)
async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections."""
@ -1338,6 +1299,11 @@ class AgentLoop:
"include_timestamps": True,
}
ctx.history = ctx.session.get_history(**_hist_kwargs)
self._webui_turns.capture_title_context(
ctx.session_key,
ctx.msg,
self.llm_runtime(),
)
ctx.initial_messages = self._build_initial_messages(
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
@ -1354,7 +1320,7 @@ class AgentLoop:
return "ok"
async def _state_run(self, ctx: TurnContext) -> str:
await publish_turn_run_status(self.bus, ctx.msg, "running")
await self._webui_turns.publish_run_status(ctx.msg, "running")
result = await self._run_agent_loop(
ctx.initial_messages,
on_progress=ctx.on_progress,

View File

@ -678,11 +678,18 @@ class Consolidator:
The budget reserves space for completion tokens and a safety buffer
so the LLM request never exceeds the context window.
"""
if not session.messages or self.context_window_tokens <= 0:
if self.context_window_tokens <= 0:
return
lock = self.get_lock(session.key)
async with lock:
# Refresh session reference: AutoCompact may have replaced it.
fresh = self.sessions.get_or_create(session.key)
if fresh is not session:
session = fresh
if not session.messages:
return
budget = self._input_token_budget
target = int(budget * self.consolidation_ratio)
last_summary = await self._consolidate_replay_overflow(
@ -769,6 +776,74 @@ class Consolidator:
# the summary injection strategy with AutoCompact._archive().
self._persist_last_summary(session, last_summary)
async def compact_idle_session(
self,
session_key: str,
max_suffix: int = 8,
) -> str | None:
"""Hard-truncate an idle session under the consolidation lock.
Used by AutoCompact so all session mutation goes through a single
lock-protected path. Returns the summary text on success, ``None``
if the LLM failed (raw_archive fallback), or ``""`` if there was
nothing to archive.
"""
lock = self.get_lock(session_key)
async with lock:
self.sessions.invalidate(session_key)
session = self.sessions.get_or_create(session_key)
tail = list(session.messages[session.last_consolidated:])
if not tail:
session.updated_at = datetime.now()
self.sessions.save(session)
return ""
probe = Session(
key=session.key,
messages=tail.copy(),
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
probe.retain_recent_legal_suffix(max_suffix)
kept = probe.messages
cut = len(tail) - len(kept)
archive_msgs = tail[:cut]
if not archive_msgs and not kept:
session.updated_at = datetime.now()
self.sessions.save(session)
return ""
last_active = session.updated_at
summary: str | None = ""
if archive_msgs:
summary = await self.archive(archive_msgs)
if summary and summary != "(nothing)":
session.metadata["_last_summary"] = {
"text": summary,
"last_active": last_active.isoformat(),
}
session.messages = kept
session.last_consolidated = 0
session.updated_at = datetime.now()
self.sessions.save(session)
if archive_msgs:
logger.info(
"Idle-session compact for {}: archived={}, kept={}, summary={}",
session_key,
len(archive_msgs),
len(kept),
bool(summary),
)
return summary
# ---------------------------------------------------------------------------
# Dream — heavyweight cron-scheduled memory consolidation

View File

@ -15,6 +15,12 @@ from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.file_edit_events import (
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_tracker,
)
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
build_assistant_message,
@ -26,6 +32,10 @@ from nanobot.utils.helpers import (
strip_think,
truncate_text,
)
from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE,
@ -813,6 +823,30 @@ class AgentRunner:
return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None
)
emit_file_edit_events = (
spec.progress_callback is not None
and on_progress_accepts_file_edit_events(spec.progress_callback)
)
progress_callback = spec.progress_callback if emit_file_edit_events else None
file_edit_tracker = (
prepare_file_edit_tracker(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=spec.workspace,
params=params if isinstance(params, dict) else None,
)
if progress_callback is not None
else None
)
if file_edit_tracker is not None and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_start_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
)],
)
try:
if tool is not None:
result = await tool.execute(**params)
@ -821,6 +855,11 @@ class AgentRunner:
except asyncio.CancelledError:
raise
except BaseException as exc:
if file_edit_tracker is not None and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_error_event(file_edit_tracker, str(exc))],
)
event = {
"name": tool_call.name,
"status": "error",
@ -842,6 +881,11 @@ class AgentRunner:
return payload, event, None
if isinstance(result, str) and result.startswith("Error"):
if file_edit_tracker is not None and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_error_event(file_edit_tracker, result)],
)
event = {
"name": tool_call.name,
"status": "error",
@ -860,6 +904,12 @@ class AgentRunner:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
if file_edit_tracker is not None and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_end_event(file_edit_tracker)],
)
detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
if not detail:

View File

@ -230,6 +230,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
return f"{secret[:4]}••••{secret[-4:]}"
def _provider_requires_api_key(spec: Any) -> bool:
if spec.backend == "azure_openai":
return True
if spec.is_local or spec.is_direct:
return False
return True
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if _provider_requires_api_key(spec):
return bool(provider_config.api_key)
return bool(
provider_config.api_key
or provider_config.api_base
or getattr(provider_config, "region", None)
or getattr(provider_config, "profile", None)
)
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
@ -786,13 +805,14 @@ class WebSocketChannel(BaseChannel):
providers = []
for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth or spec.is_local:
if provider_config is None or spec.is_oauth:
continue
providers.append(
{
"name": spec.name,
"label": spec.label,
"configured": bool(provider_config.api_key),
"configured": _provider_configured_for_settings(spec, provider_config),
"api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
@ -862,7 +882,12 @@ class WebSocketChannel(BaseChannel):
if find_by_name(provider) is None:
return _http_error(400, "unknown provider")
provider_config = getattr(config.providers, provider, None)
if provider_config is None or not provider_config.api_key:
spec = find_by_name(provider)
if (
provider_config is None
or spec is None
or not _provider_configured_for_settings(spec, provider_config)
):
return _http_error(400, "provider is not configured")
if defaults.provider != provider:
defaults.provider = provider
@ -885,7 +910,7 @@ class WebSocketChannel(BaseChannel):
if not provider_name:
return _http_error(400, "provider is required")
spec = find_by_name(provider_name)
if spec is None or spec.is_oauth or spec.is_local:
if spec is None or spec.is_oauth:
return _http_error(400, "unknown provider")
config = load_config()
@ -1581,6 +1606,7 @@ class WebSocketChannel(BaseChannel):
if not conns:
if (
msg.metadata.get("_progress")
or msg.metadata.get("_file_edit_events")
or msg.metadata.get("_turn_end")
or msg.metadata.get("_session_updated")
or msg.metadata.get("_goal_status")
@ -1613,7 +1639,22 @@ class WebSocketChannel(BaseChannel):
await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob)
return
if msg.metadata.get("_session_updated"):
await self.send_session_updated(msg.chat_id)
scope = msg.metadata.get("_session_update_scope")
await self.send_session_updated(
msg.chat_id,
scope=scope if isinstance(scope, str) else None,
)
return
if msg.metadata.get("_file_edit_events"):
payload: dict[str, Any] = {
"event": "file_edit",
"chat_id": msg.chat_id,
"edits": msg.metadata["_file_edit_events"],
}
self._try_append_webui_transcript(msg.chat_id, payload)
raw = json.dumps(payload, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" ")
return
text = msg.content
payload: dict[str, Any] = {
@ -1780,12 +1821,14 @@ class WebSocketChannel(BaseChannel):
for connection in conns:
await self._safe_send_to(connection, raw, label=" goal_status ")
async def send_session_updated(self, chat_id: str) -> None:
async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None:
"""Notify clients that session metadata changed outside the main turn."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
if scope:
body["scope"] = scope
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" session_updated ")

View File

@ -91,6 +91,8 @@ app = typer.Typer(
console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "", "", "")
_REASONING_FLUSH_CHARS = 60
# ---------------------------------------------------------------------------
# CLI input: prompt_toolkit for editing, paste, history, and display
@ -242,6 +244,35 @@ def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None, render
target.print(f" [dim]↳ {text}[/dim]")
class _ReasoningBuffer:
def __init__(self) -> None:
self._text = ""
def add(self, text: str) -> str | None:
if not text:
return None
self._text += text
if self._should_flush(text):
return self.flush()
return None
def flush(self) -> str | None:
text = self._text.strip()
self._text = ""
return text or None
def clear(self) -> None:
self._text = ""
def _should_flush(self, text: str) -> bool:
stripped = text.rstrip()
return (
"\n" in text
or stripped.endswith(_REASONING_SENTENCE_ENDINGS)
or len(self._text) >= _REASONING_FLUSH_CHARS
)
def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
"""Print reasoning/thinking content in a distinct style."""
if not text.strip():
@ -254,6 +285,16 @@ def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer:
target.print(f"[dim italic]✻ {text}[/dim italic]")
def _flush_cli_reasoning(
reasoning_buffer: _ReasoningBuffer,
thinking: ThinkingSpinner | None,
renderer: StreamRenderer | None = None,
) -> None:
text = reasoning_buffer.flush()
if text:
_print_cli_reasoning(text, thinking, renderer)
async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
"""Print an interactive progress line, pausing the spinner if needed."""
if not text.strip():
@ -272,6 +313,7 @@ async def _maybe_print_interactive_progress(
thinking: ThinkingSpinner | None,
channels_config: Any,
renderer: StreamRenderer | None = None,
reasoning_buffer: _ReasoningBuffer | None = None,
) -> bool:
metadata = msg.metadata or {}
if metadata.get("_retry_wait"):
@ -281,12 +323,24 @@ async def _maybe_print_interactive_progress(
if not metadata.get("_progress"):
return False
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
if metadata.get("_reasoning_end"):
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
else:
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
return True
is_tool_hint = metadata.get("_tool_hint", False)
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
if is_reasoning:
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
return True
_print_cli_reasoning(msg.content, thinking, renderer)
text = reasoning_buffer.add(msg.content)
if text:
_print_cli_reasoning(text, thinking, renderer)
return True
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
return True
@ -918,8 +972,7 @@ def _run_gateway(
hb_cfg = config.gateway.heartbeat
heartbeat = HeartbeatService(
workspace=config.workspace_path,
provider=agent.provider,
model=agent.model,
llm_runtime=agent.llm_runtime,
on_execute=on_heartbeat_execute,
on_notify=on_heartbeat_notify,
interval_s=hb_cfg.interval_s,
@ -1111,12 +1164,25 @@ def agent(
_thinking: ThinkingSpinner | None = None
def _make_progress(renderer: StreamRenderer | None = None):
reasoning_buffer = _ReasoningBuffer()
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
ch = agent_loop.channels_config
if _kwargs.get("reasoning_end"):
if ch and not ch.show_reasoning:
reasoning_buffer.clear()
else:
_flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
return
if reasoning:
if ch and not ch.show_reasoning:
reasoning_buffer.clear()
return
_print_cli_reasoning(content, _thinking, renderer)
text = reasoning_buffer.add(content)
if text:
_print_cli_reasoning(text, _thinking, renderer)
return
if ch and tool_hint and not ch.send_tool_hints:
return
@ -1187,6 +1253,7 @@ def agent(
turn_done.set()
turn_response: list[tuple[str, dict]] = []
renderer: StreamRenderer | None = None
reasoning_buffer = _ReasoningBuffer()
async def _consume_outbound():
while True:
@ -1212,6 +1279,7 @@ def agent(
renderer,
agent_loop.channels_config,
renderer,
reasoning_buffer,
):
continue
@ -1252,6 +1320,7 @@ def agent(
turn_done.clear()
turn_response.clear()
reasoning_buffer.clear()
renderer = StreamRenderer(
render_markdown=markdown,
bot_name=config.agents.defaults.bot_name,

View File

@ -22,7 +22,7 @@ from nanobot.cli.models import (
get_model_suggestions,
)
from nanobot.config.loader import get_config_path, load_config
from nanobot.config.schema import Config
from nanobot.config.schema import Config, ModelPresetConfig
console = Console()
@ -49,6 +49,10 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
_BACK_PRESSED = object() # Sentinel value for back navigation
# Cache of model-preset names populated at runtime so that field handlers can
# offer existing presets as choices (e.g. AgentDefaults.model_preset).
_MODEL_PRESET_CACHE: set[str] = set()
def _get_questionary():
"""Return questionary or raise a clear error when wizard deps are unavailable."""
@ -588,9 +592,102 @@ def _handle_context_window_field(
setattr(working_model, field_name, new_value)
def _handle_model_preset_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'model_preset' field with a list of existing presets."""
preset_names = sorted(_MODEL_PRESET_CACHE)
choices = ["(clear/unset)"] + preset_names
default_choice = str(current_value) if current_value else "(clear/unset)"
new_value = _select_with_back(field_display, choices, default=default_choice)
if new_value is _BACK_PRESSED:
return
if new_value == "(clear/unset)":
setattr(working_model, field_name, None)
elif new_value is not None:
setattr(working_model, field_name, new_value)
def _handle_provider_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'provider' field with a list of registered providers."""
provider_names = sorted(_get_provider_names().keys())
choices = ["auto"] + provider_names
default_choice = str(current_value) if current_value else "auto"
new_value = _select_with_back(field_display, choices, default=default_choice)
if new_value is _BACK_PRESSED:
return
if new_value is not None:
setattr(working_model, field_name, new_value)
def _handle_fallback_models_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'fallback_models' field with preset-aware list management."""
from nanobot.config.schema import InlineFallbackConfig
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
preset_names = sorted(_MODEL_PRESET_CACHE)
while True:
console.clear()
console.print(f"[bold]{field_display}[/bold]")
if items:
for idx, item in enumerate(items, 1):
if isinstance(item, InlineFallbackConfig):
console.print(f" {idx}. {item.model} ({item.provider}) [inline]")
else:
console.print(f" {idx}. {item}")
else:
console.print(" [dim](empty)[/dim]")
console.print()
choices = ["[+] Add preset"]
if items:
choices.append("[-] Remove last")
choices.append("[X] Clear all")
choices.append("[Done]")
choices.append("<- Back")
answer = _get_questionary().select(
"Manage fallback models:",
choices=choices,
qmark=">",
).ask()
if answer is None or answer == "<- Back":
return
if answer == "[Done]":
setattr(working_model, field_name, items)
return
if answer == "[+] Add preset":
if not preset_names:
console.print("[yellow]! No presets defined yet.[/yellow]")
_get_questionary().press_any_key_to_continue().ask()
continue
add_choices = [p for p in preset_names if p not in items]
if not add_choices:
console.print("[yellow]! All presets already added.[/yellow]")
_get_questionary().press_any_key_to_continue().ask()
continue
picked = _select_with_back("Select preset:", add_choices)
if picked is _BACK_PRESSED or picked is None:
continue
items.append(picked)
elif answer == "[-] Remove last" and items:
items.pop()
elif answer == "[X] Clear all" and items:
items.clear()
_FIELD_HANDLERS: dict[str, Any] = {
"model": _handle_model_field,
"context_window_tokens": _handle_context_window_field,
"model_preset": _handle_model_preset_field,
"provider": _handle_provider_field,
"fallback_models": _handle_fallback_models_field,
}
@ -757,6 +854,116 @@ def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
# --- Model Preset Configuration ---
def _sync_preset_cache(config: Config) -> None:
"""Synchronise the module-level preset name cache from config."""
_MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
def _configure_model_presets(config: Config) -> None:
"""Configure model presets (CRUD)."""
_sync_preset_cache(config)
def get_preset_choices() -> list[str]:
choices: list[str] = []
for name, preset in config.model_presets.items():
choices.append(f"{name} ({preset.model})")
choices.append("[+] Add new preset")
choices.append("<- Back")
return choices
last_preset_name: str | None = None
while True:
try:
console.clear()
_show_section_header(
"Model Presets",
"Create, edit or delete named model presets for quick switching",
)
choices = get_preset_choices()
default_choice = None
if last_preset_name:
for c in choices:
if c.startswith(last_preset_name + " ("):
default_choice = c
break
answer = _select_with_back(
"Select preset:", choices, default=default_choice
)
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
break
assert isinstance(answer, str)
if answer == "[+] Add new preset":
name_input = _get_questionary().text(
"Preset name:",
validate=lambda t: True if t and t.strip() else "Name cannot be empty",
).ask()
if not name_input:
continue
name = name_input.strip()
if name in config.model_presets:
console.print(f"[yellow]! Preset '{name}' already exists[/yellow]")
_pause()
continue
if name == "default":
console.print("[yellow]! 'default' is reserved (auto-generated from Agent Settings)[/yellow]")
_pause()
continue
new_preset = ModelPresetConfig(model="")
updated = _configure_pydantic_model(new_preset, f"New Preset: {name}")
if updated is not None:
config.model_presets[name] = updated
_sync_preset_cache(config)
last_preset_name = name
continue
# Editing / deleting an existing preset
preset_name = answer.split(" (", 1)[0]
preset = config.model_presets.get(preset_name)
if preset is None:
continue
last_preset_name = preset_name
choices = ["Edit", "Cancel"]
if preset_name != "default":
choices.insert(1, "Delete")
action = _select_with_back(
f"Preset: {preset_name}",
choices,
default="Edit",
)
if action is _BACK_PRESSED or action == "Cancel" or action is None:
continue
if action == "Delete":
confirm = _get_questionary().confirm(
f"Delete preset '{preset_name}'?",
default=False,
).ask()
if confirm:
del config.model_presets[preset_name]
_sync_preset_cache(config)
last_preset_name = None
continue
if action == "Edit":
updated = _configure_pydantic_model(preset, f"Edit Preset: {preset_name}")
if updated is not None:
config.model_presets[preset_name] = updated
_sync_preset_cache(config)
except KeyboardInterrupt:
console.print("\n[dim]Returning to main menu...[/dim]")
break
# --- Provider Configuration ---
@ -1043,6 +1250,12 @@ def _show_summary(config: Config) -> None:
channel_rows.append((display, status))
_print_summary_panel(channel_rows, "Chat Channels")
# Model Presets
preset_rows = []
for name, preset in config.model_presets.items():
preset_rows.append((name, f"{preset.model} (ctx={preset.context_window_tokens})"))
_print_summary_panel(preset_rows, "Model Presets")
# Settings sections
for title, model in [
("Agent Settings", config.agents.defaults),
@ -1112,6 +1325,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
original_config = base_config.model_copy(deep=True)
config = base_config.model_copy(deep=True)
_sync_preset_cache(config)
last_main_choice: str | None = None
while True:
@ -1123,6 +1337,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
"What would you like to configure?",
choices=[
"[P] LLM Provider",
"[M] Model Presets",
"[C] Chat Channel",
"[H] Channel Common",
"[A] Agent Settings",
@ -1149,6 +1364,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
_menu_dispatch = {
"[P] LLM Provider": lambda: _configure_providers(config),
"[M] Model Presets": lambda: _configure_model_presets(config),
"[C] Chat Channel": lambda: _configure_channels(config),
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),

View File

@ -4,12 +4,12 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Coroutine
from typing import Any, Callable, Coroutine
from loguru import logger
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
from nanobot.providers.base import LLMProvider
from nanobot.utils.llm_runtime import LLMRuntimeResolver, static_llm_runtime
_HEARTBEAT_TOOL = [
{
@ -53,17 +53,21 @@ class HeartbeatService:
def __init__(
self,
workspace: Path,
provider: LLMProvider,
model: str,
provider: LLMProvider | None = None,
model: str | None = None,
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
interval_s: int = 30 * 60,
enabled: bool = True,
timezone: str | None = None,
llm_runtime: LLMRuntimeResolver | None = None,
):
self.workspace = workspace
self.provider = provider
self.model = model
if llm_runtime is None:
if provider is None or model is None:
raise ValueError("HeartbeatService requires either llm_runtime or provider/model")
llm_runtime = static_llm_runtime(provider, model)
self._llm_runtime = llm_runtime
self.on_execute = on_execute
self.on_notify = on_notify
self.interval_s = interval_s
@ -91,7 +95,9 @@ class HeartbeatService:
"""
from nanobot.utils.helpers import current_time_str
response = await self.provider.chat_with_retry(
llm = self._llm_runtime()
response = await llm.provider.chat_with_retry(
messages=[
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
{"role": "user", "content": (
@ -101,7 +107,7 @@ class HeartbeatService:
)},
],
tools=_HEARTBEAT_TOOL,
model=self.model,
model=llm.model,
)
if not response.should_execute_tools:
@ -214,8 +220,9 @@ class HeartbeatService:
)
return
llm = self._llm_runtime()
should_notify = await evaluate_response(
response, tasks, self.provider, self.model,
response, tasks, llm.provider, llm.model,
)
if should_notify and self.on_notify:
logger.info("Heartbeat: completed, delivering response")

View File

@ -396,7 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
name="vllm",
keywords=("vllm",),
env_key="HOSTED_VLLM_API_KEY",
display_name="vLLM/Local",
display_name="vLLM",
backend="openai_compat",
is_local=True,
),

View File

@ -0,0 +1,311 @@
"""File-edit activity helpers for WebUI progress events."""
from __future__ import annotations
import difflib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
@dataclass(slots=True)
class FileSnapshot:
path: Path
exists: bool
text: str | None
unreadable: bool = False
binary: bool = False
oversized: bool = False
@property
def countable(self) -> bool:
return (
self.text is not None
and not self.binary
and not self.oversized
and not self.unreadable
)
@dataclass(slots=True)
class FileEditTracker:
call_id: str
tool: str
path: Path
display_path: str
before: FileSnapshot
def is_file_edit_tool(tool_name: str | None) -> bool:
return bool(tool_name) and tool_name in TRACKED_FILE_EDIT_TOOLS
def resolve_file_edit_path(
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> Path | None:
"""Resolve the target file path after tool argument preparation."""
if not isinstance(params, dict):
return None
raw_path = params.get("path")
if not isinstance(raw_path, str) or not raw_path.strip():
return None
resolver = getattr(tool, "_resolve", None)
if callable(resolver):
try:
resolved = resolver(raw_path)
if isinstance(resolved, Path):
return resolved
if resolved:
return Path(resolved)
except Exception:
return None
if workspace is None:
return Path(raw_path).expanduser().resolve()
return (workspace / raw_path).expanduser().resolve()
def display_file_edit_path(path: Path, workspace: Path | None) -> str:
if workspace is not None:
try:
return path.resolve().relative_to(workspace.resolve()).as_posix()
except Exception:
pass
return path.as_posix()
def read_file_snapshot(path: Path, *, max_bytes: int = _MAX_SNAPSHOT_BYTES) -> FileSnapshot:
try:
if not path.exists() or not path.is_file():
return FileSnapshot(path=path, exists=False, text="")
size = path.stat().st_size
if size > max_bytes:
return FileSnapshot(path=path, exists=True, text=None, oversized=True)
raw = path.read_bytes()
except OSError:
return FileSnapshot(path=path, exists=path.exists(), text=None, unreadable=True)
if b"\x00" in raw:
return FileSnapshot(path=path, exists=True, text=None, binary=True)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return FileSnapshot(path=path, exists=True, text=None, binary=True)
return FileSnapshot(path=path, exists=True, text=text.replace("\r\n", "\n"))
def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
"""Return ``(added, deleted)`` for a UTF-8 text line-level diff."""
if before is None or after is None:
return 0, 0
before_lines = before.replace("\r\n", "\n").splitlines()
after_lines = after.replace("\r\n", "\n").splitlines()
added = 0
deleted = 0
matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
continue
if tag in ("replace", "delete"):
deleted += i2 - i1
if tag in ("replace", "insert"):
added += j2 - j1
return added, deleted
def prepare_file_edit_tracker(
*,
call_id: str,
tool_name: str,
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> FileEditTracker | None:
if not is_file_edit_tool(tool_name):
return None
path = resolve_file_edit_path(tool, workspace, params)
if path is None:
return None
before = read_file_snapshot(path)
return FileEditTracker(
call_id=str(call_id or ""),
tool=tool_name,
path=path,
display_path=display_file_edit_path(path, workspace),
before=before,
)
def build_file_edit_start_event(
tracker: FileEditTracker,
params: dict[str, Any] | None,
) -> dict[str, Any]:
predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before)
if tracker.before.countable and predicted_after is not None:
added, deleted = line_diff_stats(tracker.before.text, predicted_after)
else:
added, deleted = 0, 0
return _event_payload(
tracker,
phase="start",
status="editing",
added=added,
deleted=deleted,
approximate=True,
)
def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
after = read_file_snapshot(tracker.path)
if tracker.before.countable and after.countable:
added, deleted = line_diff_stats(tracker.before.text, after.text)
else:
added, deleted = 0, 0
return _event_payload(
tracker,
phase="end",
status="done",
added=added,
deleted=deleted,
approximate=False,
binary=after.binary or after.oversized or after.unreadable,
)
def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]:
payload = _event_payload(
tracker,
phase="error",
status="error",
added=0,
deleted=0,
approximate=False,
)
if error:
payload["error"] = error.strip()[:240]
return payload
def _event_payload(
tracker: FileEditTracker,
*,
phase: str,
status: str,
added: int,
deleted: int,
approximate: bool,
binary: bool = False,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"version": 1,
"call_id": tracker.call_id,
"tool": tracker.tool,
"path": tracker.display_path,
"phase": phase,
"added": max(0, int(added)),
"deleted": max(0, int(deleted)),
"approximate": bool(approximate),
"status": status,
}
if binary:
payload["binary"] = True
return payload
def _predict_after_text(
tool_name: str,
params: dict[str, Any],
before: FileSnapshot,
) -> str | None:
if not before.countable:
return None
before_text = before.text or ""
if tool_name == "write_file":
content = params.get("content")
return content if isinstance(content, str) else ""
if tool_name == "edit_file":
old_text = params.get("old_text")
new_text = params.get("new_text")
if not isinstance(old_text, str) or not isinstance(new_text, str):
return None
replace_all = bool(params.get("replace_all"))
if old_text == "":
return new_text if not before.exists else before_text
if old_text in before_text:
if replace_all:
return before_text.replace(old_text, new_text)
return before_text.replace(old_text, new_text, 1)
return None
if tool_name == "notebook_edit":
return _predict_notebook_after_text(params, before_text)
return None
def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
try:
nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
except Exception:
return None
cells = nb.get("cells")
if not isinstance(cells, list):
return None
try:
cell_index = int(params.get("cell_index", 0))
except (TypeError, ValueError):
return None
new_source = params.get("new_source")
source = new_source if isinstance(new_source, str) else ""
cell_type = params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
mode = params.get("edit_mode") if params.get("edit_mode") in ("replace", "insert", "delete") else "replace"
if mode == "delete":
if 0 <= cell_index < len(cells):
cells.pop(cell_index)
else:
return None
elif mode == "insert":
insert_at = min(max(cell_index + 1, 0), len(cells))
cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
else:
if not (0 <= cell_index < len(cells)):
return None
cell = cells[cell_index]
if not isinstance(cell, dict):
return None
cell["source"] = source
cell["cell_type"] = cell_type
if cell_type == "code":
cell.setdefault("outputs", [])
cell.setdefault("execution_count", None)
else:
cell.pop("outputs", None)
cell.pop("execution_count", None)
nb["cells"] = cells
try:
return json.dumps(nb, indent=1, ensure_ascii=False)
except Exception:
return None
def _empty_notebook() -> dict[str, Any]:
return {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"},
},
"cells": [],
}
def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
if cell_type == "code":
cell["outputs"] = []
cell["execution_count"] = None
return cell

View File

@ -0,0 +1,22 @@
"""Small helpers for passing the active LLM provider/model together."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from nanobot.providers.base import LLMProvider
@dataclass(frozen=True)
class LLMRuntime:
provider: LLMProvider
model: str
LLMRuntimeResolver = Callable[[], LLMRuntime]
def static_llm_runtime(provider: LLMProvider, model: str) -> LLMRuntimeResolver:
runtime = LLMRuntime(provider=provider, model=model)
return lambda: runtime

View File

@ -10,13 +10,21 @@ from nanobot.agent.hook import AgentHookContext
def on_progress_accepts_tool_events(cb: Callable[..., Any]) -> bool:
return _on_progress_accepts(cb, "tool_events")
def on_progress_accepts_file_edit_events(cb: Callable[..., Any]) -> bool:
return _on_progress_accepts(cb, "file_edit_events")
def _on_progress_accepts(cb: Callable[..., Any], name: str) -> bool:
try:
sig = inspect.signature(cb)
except (TypeError, ValueError):
return False
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
return True
return "tool_events" in sig.parameters
return name in sig.parameters
async def invoke_on_progress(
@ -32,6 +40,15 @@ async def invoke_on_progress(
await on_progress(content, tool_hint=tool_hint)
async def invoke_file_edit_progress(
on_progress: Callable[..., Awaitable[None]],
file_edit_events: list[dict[str, Any]],
) -> None:
if not file_edit_events or not on_progress_accepts_file_edit_events(on_progress):
return
await on_progress("", file_edit_events=file_edit_events)
def build_tool_event_start_payload(tool_call: Any) -> dict[str, Any]:
return {
"version": 1,

View File

@ -1,138 +0,0 @@
"""Helpers for WebUI chat title generation."""
from __future__ import annotations
import re
from typing import Any
from loguru import logger
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import truncate_text
WEBUI_SESSION_METADATA_KEY = "webui"
WEBUI_TITLE_METADATA_KEY = "title"
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
TITLE_MAX_CHARS = 60
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
return True
def clean_generated_title(raw: str | None) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE)
text = text.strip().strip("\"'`“”‘’")
text = re.sub(r"\s+", " ", text).strip()
text = text.rstrip("。.!?,;:")
if len(text) > TITLE_MAX_CHARS:
text = text[: TITLE_MAX_CHARS - 1].rstrip() + ""
return text
def _title_inputs(session: Session) -> tuple[str, str]:
user_text = ""
assistant_text = ""
for message in session.messages:
role = message.get("role")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
continue
if role == "user" and not user_text:
user_text = content.strip()
elif role == "assistant" and not assistant_text:
assistant_text = content.strip()
if user_text and assistant_text:
break
return user_text, assistant_text
async def maybe_generate_webui_title(
*,
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
"""Generate and persist a short title for WebUI-owned sessions only."""
session = sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
return False
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
if isinstance(current_title, str) and current_title.strip():
return False
user_text, assistant_text = _title_inputs(session)
if not user_text:
return False
prompt = (
"Generate a concise title for this chat.\n"
"Rules:\n"
"- Use the same language as the user when practical.\n"
"- 3 to 8 words.\n"
"- No quotes.\n"
"- No punctuation at the end.\n"
"- Return only the title.\n\n"
f"User: {truncate_text(user_text, 1_000)}"
)
if assistant_text:
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
try:
response = await provider.chat_with_retry(
[
{
"role": "system",
"content": (
"You write short, neutral chat titles. "
"Return only the title text."
),
},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=32,
temperature=0.2,
retry_mode="standard",
)
except Exception:
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
return False
title = clean_generated_title(response.content)
if not title or title.lower().startswith("error"):
return False
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
sessions.save(session)
return True
async def maybe_generate_webui_title_after_turn(
*,
channel: str,
metadata: dict[str, Any],
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
return await maybe_generate_webui_title(
sessions=sessions,
session_key=session_key,
provider=provider,
model=model,
)

View File

@ -125,11 +125,25 @@ def replay_transcript_to_ui_messages(
buffer_message_id: str | None = None
buffer_parts: list[str] = []
suppress_until_turn_end = False
active_activity_segment_id: str | None = None
active_file_edit_segment_id: str | None = None
activity_segment_counter = 0
_ts_base = int(time.time() * 1000)
def _new_id(prefix: str, idx: int) -> str:
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
def _new_activity_segment(*, activate: bool = True) -> str:
nonlocal active_activity_segment_id, activity_segment_counter
activity_segment_counter += 1
segment_id = f"activity-{activity_segment_counter}"
if activate:
active_activity_segment_id = segment_id
return segment_id
def _ensure_activity_segment() -> str:
return active_activity_segment_id or _new_activity_segment()
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
for i in range(len(prev) - 1, -1, -1):
candidate = prev[i]
@ -151,12 +165,19 @@ def replay_transcript_to_ui_messages(
**candidate,
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
"reasoningStreaming": True,
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
}
return
if not has_answer and candidate.get("isStreaming"):
prev[i] = {**candidate, "reasoning": chunk, "reasoningStreaming": True}
prev[i] = {
**candidate,
"reasoning": chunk,
"reasoningStreaming": True,
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
}
return
break
segment = _ensure_activity_segment()
prev.append(
{
"id": _new_id("as", idx),
@ -165,6 +186,7 @@ def replay_transcript_to_ui_messages(
"isStreaming": True,
"reasoning": chunk,
"reasoningStreaming": True,
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
@ -221,6 +243,7 @@ def replay_transcript_to_ui_messages(
return
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
nonlocal active_activity_segment_id
last = messages[-1] if messages else None
if last and is_reasoning_only_placeholder(last):
messages[-1] = {
@ -238,10 +261,76 @@ def replay_transcript_to_ui_messages(
**extra,
},
)
active_activity_segment_id = None
def _file_edit_key(edit: dict[str, Any]) -> str:
return "|".join(
str(edit.get(k) or "")
for k in ("call_id", "tool", "path")
)
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
nonlocal active_file_edit_segment_id
if not edits:
return
last = messages[-1] if messages else None
if (
active_file_edit_segment_id
and last
and last.get("kind") == "trace"
and last.get("fileEdits")
):
segment = active_file_edit_segment_id
else:
segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
if not (
last
and last.get("kind") == "trace"
and not last.get("isStreaming")
and last.get("fileEdits")
and last.get("activitySegmentId") == segment
):
messages.append(
{
"id": _new_id("tr", idx),
"role": "tool",
"kind": "trace",
"content": "",
"traces": [],
"fileEdits": [],
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
last = messages[-1]
existing = list(last.get("fileEdits") or [])
index_by_key = {
_file_edit_key(edit): pos
for pos, edit in enumerate(existing)
if isinstance(edit, dict)
}
for edit in edits:
if not isinstance(edit, dict):
continue
key = _file_edit_key(edit)
if key in index_by_key:
pos = index_by_key[key]
existing[pos] = {**existing[pos], **edit}
else:
index_by_key[key] = len(existing)
existing.append(dict(edit))
messages[-1] = {
**last,
"fileEdits": existing,
"activitySegmentId": last.get("activitySegmentId") or segment,
}
for idx, rec in enumerate(lines):
ev = rec.get("event")
if ev == "user":
active_activity_segment_id = None
active_file_edit_segment_id = None
text = rec.get("text")
text_s = text if isinstance(text, str) else ""
media_paths = rec.get("media_paths")
@ -264,6 +353,12 @@ def replay_transcript_to_ui_messages(
messages.append(row)
continue
if ev == "file_edit":
raw_edits = rec.get("edits")
if isinstance(raw_edits, list):
upsert_file_edits([e for e in raw_edits if isinstance(e, dict)], idx)
continue
if ev == "delta":
if suppress_until_turn_end:
continue
@ -338,14 +433,21 @@ def replay_transcript_to_ui_messages(
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
if not trace_lines:
continue
segment = _ensure_activity_segment()
last = messages[-1] if messages else None
if last and last.get("kind") == "trace" and not last.get("isStreaming"):
if (
last
and last.get("kind") == "trace"
and not last.get("isStreaming")
and (last.get("activitySegmentId") in (None, segment))
):
prev_traces = list(last.get("traces") or [last.get("content")])
merged_traces = prev_traces + trace_lines
messages[-1] = {
**last,
"traces": merged_traces,
"content": trace_lines[-1],
"activitySegmentId": last.get("activitySegmentId") or segment,
}
else:
messages.append(
@ -355,6 +457,7 @@ def replay_transcript_to_ui_messages(
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
@ -389,6 +492,8 @@ def replay_transcript_to_ui_messages(
if ev == "turn_end":
suppress_until_turn_end = False
active_activity_segment_id = None
active_file_edit_segment_id = None
for i, m in enumerate(messages):
if m.get("isStreaming"):
messages[i] = {**m, "isStreaming": False}

View File

@ -6,17 +6,163 @@ AgentLoop uses these without importing a concrete channel plugin; only
from __future__ import annotations
import re
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import truncate_text
from nanobot.utils.llm_runtime import LLMRuntime
WEBUI_SESSION_METADATA_KEY = "webui"
WEBUI_TITLE_METADATA_KEY = "title"
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
TITLE_MAX_CHARS = 60
TITLE_GENERATION_MAX_TOKENS = 96
TITLE_GENERATION_REASONING_EFFORT = "none"
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
return True
def clean_generated_title(raw: str | None) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE)
text = text.strip().strip("\"'`“”‘’")
text = re.sub(r"\s+", " ", text).strip()
text = text.rstrip("。.!?,;:")
if len(text) > TITLE_MAX_CHARS:
text = text[: TITLE_MAX_CHARS - 1].rstrip() + ""
return text
def _title_inputs(session: Session) -> tuple[str, str]:
user_text = ""
assistant_text = ""
for message in session.messages:
if message.get("_command") is True:
continue
role = message.get("role")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
continue
if role == "user" and not user_text:
user_text = content.strip()
elif role == "assistant" and not assistant_text:
assistant_text = content.strip()
if user_text and assistant_text:
break
return user_text, assistant_text
async def maybe_generate_webui_title(
*,
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
"""Generate and persist a short title for WebUI-owned sessions only."""
session = sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
return False
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
if isinstance(current_title, str) and current_title.strip():
return False
user_text, assistant_text = _title_inputs(session)
if not user_text:
return False
prompt = (
"Generate a concise title for this chat.\n"
"Rules:\n"
"- Use the same language as the user when practical.\n"
"- 3 to 8 words.\n"
"- No quotes.\n"
"- No punctuation at the end.\n"
"- Return only the title.\n\n"
f"User: {truncate_text(user_text, 1_000)}"
)
if assistant_text:
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
try:
response = await provider.chat_with_retry(
[
{
"role": "system",
"content": (
"You write short, neutral chat titles. "
"Return only the title text."
),
},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=TITLE_GENERATION_MAX_TOKENS,
temperature=0.2,
reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
retry_mode="standard",
)
except Exception:
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
return False
title = clean_generated_title(response.content)
if not title or title.lower().startswith("error"):
logger.debug(
"WebUI title generation returned no usable title for {} (finish_reason={})",
session_key,
response.finish_reason,
)
return False
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
sessions.save(session)
return True
async def maybe_generate_webui_title_after_turn(
*,
channel: str,
metadata: dict[str, Any],
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
return await maybe_generate_webui_title(
sessions=sessions,
session_key=session_key,
provider=provider,
model=model,
)
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
"""Return ``time.time()`` when the active user turn began, if still running."""
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
@ -46,3 +192,156 @@ async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status:
metadata=meta,
),
)
def build_bus_progress_callback(
bus: MessageBus,
msg: InboundMessage,
) -> Callable[..., Awaitable[None]]:
"""Return the bus progress callback for agent runtime events."""
async def _publish_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
file_edit_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
if reasoning:
meta["_reasoning_delta"] = True
if reasoning_end:
meta["_reasoning_end"] = True
if tool_events:
meta["_tool_events"] = tool_events
if file_edit_events:
meta["_file_edit_events"] = file_edit_events
await bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
)
)
if msg.channel == "websocket":
async def _websocket_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
file_edit_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
await _publish_progress(
content,
tool_hint=tool_hint,
tool_events=tool_events,
file_edit_events=file_edit_events,
reasoning=reasoning,
reasoning_end=reasoning_end,
)
return _websocket_progress
async def _bus_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
await _publish_progress(
content,
tool_hint=tool_hint,
tool_events=tool_events,
reasoning=reasoning,
reasoning_end=reasoning_end,
)
return _bus_progress
@dataclass
class WebuiTurnCoordinator:
"""Own the WebUI/WebSocket wire details that hang off AgentLoop turns."""
bus: MessageBus
sessions: SessionManager
schedule_background: Callable[[Awaitable[None]], None]
_title_contexts: dict[str, LLMRuntime] = field(default_factory=dict)
def capture_title_context(
self,
session_key: str,
msg: InboundMessage,
llm: LLMRuntime,
) -> None:
if msg.channel == "websocket" and msg.metadata.get("webui") is True:
self._title_contexts[session_key] = llm
def discard(self, session_key: str) -> None:
self._title_contexts.pop(session_key, None)
async def publish_run_status(self, msg: InboundMessage, status: str) -> None:
await publish_turn_run_status(self.bus, msg, status)
async def handle_turn_end(
self,
msg: InboundMessage,
*,
session_key: str,
latency_ms: int | None,
) -> None:
if msg.channel != "websocket":
return
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
if latency_ms is not None:
turn_metadata["latency_ms"] = int(latency_ms)
session = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata=turn_metadata,
))
self._schedule_title_update(msg, session_key=session_key)
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
title_context = self._title_contexts.pop(session_key, None)
if msg.metadata.get("webui") is not True or title_context is None:
return
async def _generate_title_and_notify(
title_llm: LLMRuntime = title_context,
) -> None:
generated = await maybe_generate_webui_title_after_turn(
channel=msg.channel,
metadata=msg.metadata,
sessions=self.sessions,
session_key=session_key,
provider=title_llm.provider,
model=title_llm.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={
**msg.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
self.schedule_background(_generate_title_and_notify())

View File

@ -1,6 +1,8 @@
"""Embedded web UI assets.
The ``dist/`` subdirectory is populated by ``cd webui && bun run build`` and
is shipped in the wheel; it stays empty in source checkouts until that command
has been run.
The ``dist/`` subdirectory holds the production WebUI bundle served by the
gateway. It is shipped inside the published wheel and is rebuilt automatically
by the ``webui-build`` Hatch hook during ``python -m build``. In an editable
source checkout it stays empty until you run ``cd webui && bun run build``
(or use the Vite dev server at ``cd webui && bun run dev``).
"""

View File

@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
version = "0.1.5.post3"
version = "0.2.0"
description = "A lightweight personal AI assistant framework"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
@ -121,12 +121,22 @@ build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.hooks.custom]
# Implementation lives in the conventional `hatch_build.py` at the repo root.
[tool.hatch.build]
include = [
"nanobot/**/*.py",
"nanobot/templates/**/*.md",
"nanobot/skills/**/*.md",
"nanobot/skills/**/*.sh",
"nanobot/web/dist/**/*",
]
# nanobot/web/dist/ is produced by `cd webui && bun run build` and is
# git-ignored. List it as an artifact so hatch ships it in both wheel and
# sdist even though VCS does not track it.
artifacts = [
"nanobot/web/dist/**/*",
]
[tool.hatch.build.targets.wheel]
@ -141,7 +151,9 @@ packages = ["nanobot"]
[tool.hatch.build.targets.sdist]
include = [
"nanobot/",
"nanobot/web/dist/",
"bridge/",
"hatch_build.py",
"README.md",
"LICENSE",
"THIRD_PARTY_NOTICES.md",

View File

@ -45,6 +45,73 @@ def _add_turns(session, turns: int, *, prefix: str = "msg") -> None:
session.add_message("assistant", f"{prefix} assistant {i}")
def _make_fake_compact(
loop: AgentLoop,
*,
summary: str = "Summary.",
on_archive=None,
track_archived: list | None = None,
track_count: bool = False,
):
"""Return a fake compact_idle_session that mirrors the real method's session mutation."""
from nanobot.session.manager import Session as _Session
state = {"count": 0}
async def _fake_compact(key: str, max_suffix: int = 8) -> str:
state["count"] += 1
session = loop.sessions.get_or_create(key)
tail = list(session.messages[session.last_consolidated:])
if not tail:
session.updated_at = datetime.now()
loop.sessions.save(session)
return ""
probe = _Session(
key=session.key,
messages=tail.copy(),
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
probe.retain_recent_legal_suffix(max_suffix)
kept = probe.messages
cut = len(tail) - len(kept)
archive_msgs = tail[:cut]
if not archive_msgs and not kept:
session.updated_at = datetime.now()
loop.sessions.save(session)
return ""
last_active = session.updated_at
s = summary
if archive_msgs:
if on_archive:
result = on_archive(archive_msgs)
s = result if isinstance(result, str) else summary
if track_archived is not None:
track_archived.extend(archive_msgs)
if s and s != "(nothing)":
session.metadata["_last_summary"] = {
"text": s,
"last_active": last_active.isoformat(),
}
session.messages = kept
session.last_consolidated = 0
session.updated_at = datetime.now()
loop.sessions.save(session)
return s
# Attach state for count access
_fake_compact.state = state # type: ignore[attr-defined]
return _fake_compact
class TestSessionTTLConfig:
"""Test session TTL configuration."""
@ -201,10 +268,7 @@ class TestAutoCompact:
s2.add_message("user", "recent")
loop.sessions.save(s2)
async def _fake_archive(messages):
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
loop.auto_compact.check_expired(loop._schedule_background)
await asyncio.sleep(0.1)
@ -222,12 +286,9 @@ class TestAutoCompact:
loop.sessions.save(session)
archived_messages = []
async def _fake_archive(messages):
archived_messages.extend(messages)
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, track_archived=archived_messages,
)
await loop.auto_compact._archive("cli:test")
@ -246,10 +307,9 @@ class TestAutoCompact:
_add_turns(session, 6, prefix="hello")
loop.sessions.save(session)
async def _fake_archive(messages):
return "User said hello."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, summary="User said hello.",
)
await loop.auto_compact._archive("cli:test")
@ -262,23 +322,16 @@ class TestAutoCompact:
@pytest.mark.asyncio
async def test_auto_compact_empty_session(self, tmp_path):
"""_archive on empty session should not archive."""
"""_archive on empty session should not store a summary."""
loop = _make_loop(tmp_path, session_ttl_minutes=15)
archive_called = False
async def _fake_archive(messages):
nonlocal archive_called
archive_called = True
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
await loop.auto_compact._archive("cli:test")
assert not archive_called
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 0
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
@pytest.mark.asyncio
@ -290,18 +343,14 @@ class TestAutoCompact:
session.last_consolidated = 18
loop.sessions.save(session)
archived_count = 0
async def _fake_archive(messages):
nonlocal archived_count
archived_count = len(messages)
return "Summary."
loop.consolidator.archive = _fake_archive
archived_messages = []
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, track_archived=archived_messages,
)
await loop.auto_compact._archive("cli:test")
assert archived_count == 2
assert len(archived_messages) == 2
await loop.close_mcp()
@ -334,12 +383,9 @@ class TestAutoCompactIdleDetection:
loop.sessions.save(session)
archived_messages = []
async def _fake_archive(messages):
archived_messages.extend(messages)
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, track_archived=archived_messages,
)
# Simulate proactive archive completing before message arrives
await loop.auto_compact._archive("cli:test")
@ -402,10 +448,7 @@ class TestAutoCompactIdleDetection:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _fake_archive(messages):
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(msg)
@ -466,10 +509,7 @@ class TestAutoCompactSystemMessages:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _fake_archive(messages):
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
# Simulate proactive archive completing before system message arrives
await loop.auto_compact._archive("cli:test")
@ -547,12 +587,9 @@ class TestAutoCompactEdgeCases:
loop.sessions.save(session)
archived_messages = []
async def _fake_archive(messages):
archived_messages.extend(messages)
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, track_archived=archived_messages,
)
# Simulate proactive archive completing before message arrives
await loop.auto_compact._archive("cli:test")
@ -644,10 +681,7 @@ class TestAutoCompactIntegration:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _fake_archive(messages):
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
# Simulate proactive archive completing before message arrives
await loop.auto_compact._archive("cli:test")
@ -704,12 +738,9 @@ class TestProactiveAutoCompact:
loop.sessions.save(session)
archived_messages = []
async def _fake_archive(messages):
archived_messages.extend(messages)
return "User chatted about old things."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, summary="User chatted about old things.", track_archived=archived_messages,
)
await self._run_check_expired(loop)
@ -748,14 +779,14 @@ class TestProactiveAutoCompact:
started = asyncio.Event()
block_forever = asyncio.Event()
async def _slow_archive(messages):
async def _slow_compact(key, max_suffix=8):
nonlocal archive_count
archive_count += 1
started.set()
await block_forever.wait()
return "Summary."
loop.consolidator.archive = _slow_archive
loop.consolidator.compact_idle_session = _slow_compact
# First call starts archiving via callback
loop.auto_compact.check_expired(loop._schedule_background)
@ -781,10 +812,10 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _failing_archive(messages):
async def _failing_compact(key, max_suffix=8):
raise RuntimeError("LLM down")
loop.consolidator.archive = _failing_archive
loop.consolidator.compact_idle_session = _failing_compact
# Should not raise
await self._run_check_expired(loop)
@ -795,24 +826,18 @@ class TestProactiveAutoCompact:
@pytest.mark.asyncio
async def test_proactive_archive_skips_empty_sessions(self, tmp_path):
"""Proactive archive should not call LLM for sessions with no un-consolidated messages."""
"""Proactive archive should not produce a summary for sessions with no messages."""
loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test")
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
archive_called = False
async def _fake_archive(messages):
nonlocal archive_called
archive_called = True
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
await self._run_check_expired(loop)
assert not archive_called
# Empty session should not produce a summary
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
@pytest.mark.asyncio
@ -824,18 +849,12 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
archive_count = 0
async def _fake_archive(messages):
nonlocal archive_count
archive_count += 1
return "Summary."
loop.consolidator.archive = _fake_archive
_fake_compact = _make_fake_compact(loop)
loop.consolidator.compact_idle_session = _fake_compact
# Simulate an active agent task for this session
await self._run_check_expired(loop, active_session_keys={"cli:test"})
assert archive_count == 0
assert _fake_compact.state["count"] == 0
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 12 # All messages preserved
@ -851,22 +870,16 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
archive_count = 0
async def _fake_archive(messages):
nonlocal archive_count
archive_count += 1
return "Summary."
loop.consolidator.archive = _fake_archive
_fake_compact = _make_fake_compact(loop)
loop.consolidator.compact_idle_session = _fake_compact
# First tick: active task, skip
await self._run_check_expired(loop, active_session_keys={"cli:test"})
assert archive_count == 0
assert _fake_compact.state["count"] == 0
# Second tick: task completed, should archive
await self._run_check_expired(loop)
assert archive_count == 1
assert _fake_compact.state["count"] == 1
await loop.close_mcp()
@pytest.mark.asyncio
@ -888,18 +901,12 @@ class TestProactiveAutoCompact:
s3.add_message("user", "recent")
loop.sessions.save(s3)
archive_count = 0
async def _fake_archive(messages):
nonlocal archive_count
archive_count += 1
return "Summary."
loop.consolidator.archive = _fake_archive
_fake_compact = _make_fake_compact(loop)
loop.consolidator.compact_idle_session = _fake_compact
await self._run_check_expired(loop, active_session_keys={"cli:expired_active"})
assert archive_count == 1
assert _fake_compact.state["count"] == 1
s1_after = loop.sessions.get_or_create("cli:expired_idle")
assert len(s1_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
s2_after = loop.sessions.get_or_create("cli:expired_active")
@ -917,22 +924,16 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
archive_count = 0
async def _fake_archive(messages):
nonlocal archive_count
archive_count += 1
return "Summary."
loop.consolidator.archive = _fake_archive
_fake_compact = _make_fake_compact(loop)
loop.consolidator.compact_idle_session = _fake_compact
# First tick: archives the session
await self._run_check_expired(loop)
assert archive_count == 1
assert _fake_compact.state["count"] == 1
# Second tick: should NOT re-schedule (updated_at is fresh after clear)
await self._run_check_expired(loop)
assert archive_count == 1 # Still 1, not re-scheduled
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
await loop.close_mcp()
@pytest.mark.asyncio
@ -943,22 +944,15 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
archive_count = 0
async def _fake_archive(messages):
nonlocal archive_count
archive_count += 1
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
# First tick: skips (no messages), refreshes updated_at
await self._run_check_expired(loop)
assert archive_count == 0
assert "cli:test" not in loop.auto_compact._summaries
# Second tick: should NOT re-schedule because updated_at is fresh
await self._run_check_expired(loop)
assert archive_count == 0
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
@pytest.mark.asyncio
@ -970,18 +964,12 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
archive_count = 0
async def _fake_archive(messages):
nonlocal archive_count
archive_count += 1
return "Summary."
loop.consolidator.archive = _fake_archive
_fake_compact = _make_fake_compact(loop)
loop.consolidator.compact_idle_session = _fake_compact
# First compact cycle
await loop.auto_compact._archive("cli:test")
assert archive_count == 1
assert _fake_compact.state["count"] == 1
# User returns, sends new messages
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="second topic")
@ -995,7 +983,7 @@ class TestProactiveAutoCompact:
# Second compact cycle should succeed
await loop.auto_compact._archive("cli:test")
assert archive_count == 2
assert _fake_compact.state["count"] == 2
await loop.close_mcp()
@ -1011,10 +999,9 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _fake_archive(messages):
return "User said hello."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, summary="User said hello.",
)
await loop.auto_compact._archive("cli:test")
@ -1036,10 +1023,9 @@ class TestSummaryPersistence:
session.updated_at = last_active
loop.sessions.save(session)
async def _fake_archive(messages):
return "User said hello."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, summary="User said hello.",
)
# Archive
await loop.auto_compact._archive("cli:test")
@ -1069,10 +1055,7 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _fake_archive(messages):
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
await loop.auto_compact._archive("cli:test")
@ -1100,10 +1083,7 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _fake_archive(messages):
return "Summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
await loop.auto_compact._archive("cli:test")
@ -1129,10 +1109,9 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _fake_archive(messages):
return "First summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, summary="First summary.",
)
await loop.auto_compact._archive("cli:test")
# Consume the first summary via hot path
@ -1148,10 +1127,9 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _fake_archive2(messages):
return "Second summary."
loop.consolidator.archive = _fake_archive2
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, summary="Second summary.",
)
await loop.auto_compact._archive("cli:test")
# The second archive writes a new summary
@ -1173,10 +1151,9 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
async def _fake_archive(messages):
return "Old summary."
loop.consolidator.archive = _fake_archive
loop.consolidator.compact_idle_session = _make_fake_compact(
loop, summary="Old summary.",
)
await loop.auto_compact._archive("cli:test")
# Verify summary exists before /new

View File

@ -38,7 +38,7 @@ def _make_autocompact(
sessions = MagicMock(spec=SessionManager)
if consolidator is None:
consolidator = MagicMock()
consolidator.archive = AsyncMock(return_value="Summary.")
consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
return AutoCompact(
sessions=sessions,
consolidator=consolidator,
@ -178,62 +178,6 @@ class TestFormatSummary:
assert result.startswith("Previous conversation summary (last active ")
# ---------------------------------------------------------------------------
# _split_unconsolidated
# ---------------------------------------------------------------------------
class TestSplitUnconsolidated:
"""Test AutoCompact._split_unconsolidated splitting logic."""
def test_empty_session_returns_both_empty(self):
"""Empty session should return ([], [])."""
ac = _make_autocompact()
session = _make_session(messages=[])
archive, kept = ac._split_unconsolidated(session)
assert archive == []
assert kept == []
def test_all_messages_archivable_when_more_than_suffix(self):
"""Session with many messages should archive a prefix and keep suffix."""
ac = _make_autocompact()
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
session = _make_session(messages=msgs)
archive, kept = ac._split_unconsolidated(session)
assert len(archive) > 0
assert len(kept) <= AutoCompact._RECENT_SUFFIX_MESSAGES
def test_fewer_messages_than_suffix_returns_empty_archive(self):
"""Session with fewer messages than suffix should have empty archive."""
ac = _make_autocompact()
msgs = [{"role": "user", "content": f"u{i}"} for i in range(3)]
session = _make_session(messages=msgs)
archive, kept = ac._split_unconsolidated(session)
assert archive == []
assert len(kept) == len(msgs)
def test_respects_last_consolidated_offset(self):
"""Only messages after last_consolidated should be considered."""
ac = _make_autocompact()
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
# First 10 are already consolidated
session = _make_session(messages=msgs, last_consolidated=10)
archive, kept = ac._split_unconsolidated(session)
# Only the tail of 10 messages is considered for splitting
assert all(m["content"] in [f"u{i}" for i in range(10, 20)] for m in kept)
assert all(m["content"] in [f"u{i}" for i in range(10, 20)] for m in archive)
def test_retain_recent_legal_suffix_keeps_last_n(self):
"""The kept suffix should be at most _RECENT_SUFFIX_MESSAGES long."""
ac = _make_autocompact()
# 20 user messages = 20 messages total, all after last_consolidated=0
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
session = _make_session(messages=msgs)
archive, kept = ac._split_unconsolidated(session)
assert len(kept) <= AutoCompact._RECENT_SUFFIX_MESSAGES
assert len(archive) == len(msgs) - len(kept)
# ---------------------------------------------------------------------------
# check_expired
# ---------------------------------------------------------------------------
@ -313,126 +257,71 @@ class TestCheckExpired:
# ---------------------------------------------------------------------------
class TestArchive:
"""Test AutoCompact._archive async method."""
class TestArchiveDelegates:
"""_archive should delegate all session mutation to Consolidator."""
@pytest.mark.asyncio
async def test_empty_session_updates_timestamp_no_archive_call(self):
"""Empty session should refresh updated_at and not call consolidator.archive."""
async def test_calls_compact_idle_session(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
empty_session = _make_session(messages=[])
mock_sm.get_or_create.return_value = empty_session
ac.sessions = mock_sm
ac.consolidator.archive = AsyncMock(return_value="Summary.")
ac.consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
await ac._archive("cli:test")
ac.consolidator.archive.assert_not_called()
mock_sm.save.assert_called_once_with(empty_session)
# updated_at was refreshed
assert empty_session.updated_at > datetime.now() - timedelta(seconds=5)
ac.consolidator.compact_idle_session.assert_awaited_once_with(
"cli:test", ac._RECENT_SUFFIX_MESSAGES,
)
@pytest.mark.asyncio
async def test_archive_returns_empty_string_no_summary_stored(self):
"""If archive returns empty string, no summary should be stored."""
async def test_populates_summaries_from_metadata(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
session = _make_session(messages=msgs)
session = _make_session(
metadata={"_last_summary": {"text": "Hello.", "last_active": "2026-05-13T10:00:00"}}
)
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
ac.consolidator.archive = AsyncMock(return_value="")
ac.consolidator.compact_idle_session = AsyncMock(return_value="Hello.")
await ac._archive("cli:test")
assert "cli:test" not in ac._summaries
@pytest.mark.asyncio
async def test_archive_returns_nothing_no_summary_stored(self):
"""If archive returns '(nothing)', no summary should be stored."""
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
session = _make_session(messages=msgs)
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
ac.consolidator.archive = AsyncMock(return_value="(nothing)")
await ac._archive("cli:test")
assert "cli:test" not in ac._summaries
@pytest.mark.asyncio
async def test_archive_exception_caught_key_removed_from_archiving(self):
"""If archive raises, exception is caught and key removed from _archiving."""
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
session = _make_session(messages=msgs)
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
ac.consolidator.archive = AsyncMock(side_effect=RuntimeError("LLM down"))
# Should not raise
await ac._archive("cli:test")
assert "cli:test" not in ac._archiving
@pytest.mark.asyncio
async def test_successful_archive_stores_summary_in_summaries_and_metadata(self):
"""Successful archive should store summary in _summaries dict and metadata."""
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
last_active = datetime(2026, 5, 13, 10, 0, 0)
session = _make_session(messages=msgs, updated_at=last_active)
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
ac.consolidator.archive = AsyncMock(return_value="User discussed AI.")
await ac._archive("cli:test")
# _summaries
entry = ac._summaries.get("cli:test")
assert entry is not None
assert entry[0] == "User discussed AI."
assert entry[1] == last_active
# metadata
meta = session.metadata.get("_last_summary")
assert meta is not None
assert meta["text"] == "User discussed AI."
assert "last_active" in meta
assert entry[0] == "Hello."
@pytest.mark.asyncio
async def test_finally_block_always_removes_from_archiving(self):
"""Finally block should always remove key from _archiving, even on error."""
async def test_no_summary_when_compact_returns_empty(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
session = _make_session(messages=msgs)
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
ac.consolidator.archive = AsyncMock(side_effect=RuntimeError("fail"))
ac.consolidator.compact_idle_session = AsyncMock(return_value="")
# Pre-add key to archiving to verify it gets removed
ac._archiving.add("cli:test")
await ac._archive("cli:test")
assert "cli:test" not in ac._archiving
assert "cli:test" not in ac._summaries
@pytest.mark.asyncio
async def test_finally_removes_from_archiving_on_success(self):
"""Finally block should remove key from _archiving on success too."""
async def test_no_summary_when_compact_returns_nothing(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
session = _make_session(messages=msgs)
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
ac.consolidator.archive = AsyncMock(return_value="Summary.")
ac.consolidator.compact_idle_session = AsyncMock(return_value="(nothing)")
await ac._archive("cli:test")
assert "cli:test" not in ac._summaries
@pytest.mark.asyncio
async def test_exception_still_removes_from_archiving(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
ac.sessions = mock_sm
ac.consolidator.compact_idle_session = AsyncMock(side_effect=RuntimeError("fail"))
ac._archiving.add("cli:test")
await ac._archive("cli:test")
assert "cli:test" not in ac._archiving

View File

@ -28,6 +28,12 @@ def mock_provider():
def consolidator(store, mock_provider):
sessions = MagicMock()
sessions.save = MagicMock()
# When maybe_consolidate_by_tokens refreshes the session reference via
# get_or_create(session.key), it should get back the same object the test
# passed in. Store sessions by key so the lookup is transparent.
_session_cache: dict[str, MagicMock] = {}
sessions.get_or_create = MagicMock(side_effect=lambda key: _session_cache.get(key, MagicMock()))
sessions._session_cache = _session_cache
return Consolidator(
store=store,
provider=mock_provider,
@ -117,6 +123,7 @@ class TestConsolidatorTokenBudget:
session.last_consolidated = 0
session.messages = [{"role": "user", "content": "hi"}]
session.key = "test:key"
consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session)
@ -152,6 +159,7 @@ class TestConsolidatorTokenBudget:
session.add_message("user", f"u{i}")
session.add_message("assistant", f"a{i}")
consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
consolidator.archive = AsyncMock(return_value="old conversation summary")
@ -184,6 +192,7 @@ class TestConsolidatorTokenBudget:
session.add_message("tool", "tool result", tool_call_id="call-1", name="x")
session.add_message("assistant", "final answer")
consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
consolidator.archive = AsyncMock(return_value="tool turn summary")
@ -210,6 +219,7 @@ class TestConsolidatorTokenBudget:
}
for i in range(70)
]
consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
@ -238,6 +248,7 @@ class TestConsolidatorTokenBudget:
for i in range(70)
]
session.metadata = {}
consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
@ -263,6 +274,7 @@ class TestConsolidatorTokenBudget:
for i in range(70)
]
session.metadata = {}
consolidator.sessions._session_cache[session.key] = session
# Keep estimates high so the loop would otherwise run multiple rounds.
consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(1200, "tiktoken")
@ -287,6 +299,7 @@ class TestConsolidatorTokenBudget:
}
for i in range(70)
]
consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
@ -299,6 +312,260 @@ class TestConsolidatorTokenBudget:
assert session.last_consolidated == 61
class TestCompactIdleSession:
"""Tests for Consolidator.compact_idle_session — lock-protected idle truncation."""
@pytest.fixture
def real_consolidator(self, store, mock_provider):
"""Create a Consolidator with a real SessionManager (not a mock)."""
from nanobot.session.manager import SessionManager
sessions = SessionManager(store.workspace)
return Consolidator(
store=store,
provider=mock_provider,
model="test-model",
sessions=sessions,
context_window_tokens=1000,
build_messages=MagicMock(return_value=[]),
get_tool_definitions=MagicMock(return_value=[]),
max_completion_tokens=100,
)
@pytest.mark.asyncio
async def test_archives_prefix_keeps_suffix(self, real_consolidator, mock_provider):
"""20 user/assistant turns → compact with max_suffix=8 → messages ≤ 8,
last_consolidated=0, _last_summary stored."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="Summary of old conversation.", finish_reason="stop"
)
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:test")
for i in range(20):
session.add_message("user", f"user msg {i}")
session.add_message("assistant", f"assistant msg {i}")
sessions.save(session)
result = await real_consolidator.compact_idle_session("cli:test", max_suffix=8)
assert result == "Summary of old conversation."
reloaded = sessions.get_or_create("cli:test")
assert len(reloaded.messages) <= 8
assert reloaded.last_consolidated == 0
meta = reloaded.metadata.get("_last_summary")
assert meta is not None
assert meta["text"] == "Summary of old conversation."
assert "last_active" in meta
@pytest.mark.asyncio
async def test_empty_session_refreshes_timestamp(self, real_consolidator):
"""Empty session with old updated_at → refreshed after call, returns ''."""
from datetime import datetime, timedelta
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:empty")
old_ts = datetime.now() - timedelta(hours=2)
session.updated_at = old_ts
sessions.save(session)
result = await real_consolidator.compact_idle_session("cli:empty")
assert result == ""
reloaded = sessions.get_or_create("cli:empty")
assert reloaded.updated_at > old_ts
@pytest.mark.asyncio
async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):
"""LLM returns '(nothing)' → _last_summary NOT in metadata."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="(nothing)", finish_reason="stop"
)
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:nothing")
for i in range(10):
session.add_message("user", f"u{i}")
session.add_message("assistant", f"a{i}")
sessions.save(session)
result = await real_consolidator.compact_idle_session("cli:nothing", max_suffix=4)
assert result == "(nothing)"
reloaded = sessions.get_or_create("cli:nothing")
assert "_last_summary" not in reloaded.metadata
@pytest.mark.asyncio
async def test_llm_failure_still_truncates(self, real_consolidator, mock_provider, store):
"""LLM raises RuntimeError → raw_archive fires, session still truncated, returns None."""
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:fail")
for i in range(10):
session.add_message("user", f"u{i}")
session.add_message("assistant", f"a{i}")
sessions.save(session)
result = await real_consolidator.compact_idle_session("cli:fail", max_suffix=4)
assert result is None
# raw_archive should have been called (history.jsonl gets an entry)
entries = store.read_unprocessed_history(since_cursor=0)
assert any("[RAW]" in e["content"] for e in entries)
# Session should still be truncated
reloaded = sessions.get_or_create("cli:fail")
assert len(reloaded.messages) <= 4
@pytest.mark.asyncio
async def test_respects_last_consolidated(self, real_consolidator, mock_provider):
"""30 turns with last_consolidated=50 → only unconsolidated tail considered."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="Tail summary.", finish_reason="stop"
)
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:offset")
for i in range(30):
session.add_message("user", f"u{i}")
session.add_message("assistant", f"a{i}")
session.last_consolidated = 50 # Only 10 messages unconsolidated
sessions.save(session)
result = await real_consolidator.compact_idle_session("cli:offset", max_suffix=4)
assert result == "Tail summary."
# Verify only the unconsolidated tail was processed:
# 10 unconsolidated messages (50-59), keep suffix of 4 → archive 6
archived_call = mock_provider.chat_with_retry.call_args
user_content = archived_call.kwargs["messages"][1]["content"]
# Should contain only tail messages, not early ones
assert "u0" not in user_content
assert "u25" in user_content or "a25" in user_content
@pytest.mark.asyncio
async def test_acquires_consolidation_lock(self, real_consolidator, mock_provider):
"""Verify lock is held during execution."""
import asyncio
# Use a slow LLM response to ensure the lock is held while we check
started = asyncio.Event()
async def slow_chat(**kwargs):
started.set()
await asyncio.sleep(0.1)
return MagicMock(content="Summary.", finish_reason="stop")
mock_provider.chat_with_retry = slow_chat
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:lock")
for i in range(10):
session.add_message("user", f"u{i}")
session.add_message("assistant", f"a{i}")
sessions.save(session)
lock = real_consolidator.get_lock("cli:lock")
assert not lock.locked()
task = asyncio.ensure_future(
real_consolidator.compact_idle_session("cli:lock", max_suffix=4)
)
await started.wait()
assert lock.locked()
await task
assert not lock.locked()
class TestConsolidatorSessionRefresh:
"""Background consolidation must detect stale session references."""
@pytest.mark.asyncio
async def test_reloads_before_empty_session_guard(self, tmp_path):
"""A stale empty reference must not skip a non-empty cached session."""
from nanobot.agent.memory import Consolidator, MemoryStore
from nanobot.session.manager import Session, SessionManager
store = MemoryStore(tmp_path)
provider = MagicMock()
provider.chat_with_retry = AsyncMock(
return_value=MagicMock(content="summary", finish_reason="stop")
)
provider.generation.max_tokens = 4096
provider.estimate_prompt_tokens = MagicMock(return_value=(10, "test"))
sessions = SessionManager(tmp_path)
consolidator = Consolidator(
store=store,
provider=provider,
model="test-model",
sessions=sessions,
context_window_tokens=128_000,
build_messages=MagicMock(return_value=[]),
get_tool_definitions=MagicMock(return_value=[]),
)
fresh = sessions.get_or_create("cli:test")
fresh.add_message("user", "fresh message")
sessions.save(fresh)
stale_empty = Session(key="cli:test")
seen: dict[str, Session] = {}
def estimate(session: Session):
seen["session"] = session
return 10, "test"
consolidator.estimate_session_prompt_tokens = MagicMock(side_effect=estimate)
await consolidator.maybe_consolidate_by_tokens(stale_empty)
assert seen["session"] is fresh
@pytest.mark.asyncio
async def test_reloads_stale_session_after_compact(self, tmp_path):
"""After compact_idle_session replaces the session, a concurrent
maybe_consolidate_by_tokens with the old reference should use the
fresh session from cache instead of overwriting."""
from nanobot.agent.memory import Consolidator, MemoryStore
from nanobot.session.manager import SessionManager
store = MemoryStore(tmp_path)
provider = MagicMock()
provider.chat_with_retry = AsyncMock(
return_value=MagicMock(content="summary", finish_reason="stop")
)
provider.generation.max_tokens = 4096
provider.estimate_prompt_tokens = MagicMock(return_value=(10, "test"))
sessions = SessionManager(tmp_path)
consolidator = Consolidator(
store=store,
provider=provider,
model="test-model",
sessions=sessions,
context_window_tokens=128_000,
build_messages=MagicMock(return_value=[]),
get_tool_definitions=MagicMock(return_value=[]),
)
# Populate session with many messages
session = sessions.get_or_create("cli:test")
for i in range(20):
session.add_message("user", f"u{i}")
session.add_message("assistant", f"a{i}")
sessions.save(session)
# Simulate: background consolidation captures old reference
old_ref = session
# AutoCompact runs first and truncates to 8
await consolidator.compact_idle_session("cli:test", max_suffix=8)
# Background consolidation runs with stale reference —
# should detect the session was replaced and not undo the compact.
await consolidator.maybe_consolidate_by_tokens(old_ref)
session_after = sessions.get_or_create("cli:test")
# Messages should still be truncated (not restored to 40)
assert len(session_after.messages) <= 8
class TestRawArchiveTruncation:
"""raw_archive() must cap entry size to avoid bloating history.jsonl."""

View File

@ -4,6 +4,7 @@ import pytest
from nanobot.heartbeat.service import HeartbeatService
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.llm_runtime import LLMRuntime
class DummyProvider(LLMProvider):
@ -11,9 +12,11 @@ class DummyProvider(LLMProvider):
super().__init__()
self._responses = list(responses)
self.calls = 0
self.models: list[str | None] = []
async def chat(self, *args, **kwargs) -> LLMResponse:
self.calls += 1
self.models.append(kwargs.get("model"))
if self._responses:
return self._responses.pop(0)
return LLMResponse(content="", tool_calls=[])
@ -215,6 +218,51 @@ async def test_tick_suppresses_when_evaluator_says_no(tmp_path, monkeypatch) ->
assert notified == []
def test_tick_uses_runtime_provider_and_model(tmp_path, monkeypatch) -> None:
"""Preset changes must apply to heartbeat decision and post-run evaluation."""
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check runtime model", encoding="utf-8")
runtime_provider = DummyProvider([
LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1",
name="heartbeat",
arguments={"action": "run", "tasks": "check runtime model"},
)
],
),
])
runtime_model = "openai/gpt-4.1"
executed: list[str] = []
evaluated: list[tuple[LLMProvider, str]] = []
async def _on_execute(tasks: str) -> str:
executed.append(tasks)
return "runtime model produced a user-facing update"
async def _eval_capture(response, tasks, provider, model):
evaluated.append((provider, model))
return False
service = HeartbeatService(
workspace=tmp_path,
llm_runtime=lambda: LLMRuntime(runtime_provider, runtime_model),
on_execute=_on_execute,
)
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_capture)
asyncio.run(service._tick())
assert runtime_provider.calls == 1
assert runtime_provider.models == [runtime_model]
assert executed == ["check runtime model"]
assert evaluated == [(runtime_provider, runtime_model)]
@pytest.mark.asyncio
async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
provider = DummyProvider([
@ -286,4 +334,3 @@ async def test_decide_prompt_includes_current_time(tmp_path) -> None:
user_msg = captured_messages[1]
assert user_msg["role"] == "user"
assert "Current Time:" in user_msg["content"]

View File

@ -6,10 +6,15 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import nanobot.agent.runner as runner_module
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
def _make_loop(tmp_path: Path) -> AgentLoop:
@ -82,6 +87,142 @@ class TestToolEventProgress:
),
]
@pytest.mark.asyncio
async def test_write_file_emits_file_edit_progress(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
target = tmp_path / "foo.txt"
target.write_text("old\n", encoding="utf-8")
tool_call = ToolCallRequest(
id="call-write",
name="write_file",
arguments={"path": "foo.txt", "content": "new\nextra\n"},
)
calls = iter([
LLMResponse(content="", tool_calls=[tool_call]),
LLMResponse(content="Done", tool_calls=[]),
])
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.prepare_call = MagicMock(
return_value=(None, {"path": "foo.txt", "content": "new\nextra\n"}, None),
)
async def execute(name: str, params: dict) -> str:
target.write_text(params["content"], encoding="utf-8")
return "ok"
loop.tools.execute = AsyncMock(side_effect=execute)
file_events: list[dict] = []
async def on_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict] | None = None,
file_edit_events: list[dict] | None = None,
) -> None:
if file_edit_events:
file_events.extend(file_edit_events)
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
assert final_content == "Done"
assert [event["phase"] for event in file_events] == ["start", "end"]
assert file_events[0] == {
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "start",
"added": 2,
"deleted": 1,
"approximate": True,
"status": "editing",
}
assert file_events[1]["status"] == "done"
assert file_events[1]["approximate"] is False
assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1)
@pytest.mark.asyncio
async def test_file_edit_snapshot_skipped_when_progress_callback_cannot_emit_file_edits(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
loop = _make_loop(tmp_path)
target = tmp_path / "foo.txt"
target.write_text("old\n", encoding="utf-8")
tool_call = ToolCallRequest(
id="call-write",
name="write_file",
arguments={"path": "foo.txt", "content": "new\n"},
)
calls = iter([
LLMResponse(content="", tool_calls=[tool_call]),
LLMResponse(content="Done", tool_calls=[]),
])
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.prepare_call = MagicMock(
return_value=(None, {"path": "foo.txt", "content": "new\n"}, None),
)
async def execute(name: str, params: dict) -> str:
target.write_text(params["content"], encoding="utf-8")
return "ok"
loop.tools.execute = AsyncMock(side_effect=execute)
prepare_tracker = MagicMock(side_effect=AssertionError("unexpected file snapshot"))
monkeypatch.setattr(runner_module, "prepare_file_edit_tracker", prepare_tracker)
async def on_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict] | None = None,
) -> None:
pass
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
assert final_content == "Done"
assert target.read_text(encoding="utf-8") == "new\n"
prepare_tracker.assert_not_called()
@pytest.mark.asyncio
async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
tool_call = ToolCallRequest(
id="call-exec",
name="exec",
arguments={"command": "printf hi > foo.txt"},
)
calls = iter([
LLMResponse(content="", tool_calls=[tool_call]),
LLMResponse(content="Done", tool_calls=[]),
])
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.prepare_call = MagicMock(
return_value=(None, {"command": "printf hi > foo.txt"}, None),
)
loop.tools.execute = AsyncMock(return_value="ok")
file_events: list[dict] = []
async def on_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict] | None = None,
file_edit_events: list[dict] | None = None,
) -> None:
if file_edit_events:
file_events.extend(file_edit_events)
await loop._run_agent_loop([], on_progress=on_progress)
assert file_events == []
@pytest.mark.asyncio
async def test_bus_progress_forwards_tool_events_to_outbound_metadata(self, tmp_path: Path) -> None:
"""When run() handles a bus message, _tool_events lands in OutboundMessage metadata."""
@ -130,6 +271,44 @@ class TestToolEventProgress:
assert finish["phase"] == "end"
assert finish["result"] == "file.txt"
@pytest.mark.asyncio
async def test_bus_progress_forwards_file_edit_events_for_websocket_only(self, tmp_path: Path) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
edit_events = [{
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "start",
"added": 1,
"deleted": 0,
"approximate": True,
"status": "editing",
}]
websocket_progress = await loop._build_bus_progress_callback(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="edit",
))
assert on_progress_accepts_file_edit_events(websocket_progress) is True
await websocket_progress("", file_edit_events=edit_events)
outbound = await bus.consume_outbound()
assert outbound.metadata["_file_edit_events"] == edit_events
telegram_progress = await loop._build_bus_progress_callback(InboundMessage(
channel="telegram",
sender_id="u1",
chat_id="chat2",
content="edit",
))
assert on_progress_accepts_file_edit_events(telegram_progress) is False
await invoke_file_edit_progress(telegram_progress, edit_events)
assert bus.outbound_size == 0
@pytest.mark.asyncio
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
self,
@ -353,8 +532,93 @@ class TestToolEventProgress:
assert session_updated is not None
assert (session_updated.metadata or {}).get("_session_updated") is True
assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata"
assert provider.chat_with_retry.await_count == 2
@pytest.mark.asyncio
async def test_webui_title_generation_uses_turn_model_snapshot(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
captured: dict[str, object] = {}
async def fake_title_after_turn(**kwargs: object) -> bool:
captured.update(kwargs)
return False
monkeypatch.setattr(
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
fake_title_after_turn,
)
scheduled_title: list[object] = []
def schedule_background(coro: object) -> None:
name = getattr(coro, "__qualname__", "")
if "_generate_title_and_notify" in name:
scheduled_title.append(coro)
elif hasattr(coro, "close"):
coro.close()
loop._schedule_background = schedule_background # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="say hello",
metadata={"webui": True},
))
assert len(scheduled_title) == 1
loop.provider = MagicMock()
loop.model = "switched-after-turn"
await scheduled_title[0] # type: ignore[misc]
assert captured["provider"] is provider
assert captured["model"] == "test-model"
@pytest.mark.asyncio
async def test_webui_command_turn_does_not_schedule_title_generation(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
async def fake_title_after_turn(**_kwargs: object) -> bool:
raise AssertionError("command-only turns should not generate titles")
monkeypatch.setattr(
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
fake_title_after_turn,
)
scheduled: list[object] = []
loop._schedule_background = scheduled.append # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="/model",
metadata={"webui": True},
))
assert scheduled == []
@pytest.mark.asyncio
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
bus = MessageBus()

View File

@ -10,12 +10,16 @@ from nanobot.bus.events import InboundMessage
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
from nanobot.utils.webui_titles import (
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.webui_turn_helpers import (
TITLE_GENERATION_MAX_TOKENS,
TITLE_GENERATION_REASONING_EFFORT,
WEBUI_SESSION_METADATA_KEY,
WEBUI_TITLE_METADATA_KEY,
WebuiTurnCoordinator,
maybe_generate_webui_title,
)
from nanobot.utils.llm_runtime import LLMRuntime
def _mk_loop() -> AgentLoop:
@ -33,6 +37,22 @@ def _make_full_loop(tmp_path: Path) -> AgentLoop:
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
runtime = loop.llm_runtime()
assert runtime.provider is loop.provider
assert runtime.model == "test-model"
next_provider = MagicMock()
loop.provider = next_provider
loop.model = "next-model"
runtime = loop.llm_runtime()
assert runtime.provider is next_provider
assert runtime.model == "next-model"
@pytest.mark.asyncio
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
@ -55,6 +75,11 @@ async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Pat
assert generated is True
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
loop.provider.chat_with_retry.assert_awaited_once()
assert loop.provider.chat_with_retry.await_args.kwargs["max_tokens"] == TITLE_GENERATION_MAX_TOKENS
assert (
loop.provider.chat_with_retry.await_args.kwargs["reasoning_effort"]
== TITLE_GENERATION_REASONING_EFFORT
)
@pytest.mark.asyncio
@ -79,6 +104,80 @@ async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Pat
loop.provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_generate_webui_title_ignores_command_only_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
session = loop.sessions.get_or_create("websocket:command-title")
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
session.add_message("user", "/model deep", _command=True)
session.add_message(
"assistant",
"Switched model preset to `deep`.\n- Model: `deepseek-v4-pro`",
_command=True,
)
loop.sessions.save(session)
generated = await maybe_generate_webui_title(
sessions=loop.sessions,
session_key="websocket:command-title",
provider=loop.provider,
model=loop.model,
)
assert generated is False
assert WEBUI_TITLE_METADATA_KEY not in session.metadata
loop.provider.chat_with_retry.assert_not_awaited()
def test_webui_title_update_uses_captured_llm_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
bus = MessageBus()
sessions = SessionManager(tmp_path)
scheduled: list[object] = []
captured: dict[str, object] = {}
async def fake_title_after_turn(**kwargs: object) -> bool:
captured.update(kwargs)
return False
monkeypatch.setattr(
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
fake_title_after_turn,
)
coordinator = WebuiTurnCoordinator(
bus=bus,
sessions=sessions,
schedule_background=lambda coro: scheduled.append(coro),
)
provider = MagicMock()
msg = InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="say hello",
metadata={"webui": True},
)
coordinator.capture_title_context(
"websocket:chat1",
msg,
LLMRuntime(provider, "turn-model"),
)
asyncio.run(coordinator.handle_turn_end(
msg,
session_key="websocket:chat1",
latency_ms=None,
))
assert len(scheduled) == 1
asyncio.run(scheduled[0]) # type: ignore[arg-type]
assert captured["provider"] is provider
assert captured["model"] == "turn-model"
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
loop = _mk_loop()
session = Session(key="test:runtime-only")

View File

@ -1074,3 +1074,242 @@ class TestConfigurePydanticModelEmptyString:
result = _configure_pydantic_model(model, "Test")
assert result is not None
assert result.api_key == ""
class TestModelPresetWizard:
"""Tests for model preset CRUD in the onboard wizard."""
def test_sync_preset_cache(self):
"""_sync_preset_cache should populate the module-level cache."""
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _sync_preset_cache
from nanobot.config.schema import ModelPresetConfig
config = Config()
config.model_presets["fast"] = ModelPresetConfig(model="gpt-4.1-mini")
config.model_presets["power"] = ModelPresetConfig(model="gpt-4.1")
_sync_preset_cache(config)
assert _MODEL_PRESET_CACHE == {"fast", "power"}
_MODEL_PRESET_CACHE.clear()
def test_model_preset_add(self, monkeypatch):
"""_configure_model_presets should add a new preset."""
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets
from nanobot.config.schema import ModelPresetConfig
config = Config()
_MODEL_PRESET_CACHE.clear()
responses = iter([
"[+] Add new preset",
"my-preset",
"<- Back",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_text(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_configure(*_model, **_kwargs):
return ModelPresetConfig(model="gpt-test", temperature=0.5)
def fake_select_with_back(*_args, **_kwargs):
return next(responses)
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
monkeypatch.setattr(
onboard_wizard, "questionary", SimpleNamespace(select=fake_select, text=fake_text)
)
monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fake_configure)
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
_configure_model_presets(config)
assert "my-preset" in config.model_presets
assert config.model_presets["my-preset"].model == "gpt-test"
assert config.model_presets["my-preset"].temperature == 0.5
_MODEL_PRESET_CACHE.clear()
def test_model_preset_delete(self, monkeypatch):
"""_configure_model_presets should delete an existing preset."""
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets
from nanobot.config.schema import ModelPresetConfig
config = Config()
config.model_presets["old"] = ModelPresetConfig(model="x")
_MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.update({"old", "default"})
responses = iter([
"old (x)",
"Delete",
True,
"<- Back",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_confirm(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_select_with_back(*_args, **_kwargs):
return next(responses)
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
monkeypatch.setattr(
onboard_wizard, "questionary", SimpleNamespace(select=fake_select, confirm=fake_confirm)
)
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
_configure_model_presets(config)
assert "old" not in config.model_presets
assert "old" not in _MODEL_PRESET_CACHE
_MODEL_PRESET_CACHE.clear()
def test_model_preset_field_handler(self, monkeypatch):
"""_handle_model_preset_field should set a preset name from choices."""
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field
from nanobot.config.schema import AgentDefaults
_MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.update({"fast", "power", "default"})
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "fast")
defaults = AgentDefaults()
_handle_model_preset_field(defaults, "model_preset", "Model Preset", None)
assert defaults.model_preset == "fast"
_MODEL_PRESET_CACHE.clear()
def test_model_preset_field_handler_clear(self, monkeypatch):
"""_handle_model_preset_field should clear preset when (clear/unset) chosen."""
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field
from nanobot.config.schema import AgentDefaults
_MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.add("fast")
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "(clear/unset)")
defaults = AgentDefaults(model_preset="fast")
_handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast")
assert defaults.model_preset is None
_MODEL_PRESET_CACHE.clear()
def test_main_menu_dispatch_includes_model_presets(self):
"""_configure_model_presets should be importable and callable."""
from nanobot.cli.onboard import _configure_model_presets
assert callable(_configure_model_presets)
def test_run_onboard_model_presets_edit(self, monkeypatch):
"""run_onboard should handle [M] Model Presets correctly."""
from nanobot.config.schema import ModelPresetConfig
initial_config = Config()
responses = iter([
"[M] Model Presets",
"[S] Save and Exit",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
preset_mutated = {"n": 0}
def fake_configure_model_presets(config):
preset_mutated["n"] += 1
config.model_presets["test"] = ModelPresetConfig(model="gpt-test")
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
monkeypatch.setattr(onboard_wizard, "_configure_model_presets", fake_configure_model_presets)
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
result = run_onboard(initial_config)
assert result.should_save is True
assert preset_mutated["n"] == 1
assert "test" in result.config.model_presets
def test_fallback_models_field_add(self, monkeypatch):
"""_handle_fallback_models_field should add a preset name."""
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_models_field
from nanobot.config.schema import AgentDefaults
_MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.update({"fast", "default"})
select_responses = iter(["fast"])
questionary_responses = iter(["[+] Add preset", "[Done]"])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_questionary_select(*_args, **_kwargs):
return FakePrompt(next(questionary_responses))
def fake_select_with_back(*_args, **_kwargs):
return next(select_responses)
monkeypatch.setattr(
onboard_wizard, "questionary",
SimpleNamespace(select=fake_questionary_select, press_any_key_to_continue=lambda: FakePrompt(None)),
)
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
defaults = AgentDefaults()
_handle_fallback_models_field(defaults, "fallback_models", "Fallback Models", [])
assert defaults.fallback_models == ["fast"]
_MODEL_PRESET_CACHE.clear()
def test_provider_field_handler(self, monkeypatch):
"""_handle_provider_field should set provider from choices."""
from nanobot.cli.onboard import _handle_provider_field
from nanobot.config.schema import AgentDefaults
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "anthropic")
defaults = AgentDefaults()
_handle_provider_field(defaults, "provider", "Provider", "auto")
assert defaults.provider == "anthropic"

View File

@ -47,3 +47,28 @@ def test_provider_refresh_updates_all_model_dependents(tmp_path: Path) -> None:
assert loop.dream.provider is new_provider
assert loop.dream.model == "new-model"
assert loop.dream._runner.provider is new_provider
def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None:
old_provider = _provider("old-model")
new_provider = _provider("new-model", max_tokens=456)
loop = AgentLoop(
bus=MessageBus(),
provider=old_provider,
workspace=tmp_path,
model="old-model",
context_window_tokens=1000,
provider_snapshot_loader=lambda: ProviderSnapshot(
provider=new_provider,
model="new-model",
context_window_tokens=2000,
signature=("new-model",),
),
)
runtime = loop.llm_runtime()
assert runtime.provider is new_provider
assert runtime.model == "new-model"
assert loop.provider is new_provider
assert loop.runner.provider is new_provider

View File

@ -387,6 +387,7 @@ class TestConsolidationUnaffectedByUnifiedSession:
session = Session(key="unified:default")
session.messages = [{"role": "user", "content": "msg"}]
sessions.get_or_create.return_value = session
# Simulate over-budget: estimated > budget
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(950, "tiktoken"))

View File

@ -370,6 +370,55 @@ async def test_send_progress_includes_structured_tool_events() -> None:
]
@pytest.mark.asyncio
async def test_send_file_edit_progress_uses_file_edit_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={
"_progress": True,
"_file_edit_events": [
{
"version": 1,
"phase": "start",
"call_id": "call-1",
"tool": "write_file",
"path": "src/app.py",
"added": 12,
"deleted": 2,
"approximate": True,
"status": "editing",
}
],
},
))
payload = json.loads(mock_ws.send.await_args.args[0])
assert payload == {
"event": "file_edit",
"chat_id": "chat-1",
"edits": [
{
"version": 1,
"phase": "start",
"call_id": "call-1",
"tool": "write_file",
"path": "src/app.py",
"added": 12,
"deleted": 2,
"approximate": True,
"status": "editing",
}
],
}
@pytest.mark.asyncio
async def test_send_progress_includes_agent_ui_blob() -> None:
bus = MagicMock()
@ -758,6 +807,25 @@ async def test_send_session_updated_emits_session_updated_event() -> None:
assert body == {"event": "session_updated", "chat_id": "chat-1"}
@pytest.mark.asyncio
async def test_send_session_updated_includes_scope_when_present() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_session_updated": True, "_session_update_scope": "metadata"},
))
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {"event": "session_updated", "chat_id": "chat-1", "scope": "metadata"}
@pytest.mark.asyncio
async def test_send_non_connection_closed_exception_is_raised() -> None:
bus = MagicMock()
@ -946,7 +1014,12 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
providers = {provider["name"]: provider for provider in body["providers"]}
assert providers["openai"]["configured"] is True
assert providers["openai"]["api_key_hint"] == "secr••••-key"
assert providers["azure_openai"]["api_key_required"] is True
assert providers["openrouter"]["configured"] is False
assert providers["openrouter"]["api_key_required"] is True
assert providers["atomic_chat"]["configured"] is False
assert providers["atomic_chat"]["api_key_required"] is False
assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1"
assert body["agent"]["has_api_key"] is True
assert body["web_search"]["provider"] == "brave"
assert body["web_search"]["api_key_hint"] == "brav••••cret"
@ -969,10 +1042,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert provider_rows["openrouter"]["configured"] is True
assert "sk-or-test" not in provider_updated.text
local_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=atomic_chat"
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
headers={"Authorization": "Bearer tok"},
)
assert local_provider_updated.status_code == 200
local_provider_body = local_provider_updated.json()
local_provider_rows = {
provider["name"]: provider for provider in local_provider_body["providers"]
}
assert local_provider_rows["atomic_chat"]["configured"] is True
assert "localhost:1337" in local_provider_updated.text
updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model=openrouter/test"
"&provider=openrouter",
f"{port}/api/settings/update?model=atomic_chat/test"
"&provider=atomic_chat",
headers={"Authorization": "Bearer tok"},
)
assert updated.status_code == 200
@ -992,10 +1079,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert search_body["web_search"]["base_url"] == "https://search.example.com"
saved = load_config(config_path)
assert saved.agents.defaults.model == "openrouter/test"
assert saved.agents.defaults.provider == "openrouter"
assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat"
assert saved.providers.openrouter.api_key == "sk-or-test"
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
assert saved.tools.web.search.provider == "searxng"
assert saved.tools.web.search.api_key == ""
assert saved.tools.web.search.base_url == "https://search.example.com"

View File

@ -1170,6 +1170,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
self.model = "test-model"
self.provider = kwargs.get("provider", object())
self.tools = {}
seen["agent"] = self
async def process_direct(self, *_args, **_kwargs):
return OutboundMessage(
@ -1218,6 +1219,11 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
assert isinstance(cron, _FakeCron)
assert cron.on_job is not None
runtime_provider = object()
agent = seen["agent"]
agent.provider = runtime_provider
agent.model = "runtime-model"
job = CronJob(
id="cron-1",
name="stretch",
@ -1233,8 +1239,8 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
assert response == "Time to stretch."
assert seen["response"] == "Time to stretch."
assert seen["provider"] is provider
assert seen["model"] == "test-model"
assert seen["provider"] is runtime_provider
assert seen["model"] == "runtime-model"
assert seen["task_context"] == (
"The scheduled time has arrived. Deliver this reminder to the user now, "
"as a brief and natural message in their language. Speak directly to them — "
@ -1543,6 +1549,9 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
self.dream = _FakeDream()
self.sessions = _FakeSessionManager()
def llm_runtime(self) -> None:
return None
async def run(self) -> None:
await asyncio.Event().wait()

View File

@ -69,6 +69,72 @@ async def test_reasoning_delta_displayed_when_show_reasoning_enabled():
assert calls == ["I should search first."]
@pytest.mark.asyncio
async def test_reasoning_delta_buffers_until_sentence_boundary():
calls: list[str] = []
channels_config = SimpleNamespace(
send_progress=True, send_tool_hints=False, show_reasoning=True,
)
reasoning_buffer = commands._ReasoningBuffer()
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
first = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content="The",
metadata={"_progress": True, "_reasoning_delta": True},
),
None,
channels_config,
reasoning_buffer=reasoning_buffer,
)
second = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content=" user asked.",
metadata={"_progress": True, "_reasoning_delta": True},
),
None,
channels_config,
reasoning_buffer=reasoning_buffer,
)
assert first is True
assert second is True
assert calls == ["The user asked."]
@pytest.mark.asyncio
async def test_reasoning_end_flushes_buffered_delta():
calls: list[str] = []
channels_config = SimpleNamespace(
send_progress=True, send_tool_hints=False, show_reasoning=True,
)
reasoning_buffer = commands._ReasoningBuffer()
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
delta = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content="The user asked",
metadata={"_progress": True, "_reasoning_delta": True},
),
None,
channels_config,
reasoning_buffer=reasoning_buffer,
)
end = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content="",
metadata={"_progress": True, "_reasoning_end": True},
),
None,
channels_config,
reasoning_buffer=reasoning_buffer,
)
assert delta is True
assert end is True
assert calls == ["The user asked"]
@pytest.mark.asyncio
async def test_reasoning_hidden_when_show_reasoning_disabled():
"""Reasoning content should be suppressed when show_reasoning is False."""

View File

@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
from nanobot.utils.file_edit_events import (
build_file_edit_end_event,
build_file_edit_start_event,
line_diff_stats,
prepare_file_edit_tracker,
read_file_snapshot,
)
def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None:
added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n")
assert (added, deleted) == (2, 1)
def test_line_diff_stats_normalizes_crlf() -> None:
assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0)
def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None:
target = tmp_path / "notes.txt"
target.write_text("old\nkeep\n", encoding="utf-8")
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
tracker = prepare_file_edit_tracker(
call_id="call-write",
tool_name="write_file",
tool=None,
workspace=tmp_path,
params=params,
)
assert tracker is not None
start = build_file_edit_start_event(tracker, params)
assert start == {
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "notes.txt",
"phase": "start",
"added": 2,
"deleted": 1,
"approximate": True,
"status": "editing",
}
target.write_text("new\nkeep\nextra\n", encoding="utf-8")
end = build_file_edit_end_event(tracker)
assert end["phase"] == "end"
assert end["status"] == "done"
assert end["approximate"] is False
assert (end["added"], end["deleted"]) == (2, 1)
def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
target = tmp_path / "data.bin"
target.write_bytes(b"\x00\x01before")
tracker = prepare_file_edit_tracker(
call_id="call-bin",
tool_name="edit_file",
tool=None,
workspace=tmp_path,
params={"path": "data.bin", "old_text": "before", "new_text": "after"},
)
assert tracker is not None
assert not read_file_snapshot(target).countable
target.write_bytes(b"\x00\x01after")
event = build_file_edit_end_event(tracker)
assert event["binary"] is True
assert (event["added"], event["deleted"]) == (0, 0)
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
assert prepare_file_edit_tracker(
call_id="call-exec",
tool_name="exec",
tool=None,
workspace=tmp_path,
params={"path": "created-by-shell.txt"},
) is None

View File

@ -42,6 +42,62 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-file"
for ev in (
{"event": "user", "chat_id": "t-file", "text": "edit"},
{
"event": "message",
"chat_id": "t-file",
"text": 'write_file({"path":"foo.txt"})',
"kind": "tool_hint",
},
{
"event": "file_edit",
"chat_id": "t-file",
"edits": [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "end",
"added": 2,
"deleted": 1,
"approximate": False,
"status": "done",
},
],
},
):
append_transcript_object(key, ev)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert len(msgs) == 3
assert msgs[1]["kind"] == "trace"
assert msgs[1]["traces"] == ['write_file({"path":"foo.txt"})']
assert "fileEdits" not in msgs[1]
assert msgs[2]["kind"] == "trace"
assert msgs[2]["traces"] == []
assert msgs[2]["fileEdits"] == [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "foo.txt",
"phase": "end",
"added": 2,
"deleted": 1,
"approximate": False,
"status": "done",
},
]
assert msgs[2]["activitySegmentId"]
assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
def test_build_response_schema(monkeypatch, tmp_path) -> None:
from nanobot.utils.webui_transcript import build_webui_thread_response

View File

@ -8,15 +8,11 @@ on the same port.
For the project overview, install guide, and general docs map, see the root
[`README.md`](../README.md).
## Current status
## Just want to use the WebUI?
> [!NOTE]
> The standalone WebUI development workflow currently requires a source
> checkout.
>
> WebUI changes in the GitHub repository may land before they are included in
> the next packaged release, so source installs and published package versions
> are not yet guaranteed to move in lockstep.
If you installed nanobot via `pip install nanobot-ai`, the WebUI is **already bundled** in the wheel. Enable the WebSocket channel in `~/.nanobot/config.json` and run `nanobot gateway` — see the root [`README.md`](../README.md#-webui) for the 3-step setup. You do **not** need anything in this directory.
This `webui/` tree is for people **hacking on the WebUI itself** (UI changes, new components, styling, etc.).
## Layout
@ -25,7 +21,7 @@ webui/ source tree (this directory)
nanobot/web/dist/ build output served by the gateway
```
## Develop from source
## Develop the WebUI (Vite HMR)
### 1. Install nanobot from source
@ -35,6 +31,8 @@ From the repository root:
pip install -e .
```
> Editable installs intentionally **skip** the WebUI bundle step — Vite HMR is faster than rebuilding `dist/` on every change.
### 2. Enable the WebSocket channel
In `~/.nanobot/config.json`:
@ -63,8 +61,7 @@ bun run dev
Then open `http://127.0.0.1:5173`.
By default, the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket
traffic to `http://127.0.0.1:8765`.
By default the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to `http://127.0.0.1:8765`.
If your gateway listens on a non-default port, point the dev server at it:
@ -74,7 +71,7 @@ NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
### Access from another device (LAN)
To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
To use the WebUI from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
```json
{
@ -91,20 +88,20 @@ To use the webui from another device on the same network, set `host` to `"0.0.0.
The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
Then open `http://<your-ip>:8765` on the other device. The webui will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
Then open `http://<your-ip>:8765` on the other device. The WebUI will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
## Build for packaged runtime
You usually do not need to run this by hand: `python -m build` invokes the WebUI build automatically when packaging the wheel.
If you want to preview the production bundle locally without rebuilding the wheel:
```bash
cd webui
bun run build
bun run build # writes to ../nanobot/web/dist
```
This writes the production assets to `../nanobot/web/dist`, which is the
directory served by `nanobot gateway` and bundled into the Python wheel.
If you are cutting a release, run the build before packaging so the published
wheel contains the current WebUI assets.
The gateway picks up the new bundle on the next restart.
## Test

View File

@ -15,12 +15,15 @@
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.0.6",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"tailwind-merge": "^2.6.0",
@ -506,8 +509,12 @@
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"i18next": ["i18next@26.2.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
@ -588,6 +595,8 @@
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
@ -718,6 +727,8 @@
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
@ -742,6 +753,8 @@
"rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="],
"remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="],
@ -860,6 +873,8 @@
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],

744
webui/package-lock.json generated
View File

@ -318,6 +318,278 @@
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"cpu": [
@ -333,6 +605,108 @@
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"license": "MIT",
@ -1280,6 +1654,277 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.1",
"cpu": [
@ -1304,6 +1949,90 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"dev": true,
@ -2309,6 +3038,21 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,

View File

@ -30,6 +30,7 @@
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"tailwind-merge": "^2.6.0"

View File

@ -7,7 +7,8 @@ import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useSessions } from "@/hooks/useSessions";
import { useTheme } from "@/hooks/useTheme";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
clearSavedSecret,
@ -16,6 +17,7 @@ import {
loadSavedSecret,
saveSecret,
} from "@/lib/bootstrap";
import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type { ChatSummary } from "@/lib/types";
@ -30,14 +32,30 @@ type BootState =
status: "ready";
client: NanobotClient;
token: string;
tokenExpiresAt: number;
modelName: string | null;
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings";
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
}
function tokenRefreshDelayMs(expiresAt: number): number {
const remaining = Math.max(0, expiresAt - Date.now());
const margin = Math.min(
TOKEN_REFRESH_MARGIN_MS,
Math.max(1_000, remaining / 2),
);
return Math.max(TOKEN_REFRESH_MIN_DELAY_MS, remaining - margin);
}
function AuthForm({
failed,
onSecret,
@ -106,6 +124,7 @@ function readSidebarOpen(): boolean {
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
const bootstrapSecretRef = useRef("");
const bootstrapWithSecret = useCallback(
(secret: string) => {
@ -117,22 +136,37 @@ export default function App() {
if (cancelled) return;
if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({
let client: NanobotClient;
client = new NanobotClient({
url,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap("", secret);
return deriveWsUrl(refreshed.ws_path, refreshed.token);
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
const refreshedUrl = deriveWsUrl(refreshed.ws_path, refreshed.token);
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: refreshed.token,
tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName,
}
: current,
);
return refreshedUrl;
} catch {
return null;
}
},
});
bootstrapSecretRef.current = secret;
client.connect();
setState({
status: "ready",
client,
token: boot.token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null,
});
} catch (e) {
@ -152,6 +186,35 @@ export default function App() {
[],
);
useEffect(() => {
if (state.status !== "ready") return;
const client = state.client;
const timer = window.setTimeout(async () => {
try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
client.updateUrl(url);
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
}
: current,
);
} catch (e) {
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
setState({ status: "auth", failed: true });
}
}
}, tokenRefreshDelayMs(state.tokenExpiresAt));
return () => window.clearTimeout(timer);
}, [state]);
useEffect(() => {
const saved = loadSavedSecret();
return bootstrapWithSecret(saved);
@ -219,7 +282,13 @@ export default function App() {
);
}
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
function Shell({
onModelNameChange,
onLogout,
}: {
onModelNameChange: (modelName: string | null) => void;
onLogout: () => void;
}) {
const { t, i18n } = useTranslation();
const { client } = useClient();
const { theme, toggle } = useTheme();
@ -362,9 +431,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
});
}, [client, t]);
const onTurnEnd = useCallback(() => {
void refresh();
}, [refresh]);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
@ -386,8 +453,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
const headerTitle = activeSession
? activeSession.title ||
activeSession.preview ||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand");
useEffect(() => {
@ -415,93 +481,95 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
const showMainSidebar = view !== "settings";
return (
<div className="relative flex h-full w-full overflow-hidden">
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
>
<ThemeProvider theme={theme}>
<div className="relative flex h-full w-full overflow-hidden">
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
style={{ width: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
</aside>
) : null}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
</Sheet>
) : null}
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
className={cn(
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
"absolute inset-0 flex flex-col",
view === "settings" && "invisible pointer-events-none",
)}
style={{ width: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
</aside>
) : null}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
</Sheet>
) : null}
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
className={cn(
"absolute inset-0 flex flex-col",
view === "settings" && "invisible pointer-events-none",
)}
>
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
</div>
{view === "settings" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onLogout={onLogout}
onRestart={onRestart}
isRestarting={isRestarting}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
</div>
)}
</main>
{view === "settings" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
theme={theme}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onLogout={onLogout}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</div>
)}
</main>
<DeleteConfirm
open={!!pendingDelete}
title={pendingDelete?.label ?? ""}
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
{restartToast ? (
<div
role="status"
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
>
{restartToast}
</div>
) : null}
</div>
<DeleteConfirm
open={!!pendingDelete}
title={pendingDelete?.label ?? ""}
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
{restartToast ? (
<div
role="status"
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
>
{restartToast}
</div>
) : null}
</div>
</ThemeProvider>
);
}

View File

@ -7,6 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { deriveTitle } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
@ -64,8 +65,11 @@ export function ChatList({
const fallbackTitle = t("chat.fallbackTitle", {
id: s.chatId.slice(0, 6),
});
const rawLabel = (s.title || s.preview)?.trim();
const title = rawLabel || fallbackTitle;
const generatedTitle = s.title?.trim() || "";
const title =
generatedTitle || deriveTitle(s.preview, t("chat.newChat"));
const tooltipTitle =
generatedTitle || deriveTitle(s.preview, fallbackTitle);
return (
<li key={s.key} className="min-w-0">
<div
@ -79,7 +83,7 @@ export function ChatList({
<button
type="button"
onClick={() => onSelect(s.key)}
title={rawLabel || fallbackTitle}
title={tooltipTitle}
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
>
<span className="block w-full truncate font-medium leading-5">{title}</span>

View File

@ -1,44 +1,75 @@
import { useCallback, useEffect, useState } from "react";
import { Suspense, lazy, useCallback, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { useThemeValue } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
interface CodeBlockProps {
language?: string;
code: string;
className?: string;
highlight?: boolean;
}
/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */
function useIsDark() {
const [isDark, setIsDark] = useState(() =>
typeof document !== "undefined"
? document.documentElement.classList.contains("dark")
: true,
interface HighlightedCodeProps {
language?: string;
code: string;
isDark: boolean;
}
const LazyHighlightedCode = lazy(async () => {
const [
{ default: SyntaxHighlighter },
{ default: oneDark },
{ default: oneLight },
] = await Promise.all([
import("react-syntax-highlighter/dist/esm/prism-async-light"),
import("react-syntax-highlighter/dist/esm/styles/prism/one-dark"),
import("react-syntax-highlighter/dist/esm/styles/prism/one-light"),
]);
return {
default({ language, code, isDark }: HighlightedCodeProps) {
return (
<SyntaxHighlighter
language={language}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: 1.6,
}}
PreTag="pre"
wrapLongLines
>
{code}
</SyntaxHighlighter>
);
},
};
});
function PlainCodeFallback({ code }: { code: string }) {
return (
<pre
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]"
>
<code>{code}</code>
</pre>
);
useEffect(() => {
const el = document.documentElement;
const observer = new MutationObserver(() => {
setIsDark(el.classList.contains("dark"));
});
observer.observe(el, { attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return isDark;
}
export function CodeBlock({ language, code, className }: CodeBlockProps) {
export function CodeBlock({
language,
code,
className,
highlight = true,
}: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isDark = useIsDark();
const isDark = useThemeValue() === "dark";
const onCopy = useCallback(() => {
if (!navigator.clipboard) return;
@ -86,20 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
<SyntaxHighlighter
language={language}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: 1.6,
}}
PreTag="pre"
wrapLongLines
>
{code}
</SyntaxHighlighter>
{highlight ? (
<Suspense fallback={<PlainCodeFallback code={code} />}>
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
</Suspense>
) : (
<PlainCodeFallback code={code} />
)}
</div>
);
}

View File

@ -36,21 +36,25 @@ export function ConnectionBadge() {
status === "connecting" ||
status === "reconnecting" ||
status === "error";
const label = t(`connection.${status}`);
return (
<span
className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors",
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
"text-muted-foreground/70 hover:bg-sidebar-accent/65",
meta.color,
)}
aria-live="polite"
role="status"
title={label}
>
<span className="relative flex h-1.5 w-1.5" aria-hidden>
<span className="relative flex h-2 w-2" aria-hidden>
{pulsing && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
)}
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
</span>
{t(`connection.${status}`)}
<span className="sr-only">{label}</span>
</span>
);
}

View File

@ -0,0 +1,220 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type FileReferenceKind =
| "default"
| "css"
| "html"
| "json"
| "markdown"
| "notebook"
| "python"
| "react"
| "typescript";
interface FileReferenceChipProps {
path: string;
display?: "name" | "path";
active?: boolean;
className?: string;
textClassName?: string;
testId?: string;
}
export function FileReferenceChip({
path,
display = "name",
active = false,
className,
textClassName,
testId = "inline-file-path",
}: FileReferenceChipProps) {
const { name } = splitFilePath(path);
const kind = fileKindForPath(path);
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("not-prose inline-flex max-w-full align-[0.14em]", className)}
>
<span
data-testid={testId}
aria-label={path}
className={cn(
"inline-flex max-w-full items-center gap-1 font-medium leading-[1.1]",
"text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200",
)}
>
<FileReferenceIcon kind={kind} />
<span
data-sheen-text={active ? displayText : undefined}
className={cn(
"min-w-0 truncate",
active && "streaming-text-sheen",
textClassName,
)}
>
{displayText}
</span>
</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
sideOffset={8}
collisionPadding={12}
className={cn(
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]",
"border-border/60 bg-popover/95 px-2.5 py-1.5",
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
"shadow-lg backdrop-blur",
)}
>
{path}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function isLikelyFilePath(value: string): boolean {
const raw = value.trim();
if (!raw || raw.includes("\n")) return false;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
return false;
}
const normalized = raw.replace(/\\/g, "/");
const name = normalized.split("/").filter(Boolean).pop() ?? normalized;
if (!name || name === "." || name === "..") return false;
if (/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(name)) return true;
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
}
function splitFilePath(path: string): { directory: string; name: string } {
const normalized = path.replace(/\\/g, "/");
const slash = normalized.lastIndexOf("/");
if (slash < 0) return { directory: "", name: path };
return {
directory: normalized.slice(0, slash + 1),
name: normalized.slice(slash + 1) || normalized,
};
}
function fileKindForPath(path: string): FileReferenceKind {
const normalized = path.toLowerCase();
const name = normalized.split(/[\\/]/).pop() ?? normalized;
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
if (name === "dockerfile") {
return "default";
}
switch (ext) {
case "py":
case "pyi":
return "python";
case "jsx":
case "tsx":
return "react";
case "ts":
return "typescript";
case "html":
case "htm":
return "html";
case "css":
case "scss":
case "sass":
return "css";
case "json":
case "jsonl":
return "json";
case "md":
case "mdx":
return "markdown";
case "ipynb":
return "notebook";
default:
return "default";
}
}
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
if (kind === "react") {
return (
<svg
aria-hidden
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none" />
<ellipse cx="12" cy="12" rx="9" ry="3.7" />
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(60 12 12)" />
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(120 12 12)" />
</svg>
);
}
if (kind === "default") {
return (
<svg
aria-hidden
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7z" />
<path d="M14 2v5h5" />
</svg>
);
}
const label = fileKindLabel(kind);
return (
<span
aria-hidden
className={cn(
"inline-flex h-[1.05em] min-w-[1.05em] shrink-0 items-center justify-center",
"rounded-[4px] bg-sky-500/12 px-[0.22em] text-[0.58em] font-bold uppercase leading-none",
"text-sky-600 dark:bg-sky-400/15 dark:text-sky-300",
)}
>
{label}
</span>
);
}
function fileKindLabel(kind: FileReferenceKind): string {
switch (kind) {
case "css":
return "#";
case "html":
return "H";
case "json":
return "{}";
case "markdown":
return "M";
case "notebook":
return "N";
case "python":
return "PY";
case "typescript":
return "TS";
default:
return "";
}
}

View File

@ -1,15 +1,46 @@
import { Suspense, lazy } from "react";
import {
Suspense,
lazy,
memo,
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
interface MarkdownTextProps {
children: string;
className?: string;
streaming?: boolean;
}
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
source,
className,
highlightCode,
}: {
source: string;
className?: string;
highlightCode: boolean;
}) {
return (
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}>
{source}
</LazyMarkdownRenderer>
);
});
const SHORT_STREAM_COMMIT_MS = 80;
const MEDIUM_STREAM_COMMIT_MS = 140;
const LONG_STREAM_COMMIT_MS = 220;
export function preloadMarkdownText(): void {
void loadMarkdownRenderer();
}
@ -19,7 +50,18 @@ export function preloadMarkdownText(): void {
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
*/
export function MarkdownText({ children, className }: MarkdownTextProps) {
export function MarkdownText({
children,
className,
streaming = false,
}: MarkdownTextProps) {
const renderedSource = useStreamingMarkdownSource(children, streaming);
const highlightCode = !streaming && renderedSource === children;
useEffect(() => {
if (streaming) preloadMarkdownText();
}, [streaming]);
return (
<Suspense
fallback={
@ -29,11 +71,73 @@ export function MarkdownText({ children, className }: MarkdownTextProps) {
className,
)}
>
{children}
{renderedSource}
</div>
}
>
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer>
<MemoizedMarkdownRenderer
source={renderedSource}
className={className}
highlightCode={highlightCode}
/>
</Suspense>
);
}
function useStreamingMarkdownSource(source: string, streaming: boolean): string {
const [renderedSource, setRenderedSource] = useState(source);
const latestSourceRef = useRef(source);
const renderedSourceRef = useRef(source);
const timerRef = useRef<number | null>(null);
const clearPendingCommit = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const commitSource = useCallback((next: string, urgent: boolean) => {
if (renderedSourceRef.current === next) return;
renderedSourceRef.current = next;
if (urgent) {
setRenderedSource(next);
return;
}
startTransition(() => setRenderedSource(next));
}, []);
const scheduleCommit = useCallback(() => {
if (timerRef.current !== null) return;
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
commitSource(latestSourceRef.current, false);
}, streamingCommitDelay(latestSourceRef.current.length));
}, [commitSource]);
latestSourceRef.current = source;
useLayoutEffect(() => {
latestSourceRef.current = source;
if (!streaming) {
clearPendingCommit();
commitSource(source, true);
}
}, [clearPendingCommit, commitSource, source, streaming]);
useEffect(() => {
latestSourceRef.current = source;
if (!streaming) return;
scheduleCommit();
}, [scheduleCommit, source, streaming]);
useEffect(() => clearPendingCommit, [clearPendingCommit]);
return renderedSource;
}
function streamingCommitDelay(length: number): number {
if (length > 24_000) return LONG_STREAM_COMMIT_MS;
if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS;
return SHORT_STREAM_COMMIT_MS;
}

View File

@ -1,10 +1,13 @@
import { Children, isValidElement } from "react";
import { Children, isValidElement, useMemo } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeBlock } from "@/components/CodeBlock";
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
@ -12,8 +15,12 @@ import "katex/dist/katex.min.css";
interface MarkdownTextRendererProps {
children: string;
className?: string;
highlightCode?: boolean;
}
const remarkPlugins = [remarkBreaks, remarkGfm, remarkMath];
const rehypePlugins = [rehypeKatex];
/**
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
* separate chunk so the app shell can paint sooner on refresh.
@ -21,7 +28,91 @@ interface MarkdownTextRendererProps {
export default function MarkdownTextRenderer({
children,
className,
highlightCode = true,
}: MarkdownTextRendererProps) {
const components = useMemo<Components>(
() => ({
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (match) {
const code = String(kids).replace(/\n$/, "");
return (
<CodeBlock
language={match[1]}
code={code}
className="my-3"
highlight={highlightCode}
/>
);
}
const raw = String(kids).replace(/\n$/, "");
if (isLikelyFilePath(raw)) {
return <FileReferenceChip path={raw} />;
}
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
return (
<code
className={cn(
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
"leading-snug text-inherit",
cls,
)}
{...props}
>
{kids}
</code>
);
}
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
},
pre({ children: markdownChildren }) {
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
return <>{markdownChildren}</>;
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
"whitespace-pre [overflow-wrap:normal]",
)}
>
{markdownChildren}
</pre>
);
},
a({ href, children: markdownChildren, ...props }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 hover:opacity-80"
{...props}
>
{markdownChildren}
</a>
);
},
}),
[highlightCode],
);
return (
<div
className={cn(
@ -42,77 +133,9 @@ export default function MarkdownTextRenderer({
style={{ lineHeight: "var(--cjk-line-height)" }}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (match) {
const code = String(kids).replace(/\n$/, "");
return <CodeBlock language={match[1]} code={code} className="my-3" />;
}
const raw = String(kids).replace(/\n$/, "");
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
return (
<code
className={cn(
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
"leading-snug text-inherit",
cls,
)}
{...props}
>
{kids}
</code>
);
}
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
},
pre({ children: markdownChildren }) {
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
return <>{markdownChildren}</>;
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
"whitespace-pre [overflow-wrap:normal]",
)}
>
{markdownChildren}
</pre>
);
},
a({ href, children: markdownChildren, ...props }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 hover:opacity-80"
{...props}
>
{markdownChildren}
</a>
);
},
}}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{children}
</ReactMarkdown>

View File

@ -1,6 +1,5 @@
import {
useCallback,
useDeferredValue,
useEffect,
useRef,
useState,
@ -120,7 +119,7 @@ export function MessageBubble({
<TypingDots />
) : empty && message.isStreaming ? null : (
<>
<MarkdownText>{message.content}</MarkdownText>
<MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? (
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
@ -167,10 +166,15 @@ function MessageMedia({
align: "left" | "right";
}) {
if (media.length === 0) return null;
const images = media
.filter((item) => item.kind === "image")
.map(({ url, name }) => ({ url, name }));
const nonImages = media.filter((item) => item.kind !== "image");
const images: UIImage[] = [];
const nonImages: UIMediaAttachment[] = [];
for (const item of media) {
if (item.kind === "image") {
images.push({ url: item.url, name: item.name });
} else {
nonImages.push(item);
}
}
return (
<div
@ -276,13 +280,14 @@ function UserImages({
const { t } = useTranslation();
// Only real-URL images can open in the lightbox; historical-replay
// placeholders (no URL) have nothing to zoom into.
const viewable = images
.map((img, i) => ({ img, i }))
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
const viewableImages = viewable.map(({ img }) => img);
const originalToViewable = new Map<number, number>(
viewable.map(({ i }, v) => [i, v]),
);
const viewableImages: UIImage[] = [];
const originalToViewable = new Map<number, number>();
for (let i = 0; i < images.length; i += 1) {
const img = images[i];
if (typeof img.url !== "string" || img.url.length === 0) continue;
originalToViewable.set(i, viewableImages.length);
viewableImages.push(img);
}
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@ -416,7 +421,7 @@ function Dot({ delay }: { delay: string }) {
);
}
/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
export function StreamingLabelSheen({
children,
active,
@ -426,21 +431,21 @@ export function StreamingLabelSheen({
active: boolean;
className?: string;
}) {
const sheenText =
typeof children === "string" || typeof children === "number"
? String(children)
: undefined;
return (
<span className={cn("relative block min-w-0 py-px", className)}>
<span className={cn("block min-w-0 overflow-hidden py-px", className)}>
<span
data-sheen-text={active ? sheenText : undefined}
className={cn(
"relative z-0 block font-medium leading-normal text-muted-foreground",
!active && "truncate",
"block w-fit max-w-full truncate font-medium leading-normal",
active ? "streaming-text-sheen" : "text-muted-foreground",
)}
>
{children}
</span>
{active ? (
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
<span className="reasoning-sheen-stripe" />
</span>
) : null}
</span>
);
}
@ -474,8 +479,6 @@ export function ReasoningBubble({
embeddedInCluster = false,
}: ReasoningBubbleProps) {
const { t } = useTranslation();
const deferredText = useDeferredValue(text);
const markdownSource = streaming ? deferredText : text;
const [userToggled, setUserToggled] = useState(false);
const [openLocal, setOpenLocal] = useState(true);
const open = userToggled ? openLocal : streaming;
@ -531,6 +534,7 @@ export function ReasoningBubble({
)}
>
<MarkdownText
streaming={streaming}
className={cn(
"text-[12.5px] italic text-muted-foreground/88",
"prose-p:my-1.5 prose-li:my-0.5",
@ -541,7 +545,7 @@ export function ReasoningBubble({
"prose-code:text-[0.92em]",
)}
>
{markdownSource}
{text}
</MarkdownText>
</div>
)}

View File

@ -117,12 +117,12 @@ export function Sidebar(props: SidebarProps) {
/>
</div>
<Separator className="bg-sidebar-border/50" />
<div className="space-y-1 px-2.5 py-2.5 text-xs">
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs">
<Button
type="button"
variant="ghost"
onClick={props.onOpenSettings}
className="h-8 w-full justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
className="h-8 min-w-0 flex-1 justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<Settings className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.settings")}

View File

@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
type SettingsSectionKey = "general" | "byok";
type ByokPaneKey = "llm" | "web-search";
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
name,
index,
]),
);
interface SettingsViewProps {
theme: "light" | "dark";
onToggleTheme: () => void;
@ -176,7 +183,8 @@ export function SettingsView({
if (!provider) return;
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
const apiKey = providerForm.apiKey.trim();
if (!provider.configured && !apiKey) {
const apiKeyRequired = provider.api_key_required ?? true;
if (!provider.configured && apiKeyRequired && !apiKey) {
setError(t("settings.byok.apiKeyRequired"));
return;
}
@ -917,7 +925,10 @@ function ByokSettings({
const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured);
const unconfiguredProviders = useMemo(
() => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)),
[settings.providers],
);
const initialUnconfiguredCount = 6;
const visibleUnconfiguredProviders = showAllUnconfigured
? unconfiguredProviders
@ -935,6 +946,12 @@ function ByokSettings({
const saving = providerSaving === provider.name;
const keyVisible = !!visibleProviderKeys[provider.name];
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
const apiKeyRequired = provider.api_key_required ?? true;
const apiKey = form.apiKey.trim();
const apiBase = form.apiBase.trim();
const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey;
const missingOptionalCredential =
!apiKeyRequired && !provider.configured && !apiKey && !apiBase;
return (
<div
key={provider.name}
@ -1045,7 +1062,7 @@ function ByokSettings({
size="sm"
variant="outline"
onClick={() => onSaveProvider(provider.name)}
disabled={saving || (!provider.configured && !form.apiKey.trim())}
disabled={saving || missingRequiredApiKey || missingOptionalCredential}
className="rounded-full"
>
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
@ -1188,6 +1205,25 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
);
}
function orderUnconfiguredProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
return providers
.map((provider, index) => ({ provider, index }))
.sort((left, right) => {
const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
return rank || left.index - right.index;
})
.map(({ provider }) => provider);
}
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
if (localRank !== undefined) return localRank;
if ((provider.api_key_required ?? true) === false) return 100;
return 200;
}
const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
qianfan: Database,
azure_openai: Cloud,
bedrock: Database,
vllm: Cpu,
ollama: Cpu,
lm_studio: Cpu,
atomic_chat: Cpu,
ovms: Cpu,
nvidia: Zap,
};
function ProviderIcon({ provider }: { provider: string }) {

View File

@ -1,13 +1,15 @@
import { useState } from "react";
import { ChevronRight, Layers } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import type { UIFileEdit, UIMessage } from "@/lib/types";
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
if (m.role !== "assistant" || m.kind === "trace") return false;
@ -19,14 +21,70 @@ export function isAgentActivityMember(m: UIMessage): boolean {
return isReasoningOnlyAssistant(m) || m.kind === "trace";
}
function countToolCalls(messages: UIMessage[]): number {
let n = 0;
interface ActivityCounts {
reasoningSteps: number;
toolCalls: number;
fileCount: number;
added: number;
deleted: number;
hasEditingFiles: boolean;
hasFailedFiles: boolean;
primaryFilePath?: string;
}
interface FileEditSummary {
key: string;
path: string;
added: number;
deleted: number;
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
error?: string;
}
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
let reasoningSteps = 0;
let toolCalls = 0;
for (const m of messages) {
if (m.kind !== "trace") continue;
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
n += Math.max(lines, 1);
if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1;
continue;
}
if (m.kind === "trace") {
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
toolCalls += lines;
}
}
return n;
let added = 0;
let deleted = 0;
let hasEditingFiles = false;
let failedFileCount = 0;
let primaryFilePath: string | undefined;
for (const edit of fileEdits) {
primaryFilePath = edit.path;
if (edit.status === "editing") {
hasEditingFiles = true;
}
if (edit.status === "error") {
failedFileCount += 1;
}
if (edit.status === "error" || edit.binary) {
continue;
}
added += edit.added;
deleted += edit.deleted;
}
return {
reasoningSteps,
toolCalls,
fileCount: fileEdits.length,
added,
deleted,
hasEditingFiles,
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
primaryFilePath,
};
}
interface AgentActivityClusterProps {
@ -46,24 +104,56 @@ export function AgentActivityCluster({
hasBodyBelow,
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
const toolCalls = countToolCalls(messages);
const fileEdits = useMemo(
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
[messages, isTurnStreaming],
);
const {
reasoningSteps,
toolCalls,
fileCount,
added,
deleted,
hasEditingFiles,
hasFailedFiles,
primaryFilePath,
} = countActivity(messages, fileEdits);
const [userToggledOuter, setUserToggledOuter] = useState(false);
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
const activityScrollRef = useRef<HTMLDivElement>(null);
const activityContentRef = useRef<HTMLDivElement>(null);
const autoFollowActivityRef = useRef(true);
const scrollFrameRef = useRef<number | null>(null);
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
const headerBusy = isTurnStreaming;
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
const summary =
isTurnStreaming
const fileActivitySummary = fileCount > 0
? fileCount === 1 && primaryFilePath
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
file: shortFileName(primaryFilePath),
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
})
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
count: fileCount,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`,
})
: "";
const summary = fileCount > 0
? fileActivitySummary
: isTurnStreaming
? reasoningSteps > 0
? t("message.agentActivityLiveSummary", {
reasoning: reasoningSteps,
tools: toolCalls,
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
})
: toolCalls === 0 && fileCount > 0
? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" })
: t("message.agentActivityLiveToolsOnly", {
tools: toolCalls,
defaultValue: "Working… · {{tools}} tool calls",
@ -74,16 +164,73 @@ export function AgentActivityCluster({
tools: toolCalls,
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
})
: toolCalls === 0 && fileCount > 0
? t("message.agentActivityFilesOnly", { defaultValue: "File changes" })
: t("message.agentActivityToolsOnly", {
tools: toolCalls,
defaultValue: "{{tools}} tool calls",
});
const cancelActivityScrollFrame = useCallback(() => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
scrollFrameRef.current = null;
}
}, []);
const scrollActivityToBottom = useCallback(() => {
const el = activityScrollRef.current;
if (!el) return;
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
}, []);
const scheduleActivityScrollToBottom = useCallback(() => {
cancelActivityScrollFrame();
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
scrollActivityToBottom();
});
}, [cancelActivityScrollFrame, scrollActivityToBottom]);
const toggleOuter = () => {
const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded;
if (nextOpen) {
autoFollowActivityRef.current = true;
}
setUserToggledOuter(true);
setOuterOpenLocal((v) => (userToggledOuter ? !v : !outerExpanded));
setOuterOpenLocal(nextOpen);
};
useLayoutEffect(() => {
if (!outerExpanded || !autoFollowActivityRef.current) return;
scheduleActivityScrollToBottom();
}, [outerExpanded, messages, isTurnStreaming, scheduleActivityScrollToBottom]);
useEffect(() => {
if (!outerExpanded) {
autoFollowActivityRef.current = true;
return;
}
const target = activityContentRef.current;
if (!target || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (autoFollowActivityRef.current) {
scheduleActivityScrollToBottom();
}
});
observer.observe(target);
return () => observer.disconnect();
}, [outerExpanded, scheduleActivityScrollToBottom]);
useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]);
const onActivityScroll = useCallback(() => {
const el = activityScrollRef.current;
if (!el) return;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
}, []);
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@ -96,12 +243,19 @@ export function AgentActivityCluster({
aria-expanded={outerExpanded}
>
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
<StreamingLabelSheen
active={headerBusy}
className="min-w-0 flex-1 text-left"
>
{summary}
</StreamingLabelSheen>
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
<StreamingLabelSheen
active={headerBusy}
className="min-w-0"
>
{summary}
</StreamingLabelSheen>
{fileCount > 0 && (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
<DiffPair added={added} deleted={deleted} />
</span>
)}
</span>
<ChevronRight
aria-hidden
className={cn(
@ -118,29 +272,38 @@ export function AgentActivityCluster({
)}
>
<div
ref={activityScrollRef}
data-testid="agent-activity-scroll"
onScroll={onActivityScroll}
className={cn(
CLUSTER_SCROLL_MAX_CLASS,
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
)}
>
<div className="flex flex-col gap-2">
<div ref={activityContentRef} className="flex flex-col gap-2">
{messages.map((m) => {
if (isReasoningOnlyAssistant(m)) {
return (
<ReasoningBubble
key={m.id}
text={m.reasoning ?? ""}
streaming={!!m.reasoningStreaming}
streaming={isTurnStreaming && !!m.reasoningStreaming}
hasBodyBelow={false}
embeddedInCluster
/>
);
}
if (m.kind === "trace") {
return <TraceGroup key={m.id} message={m} animClass="" />;
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
return hasTraceLines ? (
<div key={m.id} className="flex flex-col gap-1">
<TraceGroup message={m} animClass="" />
</div>
) : null;
}
return null;
})}
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
</div>
</div>
</div>
@ -148,3 +311,231 @@ export function AgentActivityCluster({
</div>
);
}
function shortFileName(path: string): string {
return path.split(/[\\/]/).pop() || path;
}
function fileActivityVerb(editing: boolean, failed: boolean): string {
if (failed) return "Failed";
return editing ? "Editing" : "Edited";
}
function fileActivitySummaryKey(editing: boolean, failed: boolean): string {
if (failed) return "message.fileActivityFailedOne";
return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne";
}
function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
if (failed) return "message.fileActivityFailedMany";
return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
}
function fileEditCallKey(edit: UIFileEdit): string {
return `${edit.call_id}|${edit.tool}|${edit.path}`;
}
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
const edits: UIFileEdit[] = [];
for (const message of messages) {
if (message.kind === "trace" && message.fileEdits?.length) {
edits.push(...message.fileEdits);
}
}
return edits;
}
function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
const order: string[] = [];
const byKey = new Map<string, UIFileEdit>();
for (const edit of edits) {
const key = fileEditCallKey(edit);
if (!byKey.has(key)) order.push(key);
byKey.set(key, edit);
}
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
}
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
interface MutableSummary {
key: string;
path: string;
added: number;
deleted: number;
approximate: boolean;
binary: boolean;
hasSuccessfulChange: boolean;
hasActiveEditing: boolean;
hasFailed: boolean;
error?: string;
}
const order: string[] = [];
const byPath = new Map<string, MutableSummary>();
for (const edit of latestFileEditEvents(edits)) {
const key = edit.path;
let summary = byPath.get(key);
if (!summary) {
summary = {
key,
path: edit.path,
added: 0,
deleted: 0,
approximate: false,
binary: false,
hasSuccessfulChange: false,
hasActiveEditing: false,
hasFailed: false,
};
byPath.set(key, summary);
order.push(key);
}
if (active && edit.status === "editing") {
summary.hasActiveEditing = true;
summary.binary = summary.binary || !!edit.binary;
summary.approximate = summary.approximate || !!edit.approximate;
if (!edit.binary) {
summary.added += edit.added;
summary.deleted += edit.deleted;
}
continue;
}
if (edit.status === "error") {
summary.hasFailed = true;
summary.error = edit.error ?? summary.error;
continue;
}
summary.hasSuccessfulChange = true;
summary.binary = summary.binary || !!edit.binary;
summary.approximate = active && (summary.approximate || !!edit.approximate);
if (!edit.binary) {
summary.added += edit.added;
summary.deleted += edit.deleted;
}
}
return order.map((key) => {
const summary = byPath.get(key)!;
const status: UIFileEdit["status"] = summary.hasActiveEditing
? "editing"
: summary.hasSuccessfulChange
? "done"
: summary.hasFailed
? "error"
: "done";
return {
key: summary.key,
path: summary.path,
added: summary.added,
deleted: summary.deleted,
approximate: summary.approximate,
binary: summary.binary,
status,
error: summary.error,
};
});
}
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1 border-l border-muted-foreground/15 pl-3">
{edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} />
))}
</ul>
);
}
function FileEditRow({ edit }: { edit: FileEditSummary }) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary;
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
<div className="flex min-w-0 items-center gap-2">
<FileReferenceChip
path={edit.path}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
{failed ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
<AlertCircle className="h-3 w-3" aria-hidden />
{t("message.fileEditFailed", { defaultValue: "Failed" })}
</span>
) : null}
{edit.approximate && !failed ? (
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
</span>
) : null}
</div>
{hasCountedDiff ? (
<DiffPair added={edit.added} deleted={edit.deleted} />
) : null}
</li>
);
}
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 tabular-nums">
<span className="text-emerald-600/75 dark:text-emerald-300/75">
+<AnimatedNumber value={added} />
</span>
<span className="text-rose-600/70 dark:text-rose-300/75">
-<AnimatedNumber value={deleted} />
</span>
</span>
);
}
function AnimatedNumber({ value }: { value: number }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
const [display, setDisplay] = useState(0);
const displayRef = useRef(0);
const setAnimatedDisplay = useCallback((next: number) => {
displayRef.current = next;
setDisplay(next);
}, []);
useEffect(() => {
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setAnimatedDisplay(safeValue);
return;
}
const start = displayRef.current;
const delta = safeValue - start;
if (delta === 0) {
setAnimatedDisplay(safeValue);
return;
}
const duration = 260;
const startedAt = performance.now();
let frame = 0;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedDisplay(Math.round(start + delta * eased));
if (progress < 1) {
frame = window.requestAnimationFrame(tick);
return;
}
displayRef.current = safeValue;
};
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]);
return <>{display}</>;
}

View File

@ -1,3 +1,6 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble";
import {
AgentActivityCluster,
@ -9,6 +12,8 @@ interface ThreadMessagesProps {
messages: UIMessage[];
/** When true, agent turn still in flight — keeps activity cluster expanded. */
isStreaming?: boolean;
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
}
export type DisplayUnit =
@ -30,31 +35,160 @@ export function isFinalAssistantSliceBeforeNextUser(
return true;
}
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
const out: DisplayUnit[] = [];
let i = 0;
while (i < messages.length) {
const m = messages[i];
if (isAgentActivityMember(m)) {
const cluster: UIMessage[] = [];
while (i < messages.length && isAgentActivityMember(messages[i])) {
cluster.push(messages[i]);
let segmentId: string | undefined = m.activitySegmentId;
let clusterHasFileEdits = hasFileEdits(m);
while (
i < messages.length
&& isAgentActivityMember(messages[i])
&& canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i])
) {
const current = messages[i];
if (!segmentId && current.activitySegmentId) {
segmentId = current.activitySegmentId;
}
clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current);
cluster.push(current);
i += 1;
}
out.push({ type: "cluster", messages: cluster });
continue;
}
const previous = out[out.length - 1];
if (
previous?.type === "cluster"
&& assistantHasInlineReasoning(m)
&& canFoldInlineReasoning(previous.messages, m)
) {
previous.messages.push(reasoningOnlyMessageFromAnswer(m));
out.push({ type: "single", message: stripInlineReasoning(m) });
i += 1;
continue;
}
if (assistantHasInlineReasoning(m)) {
out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] });
out.push({ type: "single", message: stripInlineReasoning(m) });
i += 1;
continue;
}
out.push({ type: "single", message: m });
i += 1;
}
return out;
}
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
const units = buildDisplayUnits(messages);
function clusterSegmentId(messages: UIMessage[]): string | undefined {
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
}
function hasFileEdits(message: UIMessage): boolean {
return !!message.fileEdits?.length;
}
function clusterHasFileEdits(messages: UIMessage[]): boolean {
return messages.some(hasFileEdits);
}
function canJoinActivityCluster(
clusterSegmentId: string | undefined,
clusterIncludesFileEdits: boolean,
message: UIMessage,
): boolean {
const messageHasFileEdits = hasFileEdits(message);
if (!clusterIncludesFileEdits && !messageHasFileEdits) return true;
if (!clusterSegmentId || !message.activitySegmentId) return true;
return clusterSegmentId === message.activitySegmentId;
}
function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean {
if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true;
const segmentId = clusterSegmentId(cluster);
if (!segmentId || !message.activitySegmentId) return true;
return segmentId === message.activitySegmentId;
}
function assistantHasInlineReasoning(message: UIMessage): boolean {
return (
message.role === "assistant"
&& message.kind !== "trace"
&& message.content.trim().length > 0
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
);
}
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
return {
id: `${message.id}-reasoning`,
role: "assistant",
content: "",
createdAt: message.createdAt,
reasoning: message.reasoning,
reasoningStreaming: message.reasoningStreaming,
isStreaming: message.reasoningStreaming,
activitySegmentId: message.activitySegmentId,
};
}
function stripInlineReasoning(message: UIMessage): UIMessage {
const next = { ...message };
delete next.reasoning;
delete next.reasoningStreaming;
return next;
}
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
const flags = new Array<boolean>(units.length).fill(true);
let hasLaterUnitBeforeUser = false;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "single" && unit.message.role === "user") {
hasLaterUnitBeforeUser = false;
continue;
}
if (unit.type === "single" && unit.message.role === "assistant") {
flags[i] = !hasLaterUnitBeforeUser;
}
hasLaterUnitBeforeUser = true;
}
return flags;
}
export function ThreadMessages({
messages,
isStreaming = false,
hiddenMessageCount = 0,
onLoadEarlier,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndex = useMemo(
() => isStreaming ? currentActivityClusterIndex(units) : -1,
[isStreaming, units],
);
return (
<div className="flex w-full flex-col">
{hiddenMessageCount > 0 && onLoadEarlier ? (
<div className="mb-4 flex justify-center">
<button
type="button"
onClick={onLoadEarlier}
className="rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
>
{t("thread.loadEarlier", {
count: hiddenMessageCount,
defaultValue: "Load earlier messages",
})}
</button>
</div>
) : null}
{units.map((unit, index) => {
const prev = units[index - 1];
const marginTop =
@ -72,7 +206,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
{unit.type === "cluster" ? (
<AgentActivityCluster
messages={unit.messages}
isTurnStreaming={isStreaming}
isTurnStreaming={index === liveActivityClusterIndex}
hasBodyBelow={hasBodyBelow}
/>
) : (
@ -80,7 +214,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
message={unit.message}
showAssistantCopyAction={
unit.message.role === "assistant"
? isFinalAssistantSliceBeforeNextUser(units, index)
? copyFlags[index]
: true
}
/>
@ -92,6 +226,11 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
);
}
function currentActivityClusterIndex(units: DisplayUnit[]): number {
const last = units.length - 1;
return units[last]?.type === "cluster" ? last : -1;
}
function unitKey(unit: DisplayUnit, index: number): string {
if (unit.type === "cluster") {
const anchor = unit.messages[0]?.id;

View File

@ -167,8 +167,9 @@ export function ThreadShell({
useEffect(() => {
if (!chatId) return;
return client.onSessionUpdate((updatedChatId) => {
return client.onSessionUpdate((updatedChatId, scope) => {
if (updatedChatId !== chatId) return;
if (scope === "metadata") return;
pendingCanonicalHydrateRef.current.add(chatId);
refreshHistory();
});
@ -389,6 +390,7 @@ export function ThreadShell({
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
/>
</section>
);

View File

@ -1,8 +1,17 @@
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
@ -14,9 +23,27 @@ interface ThreadViewportProps {
emptyState?: ReactNode;
scrollToBottomSignal?: number;
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
}
const NEAR_BOTTOM_PX = 48;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
if (messages.length <= visibleCount) return messages;
let start = Math.max(0, messages.length - visibleCount);
while (
start > 0
&& isAgentActivityMember(messages[start])
&& isAgentActivityMember(messages[start - 1])
) {
start -= 1;
}
return messages.slice(start);
}
export function ThreadViewport({
messages,
@ -25,18 +52,33 @@ export function ThreadViewport({
emptyState,
scrollToBottomSignal = 0,
conversationKey = null,
showScrollToBottomButton = true,
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const scrollFrameIdsRef = useRef<number[]>([]);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
const userReadingHistoryRef = useRef(false);
const [atBottom, setAtBottom] = useState(true);
const [composerDockHeight, setComposerDockHeight] = useState(0);
const [visibleMessageCount, setVisibleMessageCount] =
useState(INITIAL_HISTORY_WINDOW);
const hasMessages = messages.length > 0;
const visibleMessages = useMemo(
() => windowMessages(messages, visibleMessageCount),
[messages, visibleMessageCount],
);
const hiddenMessageCount = messages.length - visibleMessages.length;
const scrollButtonBottom = composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
const cancelScheduledBottomScroll = useCallback(() => {
for (const id of scrollFrameIdsRef.current) {
@ -77,6 +119,30 @@ export function ThreadViewport({
[cancelScheduledBottomScroll, scrollToBottomNow],
);
const loadEarlierMessages = useCallback(() => {
const el = scrollRef.current;
if (el) {
restoreScrollAfterPrependRef.current = {
height: el.scrollHeight,
top: el.scrollTop,
};
}
userReadingHistoryRef.current = true;
setAtBottom(false);
setVisibleMessageCount((count) =>
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
);
}, [messages.length]);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
const height = el.getBoundingClientRect().height || el.offsetHeight;
setComposerDockHeight((current) =>
Math.abs(current - height) < 1 ? current : height,
);
}, []);
useEffect(() => {
if (!atBottom) return;
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
@ -96,8 +162,19 @@ export function ThreadViewport({
pendingConversationScrollRef.current = true;
userReadingHistoryRef.current = false;
setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
useLayoutEffect(() => {
const pending = restoreScrollAfterPrependRef.current;
if (!pending) return;
const el = scrollRef.current;
restoreScrollAfterPrependRef.current = null;
if (!el) return;
const delta = el.scrollHeight - pending.height;
el.scrollTop = pending.top + delta;
}, [visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationKey) {
@ -110,6 +187,10 @@ export function ThreadViewport({
pendingConversationScrollRef.current = false;
}, [conversationKey, hasMessages, messages, scrollToBottom]);
useLayoutEffect(() => {
measureComposerDock();
}, [composer, hasMessages, measureComposerDock]);
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
useEffect(() => {
@ -123,6 +204,14 @@ export function ThreadViewport({
return () => observer.disconnect();
}, [hasMessages, scrollToBottom]);
useEffect(() => {
const target = composerDockRef.current;
if (!target || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => measureComposerDock());
observer.observe(target);
return () => observer.disconnect();
}, [hasMessages, measureComposerDock]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
@ -155,11 +244,20 @@ export function ThreadViewport({
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div className="flex-1 px-4 pb-20 pt-4">
<div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages messages={messages} isStreaming={isStreaming} />
<ThreadMessages
messages={visibleMessages}
isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
/>
</div>
</div>
<div className="sticky bottom-0 z-10 mt-auto bg-background">
<div
ref={composerDockRef}
data-testid="thread-composer-dock"
className="sticky bottom-0 z-10 mt-auto bg-background"
>
<div className="px-4 pb-3">
{composer}
</div>
@ -183,17 +281,18 @@ export function ThreadViewport({
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
{!atBottom && (
{showScrollToBottomButton && !atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
"absolute bottom-48 left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
style={{ bottom: scrollButtonBottom }}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />

View File

@ -11,15 +11,17 @@ const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
className,
)}
{...props}
/>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;

View File

@ -117,53 +117,60 @@
--cjk-line-height: 1.625;
}
/* LR sheen over solid label text (overlay stripe). Avoids ``background-clip:
text`` loop seams that read as RTL erase or one-frame transparent glyphs. */
@keyframes reasoning-sheen-ltr {
/* LR sheen clipped to live activity labels. The highlight lives inside
the glyphs, not in the row background, so dark mode stays quiet. */
@keyframes streaming-text-sheen-ltr {
0% {
left: -44%;
background-position: 140% 50%;
}
100% {
left: 118%;
background-position: -40% 50%;
}
}
.reasoning-sheen-track {
.streaming-text-sheen {
position: relative;
color: hsl(var(--muted-foreground));
}
.streaming-text-sheen::after {
content: attr(data-sheen-text);
position: absolute;
inset: 0;
z-index: 1;
display: block;
overflow: hidden;
border-radius: 2px;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
}
.reasoning-sheen-stripe {
position: absolute;
top: 0;
bottom: 0;
width: 44%;
min-width: 3.25rem;
left: -44%;
border-radius: inherit;
color: transparent;
background: linear-gradient(
90deg,
transparent 0%,
hsl(0 0% 100% / 0.07) 34%,
hsl(0 0% 100% / 0.76) 50%,
hsl(0 0% 100% / 0.07) 66%,
transparent 38%,
hsl(var(--foreground) / 0.98) 50%,
transparent 62%,
transparent 100%
);
mix-blend-mode: soft-light;
opacity: 0.95;
animation: reasoning-sheen-ltr 5.2s linear infinite;
background-size: 260% 100%;
background-position: 140% 50%;
background-repeat: no-repeat;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: streaming-text-sheen-ltr 2.8s ease-in-out infinite;
}
.dark .reasoning-sheen-stripe {
mix-blend-mode: overlay;
opacity: 1;
.dark .streaming-text-sheen::after {
background-image: linear-gradient(
90deg,
transparent 0%,
transparent 38%,
hsl(var(--foreground) / 0.98) 50%,
transparent 62%,
transparent 100%
);
}
@media (prefers-reduced-motion: reduce) {
.reasoning-sheen-stripe {
.streaming-text-sheen::after {
animation: none;
opacity: 0;
visibility: hidden;
content: "";
}
}

View File

@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef } from "react";
import type { ChatSummary } from "@/lib/types";
const TITLE_REFRESH_RETRY_DELAYS_MS = [1_000, 3_000, 7_000] as const;
function hasGeneratedTitle(session: ChatSummary | null): boolean {
return !!session?.title?.trim();
}
/**
* The server generates WebUI titles after the main turn has already ended.
* Refresh once immediately, then retry lightly for untitled sessions so the
* async title appears even if the websocket metadata notification is delayed.
*/
export function useDeferredTitleRefresh(
activeSession: ChatSummary | null,
refresh: () => Promise<void>,
retryDelaysMs: readonly number[] = TITLE_REFRESH_RETRY_DELAYS_MS,
): () => void {
const activeSessionRef = useRef(activeSession);
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
activeSessionRef.current = activeSession;
const clearTimers = useCallback(() => {
for (const timer of timersRef.current) {
clearTimeout(timer);
}
timersRef.current = [];
}, []);
useEffect(() => clearTimers, [clearTimers]);
useEffect(() => {
clearTimers();
}, [activeSession?.key, clearTimers]);
useEffect(() => {
if (hasGeneratedTitle(activeSession)) {
clearTimers();
}
}, [activeSession, clearTimers]);
return useCallback(() => {
void refresh();
const sessionAtTurnEnd = activeSessionRef.current;
if (!sessionAtTurnEnd || hasGeneratedTitle(sessionAtTurnEnd)) {
return;
}
clearTimers();
for (const delayMs of retryDelaysMs) {
const timer = setTimeout(() => {
const latest = activeSessionRef.current;
if (
!latest ||
latest.key !== sessionAtTurnEnd.key ||
hasGeneratedTitle(latest)
) {
return;
}
void refresh();
}, delayMs);
timersRef.current.push(timer);
}
}, [clearTimers, refresh, retryDelaysMs]);
}

View File

@ -10,6 +10,7 @@ import type {
OutboundMedia,
GoalStateWsPayload,
UIImage,
UIFileEdit,
UIMessage,
} from "@/lib/types";
@ -18,12 +19,26 @@ interface StreamBuffer {
messageId: string;
}
/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */
function findStreamingAssistantId(prev: UIMessage[]): string | null {
interface ActiveAssistantCursor {
id: string;
index: number;
}
type PendingStreamEvent =
| { kind: "delta"; text: string }
| { kind: "reasoning"; text: string };
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
* as streaming until ``turn_end`` for visual continuity, but they must not
* receive later delta segments. */
function findStreamingAssistantIndex(
prev: UIMessage[],
closedStreamIds: ReadonlySet<string>,
): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.kind === "trace") continue;
if (m.role === "assistant" && m.isStreaming) return m.id;
if (m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id)) return i;
if (m.role === "user") break;
}
return null;
@ -38,7 +53,13 @@ function findStreamingAssistantId(prev: UIMessage[]): string | null {
* 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[], chunk: string): UIMessage[] {
function attachReasoningChunk(
prev: UIMessage[],
chunk: string,
segments?: {
ensure: () => string;
},
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
// A user turn is a hard boundary: reasoning after it belongs to the new
@ -49,6 +70,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
// that produced those tool calls.
if (candidate.kind === "trace") break;
if (candidate.role !== "assistant") continue;
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
const hasAnswer = candidate.content.length > 0;
if (
candidate.reasoningStreaming
@ -60,6 +82,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
...candidate,
reasoning: (candidate.reasoning ?? "") + chunk,
reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}),
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
@ -68,11 +91,13 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
...candidate,
reasoning: chunk,
reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}),
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
break;
}
const activitySegmentId = segments?.ensure();
return [
...prev,
{
@ -82,6 +107,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
isStreaming: true,
reasoning: chunk,
reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}),
createdAt: Date.now(),
},
];
@ -95,13 +121,19 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
* the model already produced an answer in a previous turn, so the new
* delta belongs in a fresh row.
*/
function findActiveAssistantPlaceholder(prev: UIMessage[]): string | null {
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
const last = prev[prev.length - 1];
if (!last) return null;
if (last.role !== "assistant" || last.kind === "trace") return null;
if (last.content.length > 0) return null;
if (!last.isStreaming) return null;
return last.id;
return prev.length - 1;
}
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
const next = prev.slice();
next[index] = message;
return next;
}
/**
@ -182,6 +214,47 @@ function absorbCompleteAssistantMessage(
];
}
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
return `${edit.call_id}|${edit.tool}|${edit.path}`;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.path || !edit.tool) return null;
const inferredStatus =
edit.phase === "error"
? "error"
: edit.phase === "end"
? "done"
: "editing";
return {
...edit,
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
? edit.status
: inferredStatus,
};
}
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
const next = [...(existing ?? [])];
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
for (const raw of incoming) {
const edit = normalizeFileEdit(raw);
if (!edit) continue;
const key = fileEditKey(edit);
const existingIndex = indexByKey.get(key);
if (existingIndex === undefined) {
indexByKey.set(key, next.length);
next.push(edit);
continue;
}
next[existingIndex] = { ...next[existingIndex], ...edit };
}
return next;
}
/**
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
* a streaming flag, and a ``send`` function. Initial history must be seeded
@ -239,6 +312,13 @@ export function useNanobotStream(
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null);
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
const closedAssistantStreamIdsRef = useRef<Set<string>>(new Set());
const activitySegmentRef = useRef<string | null>(null);
const fileEditSegmentRef = useRef<string | null>(null);
const activitySegmentCounterRef = useRef(0);
const pendingStreamEventsRef = useRef<PendingStreamEvent[]>([]);
const streamFrameRef = useRef<number | null>(null);
const suppressStreamUntilTurnEndRef = useRef(false);
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
*
@ -255,6 +335,159 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []);
const clearPendingStreamWork = useCallback(() => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
pendingStreamEventsRef.current = [];
}, []);
const createActivitySegmentId = useCallback((activate = true) => {
activitySegmentCounterRef.current += 1;
const id = `activity-${activitySegmentCounterRef.current}`;
if (activate) activitySegmentRef.current = id;
return id;
}, []);
const freshActivitySegmentId = useCallback(
() => createActivitySegmentId(true),
[createActivitySegmentId],
);
const detachedActivitySegmentId = useCallback(
() => createActivitySegmentId(false),
[createActivitySegmentId],
);
const ensureActivitySegmentId = useCallback(() => {
if (activitySegmentRef.current) return activitySegmentRef.current;
return freshActivitySegmentId();
}, [freshActivitySegmentId]);
const clearActivitySegment = useCallback(() => {
activitySegmentRef.current = null;
fileEditSegmentRef.current = null;
}, []);
const closeActiveAssistantStream = useCallback(() => {
const closedStreamId = buffer.current?.messageId ?? activeAssistantRef.current?.id;
if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId);
buffer.current = null;
activeAssistantRef.current = null;
}, []);
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
const cursor = activeAssistantRef.current;
if (!cursor) return null;
const indexed = prev[cursor.index];
if (indexed?.id === cursor.id && indexed.role === "assistant" && indexed.kind !== "trace") {
return cursor.index;
}
const idx = prev.findIndex((m) => m.id === cursor.id);
if (idx === -1) {
activeAssistantRef.current = null;
return null;
}
const found = prev[idx];
if (found.role !== "assistant" || found.kind === "trace") {
activeAssistantRef.current = null;
return null;
}
activeAssistantRef.current = { id: cursor.id, index: idx };
return idx;
}, []);
const appendAnswerChunk = useCallback(
(prev: UIMessage[], chunk: string): UIMessage[] => {
let next = prev;
let targetIndex = resolveActiveAssistantIndex(next);
if (targetIndex === null) {
targetIndex = findActiveAssistantPlaceholderIndex(next);
}
if (targetIndex === null) {
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
}
if (targetIndex === null) {
const id = crypto.randomUUID();
next = [
...next,
{
id,
role: "assistant",
content: "",
isStreaming: true,
createdAt: Date.now(),
},
];
targetIndex = next.length - 1;
}
const target = next[targetIndex];
const merged: UIMessage = {
...target,
content: target.content + chunk,
isStreaming: true,
};
closedAssistantStreamIdsRef.current.delete(merged.id);
activeAssistantRef.current = { id: merged.id, index: targetIndex };
buffer.current = { messageId: merged.id };
return replaceMessageAt(next, targetIndex, merged);
},
[resolveActiveAssistantIndex],
);
const applyPendingStreamEvents = useCallback(
(prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => {
let next = prev;
for (let i = 0; i < events.length;) {
const kind = events[i].kind;
let text = "";
while (i < events.length && events[i].kind === kind) {
text += events[i].text;
i += 1;
}
next = kind === "delta"
? appendAnswerChunk(next, text)
: attachReasoningChunk(next, text, {
ensure: ensureActivitySegmentId,
});
}
return next;
},
[appendAnswerChunk, ensureActivitySegmentId],
);
const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
const events = pendingStreamEventsRef.current;
if (events.length === 0) {
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return;
}
pendingStreamEventsRef.current = [];
setMessages((prev) => {
const next = applyPendingStreamEvents(prev, events);
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return next;
});
}, [applyPendingStreamEvents, closeActiveAssistantStream]);
const schedulePendingStreamFlush = useCallback(() => {
if (streamFrameRef.current !== null) return;
streamFrameRef.current = window.requestAnimationFrame(() => {
streamFrameRef.current = null;
const events = pendingStreamEventsRef.current;
if (events.length === 0) return;
pendingStreamEventsRef.current = [];
setMessages((prev) => applyPendingStreamEvents(prev, events));
});
}, [applyPendingStreamEvents]);
// Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered.
@ -269,13 +502,17 @@ export function useNanobotStream(
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
clearActivitySegment();
clearPendingStreamWork();
suppressStreamUntilTurnEndRef.current = false;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId, client]);
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
useEffect(() => {
if (hasPendingToolCalls) setIsStreaming(true);
@ -296,54 +533,10 @@ export function useNanobotStream(
if (ev.event === "delta") {
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = typeof ev.text === "string" ? ev.text : "";
if (!chunk) return;
setIsStreaming(true);
setMessages((prev) => {
const adopted = findActiveAssistantPlaceholder(prev);
const streamingAssistId = findStreamingAssistantId(prev);
let targetId: string;
let next: UIMessage[];
if (adopted) {
targetId = adopted;
next = prev;
} else if (streamingAssistId) {
targetId = streamingAssistId;
next = prev;
} else {
targetId = crypto.randomUUID();
next = [
...prev,
{
id: targetId,
role: "assistant",
content: "",
isStreaming: true,
createdAt: Date.now(),
},
];
}
buffer.current = { messageId: targetId };
const priorContent = next.find((m) => m.id === targetId)?.content ?? "";
const combined = priorContent + chunk;
return next.map((m) =>
m.id === targetId ? { ...m, content: combined, isStreaming: true } : m,
);
});
return;
}
if (ev.event === "stream_end") {
if (suppressStreamUntilTurnEndRef.current) {
buffer.current = null;
return;
}
// stream_end only means the text segment finished — the model may
// still be executing tools. Do NOT reset isStreaming here; the
// definitive "turn is complete" signal is ``turn_end``.
if (!buffer.current) return;
buffer.current = null;
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
schedulePendingStreamFlush();
return;
}
@ -351,11 +544,23 @@ export function useNanobotStream(
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = ev.text;
if (!chunk) return;
setMessages((prev) => attachReasoningChunk(prev, chunk));
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
schedulePendingStreamFlush();
return;
}
if (ev.event === "stream_end") {
flushPendingStreamEvents({ closeAnswerSegment: true });
if (suppressStreamUntilTurnEndRef.current) return;
// stream_end only means the text segment finished — the model may
// still be executing tools. Do NOT reset isStreaming here; the
// definitive "turn is complete" signal is ``turn_end``.
return;
}
flushPendingStreamEvents();
if (ev.event === "reasoning_end") {
if (suppressStreamUntilTurnEndRef.current) return;
setMessages((prev) => closeReasoningStream(prev));
@ -393,6 +598,10 @@ export function useNanobotStream(
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
}
buffer.current = null;
activeAssistantRef.current = null;
clearActivitySegment();
closedAssistantStreamIdsRef.current.clear();
return finalized;
});
suppressStreamUntilTurnEndRef.current = false;
@ -413,7 +622,9 @@ export function useNanobotStream(
if (ev.kind === "reasoning") {
const line = ev.text;
if (!line) return;
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line)));
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
ensure: ensureActivitySegmentId,
})));
return;
}
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
@ -428,12 +639,24 @@ export function useNanobotStream(
: [];
if (lines.length === 0) return;
setMessages((prev) => {
const segmentId = ensureActivitySegmentId();
const last = prev[prev.length - 1];
if (last && last.kind === "trace" && !last.isStreaming) {
if (
last
&& last.kind === "trace"
&& !last.isStreaming
&& (!last.activitySegmentId || last.activitySegmentId === segmentId)
) {
const previousTraces = last.traces?.length
? last.traces
: last.content
? [last.content]
: [];
const merged: UIMessage = {
...last,
traces: [...(last.traces ?? [last.content]), ...lines],
traces: [...previousTraces, ...lines],
content: lines[lines.length - 1],
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];
}
@ -445,6 +668,7 @@ export function useNanobotStream(
kind: "trace",
content: lines[lines.length - 1],
traces: lines,
activitySegmentId: segmentId,
createdAt: Date.now(),
},
];
@ -459,11 +683,12 @@ export function useNanobotStream(
// A complete (non-streamed) assistant message. If a stream was in
// flight, drop the placeholder so we don't render the text twice.
const activeId = buffer.current?.messageId;
buffer.current = null;
// Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete.
setMessages((prev) => {
const activeId = buffer.current?.messageId;
buffer.current = null;
activeAssistantRef.current = null;
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
const content = ev.text;
const lat =
@ -481,6 +706,46 @@ export function useNanobotStream(
}
return;
}
if (ev.event === "file_edit") {
const edits = Array.isArray(ev.edits) ? ev.edits : [];
if (edits.length === 0) return;
setMessages((prev) => {
const last = prev[prev.length - 1];
let segmentId = fileEditSegmentRef.current;
if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) {
segmentId = detachedActivitySegmentId();
fileEditSegmentRef.current = segmentId;
}
if (
last
&& last.kind === "trace"
&& !last.isStreaming
&& !!last.fileEdits?.length
&& last.activitySegmentId === segmentId
) {
const merged: UIMessage = {
...last,
fileEdits: mergeFileEdits(last.fileEdits, edits),
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];
}
return [
...prev,
{
id: crypto.randomUUID(),
role: "tool",
kind: "trace",
content: "",
traces: [],
fileEdits: mergeFileEdits(undefined, edits),
activitySegmentId: segmentId,
createdAt: Date.now(),
},
];
});
return;
}
// ``attached`` / ``error`` frames aren't actionable here; the client
// shell handles them separately.
};
@ -489,12 +754,26 @@ export function useNanobotStream(
return () => {
unsub();
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
clearActivitySegment();
clearPendingStreamWork();
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
};
}, [chatId, client, onTurnEnd]);
}, [
chatId,
client,
clearActivitySegment,
clearPendingStreamWork,
detachedActivitySegmentId,
ensureActivitySegmentId,
flushPendingStreamEvents,
onTurnEnd,
schedulePendingStreamFlush,
]);
const send = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
@ -504,17 +783,24 @@ export function useNanobotStream(
// the image blocks via ``media`` paths.
if (!hasImages && !content.trim()) return;
flushPendingStreamEvents();
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => [
...pruneReasoningOnlyPlaceholders(prev),
{
id: crypto.randomUUID(),
role: "user",
content,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
},
]);
setMessages((prev) => {
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
clearActivitySegment();
return [
...pruneReasoningOnlyPlaceholders(prev),
{
id: crypto.randomUUID(),
role: "user",
content,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
},
];
});
// Mark streaming immediately so the UI shows the loading indicator
// right away, before the first delta arrives from the server.
setIsStreaming(true);
@ -525,18 +811,23 @@ export function useNanobotStream(
client.sendMessage(chatId, content, wireMedia);
}
},
[chatId, client],
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
);
const stop = useCallback(() => {
if (!chatId) return;
flushPendingStreamEvents();
setIsStreaming(false);
setMessages((prev) =>
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
setMessages((prev) => {
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
clearActivitySegment();
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
});
suppressStreamUntilTurnEndRef.current = false;
client.sendMessage(chatId, "/stop");
}, [chatId, client]);
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
return {
messages,

View File

@ -1,7 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
type Theme = "light" | "dark";
const STORAGE_KEY = "nanobot-webui.theme";
const ThemeContext = createContext<Theme>("light");
function readStored(): Theme | null {
try {
@ -18,7 +27,11 @@ function applyTheme(theme: Theme): void {
else root.classList.remove("dark");
}
export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Theme) => void } {
export function useTheme(): {
theme: Theme;
toggle: () => void;
setTheme: (t: Theme) => void;
} {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = readStored();
if (stored) return stored;
@ -46,3 +59,11 @@ export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Th
);
return { theme, toggle, setTheme };
}
export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
return createElement(ThemeContext.Provider, { value: theme }, children);
}
export function useThemeValue(): Theme {
return useContext(ThemeContext);
}

View File

@ -335,7 +335,8 @@
"io": "Couldn't read this file"
}
},
"scrollToBottom": "Scroll to bottom"
"scrollToBottom": "Scroll to bottom",
"loadEarlier": "Load earlier messages"
},
"message": {
"streaming": "streaming",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "Cerrar objetivo"
},
"scrollToBottom": "Desplazarse al final"
"scrollToBottom": "Desplazarse al final",
"loadEarlier": "Cargar mensajes anteriores"
},
"message": {
"streaming": "transmitiendo",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "Fermer lobjectif"
},
"scrollToBottom": "Faire défiler vers le bas"
"scrollToBottom": "Faire défiler vers le bas",
"loadEarlier": "Charger les messages précédents"
},
"message": {
"streaming": "en cours de génération",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "Tutup tujuan"
},
"scrollToBottom": "Gulir ke bawah"
"scrollToBottom": "Gulir ke bawah",
"loadEarlier": "Muat pesan sebelumnya"
},
"message": {
"streaming": "sedang mengalir",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "目標を閉じる"
},
"scrollToBottom": "一番下へスクロール"
"scrollToBottom": "一番下へスクロール",
"loadEarlier": "以前のメッセージを読み込む"
},
"message": {
"streaming": "生成中",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "목표 닫기"
},
"scrollToBottom": "맨 아래로 스크롤"
"scrollToBottom": "맨 아래로 스크롤",
"loadEarlier": "이전 메시지 불러오기"
},
"message": {
"streaming": "생성 중",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "Đóng mục tiêu"
},
"scrollToBottom": "Cuộn xuống cuối"
"scrollToBottom": "Cuộn xuống cuối",
"loadEarlier": "Tải tin nhắn trước đó"
},
"message": {
"streaming": "đang truyền",

View File

@ -323,7 +323,8 @@
},
"goalStateCloseAria": "关闭目标"
},
"scrollToBottom": "滚动到底部"
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息"
},
"message": {
"streaming": "流式输出中",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "關閉目標"
},
"scrollToBottom": "捲動到底部"
"scrollToBottom": "捲動到底部",
"loadEarlier": "載入更早訊息"
},
"message": {
"streaming": "串流輸出中",

View File

@ -1,10 +1,35 @@
import i18n, { currentLocale } from "@/i18n";
const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
"hi",
"hello",
"hey",
"hello nano",
"hello nanobot",
"hi nano",
"hi nanobot",
"你好",
"您好",
"嗨",
"哈喽",
"哈啰",
"在吗",
]);
function isLowInformationTitlePreview(text: string): boolean {
const normalized = text.toLowerCase().replace(/[.!?。!?~\s]+$/g, "").trim();
return (
normalized.startsWith("/") ||
LOW_INFORMATION_TITLE_PREVIEWS.has(normalized)
);
}
/** Truncate the first user message into a chat title. */
export function deriveTitle(preview: string | undefined, fallback: string): string {
if (!preview) return fallback;
const oneLine = preview.replace(/\s+/g, " ").trim();
if (!oneLine) return fallback;
if (isLowInformationTitlePreview(oneLine)) return fallback;
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}` : oneLine;
}

View File

@ -54,7 +54,8 @@ type Unsubscribe = () => void;
type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateHandler = (chatId: string) => void;
type SessionUpdateScope = "metadata" | "thread" | string;
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
/** Structured connection-level errors surfaced to the UI.
*
@ -364,7 +365,7 @@ export class NanobotClient {
}
if (parsed.event === "session_updated") {
this.emitSessionUpdate(parsed.chat_id);
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
return;
}
@ -382,9 +383,9 @@ export class NanobotClient {
}
}
private emitSessionUpdate(chatId: string): void {
private emitSessionUpdate(chatId: string, scope?: SessionUpdateScope): void {
for (const handler of this.sessionUpdateHandlers) {
handler(chatId);
handler(chatId, scope);
}
}

View File

@ -40,6 +40,10 @@ export interface UIMessage {
/** For trace rows: each individual hint line, so consecutive hints can
* render as a single collapsible group. */
traces?: string[];
/** Activity rows: explicit file edits emitted by edit tools. */
fileEdits?: UIFileEdit[];
/** Activity rows created during the same agent phase share one collapsible block. */
activitySegmentId?: string;
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
images?: UIImage[];
/** Signed or local UI-renderable media attachments. */
@ -80,6 +84,20 @@ export interface ToolProgressEvent {
embeds?: unknown[];
}
export interface UIFileEdit {
version?: number;
call_id: string;
tool: string;
path: string;
phase?: "start" | "end" | "error" | string;
added: number;
deleted: number;
approximate?: boolean;
status: "editing" | "done" | "error";
binary?: boolean;
error?: string;
}
export interface ChatSummary {
/** Server-side session key, e.g. ``websocket:abcd-...``. */
key: string;
@ -110,6 +128,7 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
api_key_required?: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
@ -182,6 +201,11 @@ export type InboundEvent =
/** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob;
}
| {
event: "file_edit";
chat_id: string;
edits: UIFileEdit[];
}
| {
event: "delta";
chat_id: string;
@ -229,7 +253,7 @@ export type InboundEvent =
chat_id: string;
goal_state: GoalStateWsPayload;
}
| { event: "session_updated"; chat_id: string }
| { event: "session_updated"; chat_id: string; scope?: "metadata" | "thread" | string }
| { event: "error"; chat_id?: string; detail?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.

View File

@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

View File

@ -0,0 +1,336 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import type { UIMessage } from "@/lib/types";
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: `thinking${extraReasoning}`,
reasoningStreaming: true,
isStreaming: true,
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "search()",
traces: ["search()"],
createdAt: 2,
},
];
if (extraTool) rows.push(extraTool);
return rows;
}
function installAnimationFrameQueue() {
const originalRequest = window.requestAnimationFrame;
const originalCancel = window.cancelAnimationFrame;
const callbacks = new Map<number, FrameRequestCallback>();
let nextId = 1;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
const id = nextId;
nextId += 1;
callbacks.set(id, callback);
return id;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => {
callbacks.delete(id);
}) as typeof window.cancelAnimationFrame;
return {
flush() {
const pending = Array.from(callbacks.entries());
callbacks.clear();
for (const [, callback] of pending) callback(0);
},
restore() {
window.requestAnimationFrame = originalRequest;
window.cancelAnimationFrame = originalCancel;
},
};
}
function setScrollGeometry(
element: HTMLElement,
geometry: { scrollHeight: number; clientHeight: number; scrollTop?: number },
) {
Object.defineProperties(element, {
scrollHeight: { configurable: true, value: geometry.scrollHeight },
clientHeight: { configurable: true, value: geometry.clientHeight },
scrollTop: {
configurable: true,
value: geometry.scrollTop ?? element.scrollTop,
writable: true,
},
});
}
function installReducedMotion() {
const original = window.matchMedia;
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
addEventListener: () => {},
removeEventListener: () => {},
}),
});
return () => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: original,
});
};
}
describe("AgentActivityCluster", () => {
it("jumps to the latest activity when opened", () => {
const raf = installAnimationFrameQueue();
try {
render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(880);
} finally {
raf.restore();
}
});
it("follows new reasoning and tool activity while the user is at the bottom", () => {
const raf = installAnimationFrameQueue();
try {
const { rerender } = render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
rerender(
<AgentActivityCluster
messages={activityMessages(" with more detail", {
id: "t2",
role: "tool",
kind: "trace",
content: "open_browser()",
traces: ["open_browser()"],
createdAt: 3,
})}
isTurnStreaming
hasBodyBelow={false}
/>,
);
setScrollGeometry(scrollport, {
scrollHeight: 1500,
clientHeight: 120,
scrollTop: scrollport.scrollTop,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(1380);
} finally {
raf.restore();
}
});
it("does not pull the user down after they scroll up inside the activity pane", () => {
const raf = installAnimationFrameQueue();
try {
const { rerender } = render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
scrollport.scrollTop = 100;
fireEvent.scroll(scrollport);
rerender(
<AgentActivityCluster
messages={activityMessages(" still streaming")}
isTurnStreaming
hasBodyBelow={false}
/>,
);
setScrollGeometry(scrollport, {
scrollHeight: 1500,
clientHeight: 120,
scrollTop: scrollport.scrollTop,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(100);
} finally {
raf.restore();
}
});
it("renders file edit totals and a compact expanded file list", async () => {
const restoreMotion = installReducedMotion();
try {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 12,
deleted: 3,
approximate: false,
status: "done",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
expect(screen.queryByText("Edited")).not.toBeInTheDocument();
const fileRef = screen.getByTestId("activity-file-reference");
expect(fileRef).toHaveTextContent("src/app.tsx");
expect(fileRef).toHaveAttribute("aria-label", "src/app.tsx");
await waitFor(() => {
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
});
} finally {
restoreMotion();
}
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [
{
call_id: "call-edit-1",
tool: "edit_file",
path: "minecraft-fps/index.html",
phase: "end",
added: 2,
deleted: 1,
approximate: false,
status: "done",
},
{
call_id: "call-edit-2",
tool: "edit_file",
path: "minecraft-fps/index.html",
phase: "error",
added: 0,
deleted: 0,
approximate: false,
status: "error",
error: "patch failed",
},
{
call_id: "call-edit-3",
tool: "edit_file",
path: "minecraft-fps/index.html",
phase: "end",
added: 6,
deleted: 6,
approximate: false,
status: "done",
},
],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i }));
const fileRefs = screen.getAllByTestId("activity-file-reference");
expect(fileRefs).toHaveLength(1);
expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html");
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByText("+8").length).toBeGreaterThan(0);
expect(screen.getAllByText("-7").length).toBeGreaterThan(0);
});
} finally {
restoreMotion();
}
});
});

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
@ -8,6 +8,7 @@ const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => {
@ -32,12 +33,18 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
};
});
vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({
theme: "light" as const,
toggle: toggleThemeSpy,
}),
}));
vi.mock("@/hooks/useTheme", async () => {
const React = await import("react");
return {
ThemeProvider: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
useTheme: () => ({
theme: "light" as const,
toggle: toggleThemeSpy,
}),
useThemeValue: () => "light" as const,
};
});
vi.mock("@/lib/bootstrap", () => ({
fetchBootstrap: vi.fn().mockResolvedValue({
@ -64,22 +71,30 @@ vi.mock("@/lib/nanobot-client", () => {
newChat = vi.fn();
attach = vi.fn();
close = vi.fn();
updateUrl = vi.fn();
updateUrl = updateUrlSpy;
}
return { NanobotClient: MockClient };
});
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import App from "@/App";
describe("App layout", () => {
beforeEach(() => {
mockSessions = [];
connectSpy.mockClear();
updateUrlSpy.mockClear();
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
toggleThemeSpy.mockReset();
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
ws_path: "/",
expires_in: 300,
});
vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
@ -89,6 +104,10 @@ describe("App layout", () => {
);
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps sidebar layout out of the main thread width contract", async () => {
const { container } = render(<App />);
@ -192,8 +211,52 @@ describe("App layout", () => {
name: "openrouter",
label: "OpenRouter",
configured: false,
api_key_required: true,
default_api_base: "https://openrouter.ai/api/v1",
},
{
name: "azure_openai",
label: "Azure OpenAI",
configured: false,
api_key_required: true,
},
{
name: "huggingface",
label: "Hugging Face",
configured: false,
api_key_required: true,
},
{
name: "siliconflow",
label: "SiliconFlow",
configured: false,
api_key_required: true,
},
{
name: "volcengine",
label: "VolcEngine",
configured: false,
api_key_required: true,
},
{
name: "byteplus",
label: "BytePlus",
configured: false,
api_key_required: true,
},
{
name: "qianfan",
label: "Qianfan",
configured: false,
api_key_required: true,
},
{
name: "atomic_chat",
label: "Atomic Chat",
configured: false,
api_key_required: false,
default_api_base: "http://localhost:1337/v1",
},
],
web_search: {
provider: "brave",
@ -248,6 +311,9 @@ describe("App layout", () => {
fireEvent.click(screen.getByText("OpenAI"));
expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Atomic Chat"));
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
expect(screen.getByText("Search provider")).toBeInTheDocument();
@ -426,4 +492,36 @@ describe("App layout", () => {
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
});
it("refreshes the bootstrap token before REST settings auth expires", async () => {
vi.useFakeTimers();
vi.mocked(fetchBootstrap)
.mockResolvedValueOnce({
token: "tok-1",
ws_path: "/",
expires_in: 30,
})
.mockResolvedValueOnce({
token: "tok-2",
ws_path: "/",
expires_in: 300,
});
vi.mocked(deriveWsUrl).mockImplementation(
(_wsPath: string, token: string) => `ws://test?token=${token}`,
);
const { unmount } = render(<App />);
await act(async () => {});
expect(connectSpy).toHaveBeenCalled();
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});
expect(fetchBootstrap).toHaveBeenCalledTimes(2);
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
unmount();
});
});

View File

@ -0,0 +1,104 @@
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CodeBlock } from "@/components/CodeBlock";
import { ThemeProvider } from "@/hooks/useTheme";
const mockedStyles = vi.hoisted(() => ({
dark: { pre: { background: "#111" } },
light: { pre: { background: "#fff" } },
}));
vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => ({
default: ({
children,
style,
}: {
children: string;
style: Record<string, unknown>;
}) => (
<pre
data-testid="highlighted-code"
data-theme={style === mockedStyles.dark ? "dark" : "light"}
>
<code>{children}</code>
</pre>
),
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-dark", () => ({
default: mockedStyles.dark,
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
default: mockedStyles.light,
}));
describe("CodeBlock", () => {
it("renders plain code without mounting the highlighter when highlighting is disabled", () => {
render(
<ThemeProvider theme="dark">
<CodeBlock language="ts" code="const value = 1;" highlight={false} />
</ThemeProvider>,
);
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
expect(screen.getByText("ts")).toBeInTheDocument();
});
it("reads theme from context without creating per-block observers", async () => {
const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn();
class MockMutationObserver {
constructor(callback: MutationCallback) {
observer(callback);
}
observe = vi.fn();
disconnect = vi.fn();
takeRecords() {
return [];
}
}
vi.stubGlobal("MutationObserver", MockMutationObserver);
try {
const { rerender } = render(
<ThemeProvider theme="dark">
<CodeBlock language="ts" code="const value = 1;" />
</ThemeProvider>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
"data-theme",
"dark",
);
rerender(
<ThemeProvider theme="light">
<CodeBlock language="ts" code="const value = 1;" />
</ThemeProvider>,
);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
"data-theme",
"light",
);
expect(observer).not.toHaveBeenCalled();
} finally {
vi.stubGlobal("MutationObserver", originalMutationObserver);
}
});
});

View File

@ -0,0 +1,82 @@
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MarkdownText } from "@/components/MarkdownText";
const rendererSpy = vi.hoisted(() => vi.fn());
vi.mock("@/components/MarkdownTextRenderer", () => ({
default: ({
children,
highlightCode,
}: {
children: string;
highlightCode?: boolean;
}) => {
rendererSpy({ children, highlightCode });
return (
<div
data-testid="markdown-renderer"
data-highlight-code={String(highlightCode)}
>
{children}
</div>
);
},
}));
describe("MarkdownText", () => {
it("throttles streaming markdown commits and flushes before final highlighting", async () => {
rendererSpy.mockClear();
vi.useFakeTimers();
try {
const { rerender } = render(
<MarkdownText streaming>hello</MarkdownText>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"false",
);
expect(rendererSpy).toHaveBeenCalledTimes(1);
rerender(<MarkdownText streaming>hello world</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(79);
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(1);
});
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
expect(rendererSpy).toHaveBeenCalledTimes(2);
rerender(<MarkdownText streaming>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
rerender(<MarkdownText>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"true",
);
} finally {
vi.useRealTimers();
}
});
});

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
@ -131,7 +131,9 @@ describe("MessageBubble", () => {
expect(screen.getByText("Thinking…")).toBeInTheDocument();
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
expect(container.querySelector(".reasoning-sheen-stripe")).toBeInTheDocument();
expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument();
expect(screen.getByText("Thinking…")).toHaveClass("streaming-text-sheen");
expect(screen.getByText("Thinking…")).toHaveAttribute("data-sheen-text", "Thinking…");
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
});
@ -177,6 +179,47 @@ describe("MessageBubble", () => {
expect(screen.getByText("Body line.")).toBeInTheDocument();
});
it("renders inline file paths as compact file references", async () => {
await import("@/components/MarkdownTextRenderer");
const message: UIMessage = {
id: "a-file-path",
role: "assistant",
content:
"改动在 `webui/src/components/MarkdownTextRenderer.tsx` 和 `/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html`。",
createdAt: Date.now(),
};
try {
render(<MessageBubble message={message} />);
const references = await screen.findAllByTestId("inline-file-path");
expect(references).toHaveLength(2);
expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]");
expect(references[0].parentElement).toHaveClass("align-[0.14em]");
expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx");
expect(references[0]).not.toHaveTextContent("webui/src/components");
expect(screen.getByText("index.html")).toBeInTheDocument();
expect(references[1]).not.toHaveTextContent("/Users/renxubin");
expect(references[1]).not.toHaveAttribute("title");
expect(references[1]).toHaveAttribute(
"aria-label",
"/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
);
vi.useFakeTimers();
fireEvent.pointerMove(references[1].parentElement!);
await act(async () => {
vi.advanceTimersByTime(500);
});
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent(
"/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
);
} finally {
vi.useRealTimers();
}
});
it("renders assistant image media as a larger generated result", () => {
const message: UIMessage = {
id: "a-image",

View File

@ -233,9 +233,13 @@ describe("NanobotClient", () => {
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({ event: "session_updated", chat_id: "chat-title" });
lastSocket().fakeMessage({
event: "session_updated",
chat_id: "chat-title",
scope: "metadata",
});
expect(globalHandler).toHaveBeenCalledWith("chat-title");
expect(globalHandler).toHaveBeenCalledWith("chat-title", "metadata");
expect(chatHandler).not.toHaveBeenCalled();
});

View File

@ -1,7 +1,11 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import {
assistantCopyFlags,
buildDisplayUnits,
ThreadMessages,
} from "@/components/thread/ThreadMessages";
import type { UIMessage } from "@/lib/types";
describe("ThreadMessages", () => {
@ -51,6 +55,206 @@ describe("ThreadMessages", () => {
expect(rows[1]).toHaveClass("mt-4");
});
it("starts a new activity cluster when the activity segment changes", () => {
const messages: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: "first pass",
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "foo.txt",
phase: "end",
added: 2,
deleted: 1,
status: "done",
}],
activitySegmentId: "seg-1",
createdAt: 2,
},
{
id: "r2",
role: "assistant",
content: "",
reasoning: "second pass",
activitySegmentId: "seg-2",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([
"r2",
]);
});
it("does not split ordinary tool activity just because segment ids changed", () => {
const messages: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: "first pass",
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "read_file()",
traces: ["read_file()"],
activitySegmentId: "seg-1",
createdAt: 2,
},
{
id: "r2",
role: "assistant",
content: "",
reasoning: "second pass",
activitySegmentId: "seg-2",
createdAt: 3,
},
{
id: "t2",
role: "tool",
kind: "trace",
content: "grep()",
traces: ["grep()"],
activitySegmentId: "seg-2",
createdAt: 4,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(1);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
"r2",
"t2",
]);
});
it("only marks the current activity cluster as live while streaming", () => {
const messages: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: "first pass",
reasoningStreaming: true,
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "foo.txt",
phase: "start",
added: 4,
deleted: 1,
approximate: true,
status: "editing",
}],
activitySegmentId: "seg-1",
createdAt: 2,
},
{
id: "r2",
role: "assistant",
content: "",
reasoning: "second pass",
reasoningStreaming: true,
activitySegmentId: "seg-2",
createdAt: 3,
},
];
render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument();
});
it("folds final answer reasoning into the preceding activity cluster", () => {
const messages: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: "search plan",
reasoningStreaming: false,
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "web_search()",
traces: ["web_search()"],
createdAt: 2,
},
{
id: "a1",
role: "assistant",
content: "final answer",
reasoning: "summarize results",
reasoningStreaming: false,
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0]).toMatchObject({ type: "cluster" });
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
"a1-reasoning",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "a1",
content: "final answer",
},
});
if (units[1].type === "single") {
expect(units[1].message).not.toHaveProperty("reasoning");
}
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument();
expect(screen.getByText("final answer")).toBeInTheDocument();
});
it("shows copy only on the last assistant slice before the next user turn", () => {
const messages: UIMessage[] = [
{
@ -89,4 +293,37 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
});
it("computes final assistant copy flags with user-boundary semantics", () => {
const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", createdAt: 1 },
{ id: "a1", role: "assistant", content: "draft", createdAt: 2 },
{
id: "t1",
role: "tool",
kind: "trace",
content: "tool()",
traces: ["tool()"],
createdAt: 3,
},
{ id: "a2", role: "assistant", content: "final", createdAt: 4 },
{ id: "u2", role: "user", content: "two", createdAt: 5 },
{ id: "a3", role: "assistant", content: "next", createdAt: 6 },
]);
const flags = assistantCopyFlags(units);
const assistantFlags = units
.map((unit, index) =>
unit.type === "single" && unit.message.role === "assistant"
? [unit.message.id, flags[index]]
: null,
)
.filter(Boolean);
expect(assistantFlags).toEqual([
["a1", false],
["a2", true],
["a3", true],
]);
});
});

View File

@ -8,7 +8,7 @@ import type { UIMessage } from "@/lib/types";
function makeClient() {
const errorHandlers = new Set<(err: { kind: string }) => void>();
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
return {
status: "open" as const,
@ -34,7 +34,7 @@ function makeClient() {
errorHandlers.delete(handler);
};
},
onSessionUpdate: (handler: (chatId: string) => void) => {
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
sessionUpdateHandlers.add(handler);
return () => {
sessionUpdateHandlers.delete(handler);
@ -49,8 +49,8 @@ function makeClient() {
}
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
},
_emitSessionUpdate(chatId: string) {
for (const h of sessionUpdateHandlers) h(chatId);
_emitSessionUpdate(chatId: string, scope?: string) {
for (const h of sessionUpdateHandlers) h(chatId, scope);
},
sendMessage: vi.fn(),
newChat: vi.fn(),
@ -651,6 +651,52 @@ describe("ThreadShell", () => {
expect(historyCalls).toBe(1);
});
it("does not refetch thread history for metadata-only session updates", async () => {
const client = makeClient();
let historyCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/webui-thread")) {
historyCalls += 1;
return httpJson(
transcriptFromSimpleMessages([
{ role: "user", content: "question" },
{ role: "assistant", content: "answer" },
]),
);
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
expect(historyCalls).toBe(1);
await act(async () => {
client._emitSessionUpdate("chat-a", "metadata");
});
expect(historyCalls).toBe(1);
});
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
const client = makeClient();
const scrollIntoView = vi.fn();

View File

@ -1,7 +1,12 @@
import { act, render, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import {
HISTORY_WINDOW_INCREMENT,
INITIAL_HISTORY_WINDOW,
ThreadViewport,
windowMessages,
} from "@/components/thread/ThreadViewport";
import type { UIMessage } from "@/lib/types";
const messages: UIMessage[] = [
@ -15,7 +20,191 @@ const messages: UIMessage[] = [
const emptyMessages: UIMessage[] = [];
interface ResizeObserverInstance {
element?: Element;
callback: ResizeObserverCallback;
disconnect: ReturnType<typeof vi.fn>;
}
function makeLongMessages(count: number): UIMessage[] {
return Array.from({ length: count }, (_, index) => ({
id: `m${index}`,
role: "user" as const,
content: `message ${index}`,
createdAt: index,
}));
}
describe("ThreadViewport", () => {
it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const resizeObservers: ResizeObserverInstance[] = [];
class MockResizeObserver {
element?: Element;
callback: ResizeObserverCallback;
disconnect = vi.fn();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
resizeObservers.push(this);
}
observe(element: Element) {
this.element = element;
}
}
vi.stubGlobal("ResizeObserver", MockResizeObserver);
try {
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div>composer</div>}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
const button = screen.getByRole("button", { name: "Scroll to bottom" });
expect(button).toHaveStyle({ bottom: "192px" });
const composerDock = screen.getByTestId("thread-composer-dock");
composerDock.getBoundingClientRect = () =>
({
height: 240,
width: 800,
top: 0,
right: 800,
bottom: 240,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
const composerObserver = resizeObservers.find(
(observer) => observer.element === composerDock,
);
expect(composerObserver).toBeDefined();
act(() => {
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
});
expect(button).toHaveStyle({ bottom: "256px" });
} finally {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
}
});
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
const { container } = render(
<ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div>composer</div>}
emptyState={<div>welcome</div>}
showScrollToBottomButton={false}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
});
it("renders only the tail window for long history by default", () => {
const longMessages = makeLongMessages(300);
render(
<ThreadViewport
messages={longMessages}
isStreaming={false}
composer={<div />}
/>,
);
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
expect(screen.getByText("message 140")).toBeInTheDocument();
expect(screen.getByText("message 299")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Load earlier messages" })).toBeInTheDocument();
});
it("loads earlier history in fixed increments without rendering the whole transcript", () => {
const longMessages = makeLongMessages(300);
render(
<ThreadViewport
messages={longMessages}
isStreaming={false}
composer={<div />}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Load earlier messages" }));
const firstVisible =
300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT;
expect(
screen.queryByText(`message ${firstVisible - 1}`),
).not.toBeInTheDocument();
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
expect(screen.getByText("message 299")).toBeInTheDocument();
});
it("expands the window start to avoid cutting an agent activity cluster", () => {
const clustered = makeLongMessages(200);
clustered.splice(
38,
3,
{
id: "r0",
role: "assistant",
content: "",
reasoning: "first reasoning",
createdAt: 38,
},
{
id: "t0",
role: "tool",
kind: "trace",
content: "tool()",
traces: ["tool()"],
createdAt: 39,
},
{
id: "r1",
role: "assistant",
content: "",
reasoning: "second reasoning",
createdAt: 40,
},
);
const visible = windowMessages(clustered, INITIAL_HISTORY_WINDOW);
expect(visible[0].id).toBe("r0");
expect(visible).toHaveLength(INITIAL_HISTORY_WINDOW + 2);
});
it("resets to the bottom when opening a different conversation", async () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;

View File

@ -0,0 +1,110 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial<ChatSummary> = {}): ChatSummary {
return {
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: null,
updatedAt: null,
title: "",
preview: "First user message",
...overrides,
};
}
describe("useDeferredTitleRefresh", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("retries refreshing untitled sessions after turn_end", () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useDeferredTitleRefresh(session(), refresh, [100, 300]),
);
act(() => {
result.current();
});
expect(refresh).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(100);
});
expect(refresh).toHaveBeenCalledTimes(2);
act(() => {
vi.advanceTimersByTime(200);
});
expect(refresh).toHaveBeenCalledTimes(3);
});
it("stops pending retries once a generated title arrives", () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const { result, rerender } = renderHook(
({ activeSession }) =>
useDeferredTitleRefresh(activeSession, refresh, [100, 300]),
{ initialProps: { activeSession: session() } },
);
act(() => {
result.current();
});
rerender({ activeSession: session({ title: "Generated title" }) });
act(() => {
vi.advanceTimersByTime(300);
});
expect(refresh).toHaveBeenCalledTimes(1);
});
it("does not retry when the active session already has a title", () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useDeferredTitleRefresh(session({ title: "Existing title" }), refresh, [100]),
);
act(() => {
result.current();
vi.advanceTimersByTime(100);
});
expect(refresh).toHaveBeenCalledTimes(1);
});
it("clears pending retries when the active chat changes", () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const { result, rerender } = renderHook(
({ activeSession }) =>
useDeferredTitleRefresh(activeSession, refresh, [100]),
{ initialProps: { activeSession: session() } },
);
act(() => {
result.current();
});
rerender({
activeSession: session({
key: "websocket:chat-b",
chatId: "chat-b",
}),
});
act(() => {
vi.advanceTimersByTime(100);
});
expect(refresh).toHaveBeenCalledTimes(1);
});
});

View File

@ -83,7 +83,112 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
};
}
async function flushStreamFrame() {
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
});
}
describe("useNanobotStream", () => {
it("batches answer deltas into one animation-frame update", async () => {
const fake = fakeClient();
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
const { result } = renderHook(() => useNanobotStream("chat-batch", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-batch", {
event: "delta",
chat_id: "chat-batch",
text: "Hello",
});
fake.emit("chat-batch", {
event: "delta",
chat_id: "chat-batch",
text: " world",
});
});
expect(requestFrame).toHaveBeenCalledTimes(1);
expect(result.current.messages).toHaveLength(0);
await flushStreamFrame();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Hello world",
isStreaming: true,
});
requestFrame.mockRestore();
});
it("flushes pending delta text before turn_end finalizes the turn", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-flush", {
event: "delta",
chat_id: "chat-flush",
text: "final chunk",
});
fake.emit("chat-flush", {
event: "turn_end",
chat_id: "chat-flush",
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "final chunk",
isStreaming: false,
});
expect(result.current.isStreaming).toBe(false);
});
it("drops pending stream work when switching chats", async () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES),
{
wrapper: wrap(fake.client),
initialProps: { chatId: "chat-old" },
},
);
act(() => {
fake.emit("chat-old", {
event: "delta",
chat_id: "chat-old",
text: "stale",
});
});
rerender({ chatId: "chat-new" });
act(() => {
fake.emit("chat-new", {
event: "delta",
chat_id: "chat-new",
text: "fresh",
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "fresh",
});
});
it("starts in streaming mode when history shows pending tool calls", () => {
const fake = fakeClient();
const initialMessages = [{
@ -203,7 +308,174 @@ describe("useNanobotStream", () => {
);
});
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
it("renders live file_edit events as their own activity trace", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-edit", {
event: "message",
chat_id: "chat-file-edit",
text: 'write_file({"path":"foo.txt"})',
kind: "tool_hint",
});
fake.emit("chat-file-edit", {
event: "file_edit",
chat_id: "chat-file-edit",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
phase: "start",
added: 1,
deleted: 0,
approximate: true,
status: "editing",
}],
});
fake.emit("chat-file-edit", {
event: "file_edit",
chat_id: "chat-file-edit",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
phase: "end",
added: 3,
deleted: 1,
approximate: false,
status: "done",
}],
});
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toMatchObject({
role: "tool",
kind: "trace",
traces: ['write_file({"path":"foo.txt"})'],
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
kind: "trace",
fileEdits: [{
call_id: "call-write",
status: "done",
added: 3,
deleted: 1,
approximate: false,
}],
});
expect(result.current.messages[1].activitySegmentId).toBeTruthy();
expect(result.current.messages[1].activitySegmentId).not.toBe(
result.current.messages[0].activitySegmentId,
);
});
it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-stream-segments", {
event: "delta",
chat_id: "chat-stream-segments",
text: "I created the files.",
});
fake.emit("chat-stream-segments", {
event: "stream_end",
chat_id: "chat-stream-segments",
});
fake.emit("chat-stream-segments", {
event: "message",
chat_id: "chat-stream-segments",
text: 'write_file({"path":"minecraft-fps/options.txt"})',
kind: "tool_hint",
});
fake.emit("chat-stream-segments", {
event: "delta",
chat_id: "chat-stream-segments",
text: "Now I will summarize the edits.",
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "I created the files.",
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
kind: "trace",
traces: ['write_file({"path":"minecraft-fps/options.txt"})'],
});
expect(result.current.messages[2]).toMatchObject({
role: "assistant",
content: "Now I will summarize the edits.",
});
});
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), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-segments", {
event: "reasoning_delta",
chat_id: "chat-file-segments",
text: "Plan.",
});
fake.emit("chat-file-segments", {
event: "reasoning_end",
chat_id: "chat-file-segments",
});
fake.emit("chat-file-segments", {
event: "message",
chat_id: "chat-file-segments",
text: 'edit_file({"path":"foo.txt"})',
kind: "tool_hint",
});
fake.emit("chat-file-segments", {
event: "file_edit",
chat_id: "chat-file-segments",
edits: [{
call_id: "call-edit",
tool: "edit_file",
path: "foo.txt",
phase: "start",
added: 1,
deleted: 1,
approximate: true,
status: "editing",
}],
});
fake.emit("chat-file-segments", {
event: "reasoning_delta",
chat_id: "chat-file-segments",
text: "Review result.",
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(4);
const firstSegment = result.current.messages[0].activitySegmentId;
expect(firstSegment).toBeTruthy();
expect(result.current.messages[1].activitySegmentId).toBe(firstSegment);
expect(result.current.messages[2].activitySegmentId).toBeTruthy();
expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment);
expect(result.current.messages[3].activitySegmentId).toBe(firstSegment);
});
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@ -222,6 +494,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe("assistant");
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
@ -328,7 +602,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false);
});
it("does not attach a new turn's reasoning across the latest user boundary", () => {
it("does not attach a new turn's reasoning across the latest user boundary", async () => {
const fake = fakeClient();
const initialMessages = [
{
@ -358,6 +632,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
expect(result.current.messages[2].role).toBe("assistant");
@ -366,7 +642,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[2].reasoningStreaming).toBe(true);
});
it("does not attach reasoning across a tool trace boundary", () => {
it("does not attach reasoning across a tool trace boundary", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@ -392,6 +668,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
"message",
@ -651,7 +929,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].content).toBe("long task");
});
it("keeps streaming alive across stream_end and completes on turn_end", () => {
it("keeps streaming alive across stream_end and completes on turn_end", async () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
@ -666,6 +944,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",

View File

@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSessionHistory, useSessions } from "@/hooks/useSessions";
import { sessionTitle, useSessionHistory, useSessions } from "@/hooks/useSessions";
import * as api from "@/lib/api";
import { ClientProvider } from "@/providers/ClientProvider";
@ -17,7 +17,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
});
function fakeClient() {
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
return {
status: "open" as const,
defaultChatId: null as string | null,
@ -25,12 +25,12 @@ function fakeClient() {
onError: () => () => {},
onChat: () => () => {},
getRunStartedAt: () => null,
onSessionUpdate: (handler: (chatId: string) => void) => {
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
sessionUpdateHandlers.add(handler);
return () => sessionUpdateHandlers.delete(handler);
},
emitSessionUpdate: (chatId: string) => {
for (const handler of sessionUpdateHandlers) handler(chatId);
emitSessionUpdate: (chatId: string, scope?: string) => {
for (const handler of sessionUpdateHandlers) handler(chatId, scope);
},
sendMessage: vi.fn(),
newChat: vi.fn(),
@ -61,6 +61,28 @@ describe("useSessions", () => {
vi.mocked(api.fetchWebuiThread).mockReset();
});
it("does not use low-information greetings as fallback session titles", () => {
expect(sessionTitle({
key: "websocket:chat-hi",
channel: "websocket",
chatId: "chat-hi",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
title: "",
preview: "hi",
})).toBe("New chat");
expect(sessionTitle({
key: "websocket:chat-work",
channel: "websocket",
chatId: "chat-work",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
title: "",
preview: "帮我优化 WebUI 性能",
})).toBe("帮我优化 WebUI 性能");
});
it("removes a session from the local list after delete succeeds", async () => {
vi.mocked(api.listSessions).mockResolvedValue([
{

View File

@ -0,0 +1,22 @@
declare module "react-syntax-highlighter/dist/esm/prism-async-light" {
import * as React from "react";
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
export default class SyntaxHighlighter extends React.Component<SyntaxHighlighterProps> {
static registerLanguage(name: string, func: unknown): void;
}
}
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-dark" {
import type * as React from "react";
const style: { [key: string]: React.CSSProperties };
export default style;
}
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-light" {
import type * as React from "react";
const style: { [key: string]: React.CSSProperties };
export default style;
}

View File

@ -1,7 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"]
"types": ["node", "vite/client"]
},
"exclude": ["src/tests/**"]
}

View File

@ -25,6 +25,36 @@ export default defineConfig(({ mode }) => {
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
emptyOutDir: true,
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("node_modules/refractor/lang/")) {
return;
}
if (
id.includes("node_modules/react-syntax-highlighter")
|| id.includes("node_modules/refractor/core")
) {
return "syntax-highlight";
}
if (
id.includes("node_modules/react-markdown")
|| id.includes("node_modules/remark-")
|| id.includes("node_modules/rehype-")
|| id.includes("node_modules/unified")
|| id.includes("node_modules/mdast-")
|| id.includes("node_modules/hast-")
|| id.includes("node_modules/micromark")
|| id.includes("node_modules/unist-")
) {
return "markdown-vendor";
}
if (id.includes("node_modules/katex")) {
return "katex";
}
},
},
},
},
server: {
host: "127.0.0.1",