mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
Merge branch 'main' into nightly
This commit is contained in:
commit
e02615c93d
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -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
|
||||
|
||||
|
||||
10
Dockerfile
10
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"]
|
||||
|
||||
17
README.md
17
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.
|
||||
|
||||
<p align="center">
|
||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||
@ -235,13 +235,12 @@ nanobot agent
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
**3. Start the webui dev server**
|
||||
**3. Open the WebUI**
|
||||
|
||||
```bash
|
||||
cd webui
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs → LAN access](./webui/README.md#access-from-another-device-lan).
|
||||
|
||||
> [!TIP]
|
||||
> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the Vite dev server (HMR) workflow.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 18790:18790
|
||||
- 8765:8765
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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. |
|
||||
|
||||
@ -10,6 +10,18 @@
|
||||
> [!IMPORTANT]
|
||||
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "gateway": { "host": "0.0.0.0" },
|
||||
> "channels": { "websocket": { "host": "0.0.0.0" } }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> When `host` is `0.0.0.0`, the gateway refuses to start unless `token` or `tokenIssueSecret` is also configured on the WebSocket channel — see [`webui/README.md`](../webui/README.md) for details.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
@ -36,8 +48,20 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
||||
# Edit config on host to add API keys
|
||||
vim ~/.nanobot/config.json
|
||||
|
||||
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat)
|
||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway
|
||||
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
|
||||
# Mirrors the security caps and port mappings declared in docker-compose.yml:
|
||||
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
|
||||
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
|
||||
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
|
||||
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
|
||||
# endpoint on 18790.
|
||||
docker run \
|
||||
--cap-drop ALL --cap-add SYS_ADMIN \
|
||||
--security-opt apparmor=unconfined \
|
||||
--security-opt seccomp=unconfined \
|
||||
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||
-p 18790:18790 -p 8765:8765 \
|
||||
nanobot gateway
|
||||
|
||||
# Or run a single command
|
||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"
|
||||
|
||||
101
hatch_build.py
Normal file
101
hatch_build.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""Hatch build hook that bundles the webui (Vite) into nanobot/web/dist.
|
||||
|
||||
Triggered automatically by `python -m build` (and any other hatch-driven build)
|
||||
so published wheels and sdists ship a fresh webui without requiring developers
|
||||
to remember `cd webui && bun run build` beforehand.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- Skips for editable installs (`pip install -e .`). Editable mode is for Python
|
||||
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
|
||||
do not need a packaged `dist/`.
|
||||
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
|
||||
already contains a prebuilt `nanobot/web/dist/`).
|
||||
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
|
||||
- Skips when `nanobot/web/dist/index.html` already exists, unless
|
||||
`NANOBOT_FORCE_WEBUI_BUILD=1` is set.
|
||||
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
|
||||
performs `install` followed by `run build`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
|
||||
class WebUIBuildHook(BuildHookInterface):
|
||||
PLUGIN_NAME = "webui-build"
|
||||
|
||||
def initialize(self, version: str, build_data: dict) -> None: # noqa: D401
|
||||
root = Path(self.root)
|
||||
webui_dir = root / "webui"
|
||||
package_json = webui_dir / "package.json"
|
||||
dist_dir = root / "nanobot" / "web" / "dist"
|
||||
index_html = dist_dir / "index.html"
|
||||
|
||||
# `pip install -e .` builds an editable wheel; skip the (slow) webui
|
||||
# bundle since editable installs target Python development and webui
|
||||
# work uses `bun run dev` instead.
|
||||
if self.target_name == "wheel" and version == "editable":
|
||||
self.app.display_info(
|
||||
"[webui-build] skipped for editable install "
|
||||
"(use `cd webui && bun run build` to bundle webui manually)"
|
||||
)
|
||||
return
|
||||
|
||||
if os.environ.get("NANOBOT_SKIP_WEBUI_BUILD") == "1":
|
||||
self.app.display_info("[webui-build] skipped via NANOBOT_SKIP_WEBUI_BUILD=1")
|
||||
return
|
||||
|
||||
if not package_json.is_file():
|
||||
self.app.display_info(
|
||||
"[webui-build] no webui/ source tree, assuming prebuilt nanobot/web/dist/"
|
||||
)
|
||||
return
|
||||
|
||||
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
|
||||
if index_html.is_file() and not force:
|
||||
self.app.display_info(
|
||||
f"[webui-build] reusing existing build at {dist_dir} "
|
||||
"(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
|
||||
)
|
||||
return
|
||||
|
||||
runner = self._pick_runner()
|
||||
if runner is None:
|
||||
raise RuntimeError(
|
||||
"[webui-build] neither `bun` nor `npm` is available on PATH; "
|
||||
"install one or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
|
||||
)
|
||||
|
||||
self.app.display_info(f"[webui-build] using {runner} to build webui")
|
||||
self._run([runner, "install"], cwd=webui_dir)
|
||||
self._run([runner, "run", "build"], cwd=webui_dir)
|
||||
|
||||
if not index_html.is_file():
|
||||
raise RuntimeError(
|
||||
f"[webui-build] build finished but {index_html} is missing; "
|
||||
"check webui/vite.config.ts outDir."
|
||||
)
|
||||
self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
|
||||
|
||||
@staticmethod
|
||||
def _pick_runner() -> str | None:
|
||||
for candidate in ("bun", "npm"):
|
||||
if shutil.which(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _run(self, cmd: list[str], *, cwd: Path) -> None:
|
||||
self.app.display_info(f"[webui-build] $ {' '.join(cmd)} (cwd={cwd})")
|
||||
try:
|
||||
subprocess.run(cmd, cwd=cwd, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
f"[webui-build] command failed ({exc.returncode}): {' '.join(cmd)}"
|
||||
) from exc
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 ")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
311
nanobot/utils/file_edit_events.py
Normal file
311
nanobot/utils/file_edit_events.py
Normal file
@ -0,0 +1,311 @@
|
||||
"""File-edit activity helpers for WebUI progress events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
|
||||
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FileSnapshot:
|
||||
path: Path
|
||||
exists: bool
|
||||
text: str | None
|
||||
unreadable: bool = False
|
||||
binary: bool = False
|
||||
oversized: bool = False
|
||||
|
||||
@property
|
||||
def countable(self) -> bool:
|
||||
return (
|
||||
self.text is not None
|
||||
and not self.binary
|
||||
and not self.oversized
|
||||
and not self.unreadable
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FileEditTracker:
|
||||
call_id: str
|
||||
tool: str
|
||||
path: Path
|
||||
display_path: str
|
||||
before: FileSnapshot
|
||||
|
||||
|
||||
def is_file_edit_tool(tool_name: str | None) -> bool:
|
||||
return bool(tool_name) and tool_name in TRACKED_FILE_EDIT_TOOLS
|
||||
|
||||
|
||||
def resolve_file_edit_path(
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> Path | None:
|
||||
"""Resolve the target file path after tool argument preparation."""
|
||||
if not isinstance(params, dict):
|
||||
return None
|
||||
raw_path = params.get("path")
|
||||
if not isinstance(raw_path, str) or not raw_path.strip():
|
||||
return None
|
||||
resolver = getattr(tool, "_resolve", None)
|
||||
if callable(resolver):
|
||||
try:
|
||||
resolved = resolver(raw_path)
|
||||
if isinstance(resolved, Path):
|
||||
return resolved
|
||||
if resolved:
|
||||
return Path(resolved)
|
||||
except Exception:
|
||||
return None
|
||||
if workspace is None:
|
||||
return Path(raw_path).expanduser().resolve()
|
||||
return (workspace / raw_path).expanduser().resolve()
|
||||
|
||||
|
||||
def display_file_edit_path(path: Path, workspace: Path | None) -> str:
|
||||
if workspace is not None:
|
||||
try:
|
||||
return path.resolve().relative_to(workspace.resolve()).as_posix()
|
||||
except Exception:
|
||||
pass
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def read_file_snapshot(path: Path, *, max_bytes: int = _MAX_SNAPSHOT_BYTES) -> FileSnapshot:
|
||||
try:
|
||||
if not path.exists() or not path.is_file():
|
||||
return FileSnapshot(path=path, exists=False, text="")
|
||||
size = path.stat().st_size
|
||||
if size > max_bytes:
|
||||
return FileSnapshot(path=path, exists=True, text=None, oversized=True)
|
||||
raw = path.read_bytes()
|
||||
except OSError:
|
||||
return FileSnapshot(path=path, exists=path.exists(), text=None, unreadable=True)
|
||||
if b"\x00" in raw:
|
||||
return FileSnapshot(path=path, exists=True, text=None, binary=True)
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return FileSnapshot(path=path, exists=True, text=None, binary=True)
|
||||
return FileSnapshot(path=path, exists=True, text=text.replace("\r\n", "\n"))
|
||||
|
||||
|
||||
def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
|
||||
"""Return ``(added, deleted)`` for a UTF-8 text line-level diff."""
|
||||
if before is None or after is None:
|
||||
return 0, 0
|
||||
before_lines = before.replace("\r\n", "\n").splitlines()
|
||||
after_lines = after.replace("\r\n", "\n").splitlines()
|
||||
added = 0
|
||||
deleted = 0
|
||||
matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == "equal":
|
||||
continue
|
||||
if tag in ("replace", "delete"):
|
||||
deleted += i2 - i1
|
||||
if tag in ("replace", "insert"):
|
||||
added += j2 - j1
|
||||
return added, deleted
|
||||
|
||||
|
||||
def prepare_file_edit_tracker(
|
||||
*,
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> FileEditTracker | None:
|
||||
if not is_file_edit_tool(tool_name):
|
||||
return None
|
||||
path = resolve_file_edit_path(tool, workspace, params)
|
||||
if path is None:
|
||||
return None
|
||||
before = read_file_snapshot(path)
|
||||
return FileEditTracker(
|
||||
call_id=str(call_id or ""),
|
||||
tool=tool_name,
|
||||
path=path,
|
||||
display_path=display_file_edit_path(path, workspace),
|
||||
before=before,
|
||||
)
|
||||
|
||||
|
||||
def build_file_edit_start_event(
|
||||
tracker: FileEditTracker,
|
||||
params: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before)
|
||||
if tracker.before.countable and predicted_after is not None:
|
||||
added, deleted = line_diff_stats(tracker.before.text, predicted_after)
|
||||
else:
|
||||
added, deleted = 0, 0
|
||||
return _event_payload(
|
||||
tracker,
|
||||
phase="start",
|
||||
status="editing",
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
approximate=True,
|
||||
)
|
||||
|
||||
|
||||
def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
|
||||
after = read_file_snapshot(tracker.path)
|
||||
if tracker.before.countable and after.countable:
|
||||
added, deleted = line_diff_stats(tracker.before.text, after.text)
|
||||
else:
|
||||
added, deleted = 0, 0
|
||||
return _event_payload(
|
||||
tracker,
|
||||
phase="end",
|
||||
status="done",
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
approximate=False,
|
||||
binary=after.binary or after.oversized or after.unreadable,
|
||||
)
|
||||
|
||||
|
||||
def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]:
|
||||
payload = _event_payload(
|
||||
tracker,
|
||||
phase="error",
|
||||
status="error",
|
||||
added=0,
|
||||
deleted=0,
|
||||
approximate=False,
|
||||
)
|
||||
if error:
|
||||
payload["error"] = error.strip()[:240]
|
||||
return payload
|
||||
|
||||
|
||||
def _event_payload(
|
||||
tracker: FileEditTracker,
|
||||
*,
|
||||
phase: str,
|
||||
status: str,
|
||||
added: int,
|
||||
deleted: int,
|
||||
approximate: bool,
|
||||
binary: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"call_id": tracker.call_id,
|
||||
"tool": tracker.tool,
|
||||
"path": tracker.display_path,
|
||||
"phase": phase,
|
||||
"added": max(0, int(added)),
|
||||
"deleted": max(0, int(deleted)),
|
||||
"approximate": bool(approximate),
|
||||
"status": status,
|
||||
}
|
||||
if binary:
|
||||
payload["binary"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _predict_after_text(
|
||||
tool_name: str,
|
||||
params: dict[str, Any],
|
||||
before: FileSnapshot,
|
||||
) -> str | None:
|
||||
if not before.countable:
|
||||
return None
|
||||
before_text = before.text or ""
|
||||
if tool_name == "write_file":
|
||||
content = params.get("content")
|
||||
return content if isinstance(content, str) else ""
|
||||
if tool_name == "edit_file":
|
||||
old_text = params.get("old_text")
|
||||
new_text = params.get("new_text")
|
||||
if not isinstance(old_text, str) or not isinstance(new_text, str):
|
||||
return None
|
||||
replace_all = bool(params.get("replace_all"))
|
||||
if old_text == "":
|
||||
return new_text if not before.exists else before_text
|
||||
if old_text in before_text:
|
||||
if replace_all:
|
||||
return before_text.replace(old_text, new_text)
|
||||
return before_text.replace(old_text, new_text, 1)
|
||||
return None
|
||||
if tool_name == "notebook_edit":
|
||||
return _predict_notebook_after_text(params, before_text)
|
||||
return None
|
||||
|
||||
|
||||
def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
|
||||
try:
|
||||
nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
|
||||
except Exception:
|
||||
return None
|
||||
cells = nb.get("cells")
|
||||
if not isinstance(cells, list):
|
||||
return None
|
||||
try:
|
||||
cell_index = int(params.get("cell_index", 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
new_source = params.get("new_source")
|
||||
source = new_source if isinstance(new_source, str) else ""
|
||||
cell_type = params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
|
||||
mode = params.get("edit_mode") if params.get("edit_mode") in ("replace", "insert", "delete") else "replace"
|
||||
if mode == "delete":
|
||||
if 0 <= cell_index < len(cells):
|
||||
cells.pop(cell_index)
|
||||
else:
|
||||
return None
|
||||
elif mode == "insert":
|
||||
insert_at = min(max(cell_index + 1, 0), len(cells))
|
||||
cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
|
||||
else:
|
||||
if not (0 <= cell_index < len(cells)):
|
||||
return None
|
||||
cell = cells[cell_index]
|
||||
if not isinstance(cell, dict):
|
||||
return None
|
||||
cell["source"] = source
|
||||
cell["cell_type"] = cell_type
|
||||
if cell_type == "code":
|
||||
cell.setdefault("outputs", [])
|
||||
cell.setdefault("execution_count", None)
|
||||
else:
|
||||
cell.pop("outputs", None)
|
||||
cell.pop("execution_count", None)
|
||||
nb["cells"] = cells
|
||||
try:
|
||||
return json.dumps(nb, indent=1, ensure_ascii=False)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _empty_notebook() -> dict[str, Any]:
|
||||
return {
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
"metadata": {
|
||||
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
||||
"language_info": {"name": "python"},
|
||||
},
|
||||
"cells": [],
|
||||
}
|
||||
|
||||
|
||||
def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
|
||||
cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
|
||||
if cell_type == "code":
|
||||
cell["outputs"] = []
|
||||
cell["execution_count"] = None
|
||||
return cell
|
||||
22
nanobot/utils/llm_runtime.py
Normal file
22
nanobot/utils/llm_runtime.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Small helpers for passing the active LLM provider/model together."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMRuntime:
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
|
||||
|
||||
LLMRuntimeResolver = Callable[[], LLMRuntime]
|
||||
|
||||
|
||||
def static_llm_runtime(provider: LLMProvider, model: str) -> LLMRuntimeResolver:
|
||||
runtime = LLMRuntime(provider=provider, model=model)
|
||||
return lambda: runtime
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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``).
|
||||
"""
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
83
tests/utils/test_file_edit_events.py
Normal file
83
tests/utils/test_file_edit_events.py
Normal file
@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.utils.file_edit_events import (
|
||||
build_file_edit_end_event,
|
||||
build_file_edit_start_event,
|
||||
line_diff_stats,
|
||||
prepare_file_edit_tracker,
|
||||
read_file_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None:
|
||||
added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n")
|
||||
assert (added, deleted) == (2, 1)
|
||||
|
||||
|
||||
def test_line_diff_stats_normalizes_crlf() -> None:
|
||||
assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0)
|
||||
|
||||
|
||||
def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None:
|
||||
target = tmp_path / "notes.txt"
|
||||
target.write_text("old\nkeep\n", encoding="utf-8")
|
||||
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-write",
|
||||
tool_name="write_file",
|
||||
tool=None,
|
||||
workspace=tmp_path,
|
||||
params=params,
|
||||
)
|
||||
|
||||
assert tracker is not None
|
||||
start = build_file_edit_start_event(tracker, params)
|
||||
assert start == {
|
||||
"version": 1,
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "notes.txt",
|
||||
"phase": "start",
|
||||
"added": 2,
|
||||
"deleted": 1,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
}
|
||||
|
||||
target.write_text("new\nkeep\nextra\n", encoding="utf-8")
|
||||
end = build_file_edit_end_event(tracker)
|
||||
assert end["phase"] == "end"
|
||||
assert end["status"] == "done"
|
||||
assert end["approximate"] is False
|
||||
assert (end["added"], end["deleted"]) == (2, 1)
|
||||
|
||||
|
||||
def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||
target = tmp_path / "data.bin"
|
||||
target.write_bytes(b"\x00\x01before")
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-bin",
|
||||
tool_name="edit_file",
|
||||
tool=None,
|
||||
workspace=tmp_path,
|
||||
params={"path": "data.bin", "old_text": "before", "new_text": "after"},
|
||||
)
|
||||
|
||||
assert tracker is not None
|
||||
assert not read_file_snapshot(target).countable
|
||||
target.write_bytes(b"\x00\x01after")
|
||||
event = build_file_edit_end_event(tracker)
|
||||
assert event["binary"] is True
|
||||
assert (event["added"], event["deleted"]) == (0, 0)
|
||||
|
||||
|
||||
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
|
||||
assert prepare_file_edit_tracker(
|
||||
call_id="call-exec",
|
||||
tool_name="exec",
|
||||
tool=None,
|
||||
workspace=tmp_path,
|
||||
params={"path": "created-by-shell.txt"},
|
||||
) is None
|
||||
@ -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
|
||||
|
||||
|
||||
@ -8,15 +8,11 @@ on the same port.
|
||||
For the project overview, install guide, and general docs map, see the root
|
||||
[`README.md`](../README.md).
|
||||
|
||||
## Current status
|
||||
## Just want to use the WebUI?
|
||||
|
||||
> [!NOTE]
|
||||
> The standalone WebUI development workflow currently requires a source
|
||||
> checkout.
|
||||
>
|
||||
> WebUI changes in the GitHub repository may land before they are included in
|
||||
> the next packaged release, so source installs and published package versions
|
||||
> are not yet guaranteed to move in lockstep.
|
||||
If you installed nanobot via `pip install nanobot-ai`, the WebUI is **already bundled** in the wheel. Enable the WebSocket channel in `~/.nanobot/config.json` and run `nanobot gateway` — see the root [`README.md`](../README.md#-webui) for the 3-step setup. You do **not** need anything in this directory.
|
||||
|
||||
This `webui/` tree is for people **hacking on the WebUI itself** (UI changes, new components, styling, etc.).
|
||||
|
||||
## Layout
|
||||
|
||||
@ -25,7 +21,7 @@ webui/ source tree (this directory)
|
||||
nanobot/web/dist/ build output served by the gateway
|
||||
```
|
||||
|
||||
## Develop from source
|
||||
## Develop the WebUI (Vite HMR)
|
||||
|
||||
### 1. Install nanobot from source
|
||||
|
||||
@ -35,6 +31,8 @@ From the repository root:
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
> Editable installs intentionally **skip** the WebUI bundle step — Vite HMR is faster than rebuilding `dist/` on every change.
|
||||
|
||||
### 2. Enable the WebSocket channel
|
||||
|
||||
In `~/.nanobot/config.json`:
|
||||
@ -63,8 +61,7 @@ bun run dev
|
||||
|
||||
Then open `http://127.0.0.1:5173`.
|
||||
|
||||
By default, the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket
|
||||
traffic to `http://127.0.0.1:8765`.
|
||||
By default the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to `http://127.0.0.1:8765`.
|
||||
|
||||
If your gateway listens on a non-default port, point the dev server at it:
|
||||
|
||||
@ -74,7 +71,7 @@ NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
|
||||
|
||||
### Access from another device (LAN)
|
||||
|
||||
To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
|
||||
To use the WebUI from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -91,20 +88,20 @@ To use the webui from another device on the same network, set `host` to `"0.0.0.
|
||||
|
||||
The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
|
||||
|
||||
Then open `http://<your-ip>:8765` on the other device. The webui will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
|
||||
Then open `http://<your-ip>:8765` on the other device. The WebUI will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
|
||||
|
||||
## Build for packaged runtime
|
||||
|
||||
You usually do not need to run this by hand: `python -m build` invokes the WebUI build automatically when packaging the wheel.
|
||||
|
||||
If you want to preview the production bundle locally without rebuilding the wheel:
|
||||
|
||||
```bash
|
||||
cd webui
|
||||
bun run build
|
||||
bun run build # writes to ../nanobot/web/dist
|
||||
```
|
||||
|
||||
This writes the production assets to `../nanobot/web/dist`, which is the
|
||||
directory served by `nanobot gateway` and bundled into the Python wheel.
|
||||
|
||||
If you are cutting a release, run the build before packaging so the published
|
||||
wheel contains the current WebUI assets.
|
||||
The gateway picks up the new bundle on the next restart.
|
||||
|
||||
## Test
|
||||
|
||||
|
||||
@ -15,12 +15,15 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.0.6",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
@ -506,8 +509,12 @@
|
||||
|
||||
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"i18next": ["i18next@26.2.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
@ -588,6 +595,8 @@
|
||||
|
||||
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
@ -718,6 +727,8 @@
|
||||
|
||||
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
|
||||
@ -742,6 +753,8 @@
|
||||
|
||||
"rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="],
|
||||
|
||||
"remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="],
|
||||
@ -860,6 +873,8 @@
|
||||
|
||||
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
744
webui/package-lock.json
generated
744
webui/package-lock.json
generated
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -7,7 +7,8 @@ import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
|
||||
import { useSessions } from "@/hooks/useSessions";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
|
||||
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
clearSavedSecret,
|
||||
@ -16,6 +17,7 @@ import {
|
||||
loadSavedSecret,
|
||||
saveSecret,
|
||||
} from "@/lib/bootstrap";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
@ -30,14 +32,30 @@ type BootState =
|
||||
status: "ready";
|
||||
client: NanobotClient;
|
||||
token: string;
|
||||
tokenExpiresAt: number;
|
||||
modelName: string | null;
|
||||
};
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||
const SIDEBAR_WIDTH = 272;
|
||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||
type ShellView = "chat" | "settings";
|
||||
|
||||
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
|
||||
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
|
||||
}
|
||||
|
||||
function tokenRefreshDelayMs(expiresAt: number): number {
|
||||
const remaining = Math.max(0, expiresAt - Date.now());
|
||||
const margin = Math.min(
|
||||
TOKEN_REFRESH_MARGIN_MS,
|
||||
Math.max(1_000, remaining / 2),
|
||||
);
|
||||
return Math.max(TOKEN_REFRESH_MIN_DELAY_MS, remaining - margin);
|
||||
}
|
||||
|
||||
function AuthForm({
|
||||
failed,
|
||||
onSecret,
|
||||
@ -106,6 +124,7 @@ function readSidebarOpen(): boolean {
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<BootState>({ status: "loading" });
|
||||
const bootstrapSecretRef = useRef("");
|
||||
|
||||
const bootstrapWithSecret = useCallback(
|
||||
(secret: string) => {
|
||||
@ -117,22 +136,37 @@ export default function App() {
|
||||
if (cancelled) return;
|
||||
if (secret) saveSecret(secret);
|
||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||
const client = new NanobotClient({
|
||||
let client: NanobotClient;
|
||||
client = new NanobotClient({
|
||||
url,
|
||||
onReauth: async () => {
|
||||
try {
|
||||
const refreshed = await fetchBootstrap("", secret);
|
||||
return deriveWsUrl(refreshed.ws_path, refreshed.token);
|
||||
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
|
||||
const refreshedUrl = deriveWsUrl(refreshed.ws_path, refreshed.token);
|
||||
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
|
||||
setState((current) =>
|
||||
current.status === "ready" && current.client === client
|
||||
? {
|
||||
...current,
|
||||
token: refreshed.token,
|
||||
tokenExpiresAt,
|
||||
modelName: refreshed.model_name ?? current.modelName,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return refreshedUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
bootstrapSecretRef.current = secret;
|
||||
client.connect();
|
||||
setState({
|
||||
status: "ready",
|
||||
client,
|
||||
token: boot.token,
|
||||
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
||||
modelName: boot.model_name ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
@ -152,6 +186,35 @@ export default function App() {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "ready") return;
|
||||
const client = state.client;
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
|
||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
|
||||
client.updateUrl(url);
|
||||
setState((current) =>
|
||||
current.status === "ready" && current.client === client
|
||||
? {
|
||||
...current,
|
||||
token: boot.token,
|
||||
tokenExpiresAt,
|
||||
modelName: boot.model_name ?? current.modelName,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
|
||||
setState({ status: "auth", failed: true });
|
||||
}
|
||||
}
|
||||
}, tokenRefreshDelayMs(state.tokenExpiresAt));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadSavedSecret();
|
||||
return bootstrapWithSecret(saved);
|
||||
@ -219,7 +282,13 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
|
||||
function Shell({
|
||||
onModelNameChange,
|
||||
onLogout,
|
||||
}: {
|
||||
onModelNameChange: (modelName: string | null) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { client } = useClient();
|
||||
const { theme, toggle } = useTheme();
|
||||
@ -362,9 +431,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
});
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
@ -386,8 +453,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
|
||||
const headerTitle = activeSession
|
||||
? activeSession.title ||
|
||||
activeSession.preview ||
|
||||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
|
||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||
: t("app.brand");
|
||||
|
||||
useEffect(() => {
|
||||
@ -415,93 +481,95 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
const showMainSidebar = view !== "settings";
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||
{showMainSidebar ? (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||
"transition-[width] duration-300 ease-out",
|
||||
)}
|
||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||
{showMainSidebar ? (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||
"transition-[width] duration-300 ease-out",
|
||||
)}
|
||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
||||
"transition-transform duration-300 ease-out",
|
||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
style={{ width: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{showMainSidebar ? (
|
||||
<Sheet
|
||||
open={mobileSidebarOpen}
|
||||
onOpenChange={(open) => setMobileSidebarOpen(open)}
|
||||
>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 lg:hidden"
|
||||
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
||||
"transition-transform duration-300 ease-out",
|
||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
"absolute inset-0 flex flex-col",
|
||||
view === "settings" && "invisible pointer-events-none",
|
||||
)}
|
||||
style={{ width: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{showMainSidebar ? (
|
||||
<Sheet
|
||||
open={mobileSidebarOpen}
|
||||
onOpenChange={(open) => setMobileSidebarOpen(open)}
|
||||
>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 lg:hidden"
|
||||
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col",
|
||||
view === "settings" && "invisible pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
</div>
|
||||
{view === "settings" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<SettingsView
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
onModelNameChange={onModelNameChange}
|
||||
onLogout={onLogout}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{view === "settings" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<SettingsView
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
onModelNameChange={onModelNameChange}
|
||||
onLogout={onLogout}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<DeleteConfirm
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.label ?? ""}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
/>
|
||||
{restartToast ? (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DeleteConfirm
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.label ?? ""}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
/>
|
||||
{restartToast ? (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
@ -64,8 +65,11 @@ export function ChatList({
|
||||
const fallbackTitle = t("chat.fallbackTitle", {
|
||||
id: s.chatId.slice(0, 6),
|
||||
});
|
||||
const rawLabel = (s.title || s.preview)?.trim();
|
||||
const title = rawLabel || fallbackTitle;
|
||||
const generatedTitle = s.title?.trim() || "";
|
||||
const title =
|
||||
generatedTitle || deriveTitle(s.preview, t("chat.newChat"));
|
||||
const tooltipTitle =
|
||||
generatedTitle || deriveTitle(s.preview, fallbackTitle);
|
||||
return (
|
||||
<li key={s.key} className="min-w-0">
|
||||
<div
|
||||
@ -79,7 +83,7 @@ export function ChatList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.key)}
|
||||
title={rawLabel || fallbackTitle}
|
||||
title={tooltipTitle}
|
||||
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
|
||||
>
|
||||
<span className="block w-full truncate font-medium leading-5">{title}</span>
|
||||
|
||||
@ -1,44 +1,75 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Suspense, lazy, useCallback, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
import { useThemeValue } from "@/hooks/useTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CodeBlockProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
className?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */
|
||||
function useIsDark() {
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
typeof document !== "undefined"
|
||||
? document.documentElement.classList.contains("dark")
|
||||
: true,
|
||||
interface HighlightedCodeProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const LazyHighlightedCode = lazy(async () => {
|
||||
const [
|
||||
{ default: SyntaxHighlighter },
|
||||
{ default: oneDark },
|
||||
{ default: oneLight },
|
||||
] = await Promise.all([
|
||||
import("react-syntax-highlighter/dist/esm/prism-async-light"),
|
||||
import("react-syntax-highlighter/dist/esm/styles/prism/one-dark"),
|
||||
import("react-syntax-highlighter/dist/esm/styles/prism/one-light"),
|
||||
]);
|
||||
|
||||
return {
|
||||
default({ language, code, isDark }: HighlightedCodeProps) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDark ? oneDark : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
PreTag="pre"
|
||||
wrapLongLines
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function PlainCodeFallback({ code }: { code: string }) {
|
||||
return (
|
||||
<pre
|
||||
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]"
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(el.classList.contains("dark"));
|
||||
});
|
||||
observer.observe(el, { attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||
export function CodeBlock({
|
||||
language,
|
||||
code,
|
||||
className,
|
||||
highlight = true,
|
||||
}: CodeBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isDark = useIsDark();
|
||||
const isDark = useThemeValue() === "dark";
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (!navigator.clipboard) return;
|
||||
@ -86,20 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDark ? oneDark : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
PreTag="pre"
|
||||
wrapLongLines
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
{highlight ? (
|
||||
<Suspense fallback={<PlainCodeFallback code={code} />}>
|
||||
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<PlainCodeFallback code={code} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -36,21 +36,25 @@ export function ConnectionBadge() {
|
||||
status === "connecting" ||
|
||||
status === "reconnecting" ||
|
||||
status === "error";
|
||||
const label = t(`connection.${status}`);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors",
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
"text-muted-foreground/70 hover:bg-sidebar-accent/65",
|
||||
meta.color,
|
||||
)}
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
title={label}
|
||||
>
|
||||
<span className="relative flex h-1.5 w-1.5" aria-hidden>
|
||||
<span className="relative flex h-2 w-2" aria-hidden>
|
||||
{pulsing && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
|
||||
)}
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
|
||||
</span>
|
||||
{t(`connection.${status}`)}
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
220
webui/src/components/FileReferenceChip.tsx
Normal file
220
webui/src/components/FileReferenceChip.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FileReferenceKind =
|
||||
| "default"
|
||||
| "css"
|
||||
| "html"
|
||||
| "json"
|
||||
| "markdown"
|
||||
| "notebook"
|
||||
| "python"
|
||||
| "react"
|
||||
| "typescript";
|
||||
|
||||
interface FileReferenceChipProps {
|
||||
path: string;
|
||||
display?: "name" | "path";
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function FileReferenceChip({
|
||||
path,
|
||||
display = "name",
|
||||
active = false,
|
||||
className,
|
||||
textClassName,
|
||||
testId = "inline-file-path",
|
||||
}: FileReferenceChipProps) {
|
||||
const { name } = splitFilePath(path);
|
||||
const kind = fileKindForPath(path);
|
||||
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn("not-prose inline-flex max-w-full align-[0.14em]", className)}
|
||||
>
|
||||
<span
|
||||
data-testid={testId}
|
||||
aria-label={path}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1 font-medium leading-[1.1]",
|
||||
"text-sky-600 transition-colors hover:text-sky-700",
|
||||
"dark:text-sky-300 dark:hover:text-sky-200",
|
||||
)}
|
||||
>
|
||||
<FileReferenceIcon kind={kind} />
|
||||
<span
|
||||
data-sheen-text={active ? displayText : undefined}
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
active && "streaming-text-sheen",
|
||||
textClassName,
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className={cn(
|
||||
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]",
|
||||
"border-border/60 bg-popover/95 px-2.5 py-1.5",
|
||||
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
|
||||
"shadow-lg backdrop-blur",
|
||||
)}
|
||||
>
|
||||
{path}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyFilePath(value: string): boolean {
|
||||
const raw = value.trim();
|
||||
if (!raw || raw.includes("\n")) return false;
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
|
||||
if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
|
||||
return false;
|
||||
}
|
||||
const normalized = raw.replace(/\\/g, "/");
|
||||
const name = normalized.split("/").filter(Boolean).pop() ?? normalized;
|
||||
if (!name || name === "." || name === "..") return false;
|
||||
if (/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(name)) return true;
|
||||
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
|
||||
}
|
||||
|
||||
function splitFilePath(path: string): { directory: string; name: string } {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const slash = normalized.lastIndexOf("/");
|
||||
if (slash < 0) return { directory: "", name: path };
|
||||
return {
|
||||
directory: normalized.slice(0, slash + 1),
|
||||
name: normalized.slice(slash + 1) || normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function fileKindForPath(path: string): FileReferenceKind {
|
||||
const normalized = path.toLowerCase();
|
||||
const name = normalized.split(/[\\/]/).pop() ?? normalized;
|
||||
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||
if (name === "dockerfile") {
|
||||
return "default";
|
||||
}
|
||||
switch (ext) {
|
||||
case "py":
|
||||
case "pyi":
|
||||
return "python";
|
||||
case "jsx":
|
||||
case "tsx":
|
||||
return "react";
|
||||
case "ts":
|
||||
return "typescript";
|
||||
case "html":
|
||||
case "htm":
|
||||
return "html";
|
||||
case "css":
|
||||
case "scss":
|
||||
case "sass":
|
||||
return "css";
|
||||
case "json":
|
||||
case "jsonl":
|
||||
return "json";
|
||||
case "md":
|
||||
case "mdx":
|
||||
return "markdown";
|
||||
case "ipynb":
|
||||
return "notebook";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
||||
if (kind === "react") {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3.7" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(60 12 12)" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(120 12 12)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (kind === "default") {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7z" />
|
||||
<path d="M14 2v5h5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const label = fileKindLabel(kind);
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-flex h-[1.05em] min-w-[1.05em] shrink-0 items-center justify-center",
|
||||
"rounded-[4px] bg-sky-500/12 px-[0.22em] text-[0.58em] font-bold uppercase leading-none",
|
||||
"text-sky-600 dark:bg-sky-400/15 dark:text-sky-300",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function fileKindLabel(kind: FileReferenceKind): string {
|
||||
switch (kind) {
|
||||
case "css":
|
||||
return "#";
|
||||
case "html":
|
||||
return "H";
|
||||
case "json":
|
||||
return "{}";
|
||||
case "markdown":
|
||||
return "M";
|
||||
case "notebook":
|
||||
return "N";
|
||||
case "python":
|
||||
return "PY";
|
||||
case "typescript":
|
||||
return "TS";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,46 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
lazy,
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownTextProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
||||
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
|
||||
|
||||
const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
||||
source,
|
||||
className,
|
||||
highlightCode,
|
||||
}: {
|
||||
source: string;
|
||||
className?: string;
|
||||
highlightCode: boolean;
|
||||
}) {
|
||||
return (
|
||||
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}>
|
||||
{source}
|
||||
</LazyMarkdownRenderer>
|
||||
);
|
||||
});
|
||||
|
||||
const SHORT_STREAM_COMMIT_MS = 80;
|
||||
const MEDIUM_STREAM_COMMIT_MS = 140;
|
||||
const LONG_STREAM_COMMIT_MS = 220;
|
||||
|
||||
export function preloadMarkdownText(): void {
|
||||
void loadMarkdownRenderer();
|
||||
}
|
||||
@ -19,7 +50,18 @@ export function preloadMarkdownText(): void {
|
||||
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
|
||||
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
|
||||
*/
|
||||
export function MarkdownText({ children, className }: MarkdownTextProps) {
|
||||
export function MarkdownText({
|
||||
children,
|
||||
className,
|
||||
streaming = false,
|
||||
}: MarkdownTextProps) {
|
||||
const renderedSource = useStreamingMarkdownSource(children, streaming);
|
||||
const highlightCode = !streaming && renderedSource === children;
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming) preloadMarkdownText();
|
||||
}, [streaming]);
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
@ -29,11 +71,73 @@ export function MarkdownText({ children, className }: MarkdownTextProps) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{renderedSource}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer>
|
||||
<MemoizedMarkdownRenderer
|
||||
source={renderedSource}
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function useStreamingMarkdownSource(source: string, streaming: boolean): string {
|
||||
const [renderedSource, setRenderedSource] = useState(source);
|
||||
const latestSourceRef = useRef(source);
|
||||
const renderedSourceRef = useRef(source);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const clearPendingCommit = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const commitSource = useCallback((next: string, urgent: boolean) => {
|
||||
if (renderedSourceRef.current === next) return;
|
||||
renderedSourceRef.current = next;
|
||||
if (urgent) {
|
||||
setRenderedSource(next);
|
||||
return;
|
||||
}
|
||||
startTransition(() => setRenderedSource(next));
|
||||
}, []);
|
||||
|
||||
const scheduleCommit = useCallback(() => {
|
||||
if (timerRef.current !== null) return;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
commitSource(latestSourceRef.current, false);
|
||||
}, streamingCommitDelay(latestSourceRef.current.length));
|
||||
}, [commitSource]);
|
||||
|
||||
latestSourceRef.current = source;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
latestSourceRef.current = source;
|
||||
if (!streaming) {
|
||||
clearPendingCommit();
|
||||
commitSource(source, true);
|
||||
}
|
||||
}, [clearPendingCommit, commitSource, source, streaming]);
|
||||
|
||||
useEffect(() => {
|
||||
latestSourceRef.current = source;
|
||||
if (!streaming) return;
|
||||
scheduleCommit();
|
||||
}, [scheduleCommit, source, streaming]);
|
||||
|
||||
useEffect(() => clearPendingCommit, [clearPendingCommit]);
|
||||
|
||||
return renderedSource;
|
||||
}
|
||||
|
||||
function streamingCommitDelay(length: number): number {
|
||||
if (length > 24_000) return LONG_STREAM_COMMIT_MS;
|
||||
if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS;
|
||||
return SHORT_STREAM_COMMIT_MS;
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { Children, isValidElement } from "react";
|
||||
import { Children, isValidElement, useMemo } from "react";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "katex/dist/katex.min.css";
|
||||
@ -12,8 +15,12 @@ import "katex/dist/katex.min.css";
|
||||
interface MarkdownTextRendererProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
highlightCode?: boolean;
|
||||
}
|
||||
|
||||
const remarkPlugins = [remarkBreaks, remarkGfm, remarkMath];
|
||||
const rehypePlugins = [rehypeKatex];
|
||||
|
||||
/**
|
||||
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
|
||||
* separate chunk so the app shell can paint sooner on refresh.
|
||||
@ -21,7 +28,91 @@ interface MarkdownTextRendererProps {
|
||||
export default function MarkdownTextRenderer({
|
||||
children,
|
||||
className,
|
||||
highlightCode = true,
|
||||
}: MarkdownTextRendererProps) {
|
||||
const components = useMemo<Components>(
|
||||
() => ({
|
||||
code({ className: cls, children: kids, ...props }) {
|
||||
const match = /language-(\w+)/.exec(cls || "");
|
||||
if (match) {
|
||||
const code = String(kids).replace(/\n$/, "");
|
||||
return (
|
||||
<CodeBlock
|
||||
language={match[1]}
|
||||
code={code}
|
||||
className="my-3"
|
||||
highlight={highlightCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const raw = String(kids).replace(/\n$/, "");
|
||||
if (isLikelyFilePath(raw)) {
|
||||
return <FileReferenceChip path={raw} />;
|
||||
}
|
||||
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
|
||||
const widePlainBlock = raw.includes("\n") || raw.length > 120;
|
||||
if (widePlainBlock) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
|
||||
"leading-snug text-inherit",
|
||||
cls,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{kids}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
|
||||
cls,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{kids}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children: markdownChildren }) {
|
||||
const kids = Children.toArray(markdownChildren);
|
||||
const lone = kids.length === 1 ? kids[0] : null;
|
||||
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
|
||||
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
|
||||
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
|
||||
"whitespace-pre [overflow-wrap:normal]",
|
||||
)}
|
||||
>
|
||||
{markdownChildren}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children: markdownChildren, ...props }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-primary underline underline-offset-2 hover:opacity-80"
|
||||
{...props}
|
||||
>
|
||||
{markdownChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[highlightCode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -42,77 +133,9 @@ export default function MarkdownTextRenderer({
|
||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code({ className: cls, children: kids, ...props }) {
|
||||
const match = /language-(\w+)/.exec(cls || "");
|
||||
if (match) {
|
||||
const code = String(kids).replace(/\n$/, "");
|
||||
return <CodeBlock language={match[1]} code={code} className="my-3" />;
|
||||
}
|
||||
const raw = String(kids).replace(/\n$/, "");
|
||||
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
|
||||
const widePlainBlock = raw.includes("\n") || raw.length > 120;
|
||||
if (widePlainBlock) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
|
||||
"leading-snug text-inherit",
|
||||
cls,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{kids}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
|
||||
cls,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{kids}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children: markdownChildren }) {
|
||||
const kids = Children.toArray(markdownChildren);
|
||||
const lone = kids.length === 1 ? kids[0] : null;
|
||||
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
|
||||
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
|
||||
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
|
||||
"whitespace-pre [overflow-wrap:normal]",
|
||||
)}
|
||||
>
|
||||
{markdownChildren}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children: markdownChildren, ...props }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-primary underline underline-offset-2 hover:opacity-80"
|
||||
{...props}
|
||||
>
|
||||
{markdownChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@ -120,7 +119,7 @@ export function MessageBubble({
|
||||
<TypingDots />
|
||||
) : empty && message.isStreaming ? null : (
|
||||
<>
|
||||
<MarkdownText>{message.content}</MarkdownText>
|
||||
<MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText>
|
||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||
{showAssistantFooterRow ? (
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||
@ -167,10 +166,15 @@ function MessageMedia({
|
||||
align: "left" | "right";
|
||||
}) {
|
||||
if (media.length === 0) return null;
|
||||
const images = media
|
||||
.filter((item) => item.kind === "image")
|
||||
.map(({ url, name }) => ({ url, name }));
|
||||
const nonImages = media.filter((item) => item.kind !== "image");
|
||||
const images: UIImage[] = [];
|
||||
const nonImages: UIMediaAttachment[] = [];
|
||||
for (const item of media) {
|
||||
if (item.kind === "image") {
|
||||
images.push({ url: item.url, name: item.name });
|
||||
} else {
|
||||
nonImages.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -276,13 +280,14 @@ function UserImages({
|
||||
const { t } = useTranslation();
|
||||
// Only real-URL images can open in the lightbox; historical-replay
|
||||
// placeholders (no URL) have nothing to zoom into.
|
||||
const viewable = images
|
||||
.map((img, i) => ({ img, i }))
|
||||
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
|
||||
const viewableImages = viewable.map(({ img }) => img);
|
||||
const originalToViewable = new Map<number, number>(
|
||||
viewable.map(({ i }, v) => [i, v]),
|
||||
);
|
||||
const viewableImages: UIImage[] = [];
|
||||
const originalToViewable = new Map<number, number>();
|
||||
for (let i = 0; i < images.length; i += 1) {
|
||||
const img = images[i];
|
||||
if (typeof img.url !== "string" || img.url.length === 0) continue;
|
||||
originalToViewable.set(i, viewableImages.length);
|
||||
viewableImages.push(img);
|
||||
}
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
@ -416,7 +421,7 @@ function Dot({ delay }: { delay: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */
|
||||
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
|
||||
export function StreamingLabelSheen({
|
||||
children,
|
||||
active,
|
||||
@ -426,21 +431,21 @@ export function StreamingLabelSheen({
|
||||
active: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const sheenText =
|
||||
typeof children === "string" || typeof children === "number"
|
||||
? String(children)
|
||||
: undefined;
|
||||
return (
|
||||
<span className={cn("relative block min-w-0 py-px", className)}>
|
||||
<span className={cn("block min-w-0 overflow-hidden py-px", className)}>
|
||||
<span
|
||||
data-sheen-text={active ? sheenText : undefined}
|
||||
className={cn(
|
||||
"relative z-0 block font-medium leading-normal text-muted-foreground",
|
||||
!active && "truncate",
|
||||
"block w-fit max-w-full truncate font-medium leading-normal",
|
||||
active ? "streaming-text-sheen" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{active ? (
|
||||
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
|
||||
<span className="reasoning-sheen-stripe" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -474,8 +479,6 @@ export function ReasoningBubble({
|
||||
embeddedInCluster = false,
|
||||
}: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const deferredText = useDeferredValue(text);
|
||||
const markdownSource = streaming ? deferredText : text;
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [openLocal, setOpenLocal] = useState(true);
|
||||
const open = userToggled ? openLocal : streaming;
|
||||
@ -531,6 +534,7 @@ export function ReasoningBubble({
|
||||
)}
|
||||
>
|
||||
<MarkdownText
|
||||
streaming={streaming}
|
||||
className={cn(
|
||||
"text-[12.5px] italic text-muted-foreground/88",
|
||||
"prose-p:my-1.5 prose-li:my-0.5",
|
||||
@ -541,7 +545,7 @@ export function ReasoningBubble({
|
||||
"prose-code:text-[0.92em]",
|
||||
)}
|
||||
>
|
||||
{markdownSource}
|
||||
{text}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -117,12 +117,12 @@ export function Sidebar(props: SidebarProps) {
|
||||
/>
|
||||
</div>
|
||||
<Separator className="bg-sidebar-border/50" />
|
||||
<div className="space-y-1 px-2.5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={props.onOpenSettings}
|
||||
className="h-8 w-full justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
||||
className="h-8 min-w-0 flex-1 justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("sidebar.settings")}
|
||||
|
||||
@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
|
||||
type SettingsSectionKey = "general" | "byok";
|
||||
type ByokPaneKey = "llm" | "web-search";
|
||||
|
||||
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
|
||||
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
|
||||
name,
|
||||
index,
|
||||
]),
|
||||
);
|
||||
|
||||
interface SettingsViewProps {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
@ -176,7 +183,8 @@ export function SettingsView({
|
||||
if (!provider) return;
|
||||
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
|
||||
const apiKey = providerForm.apiKey.trim();
|
||||
if (!provider.configured && !apiKey) {
|
||||
const apiKeyRequired = provider.api_key_required ?? true;
|
||||
if (!provider.configured && apiKeyRequired && !apiKey) {
|
||||
setError(t("settings.byok.apiKeyRequired"));
|
||||
return;
|
||||
}
|
||||
@ -917,7 +925,10 @@ function ByokSettings({
|
||||
const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
|
||||
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
|
||||
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
||||
const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured);
|
||||
const unconfiguredProviders = useMemo(
|
||||
() => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)),
|
||||
[settings.providers],
|
||||
);
|
||||
const initialUnconfiguredCount = 6;
|
||||
const visibleUnconfiguredProviders = showAllUnconfigured
|
||||
? unconfiguredProviders
|
||||
@ -935,6 +946,12 @@ function ByokSettings({
|
||||
const saving = providerSaving === provider.name;
|
||||
const keyVisible = !!visibleProviderKeys[provider.name];
|
||||
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
|
||||
const apiKeyRequired = provider.api_key_required ?? true;
|
||||
const apiKey = form.apiKey.trim();
|
||||
const apiBase = form.apiBase.trim();
|
||||
const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey;
|
||||
const missingOptionalCredential =
|
||||
!apiKeyRequired && !provider.configured && !apiKey && !apiBase;
|
||||
return (
|
||||
<div
|
||||
key={provider.name}
|
||||
@ -1045,7 +1062,7 @@ function ByokSettings({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onSaveProvider(provider.name)}
|
||||
disabled={saving || (!provider.configured && !form.apiKey.trim())}
|
||||
disabled={saving || missingRequiredApiKey || missingOptionalCredential}
|
||||
className="rounded-full"
|
||||
>
|
||||
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
||||
@ -1188,6 +1205,25 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function orderUnconfiguredProviders(
|
||||
providers: SettingsPayload["providers"],
|
||||
): SettingsPayload["providers"] {
|
||||
return providers
|
||||
.map((provider, index) => ({ provider, index }))
|
||||
.sort((left, right) => {
|
||||
const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
|
||||
return rank || left.index - right.index;
|
||||
})
|
||||
.map(({ provider }) => provider);
|
||||
}
|
||||
|
||||
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
|
||||
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
|
||||
if (localRank !== undefined) return localRank;
|
||||
if ((provider.api_key_required ?? true) === false) return 100;
|
||||
return 200;
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||
custom: Hexagon,
|
||||
openrouter: Sparkles,
|
||||
@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||
qianfan: Database,
|
||||
azure_openai: Cloud,
|
||||
bedrock: Database,
|
||||
vllm: Cpu,
|
||||
ollama: Cpu,
|
||||
lm_studio: Cpu,
|
||||
atomic_chat: Cpu,
|
||||
ovms: Cpu,
|
||||
nvidia: Zap,
|
||||
};
|
||||
|
||||
function ProviderIcon({ provider }: { provider: string }) {
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Layers } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { UIFileEdit, UIMessage } from "@/lib/types";
|
||||
|
||||
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||
|
||||
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
||||
if (m.role !== "assistant" || m.kind === "trace") return false;
|
||||
@ -19,14 +21,70 @@ export function isAgentActivityMember(m: UIMessage): boolean {
|
||||
return isReasoningOnlyAssistant(m) || m.kind === "trace";
|
||||
}
|
||||
|
||||
function countToolCalls(messages: UIMessage[]): number {
|
||||
let n = 0;
|
||||
interface ActivityCounts {
|
||||
reasoningSteps: number;
|
||||
toolCalls: number;
|
||||
fileCount: number;
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasEditingFiles: boolean;
|
||||
hasFailedFiles: boolean;
|
||||
primaryFilePath?: string;
|
||||
}
|
||||
|
||||
interface FileEditSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
status: UIFileEdit["status"];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
|
||||
let reasoningSteps = 0;
|
||||
let toolCalls = 0;
|
||||
for (const m of messages) {
|
||||
if (m.kind !== "trace") continue;
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
n += Math.max(lines, 1);
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
reasoningSteps += 1;
|
||||
continue;
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
toolCalls += lines;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
let added = 0;
|
||||
let deleted = 0;
|
||||
let hasEditingFiles = false;
|
||||
let failedFileCount = 0;
|
||||
let primaryFilePath: string | undefined;
|
||||
for (const edit of fileEdits) {
|
||||
primaryFilePath = edit.path;
|
||||
if (edit.status === "editing") {
|
||||
hasEditingFiles = true;
|
||||
}
|
||||
if (edit.status === "error") {
|
||||
failedFileCount += 1;
|
||||
}
|
||||
if (edit.status === "error" || edit.binary) {
|
||||
continue;
|
||||
}
|
||||
added += edit.added;
|
||||
deleted += edit.deleted;
|
||||
}
|
||||
return {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
fileCount: fileEdits.length,
|
||||
added,
|
||||
deleted,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
||||
primaryFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentActivityClusterProps {
|
||||
@ -46,24 +104,56 @@ export function AgentActivityCluster({
|
||||
hasBodyBelow,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
|
||||
const toolCalls = countToolCalls(messages);
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||
[messages, isTurnStreaming],
|
||||
);
|
||||
const {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
fileCount,
|
||||
added,
|
||||
deleted,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles,
|
||||
primaryFilePath,
|
||||
} = countActivity(messages, fileEdits);
|
||||
|
||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
|
||||
const activityScrollRef = useRef<HTMLDivElement>(null);
|
||||
const activityContentRef = useRef<HTMLDivElement>(null);
|
||||
const autoFollowActivityRef = useRef(true);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
|
||||
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
||||
|
||||
const headerBusy = isTurnStreaming;
|
||||
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
|
||||
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||
|
||||
const summary =
|
||||
isTurnStreaming
|
||||
const fileActivitySummary = fileCount > 0
|
||||
? fileCount === 1 && primaryFilePath
|
||||
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||
file: shortFileName(primaryFilePath),
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
|
||||
})
|
||||
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||
count: fileCount,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`,
|
||||
})
|
||||
: "";
|
||||
|
||||
const summary = fileCount > 0
|
||||
? fileActivitySummary
|
||||
: isTurnStreaming
|
||||
? reasoningSteps > 0
|
||||
? t("message.agentActivityLiveSummary", {
|
||||
reasoning: reasoningSteps,
|
||||
tools: toolCalls,
|
||||
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||
})
|
||||
: toolCalls === 0 && fileCount > 0
|
||||
? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" })
|
||||
: t("message.agentActivityLiveToolsOnly", {
|
||||
tools: toolCalls,
|
||||
defaultValue: "Working… · {{tools}} tool calls",
|
||||
@ -74,16 +164,73 @@ export function AgentActivityCluster({
|
||||
tools: toolCalls,
|
||||
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
|
||||
})
|
||||
: toolCalls === 0 && fileCount > 0
|
||||
? t("message.agentActivityFilesOnly", { defaultValue: "File changes" })
|
||||
: t("message.agentActivityToolsOnly", {
|
||||
tools: toolCalls,
|
||||
defaultValue: "{{tools}} tool calls",
|
||||
});
|
||||
|
||||
const cancelActivityScrollFrame = useCallback(() => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current);
|
||||
scrollFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollActivityToBottom = useCallback(() => {
|
||||
const el = activityScrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
}, []);
|
||||
|
||||
const scheduleActivityScrollToBottom = useCallback(() => {
|
||||
cancelActivityScrollFrame();
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null;
|
||||
scrollActivityToBottom();
|
||||
});
|
||||
}, [cancelActivityScrollFrame, scrollActivityToBottom]);
|
||||
|
||||
const toggleOuter = () => {
|
||||
const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded;
|
||||
if (nextOpen) {
|
||||
autoFollowActivityRef.current = true;
|
||||
}
|
||||
setUserToggledOuter(true);
|
||||
setOuterOpenLocal((v) => (userToggledOuter ? !v : !outerExpanded));
|
||||
setOuterOpenLocal(nextOpen);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!outerExpanded || !autoFollowActivityRef.current) return;
|
||||
scheduleActivityScrollToBottom();
|
||||
}, [outerExpanded, messages, isTurnStreaming, scheduleActivityScrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!outerExpanded) {
|
||||
autoFollowActivityRef.current = true;
|
||||
return;
|
||||
}
|
||||
const target = activityContentRef.current;
|
||||
if (!target || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (autoFollowActivityRef.current) {
|
||||
scheduleActivityScrollToBottom();
|
||||
}
|
||||
});
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [outerExpanded, scheduleActivityScrollToBottom]);
|
||||
|
||||
useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]);
|
||||
|
||||
const onActivityScroll = useCallback(() => {
|
||||
const el = activityScrollRef.current;
|
||||
if (!el) return;
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||
<button
|
||||
@ -96,12 +243,19 @@ export function AgentActivityCluster({
|
||||
aria-expanded={outerExpanded}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<StreamingLabelSheen
|
||||
active={headerBusy}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
{summary}
|
||||
</StreamingLabelSheen>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
||||
<StreamingLabelSheen
|
||||
active={headerBusy}
|
||||
className="min-w-0"
|
||||
>
|
||||
{summary}
|
||||
</StreamingLabelSheen>
|
||||
{fileCount > 0 && (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
|
||||
<DiffPair added={added} deleted={deleted} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
@ -118,29 +272,38 @@ export function AgentActivityCluster({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={activityScrollRef}
|
||||
data-testid="agent-activity-scroll"
|
||||
onScroll={onActivityScroll}
|
||||
className={cn(
|
||||
CLUSTER_SCROLL_MAX_CLASS,
|
||||
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div ref={activityContentRef} className="flex flex-col gap-2">
|
||||
{messages.map((m) => {
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
return (
|
||||
<ReasoningBubble
|
||||
key={m.id}
|
||||
text={m.reasoning ?? ""}
|
||||
streaming={!!m.reasoningStreaming}
|
||||
streaming={isTurnStreaming && !!m.reasoningStreaming}
|
||||
hasBodyBelow={false}
|
||||
embeddedInCluster
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
return <TraceGroup key={m.id} message={m} animClass="" />;
|
||||
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
|
||||
return hasTraceLines ? (
|
||||
<div key={m.id} className="flex flex-col gap-1">
|
||||
<TraceGroup message={m} animClass="" />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -148,3 +311,231 @@ export function AgentActivityCluster({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortFileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() || path;
|
||||
}
|
||||
|
||||
function fileActivityVerb(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "Failed";
|
||||
return editing ? "Editing" : "Edited";
|
||||
}
|
||||
|
||||
function fileActivitySummaryKey(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "message.fileActivityFailedOne";
|
||||
return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne";
|
||||
}
|
||||
|
||||
function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "message.fileActivityFailedMany";
|
||||
return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
|
||||
}
|
||||
|
||||
function fileEditCallKey(edit: UIFileEdit): string {
|
||||
return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
||||
}
|
||||
|
||||
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
|
||||
const edits: UIFileEdit[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.kind === "trace" && message.fileEdits?.length) {
|
||||
edits.push(...message.fileEdits);
|
||||
}
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||
const order: string[] = [];
|
||||
const byKey = new Map<string, UIFileEdit>();
|
||||
for (const edit of edits) {
|
||||
const key = fileEditCallKey(edit);
|
||||
if (!byKey.has(key)) order.push(key);
|
||||
byKey.set(key, edit);
|
||||
}
|
||||
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
|
||||
}
|
||||
|
||||
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
||||
interface MutableSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
hasSuccessfulChange: boolean;
|
||||
hasActiveEditing: boolean;
|
||||
hasFailed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const order: string[] = [];
|
||||
const byPath = new Map<string, MutableSummary>();
|
||||
for (const edit of latestFileEditEvents(edits)) {
|
||||
const key = edit.path;
|
||||
let summary = byPath.get(key);
|
||||
if (!summary) {
|
||||
summary = {
|
||||
key,
|
||||
path: edit.path,
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
binary: false,
|
||||
hasSuccessfulChange: false,
|
||||
hasActiveEditing: false,
|
||||
hasFailed: false,
|
||||
};
|
||||
byPath.set(key, summary);
|
||||
order.push(key);
|
||||
}
|
||||
|
||||
if (active && edit.status === "editing") {
|
||||
summary.hasActiveEditing = true;
|
||||
summary.binary = summary.binary || !!edit.binary;
|
||||
summary.approximate = summary.approximate || !!edit.approximate;
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edit.status === "error") {
|
||||
summary.hasFailed = true;
|
||||
summary.error = edit.error ?? summary.error;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.hasSuccessfulChange = true;
|
||||
summary.binary = summary.binary || !!edit.binary;
|
||||
summary.approximate = active && (summary.approximate || !!edit.approximate);
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
}
|
||||
|
||||
return order.map((key) => {
|
||||
const summary = byPath.get(key)!;
|
||||
const status: UIFileEdit["status"] = summary.hasActiveEditing
|
||||
? "editing"
|
||||
: summary.hasSuccessfulChange
|
||||
? "done"
|
||||
: summary.hasFailed
|
||||
? "error"
|
||||
: "done";
|
||||
return {
|
||||
key: summary.key,
|
||||
path: summary.path,
|
||||
added: summary.added,
|
||||
deleted: summary.deleted,
|
||||
approximate: summary.approximate,
|
||||
binary: summary.binary,
|
||||
status,
|
||||
error: summary.error,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1 border-l border-muted-foreground/15 pl-3">
|
||||
{edits.map((edit) => (
|
||||
<FileEditRow key={edit.key} edit={edit} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
||||
const { t } = useTranslation();
|
||||
const editing = edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
const hasCountedDiff = !failed && !edit.binary;
|
||||
return (
|
||||
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileReferenceChip
|
||||
path={edit.path}
|
||||
display="path"
|
||||
active={editing}
|
||||
className="min-w-0"
|
||||
textClassName="text-[12px]"
|
||||
testId="activity-file-reference"
|
||||
/>
|
||||
{failed ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
|
||||
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||
{t("message.fileEditFailed", { defaultValue: "Failed" })}
|
||||
</span>
|
||||
) : null}
|
||||
{edit.approximate && !failed ? (
|
||||
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
|
||||
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{hasCountedDiff ? (
|
||||
<DiffPair added={edit.added} deleted={edit.deleted} />
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 tabular-nums">
|
||||
<span className="text-emerald-600/75 dark:text-emerald-300/75">
|
||||
+<AnimatedNumber value={added} />
|
||||
</span>
|
||||
<span className="text-rose-600/70 dark:text-rose-300/75">
|
||||
-<AnimatedNumber value={deleted} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedNumber({ value }: { value: number }) {
|
||||
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
||||
const [display, setDisplay] = useState(0);
|
||||
const displayRef = useRef(0);
|
||||
|
||||
const setAnimatedDisplay = useCallback((next: number) => {
|
||||
displayRef.current = next;
|
||||
setDisplay(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduceMotion) {
|
||||
setAnimatedDisplay(safeValue);
|
||||
return;
|
||||
}
|
||||
const start = displayRef.current;
|
||||
const delta = safeValue - start;
|
||||
if (delta === 0) {
|
||||
setAnimatedDisplay(safeValue);
|
||||
return;
|
||||
}
|
||||
const duration = 260;
|
||||
const startedAt = performance.now();
|
||||
let frame = 0;
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - startedAt) / duration);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setAnimatedDisplay(Math.round(start + delta * eased));
|
||||
if (progress < 1) {
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
displayRef.current = safeValue;
|
||||
};
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [safeValue, setAnimatedDisplay]);
|
||||
|
||||
return <>{display}</>;
|
||||
}
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import {
|
||||
AgentActivityCluster,
|
||||
@ -9,6 +12,8 @@ interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
}
|
||||
|
||||
export type DisplayUnit =
|
||||
@ -30,31 +35,160 @@ export function isFinalAssistantSliceBeforeNextUser(
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
const out: DisplayUnit[] = [];
|
||||
let i = 0;
|
||||
while (i < messages.length) {
|
||||
const m = messages[i];
|
||||
if (isAgentActivityMember(m)) {
|
||||
const cluster: UIMessage[] = [];
|
||||
while (i < messages.length && isAgentActivityMember(messages[i])) {
|
||||
cluster.push(messages[i]);
|
||||
let segmentId: string | undefined = m.activitySegmentId;
|
||||
let clusterHasFileEdits = hasFileEdits(m);
|
||||
while (
|
||||
i < messages.length
|
||||
&& isAgentActivityMember(messages[i])
|
||||
&& canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i])
|
||||
) {
|
||||
const current = messages[i];
|
||||
if (!segmentId && current.activitySegmentId) {
|
||||
segmentId = current.activitySegmentId;
|
||||
}
|
||||
clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current);
|
||||
cluster.push(current);
|
||||
i += 1;
|
||||
}
|
||||
out.push({ type: "cluster", messages: cluster });
|
||||
continue;
|
||||
}
|
||||
const previous = out[out.length - 1];
|
||||
if (
|
||||
previous?.type === "cluster"
|
||||
&& assistantHasInlineReasoning(m)
|
||||
&& canFoldInlineReasoning(previous.messages, m)
|
||||
) {
|
||||
previous.messages.push(reasoningOnlyMessageFromAnswer(m));
|
||||
out.push({ type: "single", message: stripInlineReasoning(m) });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (assistantHasInlineReasoning(m)) {
|
||||
out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] });
|
||||
out.push({ type: "single", message: stripInlineReasoning(m) });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
out.push({ type: "single", message: m });
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
|
||||
const units = buildDisplayUnits(messages);
|
||||
function clusterSegmentId(messages: UIMessage[]): string | undefined {
|
||||
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
|
||||
}
|
||||
|
||||
function hasFileEdits(message: UIMessage): boolean {
|
||||
return !!message.fileEdits?.length;
|
||||
}
|
||||
|
||||
function clusterHasFileEdits(messages: UIMessage[]): boolean {
|
||||
return messages.some(hasFileEdits);
|
||||
}
|
||||
|
||||
function canJoinActivityCluster(
|
||||
clusterSegmentId: string | undefined,
|
||||
clusterIncludesFileEdits: boolean,
|
||||
message: UIMessage,
|
||||
): boolean {
|
||||
const messageHasFileEdits = hasFileEdits(message);
|
||||
if (!clusterIncludesFileEdits && !messageHasFileEdits) return true;
|
||||
if (!clusterSegmentId || !message.activitySegmentId) return true;
|
||||
return clusterSegmentId === message.activitySegmentId;
|
||||
}
|
||||
|
||||
function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean {
|
||||
if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true;
|
||||
const segmentId = clusterSegmentId(cluster);
|
||||
if (!segmentId || !message.activitySegmentId) return true;
|
||||
return segmentId === message.activitySegmentId;
|
||||
}
|
||||
|
||||
function assistantHasInlineReasoning(message: UIMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant"
|
||||
&& message.kind !== "trace"
|
||||
&& message.content.trim().length > 0
|
||||
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
|
||||
);
|
||||
}
|
||||
|
||||
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
||||
return {
|
||||
id: `${message.id}-reasoning`,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: message.createdAt,
|
||||
reasoning: message.reasoning,
|
||||
reasoningStreaming: message.reasoningStreaming,
|
||||
isStreaming: message.reasoningStreaming,
|
||||
activitySegmentId: message.activitySegmentId,
|
||||
};
|
||||
}
|
||||
|
||||
function stripInlineReasoning(message: UIMessage): UIMessage {
|
||||
const next = { ...message };
|
||||
delete next.reasoning;
|
||||
delete next.reasoningStreaming;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
const flags = new Array<boolean>(units.length).fill(true);
|
||||
let hasLaterUnitBeforeUser = false;
|
||||
for (let i = units.length - 1; i >= 0; i -= 1) {
|
||||
const unit = units[i];
|
||||
if (unit.type === "single" && unit.message.role === "user") {
|
||||
hasLaterUnitBeforeUser = false;
|
||||
continue;
|
||||
}
|
||||
if (unit.type === "single" && unit.message.role === "assistant") {
|
||||
flags[i] = !hasLaterUnitBeforeUser;
|
||||
}
|
||||
hasLaterUnitBeforeUser = true;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
export function ThreadMessages({
|
||||
messages,
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
const liveActivityClusterIndex = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndex(units) : -1,
|
||||
[isStreaming, units],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{hiddenMessageCount > 0 && onLoadEarlier ? (
|
||||
<div className="mb-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadEarlier}
|
||||
className="rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
|
||||
>
|
||||
{t("thread.loadEarlier", {
|
||||
count: hiddenMessageCount,
|
||||
defaultValue: "Load earlier messages",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{units.map((unit, index) => {
|
||||
const prev = units[index - 1];
|
||||
const marginTop =
|
||||
@ -72,7 +206,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
||||
{unit.type === "cluster" ? (
|
||||
<AgentActivityCluster
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={isStreaming}
|
||||
isTurnStreaming={index === liveActivityClusterIndex}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
/>
|
||||
) : (
|
||||
@ -80,7 +214,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
||||
message={unit.message}
|
||||
showAssistantCopyAction={
|
||||
unit.message.role === "assistant"
|
||||
? isFinalAssistantSliceBeforeNextUser(units, index)
|
||||
? copyFlags[index]
|
||||
: true
|
||||
}
|
||||
/>
|
||||
@ -92,6 +226,11 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
||||
);
|
||||
}
|
||||
|
||||
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
||||
const last = units.length - 1;
|
||||
return units[last]?.type === "cluster" ? last : -1;
|
||||
}
|
||||
|
||||
function unitKey(unit: DisplayUnit, index: number): string {
|
||||
if (unit.type === "cluster") {
|
||||
const anchor = unit.messages[0]?.id;
|
||||
|
||||
@ -167,8 +167,9 @@ export function ThreadShell({
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
return client.onSessionUpdate((updatedChatId) => {
|
||||
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||
if (updatedChatId !== chatId) return;
|
||||
if (scope === "metadata") return;
|
||||
pendingCanonicalHydrateRef.current.add(chatId);
|
||||
refreshHistory();
|
||||
});
|
||||
@ -389,6 +390,7 @@ export function ThreadShell({
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
@ -14,9 +23,27 @@ interface ThreadViewportProps {
|
||||
emptyState?: ReactNode;
|
||||
scrollToBottomSignal?: number;
|
||||
conversationKey?: string | null;
|
||||
showScrollToBottomButton?: boolean;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||
|
||||
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
|
||||
if (messages.length <= visibleCount) return messages;
|
||||
let start = Math.max(0, messages.length - visibleCount);
|
||||
while (
|
||||
start > 0
|
||||
&& isAgentActivityMember(messages[start])
|
||||
&& isAgentActivityMember(messages[start - 1])
|
||||
) {
|
||||
start -= 1;
|
||||
}
|
||||
return messages.slice(start);
|
||||
}
|
||||
|
||||
export function ThreadViewport({
|
||||
messages,
|
||||
@ -25,18 +52,33 @@ export function ThreadViewport({
|
||||
emptyState,
|
||||
scrollToBottomSignal = 0,
|
||||
conversationKey = null,
|
||||
showScrollToBottomButton = true,
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||
const pendingConversationScrollRef = useRef(true);
|
||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||
const restoreScrollAfterPrependRef =
|
||||
useRef<{ height: number; top: number } | null>(null);
|
||||
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
|
||||
const userReadingHistoryRef = useRef(false);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||
const [visibleMessageCount, setVisibleMessageCount] =
|
||||
useState(INITIAL_HISTORY_WINDOW);
|
||||
const hasMessages = messages.length > 0;
|
||||
const visibleMessages = useMemo(
|
||||
() => windowMessages(messages, visibleMessageCount),
|
||||
[messages, visibleMessageCount],
|
||||
);
|
||||
const hiddenMessageCount = messages.length - visibleMessages.length;
|
||||
const scrollButtonBottom = composerDockHeight > 0
|
||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
|
||||
|
||||
const cancelScheduledBottomScroll = useCallback(() => {
|
||||
for (const id of scrollFrameIdsRef.current) {
|
||||
@ -77,6 +119,30 @@ export function ThreadViewport({
|
||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
||||
);
|
||||
|
||||
const loadEarlierMessages = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
restoreScrollAfterPrependRef.current = {
|
||||
height: el.scrollHeight,
|
||||
top: el.scrollTop,
|
||||
};
|
||||
}
|
||||
userReadingHistoryRef.current = true;
|
||||
setAtBottom(false);
|
||||
setVisibleMessageCount((count) =>
|
||||
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
|
||||
);
|
||||
}, [messages.length]);
|
||||
|
||||
const measureComposerDock = useCallback(() => {
|
||||
const el = composerDockRef.current;
|
||||
if (!el) return;
|
||||
const height = el.getBoundingClientRect().height || el.offsetHeight;
|
||||
setComposerDockHeight((current) =>
|
||||
Math.abs(current - height) < 1 ? current : height,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!atBottom) return;
|
||||
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||
@ -96,8 +162,19 @@ export function ThreadViewport({
|
||||
pendingConversationScrollRef.current = true;
|
||||
userReadingHistoryRef.current = false;
|
||||
setAtBottom(true);
|
||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||
}, [conversationKey]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pending = restoreScrollAfterPrependRef.current;
|
||||
if (!pending) return;
|
||||
const el = scrollRef.current;
|
||||
restoreScrollAfterPrependRef.current = null;
|
||||
if (!el) return;
|
||||
const delta = el.scrollHeight - pending.height;
|
||||
el.scrollTop = pending.top + delta;
|
||||
}, [visibleMessages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingConversationScrollRef.current) return;
|
||||
if (!conversationKey) {
|
||||
@ -110,6 +187,10 @@ export function ThreadViewport({
|
||||
pendingConversationScrollRef.current = false;
|
||||
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measureComposerDock();
|
||||
}, [composer, hasMessages, measureComposerDock]);
|
||||
|
||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -123,6 +204,14 @@ export function ThreadViewport({
|
||||
return () => observer.disconnect();
|
||||
}, [hasMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = composerDockRef.current;
|
||||
if (!target || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => measureComposerDock());
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMessages, measureComposerDock]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@ -155,11 +244,20 @@ export function ThreadViewport({
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||
<div className="flex-1 px-4 pb-20 pt-4">
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages messages={messages} isStreaming={isStreaming} />
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
isStreaming={isStreaming}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-10 mt-auto bg-background">
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
className="sticky bottom-0 z-10 mt-auto bg-background"
|
||||
>
|
||||
<div className="px-4 pb-3">
|
||||
{composer}
|
||||
</div>
|
||||
@ -183,17 +281,18 @@ export function ThreadViewport({
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||
/>
|
||||
|
||||
{!atBottom && (
|
||||
{showScrollToBottomButton && !atBottom && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||
className={cn(
|
||||
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
|
||||
"absolute bottom-48 left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||
"bg-background/90 backdrop-blur",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
style={{ bottom: scrollButtonBottom }}
|
||||
aria-label={t("thread.scrollToBottom")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
|
||||
@ -11,15 +11,17 @@ const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@ -117,53 +117,60 @@
|
||||
--cjk-line-height: 1.625;
|
||||
}
|
||||
|
||||
/* L→R sheen over solid label text (overlay stripe). Avoids ``background-clip:
|
||||
text`` loop seams that read as RTL “erase” or one-frame transparent glyphs. */
|
||||
@keyframes reasoning-sheen-ltr {
|
||||
/* L→R sheen clipped to live activity labels. The highlight lives inside
|
||||
the glyphs, not in the row background, so dark mode stays quiet. */
|
||||
@keyframes streaming-text-sheen-ltr {
|
||||
0% {
|
||||
left: -44%;
|
||||
background-position: 140% 50%;
|
||||
}
|
||||
100% {
|
||||
left: 118%;
|
||||
background-position: -40% 50%;
|
||||
}
|
||||
}
|
||||
.reasoning-sheen-track {
|
||||
.streaming-text-sheen {
|
||||
position: relative;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.streaming-text-sheen::after {
|
||||
content: attr(data-sheen-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
.reasoning-sheen-stripe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 44%;
|
||||
min-width: 3.25rem;
|
||||
left: -44%;
|
||||
border-radius: inherit;
|
||||
color: transparent;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(0 0% 100% / 0.07) 34%,
|
||||
hsl(0 0% 100% / 0.76) 50%,
|
||||
hsl(0 0% 100% / 0.07) 66%,
|
||||
transparent 38%,
|
||||
hsl(var(--foreground) / 0.98) 50%,
|
||||
transparent 62%,
|
||||
transparent 100%
|
||||
);
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.95;
|
||||
animation: reasoning-sheen-ltr 5.2s linear infinite;
|
||||
background-size: 260% 100%;
|
||||
background-position: 140% 50%;
|
||||
background-repeat: no-repeat;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: streaming-text-sheen-ltr 2.8s ease-in-out infinite;
|
||||
}
|
||||
.dark .reasoning-sheen-stripe {
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 1;
|
||||
.dark .streaming-text-sheen::after {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
transparent 38%,
|
||||
hsl(var(--foreground) / 0.98) 50%,
|
||||
transparent 62%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reasoning-sheen-stripe {
|
||||
.streaming-text-sheen::after {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
webui/src/hooks/useDeferredTitleRefresh.ts
Normal file
68
webui/src/hooks/useDeferredTitleRefresh.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
const TITLE_REFRESH_RETRY_DELAYS_MS = [1_000, 3_000, 7_000] as const;
|
||||
|
||||
function hasGeneratedTitle(session: ChatSummary | null): boolean {
|
||||
return !!session?.title?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The server generates WebUI titles after the main turn has already ended.
|
||||
* Refresh once immediately, then retry lightly for untitled sessions so the
|
||||
* async title appears even if the websocket metadata notification is delayed.
|
||||
*/
|
||||
export function useDeferredTitleRefresh(
|
||||
activeSession: ChatSummary | null,
|
||||
refresh: () => Promise<void>,
|
||||
retryDelaysMs: readonly number[] = TITLE_REFRESH_RETRY_DELAYS_MS,
|
||||
): () => void {
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
activeSessionRef.current = activeSession;
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
for (const timer of timersRef.current) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timersRef.current = [];
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
clearTimers();
|
||||
}, [activeSession?.key, clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasGeneratedTitle(activeSession)) {
|
||||
clearTimers();
|
||||
}
|
||||
}, [activeSession, clearTimers]);
|
||||
|
||||
return useCallback(() => {
|
||||
void refresh();
|
||||
|
||||
const sessionAtTurnEnd = activeSessionRef.current;
|
||||
if (!sessionAtTurnEnd || hasGeneratedTitle(sessionAtTurnEnd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
for (const delayMs of retryDelaysMs) {
|
||||
const timer = setTimeout(() => {
|
||||
const latest = activeSessionRef.current;
|
||||
if (
|
||||
!latest ||
|
||||
latest.key !== sessionAtTurnEnd.key ||
|
||||
hasGeneratedTitle(latest)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, delayMs);
|
||||
timersRef.current.push(timer);
|
||||
}
|
||||
}, [clearTimers, refresh, retryDelaysMs]);
|
||||
}
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
OutboundMedia,
|
||||
GoalStateWsPayload,
|
||||
UIImage,
|
||||
UIFileEdit,
|
||||
UIMessage,
|
||||
} from "@/lib/types";
|
||||
|
||||
@ -18,12 +19,26 @@ interface StreamBuffer {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */
|
||||
function findStreamingAssistantId(prev: UIMessage[]): string | null {
|
||||
interface ActiveAssistantCursor {
|
||||
id: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type PendingStreamEvent =
|
||||
| { kind: "delta"; text: string }
|
||||
| { kind: "reasoning"; text: string };
|
||||
|
||||
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
|
||||
* as streaming until ``turn_end`` for visual continuity, but they must not
|
||||
* receive later delta segments. */
|
||||
function findStreamingAssistantIndex(
|
||||
prev: UIMessage[],
|
||||
closedStreamIds: ReadonlySet<string>,
|
||||
): number | null {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const m = prev[i];
|
||||
if (m.kind === "trace") continue;
|
||||
if (m.role === "assistant" && m.isStreaming) return m.id;
|
||||
if (m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id)) return i;
|
||||
if (m.role === "user") break;
|
||||
}
|
||||
return null;
|
||||
@ -38,7 +53,13 @@ function findStreamingAssistantId(prev: UIMessage[]): string | null {
|
||||
* case the reasoning still belongs to the same assistant turn and must render
|
||||
* above the answer, not as a new row below it.
|
||||
*/
|
||||
function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
function attachReasoningChunk(
|
||||
prev: UIMessage[],
|
||||
chunk: string,
|
||||
segments?: {
|
||||
ensure: () => string;
|
||||
},
|
||||
): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
// A user turn is a hard boundary: reasoning after it belongs to the new
|
||||
@ -49,6 +70,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
// that produced those tool calls.
|
||||
if (candidate.kind === "trace") break;
|
||||
if (candidate.role !== "assistant") continue;
|
||||
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
|
||||
const hasAnswer = candidate.content.length > 0;
|
||||
if (
|
||||
candidate.reasoningStreaming
|
||||
@ -60,6 +82,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
...candidate,
|
||||
reasoning: (candidate.reasoning ?? "") + chunk,
|
||||
reasoningStreaming: true,
|
||||
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
@ -68,11 +91,13 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
...candidate,
|
||||
reasoning: chunk,
|
||||
reasoningStreaming: true,
|
||||
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
break;
|
||||
}
|
||||
const activitySegmentId = segments?.ensure();
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
@ -82,6 +107,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
isStreaming: true,
|
||||
reasoning: chunk,
|
||||
reasoningStreaming: true,
|
||||
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@ -95,13 +121,19 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
* the model already produced an answer in a previous turn, so the new
|
||||
* delta belongs in a fresh row.
|
||||
*/
|
||||
function findActiveAssistantPlaceholder(prev: UIMessage[]): string | null {
|
||||
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
|
||||
const last = prev[prev.length - 1];
|
||||
if (!last) return null;
|
||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||
if (last.content.length > 0) return null;
|
||||
if (!last.isStreaming) return null;
|
||||
return last.id;
|
||||
return prev.length - 1;
|
||||
}
|
||||
|
||||
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
|
||||
const next = prev.slice();
|
||||
next[index] = message;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -182,6 +214,47 @@ function absorbCompleteAssistantMessage(
|
||||
];
|
||||
}
|
||||
|
||||
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
||||
return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
||||
}
|
||||
|
||||
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
|
||||
if (!edit || !edit.path || !edit.tool) return null;
|
||||
const inferredStatus =
|
||||
edit.phase === "error"
|
||||
? "error"
|
||||
: edit.phase === "end"
|
||||
? "done"
|
||||
: "editing";
|
||||
return {
|
||||
...edit,
|
||||
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
|
||||
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
|
||||
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
|
||||
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
|
||||
? edit.status
|
||||
: inferredStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
|
||||
const next = [...(existing ?? [])];
|
||||
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
|
||||
for (const raw of incoming) {
|
||||
const edit = normalizeFileEdit(raw);
|
||||
if (!edit) continue;
|
||||
const key = fileEditKey(edit);
|
||||
const existingIndex = indexByKey.get(key);
|
||||
if (existingIndex === undefined) {
|
||||
indexByKey.set(key, next.length);
|
||||
next.push(edit);
|
||||
continue;
|
||||
}
|
||||
next[existingIndex] = { ...next[existingIndex], ...edit };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
||||
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
||||
@ -239,6 +312,13 @@ export function useNanobotStream(
|
||||
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||
const buffer = useRef<StreamBuffer | null>(null);
|
||||
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
|
||||
const closedAssistantStreamIdsRef = useRef<Set<string>>(new Set());
|
||||
const activitySegmentRef = useRef<string | null>(null);
|
||||
const fileEditSegmentRef = useRef<string | null>(null);
|
||||
const activitySegmentCounterRef = useRef(0);
|
||||
const pendingStreamEventsRef = useRef<PendingStreamEvent[]>([]);
|
||||
const streamFrameRef = useRef<number | null>(null);
|
||||
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
||||
*
|
||||
@ -255,6 +335,159 @@ export function useNanobotStream(
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
const clearPendingStreamWork = useCallback(() => {
|
||||
if (streamFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(streamFrameRef.current);
|
||||
streamFrameRef.current = null;
|
||||
}
|
||||
pendingStreamEventsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const createActivitySegmentId = useCallback((activate = true) => {
|
||||
activitySegmentCounterRef.current += 1;
|
||||
const id = `activity-${activitySegmentCounterRef.current}`;
|
||||
if (activate) activitySegmentRef.current = id;
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const freshActivitySegmentId = useCallback(
|
||||
() => createActivitySegmentId(true),
|
||||
[createActivitySegmentId],
|
||||
);
|
||||
|
||||
const detachedActivitySegmentId = useCallback(
|
||||
() => createActivitySegmentId(false),
|
||||
[createActivitySegmentId],
|
||||
);
|
||||
|
||||
const ensureActivitySegmentId = useCallback(() => {
|
||||
if (activitySegmentRef.current) return activitySegmentRef.current;
|
||||
return freshActivitySegmentId();
|
||||
}, [freshActivitySegmentId]);
|
||||
|
||||
const clearActivitySegment = useCallback(() => {
|
||||
activitySegmentRef.current = null;
|
||||
fileEditSegmentRef.current = null;
|
||||
}, []);
|
||||
|
||||
const closeActiveAssistantStream = useCallback(() => {
|
||||
const closedStreamId = buffer.current?.messageId ?? activeAssistantRef.current?.id;
|
||||
if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId);
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
|
||||
const cursor = activeAssistantRef.current;
|
||||
if (!cursor) return null;
|
||||
const indexed = prev[cursor.index];
|
||||
if (indexed?.id === cursor.id && indexed.role === "assistant" && indexed.kind !== "trace") {
|
||||
return cursor.index;
|
||||
}
|
||||
const idx = prev.findIndex((m) => m.id === cursor.id);
|
||||
if (idx === -1) {
|
||||
activeAssistantRef.current = null;
|
||||
return null;
|
||||
}
|
||||
const found = prev[idx];
|
||||
if (found.role !== "assistant" || found.kind === "trace") {
|
||||
activeAssistantRef.current = null;
|
||||
return null;
|
||||
}
|
||||
activeAssistantRef.current = { id: cursor.id, index: idx };
|
||||
return idx;
|
||||
}, []);
|
||||
|
||||
const appendAnswerChunk = useCallback(
|
||||
(prev: UIMessage[], chunk: string): UIMessage[] => {
|
||||
let next = prev;
|
||||
let targetIndex = resolveActiveAssistantIndex(next);
|
||||
|
||||
if (targetIndex === null) {
|
||||
targetIndex = findActiveAssistantPlaceholderIndex(next);
|
||||
}
|
||||
if (targetIndex === null) {
|
||||
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
|
||||
}
|
||||
if (targetIndex === null) {
|
||||
const id = crypto.randomUUID();
|
||||
next = [
|
||||
...next,
|
||||
{
|
||||
id,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
targetIndex = next.length - 1;
|
||||
}
|
||||
|
||||
const target = next[targetIndex];
|
||||
const merged: UIMessage = {
|
||||
...target,
|
||||
content: target.content + chunk,
|
||||
isStreaming: true,
|
||||
};
|
||||
closedAssistantStreamIdsRef.current.delete(merged.id);
|
||||
activeAssistantRef.current = { id: merged.id, index: targetIndex };
|
||||
buffer.current = { messageId: merged.id };
|
||||
return replaceMessageAt(next, targetIndex, merged);
|
||||
},
|
||||
[resolveActiveAssistantIndex],
|
||||
);
|
||||
|
||||
const applyPendingStreamEvents = useCallback(
|
||||
(prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => {
|
||||
let next = prev;
|
||||
for (let i = 0; i < events.length;) {
|
||||
const kind = events[i].kind;
|
||||
let text = "";
|
||||
while (i < events.length && events[i].kind === kind) {
|
||||
text += events[i].text;
|
||||
i += 1;
|
||||
}
|
||||
next = kind === "delta"
|
||||
? appendAnswerChunk(next, text)
|
||||
: attachReasoningChunk(next, text, {
|
||||
ensure: ensureActivitySegmentId,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
},
|
||||
[appendAnswerChunk, ensureActivitySegmentId],
|
||||
);
|
||||
|
||||
const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => {
|
||||
if (streamFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(streamFrameRef.current);
|
||||
streamFrameRef.current = null;
|
||||
}
|
||||
const events = pendingStreamEventsRef.current;
|
||||
if (events.length === 0) {
|
||||
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||
return;
|
||||
}
|
||||
pendingStreamEventsRef.current = [];
|
||||
setMessages((prev) => {
|
||||
const next = applyPendingStreamEvents(prev, events);
|
||||
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||
return next;
|
||||
});
|
||||
}, [applyPendingStreamEvents, closeActiveAssistantStream]);
|
||||
|
||||
const schedulePendingStreamFlush = useCallback(() => {
|
||||
if (streamFrameRef.current !== null) return;
|
||||
streamFrameRef.current = window.requestAnimationFrame(() => {
|
||||
streamFrameRef.current = null;
|
||||
const events = pendingStreamEventsRef.current;
|
||||
if (events.length === 0) return;
|
||||
pendingStreamEventsRef.current = [];
|
||||
setMessages((prev) => applyPendingStreamEvents(prev, events));
|
||||
});
|
||||
}, [applyPendingStreamEvents]);
|
||||
|
||||
// Reset local state when switching chats. Do not reset on every
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
@ -269,13 +502,17 @@ export function useNanobotStream(
|
||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
clearPendingStreamWork();
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId, client]);
|
||||
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPendingToolCalls) setIsStreaming(true);
|
||||
@ -296,54 +533,10 @@ export function useNanobotStream(
|
||||
if (ev.event === "delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||
if (!chunk) return;
|
||||
setIsStreaming(true);
|
||||
setMessages((prev) => {
|
||||
const adopted = findActiveAssistantPlaceholder(prev);
|
||||
const streamingAssistId = findStreamingAssistantId(prev);
|
||||
let targetId: string;
|
||||
let next: UIMessage[];
|
||||
|
||||
if (adopted) {
|
||||
targetId = adopted;
|
||||
next = prev;
|
||||
} else if (streamingAssistId) {
|
||||
targetId = streamingAssistId;
|
||||
next = prev;
|
||||
} else {
|
||||
targetId = crypto.randomUUID();
|
||||
next = [
|
||||
...prev,
|
||||
{
|
||||
id: targetId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
buffer.current = { messageId: targetId };
|
||||
|
||||
const priorContent = next.find((m) => m.id === targetId)?.content ?? "";
|
||||
const combined = priorContent + chunk;
|
||||
return next.map((m) =>
|
||||
m.id === targetId ? { ...m, content: combined, isStreaming: true } : m,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "stream_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) {
|
||||
buffer.current = null;
|
||||
return;
|
||||
}
|
||||
// stream_end only means the text segment finished — the model may
|
||||
// still be executing tools. Do NOT reset isStreaming here; the
|
||||
// definitive "turn is complete" signal is ``turn_end``.
|
||||
if (!buffer.current) return;
|
||||
buffer.current = null;
|
||||
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
|
||||
schedulePendingStreamFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -351,11 +544,23 @@ export function useNanobotStream(
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = ev.text;
|
||||
if (!chunk) return;
|
||||
setMessages((prev) => attachReasoningChunk(prev, chunk));
|
||||
setIsStreaming(true);
|
||||
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
|
||||
schedulePendingStreamFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "stream_end") {
|
||||
flushPendingStreamEvents({ closeAnswerSegment: true });
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
// stream_end only means the text segment finished — the model may
|
||||
// still be executing tools. Do NOT reset isStreaming here; the
|
||||
// definitive "turn is complete" signal is ``turn_end``.
|
||||
return;
|
||||
}
|
||||
|
||||
flushPendingStreamEvents();
|
||||
|
||||
if (ev.event === "reasoning_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
setMessages((prev) => closeReasoningStream(prev));
|
||||
@ -393,6 +598,10 @@ export function useNanobotStream(
|
||||
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
||||
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
|
||||
}
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
clearActivitySegment();
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
return finalized;
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
@ -413,7 +622,9 @@ export function useNanobotStream(
|
||||
if (ev.kind === "reasoning") {
|
||||
const line = ev.text;
|
||||
if (!line) return;
|
||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line)));
|
||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
|
||||
ensure: ensureActivitySegmentId,
|
||||
})));
|
||||
return;
|
||||
}
|
||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||
@ -428,12 +639,24 @@ export function useNanobotStream(
|
||||
: [];
|
||||
if (lines.length === 0) return;
|
||||
setMessages((prev) => {
|
||||
const segmentId = ensureActivitySegmentId();
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.kind === "trace" && !last.isStreaming) {
|
||||
if (
|
||||
last
|
||||
&& last.kind === "trace"
|
||||
&& !last.isStreaming
|
||||
&& (!last.activitySegmentId || last.activitySegmentId === segmentId)
|
||||
) {
|
||||
const previousTraces = last.traces?.length
|
||||
? last.traces
|
||||
: last.content
|
||||
? [last.content]
|
||||
: [];
|
||||
const merged: UIMessage = {
|
||||
...last,
|
||||
traces: [...(last.traces ?? [last.content]), ...lines],
|
||||
traces: [...previousTraces, ...lines],
|
||||
content: lines[lines.length - 1],
|
||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||
};
|
||||
return [...prev.slice(0, -1), merged];
|
||||
}
|
||||
@ -445,6 +668,7 @@ export function useNanobotStream(
|
||||
kind: "trace",
|
||||
content: lines[lines.length - 1],
|
||||
traces: lines,
|
||||
activitySegmentId: segmentId,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@ -459,11 +683,12 @@ export function useNanobotStream(
|
||||
|
||||
// A complete (non-streamed) assistant message. If a stream was in
|
||||
// flight, drop the placeholder so we don't render the text twice.
|
||||
const activeId = buffer.current?.messageId;
|
||||
buffer.current = null;
|
||||
// Do NOT reset isStreaming here — only ``turn_end`` signals that
|
||||
// the full turn (all tool calls + final text) is complete.
|
||||
setMessages((prev) => {
|
||||
const activeId = buffer.current?.messageId;
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||
const content = ev.text;
|
||||
const lat =
|
||||
@ -481,6 +706,46 @@ export function useNanobotStream(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.event === "file_edit") {
|
||||
const edits = Array.isArray(ev.edits) ? ev.edits : [];
|
||||
if (edits.length === 0) return;
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
let segmentId = fileEditSegmentRef.current;
|
||||
if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) {
|
||||
segmentId = detachedActivitySegmentId();
|
||||
fileEditSegmentRef.current = segmentId;
|
||||
}
|
||||
if (
|
||||
last
|
||||
&& last.kind === "trace"
|
||||
&& !last.isStreaming
|
||||
&& !!last.fileEdits?.length
|
||||
&& last.activitySegmentId === segmentId
|
||||
) {
|
||||
const merged: UIMessage = {
|
||||
...last,
|
||||
fileEdits: mergeFileEdits(last.fileEdits, edits),
|
||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||
};
|
||||
return [...prev.slice(0, -1), merged];
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "",
|
||||
traces: [],
|
||||
fileEdits: mergeFileEdits(undefined, edits),
|
||||
activitySegmentId: segmentId,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ``attached`` / ``error`` frames aren't actionable here; the client
|
||||
// shell handles them separately.
|
||||
};
|
||||
@ -489,12 +754,26 @@ export function useNanobotStream(
|
||||
return () => {
|
||||
unsub();
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
clearPendingStreamWork();
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [chatId, client, onTurnEnd]);
|
||||
}, [
|
||||
chatId,
|
||||
client,
|
||||
clearActivitySegment,
|
||||
clearPendingStreamWork,
|
||||
detachedActivitySegmentId,
|
||||
ensureActivitySegmentId,
|
||||
flushPendingStreamEvents,
|
||||
onTurnEnd,
|
||||
schedulePendingStreamFlush,
|
||||
]);
|
||||
|
||||
const send = useCallback(
|
||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
@ -504,17 +783,24 @@ export function useNanobotStream(
|
||||
// the image blocks via ``media`` paths.
|
||||
if (!hasImages && !content.trim()) return;
|
||||
|
||||
flushPendingStreamEvents();
|
||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||
setMessages((prev) => [
|
||||
...pruneReasoningOnlyPlaceholders(prev),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
},
|
||||
]);
|
||||
setMessages((prev) => {
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
return [
|
||||
...pruneReasoningOnlyPlaceholders(prev),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
// Mark streaming immediately so the UI shows the loading indicator
|
||||
// right away, before the first delta arrives from the server.
|
||||
setIsStreaming(true);
|
||||
@ -525,18 +811,23 @@ export function useNanobotStream(
|
||||
client.sendMessage(chatId, content, wireMedia);
|
||||
}
|
||||
},
|
||||
[chatId, client],
|
||||
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!chatId) return;
|
||||
flushPendingStreamEvents();
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
||||
);
|
||||
setMessages((prev) => {
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
client.sendMessage(chatId, "/stop");
|
||||
}, [chatId, client]);
|
||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const STORAGE_KEY = "nanobot-webui.theme";
|
||||
const ThemeContext = createContext<Theme>("light");
|
||||
|
||||
function readStored(): Theme | null {
|
||||
try {
|
||||
@ -18,7 +27,11 @@ function applyTheme(theme: Theme): void {
|
||||
else root.classList.remove("dark");
|
||||
}
|
||||
|
||||
export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Theme) => void } {
|
||||
export function useTheme(): {
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
setTheme: (t: Theme) => void;
|
||||
} {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = readStored();
|
||||
if (stored) return stored;
|
||||
@ -46,3 +59,11 @@ export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Th
|
||||
);
|
||||
return { theme, toggle, setTheme };
|
||||
}
|
||||
|
||||
export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
|
||||
return createElement(ThemeContext.Provider, { value: theme }, children);
|
||||
}
|
||||
|
||||
export function useThemeValue(): Theme {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
@ -335,7 +335,8 @@
|
||||
"io": "Couldn't read this file"
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Scroll to bottom"
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"loadEarlier": "Load earlier messages"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "streaming",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Cerrar objetivo"
|
||||
},
|
||||
"scrollToBottom": "Desplazarse al final"
|
||||
"scrollToBottom": "Desplazarse al final",
|
||||
"loadEarlier": "Cargar mensajes anteriores"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "transmitiendo",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Fermer l’objectif"
|
||||
},
|
||||
"scrollToBottom": "Faire défiler vers le bas"
|
||||
"scrollToBottom": "Faire défiler vers le bas",
|
||||
"loadEarlier": "Charger les messages précédents"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "en cours de génération",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Tutup tujuan"
|
||||
},
|
||||
"scrollToBottom": "Gulir ke bawah"
|
||||
"scrollToBottom": "Gulir ke bawah",
|
||||
"loadEarlier": "Muat pesan sebelumnya"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "sedang mengalir",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "目標を閉じる"
|
||||
},
|
||||
"scrollToBottom": "一番下へスクロール"
|
||||
"scrollToBottom": "一番下へスクロール",
|
||||
"loadEarlier": "以前のメッセージを読み込む"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "生成中",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "목표 닫기"
|
||||
},
|
||||
"scrollToBottom": "맨 아래로 스크롤"
|
||||
"scrollToBottom": "맨 아래로 스크롤",
|
||||
"loadEarlier": "이전 메시지 불러오기"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "생성 중",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Đóng mục tiêu"
|
||||
},
|
||||
"scrollToBottom": "Cuộn xuống cuối"
|
||||
"scrollToBottom": "Cuộn xuống cuối",
|
||||
"loadEarlier": "Tải tin nhắn trước đó"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "đang truyền",
|
||||
|
||||
@ -323,7 +323,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "关闭目标"
|
||||
},
|
||||
"scrollToBottom": "滚动到底部"
|
||||
"scrollToBottom": "滚动到底部",
|
||||
"loadEarlier": "加载更早消息"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "流式输出中",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "關閉目標"
|
||||
},
|
||||
"scrollToBottom": "捲動到底部"
|
||||
"scrollToBottom": "捲動到底部",
|
||||
"loadEarlier": "載入更早訊息"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "串流輸出中",
|
||||
|
||||
@ -1,10 +1,35 @@
|
||||
import i18n, { currentLocale } from "@/i18n";
|
||||
|
||||
const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
|
||||
"hi",
|
||||
"hello",
|
||||
"hey",
|
||||
"hello nano",
|
||||
"hello nanobot",
|
||||
"hi nano",
|
||||
"hi nanobot",
|
||||
"你好",
|
||||
"您好",
|
||||
"嗨",
|
||||
"哈喽",
|
||||
"哈啰",
|
||||
"在吗",
|
||||
]);
|
||||
|
||||
function isLowInformationTitlePreview(text: string): boolean {
|
||||
const normalized = text.toLowerCase().replace(/[.!?。!?~~\s]+$/g, "").trim();
|
||||
return (
|
||||
normalized.startsWith("/") ||
|
||||
LOW_INFORMATION_TITLE_PREVIEWS.has(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
/** Truncate the first user message into a chat title. */
|
||||
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
||||
if (!preview) return fallback;
|
||||
const oneLine = preview.replace(/\s+/g, " ").trim();
|
||||
if (!oneLine) return fallback;
|
||||
if (isLowInformationTitlePreview(oneLine)) return fallback;
|
||||
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
||||
}
|
||||
|
||||
|
||||
@ -54,7 +54,8 @@ type Unsubscribe = () => void;
|
||||
type EventHandler = (ev: InboundEvent) => void;
|
||||
type StatusHandler = (status: ConnectionStatus) => void;
|
||||
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
|
||||
type SessionUpdateHandler = (chatId: string) => void;
|
||||
type SessionUpdateScope = "metadata" | "thread" | string;
|
||||
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
|
||||
|
||||
/** Structured connection-level errors surfaced to the UI.
|
||||
*
|
||||
@ -364,7 +365,7 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
if (parsed.event === "session_updated") {
|
||||
this.emitSessionUpdate(parsed.chat_id);
|
||||
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -382,9 +383,9 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private emitSessionUpdate(chatId: string): void {
|
||||
private emitSessionUpdate(chatId: string, scope?: SessionUpdateScope): void {
|
||||
for (const handler of this.sessionUpdateHandlers) {
|
||||
handler(chatId);
|
||||
handler(chatId, scope);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,6 +40,10 @@ export interface UIMessage {
|
||||
/** For trace rows: each individual hint line, so consecutive hints can
|
||||
* render as a single collapsible group. */
|
||||
traces?: string[];
|
||||
/** Activity rows: explicit file edits emitted by edit tools. */
|
||||
fileEdits?: UIFileEdit[];
|
||||
/** Activity rows created during the same agent phase share one collapsible block. */
|
||||
activitySegmentId?: string;
|
||||
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
|
||||
images?: UIImage[];
|
||||
/** Signed or local UI-renderable media attachments. */
|
||||
@ -80,6 +84,20 @@ export interface ToolProgressEvent {
|
||||
embeds?: unknown[];
|
||||
}
|
||||
|
||||
export interface UIFileEdit {
|
||||
version?: number;
|
||||
call_id: string;
|
||||
tool: string;
|
||||
path: string;
|
||||
phase?: "start" | "end" | "error" | string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate?: boolean;
|
||||
status: "editing" | "done" | "error";
|
||||
binary?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ChatSummary {
|
||||
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
||||
key: string;
|
||||
@ -110,6 +128,7 @@ export interface SettingsPayload {
|
||||
name: string;
|
||||
label: string;
|
||||
configured: boolean;
|
||||
api_key_required?: boolean;
|
||||
api_key_hint?: string | null;
|
||||
api_base?: string | null;
|
||||
default_api_base?: string | null;
|
||||
@ -182,6 +201,11 @@ export type InboundEvent =
|
||||
/** Optional structured payload on progress frames (channel-specific). */
|
||||
agent_ui?: AgentUIBlob;
|
||||
}
|
||||
| {
|
||||
event: "file_edit";
|
||||
chat_id: string;
|
||||
edits: UIFileEdit[];
|
||||
}
|
||||
| {
|
||||
event: "delta";
|
||||
chat_id: string;
|
||||
@ -229,7 +253,7 @@ export type InboundEvent =
|
||||
chat_id: string;
|
||||
goal_state: GoalStateWsPayload;
|
||||
}
|
||||
| { event: "session_updated"; chat_id: string }
|
||||
| { event: "session_updated"; chat_id: string; scope?: "metadata" | "thread" | string }
|
||||
| { event: "error"; chat_id?: string; detail?: string };
|
||||
|
||||
/** Base64-encoded image attached to an outbound ``message`` envelope.
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
336
webui/src/tests/agent-activity-cluster.test.tsx
Normal file
336
webui/src/tests/agent-activity-cluster.test.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
|
||||
const rows: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: `thinking${extraReasoning}`,
|
||||
reasoningStreaming: true,
|
||||
isStreaming: true,
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "search()",
|
||||
traces: ["search()"],
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
if (extraTool) rows.push(extraTool);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function installAnimationFrameQueue() {
|
||||
const originalRequest = window.requestAnimationFrame;
|
||||
const originalCancel = window.cancelAnimationFrame;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextId = 1;
|
||||
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = ((id: number) => {
|
||||
callbacks.delete(id);
|
||||
}) as typeof window.cancelAnimationFrame;
|
||||
|
||||
return {
|
||||
flush() {
|
||||
const pending = Array.from(callbacks.entries());
|
||||
callbacks.clear();
|
||||
for (const [, callback] of pending) callback(0);
|
||||
},
|
||||
restore() {
|
||||
window.requestAnimationFrame = originalRequest;
|
||||
window.cancelAnimationFrame = originalCancel;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setScrollGeometry(
|
||||
element: HTMLElement,
|
||||
geometry: { scrollHeight: number; clientHeight: number; scrollTop?: number },
|
||||
) {
|
||||
Object.defineProperties(element, {
|
||||
scrollHeight: { configurable: true, value: geometry.scrollHeight },
|
||||
clientHeight: { configurable: true, value: geometry.clientHeight },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
value: geometry.scrollTop ?? element.scrollTop,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function installReducedMotion() {
|
||||
const original = window.matchMedia;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}),
|
||||
});
|
||||
return () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentActivityCluster", () => {
|
||||
it("jumps to the latest activity when opened", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(880);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("follows new reasoning and tool activity while the user is at the bottom", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages(" with more detail", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "open_browser()",
|
||||
traces: ["open_browser()"],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1500,
|
||||
clientHeight: 120,
|
||||
scrollTop: scrollport.scrollTop,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(1380);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not pull the user down after they scroll up inside the activity pane", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
scrollport.scrollTop = 100;
|
||||
fireEvent.scroll(scrollport);
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages(" still streaming")}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1500,
|
||||
clientHeight: 120,
|
||||
scrollTop: scrollport.scrollTop,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(100);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders file edit totals and a compact expanded file list", async () => {
|
||||
const restoreMotion = installReducedMotion();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages("", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "src/app.tsx",
|
||||
phase: "end",
|
||||
added: 12,
|
||||
deleted: 3,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
}],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
|
||||
|
||||
expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Edited")).not.toBeInTheDocument();
|
||||
const fileRef = screen.getByTestId("activity-file-reference");
|
||||
expect(fileRef).toHaveTextContent("src/app.tsx");
|
||||
expect(fileRef).toHaveAttribute("aria-label", "src/app.tsx");
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
restoreMotion();
|
||||
}
|
||||
});
|
||||
|
||||
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
|
||||
const restoreMotion = installReducedMotion();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages("", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [
|
||||
{
|
||||
call_id: "call-edit-1",
|
||||
tool: "edit_file",
|
||||
path: "minecraft-fps/index.html",
|
||||
phase: "end",
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
},
|
||||
{
|
||||
call_id: "call-edit-2",
|
||||
tool: "edit_file",
|
||||
path: "minecraft-fps/index.html",
|
||||
phase: "error",
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
status: "error",
|
||||
error: "patch failed",
|
||||
},
|
||||
{
|
||||
call_id: "call-edit-3",
|
||||
tool: "edit_file",
|
||||
path: "minecraft-fps/index.html",
|
||||
phase: "end",
|
||||
added: 6,
|
||||
deleted: 6,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
},
|
||||
],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i }));
|
||||
|
||||
const fileRefs = screen.getAllByTestId("activity-file-reference");
|
||||
expect(fileRefs).toHaveLength(1);
|
||||
expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html");
|
||||
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("+8").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("-7").length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
restoreMotion();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
@ -8,6 +8,7 @@ const refreshSpy = vi.fn();
|
||||
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
||||
const deleteChatSpy = vi.fn();
|
||||
const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
|
||||
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
@ -32,12 +33,18 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/useTheme", () => ({
|
||||
useTheme: () => ({
|
||||
theme: "light" as const,
|
||||
toggle: toggleThemeSpy,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/useTheme", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
ThemeProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
useTheme: () => ({
|
||||
theme: "light" as const,
|
||||
toggle: toggleThemeSpy,
|
||||
}),
|
||||
useThemeValue: () => "light" as const,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/bootstrap", () => ({
|
||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||
@ -64,22 +71,30 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
newChat = vi.fn();
|
||||
attach = vi.fn();
|
||||
close = vi.fn();
|
||||
updateUrl = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
}
|
||||
|
||||
return { NanobotClient: MockClient };
|
||||
});
|
||||
|
||||
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||
import App from "@/App";
|
||||
|
||||
describe("App layout", () => {
|
||||
beforeEach(() => {
|
||||
mockSessions = [];
|
||||
connectSpy.mockClear();
|
||||
updateUrlSpy.mockClear();
|
||||
refreshSpy.mockReset();
|
||||
createChatSpy.mockClear();
|
||||
deleteChatSpy.mockReset();
|
||||
toggleThemeSpy.mockReset();
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
});
|
||||
vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
@ -89,6 +104,10 @@ describe("App layout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps sidebar layout out of the main thread width contract", async () => {
|
||||
const { container } = render(<App />);
|
||||
|
||||
@ -192,8 +211,52 @@ describe("App layout", () => {
|
||||
name: "openrouter",
|
||||
label: "OpenRouter",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
default_api_base: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
{
|
||||
name: "azure_openai",
|
||||
label: "Azure OpenAI",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "huggingface",
|
||||
label: "Hugging Face",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "siliconflow",
|
||||
label: "SiliconFlow",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "volcengine",
|
||||
label: "VolcEngine",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "byteplus",
|
||||
label: "BytePlus",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "qianfan",
|
||||
label: "Qianfan",
|
||||
configured: false,
|
||||
api_key_required: true,
|
||||
},
|
||||
{
|
||||
name: "atomic_chat",
|
||||
label: "Atomic Chat",
|
||||
configured: false,
|
||||
api_key_required: false,
|
||||
default_api_base: "http://localhost:1337/v1",
|
||||
},
|
||||
],
|
||||
web_search: {
|
||||
provider: "brave",
|
||||
@ -248,6 +311,9 @@ describe("App layout", () => {
|
||||
fireEvent.click(screen.getByText("OpenAI"));
|
||||
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Atomic Chat"));
|
||||
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
|
||||
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
||||
@ -426,4 +492,36 @@ describe("App layout", () => {
|
||||
|
||||
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes the bootstrap token before REST settings auth expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fetchBootstrap)
|
||||
.mockResolvedValueOnce({
|
||||
token: "tok-1",
|
||||
ws_path: "/",
|
||||
expires_in: 30,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
token: "tok-2",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
});
|
||||
vi.mocked(deriveWsUrl).mockImplementation(
|
||||
(_wsPath: string, token: string) => `ws://test?token=${token}`,
|
||||
);
|
||||
|
||||
const { unmount } = render(<App />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(connectSpy).toHaveBeenCalled();
|
||||
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
});
|
||||
|
||||
expect(fetchBootstrap).toHaveBeenCalledTimes(2);
|
||||
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
104
webui/src/tests/code-block.test.tsx
Normal file
104
webui/src/tests/code-block.test.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { ThemeProvider } from "@/hooks/useTheme";
|
||||
|
||||
const mockedStyles = vi.hoisted(() => ({
|
||||
dark: { pre: { background: "#111" } },
|
||||
light: { pre: { background: "#fff" } },
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => ({
|
||||
default: ({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: string;
|
||||
style: Record<string, unknown>;
|
||||
}) => (
|
||||
<pre
|
||||
data-testid="highlighted-code"
|
||||
data-theme={style === mockedStyles.dark ? "dark" : "light"}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-dark", () => ({
|
||||
default: mockedStyles.dark,
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
|
||||
default: mockedStyles.light,
|
||||
}));
|
||||
|
||||
describe("CodeBlock", () => {
|
||||
it("renders plain code without mounting the highlighter when highlighting is disabled", () => {
|
||||
render(
|
||||
<ThemeProvider theme="dark">
|
||||
<CodeBlock language="ts" code="const value = 1;" highlight={false} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
|
||||
expect(screen.getByText("ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads theme from context without creating per-block observers", async () => {
|
||||
const originalMutationObserver = globalThis.MutationObserver;
|
||||
const observer = vi.fn();
|
||||
class MockMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
observer(callback);
|
||||
}
|
||||
|
||||
observe = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("MutationObserver", MockMutationObserver);
|
||||
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<ThemeProvider theme="dark">
|
||||
<CodeBlock language="ts" code="const value = 1;" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
|
||||
"data-theme",
|
||||
"dark",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ThemeProvider theme="light">
|
||||
<CodeBlock language="ts" code="const value = 1;" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
|
||||
"data-theme",
|
||||
"light",
|
||||
);
|
||||
expect(observer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.stubGlobal("MutationObserver", originalMutationObserver);
|
||||
}
|
||||
});
|
||||
});
|
||||
82
webui/src/tests/markdown-text.test.tsx
Normal file
82
webui/src/tests/markdown-text.test.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
|
||||
const rendererSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/MarkdownTextRenderer", () => ({
|
||||
default: ({
|
||||
children,
|
||||
highlightCode,
|
||||
}: {
|
||||
children: string;
|
||||
highlightCode?: boolean;
|
||||
}) => {
|
||||
rendererSpy({ children, highlightCode });
|
||||
return (
|
||||
<div
|
||||
data-testid="markdown-renderer"
|
||||
data-highlight-code={String(highlightCode)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe("MarkdownText", () => {
|
||||
it("throttles streaming markdown commits and flushes before final highlighting", async () => {
|
||||
rendererSpy.mockClear();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<MarkdownText streaming>hello</MarkdownText>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-highlight-code",
|
||||
"false",
|
||||
);
|
||||
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<MarkdownText streaming>hello world</MarkdownText>);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
|
||||
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(79);
|
||||
});
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
|
||||
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
|
||||
expect(rendererSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
rerender(<MarkdownText streaming>hello world!!!</MarkdownText>);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
|
||||
|
||||
rerender(<MarkdownText>hello world!!!</MarkdownText>);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-highlight-code",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
@ -131,7 +131,9 @@ describe("MessageBubble", () => {
|
||||
|
||||
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
|
||||
expect(container.querySelector(".reasoning-sheen-stripe")).toBeInTheDocument();
|
||||
expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Thinking…")).toHaveClass("streaming-text-sheen");
|
||||
expect(screen.getByText("Thinking…")).toHaveAttribute("data-sheen-text", "Thinking…");
|
||||
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
|
||||
});
|
||||
|
||||
@ -177,6 +179,47 @@ describe("MessageBubble", () => {
|
||||
expect(screen.getByText("Body line.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders inline file paths as compact file references", async () => {
|
||||
await import("@/components/MarkdownTextRenderer");
|
||||
const message: UIMessage = {
|
||||
id: "a-file-path",
|
||||
role: "assistant",
|
||||
content:
|
||||
"改动在 `webui/src/components/MarkdownTextRenderer.tsx` 和 `/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html`。",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
const references = await screen.findAllByTestId("inline-file-path");
|
||||
expect(references).toHaveLength(2);
|
||||
expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]");
|
||||
expect(references[0].parentElement).toHaveClass("align-[0.14em]");
|
||||
expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx");
|
||||
expect(references[0]).not.toHaveTextContent("webui/src/components");
|
||||
expect(screen.getByText("index.html")).toBeInTheDocument();
|
||||
expect(references[1]).not.toHaveTextContent("/Users/renxubin");
|
||||
expect(references[1]).not.toHaveAttribute("title");
|
||||
expect(references[1]).toHaveAttribute(
|
||||
"aria-label",
|
||||
"/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
fireEvent.pointerMove(references[1].parentElement!);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent(
|
||||
"/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders assistant image media as a larger generated result", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-image",
|
||||
|
||||
@ -233,9 +233,13 @@ describe("NanobotClient", () => {
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
lastSocket().fakeMessage({ event: "session_updated", chat_id: "chat-title" });
|
||||
lastSocket().fakeMessage({
|
||||
event: "session_updated",
|
||||
chat_id: "chat-title",
|
||||
scope: "metadata",
|
||||
});
|
||||
|
||||
expect(globalHandler).toHaveBeenCalledWith("chat-title");
|
||||
expect(globalHandler).toHaveBeenCalledWith("chat-title", "metadata");
|
||||
expect(chatHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import {
|
||||
assistantCopyFlags,
|
||||
buildDisplayUnits,
|
||||
ThreadMessages,
|
||||
} from "@/components/thread/ThreadMessages";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
describe("ThreadMessages", () => {
|
||||
@ -51,6 +55,206 @@ describe("ThreadMessages", () => {
|
||||
expect(rows[1]).toHaveClass("mt-4");
|
||||
});
|
||||
|
||||
it("starts a new activity cluster when the activity segment changes", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first pass",
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "foo.txt",
|
||||
phase: "end",
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
status: "done",
|
||||
}],
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second pass",
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
]);
|
||||
expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([
|
||||
"r2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not split ordinary tool activity just because segment ids changed", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first pass",
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "read_file()",
|
||||
traces: ["read_file()"],
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second pass",
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "grep()",
|
||||
traces: ["grep()"],
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(1);
|
||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
"r2",
|
||||
"t2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("only marks the current activity cluster as live while streaming", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first pass",
|
||||
reasoningStreaming: true,
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 4,
|
||||
deleted: 1,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second pass",
|
||||
reasoningStreaming: true,
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds final answer reasoning into the preceding activity cluster", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "search plan",
|
||||
reasoningStreaming: false,
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "web_search()",
|
||||
traces: ["web_search()"],
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "final answer",
|
||||
reasoning: "summarize results",
|
||||
reasoningStreaming: false,
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0]).toMatchObject({ type: "cluster" });
|
||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
"a1-reasoning",
|
||||
]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "single",
|
||||
message: {
|
||||
id: "a1",
|
||||
content: "final answer",
|
||||
},
|
||||
});
|
||||
if (units[1].type === "single") {
|
||||
expect(units[1].message).not.toHaveProperty("reasoning");
|
||||
}
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("final answer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows copy only on the last assistant slice before the next user turn", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
@ -89,4 +293,37 @@ describe("ThreadMessages", () => {
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("computes final assistant copy flags with user-boundary semantics", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "draft", createdAt: 2 },
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "tool()",
|
||||
traces: ["tool()"],
|
||||
createdAt: 3,
|
||||
},
|
||||
{ id: "a2", role: "assistant", content: "final", createdAt: 4 },
|
||||
{ id: "u2", role: "user", content: "two", createdAt: 5 },
|
||||
{ id: "a3", role: "assistant", content: "next", createdAt: 6 },
|
||||
]);
|
||||
|
||||
const flags = assistantCopyFlags(units);
|
||||
const assistantFlags = units
|
||||
.map((unit, index) =>
|
||||
unit.type === "single" && unit.message.role === "assistant"
|
||||
? [unit.message.id, flags[index]]
|
||||
: null,
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(assistantFlags).toEqual([
|
||||
["a1", false],
|
||||
["a2", true],
|
||||
["a3", true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ import type { UIMessage } from "@/lib/types";
|
||||
function makeClient() {
|
||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||
return {
|
||||
status: "open" as const,
|
||||
@ -34,7 +34,7 @@ function makeClient() {
|
||||
errorHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
||||
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
|
||||
sessionUpdateHandlers.add(handler);
|
||||
return () => {
|
||||
sessionUpdateHandlers.delete(handler);
|
||||
@ -49,8 +49,8 @@ function makeClient() {
|
||||
}
|
||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||
},
|
||||
_emitSessionUpdate(chatId: string) {
|
||||
for (const h of sessionUpdateHandlers) h(chatId);
|
||||
_emitSessionUpdate(chatId: string, scope?: string) {
|
||||
for (const h of sessionUpdateHandlers) h(chatId, scope);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
@ -651,6 +651,52 @@ describe("ThreadShell", () => {
|
||||
expect(historyCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("does not refetch thread history for metadata-only session updates", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
return httpJson(
|
||||
transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
||||
expect(historyCalls).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
client._emitSessionUpdate("chat-a", "metadata");
|
||||
});
|
||||
|
||||
expect(historyCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
|
||||
const client = makeClient();
|
||||
const scrollIntoView = vi.fn();
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import {
|
||||
HISTORY_WINDOW_INCREMENT,
|
||||
INITIAL_HISTORY_WINDOW,
|
||||
ThreadViewport,
|
||||
windowMessages,
|
||||
} from "@/components/thread/ThreadViewport";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
const messages: UIMessage[] = [
|
||||
@ -15,7 +20,191 @@ const messages: UIMessage[] = [
|
||||
|
||||
const emptyMessages: UIMessage[] = [];
|
||||
|
||||
interface ResizeObserverInstance {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeLongMessages(count: number): UIMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
role: "user" as const,
|
||||
content: `message ${index}`,
|
||||
createdAt: index,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("ThreadViewport", () => {
|
||||
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const resizeObservers: ResizeObserverInstance[] = [];
|
||||
class MockResizeObserver {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect = vi.fn();
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.element = element;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
|
||||
try {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: "Scroll to bottom" });
|
||||
expect(button).toHaveStyle({ bottom: "192px" });
|
||||
|
||||
const composerDock = screen.getByTestId("thread-composer-dock");
|
||||
composerDock.getBoundingClientRect = () =>
|
||||
({
|
||||
height: 240,
|
||||
width: 800,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 240,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
const composerObserver = resizeObservers.find(
|
||||
(observer) => observer.element === composerDock,
|
||||
);
|
||||
expect(composerObserver).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
||||
});
|
||||
|
||||
expect(button).toHaveStyle({ bottom: "256px" });
|
||||
} finally {
|
||||
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div>composer</div>}
|
||||
emptyState={<div>welcome</div>}
|
||||
showScrollToBottomButton={false}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the tail window for long history by default", () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={longMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("message 140")).toBeInTheDocument();
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Load earlier messages" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads earlier history in fixed increments without rendering the whole transcript", () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={longMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load earlier messages" }));
|
||||
|
||||
const firstVisible =
|
||||
300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT;
|
||||
|
||||
expect(
|
||||
screen.queryByText(`message ${firstVisible - 1}`),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands the window start to avoid cutting an agent activity cluster", () => {
|
||||
const clustered = makeLongMessages(200);
|
||||
clustered.splice(
|
||||
38,
|
||||
3,
|
||||
{
|
||||
id: "r0",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first reasoning",
|
||||
createdAt: 38,
|
||||
},
|
||||
{
|
||||
id: "t0",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "tool()",
|
||||
traces: ["tool()"],
|
||||
createdAt: 39,
|
||||
},
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second reasoning",
|
||||
createdAt: 40,
|
||||
},
|
||||
);
|
||||
|
||||
const visible = windowMessages(clustered, INITIAL_HISTORY_WINDOW);
|
||||
|
||||
expect(visible[0].id).toBe("r0");
|
||||
expect(visible).toHaveLength(INITIAL_HISTORY_WINDOW + 2);
|
||||
});
|
||||
|
||||
it("resets to the bottom when opening a different conversation", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
110
webui/src/tests/useDeferredTitleRefresh.test.tsx
Normal file
110
webui/src/tests/useDeferredTitleRefresh.test.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(overrides: Partial<ChatSummary> = {}): ChatSummary {
|
||||
return {
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
title: "",
|
||||
preview: "First user message",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDeferredTitleRefresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("retries refreshing untitled sessions after turn_end", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useDeferredTitleRefresh(session(), refresh, [100, 300]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("stops pending retries once a generated title arrives", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeSession }) =>
|
||||
useDeferredTitleRefresh(activeSession, refresh, [100, 300]),
|
||||
{ initialProps: { activeSession: session() } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
rerender({ activeSession: session({ title: "Generated title" }) });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry when the active session already has a title", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useDeferredTitleRefresh(session({ title: "Existing title" }), refresh, [100]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears pending retries when the active chat changes", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeSession }) =>
|
||||
useDeferredTitleRefresh(activeSession, refresh, [100]),
|
||||
{ initialProps: { activeSession: session() } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
rerender({
|
||||
activeSession: session({
|
||||
key: "websocket:chat-b",
|
||||
chatId: "chat-b",
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -83,7 +83,112 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
|
||||
};
|
||||
}
|
||||
|
||||
async function flushStreamFrame() {
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("useNanobotStream", () => {
|
||||
it("batches answer deltas into one animation-frame update", async () => {
|
||||
const fake = fakeClient();
|
||||
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
|
||||
const { result } = renderHook(() => useNanobotStream("chat-batch", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-batch", {
|
||||
event: "delta",
|
||||
chat_id: "chat-batch",
|
||||
text: "Hello",
|
||||
});
|
||||
fake.emit("chat-batch", {
|
||||
event: "delta",
|
||||
chat_id: "chat-batch",
|
||||
text: " world",
|
||||
});
|
||||
});
|
||||
|
||||
expect(requestFrame).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Hello world",
|
||||
isStreaming: true,
|
||||
});
|
||||
requestFrame.mockRestore();
|
||||
});
|
||||
|
||||
it("flushes pending delta text before turn_end finalizes the turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-flush", {
|
||||
event: "delta",
|
||||
chat_id: "chat-flush",
|
||||
text: "final chunk",
|
||||
});
|
||||
fake.emit("chat-flush", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-flush",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "final chunk",
|
||||
isStreaming: false,
|
||||
});
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("drops pending stream work when switching chats", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result, rerender } = renderHook(
|
||||
({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES),
|
||||
{
|
||||
wrapper: wrap(fake.client),
|
||||
initialProps: { chatId: "chat-old" },
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-old", {
|
||||
event: "delta",
|
||||
chat_id: "chat-old",
|
||||
text: "stale",
|
||||
});
|
||||
});
|
||||
|
||||
rerender({ chatId: "chat-new" });
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-new", {
|
||||
event: "delta",
|
||||
chat_id: "chat-new",
|
||||
text: "fresh",
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "fresh",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts in streaming mode when history shows pending tool calls", () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [{
|
||||
@ -203,7 +308,174 @@ describe("useNanobotStream", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
|
||||
it("renders live file_edit events as their own activity trace", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-file-edit", {
|
||||
event: "message",
|
||||
chat_id: "chat-file-edit",
|
||||
text: 'write_file({"path":"foo.txt"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-file-edit", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-edit",
|
||||
edits: [{
|
||||
call_id: "call-write",
|
||||
tool: "write_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-file-edit", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-edit",
|
||||
edits: [{
|
||||
call_id: "call-write",
|
||||
tool: "write_file",
|
||||
path: "foo.txt",
|
||||
phase: "end",
|
||||
added: 3,
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: ['write_file({"path":"foo.txt"})'],
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
fileEdits: [{
|
||||
call_id: "call-write",
|
||||
status: "done",
|
||||
added: 3,
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
}],
|
||||
});
|
||||
expect(result.current.messages[1].activitySegmentId).toBeTruthy();
|
||||
expect(result.current.messages[1].activitySegmentId).not.toBe(
|
||||
result.current.messages[0].activitySegmentId,
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "delta",
|
||||
chat_id: "chat-stream-segments",
|
||||
text: "I created the files.",
|
||||
});
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-stream-segments",
|
||||
});
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "message",
|
||||
chat_id: "chat-stream-segments",
|
||||
text: 'write_file({"path":"minecraft-fps/options.txt"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "delta",
|
||||
chat_id: "chat-stream-segments",
|
||||
text: "Now I will summarize the edits.",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "I created the files.",
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: ['write_file({"path":"minecraft-fps/options.txt"})'],
|
||||
});
|
||||
expect(result.current.messages[2]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Now I will summarize the edits.",
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a new activity segment for reasoning after file edit activity", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-file-segments",
|
||||
text: "Plan.",
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "reasoning_end",
|
||||
chat_id: "chat-file-segments",
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "message",
|
||||
chat_id: "chat-file-segments",
|
||||
text: 'edit_file({"path":"foo.txt"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-segments",
|
||||
edits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-file-segments",
|
||||
text: "Review result.",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(4);
|
||||
const firstSegment = result.current.messages[0].activitySegmentId;
|
||||
expect(firstSegment).toBeTruthy();
|
||||
expect(result.current.messages[1].activitySegmentId).toBe(firstSegment);
|
||||
expect(result.current.messages[2].activitySegmentId).toBeTruthy();
|
||||
expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment);
|
||||
expect(result.current.messages[3].activitySegmentId).toBe(firstSegment);
|
||||
});
|
||||
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@ -222,6 +494,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].role).toBe("assistant");
|
||||
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
|
||||
@ -328,7 +602,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("does not attach a new turn's reasoning across the latest user boundary", () => {
|
||||
it("does not attach a new turn's reasoning across the latest user boundary", async () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [
|
||||
{
|
||||
@ -358,6 +632,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
||||
expect(result.current.messages[2].role).toBe("assistant");
|
||||
@ -366,7 +642,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[2].reasoningStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attach reasoning across a tool trace boundary", () => {
|
||||
it("does not attach reasoning across a tool trace boundary", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@ -392,6 +668,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
||||
"message",
|
||||
@ -651,7 +929,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].content).toBe("long task");
|
||||
});
|
||||
|
||||
it("keeps streaming alive across stream_end and completes on turn_end", () => {
|
||||
it("keeps streaming alive across stream_end and completes on turn_end", async () => {
|
||||
const fake = fakeClient();
|
||||
const onTurnEnd = vi.fn();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
|
||||
@ -666,6 +944,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
|
||||
@ -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([
|
||||
{
|
||||
|
||||
22
webui/src/types/react-syntax-highlighter-subpaths.d.ts
vendored
Normal file
22
webui/src/types/react-syntax-highlighter-subpaths.d.ts
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
declare module "react-syntax-highlighter/dist/esm/prism-async-light" {
|
||||
import * as React from "react";
|
||||
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
|
||||
|
||||
export default class SyntaxHighlighter extends React.Component<SyntaxHighlighterProps> {
|
||||
static registerLanguage(name: string, func: unknown): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-dark" {
|
||||
import type * as React from "react";
|
||||
|
||||
const style: { [key: string]: React.CSSProperties };
|
||||
export default style;
|
||||
}
|
||||
|
||||
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-light" {
|
||||
import type * as React from "react";
|
||||
|
||||
const style: { [key: string]: React.CSSProperties };
|
||||
export default style;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
"exclude": ["src/tests/**"]
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user