mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +03:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44ee6cb27 | ||
|
|
5be3df1d6f | ||
|
|
79c23787f6 | ||
|
|
b647aa5f47 | ||
|
|
a9a8bdcef6 | ||
|
|
2d81cc0ae1 | ||
|
|
aed6b6967c | ||
|
|
3874b3acf4 | ||
|
|
01725bab11 | ||
|
|
a786e3d225 | ||
|
|
626f262121 | ||
|
|
7caf492ae2 | ||
|
|
9aa2ab1657 | ||
|
|
971b774282 | ||
|
|
1377759705 | ||
|
|
d56bafa6d0 | ||
|
|
6ec6c9bb83 | ||
|
|
8a2a5eecdd | ||
|
|
08154b4374 | ||
|
|
880097acd5 | ||
|
|
e02615c93d | ||
|
|
e9259e680e | ||
|
|
a5b85a3d6b | ||
|
|
d4ade8f680 | ||
|
|
28d0f8560e | ||
|
|
ba38f90832 | ||
|
|
eb3aed359f | ||
|
|
4445fcc8b9 | ||
|
|
b67205f5aa | ||
|
|
de8761f25a | ||
|
|
8708ccea86 | ||
|
|
eb0ff3ad1d | ||
|
|
c58a360b25 | ||
|
|
5bb94edc99 | ||
|
|
888d54790d | ||
|
|
48d35bd2d9 | ||
|
|
fce1550814 | ||
|
|
bf8a6e35fd | ||
|
|
f017e209da | ||
|
|
5a34504b76 | ||
|
|
af26ed0041 | ||
|
|
112f40ad67 | ||
|
|
2f323e24c1 | ||
|
|
361f31c0e4 | ||
|
|
945f208d38 | ||
|
|
c8bb04a8fe | ||
|
|
4b5de66c58 | ||
|
|
9340567f2d | ||
|
|
e5be4dac7a | ||
|
|
175b58e259 | ||
|
|
3bf8de047a | ||
|
|
400f822601 | ||
|
|
9fb9d7afcb | ||
|
|
82c323c2d9 |
+6
-4
@@ -14,8 +14,9 @@ RUN apt-get update && \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python dependencies first (cached layer)
|
# Install Python dependencies first (cached layer). Hatch reads the custom build
|
||||||
COPY pyproject.toml README.md LICENSE ./
|
# 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 && \
|
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
||||||
uv pip install --system --no-cache . && \
|
uv pip install --system --no-cache . && \
|
||||||
rm -rf nanobot bridge
|
rm -rf nanobot bridge
|
||||||
@@ -23,6 +24,7 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
|||||||
# Copy the full source and install
|
# Copy the full source and install
|
||||||
COPY nanobot/ nanobot/
|
COPY nanobot/ nanobot/
|
||||||
COPY bridge/ bridge/
|
COPY bridge/ bridge/
|
||||||
|
COPY webui/ webui/
|
||||||
RUN uv pip install --system --no-cache .
|
RUN uv pip install --system --no-cache .
|
||||||
|
|
||||||
# Build the WhatsApp bridge
|
# 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
|
USER nanobot
|
||||||
ENV HOME=/home/nanobot
|
ENV HOME=/home/nanobot
|
||||||
|
|
||||||
# Gateway default port
|
# Gateway health endpoint and optional WebUI/WebSocket channel ports
|
||||||
EXPOSE 18790
|
EXPOSE 18790 8765
|
||||||
|
|
||||||
ENTRYPOINT ["entrypoint.sh"]
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
CMD ["status"]
|
CMD ["status"]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
## 📢 News
|
## 📢 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-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-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.
|
- **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 18790:18790
|
- 18790:18790
|
||||||
|
- 8765:8765
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
|
|||||||
| **Wecom** | Bot ID + Bot Secret |
|
| **Wecom** | Bot ID + Bot Secret |
|
||||||
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
|
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
|
||||||
| **Mochat** | Claw token (auto-setup available) |
|
| **Mochat** | Claw token (auto-setup available) |
|
||||||
|
| **Signal** | signal-cli daemon + phone number |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
@@ -669,3 +670,69 @@ nanobot gateway
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Signal</b></summary>
|
||||||
|
|
||||||
|
Uses **signal-cli** daemon in HTTP mode — receive messages via SSE, send via JSON-RPC.
|
||||||
|
|
||||||
|
**1. Install signal-cli**
|
||||||
|
|
||||||
|
Install [signal-cli](https://github.com/AsamK/signal-cli) and register a phone number:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
signal-cli -u +1234567890 register
|
||||||
|
signal-cli -u +1234567890 verify <CODE>
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
signal-cli -a +1234567890 daemon --http localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"signal": {
|
||||||
|
"enabled": true,
|
||||||
|
"phoneNumber": "+1234567890",
|
||||||
|
"daemonHost": "localhost",
|
||||||
|
"daemonPort": 8080,
|
||||||
|
"dm": {
|
||||||
|
"enabled": true,
|
||||||
|
"policy": "open"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"enabled": true,
|
||||||
|
"policy": "open",
|
||||||
|
"requireMention": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> - `phoneNumber`: Your registered Signal phone number.
|
||||||
|
> - `daemonHost` / `daemonPort`: Where signal-cli daemon is listening (default `localhost:8080`).
|
||||||
|
> - `dm.policy`: `"open"` (anyone can DM) or `"allowlist"` (only listed numbers/UUIDs). When `"allowlist"`, unlisted DM senders receive a pairing code.
|
||||||
|
> - `dm.allowFrom`: List of allowed phone numbers or UUIDs (used when policy is `"allowlist"`).
|
||||||
|
> - `group.policy`: `"open"` (all groups) or `"allowlist"` (only listed group IDs).
|
||||||
|
> - `group.requireMention`: When `true` (default), the bot only responds in groups when @mentioned.
|
||||||
|
> - `group.allowFrom`: List of allowed group IDs (used when group policy is `"allowlist"`).
|
||||||
|
> - `attachmentsDir`: Override the directory where signal-cli stores inbound attachments. Defaults to `~/.local/share/signal-cli/attachments` (the Linux default). Set this if signal-cli runs with a custom `XDG_DATA_HOME` or on macOS/Windows.
|
||||||
|
> - `groupMessageBufferSize`: Number of recent group messages kept for context (default `20`, must be > 0).
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> The channel automatically reconnects to the signal-cli daemon with exponential backoff if the connection drops.
|
||||||
|
> Markdown in bot replies is automatically converted to Signal text styles (bold, italic, code, etc.).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
+82
-6
@@ -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
|
```ini
|
||||||
# /etc/systemd/system/nanobot.service (excerpt)
|
# /etc/systemd/system/nanobot.service (excerpt)
|
||||||
@@ -42,6 +87,35 @@ TELEGRAM_TOKEN=your-token-here
|
|||||||
IMAP_PASSWORD=your-password-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
|
## Providers
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
@@ -917,7 +991,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
|
|||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "brave",
|
"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": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "tavily",
|
"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": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "jina",
|
"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": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "kagi",
|
"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": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "olostep",
|
"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]
|
> [!TIP]
|
||||||
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
|
> 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 |
|
| 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. |
|
| `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. |
|
||||||
|
|||||||
+26
-2
@@ -10,6 +10,18 @@
|
|||||||
> [!IMPORTANT]
|
> [!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.
|
> 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
|
### Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -36,8 +48,20 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
|||||||
# Edit config on host to add API keys
|
# Edit config on host to add API keys
|
||||||
vim ~/.nanobot/config.json
|
vim ~/.nanobot/config.json
|
||||||
|
|
||||||
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat)
|
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
|
||||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway
|
# 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
|
# Or run a single command
|
||||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"
|
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"
|
||||||
|
|||||||
@@ -48,6 +48,28 @@ AIHubMix example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Gemini example (Imagen 4):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"gemini": {
|
||||||
|
"apiKey": "${GEMINI_API_KEY}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "gemini",
|
||||||
|
"model": "imagen-4.0-generate-001",
|
||||||
|
"defaultAspectRatio": "1:1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For Gemini Flash (which supports reference-image edits) see the [Gemini](#gemini) section below.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
||||||
|
|
||||||
@@ -69,7 +91,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
|
|||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
||||||
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Currently `openrouter` and `aihubmix` are supported |
|
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `gemini` |
|
||||||
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
||||||
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
|
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
|
||||||
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
|
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
|
||||||
@@ -139,6 +161,36 @@ Configure:
|
|||||||
|
|
||||||
`quality: low` is optional. It can make free image models faster and less likely to time out, but it is not required for correctness.
|
`quality: low` is optional. It can make free image models faster and less likely to time out, but it is not required for correctness.
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
nanobot supports two Gemini image generation model families via Google's Generative Language API:
|
||||||
|
|
||||||
|
| Model | Endpoint | Reference images |
|
||||||
|
|-------|----------|-----------------|
|
||||||
|
| `imagen-4.0-generate-001` | `:predict` | Not supported by this integration |
|
||||||
|
| `gemini-2.5-flash-image` | `:generateContent` | Supported |
|
||||||
|
|
||||||
|
For reference-image edits, use a Gemini Flash image model:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"gemini": {
|
||||||
|
"apiKey": "${GEMINI_API_KEY}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "gemini",
|
||||||
|
"model": "gemini-2.5-flash-image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Imagen 4 supports the aspect ratios `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`. Unsupported ratios are ignored and the model uses its default. The `defaultImageSize` setting has no effect on Gemini models; sizing is controlled by `defaultAspectRatio` only. Reference images passed with an Imagen model are ignored (with a warning logged).
|
||||||
|
|
||||||
## Artifacts
|
## Artifacts
|
||||||
|
|
||||||
Generated images are stored under the active nanobot instance's media directory:
|
Generated images are stored under the active nanobot instance's media directory:
|
||||||
@@ -193,7 +245,7 @@ Use the reference image. Keep the same robot and composition, change the palette
|
|||||||
|---------|-------|
|
|---------|-------|
|
||||||
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
|
||||||
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
|
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
|
||||||
| `unsupported image generation provider` | Use `openrouter` or `aihubmix` |
|
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, or `gemini` |
|
||||||
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
||||||
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
|
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
|
||||||
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
|
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Collection
|
from collections.abc import Collection
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
from typing import TYPE_CHECKING, Callable, Coroutine
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -37,27 +37,6 @@ class AutoCompact:
|
|||||||
def _format_summary(text: str, last_active: datetime) -> str:
|
def _format_summary(text: str, last_active: datetime) -> str:
|
||||||
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
|
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],
|
def check_expired(self, schedule_background: Callable[[Coroutine], None],
|
||||||
active_session_keys: Collection[str] = ()) -> None:
|
active_session_keys: Collection[str] = ()) -> None:
|
||||||
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
|
"""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:
|
async def _archive(self, key: str) -> None:
|
||||||
try:
|
try:
|
||||||
self.sessions.invalidate(key)
|
summary = await self.consolidator.compact_idle_session(
|
||||||
session = self.sessions.get_or_create(key)
|
key, self._RECENT_SUFFIX_MESSAGES,
|
||||||
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 ""
|
|
||||||
if summary and summary != "(nothing)":
|
if summary and summary != "(nothing)":
|
||||||
self._summaries[key] = (summary, last_active)
|
session = self.sessions.get_or_create(key)
|
||||||
session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()}
|
meta = session.metadata.get("_last_summary")
|
||||||
session.messages = kept_msgs
|
if isinstance(meta, dict):
|
||||||
session.last_consolidated = 0
|
self._summaries[key] = (
|
||||||
session.updated_at = datetime.now()
|
meta["text"],
|
||||||
self.sessions.save(session)
|
datetime.fromisoformat(meta["last_active"]),
|
||||||
if archive_msgs:
|
)
|
||||||
logger.info(
|
|
||||||
"Auto-compact: archived {} (archived={}, kept={}, summary={})",
|
|
||||||
key,
|
|
||||||
len(archive_msgs),
|
|
||||||
len(kept_msgs),
|
|
||||||
bool(summary),
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Auto-compact: failed for {}", key)
|
logger.exception("Auto-compact: failed for {}", key)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+30
-64
@@ -33,7 +33,6 @@ from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
|||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
goal_state_ws_blob,
|
|
||||||
runner_wall_llm_timeout_s,
|
runner_wall_llm_timeout_s,
|
||||||
)
|
)
|
||||||
from nanobot.session.manager import Session, SessionManager
|
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 image_placeholder_text
|
||||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||||
from nanobot.utils.image_generation_intent import image_generation_prompt
|
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.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
|
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 (
|
||||||
from nanobot.utils.webui_turn_helpers import publish_turn_run_status
|
WebuiTurnCoordinator,
|
||||||
|
build_bus_progress_callback,
|
||||||
|
mark_webui_session,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
@@ -136,6 +139,11 @@ class AgentLoop:
|
|||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
return self.tools.tool_names
|
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"
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||||
|
|
||||||
@@ -237,6 +245,11 @@ class AgentLoop:
|
|||||||
|
|
||||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||||
self.sessions = session_manager or SessionManager(workspace)
|
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()
|
self.tools = ToolRegistry()
|
||||||
# One file-read/write tracker per logical session. The tool registry is
|
# One file-read/write tracker per logical session. The tool registry is
|
||||||
# shared by this loop, so tools resolve the active state via contextvars.
|
# shared by this loop, so tools resolve the active state via contextvars.
|
||||||
@@ -524,34 +537,7 @@ class AgentLoop:
|
|||||||
self, msg: InboundMessage
|
self, msg: InboundMessage
|
||||||
) -> Callable[..., Awaitable[None]]:
|
) -> Callable[..., Awaitable[None]]:
|
||||||
"""Build a progress callback that publishes to the message bus."""
|
"""Build a progress callback that publishes to the message bus."""
|
||||||
|
return build_bus_progress_callback(self.bus, msg)
|
||||||
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
|
|
||||||
|
|
||||||
async def _build_retry_wait_callback(
|
async def _build_retry_wait_callback(
|
||||||
self, msg: InboundMessage
|
self, msg: InboundMessage
|
||||||
@@ -938,38 +924,12 @@ class AgentLoop:
|
|||||||
content="", metadata=msg.metadata or {},
|
content="", metadata=msg.metadata or {},
|
||||||
))
|
))
|
||||||
if msg.channel == "websocket":
|
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_lat = self._pending_turn_latency_ms.pop(session_key, None)
|
||||||
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
|
await self._webui_turns.handle_turn_end(
|
||||||
if turn_lat is not None:
|
msg,
|
||||||
turn_metadata["latency_ms"] = int(turn_lat)
|
session_key=session_key,
|
||||||
sess_turn = self.sessions.get_or_create(session_key)
|
latency_ms=turn_lat,
|
||||||
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())
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Task cancelled for session {}", session_key)
|
logger.info("Task cancelled for session {}", session_key)
|
||||||
# Preserve partial context from the interrupted turn so
|
# Preserve partial context from the interrupted turn so
|
||||||
@@ -1021,8 +981,9 @@ class AgentLoop:
|
|||||||
"Re-published {} leftover message(s) to bus for session {}",
|
"Re-published {} leftover message(s) to bus for session {}",
|
||||||
leftover, session_key,
|
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._pending_turn_latency_ms.pop(session_key, None)
|
||||||
|
self._webui_turns.discard(session_key)
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
"""Drain pending background archives, then close MCP connections."""
|
"""Drain pending background archives, then close MCP connections."""
|
||||||
@@ -1338,6 +1299,11 @@ class AgentLoop:
|
|||||||
"include_timestamps": True,
|
"include_timestamps": True,
|
||||||
}
|
}
|
||||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
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.initial_messages = self._build_initial_messages(
|
||||||
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
|
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
|
||||||
@@ -1354,7 +1320,7 @@ class AgentLoop:
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
async def _state_run(self, ctx: TurnContext) -> str:
|
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(
|
result = await self._run_agent_loop(
|
||||||
ctx.initial_messages,
|
ctx.initial_messages,
|
||||||
on_progress=ctx.on_progress,
|
on_progress=ctx.on_progress,
|
||||||
|
|||||||
+76
-1
@@ -678,11 +678,18 @@ class Consolidator:
|
|||||||
The budget reserves space for completion tokens and a safety buffer
|
The budget reserves space for completion tokens and a safety buffer
|
||||||
so the LLM request never exceeds the context window.
|
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
|
return
|
||||||
|
|
||||||
lock = self.get_lock(session.key)
|
lock = self.get_lock(session.key)
|
||||||
async with lock:
|
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
|
budget = self._input_token_budget
|
||||||
target = int(budget * self.consolidation_ratio)
|
target = int(budget * self.consolidation_ratio)
|
||||||
last_summary = await self._consolidate_replay_overflow(
|
last_summary = await self._consolidate_replay_overflow(
|
||||||
@@ -769,6 +776,74 @@ class Consolidator:
|
|||||||
# the summary injection strategy with AutoCompact._archive().
|
# the summary injection strategy with AutoCompact._archive().
|
||||||
self._persist_last_summary(session, last_summary)
|
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
|
# Dream — heavyweight cron-scheduled memory consolidation
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ from loguru import logger
|
|||||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
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 (
|
from nanobot.utils.helpers import (
|
||||||
IncrementalThinkExtractor,
|
IncrementalThinkExtractor,
|
||||||
build_assistant_message,
|
build_assistant_message,
|
||||||
@@ -26,6 +32,10 @@ from nanobot.utils.helpers import (
|
|||||||
strip_think,
|
strip_think,
|
||||||
truncate_text,
|
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.prompt_templates import render_template
|
||||||
from nanobot.utils.runtime import (
|
from nanobot.utils.runtime import (
|
||||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||||
@@ -813,6 +823,30 @@ class AgentRunner:
|
|||||||
return prep_error + hint, event, (
|
return prep_error + hint, event, (
|
||||||
RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
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:
|
try:
|
||||||
if tool is not None:
|
if tool is not None:
|
||||||
result = await tool.execute(**params)
|
result = await tool.execute(**params)
|
||||||
@@ -821,6 +855,11 @@ class AgentRunner:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except BaseException as exc:
|
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 = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -842,6 +881,11 @@ class AgentRunner:
|
|||||||
return payload, event, None
|
return payload, event, None
|
||||||
|
|
||||||
if isinstance(result, str) and result.startswith("Error"):
|
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 = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -860,6 +904,12 @@ class AgentRunner:
|
|||||||
return result + hint, event, RuntimeError(result)
|
return result + hint, event, RuntimeError(result)
|
||||||
return result + hint, event, None
|
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 = "" if result is None else str(result)
|
||||||
detail = detail.replace("\n", " ").strip()
|
detail = detail.replace("\n", " ").strip()
|
||||||
if not detail:
|
if not detail:
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ from nanobot.config.paths import get_media_dir
|
|||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
AIHubMixImageGenerationClient,
|
AIHubMixImageGenerationClient,
|
||||||
|
GeminiImageGenerationClient,
|
||||||
ImageGenerationError,
|
ImageGenerationError,
|
||||||
|
MiniMaxImageGenerationClient,
|
||||||
OpenRouterImageGenerationClient,
|
OpenRouterImageGenerationClient,
|
||||||
)
|
)
|
||||||
from nanobot.utils.artifacts import (
|
from nanobot.utils.artifacts import (
|
||||||
@@ -117,7 +119,9 @@ class ImageGenerationTool(Tool):
|
|||||||
def _provider_config(self) -> ProviderConfig | None:
|
def _provider_config(self) -> ProviderConfig | None:
|
||||||
return self.provider_configs.get(self.config.provider)
|
return self.provider_configs.get(self.config.provider)
|
||||||
|
|
||||||
def _provider_client(self) -> OpenRouterImageGenerationClient | AIHubMixImageGenerationClient | None:
|
def _provider_client(
|
||||||
|
self,
|
||||||
|
) -> OpenRouterImageGenerationClient | AIHubMixImageGenerationClient | MiniMaxImageGenerationClient | GeminiImageGenerationClient | None:
|
||||||
provider = self._provider_config()
|
provider = self._provider_config()
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"api_key": provider.api_key if provider else None,
|
"api_key": provider.api_key if provider else None,
|
||||||
@@ -129,6 +133,10 @@ class ImageGenerationTool(Tool):
|
|||||||
return OpenRouterImageGenerationClient(**kwargs)
|
return OpenRouterImageGenerationClient(**kwargs)
|
||||||
if self.config.provider == "aihubmix":
|
if self.config.provider == "aihubmix":
|
||||||
return AIHubMixImageGenerationClient(**kwargs)
|
return AIHubMixImageGenerationClient(**kwargs)
|
||||||
|
if self.config.provider == "minimax":
|
||||||
|
return MiniMaxImageGenerationClient(**kwargs)
|
||||||
|
if self.config.provider == "gemini":
|
||||||
|
return GeminiImageGenerationClient(**kwargs)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _missing_api_key_error(self) -> str:
|
def _missing_api_key_error(self) -> str:
|
||||||
@@ -137,6 +145,10 @@ class ImageGenerationTool(Tool):
|
|||||||
return "Error: OpenRouter API key is not configured. Set providers.openrouter.apiKey."
|
return "Error: OpenRouter API key is not configured. Set providers.openrouter.apiKey."
|
||||||
if provider == "aihubmix":
|
if provider == "aihubmix":
|
||||||
return "Error: AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
|
return "Error: AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
|
||||||
|
if provider == "minimax":
|
||||||
|
return "Error: MiniMax API key is not configured. Set providers.minimax.apiKey."
|
||||||
|
if provider == "gemini":
|
||||||
|
return "Error: Gemini API key is not configured. Set providers.gemini.apiKey."
|
||||||
return f"Error: {provider} API key is not configured."
|
return f"Error: {provider} API key is not configured."
|
||||||
|
|
||||||
def _resolve_reference_image(self, value: str) -> str:
|
def _resolve_reference_image(self, value: str) -> str:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -230,6 +230,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
|
|||||||
return f"{secret[:4]}••••{secret[-4:]}"
|
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], ...] = (
|
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
||||||
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
||||||
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
||||||
@@ -786,13 +805,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
providers = []
|
providers = []
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
provider_config = getattr(config.providers, spec.name, None)
|
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
|
continue
|
||||||
providers.append(
|
providers.append(
|
||||||
{
|
{
|
||||||
"name": spec.name,
|
"name": spec.name,
|
||||||
"label": spec.label,
|
"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_key_hint": _mask_secret_hint(provider_config.api_key),
|
||||||
"api_base": provider_config.api_base,
|
"api_base": provider_config.api_base,
|
||||||
"default_api_base": spec.default_api_base or None,
|
"default_api_base": spec.default_api_base or None,
|
||||||
@@ -862,7 +882,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if find_by_name(provider) is None:
|
if find_by_name(provider) is None:
|
||||||
return _http_error(400, "unknown provider")
|
return _http_error(400, "unknown provider")
|
||||||
provider_config = getattr(config.providers, provider, None)
|
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")
|
return _http_error(400, "provider is not configured")
|
||||||
if defaults.provider != provider:
|
if defaults.provider != provider:
|
||||||
defaults.provider = provider
|
defaults.provider = provider
|
||||||
@@ -885,7 +910,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not provider_name:
|
if not provider_name:
|
||||||
return _http_error(400, "provider is required")
|
return _http_error(400, "provider is required")
|
||||||
spec = find_by_name(provider_name)
|
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")
|
return _http_error(400, "unknown provider")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -1581,6 +1606,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not conns:
|
if not conns:
|
||||||
if (
|
if (
|
||||||
msg.metadata.get("_progress")
|
msg.metadata.get("_progress")
|
||||||
|
or msg.metadata.get("_file_edit_events")
|
||||||
or msg.metadata.get("_turn_end")
|
or msg.metadata.get("_turn_end")
|
||||||
or msg.metadata.get("_session_updated")
|
or msg.metadata.get("_session_updated")
|
||||||
or msg.metadata.get("_goal_status")
|
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)
|
await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob)
|
||||||
return
|
return
|
||||||
if msg.metadata.get("_session_updated"):
|
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
|
return
|
||||||
text = msg.content
|
text = msg.content
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
@@ -1780,12 +1821,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" goal_status ")
|
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."""
|
"""Notify clients that session metadata changed outside the main turn."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
if not conns:
|
if not conns:
|
||||||
return
|
return
|
||||||
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
|
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
|
||||||
|
if scope:
|
||||||
|
body["scope"] = scope
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" session_updated ")
|
await self._safe_send_to(connection, raw, label=" session_updated ")
|
||||||
|
|||||||
+77
-4
@@ -91,6 +91,8 @@ app = typer.Typer(
|
|||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
|
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
|
||||||
|
_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "。", "!", "?")
|
||||||
|
_REASONING_FLUSH_CHARS = 60
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI input: prompt_toolkit for editing, paste, history, and display
|
# 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]")
|
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:
|
def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
|
||||||
"""Print reasoning/thinking content in a distinct style."""
|
"""Print reasoning/thinking content in a distinct style."""
|
||||||
if not text.strip():
|
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]")
|
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:
|
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."""
|
"""Print an interactive progress line, pausing the spinner if needed."""
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
@@ -272,6 +313,7 @@ async def _maybe_print_interactive_progress(
|
|||||||
thinking: ThinkingSpinner | None,
|
thinking: ThinkingSpinner | None,
|
||||||
channels_config: Any,
|
channels_config: Any,
|
||||||
renderer: StreamRenderer | None = None,
|
renderer: StreamRenderer | None = None,
|
||||||
|
reasoning_buffer: _ReasoningBuffer | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
metadata = msg.metadata or {}
|
metadata = msg.metadata or {}
|
||||||
if metadata.get("_retry_wait"):
|
if metadata.get("_retry_wait"):
|
||||||
@@ -281,12 +323,24 @@ async def _maybe_print_interactive_progress(
|
|||||||
if not metadata.get("_progress"):
|
if not metadata.get("_progress"):
|
||||||
return False
|
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_tool_hint = metadata.get("_tool_hint", False)
|
||||||
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
|
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
|
||||||
if is_reasoning:
|
if is_reasoning:
|
||||||
if channels_config and not channels_config.show_reasoning:
|
if channels_config and not channels_config.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
return True
|
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
|
return True
|
||||||
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
|
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
|
||||||
return True
|
return True
|
||||||
@@ -588,6 +642,8 @@ def serve(
|
|||||||
image_generation_provider_configs={
|
image_generation_provider_configs={
|
||||||
"openrouter": runtime_config.providers.openrouter,
|
"openrouter": runtime_config.providers.openrouter,
|
||||||
"aihubmix": runtime_config.providers.aihubmix,
|
"aihubmix": runtime_config.providers.aihubmix,
|
||||||
|
"minimax": runtime_config.providers.minimax,
|
||||||
|
"gemini": runtime_config.providers.gemini,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -701,6 +757,8 @@ def _run_gateway(
|
|||||||
image_generation_provider_configs={
|
image_generation_provider_configs={
|
||||||
"openrouter": config.providers.openrouter,
|
"openrouter": config.providers.openrouter,
|
||||||
"aihubmix": config.providers.aihubmix,
|
"aihubmix": config.providers.aihubmix,
|
||||||
|
"minimax": config.providers.minimax,
|
||||||
|
"gemini": config.providers.gemini,
|
||||||
},
|
},
|
||||||
provider_snapshot_loader=load_provider_snapshot,
|
provider_snapshot_loader=load_provider_snapshot,
|
||||||
runtime_model_publisher=lambda model, preset: publish_runtime_model_update(
|
runtime_model_publisher=lambda model, preset: publish_runtime_model_update(
|
||||||
@@ -914,8 +972,7 @@ def _run_gateway(
|
|||||||
hb_cfg = config.gateway.heartbeat
|
hb_cfg = config.gateway.heartbeat
|
||||||
heartbeat = HeartbeatService(
|
heartbeat = HeartbeatService(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
provider=agent.provider,
|
llm_runtime=agent.llm_runtime,
|
||||||
model=agent.model,
|
|
||||||
on_execute=on_heartbeat_execute,
|
on_execute=on_heartbeat_execute,
|
||||||
on_notify=on_heartbeat_notify,
|
on_notify=on_heartbeat_notify,
|
||||||
interval_s=hb_cfg.interval_s,
|
interval_s=hb_cfg.interval_s,
|
||||||
@@ -1107,12 +1164,25 @@ def agent(
|
|||||||
_thinking: ThinkingSpinner | None = None
|
_thinking: ThinkingSpinner | None = None
|
||||||
|
|
||||||
def _make_progress(renderer: StreamRenderer | 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:
|
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
|
||||||
ch = agent_loop.channels_config
|
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 reasoning:
|
||||||
if ch and not ch.show_reasoning:
|
if ch and not ch.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
return
|
return
|
||||||
_print_cli_reasoning(content, _thinking, renderer)
|
text = reasoning_buffer.add(content)
|
||||||
|
if text:
|
||||||
|
_print_cli_reasoning(text, _thinking, renderer)
|
||||||
return
|
return
|
||||||
if ch and tool_hint and not ch.send_tool_hints:
|
if ch and tool_hint and not ch.send_tool_hints:
|
||||||
return
|
return
|
||||||
@@ -1183,6 +1253,7 @@ def agent(
|
|||||||
turn_done.set()
|
turn_done.set()
|
||||||
turn_response: list[tuple[str, dict]] = []
|
turn_response: list[tuple[str, dict]] = []
|
||||||
renderer: StreamRenderer | None = None
|
renderer: StreamRenderer | None = None
|
||||||
|
reasoning_buffer = _ReasoningBuffer()
|
||||||
|
|
||||||
async def _consume_outbound():
|
async def _consume_outbound():
|
||||||
while True:
|
while True:
|
||||||
@@ -1208,6 +1279,7 @@ def agent(
|
|||||||
renderer,
|
renderer,
|
||||||
agent_loop.channels_config,
|
agent_loop.channels_config,
|
||||||
renderer,
|
renderer,
|
||||||
|
reasoning_buffer,
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1248,6 +1320,7 @@ def agent(
|
|||||||
|
|
||||||
turn_done.clear()
|
turn_done.clear()
|
||||||
turn_response.clear()
|
turn_response.clear()
|
||||||
|
reasoning_buffer.clear()
|
||||||
renderer = StreamRenderer(
|
renderer = StreamRenderer(
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
bot_name=config.agents.defaults.bot_name,
|
bot_name=config.agents.defaults.bot_name,
|
||||||
|
|||||||
+217
-1
@@ -22,7 +22,7 @@ from nanobot.cli.models import (
|
|||||||
get_model_suggestions,
|
get_model_suggestions,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import get_config_path, load_config
|
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()
|
console = Console()
|
||||||
|
|
||||||
@@ -49,6 +49,10 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
|
|||||||
|
|
||||||
_BACK_PRESSED = object() # Sentinel value for back navigation
|
_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():
|
def _get_questionary():
|
||||||
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
"""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)
|
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] = {
|
_FIELD_HANDLERS: dict[str, Any] = {
|
||||||
"model": _handle_model_field,
|
"model": _handle_model_field,
|
||||||
"context_window_tokens": _handle_context_window_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]")
|
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 ---
|
# --- Provider Configuration ---
|
||||||
|
|
||||||
|
|
||||||
@@ -1043,6 +1250,12 @@ def _show_summary(config: Config) -> None:
|
|||||||
channel_rows.append((display, status))
|
channel_rows.append((display, status))
|
||||||
_print_summary_panel(channel_rows, "Chat Channels")
|
_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
|
# Settings sections
|
||||||
for title, model in [
|
for title, model in [
|
||||||
("Agent Settings", config.agents.defaults),
|
("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)
|
original_config = base_config.model_copy(deep=True)
|
||||||
config = base_config.model_copy(deep=True)
|
config = base_config.model_copy(deep=True)
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
last_main_choice: str | None = None
|
last_main_choice: str | None = None
|
||||||
while True:
|
while True:
|
||||||
@@ -1123,6 +1337,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
"What would you like to configure?",
|
"What would you like to configure?",
|
||||||
choices=[
|
choices=[
|
||||||
"[P] LLM Provider",
|
"[P] LLM Provider",
|
||||||
|
"[M] Model Presets",
|
||||||
"[C] Chat Channel",
|
"[C] Chat Channel",
|
||||||
"[H] Channel Common",
|
"[H] Channel Common",
|
||||||
"[A] Agent Settings",
|
"[A] Agent Settings",
|
||||||
@@ -1149,6 +1364,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
|
|
||||||
_menu_dispatch = {
|
_menu_dispatch = {
|
||||||
"[P] LLM Provider": lambda: _configure_providers(config),
|
"[P] LLM Provider": lambda: _configure_providers(config),
|
||||||
|
"[M] Model Presets": lambda: _configure_model_presets(config),
|
||||||
"[C] Chat Channel": lambda: _configure_channels(config),
|
"[C] Chat Channel": lambda: _configure_channels(config),
|
||||||
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
||||||
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
from typing import Any, Callable, Coroutine
|
||||||
|
|
||||||
from loguru import logger
|
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 = [
|
_HEARTBEAT_TOOL = [
|
||||||
{
|
{
|
||||||
@@ -53,17 +53,21 @@ class HeartbeatService:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
provider: LLMProvider,
|
provider: LLMProvider | None = None,
|
||||||
model: str,
|
model: str | None = None,
|
||||||
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
||||||
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
||||||
interval_s: int = 30 * 60,
|
interval_s: int = 30 * 60,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
|
llm_runtime: LLMRuntimeResolver | None = None,
|
||||||
):
|
):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.provider = provider
|
if llm_runtime is None:
|
||||||
self.model = model
|
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_execute = on_execute
|
||||||
self.on_notify = on_notify
|
self.on_notify = on_notify
|
||||||
self.interval_s = interval_s
|
self.interval_s = interval_s
|
||||||
@@ -91,7 +95,9 @@ class HeartbeatService:
|
|||||||
"""
|
"""
|
||||||
from nanobot.utils.helpers import current_time_str
|
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=[
|
messages=[
|
||||||
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
|
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
|
||||||
{"role": "user", "content": (
|
{"role": "user", "content": (
|
||||||
@@ -101,7 +107,7 @@ class HeartbeatService:
|
|||||||
)},
|
)},
|
||||||
],
|
],
|
||||||
tools=_HEARTBEAT_TOOL,
|
tools=_HEARTBEAT_TOOL,
|
||||||
model=self.model,
|
model=llm.model,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.should_execute_tools:
|
if not response.should_execute_tools:
|
||||||
@@ -214,8 +220,9 @@ class HeartbeatService:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
llm = self._llm_runtime()
|
||||||
should_notify = await evaluate_response(
|
should_notify = await evaluate_response(
|
||||||
response, tasks, self.provider, self.model,
|
response, tasks, llm.provider, llm.model,
|
||||||
)
|
)
|
||||||
if should_notify and self.on_notify:
|
if should_notify and self.on_notify:
|
||||||
logger.info("Heartbeat: completed, delivering response")
|
logger.info("Heartbeat: completed, delivering response")
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ class Nanobot:
|
|||||||
image_generation_provider_configs={
|
image_generation_provider_configs={
|
||||||
"openrouter": config.providers.openrouter,
|
"openrouter": config.providers.openrouter,
|
||||||
"aihubmix": config.providers.aihubmix,
|
"aihubmix": config.providers.aihubmix,
|
||||||
|
"minimax": config.providers.minimax,
|
||||||
|
"gemini": config.providers.gemini,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return cls(loop)
|
return cls(loop)
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class LLMProvider(ABC):
|
|||||||
"server error",
|
"server error",
|
||||||
"temporarily unavailable",
|
"temporarily unavailable",
|
||||||
"速率限制",
|
"速率限制",
|
||||||
|
"访问量过大",
|
||||||
)
|
)
|
||||||
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
|
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
|
||||||
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
|
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.utils.helpers import detect_image_mime
|
from nanobot.utils.helpers import detect_image_mime
|
||||||
@@ -26,6 +27,8 @@ _AIHUBMIX_ASPECT_RATIO_SIZES = {
|
|||||||
"4:3": "1536x1024",
|
"4:3": "1536x1024",
|
||||||
"16:9": "1536x1024",
|
"16:9": "1536x1024",
|
||||||
}
|
}
|
||||||
|
_GEMINI_DEFAULT_TIMEOUT_S = 120.0
|
||||||
|
_GEMINI_IMAGEN_ASPECT_RATIOS = {"1:1", "9:16", "16:9", "3:4", "4:3"}
|
||||||
|
|
||||||
|
|
||||||
class ImageGenerationError(RuntimeError):
|
class ImageGenerationError(RuntimeError):
|
||||||
@@ -50,17 +53,28 @@ def _provider_base_url(provider: str, api_base: str | None, fallback: str) -> st
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def image_path_to_data_url(path: str | Path) -> str:
|
def _read_image_b64(path: str | Path) -> tuple[str, str]:
|
||||||
"""Convert a local image path to an image data URL."""
|
"""Return ``(mime, base64)`` for the image at ``path``."""
|
||||||
p = Path(path).expanduser()
|
p = Path(path).expanduser()
|
||||||
raw = p.read_bytes()
|
raw = p.read_bytes()
|
||||||
mime = detect_image_mime(raw)
|
mime = detect_image_mime(raw)
|
||||||
if mime is None:
|
if mime is None:
|
||||||
raise ImageGenerationError(f"unsupported reference image: {p}")
|
raise ImageGenerationError(f"unsupported reference image: {p}")
|
||||||
encoded = base64.b64encode(raw).decode("ascii")
|
return mime, base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def image_path_to_data_url(path: str | Path) -> str:
|
||||||
|
"""Convert a local image path to an image data URL."""
|
||||||
|
mime, encoded = _read_image_b64(path)
|
||||||
return f"data:{mime};base64,{encoded}"
|
return f"data:{mime};base64,{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def image_path_to_inline_data(path: str | Path) -> dict[str, str]:
|
||||||
|
"""Convert a local image path to a Gemini ``inlineData`` payload dict."""
|
||||||
|
mime, encoded = _read_image_b64(path)
|
||||||
|
return {"mimeType": mime, "data": encoded}
|
||||||
|
|
||||||
|
|
||||||
def _b64_png_data_url(value: str) -> str:
|
def _b64_png_data_url(value: str) -> str:
|
||||||
return f"data:image/png;base64,{value}"
|
return f"data:image/png;base64,{value}"
|
||||||
|
|
||||||
@@ -341,6 +355,203 @@ class AIHubMixImageGenerationClient:
|
|||||||
return GeneratedImageResponse(images=images, content="", raw=payload)
|
return GeneratedImageResponse(images=images, content="", raw=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error_detail(response: httpx.Response) -> str:
|
||||||
|
"""Extract a readable error message from an HTTP error response."""
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
err = data.get("error")
|
||||||
|
if isinstance(err, dict):
|
||||||
|
return err.get("message") or str(err)
|
||||||
|
if err:
|
||||||
|
return str(err)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return response.text[:500] or "<empty response body>"
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiImageGenerationClient:
|
||||||
|
"""Async client for Gemini/Imagen image generation via the Generative Language API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str | None,
|
||||||
|
api_base: str | None = None,
|
||||||
|
extra_headers: dict[str, str] | None = None,
|
||||||
|
extra_body: dict[str, Any] | None = None,
|
||||||
|
timeout: float = _GEMINI_DEFAULT_TIMEOUT_S,
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.api_key = api_key
|
||||||
|
# The Gemini provider's registry default_api_base is the OpenAI-compat
|
||||||
|
# shim (.../v1beta/openai/), which has no image endpoints. Image
|
||||||
|
# generation needs the native Generative Language API base, so we don't
|
||||||
|
# use _provider_base_url() here.
|
||||||
|
self.api_base = (
|
||||||
|
api_base or "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
).rstrip("/")
|
||||||
|
self.extra_headers = extra_headers or {}
|
||||||
|
self.extra_body = extra_body or {}
|
||||||
|
self.timeout = timeout
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
reference_images: list[str] | None = None,
|
||||||
|
aspect_ratio: str | None = None,
|
||||||
|
image_size: str | None = None,
|
||||||
|
) -> GeneratedImageResponse:
|
||||||
|
if not self.api_key:
|
||||||
|
raise ImageGenerationError(
|
||||||
|
"Gemini API key is not configured. Set providers.gemini.apiKey."
|
||||||
|
)
|
||||||
|
if "imagen" in model.lower():
|
||||||
|
if reference_images:
|
||||||
|
logger.warning(
|
||||||
|
"Imagen models do not support reference images; "
|
||||||
|
"ignoring {} reference image(s) for {}",
|
||||||
|
len(reference_images),
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
return await self._generate_imagen(
|
||||||
|
prompt=prompt, model=model, aspect_ratio=aspect_ratio
|
||||||
|
)
|
||||||
|
return await self._generate_gemini_flash(
|
||||||
|
prompt=prompt, model=model, reference_images=reference_images or []
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate_imagen(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
aspect_ratio: str | None,
|
||||||
|
) -> GeneratedImageResponse:
|
||||||
|
parameters: dict[str, Any] = {"sampleCount": 1}
|
||||||
|
if aspect_ratio in _GEMINI_IMAGEN_ASPECT_RATIOS:
|
||||||
|
parameters["aspectRatio"] = aspect_ratio
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"instances": [{"prompt": prompt}],
|
||||||
|
"parameters": parameters,
|
||||||
|
}
|
||||||
|
body.update(self.extra_body)
|
||||||
|
|
||||||
|
url = f"{self.api_base}/models/{model}:predict"
|
||||||
|
headers = {
|
||||||
|
"x-goog-api-key": self.api_key or "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._client is not None:
|
||||||
|
response = await self._client.post(url, headers=headers, json=body)
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = _http_error_detail(response)
|
||||||
|
logger.error("Gemini Imagen generation failed (HTTP {}): {}", response.status_code, detail)
|
||||||
|
raise ImageGenerationError(
|
||||||
|
f"Gemini Imagen generation failed (HTTP {response.status_code}): {detail}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
images: list[str] = []
|
||||||
|
for prediction in data.get("predictions") or []:
|
||||||
|
if not isinstance(prediction, dict):
|
||||||
|
continue
|
||||||
|
b64 = prediction.get("bytesBase64Encoded")
|
||||||
|
mime = prediction.get("mimeType", "image/png")
|
||||||
|
if isinstance(b64, str) and b64:
|
||||||
|
images.append(f"data:{mime};base64,{b64}")
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
provider_error = data.get("error") if isinstance(data, dict) else None
|
||||||
|
if provider_error:
|
||||||
|
raise ImageGenerationError(f"Gemini Imagen returned no images: {provider_error}")
|
||||||
|
raise ImageGenerationError("Gemini Imagen returned no images for this request")
|
||||||
|
|
||||||
|
return GeneratedImageResponse(images=images, content="", raw=data)
|
||||||
|
|
||||||
|
async def _generate_gemini_flash(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
reference_images: list[str],
|
||||||
|
) -> GeneratedImageResponse:
|
||||||
|
parts: list[dict[str, Any]] = [
|
||||||
|
{"inlineData": image_path_to_inline_data(path)} for path in reference_images
|
||||||
|
]
|
||||||
|
parts.append({"text": prompt})
|
||||||
|
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"contents": [{"role": "user", "parts": parts}],
|
||||||
|
"generationConfig": {"responseModalities": ["TEXT", "IMAGE"]},
|
||||||
|
}
|
||||||
|
body.update(self.extra_body)
|
||||||
|
|
||||||
|
url = f"{self.api_base}/models/{model}:generateContent"
|
||||||
|
headers = {
|
||||||
|
"x-goog-api-key": self.api_key or "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._client is not None:
|
||||||
|
response = await self._client.post(url, headers=headers, json=body)
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = _http_error_detail(response)
|
||||||
|
logger.error("Gemini image generation failed (HTTP {}): {}", response.status_code, detail)
|
||||||
|
raise ImageGenerationError(
|
||||||
|
f"Gemini image generation failed (HTTP {response.status_code}): {detail}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
images: list[str] = []
|
||||||
|
text_parts: list[str] = []
|
||||||
|
for candidate in data.get("candidates") or []:
|
||||||
|
if not isinstance(candidate, dict):
|
||||||
|
continue
|
||||||
|
content = candidate.get("content") or {}
|
||||||
|
for part in content.get("parts") or []:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
if "text" in part:
|
||||||
|
text_parts.append(part["text"])
|
||||||
|
inline = part.get("inlineData")
|
||||||
|
if isinstance(inline, dict):
|
||||||
|
mime = inline.get("mimeType", "image/png")
|
||||||
|
b64 = inline.get("data", "")
|
||||||
|
if b64:
|
||||||
|
images.append(f"data:{mime};base64,{b64}")
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
provider_error = data.get("error") if isinstance(data, dict) else None
|
||||||
|
if provider_error:
|
||||||
|
raise ImageGenerationError(f"Gemini returned no images: {provider_error}")
|
||||||
|
raise ImageGenerationError("Gemini returned no images for this request")
|
||||||
|
|
||||||
|
return GeneratedImageResponse(
|
||||||
|
images=images,
|
||||||
|
content="\n".join(t for t in text_parts if t).strip(),
|
||||||
|
raw=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _aihubmix_images_from_payload(
|
async def _aihubmix_images_from_payload(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
@@ -393,3 +604,136 @@ async def _aihubmix_images_from_payload(
|
|||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
await collect(candidate)
|
await collect(candidate)
|
||||||
return images
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
_MINIMAX_TIMEOUT_S = 300.0
|
||||||
|
|
||||||
|
_MINIMAX_ASPECT_RATIO_SIZES = {
|
||||||
|
"1:1": "1:1",
|
||||||
|
"16:9": "16:9",
|
||||||
|
"4:3": "4:3",
|
||||||
|
"3:2": "3:2",
|
||||||
|
"2:3": "2:3",
|
||||||
|
"3:4": "3:4",
|
||||||
|
"9:16": "9:16",
|
||||||
|
"21:9": "21:9",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MiniMaxImageGenerationClient:
|
||||||
|
"""Async client for MiniMax image generation API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str | None,
|
||||||
|
api_base: str | None = None,
|
||||||
|
extra_headers: dict[str, str] | None = None,
|
||||||
|
extra_body: dict[str, Any] | None = None,
|
||||||
|
timeout: float = _MINIMAX_TIMEOUT_S,
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.api_key = api_key
|
||||||
|
self.api_base = _provider_base_url(
|
||||||
|
"minimax",
|
||||||
|
api_base,
|
||||||
|
"https://api.minimaxi.com/v1",
|
||||||
|
)
|
||||||
|
self.extra_headers = extra_headers or {}
|
||||||
|
self.extra_body = extra_body or {}
|
||||||
|
self.timeout = timeout
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def _resolve_aspect_ratio(self, aspect_ratio: str | None) -> str:
|
||||||
|
if aspect_ratio and aspect_ratio in _MINIMAX_ASPECT_RATIO_SIZES:
|
||||||
|
return _MINIMAX_ASPECT_RATIO_SIZES[aspect_ratio]
|
||||||
|
return "1:1"
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
reference_images: list[str] | None = None,
|
||||||
|
aspect_ratio: str | None = None,
|
||||||
|
image_size: str | None = None,
|
||||||
|
) -> GeneratedImageResponse:
|
||||||
|
if not self.api_key:
|
||||||
|
raise ImageGenerationError(
|
||||||
|
"MiniMax API key is not configured. Set providers.minimax.apiKey."
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"response_format": "base64",
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved_ratio = self._resolve_aspect_ratio(aspect_ratio)
|
||||||
|
body["aspect_ratio"] = resolved_ratio
|
||||||
|
|
||||||
|
refs = list(reference_images or [])
|
||||||
|
if refs:
|
||||||
|
image_refs = [image_path_to_data_url(path) for path in refs]
|
||||||
|
body["subject_reference"] = [
|
||||||
|
{"type": "character", "image_file": ref} for ref in image_refs
|
||||||
|
]
|
||||||
|
|
||||||
|
body.update(self.extra_body)
|
||||||
|
|
||||||
|
if self._client is not None:
|
||||||
|
return await self._generate_with_client(self._client, body, headers)
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
return await self._generate_with_client(client, body, headers)
|
||||||
|
|
||||||
|
async def _generate_with_client(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
body: dict[str, Any],
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> GeneratedImageResponse:
|
||||||
|
url = f"{self.api_base}/image_generation"
|
||||||
|
try:
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ImageGenerationError("MiniMax image generation timed out") from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise ImageGenerationError(f"MiniMax image generation request failed: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = response.text[:500]
|
||||||
|
raise ImageGenerationError(f"MiniMax image generation failed: {detail}") from exc
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
images = _minimax_images_from_payload(payload)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
provider_error = payload.get("error") if isinstance(payload, dict) else None
|
||||||
|
if provider_error:
|
||||||
|
raise ImageGenerationError(f"MiniMax returned no images: {provider_error}")
|
||||||
|
raise ImageGenerationError("MiniMax returned no images for this request")
|
||||||
|
|
||||||
|
return GeneratedImageResponse(images=images, content="", raw=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
||||||
|
"""Extract base64 images from MiniMax API response.
|
||||||
|
|
||||||
|
MiniMax returns images in ``data.image_base64`` (list of base64 strings).
|
||||||
|
"""
|
||||||
|
images: list[str] = []
|
||||||
|
data = payload.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return images
|
||||||
|
for b64 in data.get("image_base64") or []:
|
||||||
|
if isinstance(b64, str) and b64:
|
||||||
|
images.append(_b64_png_data_url(b64))
|
||||||
|
return images
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
name="vllm",
|
name="vllm",
|
||||||
keywords=("vllm",),
|
keywords=("vllm",),
|
||||||
env_key="HOSTED_VLLM_API_KEY",
|
env_key="HOSTED_VLLM_API_KEY",
|
||||||
display_name="vLLM/Local",
|
display_name="vLLM",
|
||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
is_local=True,
|
is_local=True,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -88,6 +88,27 @@ AIHubMix `gpt-image-2-free` uses AIHubMix's unified predictions endpoint interna
|
|||||||
|
|
||||||
`providers.aihubmix.extraBody` can be used for provider-specific options. For example, `"extraBody": {"quality": "low"}` is optional but can make `gpt-image-2-free` faster and less likely to time out.
|
`providers.aihubmix.extraBody` can be used for provider-specific options. For example, `"extraBody": {"quality": "low"}` is optional but can make `gpt-image-2-free` faster and less likely to time out.
|
||||||
|
|
||||||
|
For Gemini, the image tool supports two model families. Imagen 4 (`imagen-4.0-generate-001`) supports text-to-image only. Gemini Flash (`gemini-2.5-flash-image`) also supports reference-image edits. Configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"gemini": {
|
||||||
|
"apiKey": "AIza..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "gemini",
|
||||||
|
"model": "imagen-4.0-generate-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For Gemini models, `defaultImageSize` has no effect; use `defaultAspectRatio` instead. Imagen 4 supports `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Generate a new image:
|
Generate a new image:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
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:
|
try:
|
||||||
sig = inspect.signature(cb)
|
sig = inspect.signature(cb)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return False
|
return False
|
||||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||||
return True
|
return True
|
||||||
return "tool_events" in sig.parameters
|
return name in sig.parameters
|
||||||
|
|
||||||
|
|
||||||
async def invoke_on_progress(
|
async def invoke_on_progress(
|
||||||
@@ -32,6 +40,15 @@ async def invoke_on_progress(
|
|||||||
await on_progress(content, tool_hint=tool_hint)
|
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]:
|
def build_tool_event_start_payload(tool_call: Any) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"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_message_id: str | None = None
|
||||||
buffer_parts: list[str] = []
|
buffer_parts: list[str] = []
|
||||||
suppress_until_turn_end = False
|
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)
|
_ts_base = int(time.time() * 1000)
|
||||||
|
|
||||||
def _new_id(prefix: str, idx: int) -> str:
|
def _new_id(prefix: str, idx: int) -> str:
|
||||||
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
|
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:
|
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
|
||||||
for i in range(len(prev) - 1, -1, -1):
|
for i in range(len(prev) - 1, -1, -1):
|
||||||
candidate = prev[i]
|
candidate = prev[i]
|
||||||
@@ -151,12 +165,19 @@ def replay_transcript_to_ui_messages(
|
|||||||
**candidate,
|
**candidate,
|
||||||
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
|
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
|
||||||
"reasoningStreaming": True,
|
"reasoningStreaming": True,
|
||||||
|
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
if not has_answer and candidate.get("isStreaming"):
|
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
|
return
|
||||||
break
|
break
|
||||||
|
segment = _ensure_activity_segment()
|
||||||
prev.append(
|
prev.append(
|
||||||
{
|
{
|
||||||
"id": _new_id("as", idx),
|
"id": _new_id("as", idx),
|
||||||
@@ -165,6 +186,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
"reasoning": chunk,
|
"reasoning": chunk,
|
||||||
"reasoningStreaming": True,
|
"reasoningStreaming": True,
|
||||||
|
"activitySegmentId": segment,
|
||||||
"createdAt": _ts_base + idx,
|
"createdAt": _ts_base + idx,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -221,6 +243,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
return
|
return
|
||||||
|
|
||||||
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
|
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
|
||||||
|
nonlocal active_activity_segment_id
|
||||||
last = messages[-1] if messages else None
|
last = messages[-1] if messages else None
|
||||||
if last and is_reasoning_only_placeholder(last):
|
if last and is_reasoning_only_placeholder(last):
|
||||||
messages[-1] = {
|
messages[-1] = {
|
||||||
@@ -238,10 +261,76 @@ def replay_transcript_to_ui_messages(
|
|||||||
**extra,
|
**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):
|
for idx, rec in enumerate(lines):
|
||||||
ev = rec.get("event")
|
ev = rec.get("event")
|
||||||
if ev == "user":
|
if ev == "user":
|
||||||
|
active_activity_segment_id = None
|
||||||
|
active_file_edit_segment_id = None
|
||||||
text = rec.get("text")
|
text = rec.get("text")
|
||||||
text_s = text if isinstance(text, str) else ""
|
text_s = text if isinstance(text, str) else ""
|
||||||
media_paths = rec.get("media_paths")
|
media_paths = rec.get("media_paths")
|
||||||
@@ -264,6 +353,12 @@ def replay_transcript_to_ui_messages(
|
|||||||
messages.append(row)
|
messages.append(row)
|
||||||
continue
|
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 ev == "delta":
|
||||||
if suppress_until_turn_end:
|
if suppress_until_turn_end:
|
||||||
continue
|
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 [])
|
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
|
||||||
if not trace_lines:
|
if not trace_lines:
|
||||||
continue
|
continue
|
||||||
|
segment = _ensure_activity_segment()
|
||||||
last = messages[-1] if messages else None
|
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")])
|
prev_traces = list(last.get("traces") or [last.get("content")])
|
||||||
merged_traces = prev_traces + trace_lines
|
merged_traces = prev_traces + trace_lines
|
||||||
messages[-1] = {
|
messages[-1] = {
|
||||||
**last,
|
**last,
|
||||||
"traces": merged_traces,
|
"traces": merged_traces,
|
||||||
"content": trace_lines[-1],
|
"content": trace_lines[-1],
|
||||||
|
"activitySegmentId": last.get("activitySegmentId") or segment,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
messages.append(
|
messages.append(
|
||||||
@@ -355,6 +457,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
"kind": "trace",
|
"kind": "trace",
|
||||||
"content": trace_lines[-1],
|
"content": trace_lines[-1],
|
||||||
"traces": trace_lines,
|
"traces": trace_lines,
|
||||||
|
"activitySegmentId": segment,
|
||||||
"createdAt": _ts_base + idx,
|
"createdAt": _ts_base + idx,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -389,6 +492,8 @@ def replay_transcript_to_ui_messages(
|
|||||||
|
|
||||||
if ev == "turn_end":
|
if ev == "turn_end":
|
||||||
suppress_until_turn_end = False
|
suppress_until_turn_end = False
|
||||||
|
active_activity_segment_id = None
|
||||||
|
active_file_edit_segment_id = None
|
||||||
for i, m in enumerate(messages):
|
for i, m in enumerate(messages):
|
||||||
if m.get("isStreaming"):
|
if m.get("isStreaming"):
|
||||||
messages[i] = {**m, "isStreaming": False}
|
messages[i] = {**m, "isStreaming": False}
|
||||||
|
|||||||
@@ -6,17 +6,163 @@ AgentLoop uses these without importing a concrete channel plugin; only
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
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
|
# 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.
|
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
|
||||||
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
|
_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:
|
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
|
||||||
"""Return ``time.time()`` when the active user turn began, if still running."""
|
"""Return ``time.time()`` when the active user turn began, if still running."""
|
||||||
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
|
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,
|
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())
|
||||||
|
|||||||
+140
-163
@@ -45,6 +45,73 @@ def _add_turns(session, turns: int, *, prefix: str = "msg") -> None:
|
|||||||
session.add_message("assistant", f"{prefix} assistant {i}")
|
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:
|
class TestSessionTTLConfig:
|
||||||
"""Test session TTL configuration."""
|
"""Test session TTL configuration."""
|
||||||
|
|
||||||
@@ -201,10 +268,7 @@ class TestAutoCompact:
|
|||||||
s2.add_message("user", "recent")
|
s2.add_message("user", "recent")
|
||||||
loop.sessions.save(s2)
|
loop.sessions.save(s2)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
loop.auto_compact.check_expired(loop._schedule_background)
|
loop.auto_compact.check_expired(loop._schedule_background)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
@@ -222,12 +286,9 @@ class TestAutoCompact:
|
|||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archived_messages = []
|
archived_messages = []
|
||||||
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
async def _fake_archive(messages):
|
loop, track_archived=archived_messages,
|
||||||
archived_messages.extend(messages)
|
)
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
@@ -246,10 +307,9 @@ class TestAutoCompact:
|
|||||||
_add_turns(session, 6, prefix="hello")
|
_add_turns(session, 6, prefix="hello")
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
return "User said hello."
|
loop, summary="User said hello.",
|
||||||
|
)
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
@@ -262,23 +322,16 @@ class TestAutoCompact:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_empty_session(self, tmp_path):
|
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)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
|
|
||||||
archive_called = False
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_called
|
|
||||||
archive_called = True
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
assert not archive_called
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 0
|
assert len(session_after.messages) == 0
|
||||||
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -290,18 +343,14 @@ class TestAutoCompact:
|
|||||||
session.last_consolidated = 18
|
session.last_consolidated = 18
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archived_count = 0
|
archived_messages = []
|
||||||
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
async def _fake_archive(messages):
|
loop, track_archived=archived_messages,
|
||||||
nonlocal archived_count
|
)
|
||||||
archived_count = len(messages)
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
assert archived_count == 2
|
assert len(archived_messages) == 2
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
@@ -334,12 +383,9 @@ class TestAutoCompactIdleDetection:
|
|||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archived_messages = []
|
archived_messages = []
|
||||||
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
async def _fake_archive(messages):
|
loop, track_archived=archived_messages,
|
||||||
archived_messages.extend(messages)
|
)
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# Simulate proactive archive completing before message arrives
|
# Simulate proactive archive completing before message arrives
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -402,10 +448,7 @@ class TestAutoCompactIdleDetection:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||||
response = await loop._process_message(msg)
|
response = await loop._process_message(msg)
|
||||||
@@ -466,10 +509,7 @@ class TestAutoCompactSystemMessages:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# Simulate proactive archive completing before system message arrives
|
# Simulate proactive archive completing before system message arrives
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -547,12 +587,9 @@ class TestAutoCompactEdgeCases:
|
|||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archived_messages = []
|
archived_messages = []
|
||||||
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
async def _fake_archive(messages):
|
loop, track_archived=archived_messages,
|
||||||
archived_messages.extend(messages)
|
)
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# Simulate proactive archive completing before message arrives
|
# Simulate proactive archive completing before message arrives
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -644,10 +681,7 @@ class TestAutoCompactIntegration:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# Simulate proactive archive completing before message arrives
|
# Simulate proactive archive completing before message arrives
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -704,12 +738,9 @@ class TestProactiveAutoCompact:
|
|||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archived_messages = []
|
archived_messages = []
|
||||||
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
async def _fake_archive(messages):
|
loop, summary="User chatted about old things.", track_archived=archived_messages,
|
||||||
archived_messages.extend(messages)
|
)
|
||||||
return "User chatted about old things."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
|
|
||||||
@@ -748,14 +779,14 @@ class TestProactiveAutoCompact:
|
|||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
block_forever = asyncio.Event()
|
block_forever = asyncio.Event()
|
||||||
|
|
||||||
async def _slow_archive(messages):
|
async def _slow_compact(key, max_suffix=8):
|
||||||
nonlocal archive_count
|
nonlocal archive_count
|
||||||
archive_count += 1
|
archive_count += 1
|
||||||
started.set()
|
started.set()
|
||||||
await block_forever.wait()
|
await block_forever.wait()
|
||||||
return "Summary."
|
return "Summary."
|
||||||
|
|
||||||
loop.consolidator.archive = _slow_archive
|
loop.consolidator.compact_idle_session = _slow_compact
|
||||||
|
|
||||||
# First call starts archiving via callback
|
# First call starts archiving via callback
|
||||||
loop.auto_compact.check_expired(loop._schedule_background)
|
loop.auto_compact.check_expired(loop._schedule_background)
|
||||||
@@ -781,10 +812,10 @@ class TestProactiveAutoCompact:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _failing_archive(messages):
|
async def _failing_compact(key, max_suffix=8):
|
||||||
raise RuntimeError("LLM down")
|
raise RuntimeError("LLM down")
|
||||||
|
|
||||||
loop.consolidator.archive = _failing_archive
|
loop.consolidator.compact_idle_session = _failing_compact
|
||||||
|
|
||||||
# Should not raise
|
# Should not raise
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
@@ -795,24 +826,18 @@ class TestProactiveAutoCompact:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proactive_archive_skips_empty_sessions(self, tmp_path):
|
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)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archive_called = False
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_called
|
|
||||||
archive_called = True
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await self._run_check_expired(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()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -824,18 +849,12 @@ class TestProactiveAutoCompact:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archive_count = 0
|
_fake_compact = _make_fake_compact(loop)
|
||||||
|
loop.consolidator.compact_idle_session = _fake_compact
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_count
|
|
||||||
archive_count += 1
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# Simulate an active agent task for this session
|
# Simulate an active agent task for this session
|
||||||
await self._run_check_expired(loop, active_session_keys={"cli:test"})
|
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")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 12 # All messages preserved
|
assert len(session_after.messages) == 12 # All messages preserved
|
||||||
@@ -851,22 +870,16 @@ class TestProactiveAutoCompact:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archive_count = 0
|
_fake_compact = _make_fake_compact(loop)
|
||||||
|
loop.consolidator.compact_idle_session = _fake_compact
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_count
|
|
||||||
archive_count += 1
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# First tick: active task, skip
|
# First tick: active task, skip
|
||||||
await self._run_check_expired(loop, active_session_keys={"cli:test"})
|
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
|
# Second tick: task completed, should archive
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
assert archive_count == 1
|
assert _fake_compact.state["count"] == 1
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -888,18 +901,12 @@ class TestProactiveAutoCompact:
|
|||||||
s3.add_message("user", "recent")
|
s3.add_message("user", "recent")
|
||||||
loop.sessions.save(s3)
|
loop.sessions.save(s3)
|
||||||
|
|
||||||
archive_count = 0
|
_fake_compact = _make_fake_compact(loop)
|
||||||
|
loop.consolidator.compact_idle_session = _fake_compact
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_count
|
|
||||||
archive_count += 1
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await self._run_check_expired(loop, active_session_keys={"cli:expired_active"})
|
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")
|
s1_after = loop.sessions.get_or_create("cli:expired_idle")
|
||||||
assert len(s1_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(s1_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
s2_after = loop.sessions.get_or_create("cli:expired_active")
|
s2_after = loop.sessions.get_or_create("cli:expired_active")
|
||||||
@@ -917,22 +924,16 @@ class TestProactiveAutoCompact:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archive_count = 0
|
_fake_compact = _make_fake_compact(loop)
|
||||||
|
loop.consolidator.compact_idle_session = _fake_compact
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_count
|
|
||||||
archive_count += 1
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# First tick: archives the session
|
# First tick: archives the session
|
||||||
await self._run_check_expired(loop)
|
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)
|
# Second tick: should NOT re-schedule (updated_at is fresh after clear)
|
||||||
await self._run_check_expired(loop)
|
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()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -943,22 +944,15 @@ class TestProactiveAutoCompact:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archive_count = 0
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_count
|
|
||||||
archive_count += 1
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# First tick: skips (no messages), refreshes updated_at
|
# First tick: skips (no messages), refreshes updated_at
|
||||||
await self._run_check_expired(loop)
|
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
|
# Second tick: should NOT re-schedule because updated_at is fresh
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
assert archive_count == 0
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -970,18 +964,12 @@ class TestProactiveAutoCompact:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archive_count = 0
|
_fake_compact = _make_fake_compact(loop)
|
||||||
|
loop.consolidator.compact_idle_session = _fake_compact
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_count
|
|
||||||
archive_count += 1
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# First compact cycle
|
# First compact cycle
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
assert archive_count == 1
|
assert _fake_compact.state["count"] == 1
|
||||||
|
|
||||||
# User returns, sends new messages
|
# User returns, sends new messages
|
||||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="second topic")
|
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="second topic")
|
||||||
@@ -995,7 +983,7 @@ class TestProactiveAutoCompact:
|
|||||||
|
|
||||||
# Second compact cycle should succeed
|
# Second compact cycle should succeed
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
assert archive_count == 2
|
assert _fake_compact.state["count"] == 2
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
@@ -1011,10 +999,9 @@ class TestSummaryPersistence:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
return "User said hello."
|
loop, summary="User said hello.",
|
||||||
|
)
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
@@ -1036,10 +1023,9 @@ class TestSummaryPersistence:
|
|||||||
session.updated_at = last_active
|
session.updated_at = last_active
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
return "User said hello."
|
loop, summary="User said hello.",
|
||||||
|
)
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# Archive
|
# Archive
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -1069,10 +1055,7 @@ class TestSummaryPersistence:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
@@ -1100,10 +1083,7 @@ class TestSummaryPersistence:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
@@ -1129,10 +1109,9 @@ class TestSummaryPersistence:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
return "First summary."
|
loop, summary="First summary.",
|
||||||
|
)
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
# Consume the first summary via hot path
|
# Consume the first summary via hot path
|
||||||
@@ -1148,10 +1127,9 @@ class TestSummaryPersistence:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive2(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
return "Second summary."
|
loop, summary="Second summary.",
|
||||||
|
)
|
||||||
loop.consolidator.archive = _fake_archive2
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
# The second archive writes a new summary
|
# The second archive writes a new summary
|
||||||
@@ -1173,10 +1151,9 @@ class TestSummaryPersistence:
|
|||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
loop.consolidator.compact_idle_session = _make_fake_compact(
|
||||||
return "Old summary."
|
loop, summary="Old summary.",
|
||||||
|
)
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
# Verify summary exists before /new
|
# Verify summary exists before /new
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def _make_autocompact(
|
|||||||
sessions = MagicMock(spec=SessionManager)
|
sessions = MagicMock(spec=SessionManager)
|
||||||
if consolidator is None:
|
if consolidator is None:
|
||||||
consolidator = MagicMock()
|
consolidator = MagicMock()
|
||||||
consolidator.archive = AsyncMock(return_value="Summary.")
|
consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
|
||||||
return AutoCompact(
|
return AutoCompact(
|
||||||
sessions=sessions,
|
sessions=sessions,
|
||||||
consolidator=consolidator,
|
consolidator=consolidator,
|
||||||
@@ -178,62 +178,6 @@ class TestFormatSummary:
|
|||||||
assert result.startswith("Previous conversation summary (last active ")
|
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
|
# check_expired
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -313,126 +257,71 @@ class TestCheckExpired:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestArchive:
|
class TestArchiveDelegates:
|
||||||
"""Test AutoCompact._archive async method."""
|
"""_archive should delegate all session mutation to Consolidator."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_session_updates_timestamp_no_archive_call(self):
|
async def test_calls_compact_idle_session(self):
|
||||||
"""Empty session should refresh updated_at and not call consolidator.archive."""
|
|
||||||
ac = _make_autocompact()
|
ac = _make_autocompact()
|
||||||
mock_sm = MagicMock(spec=SessionManager)
|
mock_sm = MagicMock(spec=SessionManager)
|
||||||
empty_session = _make_session(messages=[])
|
|
||||||
mock_sm.get_or_create.return_value = empty_session
|
|
||||||
ac.sessions = mock_sm
|
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")
|
await ac._archive("cli:test")
|
||||||
|
|
||||||
ac.consolidator.archive.assert_not_called()
|
ac.consolidator.compact_idle_session.assert_awaited_once_with(
|
||||||
mock_sm.save.assert_called_once_with(empty_session)
|
"cli:test", ac._RECENT_SUFFIX_MESSAGES,
|
||||||
# updated_at was refreshed
|
)
|
||||||
assert empty_session.updated_at > datetime.now() - timedelta(seconds=5)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_archive_returns_empty_string_no_summary_stored(self):
|
async def test_populates_summaries_from_metadata(self):
|
||||||
"""If archive returns empty string, no summary should be stored."""
|
|
||||||
ac = _make_autocompact()
|
ac = _make_autocompact()
|
||||||
mock_sm = MagicMock(spec=SessionManager)
|
mock_sm = MagicMock(spec=SessionManager)
|
||||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
session = _make_session(
|
||||||
session = _make_session(messages=msgs)
|
metadata={"_last_summary": {"text": "Hello.", "last_active": "2026-05-13T10:00:00"}}
|
||||||
|
)
|
||||||
mock_sm.get_or_create.return_value = session
|
mock_sm.get_or_create.return_value = session
|
||||||
ac.sessions = mock_sm
|
ac.sessions = mock_sm
|
||||||
ac.consolidator.archive = AsyncMock(return_value="")
|
ac.consolidator.compact_idle_session = AsyncMock(return_value="Hello.")
|
||||||
|
|
||||||
await ac._archive("cli:test")
|
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")
|
entry = ac._summaries.get("cli:test")
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry[0] == "User discussed AI."
|
assert entry[0] == "Hello."
|
||||||
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
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_finally_block_always_removes_from_archiving(self):
|
async def test_no_summary_when_compact_returns_empty(self):
|
||||||
"""Finally block should always remove key from _archiving, even on error."""
|
|
||||||
ac = _make_autocompact()
|
ac = _make_autocompact()
|
||||||
mock_sm = MagicMock(spec=SessionManager)
|
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.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")
|
await ac._archive("cli:test")
|
||||||
assert "cli:test" not in ac._archiving
|
|
||||||
|
assert "cli:test" not in ac._summaries
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_finally_removes_from_archiving_on_success(self):
|
async def test_no_summary_when_compact_returns_nothing(self):
|
||||||
"""Finally block should remove key from _archiving on success too."""
|
|
||||||
ac = _make_autocompact()
|
ac = _make_autocompact()
|
||||||
mock_sm = MagicMock(spec=SessionManager)
|
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.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")
|
ac._archiving.add("cli:test")
|
||||||
await ac._archive("cli:test")
|
await ac._archive("cli:test")
|
||||||
|
|
||||||
assert "cli:test" not in ac._archiving
|
assert "cli:test" not in ac._archiving
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ def mock_provider():
|
|||||||
def consolidator(store, mock_provider):
|
def consolidator(store, mock_provider):
|
||||||
sessions = MagicMock()
|
sessions = MagicMock()
|
||||||
sessions.save = 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(
|
return Consolidator(
|
||||||
store=store,
|
store=store,
|
||||||
provider=mock_provider,
|
provider=mock_provider,
|
||||||
@@ -117,6 +123,7 @@ class TestConsolidatorTokenBudget:
|
|||||||
session.last_consolidated = 0
|
session.last_consolidated = 0
|
||||||
session.messages = [{"role": "user", "content": "hi"}]
|
session.messages = [{"role": "user", "content": "hi"}]
|
||||||
session.key = "test:key"
|
session.key = "test:key"
|
||||||
|
consolidator.sessions._session_cache[session.key] = session
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
|
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
|
||||||
consolidator.archive = AsyncMock(return_value=True)
|
consolidator.archive = AsyncMock(return_value=True)
|
||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
await consolidator.maybe_consolidate_by_tokens(session)
|
||||||
@@ -152,6 +159,7 @@ class TestConsolidatorTokenBudget:
|
|||||||
session.add_message("user", f"u{i}")
|
session.add_message("user", f"u{i}")
|
||||||
session.add_message("assistant", f"a{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.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
|
||||||
consolidator.archive = AsyncMock(return_value="old conversation summary")
|
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("tool", "tool result", tool_call_id="call-1", name="x")
|
||||||
session.add_message("assistant", "final answer")
|
session.add_message("assistant", "final answer")
|
||||||
|
|
||||||
|
consolidator.sessions._session_cache[session.key] = session
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
|
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
|
||||||
consolidator.archive = AsyncMock(return_value="tool turn summary")
|
consolidator.archive = AsyncMock(return_value="tool turn summary")
|
||||||
|
|
||||||
@@ -210,6 +219,7 @@ class TestConsolidatorTokenBudget:
|
|||||||
}
|
}
|
||||||
for i in range(70)
|
for i in range(70)
|
||||||
]
|
]
|
||||||
|
consolidator.sessions._session_cache[session.key] = session
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||||
)
|
)
|
||||||
@@ -238,6 +248,7 @@ class TestConsolidatorTokenBudget:
|
|||||||
for i in range(70)
|
for i in range(70)
|
||||||
]
|
]
|
||||||
session.metadata = {}
|
session.metadata = {}
|
||||||
|
consolidator.sessions._session_cache[session.key] = session
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||||
)
|
)
|
||||||
@@ -263,6 +274,7 @@ class TestConsolidatorTokenBudget:
|
|||||||
for i in range(70)
|
for i in range(70)
|
||||||
]
|
]
|
||||||
session.metadata = {}
|
session.metadata = {}
|
||||||
|
consolidator.sessions._session_cache[session.key] = session
|
||||||
# Keep estimates high so the loop would otherwise run multiple rounds.
|
# Keep estimates high so the loop would otherwise run multiple rounds.
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||||
return_value=(1200, "tiktoken")
|
return_value=(1200, "tiktoken")
|
||||||
@@ -287,6 +299,7 @@ class TestConsolidatorTokenBudget:
|
|||||||
}
|
}
|
||||||
for i in range(70)
|
for i in range(70)
|
||||||
]
|
]
|
||||||
|
consolidator.sessions._session_cache[session.key] = session
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||||
)
|
)
|
||||||
@@ -299,6 +312,260 @@ class TestConsolidatorTokenBudget:
|
|||||||
assert session.last_consolidated == 61
|
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:
|
class TestRawArchiveTruncation:
|
||||||
"""raw_archive() must cap entry size to avoid bloating history.jsonl."""
|
"""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.heartbeat.service import HeartbeatService
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
class DummyProvider(LLMProvider):
|
class DummyProvider(LLMProvider):
|
||||||
@@ -11,9 +12,11 @@ class DummyProvider(LLMProvider):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._responses = list(responses)
|
self._responses = list(responses)
|
||||||
self.calls = 0
|
self.calls = 0
|
||||||
|
self.models: list[str | None] = []
|
||||||
|
|
||||||
async def chat(self, *args, **kwargs) -> LLMResponse:
|
async def chat(self, *args, **kwargs) -> LLMResponse:
|
||||||
self.calls += 1
|
self.calls += 1
|
||||||
|
self.models.append(kwargs.get("model"))
|
||||||
if self._responses:
|
if self._responses:
|
||||||
return self._responses.pop(0)
|
return self._responses.pop(0)
|
||||||
return LLMResponse(content="", tool_calls=[])
|
return LLMResponse(content="", tool_calls=[])
|
||||||
@@ -215,6 +218,51 @@ async def test_tick_suppresses_when_evaluator_says_no(tmp_path, monkeypatch) ->
|
|||||||
assert notified == []
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
|
async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
|
||||||
provider = DummyProvider([
|
provider = DummyProvider([
|
||||||
@@ -286,4 +334,3 @@ async def test_decide_prompt_includes_current_time(tmp_path) -> None:
|
|||||||
user_msg = captured_messages[1]
|
user_msg = captured_messages[1]
|
||||||
assert user_msg["role"] == "user"
|
assert user_msg["role"] == "user"
|
||||||
assert "Current Time:" in user_msg["content"]
|
assert "Current Time:" in user_msg["content"]
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import nanobot.agent.runner as runner_module
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
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:
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_bus_progress_forwards_tool_events_to_outbound_metadata(self, tmp_path: Path) -> None:
|
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."""
|
"""When run() handles a bus message, _tool_events lands in OutboundMessage metadata."""
|
||||||
@@ -130,6 +271,44 @@ class TestToolEventProgress:
|
|||||||
assert finish["phase"] == "end"
|
assert finish["phase"] == "end"
|
||||||
assert finish["result"] == "file.txt"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
|
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
|
||||||
self,
|
self,
|
||||||
@@ -353,8 +532,93 @@ class TestToolEventProgress:
|
|||||||
assert session_updated is not None
|
assert session_updated is not None
|
||||||
|
|
||||||
assert (session_updated.metadata or {}).get("_session_updated") is True
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
|
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ from nanobot.bus.events import InboundMessage
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.base import LLMResponse
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.webui_titles import (
|
from nanobot.utils.webui_turn_helpers import (
|
||||||
|
TITLE_GENERATION_MAX_TOKENS,
|
||||||
|
TITLE_GENERATION_REASONING_EFFORT,
|
||||||
WEBUI_SESSION_METADATA_KEY,
|
WEBUI_SESSION_METADATA_KEY,
|
||||||
WEBUI_TITLE_METADATA_KEY,
|
WEBUI_TITLE_METADATA_KEY,
|
||||||
|
WebuiTurnCoordinator,
|
||||||
maybe_generate_webui_title,
|
maybe_generate_webui_title,
|
||||||
)
|
)
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
def _mk_loop() -> AgentLoop:
|
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")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
|
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
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 generated is True
|
||||||
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
|
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
|
||||||
loop.provider.chat_with_retry.assert_awaited_once()
|
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
|
@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()
|
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:
|
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:runtime-only")
|
session = Session(key="test:runtime-only")
|
||||||
|
|||||||
@@ -1074,3 +1074,242 @@ class TestConfigurePydanticModelEmptyString:
|
|||||||
result = _configure_pydantic_model(model, "Test")
|
result = _configure_pydantic_model(model, "Test")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.api_key == ""
|
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.provider is new_provider
|
||||||
assert loop.dream.model == "new-model"
|
assert loop.dream.model == "new-model"
|
||||||
assert loop.dream._runner.provider is new_provider
|
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 = Session(key="unified:default")
|
||||||
session.messages = [{"role": "user", "content": "msg"}]
|
session.messages = [{"role": "user", "content": "msg"}]
|
||||||
|
sessions.get_or_create.return_value = session
|
||||||
|
|
||||||
# Simulate over-budget: estimated > budget
|
# Simulate over-budget: estimated > budget
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(950, "tiktoken"))
|
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(950, "tiktoken"))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
|||||||
|
"""Unit tests for the Signal markdown → plain text + textStyle converter."""
|
||||||
|
|
||||||
|
from nanobot.channels.signal import _markdown_to_signal, _partition_styles
|
||||||
|
from nanobot.utils.helpers import split_message
|
||||||
|
|
||||||
|
|
||||||
|
def _utf16_len(s: str) -> int:
|
||||||
|
return len(s.encode("utf-16-le")) // 2
|
||||||
|
|
||||||
|
|
||||||
|
def styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
|
||||||
|
"""Return a dict mapping each styled substring to its style list."""
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for entry in text_styles:
|
||||||
|
start_s, length_s, style = entry.split(":", 2)
|
||||||
|
start, length = int(start_s), int(length_s)
|
||||||
|
span = plain[start : start + length]
|
||||||
|
result.setdefault(span, []).append(style)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def utf16_styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
|
||||||
|
"""Like styles_for, but slices `plain` using UTF-16 offsets (Signal's units)."""
|
||||||
|
encoded = plain.encode("utf-16-le")
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for entry in text_styles:
|
||||||
|
start_s, length_s, style = entry.split(":", 2)
|
||||||
|
start, length = int(start_s), int(length_s)
|
||||||
|
span = encoded[start * 2 : (start + length) * 2].decode("utf-16-le")
|
||||||
|
result.setdefault(span, []).append(style)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Basic cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty():
|
||||||
|
plain, styles = _markdown_to_signal("")
|
||||||
|
assert plain == ""
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_text():
|
||||||
|
plain, styles = _markdown_to_signal("hello world")
|
||||||
|
assert plain == "hello world"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_stars():
|
||||||
|
plain, styles = _markdown_to_signal("say **hello** now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_underscores():
|
||||||
|
plain, styles = _markdown_to_signal("say __hello__ now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_italic_star():
|
||||||
|
plain, styles = _markdown_to_signal("say *hello* now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_italic_underscore():
|
||||||
|
plain, styles = _markdown_to_signal("say _hello_ now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_strikethrough():
|
||||||
|
plain, styles = _markdown_to_signal("say ~~hello~~ now")
|
||||||
|
assert plain == "say hello now"
|
||||||
|
assert styles_for(plain, styles) == {"hello": ["STRIKETHROUGH"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Code
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_code():
|
||||||
|
plain, styles = _markdown_to_signal("run `ls -la` here")
|
||||||
|
assert plain == "run ls -la here"
|
||||||
|
assert styles_for(plain, styles) == {"ls -la": ["MONOSPACE"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_block():
|
||||||
|
plain, styles = _markdown_to_signal("```\nprint('hi')\n```")
|
||||||
|
assert "print('hi')" in plain
|
||||||
|
assert styles_for(plain, styles).get("print('hi')\n") == ["MONOSPACE"] or "MONOSPACE" in str(
|
||||||
|
styles_for(plain, styles)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_block_with_lang():
|
||||||
|
plain, styles = _markdown_to_signal("```python\ncode\n```")
|
||||||
|
assert "code" in plain
|
||||||
|
assert any("MONOSPACE" in s for s in styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_block_not_processed_further():
|
||||||
|
"""Markdown inside a code block must not be styled."""
|
||||||
|
plain, styles = _markdown_to_signal("```\n**not bold**\n```")
|
||||||
|
assert "**not bold**" in plain
|
||||||
|
# Only MONOSPACE should be applied, no BOLD
|
||||||
|
for entry in styles:
|
||||||
|
assert "BOLD" not in entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_code_not_processed_further():
|
||||||
|
"""Markdown inside inline code must not be styled."""
|
||||||
|
plain, styles = _markdown_to_signal("use `**raw**` please")
|
||||||
|
assert "**raw**" in plain
|
||||||
|
for entry in styles:
|
||||||
|
assert "BOLD" not in entry
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Headers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_becomes_bold():
|
||||||
|
plain, styles = _markdown_to_signal("# My Title")
|
||||||
|
assert plain == "My Title"
|
||||||
|
assert styles_for(plain, styles) == {"My Title": ["BOLD"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_h2_becomes_bold():
|
||||||
|
plain, styles = _markdown_to_signal("## Sub-section")
|
||||||
|
assert plain == "Sub-section"
|
||||||
|
assert styles_for(plain, styles) == {"Sub-section": ["BOLD"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Blockquotes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_blockquote_strips_marker():
|
||||||
|
plain, styles = _markdown_to_signal("> some quote")
|
||||||
|
assert plain == "some quote"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bullet_dash():
|
||||||
|
plain, styles = _markdown_to_signal("- item one")
|
||||||
|
assert plain == "• item one"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bullet_star():
|
||||||
|
plain, styles = _markdown_to_signal("* item two")
|
||||||
|
assert plain == "• item two"
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbered_list():
|
||||||
|
plain, styles = _markdown_to_signal("1. first\n2. second")
|
||||||
|
assert "1. first" in plain
|
||||||
|
assert "2. second" in plain
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Links
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_text_differs_from_url():
|
||||||
|
plain, styles = _markdown_to_signal("[Click here](https://example.com)")
|
||||||
|
assert plain == "Click here (https://example.com)"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_text_equals_url():
|
||||||
|
plain, styles = _markdown_to_signal("[https://example.com](https://example.com)")
|
||||||
|
assert plain == "https://example.com"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_text_equals_url_without_scheme():
|
||||||
|
plain, styles = _markdown_to_signal("[example.com](https://example.com)")
|
||||||
|
assert plain == "https://example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mixed / nesting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_and_italic_adjacent():
|
||||||
|
plain, styles = _markdown_to_signal("**bold** and *italic*")
|
||||||
|
assert plain == "bold and italic"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert sd.get("bold") == ["BOLD"]
|
||||||
|
assert sd.get("italic") == ["ITALIC"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_with_inline_code():
|
||||||
|
"""Header becomes BOLD; code inside becomes MONOSPACE (not double-BOLD)."""
|
||||||
|
plain, styles = _markdown_to_signal("# Use `grep`")
|
||||||
|
assert plain == "Use grep"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert "BOLD" in sd.get("Use ", []) or "BOLD" in str(styles)
|
||||||
|
assert "MONOSPACE" in sd.get("grep", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_mixed():
|
||||||
|
md = "**Title**\n\nSome *italic* text.\n\n- bullet\n- another"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
assert "Title" in plain
|
||||||
|
assert "italic" in plain
|
||||||
|
assert "• bullet" in plain
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert "BOLD" in sd.get("Title", [])
|
||||||
|
assert "ITALIC" in sd.get("italic", [])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Table rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_rendered_as_monospace():
|
||||||
|
md = "| A | B |\n| - | - |\n| 1 | 2 |"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
assert "A" in plain and "B" in plain
|
||||||
|
assert any("MONOSPACE" in s for s in styles)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Style range format
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_range_format():
|
||||||
|
"""Each style entry must be 'start:length:STYLE'."""
|
||||||
|
_, styles = _markdown_to_signal("**bold** text")
|
||||||
|
for entry in styles:
|
||||||
|
parts = entry.split(":")
|
||||||
|
assert len(parts) == 3
|
||||||
|
assert parts[0].isdigit()
|
||||||
|
assert parts[1].isdigit()
|
||||||
|
assert parts[2] in {"BOLD", "ITALIC", "STRIKETHROUGH", "MONOSPACE", "SPOILER"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_ranges_are_within_bounds():
|
||||||
|
text = "hello **world** end"
|
||||||
|
plain, styles = _markdown_to_signal(text)
|
||||||
|
for entry in styles:
|
||||||
|
start_s, length_s, _ = entry.split(":", 2)
|
||||||
|
start, length = int(start_s), int(length_s)
|
||||||
|
assert start >= 0
|
||||||
|
assert start + length <= len(plain)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-BMP / UTF-16 offsets
|
||||||
|
#
|
||||||
|
# Signal's BodyRange (and signal-cli's textStyle) interprets start/length in
|
||||||
|
# UTF-16 code units. Python's len() counts code points, so characters outside
|
||||||
|
# the BMP (emojis, supplementary CJK) shift offsets by +1 per occurrence.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def assert_within_utf16_bounds(plain: str, styles: list[str]) -> None:
|
||||||
|
limit = _utf16_len(plain)
|
||||||
|
for entry in styles:
|
||||||
|
start_s, length_s, _ = entry.split(":", 2)
|
||||||
|
start, length = int(start_s), int(length_s)
|
||||||
|
assert start >= 0
|
||||||
|
assert start + length <= limit, f"range {entry} exceeds utf-16 length {limit} of {plain!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_with_emoji_inside():
|
||||||
|
plain, styles = _markdown_to_signal("**hi 🎉 bye**")
|
||||||
|
assert plain == "hi 🎉 bye"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"hi 🎉 bye": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_italic_with_trailing_emoji():
|
||||||
|
plain, styles = _markdown_to_signal("*bye 🎉*")
|
||||||
|
assert plain == "bye 🎉"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"bye 🎉": ["ITALIC"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_after_emoji_prefix():
|
||||||
|
plain, styles = _markdown_to_signal("🎉 **bold**")
|
||||||
|
assert plain == "🎉 bold"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"bold": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_after_and_inside_emoji():
|
||||||
|
plain, styles = _markdown_to_signal("🎉 **a 🎊 b**")
|
||||||
|
assert plain == "🎉 a 🎊 b"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"a 🎊 b": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_supplementary_cjk_in_bold():
|
||||||
|
"""Non-BMP CJK (U+20BB7) proves the bug is UTF-16, not emoji-specific."""
|
||||||
|
plain, styles = _markdown_to_signal("**𠮷野家**")
|
||||||
|
assert plain == "𠮷野家"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"𠮷野家": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwj_emoji_in_bold():
|
||||||
|
"""ZWJ family sequence = multiple surrogate pairs + BMP ZWJs."""
|
||||||
|
plain, styles = _markdown_to_signal("**hi 👨👩👧 bye**")
|
||||||
|
assert plain == "hi 👨👩👧 bye"
|
||||||
|
assert utf16_styles_for(plain, styles) == {"hi 👨👩👧 bye": ["BOLD"]}
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ascii_offsets_unchanged():
|
||||||
|
"""ASCII-only path must produce the same offsets as before the UTF-16 fix."""
|
||||||
|
plain, styles = _markdown_to_signal("**bold** plain *it*")
|
||||||
|
assert plain == "bold plain it"
|
||||||
|
assert sorted(styles) == sorted(["0:4:BOLD", "11:2:ITALIC"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_reported_daily_brief_pattern():
|
||||||
|
"""Regression for the reported bug: a single non-BMP emoji shifts every
|
||||||
|
subsequent styled span left by 1 UTF-16 unit, lopping off the last letter.
|
||||||
|
"""
|
||||||
|
md = (
|
||||||
|
"**Weather**\n"
|
||||||
|
"- Conditions: 🌩️ Thunderstorms\n\n"
|
||||||
|
"**News**\n"
|
||||||
|
"*World*\n"
|
||||||
|
"*Local*\n\n"
|
||||||
|
"**Quote of the Day**"
|
||||||
|
)
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
sd = utf16_styles_for(plain, styles)
|
||||||
|
assert sd.get("Weather") == ["BOLD"]
|
||||||
|
assert sd.get("News") == ["BOLD"]
|
||||||
|
assert sd.get("World") == ["ITALIC"]
|
||||||
|
assert sd.get("Local") == ["ITALIC"]
|
||||||
|
assert sd.get("Quote of the Day") == ["BOLD"]
|
||||||
|
assert_within_utf16_bounds(plain, styles)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chunk redistribution
|
||||||
|
#
|
||||||
|
# split_message can break a long Signal payload into multiple chunks. The
|
||||||
|
# style ranges from _markdown_to_signal are anchored to the full text, so
|
||||||
|
# they must be redistributed per-chunk with rebased offsets — otherwise
|
||||||
|
# styles for chunks 1..N are silently lost.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_chunk_styles(text: str, max_len: int) -> tuple[list[str], list[list[str]]]:
|
||||||
|
"""Helper: full markdown → signal pipeline, including chunking."""
|
||||||
|
plain, styles = _markdown_to_signal(text)
|
||||||
|
chunks = split_message(plain, max_len) if plain else [""]
|
||||||
|
return chunks, _partition_styles(plain, chunks, styles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_single_chunk_passthrough():
|
||||||
|
plain, styles = _markdown_to_signal("**bold** plain *it*")
|
||||||
|
parts = _partition_styles(plain, [plain], styles)
|
||||||
|
assert parts == [styles]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_no_styles():
|
||||||
|
plain = "hello world"
|
||||||
|
assert _partition_styles(plain, [plain], []) == [[]]
|
||||||
|
assert _partition_styles(plain, ["hello", "world"], []) == [[], []]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_drops_styles_outside_chunks():
|
||||||
|
"""Whitespace trimmed by split_message must not carry a style range."""
|
||||||
|
plain = "a b"
|
||||||
|
# Fake a style spanning the trimmed whitespace only.
|
||||||
|
chunks = ["a", "b"]
|
||||||
|
parts = _partition_styles(plain, chunks, ["1:3:BOLD"])
|
||||||
|
assert parts == [[], []]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_long_message_preserves_chunk_one_styles():
|
||||||
|
"""A bold span deep in the message must follow the message into chunk 1."""
|
||||||
|
# Two ~30-char paragraphs separated by a blank line, then **tail**.
|
||||||
|
line_a = "alpha " * 5 # 30 chars, ends with space
|
||||||
|
line_b = "beta " * 5
|
||||||
|
md = f"{line_a.strip()}\n\n{line_b.strip()}\n\n**tail**"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
# Force a split between the paragraphs.
|
||||||
|
max_len = len(line_a.strip()) + 2 # fits paragraph A + the "\n\n"
|
||||||
|
chunks = split_message(plain, max_len)
|
||||||
|
assert len(chunks) >= 2, "test setup must produce a split"
|
||||||
|
parts = _partition_styles(plain, chunks, styles)
|
||||||
|
# The bold "tail" should land in the last chunk, with chunk-relative offset.
|
||||||
|
final_chunk = chunks[-1]
|
||||||
|
final_styles = parts[-1]
|
||||||
|
assert any("BOLD" in s for s in final_styles)
|
||||||
|
for entry in final_styles:
|
||||||
|
s, ln, _ = entry.split(":", 2)
|
||||||
|
start, length = int(s), int(ln)
|
||||||
|
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
|
||||||
|
"utf-16-le"
|
||||||
|
)
|
||||||
|
assert slice_ == "tail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_chunk_zero_styles_unchanged():
|
||||||
|
"""Styles entirely in chunk 0 keep their original offsets."""
|
||||||
|
md = "**head** middle and **tail**"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
# Split so chunk 0 contains "head" and part of the rest, chunk 1 contains "tail".
|
||||||
|
chunks = split_message(plain, 12)
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
parts = _partition_styles(plain, chunks, styles)
|
||||||
|
# "head" lives in chunk 0; assert its offset is unchanged (chunk 0 starts at 0).
|
||||||
|
head_entries = [s for s in parts[0] if "BOLD" in s]
|
||||||
|
assert any(s.startswith("0:4:") for s in head_entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_with_non_bmp_chunk_offset():
|
||||||
|
"""Chunk-start offsets must be expressed in UTF-16 code units."""
|
||||||
|
# Emoji in chunk 0, bold in chunk 1.
|
||||||
|
md = "🎉 alpha beta gamma\n\n**tail**"
|
||||||
|
plain, styles = _markdown_to_signal(md)
|
||||||
|
chunks = split_message(plain, 18)
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
parts = _partition_styles(plain, chunks, styles)
|
||||||
|
final_styles = parts[-1]
|
||||||
|
assert any("BOLD" in s for s in final_styles)
|
||||||
|
final_chunk = chunks[-1]
|
||||||
|
for entry in final_styles:
|
||||||
|
s, ln, _ = entry.split(":", 2)
|
||||||
|
start, length = int(s), int(ln)
|
||||||
|
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
|
||||||
|
"utf-16-le"
|
||||||
|
)
|
||||||
|
assert slice_ == "tail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_styles_range_spanning_chunks_is_split():
|
||||||
|
"""A style range that straddles a chunk boundary gets sliced into both chunks."""
|
||||||
|
# Construct manually: plain = "abc def", style covers "abc def" (whole thing).
|
||||||
|
plain = "abc def"
|
||||||
|
chunks = split_message(plain, 4) # "abc" / "def"
|
||||||
|
assert chunks == ["abc", "def"]
|
||||||
|
parts = _partition_styles(plain, chunks, ["0:7:BOLD"])
|
||||||
|
# Chunk 0 holds 0:3:BOLD, chunk 1 holds 0:3:BOLD (length=3 each, "def" only
|
||||||
|
# since the space was trimmed by lstrip).
|
||||||
|
assert parts[0] == ["0:3:BOLD"]
|
||||||
|
assert parts[1] == ["0:3:BOLD"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Adjacency, nesting, and malformed input
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_italic_combo_outer_bold_inner_italic():
|
||||||
|
"""`**_combo_**` carries both BOLD and ITALIC over the same span."""
|
||||||
|
plain, styles = _markdown_to_signal("**_combo_**")
|
||||||
|
assert plain == "combo"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert set(sd.get("combo", [])) == {"BOLD", "ITALIC"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_and_italic_adjacent_no_separator():
|
||||||
|
"""`**bold***italic*` produces BOLD on `bold` and ITALIC on `italic`."""
|
||||||
|
plain, styles = _markdown_to_signal("**bold***italic*")
|
||||||
|
assert plain == "bolditalic"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert sd.get("bold") == ["BOLD"]
|
||||||
|
assert sd.get("italic") == ["ITALIC"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unclosed_bold_falls_through_as_plain():
|
||||||
|
"""An unmatched `**` opener round-trips as literal text with no style."""
|
||||||
|
plain, styles = _markdown_to_signal("**bold")
|
||||||
|
assert plain == "**bold"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unclosed_inline_code_falls_through_as_plain():
|
||||||
|
"""An unmatched backtick round-trips as literal text with no style."""
|
||||||
|
plain, styles = _markdown_to_signal("use `grep")
|
||||||
|
assert plain == "use `grep"
|
||||||
|
assert styles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_code_inside_blockquote():
|
||||||
|
"""Blockquote prefix is stripped; inline code becomes MONOSPACE."""
|
||||||
|
plain, styles = _markdown_to_signal("> use `grep`")
|
||||||
|
assert plain == "use grep"
|
||||||
|
sd = styles_for(plain, styles)
|
||||||
|
assert sd.get("grep") == ["MONOSPACE"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_with_inner_bold_produces_contiguous_bold_ranges():
|
||||||
|
"""`# **wrap** me` — header forces BOLD over the whole line; the inner `**`
|
||||||
|
splits the run, yielding two contiguous BOLD ranges that together cover
|
||||||
|
"wrap me". This is intentional — Signal renders adjacent same-style ranges
|
||||||
|
as a single visual span.
|
||||||
|
"""
|
||||||
|
plain, styles = _markdown_to_signal("# **wrap** me")
|
||||||
|
assert plain == "wrap me"
|
||||||
|
# Both ranges are BOLD; collectively they cover the whole "wrap me".
|
||||||
|
bold_ranges = [s for s in styles if s.endswith(":BOLD")]
|
||||||
|
assert len(bold_ranges) == 2
|
||||||
|
covered = set()
|
||||||
|
for entry in bold_ranges:
|
||||||
|
start, length, _ = entry.split(":", 2)
|
||||||
|
for i in range(int(start), int(start) + int(length)):
|
||||||
|
covered.add(i)
|
||||||
|
assert covered == set(range(len(plain)))
|
||||||
@@ -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
|
@pytest.mark.asyncio
|
||||||
async def test_send_progress_includes_agent_ui_blob() -> None:
|
async def test_send_progress_includes_agent_ui_blob() -> None:
|
||||||
bus = MagicMock()
|
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"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_non_connection_closed_exception_is_raised() -> None:
|
async def test_send_non_connection_closed_exception_is_raised() -> None:
|
||||||
bus = MagicMock()
|
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"]}
|
providers = {provider["name"]: provider for provider in body["providers"]}
|
||||||
assert providers["openai"]["configured"] is True
|
assert providers["openai"]["configured"] is True
|
||||||
assert providers["openai"]["api_key_hint"] == "secr••••-key"
|
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"]["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["agent"]["has_api_key"] is True
|
||||||
assert body["web_search"]["provider"] == "brave"
|
assert body["web_search"]["provider"] == "brave"
|
||||||
assert body["web_search"]["api_key_hint"] == "brav••••cret"
|
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 provider_rows["openrouter"]["configured"] is True
|
||||||
assert "sk-or-test" not in provider_updated.text
|
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(
|
updated = await _http_get(
|
||||||
"http://127.0.0.1:"
|
"http://127.0.0.1:"
|
||||||
f"{port}/api/settings/update?model=openrouter/test"
|
f"{port}/api/settings/update?model=atomic_chat/test"
|
||||||
"&provider=openrouter",
|
"&provider=atomic_chat",
|
||||||
headers={"Authorization": "Bearer tok"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert updated.status_code == 200
|
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"
|
assert search_body["web_search"]["base_url"] == "https://search.example.com"
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model == "openrouter/test"
|
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||||
assert saved.agents.defaults.provider == "openrouter"
|
assert saved.agents.defaults.provider == "atomic_chat"
|
||||||
assert saved.providers.openrouter.api_key == "sk-or-test"
|
assert saved.providers.openrouter.api_key == "sk-or-test"
|
||||||
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
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.provider == "searxng"
|
||||||
assert saved.tools.web.search.api_key == ""
|
assert saved.tools.web.search.api_key == ""
|
||||||
assert saved.tools.web.search.base_url == "https://search.example.com"
|
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.model = "test-model"
|
||||||
self.provider = kwargs.get("provider", object())
|
self.provider = kwargs.get("provider", object())
|
||||||
self.tools = {}
|
self.tools = {}
|
||||||
|
seen["agent"] = self
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@@ -1218,6 +1219,11 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
assert isinstance(cron, _FakeCron)
|
assert isinstance(cron, _FakeCron)
|
||||||
assert cron.on_job is not None
|
assert cron.on_job is not None
|
||||||
|
|
||||||
|
runtime_provider = object()
|
||||||
|
agent = seen["agent"]
|
||||||
|
agent.provider = runtime_provider
|
||||||
|
agent.model = "runtime-model"
|
||||||
|
|
||||||
job = CronJob(
|
job = CronJob(
|
||||||
id="cron-1",
|
id="cron-1",
|
||||||
name="stretch",
|
name="stretch",
|
||||||
@@ -1233,8 +1239,8 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
|
|
||||||
assert response == "Time to stretch."
|
assert response == "Time to stretch."
|
||||||
assert seen["response"] == "Time to stretch."
|
assert seen["response"] == "Time to stretch."
|
||||||
assert seen["provider"] is provider
|
assert seen["provider"] is runtime_provider
|
||||||
assert seen["model"] == "test-model"
|
assert seen["model"] == "runtime-model"
|
||||||
assert seen["task_context"] == (
|
assert seen["task_context"] == (
|
||||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
"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 — "
|
"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.dream = _FakeDream()
|
||||||
self.sessions = _FakeSessionManager()
|
self.sessions = _FakeSessionManager()
|
||||||
|
|
||||||
|
def llm_runtime(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,72 @@ async def test_reasoning_delta_displayed_when_show_reasoning_enabled():
|
|||||||
assert calls == ["I should search first."]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_hidden_when_show_reasoning_disabled():
|
async def test_reasoning_hidden_when_show_reasoning_disabled():
|
||||||
"""Reasoning content should be suppressed when show_reasoning is False."""
|
"""Reasoning content should be suppressed when show_reasoning is False."""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
AIHubMixImageGenerationClient,
|
AIHubMixImageGenerationClient,
|
||||||
|
GeminiImageGenerationClient,
|
||||||
GeneratedImageResponse,
|
GeneratedImageResponse,
|
||||||
ImageGenerationError,
|
ImageGenerationError,
|
||||||
OpenRouterImageGenerationClient,
|
OpenRouterImageGenerationClient,
|
||||||
@@ -202,3 +203,137 @@ async def test_aihubmix_image_generation_downloads_url_response() -> None:
|
|||||||
|
|
||||||
assert response.images[0].startswith("data:image/png;base64,")
|
assert response.images[0].startswith("data:image/png;base64,")
|
||||||
assert fake.get_calls[0]["url"] == "https://cdn.example/image.png"
|
assert fake.get_calls[0]["url"] == "https://cdn.example/image.png"
|
||||||
|
|
||||||
|
|
||||||
|
RAW_B64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gemini_imagen_payload_and_response() -> None:
|
||||||
|
fake = FakeClient(
|
||||||
|
FakeResponse({"predictions": [{"bytesBase64Encoded": RAW_B64, "mimeType": "image/png"}]})
|
||||||
|
)
|
||||||
|
client = GeminiImageGenerationClient(
|
||||||
|
api_key="AIza-test",
|
||||||
|
api_base="https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(
|
||||||
|
prompt="a sunset",
|
||||||
|
model="imagen-4.0-generate-001",
|
||||||
|
aspect_ratio="16:9",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.images == [PNG_DATA_URL]
|
||||||
|
assert response.content == ""
|
||||||
|
call = fake.calls[0]
|
||||||
|
assert call["url"].endswith(":predict")
|
||||||
|
assert call["headers"]["x-goog-api-key"] == "AIza-test"
|
||||||
|
assert "params" not in call
|
||||||
|
body = call["json"]
|
||||||
|
assert body["instances"] == [{"prompt": "a sunset"}]
|
||||||
|
assert body["parameters"]["sampleCount"] == 1
|
||||||
|
assert body["parameters"]["aspectRatio"] == "16:9"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gemini_imagen_ignores_unsupported_aspect_ratio() -> None:
|
||||||
|
fake = FakeClient(
|
||||||
|
FakeResponse({"predictions": [{"bytesBase64Encoded": RAW_B64, "mimeType": "image/png"}]})
|
||||||
|
)
|
||||||
|
client = GeminiImageGenerationClient(api_key="AIza-test", client=fake) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
await client.generate(prompt="a sunset", model="imagen-4.0-generate-001", aspect_ratio="2:3")
|
||||||
|
|
||||||
|
body = fake.calls[0]["json"]
|
||||||
|
assert "aspectRatio" not in body["parameters"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gemini_flash_payload_and_response() -> None:
|
||||||
|
fake = FakeClient(
|
||||||
|
FakeResponse(
|
||||||
|
{
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"parts": [
|
||||||
|
{"text": "here is your image"},
|
||||||
|
{"inlineData": {"mimeType": "image/png", "data": RAW_B64}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client = GeminiImageGenerationClient(
|
||||||
|
api_key="AIza-test",
|
||||||
|
api_base="https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
client=fake, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.generate(
|
||||||
|
prompt="draw a cat",
|
||||||
|
model="gemini-2.0-flash-preview-image-generation",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.images == [PNG_DATA_URL]
|
||||||
|
assert response.content == "here is your image"
|
||||||
|
call = fake.calls[0]
|
||||||
|
assert call["url"].endswith(":generateContent")
|
||||||
|
assert call["headers"]["x-goog-api-key"] == "AIza-test"
|
||||||
|
assert "params" not in call
|
||||||
|
body = call["json"]
|
||||||
|
assert body["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"]
|
||||||
|
assert body["contents"][0]["parts"][-1] == {"text": "draw a cat"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gemini_flash_reference_images(tmp_path: Path) -> None:
|
||||||
|
ref = tmp_path / "ref.png"
|
||||||
|
ref.write_bytes(PNG_BYTES)
|
||||||
|
fake = FakeClient(
|
||||||
|
FakeResponse(
|
||||||
|
{
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"parts": [{"inlineData": {"mimeType": "image/png", "data": RAW_B64}}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client = GeminiImageGenerationClient(api_key="AIza-test", client=fake) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
response = await client.generate(
|
||||||
|
prompt="edit this",
|
||||||
|
model="gemini-2.0-flash-preview-image-generation",
|
||||||
|
reference_images=[str(ref)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.images == [PNG_DATA_URL]
|
||||||
|
parts = fake.calls[0]["json"]["contents"][0]["parts"]
|
||||||
|
assert parts[0]["inlineData"]["mimeType"] == "image/png"
|
||||||
|
assert parts[0]["inlineData"]["data"].startswith("iVBOR")
|
||||||
|
assert parts[1] == {"text": "edit this"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gemini_requires_api_key() -> None:
|
||||||
|
client = GeminiImageGenerationClient(api_key=None)
|
||||||
|
|
||||||
|
with pytest.raises(ImageGenerationError, match="API key"):
|
||||||
|
await client.generate(prompt="draw", model="imagen-4.0-generate-001")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gemini_no_images_raises() -> None:
|
||||||
|
fake = FakeClient(FakeResponse({"candidates": [{"content": {"parts": [{"text": "sorry"}]}}]}))
|
||||||
|
client = GeminiImageGenerationClient(api_key="AIza-test", client=fake) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
with pytest.raises(ImageGenerationError, match="returned no images"):
|
||||||
|
await client.generate(prompt="draw", model="gemini-2.0-flash-preview-image-generation")
|
||||||
|
|||||||
@@ -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
|
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:
|
def test_build_response_schema(monkeypatch, tmp_path) -> None:
|
||||||
from nanobot.utils.webui_transcript import build_webui_thread_response
|
from nanobot.utils.webui_transcript import build_webui_thread_response
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
@@ -594,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-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-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=="],
|
"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=="],
|
||||||
@@ -750,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=="],
|
"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-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=="],
|
"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=="],
|
||||||
|
|||||||
Generated
+744
@@ -318,6 +318,278 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
@@ -333,6 +605,108 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.5",
|
"version": "1.7.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1280,6 +1654,277 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
@@ -1304,6 +1949,90 @@
|
|||||||
"linux"
|
"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": {
|
"node_modules/@tailwindcss/typography": {
|
||||||
"version": "0.5.19",
|
"version": "0.5.19",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2309,6 +3038,21 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"tailwind-merge": "^2.6.0"
|
"tailwind-merge": "^2.6.0"
|
||||||
|
|||||||
+157
-89
@@ -7,7 +7,8 @@ import { ThreadShell } from "@/components/thread/ThreadShell";
|
|||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
|
|
||||||
import { useSessions } from "@/hooks/useSessions";
|
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 { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
clearSavedSecret,
|
clearSavedSecret,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
loadSavedSecret,
|
loadSavedSecret,
|
||||||
saveSecret,
|
saveSecret,
|
||||||
} from "@/lib/bootstrap";
|
} from "@/lib/bootstrap";
|
||||||
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
@@ -30,14 +32,30 @@ type BootState =
|
|||||||
status: "ready";
|
status: "ready";
|
||||||
client: NanobotClient;
|
client: NanobotClient;
|
||||||
token: string;
|
token: string;
|
||||||
|
tokenExpiresAt: number;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||||
const SIDEBAR_WIDTH = 272;
|
const SIDEBAR_WIDTH = 272;
|
||||||
|
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||||
|
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||||
type ShellView = "chat" | "settings";
|
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({
|
function AuthForm({
|
||||||
failed,
|
failed,
|
||||||
onSecret,
|
onSecret,
|
||||||
@@ -106,6 +124,7 @@ function readSidebarOpen(): boolean {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<BootState>({ status: "loading" });
|
const [state, setState] = useState<BootState>({ status: "loading" });
|
||||||
|
const bootstrapSecretRef = useRef("");
|
||||||
|
|
||||||
const bootstrapWithSecret = useCallback(
|
const bootstrapWithSecret = useCallback(
|
||||||
(secret: string) => {
|
(secret: string) => {
|
||||||
@@ -117,22 +136,37 @@ export default function App() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (secret) saveSecret(secret);
|
if (secret) saveSecret(secret);
|
||||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||||
const client = new NanobotClient({
|
let client: NanobotClient;
|
||||||
|
client = new NanobotClient({
|
||||||
url,
|
url,
|
||||||
onReauth: async () => {
|
onReauth: async () => {
|
||||||
try {
|
try {
|
||||||
const refreshed = await fetchBootstrap("", secret);
|
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
|
||||||
return deriveWsUrl(refreshed.ws_path, refreshed.token);
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
bootstrapSecretRef.current = secret;
|
||||||
client.connect();
|
client.connect();
|
||||||
setState({
|
setState({
|
||||||
status: "ready",
|
status: "ready",
|
||||||
client,
|
client,
|
||||||
token: boot.token,
|
token: boot.token,
|
||||||
|
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
||||||
modelName: boot.model_name ?? null,
|
modelName: boot.model_name ?? null,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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(() => {
|
useEffect(() => {
|
||||||
const saved = loadSavedSecret();
|
const saved = loadSavedSecret();
|
||||||
return bootstrapWithSecret(saved);
|
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 { t, i18n } = useTranslation();
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
@@ -362,9 +431,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
});
|
});
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useCallback(() => {
|
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
@@ -386,8 +453,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
|
|
||||||
const headerTitle = activeSession
|
const headerTitle = activeSession
|
||||||
? activeSession.title ||
|
? activeSession.title ||
|
||||||
activeSession.preview ||
|
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
|
|
||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -415,93 +481,95 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
const showMainSidebar = view !== "settings";
|
const showMainSidebar = view !== "settings";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full overflow-hidden">
|
<ThemeProvider theme={theme}>
|
||||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
<div className="relative flex h-full w-full overflow-hidden">
|
||||||
{showMainSidebar ? (
|
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||||
<aside
|
{showMainSidebar ? (
|
||||||
className={cn(
|
<aside
|
||||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
className={cn(
|
||||||
"transition-[width] duration-300 ease-out",
|
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||||
)}
|
"transition-[width] duration-300 ease-out",
|
||||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
)}
|
||||||
>
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
"absolute inset-0 flex flex-col",
|
||||||
"transition-transform duration-300 ease-out",
|
view === "settings" && "invisible pointer-events-none",
|
||||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
|
||||||
)}
|
)}
|
||||||
style={{ width: SIDEBAR_WIDTH }}
|
|
||||||
>
|
>
|
||||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
<ThreadShell
|
||||||
</div>
|
session={activeSession}
|
||||||
</aside>
|
title={headerTitle}
|
||||||
) : null}
|
onToggleSidebar={toggleSidebar}
|
||||||
|
onNewChat={onNewChat}
|
||||||
{showMainSidebar ? (
|
onCreateChat={onCreateChat}
|
||||||
<Sheet
|
onTurnEnd={onTurnEnd}
|
||||||
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
|
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
onBackToChat={onBackToChat}
|
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||||
onModelNameChange={onModelNameChange}
|
|
||||||
onLogout={onLogout}
|
|
||||||
onRestart={onRestart}
|
|
||||||
isRestarting={isRestarting}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{view === "settings" && (
|
||||||
</main>
|
<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
|
<DeleteConfirm
|
||||||
open={!!pendingDelete}
|
open={!!pendingDelete}
|
||||||
title={pendingDelete?.label ?? ""}
|
title={pendingDelete?.label ?? ""}
|
||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
/>
|
/>
|
||||||
{restartToast ? (
|
{restartToast ? (
|
||||||
<div
|
<div
|
||||||
role="status"
|
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"
|
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}
|
{restartToast}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
@@ -64,8 +65,11 @@ export function ChatList({
|
|||||||
const fallbackTitle = t("chat.fallbackTitle", {
|
const fallbackTitle = t("chat.fallbackTitle", {
|
||||||
id: s.chatId.slice(0, 6),
|
id: s.chatId.slice(0, 6),
|
||||||
});
|
});
|
||||||
const rawLabel = (s.title || s.preview)?.trim();
|
const generatedTitle = s.title?.trim() || "";
|
||||||
const title = rawLabel || fallbackTitle;
|
const title =
|
||||||
|
generatedTitle || deriveTitle(s.preview, t("chat.newChat"));
|
||||||
|
const tooltipTitle =
|
||||||
|
generatedTitle || deriveTitle(s.preview, fallbackTitle);
|
||||||
return (
|
return (
|
||||||
<li key={s.key} className="min-w-0">
|
<li key={s.key} className="min-w-0">
|
||||||
<div
|
<div
|
||||||
@@ -79,7 +83,7 @@ export function ChatList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(s.key)}
|
onClick={() => onSelect(s.key)}
|
||||||
title={rawLabel || fallbackTitle}
|
title={tooltipTitle}
|
||||||
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
|
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>
|
<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 { Check, Copy } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
language?: string;
|
language?: string;
|
||||||
code: string;
|
code: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
highlight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */
|
interface HighlightedCodeProps {
|
||||||
function useIsDark() {
|
language?: string;
|
||||||
const [isDark, setIsDark] = useState(() =>
|
code: string;
|
||||||
typeof document !== "undefined"
|
isDark: boolean;
|
||||||
? document.documentElement.classList.contains("dark")
|
}
|
||||||
: true,
|
|
||||||
|
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 { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const isDark = useIsDark();
|
const isDark = useThemeValue() === "dark";
|
||||||
|
|
||||||
const onCopy = useCallback(() => {
|
const onCopy = useCallback(() => {
|
||||||
if (!navigator.clipboard) return;
|
if (!navigator.clipboard) return;
|
||||||
@@ -86,20 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
|||||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SyntaxHighlighter
|
{highlight ? (
|
||||||
language={language}
|
<Suspense fallback={<PlainCodeFallback code={code} />}>
|
||||||
style={isDark ? oneDark : oneLight}
|
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
|
||||||
customStyle={{
|
</Suspense>
|
||||||
margin: 0,
|
) : (
|
||||||
padding: "1rem",
|
<PlainCodeFallback code={code} />
|
||||||
fontSize: "0.875rem",
|
)}
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
PreTag="pre"
|
|
||||||
wrapLongLines
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,21 +36,25 @@ export function ConnectionBadge() {
|
|||||||
status === "connecting" ||
|
status === "connecting" ||
|
||||||
status === "reconnecting" ||
|
status === "reconnecting" ||
|
||||||
status === "error";
|
status === "error";
|
||||||
|
const label = t(`connection.${status}`);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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,
|
meta.color,
|
||||||
)}
|
)}
|
||||||
aria-live="polite"
|
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 && (
|
{pulsing && (
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
|
<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>
|
</span>
|
||||||
{t(`connection.${status}`)}
|
<span className="sr-only">{label}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface MarkdownTextProps {
|
interface MarkdownTextProps {
|
||||||
children: string;
|
children: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
||||||
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
|
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 {
|
export function preloadMarkdownText(): void {
|
||||||
void loadMarkdownRenderer();
|
void loadMarkdownRenderer();
|
||||||
}
|
}
|
||||||
@@ -19,7 +50,18 @@ export function preloadMarkdownText(): void {
|
|||||||
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
|
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
|
||||||
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
|
* ``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 (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -29,11 +71,73 @@ export function MarkdownText({ children, className }: MarkdownTextProps) {
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{renderedSource}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer>
|
<MemoizedMarkdownRenderer
|
||||||
|
source={renderedSource}
|
||||||
|
className={className}
|
||||||
|
highlightCode={highlightCode}
|
||||||
|
/>
|
||||||
</Suspense>
|
</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 ReactMarkdown from "react-markdown";
|
||||||
import rehypeKatex from "rehype-katex";
|
import rehypeKatex from "rehype-katex";
|
||||||
|
import remarkBreaks from "remark-breaks";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
|
|
||||||
import { CodeBlock } from "@/components/CodeBlock";
|
import { CodeBlock } from "@/components/CodeBlock";
|
||||||
|
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
@@ -12,8 +15,12 @@ import "katex/dist/katex.min.css";
|
|||||||
interface MarkdownTextRendererProps {
|
interface MarkdownTextRendererProps {
|
||||||
children: string;
|
children: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
highlightCode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remarkPlugins = [remarkBreaks, remarkGfm, remarkMath];
|
||||||
|
const rehypePlugins = [rehypeKatex];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
|
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
|
||||||
* separate chunk so the app shell can paint sooner on refresh.
|
* separate chunk so the app shell can paint sooner on refresh.
|
||||||
@@ -21,7 +28,91 @@ interface MarkdownTextRendererProps {
|
|||||||
export default function MarkdownTextRenderer({
|
export default function MarkdownTextRenderer({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
highlightCode = true,
|
||||||
}: MarkdownTextRendererProps) {
|
}: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -42,77 +133,9 @@ export default function MarkdownTextRenderer({
|
|||||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={remarkPlugins}
|
||||||
rehypePlugins={[rehypeKatex]}
|
rehypePlugins={rehypePlugins}
|
||||||
components={{
|
components={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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useDeferredValue,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -120,7 +119,7 @@ export function MessageBubble({
|
|||||||
<TypingDots />
|
<TypingDots />
|
||||||
) : empty && message.isStreaming ? null : (
|
) : empty && message.isStreaming ? null : (
|
||||||
<>
|
<>
|
||||||
<MarkdownText>{message.content}</MarkdownText>
|
<MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText>
|
||||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||||
{showAssistantFooterRow ? (
|
{showAssistantFooterRow ? (
|
||||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
<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";
|
align: "left" | "right";
|
||||||
}) {
|
}) {
|
||||||
if (media.length === 0) return null;
|
if (media.length === 0) return null;
|
||||||
const images = media
|
const images: UIImage[] = [];
|
||||||
.filter((item) => item.kind === "image")
|
const nonImages: UIMediaAttachment[] = [];
|
||||||
.map(({ url, name }) => ({ url, name }));
|
for (const item of media) {
|
||||||
const nonImages = media.filter((item) => item.kind !== "image");
|
if (item.kind === "image") {
|
||||||
|
images.push({ url: item.url, name: item.name });
|
||||||
|
} else {
|
||||||
|
nonImages.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -276,13 +280,14 @@ function UserImages({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Only real-URL images can open in the lightbox; historical-replay
|
// Only real-URL images can open in the lightbox; historical-replay
|
||||||
// placeholders (no URL) have nothing to zoom into.
|
// placeholders (no URL) have nothing to zoom into.
|
||||||
const viewable = images
|
const viewableImages: UIImage[] = [];
|
||||||
.map((img, i) => ({ img, i }))
|
const originalToViewable = new Map<number, number>();
|
||||||
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
|
for (let i = 0; i < images.length; i += 1) {
|
||||||
const viewableImages = viewable.map(({ img }) => img);
|
const img = images[i];
|
||||||
const originalToViewable = new Map<number, number>(
|
if (typeof img.url !== "string" || img.url.length === 0) continue;
|
||||||
viewable.map(({ i }, v) => [i, v]),
|
originalToViewable.set(i, viewableImages.length);
|
||||||
);
|
viewableImages.push(img);
|
||||||
|
}
|
||||||
|
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
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({
|
export function StreamingLabelSheen({
|
||||||
children,
|
children,
|
||||||
active,
|
active,
|
||||||
@@ -426,21 +431,21 @@ export function StreamingLabelSheen({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const sheenText =
|
||||||
|
typeof children === "string" || typeof children === "number"
|
||||||
|
? String(children)
|
||||||
|
: undefined;
|
||||||
return (
|
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
|
<span
|
||||||
|
data-sheen-text={active ? sheenText : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-0 block font-medium leading-normal text-muted-foreground",
|
"block w-fit max-w-full truncate font-medium leading-normal",
|
||||||
!active && "truncate",
|
active ? "streaming-text-sheen" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
{active ? (
|
|
||||||
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
|
|
||||||
<span className="reasoning-sheen-stripe" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -474,8 +479,6 @@ export function ReasoningBubble({
|
|||||||
embeddedInCluster = false,
|
embeddedInCluster = false,
|
||||||
}: ReasoningBubbleProps) {
|
}: ReasoningBubbleProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deferredText = useDeferredValue(text);
|
|
||||||
const markdownSource = streaming ? deferredText : text;
|
|
||||||
const [userToggled, setUserToggled] = useState(false);
|
const [userToggled, setUserToggled] = useState(false);
|
||||||
const [openLocal, setOpenLocal] = useState(true);
|
const [openLocal, setOpenLocal] = useState(true);
|
||||||
const open = userToggled ? openLocal : streaming;
|
const open = userToggled ? openLocal : streaming;
|
||||||
@@ -531,6 +534,7 @@ export function ReasoningBubble({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MarkdownText
|
<MarkdownText
|
||||||
|
streaming={streaming}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[12.5px] italic text-muted-foreground/88",
|
"text-[12.5px] italic text-muted-foreground/88",
|
||||||
"prose-p:my-1.5 prose-li:my-0.5",
|
"prose-p:my-1.5 prose-li:my-0.5",
|
||||||
@@ -541,7 +545,7 @@ export function ReasoningBubble({
|
|||||||
"prose-code:text-[0.92em]",
|
"prose-code:text-[0.92em]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{markdownSource}
|
{text}
|
||||||
</MarkdownText>
|
</MarkdownText>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -117,12 +117,12 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="bg-sidebar-border/50" />
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={props.onOpenSettings}
|
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 />
|
<Settings className="h-3.5 w-3.5" aria-hidden />
|
||||||
{t("sidebar.settings")}
|
{t("sidebar.settings")}
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
|
|||||||
type SettingsSectionKey = "general" | "byok";
|
type SettingsSectionKey = "general" | "byok";
|
||||||
type ByokPaneKey = "llm" | "web-search";
|
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 {
|
interface SettingsViewProps {
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
@@ -176,7 +183,8 @@ export function SettingsView({
|
|||||||
if (!provider) return;
|
if (!provider) return;
|
||||||
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
|
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
|
||||||
const apiKey = providerForm.apiKey.trim();
|
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"));
|
setError(t("settings.byok.apiKeyRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -917,7 +925,10 @@ function ByokSettings({
|
|||||||
const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
|
const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
|
||||||
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
|
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
|
||||||
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
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 initialUnconfiguredCount = 6;
|
||||||
const visibleUnconfiguredProviders = showAllUnconfigured
|
const visibleUnconfiguredProviders = showAllUnconfigured
|
||||||
? unconfiguredProviders
|
? unconfiguredProviders
|
||||||
@@ -935,6 +946,12 @@ function ByokSettings({
|
|||||||
const saving = providerSaving === provider.name;
|
const saving = providerSaving === provider.name;
|
||||||
const keyVisible = !!visibleProviderKeys[provider.name];
|
const keyVisible = !!visibleProviderKeys[provider.name];
|
||||||
const editingKey = !provider.configured || !!editingProviderKeys[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
@@ -1045,7 +1062,7 @@ function ByokSettings({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onSaveProvider(provider.name)}
|
onClick={() => onSaveProvider(provider.name)}
|
||||||
disabled={saving || (!provider.configured && !form.apiKey.trim())}
|
disabled={saving || missingRequiredApiKey || missingOptionalCredential}
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
>
|
>
|
||||||
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
{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> = {
|
const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||||
custom: Hexagon,
|
custom: Hexagon,
|
||||||
openrouter: Sparkles,
|
openrouter: Sparkles,
|
||||||
@@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
|||||||
qianfan: Database,
|
qianfan: Database,
|
||||||
azure_openai: Cloud,
|
azure_openai: Cloud,
|
||||||
bedrock: Database,
|
bedrock: Database,
|
||||||
|
vllm: Cpu,
|
||||||
|
ollama: Cpu,
|
||||||
|
lm_studio: Cpu,
|
||||||
|
atomic_chat: Cpu,
|
||||||
|
ovms: Cpu,
|
||||||
|
nvidia: Zap,
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProviderIcon({ provider }: { provider: string }) {
|
function ProviderIcon({ provider }: { provider: string }) {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ChevronRight, Layers } from "lucide-react";
|
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||||
import { cn } from "@/lib/utils";
|
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). */
|
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||||
|
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||||
|
|
||||||
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
||||||
if (m.role !== "assistant" || m.kind === "trace") return false;
|
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";
|
return isReasoningOnlyAssistant(m) || m.kind === "trace";
|
||||||
}
|
}
|
||||||
|
|
||||||
function countToolCalls(messages: UIMessage[]): number {
|
interface ActivityCounts {
|
||||||
let n = 0;
|
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) {
|
for (const m of messages) {
|
||||||
if (m.kind !== "trace") continue;
|
if (isReasoningOnlyAssistant(m)) {
|
||||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
reasoningSteps += 1;
|
||||||
n += Math.max(lines, 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 {
|
interface AgentActivityClusterProps {
|
||||||
@@ -46,24 +104,56 @@ export function AgentActivityCluster({
|
|||||||
hasBodyBelow,
|
hasBodyBelow,
|
||||||
}: AgentActivityClusterProps) {
|
}: AgentActivityClusterProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
|
const fileEdits = useMemo(
|
||||||
const toolCalls = countToolCalls(messages);
|
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||||
|
[messages, isTurnStreaming],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
reasoningSteps,
|
||||||
|
toolCalls,
|
||||||
|
fileCount,
|
||||||
|
added,
|
||||||
|
deleted,
|
||||||
|
hasEditingFiles,
|
||||||
|
hasFailedFiles,
|
||||||
|
primaryFilePath,
|
||||||
|
} = countActivity(messages, fileEdits);
|
||||||
|
|
||||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||||
const [outerOpenLocal, setOuterOpenLocal] = 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. */
|
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
|
||||||
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
||||||
|
|
||||||
const headerBusy = isTurnStreaming;
|
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
|
||||||
|
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||||
|
|
||||||
const summary =
|
const fileActivitySummary = fileCount > 0
|
||||||
isTurnStreaming
|
? 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
|
? reasoningSteps > 0
|
||||||
? t("message.agentActivityLiveSummary", {
|
? t("message.agentActivityLiveSummary", {
|
||||||
reasoning: reasoningSteps,
|
reasoning: reasoningSteps,
|
||||||
tools: toolCalls,
|
tools: toolCalls,
|
||||||
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||||
})
|
})
|
||||||
|
: toolCalls === 0 && fileCount > 0
|
||||||
|
? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" })
|
||||||
: t("message.agentActivityLiveToolsOnly", {
|
: t("message.agentActivityLiveToolsOnly", {
|
||||||
tools: toolCalls,
|
tools: toolCalls,
|
||||||
defaultValue: "Working… · {{tools}} tool calls",
|
defaultValue: "Working… · {{tools}} tool calls",
|
||||||
@@ -74,16 +164,73 @@ export function AgentActivityCluster({
|
|||||||
tools: toolCalls,
|
tools: toolCalls,
|
||||||
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
|
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
|
||||||
})
|
})
|
||||||
|
: toolCalls === 0 && fileCount > 0
|
||||||
|
? t("message.agentActivityFilesOnly", { defaultValue: "File changes" })
|
||||||
: t("message.agentActivityToolsOnly", {
|
: t("message.agentActivityToolsOnly", {
|
||||||
tools: toolCalls,
|
tools: toolCalls,
|
||||||
defaultValue: "{{tools}} tool calls",
|
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 toggleOuter = () => {
|
||||||
|
const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded;
|
||||||
|
if (nextOpen) {
|
||||||
|
autoFollowActivityRef.current = true;
|
||||||
|
}
|
||||||
setUserToggledOuter(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 (
|
return (
|
||||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||||
<button
|
<button
|
||||||
@@ -96,12 +243,19 @@ export function AgentActivityCluster({
|
|||||||
aria-expanded={outerExpanded}
|
aria-expanded={outerExpanded}
|
||||||
>
|
>
|
||||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
<StreamingLabelSheen
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
||||||
active={headerBusy}
|
<StreamingLabelSheen
|
||||||
className="min-w-0 flex-1 text-left"
|
active={headerBusy}
|
||||||
>
|
className="min-w-0"
|
||||||
{summary}
|
>
|
||||||
</StreamingLabelSheen>
|
{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
|
<ChevronRight
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -118,29 +272,38 @@ export function AgentActivityCluster({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={activityScrollRef}
|
||||||
|
data-testid="agent-activity-scroll"
|
||||||
|
onScroll={onActivityScroll}
|
||||||
className={cn(
|
className={cn(
|
||||||
CLUSTER_SCROLL_MAX_CLASS,
|
CLUSTER_SCROLL_MAX_CLASS,
|
||||||
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
|
"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) => {
|
{messages.map((m) => {
|
||||||
if (isReasoningOnlyAssistant(m)) {
|
if (isReasoningOnlyAssistant(m)) {
|
||||||
return (
|
return (
|
||||||
<ReasoningBubble
|
<ReasoningBubble
|
||||||
key={m.id}
|
key={m.id}
|
||||||
text={m.reasoning ?? ""}
|
text={m.reasoning ?? ""}
|
||||||
streaming={!!m.reasoningStreaming}
|
streaming={isTurnStreaming && !!m.reasoningStreaming}
|
||||||
hasBodyBelow={false}
|
hasBodyBelow={false}
|
||||||
embeddedInCluster
|
embeddedInCluster
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (m.kind === "trace") {
|
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;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,3 +311,231 @@ export function AgentActivityCluster({
|
|||||||
</div>
|
</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 { MessageBubble } from "@/components/MessageBubble";
|
||||||
import {
|
import {
|
||||||
AgentActivityCluster,
|
AgentActivityCluster,
|
||||||
@@ -9,6 +12,8 @@ interface ThreadMessagesProps {
|
|||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
hiddenMessageCount?: number;
|
||||||
|
onLoadEarlier?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisplayUnit =
|
export type DisplayUnit =
|
||||||
@@ -30,31 +35,160 @@ export function isFinalAssistantSliceBeforeNextUser(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||||
const out: DisplayUnit[] = [];
|
const out: DisplayUnit[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < messages.length) {
|
while (i < messages.length) {
|
||||||
const m = messages[i];
|
const m = messages[i];
|
||||||
if (isAgentActivityMember(m)) {
|
if (isAgentActivityMember(m)) {
|
||||||
const cluster: UIMessage[] = [];
|
const cluster: UIMessage[] = [];
|
||||||
while (i < messages.length && isAgentActivityMember(messages[i])) {
|
let segmentId: string | undefined = m.activitySegmentId;
|
||||||
cluster.push(messages[i]);
|
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;
|
i += 1;
|
||||||
}
|
}
|
||||||
out.push({ type: "cluster", messages: cluster });
|
out.push({ type: "cluster", messages: cluster });
|
||||||
continue;
|
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 });
|
out.push({ type: "single", message: m });
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
|
function clusterSegmentId(messages: UIMessage[]): string | undefined {
|
||||||
const units = buildDisplayUnits(messages);
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<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) => {
|
{units.map((unit, index) => {
|
||||||
const prev = units[index - 1];
|
const prev = units[index - 1];
|
||||||
const marginTop =
|
const marginTop =
|
||||||
@@ -72,7 +206,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
|||||||
{unit.type === "cluster" ? (
|
{unit.type === "cluster" ? (
|
||||||
<AgentActivityCluster
|
<AgentActivityCluster
|
||||||
messages={unit.messages}
|
messages={unit.messages}
|
||||||
isTurnStreaming={isStreaming}
|
isTurnStreaming={index === liveActivityClusterIndex}
|
||||||
hasBodyBelow={hasBodyBelow}
|
hasBodyBelow={hasBodyBelow}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -80,7 +214,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
|||||||
message={unit.message}
|
message={unit.message}
|
||||||
showAssistantCopyAction={
|
showAssistantCopyAction={
|
||||||
unit.message.role === "assistant"
|
unit.message.role === "assistant"
|
||||||
? isFinalAssistantSliceBeforeNextUser(units, index)
|
? copyFlags[index]
|
||||||
: true
|
: 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 {
|
function unitKey(unit: DisplayUnit, index: number): string {
|
||||||
if (unit.type === "cluster") {
|
if (unit.type === "cluster") {
|
||||||
const anchor = unit.messages[0]?.id;
|
const anchor = unit.messages[0]?.id;
|
||||||
|
|||||||
@@ -167,8 +167,9 @@ export function ThreadShell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
return client.onSessionUpdate((updatedChatId) => {
|
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||||
if (updatedChatId !== chatId) return;
|
if (updatedChatId !== chatId) return;
|
||||||
|
if (scope === "metadata") return;
|
||||||
pendingCanonicalHydrateRef.current.add(chatId);
|
pendingCanonicalHydrateRef.current.add(chatId);
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
});
|
});
|
||||||
@@ -389,6 +390,7 @@ export function ThreadShell({
|
|||||||
composer={composer}
|
composer={composer}
|
||||||
scrollToBottomSignal={scrollToBottomSignal}
|
scrollToBottomSignal={scrollToBottomSignal}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
|
showScrollToBottomButton={!!session}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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 { ArrowDown } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||||
|
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
@@ -14,9 +23,27 @@ interface ThreadViewportProps {
|
|||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
conversationKey?: string | null;
|
conversationKey?: string | null;
|
||||||
|
showScrollToBottomButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
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({
|
export function ThreadViewport({
|
||||||
messages,
|
messages,
|
||||||
@@ -25,18 +52,33 @@ export function ThreadViewport({
|
|||||||
emptyState,
|
emptyState,
|
||||||
scrollToBottomSignal = 0,
|
scrollToBottomSignal = 0,
|
||||||
conversationKey = null,
|
conversationKey = null,
|
||||||
|
showScrollToBottomButton = true,
|
||||||
}: ThreadViewportProps) {
|
}: ThreadViewportProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||||
const pendingConversationScrollRef = useRef(true);
|
const pendingConversationScrollRef = useRef(true);
|
||||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
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). */
|
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
|
||||||
const userReadingHistoryRef = useRef(false);
|
const userReadingHistoryRef = useRef(false);
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
|
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||||
|
const [visibleMessageCount, setVisibleMessageCount] =
|
||||||
|
useState(INITIAL_HISTORY_WINDOW);
|
||||||
const hasMessages = messages.length > 0;
|
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(() => {
|
const cancelScheduledBottomScroll = useCallback(() => {
|
||||||
for (const id of scrollFrameIdsRef.current) {
|
for (const id of scrollFrameIdsRef.current) {
|
||||||
@@ -77,6 +119,30 @@ export function ThreadViewport({
|
|||||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!atBottom) return;
|
if (!atBottom) return;
|
||||||
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||||
@@ -96,8 +162,19 @@ export function ThreadViewport({
|
|||||||
pendingConversationScrollRef.current = true;
|
pendingConversationScrollRef.current = true;
|
||||||
userReadingHistoryRef.current = false;
|
userReadingHistoryRef.current = false;
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
|
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||||
}, [conversationKey]);
|
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!pendingConversationScrollRef.current) return;
|
if (!pendingConversationScrollRef.current) return;
|
||||||
if (!conversationKey) {
|
if (!conversationKey) {
|
||||||
@@ -110,6 +187,10 @@ export function ThreadViewport({
|
|||||||
pendingConversationScrollRef.current = false;
|
pendingConversationScrollRef.current = false;
|
||||||
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
measureComposerDock();
|
||||||
|
}, [composer, hasMessages, measureComposerDock]);
|
||||||
|
|
||||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -123,6 +204,14 @@ export function ThreadViewport({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasMessages, scrollToBottom]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
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 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="flex-1 px-4 pb-20 pt-4">
|
||||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
<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>
|
</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">
|
<div className="px-4 pb-3">
|
||||||
{composer}
|
{composer}
|
||||||
</div>
|
</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"
|
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!atBottom && (
|
{showScrollToBottomButton && !atBottom && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||||
className={cn(
|
className={cn(
|
||||||
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
|
/* 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",
|
"bg-background/90 backdrop-blur",
|
||||||
"animate-in fade-in-0 zoom-in-95",
|
"animate-in fade-in-0 zoom-in-95",
|
||||||
)}
|
)}
|
||||||
|
style={{ bottom: scrollButtonBottom }}
|
||||||
aria-label={t("thread.scrollToBottom")}
|
aria-label={t("thread.scrollToBottom")}
|
||||||
>
|
>
|
||||||
<ArrowDown className="h-4 w-4" />
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ const TooltipContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Portal>
|
||||||
ref={ref}
|
<TooltipPrimitive.Content
|
||||||
sideOffset={sideOffset}
|
ref={ref}
|
||||||
className={cn(
|
sideOffset={sideOffset}
|
||||||
"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={cn(
|
||||||
className,
|
"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}
|
)}
|
||||||
/>
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
));
|
));
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
|||||||
+36
-29
@@ -117,53 +117,60 @@
|
|||||||
--cjk-line-height: 1.625;
|
--cjk-line-height: 1.625;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* L→R sheen over solid label text (overlay stripe). Avoids ``background-clip:
|
/* L→R sheen clipped to live activity labels. The highlight lives inside
|
||||||
text`` loop seams that read as RTL “erase” or one-frame transparent glyphs. */
|
the glyphs, not in the row background, so dark mode stays quiet. */
|
||||||
@keyframes reasoning-sheen-ltr {
|
@keyframes streaming-text-sheen-ltr {
|
||||||
0% {
|
0% {
|
||||||
left: -44%;
|
background-position: 140% 50%;
|
||||||
}
|
}
|
||||||
100% {
|
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;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 2px;
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
color: transparent;
|
||||||
.reasoning-sheen-stripe {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 44%;
|
|
||||||
min-width: 3.25rem;
|
|
||||||
left: -44%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
hsl(0 0% 100% / 0.07) 34%,
|
transparent 38%,
|
||||||
hsl(0 0% 100% / 0.76) 50%,
|
hsl(var(--foreground) / 0.98) 50%,
|
||||||
hsl(0 0% 100% / 0.07) 66%,
|
transparent 62%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
mix-blend-mode: soft-light;
|
background-size: 260% 100%;
|
||||||
opacity: 0.95;
|
background-position: 140% 50%;
|
||||||
animation: reasoning-sheen-ltr 5.2s linear infinite;
|
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 {
|
.dark .streaming-text-sheen::after {
|
||||||
mix-blend-mode: overlay;
|
background-image: linear-gradient(
|
||||||
opacity: 1;
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 38%,
|
||||||
|
hsl(var(--foreground) / 0.98) 50%,
|
||||||
|
transparent 62%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.reasoning-sheen-stripe {
|
.streaming-text-sheen::after {
|
||||||
animation: none;
|
animation: none;
|
||||||
opacity: 0;
|
content: "";
|
||||||
visibility: hidden;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
OutboundMedia,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
UIImage,
|
UIImage,
|
||||||
|
UIFileEdit,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -18,12 +19,26 @@ interface StreamBuffer {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */
|
interface ActiveAssistantCursor {
|
||||||
function findStreamingAssistantId(prev: UIMessage[]): string | null {
|
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) {
|
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||||
const m = prev[i];
|
const m = prev[i];
|
||||||
if (m.kind === "trace") continue;
|
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;
|
if (m.role === "user") break;
|
||||||
}
|
}
|
||||||
return null;
|
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
|
* case the reasoning still belongs to the same assistant turn and must render
|
||||||
* above the answer, not as a new row below it.
|
* 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) {
|
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||||
const candidate = prev[i];
|
const candidate = prev[i];
|
||||||
// A user turn is a hard boundary: reasoning after it belongs to the new
|
// 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.
|
// that produced those tool calls.
|
||||||
if (candidate.kind === "trace") break;
|
if (candidate.kind === "trace") break;
|
||||||
if (candidate.role !== "assistant") continue;
|
if (candidate.role !== "assistant") continue;
|
||||||
|
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
|
||||||
const hasAnswer = candidate.content.length > 0;
|
const hasAnswer = candidate.content.length > 0;
|
||||||
if (
|
if (
|
||||||
candidate.reasoningStreaming
|
candidate.reasoningStreaming
|
||||||
@@ -60,6 +82,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
|||||||
...candidate,
|
...candidate,
|
||||||
reasoning: (candidate.reasoning ?? "") + chunk,
|
reasoning: (candidate.reasoning ?? "") + chunk,
|
||||||
reasoningStreaming: true,
|
reasoningStreaming: true,
|
||||||
|
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||||
}
|
}
|
||||||
@@ -68,11 +91,13 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
|||||||
...candidate,
|
...candidate,
|
||||||
reasoning: chunk,
|
reasoning: chunk,
|
||||||
reasoningStreaming: true,
|
reasoningStreaming: true,
|
||||||
|
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const activitySegmentId = segments?.ensure();
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -82,6 +107,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
|||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
reasoning: chunk,
|
reasoning: chunk,
|
||||||
reasoningStreaming: true,
|
reasoningStreaming: true,
|
||||||
|
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||||
createdAt: Date.now(),
|
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
|
* the model already produced an answer in a previous turn, so the new
|
||||||
* delta belongs in a fresh row.
|
* delta belongs in a fresh row.
|
||||||
*/
|
*/
|
||||||
function findActiveAssistantPlaceholder(prev: UIMessage[]): string | null {
|
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (!last) return null;
|
if (!last) return null;
|
||||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||||
if (last.content.length > 0) return null;
|
if (last.content.length > 0) return null;
|
||||||
if (!last.isStreaming) 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,
|
* 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
|
* 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 [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||||
const buffer = useRef<StreamBuffer | 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);
|
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
||||||
*
|
*
|
||||||
@@ -255,6 +335,159 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
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
|
// Reset local state when switching chats. Do not reset on every
|
||||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||||
// history response after the optimistic first message has already rendered.
|
// history response after the optimistic first message has already rendered.
|
||||||
@@ -269,13 +502,17 @@ export function useNanobotStream(
|
|||||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
|
clearActivitySegment();
|
||||||
|
clearPendingStreamWork();
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
if (streamEndTimerRef.current !== null) {
|
if (streamEndTimerRef.current !== null) {
|
||||||
clearTimeout(streamEndTimerRef.current);
|
clearTimeout(streamEndTimerRef.current);
|
||||||
streamEndTimerRef.current = null;
|
streamEndTimerRef.current = null;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chatId, client]);
|
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPendingToolCalls) setIsStreaming(true);
|
if (hasPendingToolCalls) setIsStreaming(true);
|
||||||
@@ -296,54 +533,10 @@ export function useNanobotStream(
|
|||||||
if (ev.event === "delta") {
|
if (ev.event === "delta") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||||
|
if (!chunk) return;
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setMessages((prev) => {
|
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
|
||||||
const adopted = findActiveAssistantPlaceholder(prev);
|
schedulePendingStreamFlush();
|
||||||
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;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,11 +544,23 @@ export function useNanobotStream(
|
|||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
const chunk = ev.text;
|
const chunk = ev.text;
|
||||||
if (!chunk) return;
|
if (!chunk) return;
|
||||||
setMessages((prev) => attachReasoningChunk(prev, chunk));
|
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
|
||||||
|
schedulePendingStreamFlush();
|
||||||
return;
|
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 (ev.event === "reasoning_end") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
setMessages((prev) => closeReasoningStream(prev));
|
setMessages((prev) => closeReasoningStream(prev));
|
||||||
@@ -393,6 +598,10 @@ export function useNanobotStream(
|
|||||||
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
||||||
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
|
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
|
||||||
}
|
}
|
||||||
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
|
clearActivitySegment();
|
||||||
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
return finalized;
|
return finalized;
|
||||||
});
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
@@ -413,7 +622,9 @@ export function useNanobotStream(
|
|||||||
if (ev.kind === "reasoning") {
|
if (ev.kind === "reasoning") {
|
||||||
const line = ev.text;
|
const line = ev.text;
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line)));
|
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
|
||||||
|
ensure: ensureActivitySegmentId,
|
||||||
|
})));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||||
@@ -428,12 +639,24 @@ export function useNanobotStream(
|
|||||||
: [];
|
: [];
|
||||||
if (lines.length === 0) return;
|
if (lines.length === 0) return;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
|
const segmentId = ensureActivitySegmentId();
|
||||||
const last = prev[prev.length - 1];
|
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 = {
|
const merged: UIMessage = {
|
||||||
...last,
|
...last,
|
||||||
traces: [...(last.traces ?? [last.content]), ...lines],
|
traces: [...previousTraces, ...lines],
|
||||||
content: lines[lines.length - 1],
|
content: lines[lines.length - 1],
|
||||||
|
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, -1), merged];
|
return [...prev.slice(0, -1), merged];
|
||||||
}
|
}
|
||||||
@@ -445,6 +668,7 @@ export function useNanobotStream(
|
|||||||
kind: "trace",
|
kind: "trace",
|
||||||
content: lines[lines.length - 1],
|
content: lines[lines.length - 1],
|
||||||
traces: lines,
|
traces: lines,
|
||||||
|
activitySegmentId: segmentId,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -459,11 +683,12 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
// A complete (non-streamed) assistant message. If a stream was in
|
// A complete (non-streamed) assistant message. If a stream was in
|
||||||
// flight, drop the placeholder so we don't render the text twice.
|
// 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
|
// Do NOT reset isStreaming here — only ``turn_end`` signals that
|
||||||
// the full turn (all tool calls + final text) is complete.
|
// the full turn (all tool calls + final text) is complete.
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
|
const activeId = buffer.current?.messageId;
|
||||||
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||||
const content = ev.text;
|
const content = ev.text;
|
||||||
const lat =
|
const lat =
|
||||||
@@ -481,6 +706,46 @@ export function useNanobotStream(
|
|||||||
}
|
}
|
||||||
return;
|
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
|
// ``attached`` / ``error`` frames aren't actionable here; the client
|
||||||
// shell handles them separately.
|
// shell handles them separately.
|
||||||
};
|
};
|
||||||
@@ -489,12 +754,26 @@ export function useNanobotStream(
|
|||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
|
clearActivitySegment();
|
||||||
|
clearPendingStreamWork();
|
||||||
if (streamEndTimerRef.current !== null) {
|
if (streamEndTimerRef.current !== null) {
|
||||||
clearTimeout(streamEndTimerRef.current);
|
clearTimeout(streamEndTimerRef.current);
|
||||||
streamEndTimerRef.current = null;
|
streamEndTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [chatId, client, onTurnEnd]);
|
}, [
|
||||||
|
chatId,
|
||||||
|
client,
|
||||||
|
clearActivitySegment,
|
||||||
|
clearPendingStreamWork,
|
||||||
|
detachedActivitySegmentId,
|
||||||
|
ensureActivitySegmentId,
|
||||||
|
flushPendingStreamEvents,
|
||||||
|
onTurnEnd,
|
||||||
|
schedulePendingStreamFlush,
|
||||||
|
]);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||||
@@ -504,17 +783,24 @@ export function useNanobotStream(
|
|||||||
// the image blocks via ``media`` paths.
|
// the image blocks via ``media`` paths.
|
||||||
if (!hasImages && !content.trim()) return;
|
if (!hasImages && !content.trim()) return;
|
||||||
|
|
||||||
|
flushPendingStreamEvents();
|
||||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||||
setMessages((prev) => [
|
setMessages((prev) => {
|
||||||
...pruneReasoningOnlyPlaceholders(prev),
|
buffer.current = null;
|
||||||
{
|
activeAssistantRef.current = null;
|
||||||
id: crypto.randomUUID(),
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
role: "user",
|
clearActivitySegment();
|
||||||
content,
|
return [
|
||||||
createdAt: Date.now(),
|
...pruneReasoningOnlyPlaceholders(prev),
|
||||||
...(previews ? { images: previews } : {}),
|
{
|
||||||
},
|
id: crypto.randomUUID(),
|
||||||
]);
|
role: "user",
|
||||||
|
content,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
...(previews ? { images: previews } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
// Mark streaming immediately so the UI shows the loading indicator
|
// Mark streaming immediately so the UI shows the loading indicator
|
||||||
// right away, before the first delta arrives from the server.
|
// right away, before the first delta arrives from the server.
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
@@ -525,18 +811,23 @@ export function useNanobotStream(
|
|||||||
client.sendMessage(chatId, content, wireMedia);
|
client.sendMessage(chatId, content, wireMedia);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[chatId, client],
|
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
|
flushPendingStreamEvents();
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setMessages((prev) =>
|
setMessages((prev) => {
|
||||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
buffer.current = null;
|
||||||
);
|
activeAssistantRef.current = null;
|
||||||
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
|
clearActivitySegment();
|
||||||
|
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||||
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
client.sendMessage(chatId, "/stop");
|
client.sendMessage(chatId, "/stop");
|
||||||
}, [chatId, client]);
|
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
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";
|
type Theme = "light" | "dark";
|
||||||
const STORAGE_KEY = "nanobot-webui.theme";
|
const STORAGE_KEY = "nanobot-webui.theme";
|
||||||
|
const ThemeContext = createContext<Theme>("light");
|
||||||
|
|
||||||
function readStored(): Theme | null {
|
function readStored(): Theme | null {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +27,11 @@ function applyTheme(theme: Theme): void {
|
|||||||
else root.classList.remove("dark");
|
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 [theme, setThemeState] = useState<Theme>(() => {
|
||||||
const stored = readStored();
|
const stored = readStored();
|
||||||
if (stored) return stored;
|
if (stored) return stored;
|
||||||
@@ -46,3 +59,11 @@ export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Th
|
|||||||
);
|
);
|
||||||
return { theme, toggle, setTheme };
|
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"
|
"io": "Couldn't read this file"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Scroll to bottom"
|
"scrollToBottom": "Scroll to bottom",
|
||||||
|
"loadEarlier": "Load earlier messages"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "streaming",
|
"streaming": "streaming",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Cerrar objetivo"
|
"goalStateCloseAria": "Cerrar objetivo"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Desplazarse al final"
|
"scrollToBottom": "Desplazarse al final",
|
||||||
|
"loadEarlier": "Cargar mensajes anteriores"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "transmitiendo",
|
"streaming": "transmitiendo",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Fermer l’objectif"
|
"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": {
|
"message": {
|
||||||
"streaming": "en cours de génération",
|
"streaming": "en cours de génération",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Tutup tujuan"
|
"goalStateCloseAria": "Tutup tujuan"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Gulir ke bawah"
|
"scrollToBottom": "Gulir ke bawah",
|
||||||
|
"loadEarlier": "Muat pesan sebelumnya"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "sedang mengalir",
|
"streaming": "sedang mengalir",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "目標を閉じる"
|
"goalStateCloseAria": "目標を閉じる"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "一番下へスクロール"
|
"scrollToBottom": "一番下へスクロール",
|
||||||
|
"loadEarlier": "以前のメッセージを読み込む"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "生成中",
|
"streaming": "生成中",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "목표 닫기"
|
"goalStateCloseAria": "목표 닫기"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "맨 아래로 스크롤"
|
"scrollToBottom": "맨 아래로 스크롤",
|
||||||
|
"loadEarlier": "이전 메시지 불러오기"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "생성 중",
|
"streaming": "생성 중",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Đóng mục tiêu"
|
"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": {
|
"message": {
|
||||||
"streaming": "đang truyền",
|
"streaming": "đang truyền",
|
||||||
|
|||||||
@@ -323,7 +323,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "关闭目标"
|
"goalStateCloseAria": "关闭目标"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "滚动到底部"
|
"scrollToBottom": "滚动到底部",
|
||||||
|
"loadEarlier": "加载更早消息"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "流式输出中",
|
"streaming": "流式输出中",
|
||||||
|
|||||||
@@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "關閉目標"
|
"goalStateCloseAria": "關閉目標"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "捲動到底部"
|
"scrollToBottom": "捲動到底部",
|
||||||
|
"loadEarlier": "載入更早訊息"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "串流輸出中",
|
"streaming": "串流輸出中",
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
import i18n, { currentLocale } from "@/i18n";
|
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. */
|
/** Truncate the first user message into a chat title. */
|
||||||
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
||||||
if (!preview) return fallback;
|
if (!preview) return fallback;
|
||||||
const oneLine = preview.replace(/\s+/g, " ").trim();
|
const oneLine = preview.replace(/\s+/g, " ").trim();
|
||||||
if (!oneLine) return fallback;
|
if (!oneLine) return fallback;
|
||||||
|
if (isLowInformationTitlePreview(oneLine)) return fallback;
|
||||||
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ type Unsubscribe = () => void;
|
|||||||
type EventHandler = (ev: InboundEvent) => void;
|
type EventHandler = (ev: InboundEvent) => void;
|
||||||
type StatusHandler = (status: ConnectionStatus) => void;
|
type StatusHandler = (status: ConnectionStatus) => void;
|
||||||
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => 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.
|
/** Structured connection-level errors surfaced to the UI.
|
||||||
*
|
*
|
||||||
@@ -364,7 +365,7 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.event === "session_updated") {
|
if (parsed.event === "session_updated") {
|
||||||
this.emitSessionUpdate(parsed.chat_id);
|
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
|
||||||
return;
|
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) {
|
for (const handler of this.sessionUpdateHandlers) {
|
||||||
handler(chatId);
|
handler(chatId, scope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -40,6 +40,10 @@ export interface UIMessage {
|
|||||||
/** For trace rows: each individual hint line, so consecutive hints can
|
/** For trace rows: each individual hint line, so consecutive hints can
|
||||||
* render as a single collapsible group. */
|
* render as a single collapsible group. */
|
||||||
traces?: string[];
|
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. */
|
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
|
||||||
images?: UIImage[];
|
images?: UIImage[];
|
||||||
/** Signed or local UI-renderable media attachments. */
|
/** Signed or local UI-renderable media attachments. */
|
||||||
@@ -80,6 +84,20 @@ export interface ToolProgressEvent {
|
|||||||
embeds?: unknown[];
|
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 {
|
export interface ChatSummary {
|
||||||
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
||||||
key: string;
|
key: string;
|
||||||
@@ -110,6 +128,7 @@ export interface SettingsPayload {
|
|||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
api_key_required?: boolean;
|
||||||
api_key_hint?: string | null;
|
api_key_hint?: string | null;
|
||||||
api_base?: string | null;
|
api_base?: string | null;
|
||||||
default_api_base?: string | null;
|
default_api_base?: string | null;
|
||||||
@@ -182,6 +201,11 @@ export type InboundEvent =
|
|||||||
/** Optional structured payload on progress frames (channel-specific). */
|
/** Optional structured payload on progress frames (channel-specific). */
|
||||||
agent_ui?: AgentUIBlob;
|
agent_ui?: AgentUIBlob;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
event: "file_edit";
|
||||||
|
chat_id: string;
|
||||||
|
edits: UIFileEdit[];
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
event: "delta";
|
event: "delta";
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
@@ -229,7 +253,7 @@ export type InboundEvent =
|
|||||||
chat_id: string;
|
chat_id: string;
|
||||||
goal_state: GoalStateWsPayload;
|
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 };
|
| { event: "error"; chat_id?: string; detail?: string };
|
||||||
|
|
||||||
/** Base64-encoded image attached to an outbound ``message`` envelope.
|
/** Base64-encoded image attached to an outbound ``message`` envelope.
|
||||||
|
|||||||
@@ -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 { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ const refreshSpy = vi.fn();
|
|||||||
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
||||||
const deleteChatSpy = vi.fn();
|
const deleteChatSpy = vi.fn();
|
||||||
const toggleThemeSpy = vi.fn();
|
const toggleThemeSpy = vi.fn();
|
||||||
|
const updateUrlSpy = vi.fn();
|
||||||
let mockSessions: ChatSummary[] = [];
|
let mockSessions: ChatSummary[] = [];
|
||||||
|
|
||||||
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||||
@@ -32,12 +33,18 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/hooks/useTheme", () => ({
|
vi.mock("@/hooks/useTheme", async () => {
|
||||||
useTheme: () => ({
|
const React = await import("react");
|
||||||
theme: "light" as const,
|
return {
|
||||||
toggle: toggleThemeSpy,
|
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", () => ({
|
vi.mock("@/lib/bootstrap", () => ({
|
||||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||||
@@ -64,22 +71,30 @@ vi.mock("@/lib/nanobot-client", () => {
|
|||||||
newChat = vi.fn();
|
newChat = vi.fn();
|
||||||
attach = vi.fn();
|
attach = vi.fn();
|
||||||
close = vi.fn();
|
close = vi.fn();
|
||||||
updateUrl = vi.fn();
|
updateUrl = updateUrlSpy;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { NanobotClient: MockClient };
|
return { NanobotClient: MockClient };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||||
import App from "@/App";
|
import App from "@/App";
|
||||||
|
|
||||||
describe("App layout", () => {
|
describe("App layout", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSessions = [];
|
mockSessions = [];
|
||||||
connectSpy.mockClear();
|
connectSpy.mockClear();
|
||||||
|
updateUrlSpy.mockClear();
|
||||||
refreshSpy.mockReset();
|
refreshSpy.mockReset();
|
||||||
createChatSpy.mockClear();
|
createChatSpy.mockClear();
|
||||||
deleteChatSpy.mockReset();
|
deleteChatSpy.mockReset();
|
||||||
toggleThemeSpy.mockReset();
|
toggleThemeSpy.mockReset();
|
||||||
|
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||||
|
token: "tok",
|
||||||
|
ws_path: "/",
|
||||||
|
expires_in: 300,
|
||||||
|
});
|
||||||
|
vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test");
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn().mockResolvedValue({
|
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 () => {
|
it("keeps sidebar layout out of the main thread width contract", async () => {
|
||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
|
|
||||||
@@ -192,8 +211,52 @@ describe("App layout", () => {
|
|||||||
name: "openrouter",
|
name: "openrouter",
|
||||||
label: "OpenRouter",
|
label: "OpenRouter",
|
||||||
configured: false,
|
configured: false,
|
||||||
|
api_key_required: true,
|
||||||
default_api_base: "https://openrouter.ai/api/v1",
|
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: {
|
web_search: {
|
||||||
provider: "brave",
|
provider: "brave",
|
||||||
@@ -248,6 +311,9 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(screen.getByText("OpenAI"));
|
fireEvent.click(screen.getByText("OpenAI"));
|
||||||
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
||||||
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.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" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
|
||||||
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
||||||
@@ -426,4 +492,36 @@ describe("App layout", () => {
|
|||||||
|
|
||||||
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { MessageBubble } from "@/components/MessageBubble";
|
import { MessageBubble } from "@/components/MessageBubble";
|
||||||
@@ -131,7 +131,9 @@ describe("MessageBubble", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Step 1: parse intent\./)).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");
|
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,6 +179,47 @@ describe("MessageBubble", () => {
|
|||||||
expect(screen.getByText("Body line.")).toBeInTheDocument();
|
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", () => {
|
it("renders assistant image media as a larger generated result", () => {
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
id: "a-image",
|
id: "a-image",
|
||||||
|
|||||||
@@ -233,9 +233,13 @@ describe("NanobotClient", () => {
|
|||||||
client.connect();
|
client.connect();
|
||||||
lastSocket().fakeOpen();
|
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();
|
expect(chatHandler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
describe("ThreadMessages", () => {
|
describe("ThreadMessages", () => {
|
||||||
@@ -51,6 +55,206 @@ describe("ThreadMessages", () => {
|
|||||||
expect(rows[1]).toHaveClass("mt-4");
|
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", () => {
|
it("shows copy only on the last assistant slice before the next user turn", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
@@ -89,4 +293,37 @@ describe("ThreadMessages", () => {
|
|||||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
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() {
|
function makeClient() {
|
||||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => 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>();
|
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
@@ -34,7 +34,7 @@ function makeClient() {
|
|||||||
errorHandlers.delete(handler);
|
errorHandlers.delete(handler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
|
||||||
sessionUpdateHandlers.add(handler);
|
sessionUpdateHandlers.add(handler);
|
||||||
return () => {
|
return () => {
|
||||||
sessionUpdateHandlers.delete(handler);
|
sessionUpdateHandlers.delete(handler);
|
||||||
@@ -49,8 +49,8 @@ function makeClient() {
|
|||||||
}
|
}
|
||||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||||
},
|
},
|
||||||
_emitSessionUpdate(chatId: string) {
|
_emitSessionUpdate(chatId: string, scope?: string) {
|
||||||
for (const h of sessionUpdateHandlers) h(chatId);
|
for (const h of sessionUpdateHandlers) h(chatId, scope);
|
||||||
},
|
},
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
newChat: vi.fn(),
|
newChat: vi.fn(),
|
||||||
@@ -651,6 +651,52 @@ describe("ThreadShell", () => {
|
|||||||
expect(historyCalls).toBe(1);
|
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 () => {
|
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const scrollIntoView = vi.fn();
|
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 { 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";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
@@ -15,7 +20,191 @@ const messages: UIMessage[] = [
|
|||||||
|
|
||||||
const emptyMessages: 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", () => {
|
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 () => {
|
it("resets to the bottom when opening a different conversation", async () => {
|
||||||
const scrollIntoView = vi.fn();
|
const scrollIntoView = vi.fn();
|
||||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||||
|
|||||||
@@ -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", () => {
|
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", () => {
|
it("starts in streaming mode when history shows pending tool calls", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const initialMessages = [{
|
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 fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||||
wrapper: wrap(fake.client),
|
wrapper: wrap(fake.client),
|
||||||
@@ -222,6 +494,8 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(1);
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0].role).toBe("assistant");
|
expect(result.current.messages[0].role).toBe("assistant");
|
||||||
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
|
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);
|
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 fake = fakeClient();
|
||||||
const initialMessages = [
|
const initialMessages = [
|
||||||
{
|
{
|
||||||
@@ -358,6 +632,8 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
||||||
expect(result.current.messages[2].role).toBe("assistant");
|
expect(result.current.messages[2].role).toBe("assistant");
|
||||||
@@ -366,7 +642,7 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[2].reasoningStreaming).toBe(true);
|
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 fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
||||||
wrapper: wrap(fake.client),
|
wrapper: wrap(fake.client),
|
||||||
@@ -392,6 +668,8 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
||||||
"message",
|
"message",
|
||||||
@@ -651,7 +929,7 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[0].content).toBe("long task");
|
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 fake = fakeClient();
|
||||||
const onTurnEnd = vi.fn();
|
const onTurnEnd = vi.fn();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
|
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.isStreaming).toBe(true);
|
||||||
expect(result.current.messages[0]).toMatchObject({
|
expect(result.current.messages[0]).toMatchObject({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 * as api from "@/lib/api";
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function fakeClient() {
|
function fakeClient() {
|
||||||
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
defaultChatId: null as string | null,
|
defaultChatId: null as string | null,
|
||||||
@@ -25,12 +25,12 @@ function fakeClient() {
|
|||||||
onError: () => () => {},
|
onError: () => () => {},
|
||||||
onChat: () => () => {},
|
onChat: () => () => {},
|
||||||
getRunStartedAt: () => null,
|
getRunStartedAt: () => null,
|
||||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
|
||||||
sessionUpdateHandlers.add(handler);
|
sessionUpdateHandlers.add(handler);
|
||||||
return () => sessionUpdateHandlers.delete(handler);
|
return () => sessionUpdateHandlers.delete(handler);
|
||||||
},
|
},
|
||||||
emitSessionUpdate: (chatId: string) => {
|
emitSessionUpdate: (chatId: string, scope?: string) => {
|
||||||
for (const handler of sessionUpdateHandlers) handler(chatId);
|
for (const handler of sessionUpdateHandlers) handler(chatId, scope);
|
||||||
},
|
},
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
newChat: vi.fn(),
|
newChat: vi.fn(),
|
||||||
@@ -61,6 +61,28 @@ describe("useSessions", () => {
|
|||||||
vi.mocked(api.fetchWebuiThread).mockReset();
|
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 () => {
|
it("removes a session from the local list after delete succeeds", async () => {
|
||||||
vi.mocked(api.listSessions).mockResolvedValue([
|
vi.mocked(api.listSessions).mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,6 +25,36 @@ export default defineConfig(({ mode }) => {
|
|||||||
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
|
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
sourcemap: false,
|
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: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user