diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b6172a29e..67d95e1ca 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 3b86d61b6..484abf295 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index ccc854fa6..d4e5db46c 100644 --- a/README.md +++ b/README.md @@ -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.

nanobot webui preview @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 21beb1c6f..1d87092f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: restart: unless-stopped ports: - 18790:18790 + - 8765:8765 deploy: resources: limits: diff --git a/docs/README.md b/docs/README.md index 56b8dab2f..7ac873bd1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | diff --git a/docs/configuration.md b/docs/configuration.md index 338991a33..b5d74f7ca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. | diff --git a/docs/deployment.md b/docs/deployment.md index 746c35218..8a2cd89eb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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!" diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 000000000..28dbcd09a --- /dev/null +++ b/hatch_build.py @@ -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 diff --git a/nanobot/__init__.py b/nanobot/__init__.py index e6fdbf0ba..8ab213a33 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -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() diff --git a/nanobot/agent/autocompact.py b/nanobot/agent/autocompact.py index 11e531039..4ad241170 100644 --- a/nanobot/agent/autocompact.py +++ b/nanobot/agent/autocompact.py @@ -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: diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index bc807092e..c1f521170 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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, diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index fd233bfa3..ffc9c5f0e 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -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 diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 56482f75b..776885ecb 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -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: diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 26e00ff6a..0202bd33d 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -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 ") diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index edc7339da..cedc03bd0 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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, diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 96c97c088..9f5fc0a88 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -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"), diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index b41ee7a1e..55d26cf11 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -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") diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 4dba0c46d..e6f022187 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -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, ), diff --git a/nanobot/utils/file_edit_events.py b/nanobot/utils/file_edit_events.py new file mode 100644 index 000000000..8164aa18d --- /dev/null +++ b/nanobot/utils/file_edit_events.py @@ -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 diff --git a/nanobot/utils/llm_runtime.py b/nanobot/utils/llm_runtime.py new file mode 100644 index 000000000..a74f0d8c0 --- /dev/null +++ b/nanobot/utils/llm_runtime.py @@ -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 diff --git a/nanobot/utils/progress_events.py b/nanobot/utils/progress_events.py index 10a282b99..ccf125ec4 100644 --- a/nanobot/utils/progress_events.py +++ b/nanobot/utils/progress_events.py @@ -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, diff --git a/nanobot/utils/webui_titles.py b/nanobot/utils/webui_titles.py deleted file mode 100644 index 2d363f926..000000000 --- a/nanobot/utils/webui_titles.py +++ /dev/null @@ -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, - ) diff --git a/nanobot/utils/webui_transcript.py b/nanobot/utils/webui_transcript.py index dde0e9168..bee71c542 100644 --- a/nanobot/utils/webui_transcript.py +++ b/nanobot/utils/webui_transcript.py @@ -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} diff --git a/nanobot/utils/webui_turn_helpers.py b/nanobot/utils/webui_turn_helpers.py index 3fbca3729..6a3ac2ba0 100644 --- a/nanobot/utils/webui_turn_helpers.py +++ b/nanobot/utils/webui_turn_helpers.py @@ -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()) diff --git a/nanobot/web/__init__.py b/nanobot/web/__init__.py index 7a08932f6..36ee3e934 100644 --- a/nanobot/web/__init__.py +++ b/nanobot/web/__init__.py @@ -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``). """ diff --git a/pyproject.toml b/pyproject.toml index 16ed57dd2..eaf57a2ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/agent/test_auto_compact.py b/tests/agent/test_auto_compact.py index 0bc02a694..37fcbfdae 100644 --- a/tests/agent/test_auto_compact.py +++ b/tests/agent/test_auto_compact.py @@ -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 diff --git a/tests/agent/test_autocompact_unit.py b/tests/agent/test_autocompact_unit.py index d501770dd..1d3277a01 100644 --- a/tests/agent/test_autocompact_unit.py +++ b/tests/agent/test_autocompact_unit.py @@ -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 diff --git a/tests/agent/test_consolidator.py b/tests/agent/test_consolidator.py index 64ef9a886..1fa05d3c8 100644 --- a/tests/agent/test_consolidator.py +++ b/tests/agent/test_consolidator.py @@ -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.""" diff --git a/tests/agent/test_heartbeat_service.py b/tests/agent/test_heartbeat_service.py index 8f563cff4..fe7b54256 100644 --- a/tests/agent/test_heartbeat_service.py +++ b/tests/agent/test_heartbeat_service.py @@ -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"] - diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index fcf6198c1..43a691437 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -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() diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index ed78e7192..9814c386d 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -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") diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index f192cacee..11a284bb5 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -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" diff --git a/tests/agent/test_runtime_refresh.py b/tests/agent/test_runtime_refresh.py index a6b19a9d8..b36b1899b 100644 --- a/tests/agent/test_runtime_refresh.py +++ b/tests/agent/test_runtime_refresh.py @@ -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 diff --git a/tests/agent/test_unified_session.py b/tests/agent/test_unified_session.py index 839f62f57..f22290ba6 100644 --- a/tests/agent/test_unified_session.py +++ b/tests/agent/test_unified_session.py @@ -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")) diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 9b481e251..c6f9d66a3 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -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" diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 90c2ce877..2778ddbbb 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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() diff --git a/tests/cli/test_interactive_retry_wait.py b/tests/cli/test_interactive_retry_wait.py index 52c27d2c9..5eeb2c128 100644 --- a/tests/cli/test_interactive_retry_wait.py +++ b/tests/cli/test_interactive_retry_wait.py @@ -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.""" diff --git a/tests/utils/test_file_edit_events.py b/tests/utils/test_file_edit_events.py new file mode 100644 index 000000000..6176a5e36 --- /dev/null +++ b/tests/utils/test_file_edit_events.py @@ -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 diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 419abbfcd..f13380f46 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -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 diff --git a/webui/README.md b/webui/README.md index b99874ba0..8538bc1ed 100644 --- a/webui/README.md +++ b/webui/README.md @@ -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://: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://: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 diff --git a/webui/bun.lock b/webui/bun.lock index e71f2dc54..a539068bf 100644 --- a/webui/bun.lock +++ b/webui/bun.lock @@ -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=="], diff --git a/webui/package-lock.json b/webui/package-lock.json index 2ee7152a9..471d08bba 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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, diff --git a/webui/package.json b/webui/package.json index ee666f056..7a3d02a32 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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" diff --git a/webui/src/App.tsx b/webui/src/App.tsx index e8dc0722c..7ff9bae20 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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({ 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 ( -

- {/* Desktop sidebar: in normal flow, so the thread area width stays honest. */} - {showMainSidebar ? ( - - ) : null} - - {showMainSidebar ? ( - setMobileSidebarOpen(open)} - > - - - - - ) : null} - -
-
- -
- {view === "settings" && ( -
-
- )} -
+ {view === "settings" && ( +
+ +
+ )} + - setPendingDelete(null)} - onConfirm={onConfirmDelete} - /> - {restartToast ? ( -
- {restartToast} -
- ) : null} -
+ setPendingDelete(null)} + onConfirm={onConfirmDelete} + /> + {restartToast ? ( +
+ {restartToast} +
+ ) : null} + + ); } diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index fc667883c..a51076519 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -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 (
  • onSelect(s.key)} - title={rawLabel || fallbackTitle} + title={tooltipTitle} className="min-w-0 flex-1 overflow-hidden py-1.5 text-left" > {title} diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx index c19a78645..4e3b8b736 100644 --- a/webui/src/components/CodeBlock.tsx +++ b/webui/src/components/CodeBlock.tsx @@ -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 ( + + {code} + + ); + }, + }; +}); + +function PlainCodeFallback({ code }: { code: string }) { + return ( +
    +      {code}
    +    
    ); - - 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) { {copied ? t("code.copied") : t("code.copy")}
    - - {code} - + {highlight ? ( + }> + + + ) : ( + + )} ); } diff --git a/webui/src/components/ConnectionBadge.tsx b/webui/src/components/ConnectionBadge.tsx index 7616ddbe5..a09aadd28 100644 --- a/webui/src/components/ConnectionBadge.tsx +++ b/webui/src/components/ConnectionBadge.tsx @@ -36,21 +36,25 @@ export function ConnectionBadge() { status === "connecting" || status === "reconnecting" || status === "error"; + const label = t(`connection.${status}`); return ( - + {pulsing && ( )} - + - {t(`connection.${status}`)} + {label} ); } diff --git a/webui/src/components/FileReferenceChip.tsx b/webui/src/components/FileReferenceChip.tsx new file mode 100644 index 000000000..18e63d1ca --- /dev/null +++ b/webui/src/components/FileReferenceChip.tsx @@ -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 ( + + + + + + + + {displayText} + + + + + + {path} + + + + ); +} + +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 ( + + + + + + + ); + } + if (kind === "default") { + return ( + + + + + ); + } + const label = fileKindLabel(kind); + return ( + + {label} + + ); +} + +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 ""; + } +} diff --git a/webui/src/components/MarkdownText.tsx b/webui/src/components/MarkdownText.tsx index 111158968..076ad55d0 100644 --- a/webui/src/components/MarkdownText.tsx +++ b/webui/src/components/MarkdownText.tsx @@ -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 ( + + {source} + + ); +}); + +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 ( - {children} + {renderedSource} } > - {children} + ); } + +function useStreamingMarkdownSource(source: string, streaming: boolean): string { + const [renderedSource, setRenderedSource] = useState(source); + const latestSourceRef = useRef(source); + const renderedSourceRef = useRef(source); + const timerRef = useRef(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; +} diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 17a7dc537..0355b3176 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -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( + () => ({ + code({ className: cls, children: kids, ...props }) { + const match = /language-(\w+)/.exec(cls || ""); + if (match) { + const code = String(kids).replace(/\n$/, ""); + return ( + + ); + } + const raw = String(kids).replace(/\n$/, ""); + if (isLikelyFilePath(raw)) { + return ; + } + /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */ + const widePlainBlock = raw.includes("\n") || raw.length > 120; + if (widePlainBlock) { + return ( + + {kids} + + ); + } + return ( + + {kids} + + ); + }, + pre({ children: markdownChildren }) { + const kids = Children.toArray(markdownChildren); + const lone = kids.length === 1 ? kids[0] : null; + /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
    ``. */ + if (lone != null && isValidElement(lone) && lone.type === CodeBlock) { + return <>{markdownChildren}; + } + return ( +
    +            {markdownChildren}
    +          
    + ); + }, + a({ href, children: markdownChildren, ...props }) { + return ( + + {markdownChildren} + + ); + }, + }), + [highlightCode], + ); + return (
    ; - } - 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 ( - - {kids} - - ); - } - return ( - - {kids} - - ); - }, - pre({ children: markdownChildren }) { - const kids = Children.toArray(markdownChildren); - const lone = kids.length === 1 ? kids[0] : null; - /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
    ``. */ - if (lone != null && isValidElement(lone) && lone.type === CodeBlock) { - return <>{markdownChildren}; - } - return ( -
    -                {markdownChildren}
    -              
    - ); - }, - a({ href, children: markdownChildren, ...props }) { - return ( - - {markdownChildren} - - ); - }, - }} + remarkPlugins={remarkPlugins} + rehypePlugins={rehypePlugins} + components={components} > {children} diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index ae15ced62..98ab0c941 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -1,6 +1,5 @@ import { useCallback, - useDeferredValue, useEffect, useRef, useState, @@ -120,7 +119,7 @@ export function MessageBubble({ ) : empty && message.isStreaming ? null : ( <> - {message.content} + {message.content} {media.length > 0 ? : null} {showAssistantFooterRow ? (
    @@ -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 (
    ({ img, i })) - .filter(({ img }) => typeof img.url === "string" && img.url.length > 0); - const viewableImages = viewable.map(({ img }) => img); - const originalToViewable = new Map( - viewable.map(({ i }, v) => [i, v]), - ); + const viewableImages: UIImage[] = []; + const originalToViewable = new Map(); + 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(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 ( - + {children} - {active ? ( - - - - ) : null} ); } @@ -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({ )} > - {markdownSource} + {text}
    )} diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index cf21c8865..cd55475fe 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -117,12 +117,12 @@ export function Sidebar(props: SidebarProps) { />
    -
    +
    ); } + +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(); + 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(); + 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 ( +
      + {edits.map((edit) => ( + + ))} +
    + ); +} + +function FileEditRow({ edit }: { edit: FileEditSummary }) { + const { t } = useTranslation(); + const editing = edit.status === "editing"; + const failed = edit.status === "error"; + const hasCountedDiff = !failed && !edit.binary; + return ( +
  • +
    + + {failed ? ( + + + {t("message.fileEditFailed", { defaultValue: "Failed" })} + + ) : null} + {edit.approximate && !failed ? ( + + {t("message.fileEditApproximate", { defaultValue: "estimated" })} + + ) : null} +
    + {hasCountedDiff ? ( + + ) : null} +
  • + ); +} + +function DiffPair({ added, deleted }: { added: number; deleted: number }) { + return ( + + + + + + + - + + + ); +} + +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}; +} diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 95f1ac428..869d282fe 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -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(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 (
    + {hiddenMessageCount > 0 && onLoadEarlier ? ( +
    + +
    + ) : 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" ? ( ) : ( @@ -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; diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 309f206c5..5711b6ce1 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -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} /> ); diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 38b64340a..3f84da680 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -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(null); const contentRef = useRef(null); + const composerDockRef = useRef(null); const bottomRef = useRef(null); const lastConversationKeyRef = useRef(conversationKey); const pendingConversationScrollRef = useRef(true); const scrollFrameIdsRef = useRef([]); + 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({
    - +
    -
    +
    {composer}
    @@ -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 && (
    } + />, + ); + 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( + composer
    } + emptyState={
    welcome
    } + 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( + } + />, + ); + + 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( + } + />, + ); + + 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; diff --git a/webui/src/tests/useDeferredTitleRefresh.test.tsx b/webui/src/tests/useDeferredTitleRefresh.test.tsx new file mode 100644 index 000000000..a823e5040 --- /dev/null +++ b/webui/src/tests/useDeferredTitleRefresh.test.tsx @@ -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 { + 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); + }); +}); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 57ecccd90..925102dad 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -83,7 +83,112 @@ function wrap(client: ReturnType["client"]) { }; } +async function flushStreamFrame() { + await act(async () => { + await new Promise((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", diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index 9e340a66a..72df813e0 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -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([ { diff --git a/webui/src/types/react-syntax-highlighter-subpaths.d.ts b/webui/src/types/react-syntax-highlighter-subpaths.d.ts new file mode 100644 index 000000000..57639f724 --- /dev/null +++ b/webui/src/types/react-syntax-highlighter-subpaths.d.ts @@ -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 { + 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; +} diff --git a/webui/tsconfig.build.json b/webui/tsconfig.build.json index 8b218ef93..756703b27 100644 --- a/webui/tsconfig.build.json +++ b/webui/tsconfig.build.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["node", "vite/client"] }, "exclude": ["src/tests/**"] } diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 7a2c9edba..fb5dfe37b 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -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",