Compare commits

...
54 Commits
Author SHA1 Message Date
chengyongruandchengyongru f44ee6cb27 fix(signal): bypass base is_allowed for policy-approved messages
Override _handle_message to publish directly to the bus for messages
that have already passed _check_inbound_policy. The denied DM pairing
path calls super()._handle_message() to issue pairing codes via the
base class. This avoids cross-policy leakage where e.g. group open
policy would cause is_allowed to incorrectly allow denied DM senders.

Also includes:
- SSE: strip one optional leading space after 'data:' per spec
- Convert 20+ f-string log calls to loguru lazy formatting
- Add end-to-end tests for DM/group routing through the full chain
- Add cross-policy test (dm allowlist + group open) for pairing
- Add Signal channel documentation to docs/chat-apps.md
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 5be3df1d6f fix(signal): consult pairing store in is_allowed
BaseChannel.is_allowed ORs is_approved (the pairing store) into the
allow decision; the signal override dropped that step and only looked
at config.allow_from. With the new DM-pairing flow in place, an
approved-via-pairing sender's next message would have failed the
allow check and triggered another pairing code in a loop.

OR in a normalized check against the pairing store: walk each part of
the pipe-joined sender_id through _normalize_signal_id and call
is_approved for each variant, so an approval stored under one form
(phone with/without "+", UUID/ACI) still matches when the next inbound
uses a different form. Mirrors how slack.py:643 handles it.

Also tightens the empty-allowlist warning to only fire when nothing
else granted access, since pairing-store hits are now a valid path.

Not part of the original review, but Comments 2 and 3 turn this latent
gap into a broken round-trip — included so the pairing UX actually
works.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 79c23787f6 fix(signal): join multi-line SSE data with newline per spec
Per the SSE spec, multiple data: lines within a single event must be
joined with \n before parsing. signal-cli emits single-line JSON so
this was latent, but the joining was wrong.

Addresses review comment on PR #3852.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru b647aa5f47 fix(signal): route denied DMs through _handle_message for pairing code
Previously _check_inbound_policy returned (False, chat_id) for DMs
that failed the allowlist and the caller dropped them — so unapproved
DM senders never saw a pairing code. Mirror Slack: when the policy
gate denies a DM but dm.enabled is true, still call
_handle_message(content="", is_dm=True) so BaseChannel can issue the
pairing reply. Group denials stay a hard drop.

Combined with the previous is_dm forwarding, unapproved DM senders
now receive a pairing code through the standard flow.

Addresses review comment on PR #3852.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru a9a8bdcef6 fix(signal): pass is_dm to _handle_message so DM pairing flow runs
BaseChannel._handle_message uses is_dm to decide whether to issue a
pairing code when is_allowed rejects the sender. Without it the base
class treats every denied message as a group message and silently
drops it. Forward is_dm=not is_group_message so unapproved DM users
get a pairing code through the standard flow.

This change only takes effect once denied DMs actually reach
_handle_message (next commit); on its own it is a no-op since the
policy gate still short-circuits before this call.

Addresses review comment on PR #3852.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 2d81cc0ae1 fix(signal): raise on signal-cli error response so send is retriable
_send_http_request collapses every exception path into a {"error": ...}
dict, so the if "error" in response branch inside send() is the only
place where send failures surface. Logging-only there meant the
ChannelManager retry mechanism never fired. Raise RuntimeError so the
base-class retry path is exercised; the outer try/except already
re-raises into the caller.

Addresses review comment on PR #3852.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru aed6b6967c Cleanup 2026-05-20 22:57:49 +08:00
3874b3acf4 fix(signal): normalize composite sender_ids in is_allowed too
The base BaseChannel.is_allowed() does a literal ``sender_id in allow_from``
check, but Signal's sender_id is a pipe-joined composite of phone/UUID
parts. After splitting an allowlist entry like ``+phone|uuid`` into two
separate entries, the per-DM gate accepted it but the base gate still
denied because the composite sender string wasn't literally in the list.

Override is_allowed on SignalChannel to delegate to
_sender_matches_allowlist, which already splits both sides on ``|`` and
normalizes each part. _sender_matches_allowlist itself now also splits
allowlist entries on ``|`` so legacy composite entries keep working too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
01725bab11 test(signal): cover markdown adjacency, nesting, and malformed input
The existing markdown suite was strong on UTF-16 offsets and chunk
redistribution but had no coverage for nested or adjacent styles, no test
that an unmatched opener round-trips as plain text, and no test for the
blockquote/inline-code interaction. Add six cases including the
documented contiguous-BOLD output for `# **wrap** me`, which Signal
renders as one visual span.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
a786e3d225 test(signal): consolidate channel-capture setup into one factory
Two test classes (TestHandleDataMessageDM, TestHandleDataMessageGroup)
plus three TestCommandHandling tests each repeated the same handful of
lines: build a channel, mock _handle_message to record kwargs, replace
_start_typing with a no-op, paper over the assignment with type: ignore.

Hoist the pattern into _make_channel_with_capture and call it from all
five sites. Drops 30+ lines of duplication and 7 type: ignore comments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
626f262121 test(signal): cover SSE receive loop and the empty-phone start guard
Previously the SSE loop and the empty-phone-number short-circuit in start()
had zero coverage. Both now have tests: a fake httpx stream feeds canned
SSE lines, exercising the valid-frame, invalid-JSON, non-200, and
no-http-client paths; start() with an empty phone number is asserted to
return without entering the HTTP loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
7caf492ae2 refactor(signal): split _handle_data_message into policy and assembly helpers
The receive-path handler was ~165 lines deep into nested DM/group policy
checks, buffer mutations, mention stripping, attachment downloads, and
final bus forwarding. Pull the policy gate out into _check_inbound_policy
(returns (allow, chat_id), still appends to the group buffer once allowed)
and the text+media construction into _assemble_inbound_content. The
top-level method now reads as orchestration only.

Add TestCheckInboundPolicy that exercises the helper directly across the
DM/group policy permutations, including the buffer side effect, so the
new seam is locked in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
9aa2ab1657 feat(signal): make signal-cli attachments directory configurable
The inbound attachment loop hardcoded ~/.local/share/signal-cli/attachments
as the source path. That is the daemon's default on Linux but not on macOS
or Windows, and breaks if the daemon was launched with XDG_DATA_HOME set.

Add SignalConfig.attachments_dir as an optional override. When unset the
behavior is unchanged; when set the value is run through Path.expanduser()
so ~ is honored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
971b774282 refactor(signal): wrap top-level receive handler with _safe_handle
Replace the inline try/except at the end of _handle_receive_notification
with a small async context manager that swallows the exception, logs
self.logger.error with the offending payload's repr (bounded to 200 chars),
and attaches the traceback via logger.opt(exception=True).

The previous log line only carried `e`, so diagnosing a bad envelope from
production logs required correlating timestamps. The wrapper is generic so
future receive/dispatch sites can adopt it; for now only this site uses it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
1377759705 fix(signal): normalize identifiers when matching DM allowlist
The DM allowlist check split sender_id on '|' and looked for raw membership
in the allow_from list. Senders carry their phone number with a leading
'+' but admins routinely write allowlist entries without it (or vice
versa), and UUID/ACI matches were case-sensitive. Both forms now flow
through _normalize_signal_id, so an entry like 19995550001 matches a
sender +19995550001 and a UUID matches case-insensitively.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
d56bafa6d0 refactor(signal): hygiene cleanups around constants, typing, and config
- Hoist the cell-strip patterns to module level so they match the rest of
  the module's regex style and aren't reparsed on every call.
- Type the markdown transform callback and the mention id walker so the
  inline Callable signature is no longer an untyped Any.
- Add _HTTP_TIMEOUT_SECONDS alongside the other class-level tunables.
- Reject group_message_buffer_size <= 0 in a Pydantic field_validator
  rather than silently disabling the buffer at write time.
- Mark SignalConfig.allow_from as a computed_field so it shows up in
  model_dump() instead of being invisible to serialization.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
6ec6c9bb83 fix(signal): redistribute textStyle ranges across split message chunks
split_message can break a long Signal payload into multiple JSON-RPC sends,
but the previous code attached the full textStyle list only to chunk 0.
Style ranges in later chunks were dropped, and ranges whose offsets pointed
past chunk 0's end were sent as invalid metadata against chunk 0.

Add _partition_styles, which rebases each range against the chunk it lives
in (in UTF-16 code units, matching the markdown converter) and splits
boundary-spanning ranges across the chunks they touch. Whitespace trimmed
by split_message's lstrip is skipped so offsets stay aligned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
8a2a5eecdd fix(signal): emit textStyle offsets in UTF-16 code units
Signal's BodyRange (via signal-cli's textStyle) interprets start/length as
UTF-16 code units, but the Phase-3 assembly used Python's len(), which counts
code points. A single non-BMP character (e.g. an emoji) earlier in a message
shifted every subsequent styled span left by one unit, dropping the last
letter of bold/italic words.

Track a running UTF-16 offset in the assembly loop and add regression tests
covering emojis, supplementary CJK, ZWJ sequences, and a multi-section
message that mirrors the reported failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 08154b4374 fix(signal): drop duplicate self in unconfigured-account log call
Addresses review feedback on HKUDS/nanobot#3852: self.self.logger.error
would crash if the phone_number guard ever fired.
2026-05-20 22:57:49 +08:00
Kaloyan Tenchovandchengyongru 880097acd5 feat(signal): add Signal channel support
Integrates signal-cli daemon via HTTP JSON-RPC as a nanobot channel.
Supports DMs and group chats with open/allowlist access policies,
markdown→Signal text style conversion, typing indicators, attachment
handling, group message context buffering, and automatic reconnect
with exponential backoff.

Includes unit tests for channel lifecycle, message routing, mention
detection, markdown conversion, and message splitting.

Originally based on https://github.com/HKUDS/nanobot/pull/601.
2026-05-20 22:57:49 +08:00
chengyongru e02615c93d Merge branch 'main' into nightly 2026-05-18 18:05:29 +08:00
e9259e680e feat(image-generation): add Gemini provider support
Adds GeminiImageGenerationClient covering both Imagen 4 (:predict) and
Gemini Flash (:generateContent), wires the gemini ProviderConfig through
the SDK, API server, and gateway entry points, and updates the
image-generation docs and skill. Errors from the Gemini endpoints are
logged and surface with the HTTP status and parsed message instead of an
empty string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:25:03 +08:00
yaotutuandchengyongru a5b85a3d6b feat: add MiniMax image generation provider support
Add MiniMaxImageGenerationClient with support for:
- Text-to-image generation via MiniMax image-01 model
- Reference image support (subject_reference)
- Aspect ratio selection
- Proper error handling aligned with existing providers

Wire up MiniMax provider config in ImageGenerationTool, gateway,
serve, and Nanobot class.
2026-05-18 15:14:45 +08:00
chengyongruandXubin Ren d4ade8f680 feat(cli): add Model Preset wizard to onboard
Extract the [M] Model Presets interactive CRUD screen from PR #3696
and adapt it to the current main branch schema (fallback_models
instead of fallback_presets). Adds preset cache, field handlers for
model_preset/provider/fallback_models, and 9 new tests.
2026-05-18 15:13:41 +08:00
chengyongruandXubin Ren 28d0f8560e fix(webui): preserve single newlines in markdown rendering
Add remark-breaks plugin so that single newlines in assistant messages
(such as /help output) render as line breaks instead of being collapsed
into a single paragraph by standard markdown behavior.
2026-05-18 15:12:27 +08:00
Xubin RenandGitHub ba38f90832 Merge PR #3877: feat(webui+agent): optimize streaming, activity rendering, and runtime sync
feat(webui+agent): optimize streaming, activity rendering, and runtime sync
2026-05-18 02:04:36 +08:00
Xubin Ren eb3aed359f Refine file edit progress gating 2026-05-18 01:59:55 +08:00
Xubin Ren 4445fcc8b9 refactor(cli): localize reasoning buffer state 2026-05-18 01:34:08 +08:00
liyazhouandXubin Ren b67205f5aa fix(cli): buffer reasoning tokens to avoid one-token-per-line display 2026-05-18 01:34:08 +08:00
Xubin Ren de8761f25a fix(test): add gateway llm runtime fake 2026-05-18 01:19:45 +08:00
Xubin Ren 8708ccea86 Merge branch 'main' of https://github.com/HKUDS/nanobot into codex/webui-performance 2026-05-18 01:18:28 +08:00
Xubin Ren eb0ff3ad1d fix(memory): refresh session before empty guard 2026-05-18 01:16:47 +08:00
chengyongruandXubin Ren c58a360b25 fix(test): seed get_or_create mock for session-refresh guard compatibility 2026-05-18 01:16:47 +08:00
chengyongruandXubin Ren 5bb94edc99 refactor(autocompact): delegate _archive to Consolidator.compact_idle_session
Replace AutoCompact._archive() direct session mutation with delegation
to Consolidator.compact_idle_session(). Remove _split_unconsolidated()
method since that logic now lives inside compact_idle_session.

All session mutation for idle compaction now goes through the
Consolidator's lock, eliminating the race condition between
background token consolidation and idle TTL compaction.

Changes:
- autocompact.py: rewrite _archive() to call compact_idle_session,
  remove _split_unconsolidated(), clean up unused imports
- test_autocompact_unit.py: replace TestArchive/TestSplitUnconsolidated
  with TestArchiveDelegates that verifies delegation behavior
- test_auto_compact.py: convert all consolidator.archive mocks to
  consolidator.compact_idle_session mocks via _make_fake_compact helper
2026-05-18 01:16:47 +08:00
chengyongruandXubin Ren 888d54790d fix(memory): add session-refresh guard to maybe_consolidate_by_tokens
When background consolidation runs with a stale session reference (captured
before AutoCompact replaced the session via compact_idle_session), it could
operate on outdated data. Now, after acquiring the per-session lock, the
method refreshes its session reference from SessionManager.get_or_create().
If the session was replaced, it swaps in the fresh reference before doing
any consolidation work.

This prevents a race where AutoCompact truncates an idle session while a
background maybe_consolidate_by_tokens call is in flight with the old
session object.
2026-05-18 01:16:47 +08:00
chengyongruandXubin Ren 48d35bd2d9 feat(consolidator): add compact_idle_session method with lock-protected truncation
Add Consolidator.compact_idle_session(session_key, max_suffix=8) that
performs hard-truncation of idle sessions under the per-session
consolidation lock. This is the single lock-protected path for AutoCompact
to use instead of modifying session state directly, fixing the race
condition between AutoCompact and Consolidator.

Behavior:
- Acquires per-session consolidation lock
- Invalidates cache and reloads fresh from disk
- Splits unconsolidated tail into archive prefix and retained suffix
- Archives prefix via LLM (with raw_archive fallback on failure)
- Persists _last_summary in session metadata on success
- Returns summary text, None on LLM failure, or '' if nothing to archive

Tests: 6 new tests covering prefix archival, empty session timestamp
refresh, (nothing) summary exclusion, LLM failure fallback,
last_consolidated offset, and lock acquisition verification.
2026-05-18 01:16:47 +08:00
Xubin Ren fce1550814 fix(webui): refresh bootstrap token before expiry 2026-05-18 00:53:36 +08:00
voidborne-dandXubin Ren bf8a6e35fd docs(deployment): match docker run gateway example to docker-compose.yml (refs #3873)
The `docker run` example for `gateway` in `docs/deployment.md` had drifted from
the canonical configuration in `docker-compose.yml`:

- It omitted the security flags that `docker-compose.yml` already declares
  (`cap_drop: ALL` + `cap_add: SYS_ADMIN` + unconfined apparmor/seccomp).
  These are required whenever `tools.exec.sandbox: "bwrap"` is enabled, because
  bwrap needs CAP_SYS_ADMIN for user namespaces; without them bwrap exits with
  `clone3: Operation not permitted` and exec tools silently fail.
- It omitted `-p 8765:8765`, even though both the bundled `docker-compose.yml`
  and `Dockerfile` (`EXPOSE 18790 8765`) already expose the WebSocket channel
  / WebUI port; users following the docs would get a reachable gateway health
  endpoint but an unreachable WebUI.

This change keeps the two paths in sync so anyone reading deployment.md and
using `docker run` directly gets the same security posture and port surface
as the Compose path.

Also adds a short `!IMPORTANT` note documenting that `gateway.host` and
`channels.websocket.host` default to `127.0.0.1` (set in
`nanobot/config/schema.py:GatewayConfig`). Docker `-p` cannot forward to the
container's loopback interface, so the user must set both binds to `0.0.0.0`
in `config.json` for the published ports to actually be reachable. This is
the symptom reported as items 2 + 3 of #3873; items 1 + 4 of that issue are
already resolved on `main` (`Dockerfile` line 49 already exposes both ports,
and README.md lines 218-220 already reflect that the WebUI ships in the wheel).

Docs only, no code changes.

Signed-off-by: voidborne-d <258577966+voidborne-d@users.noreply.github.com>
2026-05-18 00:45:49 +08:00
Xubin Ren f017e209da docs(configuration): align Docker env-file example 2026-05-18 00:45:34 +08:00
olgagagaandXubin Ren 5a34504b76 docs(configuration): expand "Environment Variables for Secrets" section
- Note that any string field supports ${VAR_NAME} and resolved values are
  never written back to disk.
- Document the failure mode for unset variables.
- Add MCP (stdio env + HTTP headers) and web-search examples.
- Add Docker, direnv, and secret-manager (1Password / pass / Bitwarden)
  delivery patterns alongside the existing systemd example.
- Replace plaintext apiKey values in tools.web.search examples (Brave,
  Tavily, Jina, Kagi, Olostep) with ${PROVIDER_API_KEY} placeholders so
  the docs stop modelling the anti-pattern.
- Cross-link from the Security section.

Refs: HKUDS/nanobot#2172
2026-05-18 00:45:34 +08:00
Xubin Ren af26ed0041 fix(heartbeat): remove unused runtime import 2026-05-18 00:40:31 +08:00
Xubin Ren 112f40ad67 fix(agent): refresh llm runtime for background tasks 2026-05-18 00:35:12 +08:00
Xubin Ren 2f323e24c1 fix(webui): polish session titles and status 2026-05-17 23:52:50 +08:00
Xubin Ren 361f31c0e4 fix(webui): use portal file reference tooltips 2026-05-17 23:52:29 +08:00
Xubin Ren 945f208d38 feat(webui): render file edit activity 2026-05-17 23:52:14 +08:00
Xubin Ren c8bb04a8fe feat(webui): persist agent activity events 2026-05-17 23:51:52 +08:00
Xubin Ren 4b5de66c58 Polish WebUI streaming and provider settings 2026-05-17 17:41:33 +08:00
Xubin Ren 9340567f2d Fix duplicate reasoning display 2026-05-17 17:11:38 +08:00
Xubin Ren e5be4dac7a Optimize WebUI streaming and long history rendering
Batch stream deltas, window long transcripts, lazy-load syntax highlighting, and refine activity/composer interactions.

Add title refresh retries plus tests for streaming, windowing, code blocks, and live activity behavior.
2026-05-17 17:04:57 +08:00
Xubin Ren 175b58e259 fix(docker): document bundled webui port 2026-05-17 15:51:04 +08:00
huanglei.214andXubin Ren 3bf8de047a fix docker build 2026-05-17 15:51:04 +08:00
chengyongruandXubin Ren 400f822601 fix(providers): recognize Chinese rate-limit marker '访问量过大' as transient error 2026-05-17 14:25:20 +08:00
Xubin Ren 9fb9d7afcb docs: update README with v0.2.0 release details, including new features and improvements 2026-05-16 15:22:32 +00:00
chengyongru 82c323c2d9 fix(providers): recognize Chinese rate-limit marker '访问量过大' as transient error 2026-05-16 22:06:54 +08:00
93 changed files with 11269 additions and 1046 deletions
+6 -4
View File
@@ -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"]
+1
View File
@@ -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.
+1
View File
@@ -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:
+67
View File
@@ -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
View File
@@ -26,7 +26,52 @@ Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}`
} }
``` ```
For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read: Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder.
If a referenced variable is unset, nanobot fails fast at startup with `ValueError: Environment variable 'NAME' referenced in config is not set`.
### More examples
**MCP servers** — both stdio `env` and HTTP `headers`:
```json
{
"tools": {
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" }
},
"remote": {
"url": "https://example.com/mcp/",
"headers": { "Authorization": "Bearer ${REMOTE_MCP_TOKEN}" }
}
}
}
}
```
**Web search providers:**
```json
{
"tools": {
"web": {
"search": {
"provider": "brave",
"apiKey": "${BRAVE_API_KEY}"
}
}
}
}
```
### Loading variables at startup
Pick whatever fits your deployment — nanobot only reads `os.environ` at startup, so any mechanism that populates the process environment works.
**systemd** — use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
```ini ```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
View File
@@ -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!"
+54 -2
View File
@@ -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 |
+11 -48
View File
@@ -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
View File
@@ -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
View File
@@ -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
+50
View File
@@ -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:
+13 -1
View File
@@ -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
+49 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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"),
+17 -10
View File
@@ -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")
+2
View File
@@ -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)
+1
View File
@@ -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"})
+347 -3
View File
@@ -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
+1 -1
View File
@@ -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,
), ),
+21
View File
@@ -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:
+311
View File
@@ -0,0 +1,311 @@
"""File-edit activity helpers for WebUI progress events."""
from __future__ import annotations
import difflib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
@dataclass(slots=True)
class FileSnapshot:
path: Path
exists: bool
text: str | None
unreadable: bool = False
binary: bool = False
oversized: bool = False
@property
def countable(self) -> bool:
return (
self.text is not None
and not self.binary
and not self.oversized
and not self.unreadable
)
@dataclass(slots=True)
class FileEditTracker:
call_id: str
tool: str
path: Path
display_path: str
before: FileSnapshot
def is_file_edit_tool(tool_name: str | None) -> bool:
return bool(tool_name) and tool_name in TRACKED_FILE_EDIT_TOOLS
def resolve_file_edit_path(
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> Path | None:
"""Resolve the target file path after tool argument preparation."""
if not isinstance(params, dict):
return None
raw_path = params.get("path")
if not isinstance(raw_path, str) or not raw_path.strip():
return None
resolver = getattr(tool, "_resolve", None)
if callable(resolver):
try:
resolved = resolver(raw_path)
if isinstance(resolved, Path):
return resolved
if resolved:
return Path(resolved)
except Exception:
return None
if workspace is None:
return Path(raw_path).expanduser().resolve()
return (workspace / raw_path).expanduser().resolve()
def display_file_edit_path(path: Path, workspace: Path | None) -> str:
if workspace is not None:
try:
return path.resolve().relative_to(workspace.resolve()).as_posix()
except Exception:
pass
return path.as_posix()
def read_file_snapshot(path: Path, *, max_bytes: int = _MAX_SNAPSHOT_BYTES) -> FileSnapshot:
try:
if not path.exists() or not path.is_file():
return FileSnapshot(path=path, exists=False, text="")
size = path.stat().st_size
if size > max_bytes:
return FileSnapshot(path=path, exists=True, text=None, oversized=True)
raw = path.read_bytes()
except OSError:
return FileSnapshot(path=path, exists=path.exists(), text=None, unreadable=True)
if b"\x00" in raw:
return FileSnapshot(path=path, exists=True, text=None, binary=True)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return FileSnapshot(path=path, exists=True, text=None, binary=True)
return FileSnapshot(path=path, exists=True, text=text.replace("\r\n", "\n"))
def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
"""Return ``(added, deleted)`` for a UTF-8 text line-level diff."""
if before is None or after is None:
return 0, 0
before_lines = before.replace("\r\n", "\n").splitlines()
after_lines = after.replace("\r\n", "\n").splitlines()
added = 0
deleted = 0
matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
continue
if tag in ("replace", "delete"):
deleted += i2 - i1
if tag in ("replace", "insert"):
added += j2 - j1
return added, deleted
def prepare_file_edit_tracker(
*,
call_id: str,
tool_name: str,
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> FileEditTracker | None:
if not is_file_edit_tool(tool_name):
return None
path = resolve_file_edit_path(tool, workspace, params)
if path is None:
return None
before = read_file_snapshot(path)
return FileEditTracker(
call_id=str(call_id or ""),
tool=tool_name,
path=path,
display_path=display_file_edit_path(path, workspace),
before=before,
)
def build_file_edit_start_event(
tracker: FileEditTracker,
params: dict[str, Any] | None,
) -> dict[str, Any]:
predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before)
if tracker.before.countable and predicted_after is not None:
added, deleted = line_diff_stats(tracker.before.text, predicted_after)
else:
added, deleted = 0, 0
return _event_payload(
tracker,
phase="start",
status="editing",
added=added,
deleted=deleted,
approximate=True,
)
def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
after = read_file_snapshot(tracker.path)
if tracker.before.countable and after.countable:
added, deleted = line_diff_stats(tracker.before.text, after.text)
else:
added, deleted = 0, 0
return _event_payload(
tracker,
phase="end",
status="done",
added=added,
deleted=deleted,
approximate=False,
binary=after.binary or after.oversized or after.unreadable,
)
def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]:
payload = _event_payload(
tracker,
phase="error",
status="error",
added=0,
deleted=0,
approximate=False,
)
if error:
payload["error"] = error.strip()[:240]
return payload
def _event_payload(
tracker: FileEditTracker,
*,
phase: str,
status: str,
added: int,
deleted: int,
approximate: bool,
binary: bool = False,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"version": 1,
"call_id": tracker.call_id,
"tool": tracker.tool,
"path": tracker.display_path,
"phase": phase,
"added": max(0, int(added)),
"deleted": max(0, int(deleted)),
"approximate": bool(approximate),
"status": status,
}
if binary:
payload["binary"] = True
return payload
def _predict_after_text(
tool_name: str,
params: dict[str, Any],
before: FileSnapshot,
) -> str | None:
if not before.countable:
return None
before_text = before.text or ""
if tool_name == "write_file":
content = params.get("content")
return content if isinstance(content, str) else ""
if tool_name == "edit_file":
old_text = params.get("old_text")
new_text = params.get("new_text")
if not isinstance(old_text, str) or not isinstance(new_text, str):
return None
replace_all = bool(params.get("replace_all"))
if old_text == "":
return new_text if not before.exists else before_text
if old_text in before_text:
if replace_all:
return before_text.replace(old_text, new_text)
return before_text.replace(old_text, new_text, 1)
return None
if tool_name == "notebook_edit":
return _predict_notebook_after_text(params, before_text)
return None
def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
try:
nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
except Exception:
return None
cells = nb.get("cells")
if not isinstance(cells, list):
return None
try:
cell_index = int(params.get("cell_index", 0))
except (TypeError, ValueError):
return None
new_source = params.get("new_source")
source = new_source if isinstance(new_source, str) else ""
cell_type = params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
mode = params.get("edit_mode") if params.get("edit_mode") in ("replace", "insert", "delete") else "replace"
if mode == "delete":
if 0 <= cell_index < len(cells):
cells.pop(cell_index)
else:
return None
elif mode == "insert":
insert_at = min(max(cell_index + 1, 0), len(cells))
cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
else:
if not (0 <= cell_index < len(cells)):
return None
cell = cells[cell_index]
if not isinstance(cell, dict):
return None
cell["source"] = source
cell["cell_type"] = cell_type
if cell_type == "code":
cell.setdefault("outputs", [])
cell.setdefault("execution_count", None)
else:
cell.pop("outputs", None)
cell.pop("execution_count", None)
nb["cells"] = cells
try:
return json.dumps(nb, indent=1, ensure_ascii=False)
except Exception:
return None
def _empty_notebook() -> dict[str, Any]:
return {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"},
},
"cells": [],
}
def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
if cell_type == "code":
cell["outputs"] = []
cell["execution_count"] = None
return cell
+22
View File
@@ -0,0 +1,22 @@
"""Small helpers for passing the active LLM provider/model together."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from nanobot.providers.base import LLMProvider
@dataclass(frozen=True)
class LLMRuntime:
provider: LLMProvider
model: str
LLMRuntimeResolver = Callable[[], LLMRuntime]
def static_llm_runtime(provider: LLMProvider, model: str) -> LLMRuntimeResolver:
runtime = LLMRuntime(provider=provider, model=model)
return lambda: runtime
+18 -1
View File
@@ -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,
-138
View File
@@ -1,138 +0,0 @@
"""Helpers for WebUI chat title generation."""
from __future__ import annotations
import re
from typing import Any
from loguru import logger
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import truncate_text
WEBUI_SESSION_METADATA_KEY = "webui"
WEBUI_TITLE_METADATA_KEY = "title"
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
TITLE_MAX_CHARS = 60
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
return True
def clean_generated_title(raw: str | None) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE)
text = text.strip().strip("\"'`“”‘’")
text = re.sub(r"\s+", " ", text).strip()
text = text.rstrip("。.!?,;:")
if len(text) > TITLE_MAX_CHARS:
text = text[: TITLE_MAX_CHARS - 1].rstrip() + ""
return text
def _title_inputs(session: Session) -> tuple[str, str]:
user_text = ""
assistant_text = ""
for message in session.messages:
role = message.get("role")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
continue
if role == "user" and not user_text:
user_text = content.strip()
elif role == "assistant" and not assistant_text:
assistant_text = content.strip()
if user_text and assistant_text:
break
return user_text, assistant_text
async def maybe_generate_webui_title(
*,
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
"""Generate and persist a short title for WebUI-owned sessions only."""
session = sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
return False
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
if isinstance(current_title, str) and current_title.strip():
return False
user_text, assistant_text = _title_inputs(session)
if not user_text:
return False
prompt = (
"Generate a concise title for this chat.\n"
"Rules:\n"
"- Use the same language as the user when practical.\n"
"- 3 to 8 words.\n"
"- No quotes.\n"
"- No punctuation at the end.\n"
"- Return only the title.\n\n"
f"User: {truncate_text(user_text, 1_000)}"
)
if assistant_text:
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
try:
response = await provider.chat_with_retry(
[
{
"role": "system",
"content": (
"You write short, neutral chat titles. "
"Return only the title text."
),
},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=32,
temperature=0.2,
retry_mode="standard",
)
except Exception:
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
return False
title = clean_generated_title(response.content)
if not title or title.lower().startswith("error"):
return False
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
sessions.save(session)
return True
async def maybe_generate_webui_title_after_turn(
*,
channel: str,
metadata: dict[str, Any],
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
return await maybe_generate_webui_title(
sessions=sessions,
session_key=session_key,
provider=provider,
model=model,
)
+107 -2
View File
@@ -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}
+299
View File
@@ -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
View File
@@ -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
+32 -143
View File
@@ -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
+267
View File
@@ -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."""
+48 -1
View File
@@ -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"]
+264
View File
@@ -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()
+101 -2
View File
@@ -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")
+239
View File
@@ -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"
+25
View File
@@ -47,3 +47,28 @@ def test_provider_refresh_updates_all_model_dependents(tmp_path: Path) -> None:
assert loop.dream.provider is new_provider assert loop.dream.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
+1
View File
@@ -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
+525
View File
@@ -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)))
+92 -4
View File
@@ -370,6 +370,55 @@ async def test_send_progress_includes_structured_tool_events() -> None:
] ]
@pytest.mark.asyncio
async def test_send_file_edit_progress_uses_file_edit_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={
"_progress": True,
"_file_edit_events": [
{
"version": 1,
"phase": "start",
"call_id": "call-1",
"tool": "write_file",
"path": "src/app.py",
"added": 12,
"deleted": 2,
"approximate": True,
"status": "editing",
}
],
},
))
payload = json.loads(mock_ws.send.await_args.args[0])
assert payload == {
"event": "file_edit",
"chat_id": "chat-1",
"edits": [
{
"version": 1,
"phase": "start",
"call_id": "call-1",
"tool": "write_file",
"path": "src/app.py",
"added": 12,
"deleted": 2,
"approximate": True,
"status": "editing",
}
],
}
@pytest.mark.asyncio @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"
+11 -2
View File
@@ -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()
+66
View File
@@ -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."""
+135
View File
@@ -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")
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
from nanobot.utils.file_edit_events import (
build_file_edit_end_event,
build_file_edit_start_event,
line_diff_stats,
prepare_file_edit_tracker,
read_file_snapshot,
)
def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None:
added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n")
assert (added, deleted) == (2, 1)
def test_line_diff_stats_normalizes_crlf() -> None:
assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0)
def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None:
target = tmp_path / "notes.txt"
target.write_text("old\nkeep\n", encoding="utf-8")
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
tracker = prepare_file_edit_tracker(
call_id="call-write",
tool_name="write_file",
tool=None,
workspace=tmp_path,
params=params,
)
assert tracker is not None
start = build_file_edit_start_event(tracker, params)
assert start == {
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "notes.txt",
"phase": "start",
"added": 2,
"deleted": 1,
"approximate": True,
"status": "editing",
}
target.write_text("new\nkeep\nextra\n", encoding="utf-8")
end = build_file_edit_end_event(tracker)
assert end["phase"] == "end"
assert end["status"] == "done"
assert end["approximate"] is False
assert (end["added"], end["deleted"]) == (2, 1)
def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
target = tmp_path / "data.bin"
target.write_bytes(b"\x00\x01before")
tracker = prepare_file_edit_tracker(
call_id="call-bin",
tool_name="edit_file",
tool=None,
workspace=tmp_path,
params={"path": "data.bin", "old_text": "before", "new_text": "after"},
)
assert tracker is not None
assert not read_file_snapshot(target).countable
target.write_bytes(b"\x00\x01after")
event = build_file_edit_end_event(tracker)
assert event["binary"] is True
assert (event["added"], event["deleted"]) == (0, 0)
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
assert prepare_file_edit_tracker(
call_id="call-exec",
tool_name="exec",
tool=None,
workspace=tmp_path,
params={"path": "created-by-shell.txt"},
) is None
+56
View File
@@ -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
+5
View File
@@ -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=="],
+744
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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 -3
View File
@@ -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>
+63 -39
View File
@@ -1,44 +1,75 @@
import { useCallback, useEffect, useState } from "react"; import { Suspense, lazy, useCallback, useState } from "react";
import { Check, Copy } from "lucide-react"; import { 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>
); );
} }
+8 -4
View File
@@ -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>
); );
} }
+220
View File
@@ -0,0 +1,220 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type FileReferenceKind =
| "default"
| "css"
| "html"
| "json"
| "markdown"
| "notebook"
| "python"
| "react"
| "typescript";
interface FileReferenceChipProps {
path: string;
display?: "name" | "path";
active?: boolean;
className?: string;
textClassName?: string;
testId?: string;
}
export function FileReferenceChip({
path,
display = "name",
active = false,
className,
textClassName,
testId = "inline-file-path",
}: FileReferenceChipProps) {
const { name } = splitFilePath(path);
const kind = fileKindForPath(path);
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("not-prose inline-flex max-w-full align-[0.14em]", className)}
>
<span
data-testid={testId}
aria-label={path}
className={cn(
"inline-flex max-w-full items-center gap-1 font-medium leading-[1.1]",
"text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200",
)}
>
<FileReferenceIcon kind={kind} />
<span
data-sheen-text={active ? displayText : undefined}
className={cn(
"min-w-0 truncate",
active && "streaming-text-sheen",
textClassName,
)}
>
{displayText}
</span>
</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
sideOffset={8}
collisionPadding={12}
className={cn(
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]",
"border-border/60 bg-popover/95 px-2.5 py-1.5",
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
"shadow-lg backdrop-blur",
)}
>
{path}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function isLikelyFilePath(value: string): boolean {
const raw = value.trim();
if (!raw || raw.includes("\n")) return false;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
return false;
}
const normalized = raw.replace(/\\/g, "/");
const name = normalized.split("/").filter(Boolean).pop() ?? normalized;
if (!name || name === "." || name === "..") return false;
if (/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(name)) return true;
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
}
function splitFilePath(path: string): { directory: string; name: string } {
const normalized = path.replace(/\\/g, "/");
const slash = normalized.lastIndexOf("/");
if (slash < 0) return { directory: "", name: path };
return {
directory: normalized.slice(0, slash + 1),
name: normalized.slice(slash + 1) || normalized,
};
}
function fileKindForPath(path: string): FileReferenceKind {
const normalized = path.toLowerCase();
const name = normalized.split(/[\\/]/).pop() ?? normalized;
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
if (name === "dockerfile") {
return "default";
}
switch (ext) {
case "py":
case "pyi":
return "python";
case "jsx":
case "tsx":
return "react";
case "ts":
return "typescript";
case "html":
case "htm":
return "html";
case "css":
case "scss":
case "sass":
return "css";
case "json":
case "jsonl":
return "json";
case "md":
case "mdx":
return "markdown";
case "ipynb":
return "notebook";
default:
return "default";
}
}
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
if (kind === "react") {
return (
<svg
aria-hidden
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none" />
<ellipse cx="12" cy="12" rx="9" ry="3.7" />
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(60 12 12)" />
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(120 12 12)" />
</svg>
);
}
if (kind === "default") {
return (
<svg
aria-hidden
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7z" />
<path d="M14 2v5h5" />
</svg>
);
}
const label = fileKindLabel(kind);
return (
<span
aria-hidden
className={cn(
"inline-flex h-[1.05em] min-w-[1.05em] shrink-0 items-center justify-center",
"rounded-[4px] bg-sky-500/12 px-[0.22em] text-[0.58em] font-bold uppercase leading-none",
"text-sky-600 dark:bg-sky-400/15 dark:text-sky-300",
)}
>
{label}
</span>
);
}
function fileKindLabel(kind: FileReferenceKind): string {
switch (kind) {
case "css":
return "#";
case "html":
return "H";
case "json":
return "{}";
case "markdown":
return "M";
case "notebook":
return "N";
case "python":
return "PY";
case "typescript":
return "TS";
default:
return "";
}
}
+108 -4
View File
@@ -1,15 +1,46 @@
import { Suspense, lazy } from "react"; import {
Suspense,
lazy,
memo,
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils"; 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;
}
+95 -72
View File
@@ -1,10 +1,13 @@
import { Children, isValidElement } from "react"; import { Children, isValidElement, useMemo } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown"; import 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>
+29 -25
View File
@@ -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>
)} )}
+2 -2
View File
@@ -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")}
+45 -3
View File
@@ -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}</>;
}
+146 -7
View File
@@ -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;
+3 -1
View File
@@ -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>
); );
+104 -5
View File
@@ -1,8 +1,17 @@
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { ArrowDown } from "lucide-react"; import { 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 -9
View File
@@ -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
View File
@@ -117,53 +117,60 @@
--cjk-line-height: 1.625; --cjk-line-height: 1.625;
} }
/* LR sheen over solid label text (overlay stripe). Avoids ``background-clip: /* LR 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]);
}
+367 -76
View File
@@ -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,
+23 -2
View File
@@ -1,7 +1,16 @@
import { useCallback, useEffect, useState } from "react"; import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
type Theme = "light" | "dark"; 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);
}
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -303,7 +303,8 @@
}, },
"goalStateCloseAria": "Fermer lobjectif" "goalStateCloseAria": "Fermer lobjectif"
}, },
"scrollToBottom": "Faire défiler vers le bas" "scrollToBottom": "Faire défiler vers le bas",
"loadEarlier": "Charger les messages précédents"
}, },
"message": { "message": {
"streaming": "en cours de génération", "streaming": "en cours de génération",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -303,7 +303,8 @@
}, },
"goalStateCloseAria": "目標を閉じる" "goalStateCloseAria": "目標を閉じる"
}, },
"scrollToBottom": "一番下へスクロール" "scrollToBottom": "一番下へスクロール",
"loadEarlier": "以前のメッセージを読み込む"
}, },
"message": { "message": {
"streaming": "生成中", "streaming": "生成中",
+2 -1
View File
@@ -303,7 +303,8 @@
}, },
"goalStateCloseAria": "목표 닫기" "goalStateCloseAria": "목표 닫기"
}, },
"scrollToBottom": "맨 아래로 스크롤" "scrollToBottom": "맨 아래로 스크롤",
"loadEarlier": "이전 메시지 불러오기"
}, },
"message": { "message": {
"streaming": "생성 중", "streaming": "생성 중",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -323,7 +323,8 @@
}, },
"goalStateCloseAria": "关闭目标" "goalStateCloseAria": "关闭目标"
}, },
"scrollToBottom": "滚动到底部" "scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息"
}, },
"message": { "message": {
"streaming": "流式输出中", "streaming": "流式输出中",
+2 -1
View File
@@ -303,7 +303,8 @@
}, },
"goalStateCloseAria": "關閉目標" "goalStateCloseAria": "關閉目標"
}, },
"scrollToBottom": "捲動到底部" "scrollToBottom": "捲動到底部",
"loadEarlier": "載入更早訊息"
}, },
"message": { "message": {
"streaming": "串流輸出中", "streaming": "串流輸出中",
+25
View File
@@ -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;
} }
+5 -4
View File
@@ -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
View File
@@ -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();
}
});
});
+107 -9
View File
@@ -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();
});
}); });
+104
View File
@@ -0,0 +1,104 @@
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CodeBlock } from "@/components/CodeBlock";
import { ThemeProvider } from "@/hooks/useTheme";
const mockedStyles = vi.hoisted(() => ({
dark: { pre: { background: "#111" } },
light: { pre: { background: "#fff" } },
}));
vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => ({
default: ({
children,
style,
}: {
children: string;
style: Record<string, unknown>;
}) => (
<pre
data-testid="highlighted-code"
data-theme={style === mockedStyles.dark ? "dark" : "light"}
>
<code>{children}</code>
</pre>
),
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-dark", () => ({
default: mockedStyles.dark,
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
default: mockedStyles.light,
}));
describe("CodeBlock", () => {
it("renders plain code without mounting the highlighter when highlighting is disabled", () => {
render(
<ThemeProvider theme="dark">
<CodeBlock language="ts" code="const value = 1;" highlight={false} />
</ThemeProvider>,
);
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
expect(screen.getByText("ts")).toBeInTheDocument();
});
it("reads theme from context without creating per-block observers", async () => {
const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn();
class MockMutationObserver {
constructor(callback: MutationCallback) {
observer(callback);
}
observe = vi.fn();
disconnect = vi.fn();
takeRecords() {
return [];
}
}
vi.stubGlobal("MutationObserver", MockMutationObserver);
try {
const { rerender } = render(
<ThemeProvider theme="dark">
<CodeBlock language="ts" code="const value = 1;" />
</ThemeProvider>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
"data-theme",
"dark",
);
rerender(
<ThemeProvider theme="light">
<CodeBlock language="ts" code="const value = 1;" />
</ThemeProvider>,
);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
"data-theme",
"light",
);
expect(observer).not.toHaveBeenCalled();
} finally {
vi.stubGlobal("MutationObserver", originalMutationObserver);
}
});
});
+82
View File
@@ -0,0 +1,82 @@
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MarkdownText } from "@/components/MarkdownText";
const rendererSpy = vi.hoisted(() => vi.fn());
vi.mock("@/components/MarkdownTextRenderer", () => ({
default: ({
children,
highlightCode,
}: {
children: string;
highlightCode?: boolean;
}) => {
rendererSpy({ children, highlightCode });
return (
<div
data-testid="markdown-renderer"
data-highlight-code={String(highlightCode)}
>
{children}
</div>
);
},
}));
describe("MarkdownText", () => {
it("throttles streaming markdown commits and flushes before final highlighting", async () => {
rendererSpy.mockClear();
vi.useFakeTimers();
try {
const { rerender } = render(
<MarkdownText streaming>hello</MarkdownText>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"false",
);
expect(rendererSpy).toHaveBeenCalledTimes(1);
rerender(<MarkdownText streaming>hello world</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(79);
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(1);
});
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
expect(rendererSpy).toHaveBeenCalledTimes(2);
rerender(<MarkdownText streaming>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
rerender(<MarkdownText>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"true",
);
} finally {
vi.useRealTimers();
}
});
});
+45 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { 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",
+6 -2
View File
@@ -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();
}); });
+238 -1
View File
@@ -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],
]);
});
}); });
+50 -4
View File
@@ -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();
+191 -2
View File
@@ -1,7 +1,12 @@
import { act, render, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { 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);
});
});
+284 -4
View File
@@ -83,7 +83,112 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
}; };
} }
async function flushStreamFrame() {
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
});
}
describe("useNanobotStream", () => { 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",
+27 -5
View File
@@ -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([
{ {
+22
View File
@@ -0,0 +1,22 @@
declare module "react-syntax-highlighter/dist/esm/prism-async-light" {
import * as React from "react";
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
export default class SyntaxHighlighter extends React.Component<SyntaxHighlighterProps> {
static registerLanguage(name: string, func: unknown): void;
}
}
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-dark" {
import type * as React from "react";
const style: { [key: string]: React.CSSProperties };
export default style;
}
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-light" {
import type * as React from "react";
const style: { [key: string]: React.CSSProperties };
export default style;
}
+30
View File
@@ -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",