feat: add workspace isolation and administration UI
CI / macos-latest / Node 20 (push) Has been cancelled
CI / ubuntu-latest / Node 20 (push) Has been cancelled
CI / windows-latest / Node 20 (push) Has been cancelled

This commit is contained in:
m4
2026-07-19 12:17:18 +08:00
parent b9ed81745e
commit fe982f7f95
150 changed files with 20503 additions and 1868 deletions
+51
View File
@@ -10,3 +10,54 @@ WEBUI_AUTH_SECRET=replace-with-at-least-32-random-characters
# Optional. Defaults to 12 hours. # Optional. Defaults to 12 hours.
WEBUI_AUTH_SESSION_TTL_HOURS=12 WEBUI_AUTH_SESSION_TTL_HOURS=12
# Provider editor. Integrated `EvoSci` WebUI mode sets these automatically.
# For a standalone or remote WebUI, point at the trusted EvoScientist backend
# and use the same random token configured on that backend.
EVOSCIENTIST_BACKEND_URL=http://127.0.0.1:6174
# Deployment workspace root. This is not a browser-selectable directory and is
# shared with the trusted EvoScientist backend. Defaults to ~/.evoscientist/workspace.
EVOSCIENTIST_WORKSPACE_DIR=
# Optional for local processes running as the same OS user: both sides share
# ~/.config/evoscientist/provider-admin-token automatically. Set this explicitly
# when the WebUI and backend run on different hosts, users, or containers.
EVOSCIENTIST_PROVIDER_ADMIN_TOKEN=
# Conversation workspace policy. This value must match the trusted backend and
# is read by the BFF only; never expose it as a browser-provided path or scope.
#
# - legacy: every WebUI conversation uses the deployment root. It is an explicit
# compatibility rollback and shared files are visible across conversations.
# - optional: default. Each new conversation receives an isolated scope folder;
# a missing Registry, token, or scope rejects the operation rather than falling
# back to the shared root.
# - required: isolated scopes plus strict server-side validation. Shared legacy
# workspaces are unavailable; backend startup also requires cutover and its
# pinned OCI executor configuration.
#
# Change this deployment-level setting only in a maintenance window, restart
# both backend and WebUI, and do not use it to move an existing conversation.
# For a same-host standalone WebUI, the BFF reads the server-only token from
# <workspace>/.evoscientist/control automatically. Set the token below only
# when the WebUI and trusted backend cannot share that control-plane directory;
# it is never exposed to the browser.
EVOSCIENTIST_WORKSPACE_ISOLATION=optional
EVOSCIENTIST_BACKEND_SERVICE_TOKEN=
# Required mode is validated by the backend and must use an immutable image
# digest, never a mutable tag. Keep this aligned with the backend .env.
EVOSCIENTIST_STRICT_EXECUTOR_IMAGE=registry.example/evoscientist-runtime@sha256:replace-with-verified-digest
# Token statistics for a local `EvoSci deploy` need no explicit secret here:
# both processes dynamically share <data-dir>/usage-sink-token. Set the same
# absolute data directory on both sides only when overriding the default.
EVOSCIENTIST_DATA_DIR=
# Office document preview. The WebUI converts supported Office files to a
# private PDF cache after browser-side DOCX rendering. Leave enabled to
# auto-detect `soffice`; set to false to keep DOCX local-only and use the
# limited XLSX data-view fallback.
# In production, point COMMAND to a sandboxed, no-network converter wrapper.
EVOSCIENTIST_OFFICE_PREVIEW_ENABLED=true
EVOSCIENTIST_OFFICE_PREVIEW_COMMAND=soffice
# Concurrent conversions per WebUI process (1-8; default 2).
EVOSCIENTIST_OFFICE_PREVIEW_CONCURRENCY=2
+17 -3
View File
@@ -16,15 +16,20 @@ permissions:
jobs: jobs:
ci: ci:
name: Format, lint & build name: ${{ matrix.os }} / Node ${{ matrix.node }}
runs-on: ubuntu-latest strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [20]
runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: ${{ matrix.node }}
cache: npm cache: npm
# `npm ci` does a clean, reproducible install from package-lock.json # `npm ci` does a clean, reproducible install from package-lock.json
@@ -38,5 +43,14 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Test
run: npm test
- name: Build - name: Build
run: npm run build run: npm run build
- name: Verify portable standalone dependencies
run: npm run verify:standalone
- name: Verify package contents
run: npm pack --dry-run --ignore-scripts
+6 -7
View File
@@ -11,7 +11,7 @@ We appreciate your interest and the time you spend helping improve the EvoScient
## The zero-touch-backend principle ## The zero-touch-backend principle
> [!IMPORTANT] > [!IMPORTANT]
> The WebUI is a **runtime client only**. It must work against an unmodified EvoScientist backend — it talks to a running deployment over the LangGraph SDK and thin same-origin `/api/` routes, and never requires changes to the EvoScientist repo. Keep all new features within this constraint (the one sanctioned exception, the `webui` launch mode, already lives in EvoScientist). > Browser code is a **same-origin BFF client**. It must not connect to LangGraph directly or retain deployment URLs/API keys. Browser requests go through WebUI `/api/` routes, which resolve the server-owned active deployment and call EvoScientist with server-only credentials.
## Prerequisites ## Prerequisites
@@ -73,8 +73,7 @@ EvoScientist-WebUI/
scripts/ # assemble-standalone.mjs (packs dist/, strips *.map) scripts/ # assemble-standalone.mjs (packs dist/, strips *.map)
src/ src/
app/ app/
api/ # thin same-origin server routes (browser → local disk) api/ # same-origin BFF routes (browser → active deployment)
evosci-config/ # detect backend port from EvoScientist config
skills/ # list / install / uninstall + remote EvoSkills catalog skills/ # list / install / uninstall + remote EvoSkills catalog
memory/ # global memory CRUD + observation graph + executions memory/ # global memory CRUD + observation graph + executions
workspace/ # browse / read / edit / upload / download-zip files workspace/ # browse / read / edit / upload / download-zip files
@@ -86,16 +85,16 @@ EvoScientist-WebUI/
lib/ # client helpers (asyncAgents, cronUtils, modelCommand, lib/ # client helpers (asyncAgents, cronUtils, modelCommand,
# observationGraph, fileLink, summarization, …) # observationGraph, fileLink, summarization, …)
server/ # server-only fs helpers (workspace / memory / skills) w/ path guards server/ # server-only fs helpers (workspace / memory / skills) w/ path guards
providers/ # ClientProvider (SDK) · ChatProvider · ThemeProvider providers/ # ChatProvider · ThemeProvider
``` ```
## Architecture & connection contract ## Architecture & connection contract
Three independent layers: Three independent layers:
1. **Frontend** (Next.js standalone, port `4716`) — browser connects directly to the LangGraph backend; pure client-side chat + streaming. 1. **Frontend** (Next.js standalone, port `4716`) — browser uses same-origin `fetch` for chat, streaming, workspace and model operations.
2. **Next `/api/` routes** (server tool layer, same-origin) — the reason distribution is `output: "standalone"` rather than a static export. 2. **Next `/api/` routes** (server BFF layer) — resolve the active deployment, enforce conversation scope checks and proxy server-only SDK calls; this is why distribution uses `output: "standalone"` rather than a static export.
3. **Backend** (EvoScientist's `langgraph dev`, default port `6174`) — the anchor process: runs async agents, holds state, serves the SDK. 3. **Backend** (EvoScientist's `langgraph dev`, default port `6174`) — the anchor process: runs async agents, holds state and serves the WebUI BFF.
The backend exposes graphs `EvoScientist` (main, UI-locked), `writing-agent`, `data-analysis-agent`, the `scheduler` graph (LangGraph crons behind **Scheduled Tasks**), and the `evomemory` workers. The UI filters thread lists by `metadata.graph_id == "EvoScientist"` so worker/sub-agent threads stay hidden. The backend exposes graphs `EvoScientist` (main, UI-locked), `writing-agent`, `data-analysis-agent`, the `scheduler` graph (LangGraph crons behind **Scheduled Tasks**), and the `evomemory` workers. The UI filters thread lists by `metadata.graph_id == "EvoScientist"` so worker/sub-agent threads stay hidden.
+11
View File
@@ -98,14 +98,25 @@ npx @evoscientist/webui@latest --port 5000 # or pick a custom front-end por
Open the URL, confirm the prefilled **Deployment URL** (auto-detected, default `http://127.0.0.1:6174`), click **Save**, and start chatting. Open the URL, confirm the prefilled **Deployment URL** (auto-detected, default `http://127.0.0.1:6174`), click **Save**, and start chatting.
Local standalone processes also share token statistics automatically when they
run as the same OS user. `EvoSci deploy` writes the private usage identity and
sink token under `~/.evoscientist`; the WebUI reads the same files, including
when it was started first. Keep the WebUI on the configured `webui_port`
(default `4716`), or set it with `EvoSci config set webui_port <port>`.
<p align="right"><a href="#top">🔝Back to top</a></p> <p align="right"><a href="#top">🔝Back to top</a></p>
## 🔑 Configuration ## 🔑 Configuration
- **Deployment URL** — the EvoScientist LangGraph endpoint (default `http://127.0.0.1:6174`, the `EvoSci deploy` default port). Saved in your browser's local storage. - **Deployment URL** — the EvoScientist LangGraph endpoint (default `http://127.0.0.1:6174`, the `EvoSci deploy` default port). Saved in your browser's local storage.
- **Model providers** — manage built-in and custom provider connections, API keys, and model catalogs in `providers.yaml`. Existing `config.yaml` provider fields remain a legacy fallback and are migrated per provider on first save. The editor requires WebUI authentication, including on localhost. API keys are never returned to the browser: it receives only configured/redacted status and supports explicit replacement or clearing. Use the star action in the chat model picker to persist the default provider/model pointer in `~/.config/evoscientist/config.yaml`.
- The UI always talks to the **EvoScientist** main agent; its sub-agents (`writing-agent`, `data-analysis-agent`) are internal and not user-selectable. - The UI always talks to the **EvoScientist** main agent; its sub-agents (`writing-agent`, `data-analysis-agent`) are internal and not user-selectable.
- _(Optional, advanced)_ Set `NEXT_PUBLIC_LANGSMITH_API_KEY` if you connect to a deployment that requires LangSmith authentication. - _(Optional, advanced)_ Set `NEXT_PUBLIC_LANGSMITH_API_KEY` if you connect to a deployment that requires LangSmith authentication.
Integrated `EvoSci` WebUI mode configures provider-management authentication automatically. Standalone local processes running as the same OS user share `~/.config/evoscientist/provider-admin-token` automatically. For a remote WebUI, set `EVOSCIENTIST_BACKEND_URL` on the WebUI server and configure the same `EVOSCIENTIST_PROVIDER_ADMIN_TOKEN` on both processes when they do not share that filesystem and user account.
Choose the adapter that matches the endpoint protocol: **OpenAI/OpenAI compatible/Grok/Antigravity/OpenRouter/NVIDIA** use OpenAI-style model listing, **Anthropic** uses Claude's native model API, **Google GenAI** uses Gemini's native model API, and **Ollama** uses `/api/tags`. Grok defaults to `https://api.x.ai/v1`; Antigravity is treated as a separately named OpenAI-compatible proxy and therefore requires its gateway Base URL. If an Antigravity gateway exposes an Anthropic-compatible endpoint instead, select **Anthropic compatible** for that profile.
> [!TIP] > [!TIP]
> If the backend changes ports, the health light detects the dead connection and offers a one-click **Reconnect** to the newly detected port. > If the backend changes ports, the health light detects the dead connection and offers a one-click **Reconnect** to the newly detected port.
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
# Office Preview
The workspace viewer renders DOCX files in the browser with `docx-preview`.
Other supported Office files, and DOCX files that cannot be rendered in the
browser, use a server-rendered PDF. The PDF is cached under the owning
conversation's private runtime directory, keyed by the source extension and
SHA-256. It is never added to the workspace file tree or shared across
conversations.
Supported server-rendered input formats are DOC, DOCX, DOCM, DOTX, DOTM, XLS,
XLSX, XLSM, XLSB, XLTX, XLTM, and ODS. DOCX uses the browser-side
`docx-preview` renderer first, preserving document pages, fonts, and layout;
if it cannot render a file, the private PDF conversion is used. XLSX retains a
limited data-only fallback when a server conversion cannot be produced.
Configure the converter in the WebUI server environment:
```env
EVOSCIENTIST_OFFICE_PREVIEW_ENABLED=true
EVOSCIENTIST_OFFICE_PREVIEW_COMMAND=/usr/local/bin/soffice
EVOSCIENTIST_OFFICE_PREVIEW_CONCURRENCY=2
```
`EVOSCIENTIST_OFFICE_PREVIEW_COMMAND` is executed without a shell and receives
only fixed LibreOffice arguments plus a private temporary source copy. It is
still an external document converter: production deployments should point it
at a wrapper running with no network, a non-root user, a read-only source
mount, CPU/memory limits, and an execution timeout. Set
`EVOSCIENTIST_OFFICE_PREVIEW_ENABLED=false` when that isolation cannot be
provided; the viewer will use the local DOCX renderer or limited XLSX fallback
instead.
@@ -0,0 +1,33 @@
{
"schema_version": 1,
"event_id": "11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222:callback_final:1",
"event_type": "usage_observed",
"source": "callback_final",
"authority_class": "observed_final",
"revision": 1,
"deployment_id": "11111111-1111-4111-8111-111111111111",
"workspace_id": "ws1_fixture",
"model_call_id": "22222222-2222-4222-8222-222222222222",
"parent_run_id": null,
"provider_request_id": "request-fixture",
"thread_id": "thread-fixture",
"source_session_id": null,
"turn_id": "human-message-fixture",
"workspace_dir": "/tmp/research",
"scope": "main",
"source_agent": "EvoScientist",
"provider_profile_id": "openai",
"provider_revision": null,
"provider_adapter": "openai",
"model_alias": "gpt-fixture",
"upstream_model_id": "gpt-fixture",
"usage_status": "confirmed",
"input_tokens": 1000,
"output_tokens": 200,
"provider_total_tokens": 1200,
"input_token_details": { "cache_read": 100 },
"output_token_details": {},
"started_at": "2026-07-16T12:00:00Z",
"observed_at": "2026-07-16T12:00:03Z",
"completed_at": "2026-07-16T12:00:03Z"
}
@@ -0,0 +1,33 @@
{
"schema_version": 1,
"event_id": "11111111-1111-4111-8111-111111111111:33333333-3333-4333-8333-333333333333:callback_final:1",
"event_type": "usage_observed",
"source": "callback_final",
"authority_class": "observed_final",
"revision": 1,
"deployment_id": "11111111-1111-4111-8111-111111111111",
"workspace_id": "ws1_fixture",
"model_call_id": "33333333-3333-4333-8333-333333333333",
"parent_run_id": null,
"provider_request_id": null,
"thread_id": "thread-fixture",
"source_session_id": null,
"turn_id": "human-message-fixture",
"workspace_dir": "/tmp/research",
"scope": "summarizer",
"source_agent": "EvoScientist",
"provider_profile_id": "openai",
"provider_revision": null,
"provider_adapter": "openai",
"model_alias": "gpt-fixture",
"upstream_model_id": "gpt-fixture",
"usage_status": "unknown",
"input_tokens": null,
"output_tokens": null,
"provider_total_tokens": null,
"input_token_details": {},
"output_token_details": {},
"started_at": "2026-07-16T12:01:00Z",
"observed_at": "2026-07-16T12:01:03Z",
"completed_at": "2026-07-16T12:01:03Z"
}
@@ -0,0 +1,7 @@
{
"deployment_id": "11111111-1111-4111-8111-111111111111",
"normalized_path": "/tmp/research",
"preimage_utf8_hex": "31313131313131312d313131312d343131312d383131312d313131313131313131313131002f746d702f7265736561726368",
"algorithm": "ws1_sha256",
"expected_workspace_id": "ws1_46a4131d3521c8062898c98939cf9d81e36507611b4024f325400f63d7ed8560"
}
@@ -0,0 +1,8 @@
{
"deployment_id": "11111111-1111-4111-8111-111111111111",
"posix_root": {
"normalized_path": "/",
"expected_workspace_id": "ws1_412a41c04c8471d2308633d6f2a25413f2e9d80ac6c18690979ecb8f7465abd6"
},
"symlink_contract": "realpath(link, strict=true) and realpath(target, strict=true) must produce the same workspace_id"
}
@@ -0,0 +1,7 @@
{
"deployment_id": "11111111-1111-4111-8111-111111111111",
"input": "/tmp/Cafe\u0301",
"normalized_path": "/tmp/Café",
"normalization": "NFC",
"expected_workspace_id": "ws1_f02c9be33d4752f22ca0306264a73e52906a6c0e97aa21be9e7a473ba9273fb2"
}
@@ -0,0 +1,20 @@
{
"deployment_id": "11111111-1111-4111-8111-111111111111",
"cases": [
{
"input": "C:\\Research\\",
"normalized_path": "c:/research",
"expected_workspace_id": "ws1_4895b5245596d561e44d9ee9588bb37d63b25cafe7a631a4644e157f84294782"
},
{
"input": "C:\\",
"normalized_path": "c:/",
"expected_workspace_id": "ws1_55a10f7cde11e579fb743076efde1c1b94306a50dc3c3d7b289fc89ed2a29ff0"
},
{
"input": "\\\\Server\\Share\\Research\\",
"normalized_path": "//server/share/research",
"expected_workspace_id": "ws1_045c2f30dcaa67fb42871572afa544b69aef1aa2b556dcbead918608198e66b9"
}
]
}
@@ -0,0 +1,15 @@
{
"parent_metadata": {
"turn_id": "human-message-fixture",
"thread_id": "main-thread",
"source_agent": "EvoScientist",
"workspace_dir": "/tmp/research"
},
"expected_child_metadata": {
"turn_id": "human-message-fixture",
"source_session_id": "main-thread",
"source_agent": "EvoScientist",
"workspace_dir": "/tmp/research",
"usage_scope": "async_subagent"
}
}
@@ -0,0 +1,10 @@
{
"parent_metadata": { "turn_id": "human-message-fixture" },
"expected_worker_metadata": {
"turn_id": "human-message-fixture",
"usage_scope": "memory"
},
"expected_worker_configurable": {
"evomemory_source_turn_id": "human-message-fixture"
}
}
@@ -0,0 +1,8 @@
{
"submit_location": "options.metadata",
"metadata": {
"usage_context_version": 1,
"turn_id": "human-message-fixture"
},
"config_metadata_forbidden": true
}
@@ -0,0 +1,19 @@
{
"messages_from_newest": [
{ "type": "human", "id": "async-signal", "classification": "async_update" },
{
"type": "human",
"id": "summary",
"additional_kwargs": { "lc_source": "summarization" }
},
{
"type": "human",
"id": "human-message-fixture",
"classification": "user_authored"
}
],
"expected_metadata": {
"usage_context_version": 1,
"turn_id": "human-message-fixture"
}
}
@@ -0,0 +1,9 @@
{
"sequence": [
"accepted/confirmed.json",
"projection/conflicting-confirmed.json"
],
"expected_statuses": ["accepted", "conflict"],
"expected_confirmed_calls": "1",
"expected_total_tokens": "1200"
}
@@ -0,0 +1,33 @@
{
"schema_version": 1,
"event_id": "11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222:callback_final:1",
"event_type": "usage_observed",
"source": "callback_final",
"authority_class": "observed_final",
"revision": 1,
"deployment_id": "11111111-1111-4111-8111-111111111111",
"workspace_id": "ws1_fixture",
"model_call_id": "22222222-2222-4222-8222-222222222222",
"parent_run_id": null,
"provider_request_id": "request-fixture",
"thread_id": "thread-fixture",
"source_session_id": null,
"turn_id": "human-message-fixture",
"workspace_dir": "/tmp/research",
"scope": "main",
"source_agent": "EvoScientist",
"provider_profile_id": "openai",
"provider_revision": null,
"provider_adapter": "openai",
"model_alias": "gpt-fixture",
"upstream_model_id": "gpt-fixture",
"usage_status": "confirmed",
"input_tokens": 1000,
"output_tokens": 201,
"provider_total_tokens": 1201,
"input_token_details": { "cache_read": 100 },
"output_token_details": {},
"started_at": "2026-07-16T12:00:00Z",
"observed_at": "2026-07-16T12:00:03Z",
"completed_at": "2026-07-16T12:00:03Z"
}
@@ -0,0 +1,6 @@
{
"sequence": ["accepted/confirmed.json", "accepted/confirmed.json"],
"expected_statuses": ["accepted", "duplicate"],
"expected_confirmed_calls": "1",
"expected_total_tokens": "1200"
}
@@ -0,0 +1,6 @@
{
"sequence": ["accepted/unknown.json"],
"expected_confirmed_calls": "0",
"expected_unknown_calls": "1",
"expected_total_tokens": "0"
}
@@ -0,0 +1,65 @@
{
"locked_versions": {
"langchain-core": "1.4.8",
"langchain-openai": "1.2.1",
"langchain-anthropic": "1.4.8"
},
"providers": [
{
"name": "openai",
"adapter": "openai",
"request_id_field": "response_metadata.request_id",
"usage_field": "AIMessage.usage_metadata"
},
{
"name": "anthropic",
"adapter": "anthropic",
"request_id_field": "response_metadata.id",
"usage_field": "AIMessage.usage_metadata"
},
{
"name": "custom-openai-compatible",
"adapter": "openai",
"request_id_field": "response_metadata.x_request_id",
"usage_field": "AIMessage.usage_metadata"
},
{
"name": "custom-anthropic-compatible",
"adapter": "anthropic",
"request_id_field": "response_metadata.id",
"usage_field": "AIMessage.usage_metadata"
}
],
"cases": [
{
"name": "non_stream_success",
"terminal": "success",
"usage": {
"input_tokens": 100,
"output_tokens": 20,
"total_tokens": 120
},
"expected_status": "confirmed"
},
{
"name": "stream_usage_then_error",
"terminal": "error",
"stream_usage": {
"input_tokens": 100,
"output_tokens": 5,
"total_tokens": 105
},
"expected_status": "confirmed"
},
{
"name": "success_without_usage",
"terminal": "success",
"expected_status": "unknown"
},
{
"name": "model_error_without_usage",
"terminal": "error",
"expected_status": "unknown"
}
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"input_rows": ["9007199254740991", "9007199254740991"],
"expected_decimal_sum": "18014398509481982",
"json_number_for_aggregate_forbidden": true
}
@@ -0,0 +1,5 @@
{
"base_fixture": "../accepted/confirmed.json",
"patch": { "input_tokens": 9007199254740991, "output_tokens": 1 },
"expected_reason": "token_sum_exceeds_safe_integer"
}
@@ -0,0 +1,5 @@
{
"crash_point": "after_http_200_before_inflight_delete",
"recovery": "replay_inflight_after_lease",
"collector_result": "duplicate"
}
+52
View File
@@ -0,0 +1,52 @@
# Token statistics implementation evidence
Status date: 2026-07-16.
## Automated gates
| Area | Executable evidence |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Cross-language event contract | `npm test` and `uv run pytest tests/test_usage_tracking.py` consume the shared fixtures |
| Collector and projection | Vitest covers auth, capabilities, accepted/duplicate/conflict, unknown, BigInt, retention, backup, aggregate reports, deployment isolation, async attribution, and stable cursors |
| Callback and scope | Pytest replays OpenAI, Anthropic, and two custom-compatible profiles across success, stream/error, missing usage, and model error |
| Durable spool | Pytest covers atomic enqueue, duplicate multi-worker writes, soft limits, stale inflight recovery, quarantine, retry, and ACK deletion |
| Separate local processes | `EvoSci deploy` prepares the shared identity/sink/spool; WebUI authorization tests cover discovering the sink token file after WebUI startup |
| Portable WebUI package | `npm run build`, `npm run verify:standalone`, and an install-from-tarball API smoke test verify the generated external-module shim |
| Platform matrix | WebUI CI runs install/test/build/package checks on macOS, Linux, and Windows; EvoScientist CI runs spool tests and the latency benchmark on the same matrix |
| Runtime health | Status and UI expose availability, backlog, inflight, quarantine, first loss, and degradation reason |
| Usage details UI | Vitest covers view isolation and exact BigInt totals; Playwright verifies confirmed/unknown rows and view switching at desktop and 390px mobile widths |
| Reconciliation boundary | Database migration 3 records aggregate-only provider observations; reports never mutate per-call projection without a request ID |
## Local release evidence
The macOS arm64 benchmark used 32 samples per worker:
| Workers | P95 | P99 | Limit |
| ------: | -------: | -------: | ----------: |
| 1 | 0.328 ms | 0.471 ms | 20 / 100 ms |
| 4 | 0.697 ms | 0.882 ms | 20 / 100 ms |
| 16 | 3.141 ms | 3.588 ms | 20 / 100 ms |
The installed npm tarball returned 401 without a sink token, returned compatible
capabilities with the token, accepted and deduplicated the shared event, stored a
heartbeat, and returned `1200` total confirmed tokens as a decimal string.
The restart drill sent one event online, stopped the Collector, retained one new
event in the durable spool, restarted both processes, and observed zero remaining
spool files. The final projection contained exactly two calls and `2400` tokens.
The separate-process drill started the WebUI before the backend with an empty
shared data directory. Status initially returned `unavailable`, then an independent
`EvoSci deploy` created the identity and token, delivered consecutive authenticated
heartbeats, and status changed to `available / healthy` with an empty spool.
The usage-details browser drill projected one confirmed and one unknown model call.
The chat footer and dialog reported `1200` confirmed tokens and one unknown call;
the per-call list showed exact Input/Output/Total values, explicit `Unknown` cells,
and working thread/workspace/all-source controls. Desktop and 390x844 mobile
screenshots showed no horizontal overflow, overlap, or clipped controls.
The complete Python suite passed with 2773 tests and 10 expected skips. WebUI
Vitest passed 26 tests; ESLint reported no errors. Token-owned files pass the
Prettier check. Repository-wide Prettier still reports unrelated pre-existing
authentication files and local `.playwright-cli` snapshots.
@@ -0,0 +1,27 @@
# Provider reconciliation capability decision
Decision date: 2026-07-16.
The external UsageEvent v1 endpoint remains limited to `callback_final`. Current
OpenAI and Anthropic organization usage APIs return time-bucketed aggregates;
neither response includes the provider request ID needed to match an individual
EvoScientist model call.
- OpenAI: `GET /v1/organization/usage/completions` groups by project, user, API
key, model, batch, or service tier and returns bucket totals.
- Anthropic: `GET /v1/organizations/usage_report/messages` groups by API key,
workspace, model, service tier, or context-window dimensions and returns
bucket totals.
Official references:
- https://platform.openai.com/docs/api-reference/usage/completions
- https://docs.anthropic.com/en/api/admin-api/usage-cost/get-messages-usage-report
Because these sources cannot identify a single request, the WebUI stores them as
internal `aggregate-report-v1` observations and produces an exact decimal
difference report for the same deployment/provider/model/window. Aggregate
reports never update `model_usage`, never synthesize `provider_only` calls, and
never enter `/api/usage/events`. A future connector may enable per-request
reconciliation only after its official response includes a stable request ID;
that change requires a new event schema and projection policy.
@@ -0,0 +1,22 @@
# Provider usage compatibility matrix
This matrix freezes the replay contract used by UsageCaptureCallback. It records
the provider adapter identity before custom-compatible profiles are converted to
native LangChain adapters. The replay source is
`fixtures/providers/compatibility.json` and is executed by the Python contract
suite.
Locked SDK versions: `langchain-core 1.4.8`, `langchain-openai 1.2.1`, and
`langchain-anthropic 1.4.8`.
| Provider profile type | Adapter | Final usage location | Stream usage location | Request ID location | Missing/error result |
| --------------------------- | ----------- | ------------------------------------- | ------------------------------------- | ---------------------------------------------- | -------------------- |
| OpenAI | `openai` | `AIMessage.usage_metadata` | final `AIMessageChunk.usage_metadata` | `response_metadata.request_id` | `unknown` |
| Anthropic | `anthropic` | `AIMessage.usage_metadata` | final `AIMessageChunk.usage_metadata` | `response_metadata.id` | `unknown` |
| Custom OpenAI-compatible | `openai` | normalized `AIMessage.usage_metadata` | normalized final chunk | `response_metadata.x_request_id` when supplied | `unknown` |
| Custom Anthropic-compatible | `anthropic` | normalized `AIMessage.usage_metadata` | normalized final chunk | `response_metadata.id` when supplied | `unknown` |
Every row replays non-stream success, stream usage followed by error, success
without usage, and model error without usage. Stream usage remains an in-memory
observation until the terminal callback. No request parameter is changed to
obtain usage; providers that omit it remain enumerable as `unknown`.
+35
View File
@@ -0,0 +1,35 @@
# Usage API v1
该契约服务于同机集成启动模式。`events`、`sources/heartbeat` 和 `capabilities` 使用
`Authorization: Bearer <EVOSCIENTIST_USAGE_SINK_TOKEN>`;该 token 只认证服务端 sender,
不表示用户、角色或 workspace 权限。请求和响应均为 UTF-8 JSON,禁止缓存。
## Collector
| Endpoint | 成功响应 | 其他响应 |
| ----------------------------------- | -------------------------------------------------- | ------------------------------------------------ |
| `GET /api/usage/capabilities` | 200,schema versions、collector ID、durable ingest | 401 |
| `POST /api/usage/events` | 200 `accepted`/`duplicate` | 409 `conflict`;400/413/422 `rejected`;401;426 |
| `POST /api/usage/sources/heartbeat` | 200 `accepted` | 400/413/422;401 |
事件响应至少包含 `status`、`event_id` 和稳定的 `reason_code`。只有 200
`accepted`/`duplicate` 允许 sender 删除 inflight 文件。409、400、413、422 移入
quarantine;401、403、426、429、5xx 和网络错误保留并重试或重新协商。
单事件请求上限为 262144 bytes,不接受数组。支持的唯一外部事件版本为
`usage-event-v1.schema.json`。capabilities 没有共同 schema version 时 sender 不发送事件。
## 查询
`GET /api/usage/status` 返回 `available`、`degraded`、`offline` 或 `unavailable`。
`GET /api/usage/summary` 与 `GET /api/usage/calls` 支持 deployment、workspace、thread、turn、
workspace path、provider、model、scope 和 UTC `from`/`to` 过滤。默认 7 天,最大 90 天,
超界返回 422。calls 默认 50、最大 500,使用 opaque stable cursor。
所有 Token 合计和调用计数均为十进制字符串。未知调用独立计数,不计入 confirmed Token。
## 运行参数
默认值:heartbeat interval 15s、TTL 45s、connect/read timeout 1s/3s、retry 1s..60s、
unsupported/schema reprobe 300s/60s、inflight lease 120s、event 262144 bytes、spool
100000 files/1073741824 bytes、query 7/90 days、calls 50/500。
+155
View File
@@ -0,0 +1,155 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://evoscientist.dev/schemas/usage-event-v1.schema.json",
"title": "EvoScientist UsageEvent v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"event_id",
"event_type",
"source",
"authority_class",
"revision",
"deployment_id",
"workspace_id",
"model_call_id",
"parent_run_id",
"provider_request_id",
"thread_id",
"source_session_id",
"turn_id",
"workspace_dir",
"scope",
"source_agent",
"provider_profile_id",
"provider_revision",
"provider_adapter",
"model_alias",
"upstream_model_id",
"usage_status",
"input_tokens",
"output_tokens",
"provider_total_tokens",
"input_token_details",
"output_token_details",
"started_at",
"observed_at",
"completed_at"
],
"$defs": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[^\\u0000-\\u001f\\u007f]+$"
},
"nullableId": { "anyOf": [{ "$ref": "#/$defs/id" }, { "type": "null" }] },
"modelString": {
"type": "string",
"minLength": 1,
"maxLength": 512,
"pattern": "^[^\\u0000-\\u001f\\u007f]+$"
},
"utcTime": { "type": "string", "format": "date-time", "pattern": "Z$" },
"nullableTime": {
"anyOf": [{ "$ref": "#/$defs/utcTime" }, { "type": "null" }]
},
"token": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 },
"nullableToken": {
"anyOf": [{ "$ref": "#/$defs/token" }, { "type": "null" }]
},
"details": {
"type": "object",
"propertyNames": {
"minLength": 1,
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]+$"
},
"additionalProperties": { "$ref": "#/$defs/token" }
}
},
"properties": {
"schema_version": { "const": 1 },
"event_id": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"pattern": "^[^\\u0000-\\u001f\\u007f]+$"
},
"event_type": { "const": "usage_observed" },
"source": { "const": "callback_final" },
"authority_class": { "const": "observed_final" },
"revision": { "const": 1 },
"deployment_id": { "$ref": "#/$defs/id" },
"workspace_id": { "$ref": "#/$defs/id" },
"model_call_id": { "$ref": "#/$defs/id" },
"parent_run_id": { "$ref": "#/$defs/nullableId" },
"provider_request_id": { "$ref": "#/$defs/nullableId" },
"thread_id": { "$ref": "#/$defs/nullableId" },
"source_session_id": { "$ref": "#/$defs/nullableId" },
"turn_id": { "$ref": "#/$defs/nullableId" },
"workspace_dir": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\u0000-\\u001f\\u007f]+$"
},
{ "type": "null" }
]
},
"scope": {
"enum": [
"main",
"sync_subagent",
"async_subagent",
"tool_selector",
"summarizer",
"memory",
"scheduler",
"autoskills",
"diagnostic",
"skill_eval",
"unattributed"
]
},
"source_agent": { "$ref": "#/$defs/nullableId" },
"provider_profile_id": { "$ref": "#/$defs/modelString" },
"provider_revision": { "$ref": "#/$defs/nullableId" },
"provider_adapter": { "$ref": "#/$defs/modelString" },
"model_alias": { "$ref": "#/$defs/modelString" },
"upstream_model_id": { "$ref": "#/$defs/modelString" },
"usage_status": { "enum": ["confirmed", "unknown"] },
"input_tokens": { "$ref": "#/$defs/nullableToken" },
"output_tokens": { "$ref": "#/$defs/nullableToken" },
"provider_total_tokens": { "$ref": "#/$defs/nullableToken" },
"input_token_details": { "$ref": "#/$defs/details" },
"output_token_details": { "$ref": "#/$defs/details" },
"started_at": { "$ref": "#/$defs/nullableTime" },
"observed_at": { "$ref": "#/$defs/utcTime" },
"completed_at": { "$ref": "#/$defs/utcTime" }
},
"allOf": [
{
"if": { "properties": { "usage_status": { "const": "confirmed" } } },
"then": {
"properties": {
"input_tokens": { "$ref": "#/$defs/token" },
"output_tokens": { "$ref": "#/$defs/token" }
}
}
},
{
"if": { "properties": { "usage_status": { "const": "unknown" } } },
"then": {
"properties": {
"input_tokens": { "type": "null" },
"output_tokens": { "type": "null" },
"provider_total_tokens": { "type": "null" }
}
}
}
]
}
+17
View File
@@ -0,0 +1,17 @@
# Usage Spool v1
spool 只允许位于本地文件系统,目录为 `tmp/`、`pending/`、`inflight/`、`quarantine/`。
文件名是 `lowercase_hex(sha256(event_id)) + ".json"`,正文只允许 UsageEvent v1。
写入顺序:在 `tmp` 创建唯一文件,写完整 JSON,fsync 文件,以 `O_EXCL` 获取相同 event key
的短锁,原子 rename 到 `pending`,fsync `pending` 目录,再释放锁。相同 ID/相同 payload
丢弃临时副本;相同 ID/不同 payload 移入 quarantine。任何错误必须 fail-open,并记录
`first_loss_at`,不能改变模型结果。
sender 通过同文件系统原子 rename 从 pending claim 到 inflight。200 accepted/duplicate 后删除并
fsync inflight;409、400、413、422 移入 quarantine;网络、429 和 5xx 回 pending;401、403、
404、426 保留 inflight/pending 并暂停或重新协商。超过 120 秒的 inflight 在启动和扫描时回收。
崩溃恢复:tmp 只可删除过期孤儿;pending 重发;inflight 超过 lease 后回 pending;HTTP ACK 后、
本地删除前崩溃会重发,由 Collector event ID 幂等;本地删除后不再依赖 spool。pending/inflight
不能按时间淘汰,达到软上限时拒绝新事件并将完整性状态标为 degraded。
File diff suppressed because it is too large Load Diff
+543
View File
@@ -0,0 +1,543 @@
# WebUI 三种审核模式修改方案
> 版本:3.1 | 日期:2026-07-17 | 所属项目:EvoScientist-WebUI / EvoScientist
> 状态:单用户最小方案,已实施
## 1. 目标
本功能让单用户在 WebUI 中为当前对话选择大模型的操作审核方式。它不是用户权限、
后台授权或多用户审批系统。
| WebUI 模式 | 内部值 | 受现有 HITL 管控的工具 | `ask_user` |
| ------------- | -------- | ---------------------- | ------------------ |
| Manual review | `manual` | 用户逐次审核 | 允许并等待用户 |
| Auto-approve | `auto` | WebUI 自动 approve | 允许并等待用户 |
| Full approve | `full` | WebUI 自动 approve | 禁用,模型自行判断 |
核心定义:
```text
Full approve = Auto-approve + 不使用 ask_user
```
Auto 和 Full 的工具处理完全相同。唯一差异是 Auto 允许模型通过 `ask_user` 提问,
Full 不允许模型等待用户回答,信息不足时由模型采用合理、保守的假设继续。
## 2. 工具审核范围
本次严格沿用当前 `HumanInTheLoopMiddleware` 的范围:
```text
execute
run_in_background
schedule_task
```
其他普通工具和 MCP 工具当前不会产生 HITL interrupt,三种模式都不改变它们的
执行方式。本次不扩展 HITL 工具范围。
现有 Auto-approve 流程保持不变:
```text
模型调用受审核工具
-> 后端产生 action_requests interrupt
-> 当前打开的 ChatInterface 读取 interrupt
-> Auto 或 Full 自动提交 approve decisions
-> WebUI 创建 resume run
-> 后端继续执行工具
```
因此 Full 不是新的服务端无人值守模式。页面关闭或线程未打开时,工具 interrupt
仍然停留在 Requiring Attention;用户打开线程后才触发前端自动恢复。
## 3. 设计原则与边界
### 3.1 本次实现
- WebUI 提供 Manual、Auto、Full 三种模式。
- 模式按线程保存在浏览器 `localStorage`。
- Manual 不自动处理工具 approval interrupt。
- Auto 和 Full 复用现有前端自动 approve effect。
- Manual 和 Auto 保留 `ask_user`。
- Full 从模型工具列表中移除 `ask_user`,并增加工具侧防御。
- 新 run 和 resume run 都显式携带 `review_mode`。
- 运行、重连或线程加载时禁止切换;工具 approval interrupt 暂停后允许选择模式。
- 未知、损坏或缺失的模式统一降级为 Manual。
### 3.2 本次不实现
- 不修改现有 run discovery、断线恢复和游标机制。
- 不新增 `resume_interrupt_key`、新的 resume 幂等协议或自动重试状态机。
- 不新增审批数据库、Admin Token、审批 API、用户、角色、ACL 或多租户权限。
- 不新增 Assistant 或 LangGraph graph。
- 不修改 Scheduler 审计模型。
- 不修改 `dangerous_mode`、工作区沙箱或命令 blocklist。
- 不修改 Stop、Token 统计及现有 `turn_id` 语义。
- 不把 `review_mode` 当作安全授权边界。
- 不保证同一线程被多个浏览器标签页同时操作时的模式一致性。
该设计面向当前单用户、本机可信部署。运行恢复本身的幂等增强属于独立问题,不能
为了新增审核模式而扩大本次改动范围。
## 4. WebUI 数据模型
### 4.1 类型与辅助函数
新增 `src/lib/reviewMode.ts`:
```ts
export type ReviewMode = "manual" | "auto" | "full";
export const DEFAULT_REVIEW_MODE: ReviewMode = "manual";
export function autoApprovesTools(mode: ReviewMode): boolean {
return mode === "auto" || mode === "full";
}
export function suppressesAskUser(mode: ReviewMode): boolean {
return mode === "full";
}
```
该模块同时负责模式解析、线程存储、新线程迁移和旧 Auto-approve 数据迁移。所有
读取结果都必须经过枚举校验,非法值返回 `manual`。
### 4.2 浏览器存储
新增键:
```text
evoscientist-review-mode
```
结构:
```json
{
"thread-id-1": "auto",
"thread-id-2": "full",
"__new__": "auto"
}
```
规则:
- 每个 thread ID 保存自己的模式。
- New Chat 使用现有 `__new__` sentinel 思路。
- 第一次发送消息并创建真实线程后,将 sentinel 迁移到真实 thread ID。
- 用户从 New Chat 直接打开已有线程时,清除未使用的 sentinel,避免模式泄漏。
- Manual 是默认值,不保存 Manual 条目,缺失即表示 Manual。
- localStorage 不可用、写满或内容损坏时退回 Manual,不影响聊天功能。
### 4.3 旧数据迁移
旧键:
```text
evoscientist-auto-approve
```
迁移规则:
```text
旧值不存在或 false -> manual
旧值 true -> auto
```
旧值不能迁移成 Full。迁移成功后删除对应旧条目,兼容读取保留一个版本。
## 5. 前端状态与调用接口
### 5.1 状态所有权
继续沿用当前实现,把线程审核模式保存在 `ChatInterface`:
```ts
const [reviewMode, setReviewModeState] = useState<ReviewMode>(() =>
getThreadReviewMode(threadId)
);
```
线程切换时重新读取对应模式。模式菜单通过统一 setter 同时更新 React state 和
localStorage。
该方案不把模式状态迁入 `ChatProvider`,也不新增 active-turn state/ref。单次提交
通过显式函数参数冻结模式,而不是依赖异步回调重新读取 UI state。
### 5.2 模式锁定
以下任一条件成立时禁用模式菜单:
```text
isLoading == true
isReconnecting == true
isThreadLoading == true
```
工具 approval interrupt 表示前一个 run 已经安全暂停,此时菜单保持可用。用户可在
审批卡片选择 `Approve once`,也可选择 `Auto-approve this chat`;后者在同一次操作中
保存线程模式并用 Auto 创建 resume run。刷新后从当前线程 localStorage 恢复模式;
如果存储不可用或非法,按 Manual 处理,这是保守降级。
该最小方案不处理多个浏览器标签页同时打开并修改同一线程模式的情况。另一个标签
页修改 localStorage 后再刷新当前页面,可能使后续 resume 使用新的线程模式。当前
单用户部署接受该限制,不为此重新引入 active-turn 状态或历史 run 扫描。
### 5.3 显式传递模式
修改 `useChat` 的内部调用签名:
```ts
sendMessage(content, reviewMode);
resumeInterrupt(value, reviewMode);
startBackgroundRun({ input, command, turnId, reviewMode });
buildRunConfig(reviewMode);
```
`ChatInterface` 调用 `sendMessage()` 和 `resumeInterrupt()` 时传入当前 `reviewMode`。
`startBackgroundRun()` 只使用参数值构建 run,不在异步执行时重新读取组件状态。
`ChatInterface` 分别创建普通 resume 和审批时切换模式的包装函数:
```ts
const resumeWithReviewMode = useCallback(
(value: unknown) => resumeInterrupt(value, reviewMode),
[resumeInterrupt, reviewMode]
);
const resumeWithApprovalMode = useCallback(
(value: unknown, nextMode?: ReviewMode) => {
const mode = nextMode ?? reviewMode;
if (nextMode) setThreadReviewMode(threadId, nextMode);
resumeInterrupt(value, mode);
},
[resumeInterrupt, reviewMode, threadId]
);
```
ask-user handlers 和自动 approve effect 使用普通包装函数。工具审核组件使用
`onResumeInterrupt(value, nextMode?)`,让审批卡片可以原子地切换模式并恢复。实现
必须先占用当前 interrupt 的自动恢复锁,避免按钮提交与 Auto effect 重复创建 run。
`startBackgroundRun()` 必须分离 thread metadata 和 run metadata:
```ts
const baseMetadata = {
usage_context_version: 1,
turn_id: turnId,
};
const runMetadata = {
...baseMetadata,
review_mode: reviewMode,
};
```
- `client.threads.create()` 只使用 `baseMetadata`。
- `client.runs.create()` 使用 `runMetadata`。
- 不把 `review_mode` 写入 thread metadata;线程模式的唯一来源仍是 localStorage。
新 run 和 resume run 的请求为:
```json
{
"config": {
"configurable": {
"review_mode": "full"
}
},
"metadata": {
"review_mode": "full",
"turn_id": "existing-logical-turn-id"
}
}
```
要求:
- `buildRunConfig(reviewMode)` 合并现有 configurable,不能覆盖模型配置。
- run metadata 中的模式仅用于日志和排查。
- resume run 继续使用现有 `turn_id` 和现有 run 创建/恢复逻辑。
- 不从历史 run metadata 重新计算 WebUI 模式。
## 6. WebUI 交互修改
### 6.1 模式菜单
把当前 Auto-approve boolean 按钮替换为模式菜单:
- Manual review
- Auto-approve
- Full approve
按钮显示当前模式。选择 Full 时显示确认对话框,说明模型将不再通过 `ask_user`
等待输入,而会使用合理假设继续。
### 6.2 工具自动 approve
现有 effect 的条件从:
```ts
if (!autoApprove) return;
```
改为:
```ts
if (!autoApprovesTools(reviewMode)) return;
```
approve payload 和现有去重机制保持不变:
```ts
resumeWithReviewMode({
decisions: actionRequests.map(() => ({ type: "approve" })),
});
```
Manual 不执行该 effect;Auto 和 Full 执行。
### 6.3 展示组件
`ChatInterface` 派生:
```ts
const toolsAutoApproved = autoApprovesTools(reviewMode);
```
继续通过现有 `autoApprove` boolean prop 控制展示。审批回调增加可选的
`nextReviewMode`;卡片提供 `Approve once` 和 `Auto-approve this chat`。同一 interrupt
含多个工具时,切换 Auto 会保留已经明确做出的 Reject/Edit,只为未决定项补
Approve,然后只提交一次 resume。
### 6.4 ThreadList
- Manual 的工具 interrupt 需要用户关注。
- Auto 和 Full 的当前打开线程会自动恢复工具 interrupt。
- 后台或未打开线程中的工具 interrupt 仍显示 Requiring Attention。
- Manual 和 Auto 的 `ask_user` 需要用户关注。
`ThreadList` 将 `getThreadAutoApprove()` 替换为:
```ts
autoApprovesTools(getThreadReviewMode(thread.id));
```
### 6.5 Full 的异常 ask_user fallback
正常部署下,Full run 不会产生 `ask_user` interrupt。为兼容服务更新前已经持久化
的 interrupt 或前后端短暂版本不一致,WebUI 保留轻量 fallback:
```text
interrupt.type == ask_user && reviewMode == full
-> 为每个问题填入统一回答
-> 使用现有 resumeInterrupt() 恢复
```
统一回答:
```text
请根据当前上下文采用合理、保守的假设继续,无需等待用户确认,并在最终结果中说明关键假设。
```
fallback 使用独立的 ask-user interrupt key/ref 防止重复提交。key 的优先级固定为:
```text
1. ask-user:<interrupt.id>
2. ask-user:<interrupt.value.tool_call_id>
3. ask-user:<stableStringify(ns + scope + questions)>
```
规则:
- 检测到 ask-user interrupt 后先生成 key;与 ref 相同则不再次提交。
- 首次提交前把 key 写入 ref,防止 React effect 重复执行。
- interrupt 消失、线程切换或出现不同 key 时清理旧 ref。
- key 必须带 `ask-user:` 命名空间,不能复用依赖 `action_requests` 的工具审批 key。
- resume 创建失败时沿用现有错误提示和断线恢复行为;刷新或重新打开线程会重新
挂载组件并获得一次新的 fallback 机会。
除为 `resumeInterrupt()` 增加显式 `reviewMode` 参数外,不修改其返回值、run 创建
流程或恢复语义,也不增加自动重试和新的幂等协议。
## 7. EvoScientist 修改
### 7.1 模式解析
在 `EvoScientist/middleware/ask_user.py` 增加:
```python
def _review_mode() -> str:
try:
config = get_config()
except Exception:
return "manual"
if not isinstance(config, dict):
return "manual"
configurable = config.get("configurable") or {}
if not isinstance(configurable, dict):
return "manual"
mode = configurable.get("review_mode")
return mode if mode in {"manual", "auto", "full"} else "manual"
```
该实现与现有 `configurable_model.py` 的读取方式一致。外部 runnable context、异常、
malformed config 和非法模式均降级为 Manual。
### 7.2 Full 不暴露 ask_user
修改 `AskUserMiddleware.wrap_model_call()` 和 `awrap_model_call()`:
- Manual、Auto:保持现有 ask-user system prompt 和工具列表。
- Full:不注入 ask-user prompt;从 `request.tools` 移除名为 `ask_user` 的工具;
追加无人值守提示。
```text
You are running in Full approve mode. Do not wait for user clarification. When information is missing, make reasonable, conservative assumptions, continue the task, and report material assumptions in the final response.
```
工具过滤同时支持 `BaseTool` 和 dict tool schema,并通过
`request.override(system_message=..., tools=...)` 创建新请求,不直接修改原请求。
### 7.3 工具侧防御
即使模型异常生成 `ask_user` tool call,Full 也不能进入 `interrupt()`。在
`_ask_user` 进入问题校验和 interrupt 前检查模式;Full 直接返回:
```python
Command(
update={
"messages": [
ToolMessage(
"Full approve mode: continue with reasonable assumptions and do not ask the user again.",
tool_call_id=tool_call_id,
)
]
}
)
```
Manual 和 Auto 继续使用现有校验、interrupt 和答案解析流程。
## 8. 部署前提
三种模式依赖 AskUser 和 HITL middleware 在 graph 构建时已经注册。部署的最终有效
配置必须为:
```text
auto_approve = false
auto_mode = false
enable_ask_user = true
dangerous_mode = false
```
启动 `evoscientist deploy` 前检查:
```bash
uv run EvoSci config get auto_approve
uv run EvoSci config get auto_mode
uv run EvoSci config get enable_ask_user
uv run EvoSci config get dangerous_mode
```
CLI 预期显示 `False`、`False`、`True`、`False`。配置修改后必须重启 deploy 服务。
本次不增加 capability API 或 deploy fail-fast 逻辑。
## 9. 预计修改文件
### EvoScientist-WebUI
- `src/lib/reviewMode.ts`
- `src/lib/autoApprove.ts`:删除或保留一个版本的迁移 shim
- `src/app/components/ChatInterface.tsx`
- `src/app/components/ThreadList.tsx`
- `src/app/hooks/useChat.ts`
- 对应 Vitest 测试
### EvoScientist
- `EvoScientist/middleware/ask_user.py`
- `tests/test_ask_user.py`
明确不修改:
- `src/lib/runRecovery.ts`
- `src/providers/ChatProvider.tsx`
- `src/app/components/ChatMessage.tsx`
- `src/app/components/ActionGroup.tsx`
- `src/app/components/ToolCallBox.tsx`
- `EvoScientist/EvoScientist.py`
- `EvoScientist/config/settings.py`
- `EvoScientist/deploy/server.py`
- Scheduler、Token 统计、Stop 和断线恢复模块
## 10. 测试方案
### 10.1 WebUI 单元测试
1. 缺失或非法模式解析为 Manual。
2. 旧 false/缺失迁移为 Manual,旧 true 迁移为 Auto。
3. New Chat sentinel 正确迁移到真实 thread ID。
4. Manual 不自动处理 `action_requests`。
5. Auto 和 Full 都生成现有 approve decisions。
6. Manual 和 Auto 不自动回答 `ask_user`。
7. Full 自动恢复异常或旧 `ask_user` interrupt,且同一 interrupt 不重复提交。
8. 新 run 和 resume run 的 config、run metadata 携带显式传入的 `review_mode`。
9. 创建新线程时,thread metadata 不包含 `review_mode`。
10. `buildRunConfig()` 保留现有模型 configurable。
11. ask-user fallback key 按 interrupt ID、tool call ID、稳定 payload 的顺序生成。
12. ask-user fallback 在 interrupt 消失、线程切换和 key 改变时正确清理 ref。
13. 运行、重连和线程加载时模式菜单禁用,approval interrupt 时可用。
14. ThreadList 对 Manual、Auto、Full 的关注状态判断正确。
15. 展示组件继续收到正确的 `autoApprove` boolean。
16. 审批时切换 Auto 只创建一次 resume,并保留同批次已有 Reject/Edit 决定。
### 10.2 EvoScientist 单元测试
1. 缺失、非法或 malformed config/configurable 降级为 Manual。
2. Manual 和 Auto 注入 ask-user prompt 并暴露工具。
3. Full 不注入 ask-user prompt,并从最终工具列表移除 `ask_user`。
4. Full 工具侧调用返回 ToolMessage,不调用 `interrupt()`。
5. Full 的同步和异步 wrapper 行为一致。
6. Manual 和 Auto 的现有问答、取消和错误解析测试不回归。
### 10.3 端到端测试
1. Manual 对三个现有 HITL 工具显示审核卡片。
2. Auto 在当前打开线程自动继续工具操作,`ask_user` 正常显示。
3. Full 在当前打开线程自动继续工具操作,且正常运行不出现 `ask_user`。
4. 三种模式在线程切换和刷新后保持。
5. Full 信息不足时继续执行并在结果中说明关键假设。
6. 后台 Auto/Full 线程的 interrupt 继续显示 Requiring Attention,打开后恢复。
7. Stop、断线续跑和 Token 统计行为无回归。
8. 部署配置不满足前提时,不进入三模式验收。
9. Manual 审批卡片可选择仅批准当前操作或切换当前会话为 Auto 后继续。
## 11. 验收标准
1. WebUI 可以选择 Manual review、Auto-approve、Full approve。
2. 新对话默认 Manual。
3. Manual 只审核现有 HITL 范围内的三个工具。
4. Auto 和 Full 使用相同的现有工具自动 approve 机制。
5. Auto 保留 `ask_user`。
6. Full 正常调用不暴露 `ask_user`,异常调用也不会进入 interrupt。
7. 模式按线程保存在 localStorage;新 run 和 resume run 都携带该模式,但 thread
metadata 不保存该模式。
8. 运行中不能切换模式;工具 interrupt 暂停后可以在审批过程中选择模式。
9. 不修改现有 run recovery、Stop、Token 统计、安全沙箱和命令 blocklist。
10. 不引入审批数据库、Admin Token、多用户权限或新的后端 API。
11. 文档明确同一线程多标签页同时操作不在一致性保证范围内。
## 12. 独立后续工作
以下问题有价值,但不属于审核模式功能,应另建方案和 PR:
- 为同一 `turn_id` 的多次 resume 增加 `resume_interrupt_key`。
- 让 `resumeInterrupt()` 返回 created、recovered、failed 结构化结果。
- 为 run 创建失败增加有界自动重试。
- 加强响应丢失场景下的 resume 幂等恢复。
拆分后,审核模式改动可以独立验证;run recovery 改进也能覆盖全部中断类型,而
不是只服务于 Full approve。
+7
View File
@@ -4,6 +4,13 @@ const nextConfig: NextConfig = {
// Self-contained server bundle for the npm package (the bin launcher runs // Self-contained server bundle for the npm package (the bin launcher runs
// dist/server.js). Needed because /api/skills is a server route. // dist/server.js). Needed because /api/skills is a server route.
output: "standalone", output: "standalone",
serverExternalPackages: ["better-sqlite3"],
experimental: {
// proxy.ts clones request bodies before route handlers see them. Keep its
// buffer large enough for the upload route's 100 MiB batch limit plus
// multipart framing; the route still enforces 50 MiB per file.
proxyClientMaxBodySize: "101mb",
},
}; };
export default nextConfig; export default nextConfig;
+2051 -7
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -39,8 +39,11 @@
"scripts": { "scripts": {
"dev": "next dev --turbopack --port 4716", "dev": "next dev --turbopack --port 4716",
"build": "next build && node scripts/assemble-standalone.mjs", "build": "next build && node scripts/assemble-standalone.mjs",
"verify:standalone": "node scripts/verify-standalone.mjs",
"verify:workspace-isolation": "node scripts/verify-workspace-isolation.mjs",
"start": "next start --port 4716", "start": "next start --port 4716",
"start:dist": "node dist/server.js", "start:dist": "node dist/server.js",
"test": "vitest run",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
@@ -57,8 +60,11 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"better-sqlite3": "^12.11.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"docx-preview": "^0.3.7",
"fflate": "^0.8.3",
"katex": "^0.16.47", "katex": "^0.16.47",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"mermaid": "^11.15.0", "mermaid": "^11.15.0",
@@ -69,6 +75,7 @@
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"read-excel-file": "^9.3.2",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
@@ -84,6 +91,7 @@
"@eslint/js": "^9", "@eslint/js": "^9",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -98,6 +106,7 @@
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.54.0" "typescript-eslint": "^8.54.0",
"vitest": "^3.2.4"
} }
} }
+32 -1
View File
@@ -4,7 +4,7 @@
// .next/static/ (NOT inside standalone — must be copied) // .next/static/ (NOT inside standalone — must be copied)
// We copy everything into dist/ so the published package ships one folder that // We copy everything into dist/ so the published package ships one folder that
// `node dist/server.js` can run. // `node dist/server.js` can run.
import { cp, rm, readdir } from "fs/promises"; import { cp, mkdir, readdir, rm, writeFile } from "fs/promises";
import { existsSync } from "fs"; import { existsSync } from "fs";
const STANDALONE = ".next/standalone"; const STANDALONE = ".next/standalone";
@@ -26,6 +26,37 @@ if (existsSync(PUBLIC)) {
await cp(PUBLIC, `${OUT}/public`, { recursive: true }); await cp(PUBLIC, `${OUT}/public`, { recursive: true });
} }
// Turbopack represents serverExternalPackages with generated package aliases
// under .next/node_modules. The standalone tree uses absolute symlinks for
// those aliases, which npm omits from tarballs. Replace better-sqlite3 aliases
// with portable shims and let npm install the real native dependency for the
// target platform from the package root.
const NEXT_NODE_MODULES = `${OUT}/.next/node_modules`;
if (existsSync(NEXT_NODE_MODULES)) {
for (const entry of await readdir(NEXT_NODE_MODULES)) {
if (!entry.startsWith("better-sqlite3-")) continue;
const alias = `${NEXT_NODE_MODULES}/${entry}`;
await rm(alias, { recursive: true, force: true });
await mkdir(alias, { recursive: true });
await writeFile(
`${alias}/package.json`,
`${JSON.stringify(
{ name: entry, private: true, main: "index.js" },
null,
2
)}\n`
);
await writeFile(
`${alias}/index.js`,
'module.exports = require("better-sqlite3");\n'
);
}
}
await rm(`${OUT}/node_modules/better-sqlite3`, {
recursive: true,
force: true,
});
// Next copies the whole project root into the standalone bundle. Prune it down // Next copies the whole project root into the standalone bundle. Prune it down
// to just the runtime essentials (drops src/, configs, and local notes so // to just the runtime essentials (drops src/, configs, and local notes so
// they never get published). // they never get published).
+32
View File
@@ -0,0 +1,32 @@
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { lstat, readdir } from "node:fs/promises";
import path from "node:path";
const aliasesRoot = path.resolve("dist/.next/node_modules");
const aliases = (await readdir(aliasesRoot)).filter((entry) =>
entry.startsWith("better-sqlite3-")
);
if (aliases.length === 0) {
throw new Error("standalone build contains no better-sqlite3 external alias");
}
if (existsSync(path.resolve("dist/node_modules/better-sqlite3"))) {
throw new Error("standalone build contains a build-platform SQLite binary");
}
const require = createRequire(import.meta.url);
for (const alias of aliases) {
const aliasPath = path.join(aliasesRoot, alias);
if ((await lstat(aliasPath)).isSymbolicLink()) {
throw new Error(`${alias} is an npm-incompatible symbolic link`);
}
const Database = require(aliasPath);
const database = new Database(":memory:");
try {
database.prepare("SELECT 1 value").get();
} finally {
database.close();
}
}
console.log(`Verified ${aliases.length} portable better-sqlite3 alias(es).`);
+48
View File
@@ -0,0 +1,48 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
const root = path.resolve("src");
const forbiddenSdk = /\b(?:new\s+Client|useClient|ClientProvider|useStream)\b/;
const forbiddenBrowserConfig =
/\b(?:deploymentUrl|langsmithApiKey|NEXT_PUBLIC_LANGSMITH_API_KEY)\b/;
const workspaceRoutes = [
"app/api/workspace/route.ts",
"app/api/workspace/file/route.ts",
"app/api/workspace/upload/route.ts",
"app/api/workspace/download/route.ts",
];
async function sourceFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...(await sourceFiles(absolute)));
else if (/\.(?:ts|tsx)$/.test(entry.name)) files.push(absolute);
}
return files;
}
const violations = [];
for (const file of await sourceFiles(root)) {
const relative = path.relative(root, file).replaceAll(path.sep, "/");
// Server-side BFF clients are intentionally the only LangGraph SDK clients.
if (relative.startsWith("lib/server/") || relative.startsWith("app/api/")) continue;
const source = await readFile(file, "utf8");
if (forbiddenSdk.test(source)) violations.push(`${relative} (LangGraph SDK)`);
if (forbiddenBrowserConfig.test(source)) {
violations.push(`${relative} (deployment credential or URL)`);
}
}
if (violations.length) {
throw new Error(`Browser BFF boundary violation: ${violations.join(", ")}`);
}
for (const route of workspaceRoutes) {
const source = await readFile(path.join(root, route), "utf8");
if (!source.includes("resolveConversationWorkspace")) {
throw new Error(`Workspace route is not scope-resolved: ${route}`);
}
}
console.log("Verified browser BFF boundary and conversation-scoped workspace routes.");
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { isAuthenticationEnabled } from "@/lib/auth";
import { proxyEvoScientistAdminRequest } from "@/lib/server/evoscientistAdminProxy";
export const runtime = "nodejs";
async function proxy(request: NextRequest) {
if (!isAuthenticationEnabled()) {
return NextResponse.json(
{
error:
"Authentication required. Enable WebUI authentication to manage config.yaml.",
},
{ status: 403 }
);
}
return proxyEvoScientistAdminRequest(request, {
upstreamPath: "/api/config",
requestLabel: "Built-in model configuration",
maxBodyBytes: 512_000,
});
}
export async function GET(request: NextRequest) {
return proxy(request);
}
export async function PATCH(request: NextRequest) {
return proxy(request);
}
export async function POST(request: NextRequest) {
return proxy(request);
}
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { getActiveDeployment } from "@/lib/server/activeDeployment";
import {
assertThreadMatchesScope,
sanitizeConversationData,
} from "@/lib/server/conversationResponse";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ threadId: string; taskId: string }> };
function taskFromState(value: unknown, taskId: string): Record<string, unknown> | null {
if (!value || typeof value !== "object") return null;
for (const candidate of Object.values(value as Record<string, unknown>)) {
if (candidate && typeof candidate === "object" && (candidate as { task_id?: unknown }).task_id === taskId) return candidate as Record<string, unknown>;
}
return null;
}
async function resolve(route: RouteContext) {
const { threadId, taskId } = await route.params;
const deployment = await getActiveDeployment();
if (!deployment.scopeRegistry) throw new Error("Workspace scope service is unavailable.");
const scope = await deployment.scopeRegistry.getByThread(threadId);
const thread = await deployment.threadClient.threads.get(threadId);
assertThreadMatchesScope(
threadId,
thread.metadata as Record<string, unknown> | undefined,
scope
);
const state = await deployment.threadClient.threads.getState(threadId);
const task = taskFromState((state as { values?: { async_tasks?: unknown } }).values?.async_tasks, taskId);
if (!task || typeof task.thread_id !== "string" || typeof task.agent_name !== "string") throw new Error("Async task not found.");
let owner = await deployment.scopeRegistry.registerOwner(scope.scope_id, {
ownerType: "async_subagent",
resourceId: task.thread_id,
parentOwnerId: scope.primary_owner_id,
state: "active",
}).catch(() => null);
if (!owner) {
owner = await deployment.scopeRegistry
.getOwnerByResource(scope.scope_id, task.thread_id)
.catch(() => null);
}
return { deployment, scope, task, owner };
}
export async function GET(_request: NextRequest, route: RouteContext) {
try {
const { deployment, task } = await resolve(route);
const state = await deployment.threadClient.threads.getState(task.thread_id as string);
return NextResponse.json({ state: sanitizeConversationData(state) });
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : "Failed to load async task." }, { status: 400 });
}
}
export async function POST(request: NextRequest, route: RouteContext) {
try {
const body = await request.json().catch(() => null) as { input?: unknown } | null;
const { deployment, scope, task, owner } = await resolve(route);
if (!owner || !body?.input || typeof body.input !== "object") throw new Error("Async task ownership is unavailable.");
const values = await deployment.threadClient.runs.wait(task.thread_id as string, task.agent_name as string, {
input: body.input as Record<string, unknown>,
config: { configurable: {
workspace_scope_id: scope.scope_id,
workspace_scope_owner_id: owner.owner_id,
workspace_scope_revision: scope.revision,
workspace_deployment_id: scope.deployment_id,
} },
});
return NextResponse.json({ values: sanitizeConversationData(values) });
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : "Failed to run async task." }, { status: 400 });
}
}
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import { getActiveDeployment } from "@/lib/server/activeDeployment";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ threadId: string }> };
type AsyncTask = {
task_id: string;
agent_name: string;
thread_id: string;
run_id: string;
status: string;
created_at?: string;
last_updated_at?: string;
};
function tasksFromState(value: unknown): AsyncTask[] {
if (!value || typeof value !== "object") return [];
const result: AsyncTask[] = [];
for (const item of Object.values(value as Record<string, unknown>)) {
if (!item || typeof item !== "object") continue;
const task = item as Record<string, unknown>;
if (typeof task.task_id !== "string" || typeof task.agent_name !== "string") continue;
result.push({
task_id: task.task_id,
agent_name: task.agent_name,
thread_id: typeof task.thread_id === "string" ? task.thread_id : task.task_id,
run_id: typeof task.run_id === "string" ? task.run_id : "",
status: typeof task.status === "string" ? task.status : "unknown",
created_at: typeof task.created_at === "string" ? task.created_at : undefined,
last_updated_at: typeof task.last_updated_at === "string" ? task.last_updated_at : undefined,
});
}
return result;
}
async function contextFor(threadId: string) {
const deployment = await getActiveDeployment();
if (!deployment.scopeRegistry) throw new Error("Workspace scope service is unavailable.");
const scope = await deployment.scopeRegistry.getByThread(threadId);
if (scope.state === "deleting" || scope.state === "deleted") throw new Error("Conversation is not available.");
const state = await deployment.threadClient.threads.getState(threadId);
return { deployment, scope, tasks: tasksFromState((state as { values?: { async_tasks?: unknown } }).values?.async_tasks) };
}
async function ownerForTask(
context: Awaited<ReturnType<typeof contextFor>>,
task: AsyncTask
) {
try {
return await context.deployment.scopeRegistry!.registerOwner(context.scope.scope_id, {
ownerType: "async_subagent",
resourceId: task.thread_id,
parentOwnerId: context.scope.primary_owner_id,
state: "active",
});
} catch {
// A retry normally collides with the durable owner. The originating run
// already carries its owner id; read-only status/detail access can proceed
// through the parent task record without exposing a browser SDK client.
return context.deployment.scopeRegistry!.getOwnerByResource(
context.scope.scope_id,
task.thread_id
).catch(() => null);
}
}
export async function GET(_request: NextRequest, route: RouteContext) {
try {
const { threadId } = await route.params;
const context = await contextFor(threadId);
const tasks = await Promise.all(
context.tasks.map(async (task) => {
await ownerForTask(context, task);
if (!task.run_id) return { ...task, liveStatus: task.status, startedAt: task.created_at };
try {
const run = await context.deployment.threadClient.runs.get(task.thread_id, task.run_id);
const status = run.status ?? task.status;
return {
...task,
liveStatus: status,
startedAt: run.created_at ?? task.created_at,
endedAt: ["success", "error", "timeout", "cancelled", "interrupted"].includes(status)
? run.updated_at ?? task.last_updated_at
: undefined,
};
} catch {
return { ...task, liveStatus: "expired", startedAt: task.created_at, endedAt: task.last_updated_at ?? task.created_at };
}
})
);
return NextResponse.json({ tasks });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to load async tasks." },
{ status: 400 }
);
}
}
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { getActiveDeployment } from "@/lib/server/activeDeployment";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ threadId: string }> };
export async function PUT(request: NextRequest, context: RouteContext) {
try {
const { threadId } = await context.params;
const body = await request.json().catch(() => null);
if (!body || typeof body !== "object" || Array.isArray(body) || !("files" in body)) {
return NextResponse.json({ error: "files is required." }, { status: 400 });
}
const deployment = await getActiveDeployment();
if (!deployment.scopeRegistry) throw new Error("Workspace scope service is unavailable.");
await deployment.scopeRegistry.getByThread(threadId);
await deployment.threadClient.threads.updateState(threadId, {
values: { files: (body as { files: unknown }).files },
});
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to update files." },
{ status: 400 }
);
}
}
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import {
getActiveDeployment,
moveConversationScopeToTrash,
} from "@/lib/server/activeDeployment";
import {
assertThreadMatchesScope,
sanitizeConversationData,
sanitizeConversationThread,
} from "@/lib/server/conversationResponse";
import { drainConversationScope } from "@/lib/server/scopeDrain";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ threadId: string }> };
function forbiddenMetadataKey(key: string): boolean {
return key.startsWith("workspace_") || ["graph_id", "assistant_id"].includes(key);
}
function errorResponse(error: unknown): NextResponse {
const message = error instanceof Error ? error.message : "Conversation request failed.";
const status = /not found/i.test(message) ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}
async function verifiedConversation(threadId: string) {
const deployment = await getActiveDeployment();
const thread = await deployment.threadClient.threads.get(threadId);
if (!deployment.scopeRegistry) {
if (deployment.isolationMode !== "legacy") {
throw new Error("Workspace scope service is unavailable.");
}
return { deployment, thread, scope: null };
}
const scope = await deployment.scopeRegistry.getByThread(threadId);
const metadata = (thread.metadata as Record<string, unknown> | undefined) ?? {};
assertThreadMatchesScope(threadId, metadata, scope);
return { deployment, thread, scope };
}
export async function GET(_request: NextRequest, context: RouteContext) {
try {
const { threadId } = await context.params;
const { deployment, thread } = await verifiedConversation(threadId);
const state = await deployment.threadClient.threads.getState(threadId);
return NextResponse.json({
thread: sanitizeConversationThread(thread),
state: sanitizeConversationData(state),
});
} catch (error) {
return errorResponse(error);
}
}
export async function PATCH(request: NextRequest, context: RouteContext) {
try {
const { threadId } = await context.params;
const body = await request.json().catch(() => null);
if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ error: "Invalid conversation patch." }, { status: 400 });
}
const allowed = new Set(["title", "pinned", "model_override"]);
const entries = Object.entries(body as Record<string, unknown>);
if (entries.length === 0 || entries.some(([key]) => !allowed.has(key) || forbiddenMetadataKey(key))) {
return NextResponse.json({ error: "Unsupported conversation field." }, { status: 400 });
}
if ("title" in body && typeof body.title !== "string") {
return NextResponse.json({ error: "title must be a string." }, { status: 400 });
}
if ("pinned" in body && typeof body.pinned !== "boolean") {
return NextResponse.json({ error: "pinned must be a boolean." }, { status: 400 });
}
const { deployment, thread, scope } = await verifiedConversation(threadId);
if (scope?.state === "deleting" || scope?.state === "deleted") {
return NextResponse.json({ error: "Conversation is not available." }, { status: 409 });
}
const metadata = {
...((thread.metadata as Record<string, unknown> | undefined) ?? {}),
...body,
};
const updated = await deployment.threadClient.threads.update(threadId, { metadata });
return NextResponse.json({ thread: sanitizeConversationThread(updated) });
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(_request: NextRequest, context: RouteContext) {
try {
const { threadId } = await context.params;
const deployment = await getActiveDeployment();
if (!deployment.scopeRegistry) throw new Error("Workspace scope service is unavailable.");
const scope = await deployment.scopeRegistry.getByThread(threadId);
if (scope.state !== "deleted") {
const deleting =
scope.state === "deleting"
? scope
: await deployment.scopeRegistry.transition(
scope.scope_id,
scope.revision,
"deleting"
);
const thread = await deployment.threadClient.threads.get(threadId);
await deployment.threadClient.threads.update(threadId, {
metadata: {
...((thread.metadata as Record<string, unknown> | undefined) ?? {}),
workspace_status: deleting.state,
workspace_scope_revision: deleting.revision,
},
});
await drainConversationScope(deployment, deleting);
await moveConversationScopeToTrash(deployment, deleting.scope_id);
await deployment.threadClient.threads.delete(threadId).catch((error) => {
const missing =
(typeof error === "object" &&
error !== null &&
(error as { status?: unknown }).status === 404) ||
(error instanceof Error && /not found/i.test(error.message));
if (!missing) throw error;
});
await deployment.scopeRegistry.transition(
deleting.scope_id,
deleting.revision,
"deleted"
);
}
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { getActiveDeployment } from "@/lib/server/activeDeployment";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ threadId: string; runId: string }> };
export async function POST(_request: NextRequest, context: RouteContext) {
try {
const { threadId, runId } = await context.params;
const deployment = await getActiveDeployment();
if (!deployment.scopeRegistry) throw new Error("Workspace scope service is unavailable.");
await deployment.scopeRegistry.getByThread(threadId);
await deployment.threadClient.runs.cancel(threadId, runId, false, "interrupt");
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to cancel run." },
{ status: 400 }
);
}
}
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { getActiveDeployment } from "@/lib/server/activeDeployment";
import {
assertThreadMatchesScope,
sanitizeConversationData,
} from "@/lib/server/conversationResponse";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ threadId: string; runId: string }> };
async function verifiedDeployment(threadId: string) {
const deployment = await getActiveDeployment();
if (!deployment.scopeRegistry) throw new Error("Workspace scope service is unavailable.");
const scope = await deployment.scopeRegistry.getByThread(threadId);
const thread = await deployment.threadClient.threads.get(threadId);
assertThreadMatchesScope(
threadId,
thread.metadata as Record<string, unknown> | undefined,
scope
);
if (scope.state === "deleting" || scope.state === "deleted") {
throw new Error("Conversation is not available.");
}
return deployment;
}
export async function GET(_request: NextRequest, context: RouteContext) {
try {
const { threadId, runId } = await context.params;
const deployment = await verifiedDeployment(threadId);
const run = await deployment.threadClient.runs.get(threadId, runId);
return NextResponse.json({ run: sanitizeConversationData(run) });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to get run." },
{ status: 400 }
);
}
}
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { getActiveDeployment } from "@/lib/server/activeDeployment";
import {
assertThreadMatchesScope,
sanitizeConversationData,
} from "@/lib/server/conversationResponse";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ threadId: string; runId: string }> };
const encoder = new TextEncoder();
export async function GET(request: NextRequest, context: RouteContext) {
try {
const { threadId, runId } = await context.params;
const deployment = await getActiveDeployment();
if (!deployment.scopeRegistry) throw new Error("Workspace scope service is unavailable.");
const scope = await deployment.scopeRegistry.getByThread(threadId);
const thread = await deployment.threadClient.threads.get(threadId);
assertThreadMatchesScope(
threadId,
thread.metadata as Record<string, unknown> | undefined,
scope
);
const lastEventId = request.headers.get("last-event-id") ?? "-1";
const upstream = deployment.threadClient.runs.joinStream(threadId, runId, {
signal: request.signal,
cancelOnDisconnect: false,
lastEventId,
streamMode: ["messages", "updates", "values", "tasks"],
});
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const event of upstream) {
const id = event.id ? `id: ${event.id}\n` : "";
controller.enqueue(
encoder.encode(`${id}event: ${event.event}\ndata: ${JSON.stringify(sanitizeConversationData(event.data))}\n\n`)
);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Stream failed.";
controller.enqueue(
encoder.encode(`event: error\ndata: ${JSON.stringify({ message })}\n\n`)
);
} finally {
controller.close();
}
},
cancel() {
// The upstream receives request.signal; cancelling the response only
// drops this subscription and never cancels the server-side run.
},
});
return new NextResponse(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
},
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to stream run." },
{ status: 400 }
);
}
}
@@ -0,0 +1,256 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
getActiveDeployment: vi.fn(),
assertCutoverIsIdle: vi.fn(),
assertThreadMatchesScope: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/server/activeDeployment", () => ({
getActiveDeployment: mocks.getActiveDeployment,
assertCutoverIsIdle: mocks.assertCutoverIsIdle,
CutoverInProgressError: class CutoverInProgressError extends Error {},
}));
vi.mock("@/lib/server/conversationResponse", () => ({
assertThreadMatchesScope: mocks.assertThreadMatchesScope,
sanitizeConversationData: <T>(value: T) => value,
}));
const routes = await import("./route");
const context = { params: Promise.resolve({ threadId: "thread-a" }) };
function legacyDeployment() {
return {
assistantId: "EvoScientist",
isolationMode: "legacy",
scopeRegistry: null,
threadClient: {
threads: { get: vi.fn().mockResolvedValue({ thread_id: "thread-a" }) },
runs: {
create: vi
.fn()
.mockResolvedValue({ run_id: "run-a", status: "pending" }),
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
},
},
};
}
function scopedDeployment() {
const scope = {
deployment_id: "deployment-a",
scope_id: "00000000-0000-4000-8000-000000000010",
state: "active",
revision: 1,
};
const reserveRun = vi
.fn()
.mockResolvedValueOnce({ run_owner_id: "owner-initial", run_id: null })
.mockResolvedValueOnce({ run_owner_id: "owner-resume", run_id: null });
const bindRun = vi.fn().mockResolvedValue({});
return {
assistantId: "EvoScientist",
isolationMode: "required" as const,
scopeRegistry: {
getByThread: vi.fn().mockResolvedValue(scope),
reserveRun,
bindRun,
transition: vi.fn(),
},
threadClient: {
threads: {
get: vi.fn().mockResolvedValue({
thread_id: "thread-a",
metadata: { workspace_scope_id: scope.scope_id },
}),
update: vi.fn(),
},
runs: {
create: vi
.fn()
.mockResolvedValueOnce({ run_id: "run-initial", status: "pending" })
.mockResolvedValueOnce({ run_id: "run-resume", status: "pending" }),
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
},
},
};
}
describe("conversation run route", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("keeps legacy BFF runs operational without scope metadata", async () => {
const deployment = legacyDeployment();
mocks.getActiveDeployment.mockResolvedValue(deployment);
const request = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
turn_id: "turn-a",
input: { messages: [{ type: "human", content: "hello" }] },
review_mode: "manual",
}),
}
);
const response = await routes.POST(
request as unknown as NextRequest,
context
);
expect(response.status).toBe(201);
expect(await response.json()).toMatchObject({
threadId: "thread-a",
runId: "run-a",
turnId: "turn-a",
});
expect(deployment.threadClient.runs.create).toHaveBeenCalledWith(
"thread-a",
"EvoScientist",
expect.objectContaining({
metadata: expect.not.objectContaining({
workspace_scope_id: expect.anything(),
}),
config: { configurable: { review_mode: "manual" } },
})
);
});
it("continues to fail closed in required mode without a scope service", async () => {
const deployment = {
...legacyDeployment(),
isolationMode: "required" as const,
};
mocks.getActiveDeployment.mockResolvedValue(deployment);
const request = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: { messages: [] } }),
}
);
const response = await routes.POST(
request as unknown as NextRequest,
context
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Workspace scope service is unavailable.",
});
});
it("rejects optional-mode runs when the scope service is unavailable", async () => {
const deployment = {
...legacyDeployment(),
isolationMode: "optional" as const,
};
mocks.getActiveDeployment.mockResolvedValue(deployment);
const request = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: { messages: [] } }),
}
);
const response = await routes.POST(
request as unknown as NextRequest,
context
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Workspace scope service is unavailable.",
});
expect(deployment.threadClient.runs.create).not.toHaveBeenCalled();
});
it("creates a distinct run request for an approval resume in the same turn", async () => {
const deployment = scopedDeployment();
mocks.getActiveDeployment.mockResolvedValue(deployment);
const turnId = "00000000-0000-4000-8000-000000000001";
const initialRequestId = "00000000-0000-4000-8000-000000000002";
const resumeRequestId = "00000000-0000-4000-8000-000000000003";
const initial = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
turn_id: turnId,
run_request_id: initialRequestId,
input: { messages: [{ type: "human", content: "hello" }] },
review_mode: "manual",
}),
}
);
const resume = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
turn_id: turnId,
run_request_id: resumeRequestId,
interrupt_key: "interrupt-a",
command: { resume: { decisions: [{ type: "approve" }] } },
review_mode: "manual",
}),
}
);
const initialResponse = await routes.POST(
initial as unknown as NextRequest,
context
);
const resumeResponse = await routes.POST(
resume as unknown as NextRequest,
context
);
expect(initialResponse.status).toBe(201);
expect(resumeResponse.status).toBe(201);
expect(deployment.scopeRegistry.reserveRun).toHaveBeenNthCalledWith(
1,
"00000000-0000-4000-8000-000000000010",
expect.objectContaining({
turnId,
runRequestId: initialRequestId,
interruptKey: null,
})
);
expect(deployment.scopeRegistry.reserveRun).toHaveBeenNthCalledWith(
2,
"00000000-0000-4000-8000-000000000010",
expect.objectContaining({
turnId,
runRequestId: resumeRequestId,
interruptKey: "interrupt-a",
})
);
expect(deployment.scopeRegistry.bindRun).toHaveBeenNthCalledWith(
1,
"00000000-0000-4000-8000-000000000010",
initialRequestId,
"run-initial"
);
expect(deployment.scopeRegistry.bindRun).toHaveBeenNthCalledWith(
2,
"00000000-0000-4000-8000-000000000010",
resumeRequestId,
"run-resume"
);
});
});
@@ -0,0 +1,244 @@
import { createHash, randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import {
assertCutoverIsIdle,
CutoverInProgressError,
getActiveDeployment,
} from "@/lib/server/activeDeployment";
import { ScopeRegistryClientError } from "@/lib/server/scopeRegistryClient";
import {
assertThreadMatchesScope,
sanitizeConversationData,
} from "@/lib/server/conversationResponse";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ threadId: string }> };
function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",")}}`;
}
function requestHash(value: unknown): string {
return createHash("sha256").update(canonicalJson(value)).digest("hex");
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
async function scopeForThread(threadId: string) {
const deployment = await getActiveDeployment();
const thread = await deployment.threadClient.threads.get(threadId);
if (!deployment.scopeRegistry) {
if (deployment.isolationMode !== "legacy") {
throw new Error("Workspace scope service is unavailable.");
}
return { deployment, scope: null };
}
const scope = await deployment.scopeRegistry.getByThread(threadId);
assertThreadMatchesScope(
threadId,
thread.metadata as Record<string, unknown> | undefined,
scope
);
if (scope.state === "deleting" || scope.state === "deleted") {
throw new Error("Conversation is not available for new runs.");
}
return { deployment, scope };
}
export async function GET(_request: NextRequest, context: RouteContext) {
try {
const { threadId } = await context.params;
const { deployment } = await scopeForThread(threadId);
const runs = await deployment.threadClient.runs.list(threadId, {
limit: 50,
});
return NextResponse.json({ runs: sanitizeConversationData(runs) });
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to list runs.",
},
{ status: 400 }
);
}
}
export async function POST(request: NextRequest, context: RouteContext) {
try {
const { threadId } = await context.params;
const body = await request.json().catch(() => null);
if (!isPlainObject(body)) {
return NextResponse.json(
{ error: "Invalid run request." },
{ status: 400 }
);
}
const allowed = new Set([
"turn_id",
"run_request_id",
"interrupt_key",
"input",
"command",
"review_mode",
"model_override",
]);
if (Object.keys(body).some((key) => !allowed.has(key))) {
return NextResponse.json(
{ error: "Unsupported run field." },
{ status: 400 }
);
}
const turnId =
typeof body.turn_id === "string" ? body.turn_id : randomUUID();
const runRequestId =
typeof body.run_request_id === "string"
? body.run_request_id
: randomUUID();
const interruptKey =
typeof body.interrupt_key === "string" && body.interrupt_key.length > 0
? body.interrupt_key
: null;
if (interruptKey && interruptKey.length > 256) {
return NextResponse.json(
{ error: "interrupt_key is too long." },
{ status: 400 }
);
}
if (!isPlainObject(body.input) && !isPlainObject(body.command)) {
return NextResponse.json(
{ error: "input or command is required." },
{ status: 400 }
);
}
const resolved = await scopeForThread(threadId);
const deployment = resolved.deployment;
await assertCutoverIsIdle(deployment);
let scope = resolved.scope;
if (scope?.state === "draft") {
scope = await deployment.scopeRegistry!.transition(
scope.scope_id,
scope.revision,
"active"
);
const thread = await deployment.threadClient.threads.get(threadId);
await deployment.threadClient.threads.update(threadId, {
metadata: {
...((thread.metadata as Record<string, unknown> | undefined) ?? {}),
workspace_status: scope.state,
workspace_scope_revision: scope.revision,
},
});
}
const hash = requestHash({
input: isPlainObject(body.input) ? body.input : null,
command: isPlainObject(body.command) ? body.command : null,
review_mode: body.review_mode ?? null,
model_override: isPlainObject(body.model_override)
? body.model_override
: null,
});
const reservation = scope
? await deployment.scopeRegistry!.reserveRun(scope.scope_id, {
runRequestId,
turnId,
requestHash: hash,
interruptKey,
})
: null;
if (reservation?.run_id) {
const prior = await deployment.threadClient.runs.get(
threadId,
reservation.run_id
);
return NextResponse.json({
threadId,
runId: prior.run_id,
status: prior.status,
turnId,
runRequestId,
});
}
const metadata = {
turn_id: turnId,
run_request_id: runRequestId,
...(interruptKey ? { interrupt_key: interruptKey } : {}),
request_hash: hash,
review_mode: body.review_mode,
...(scope && reservation
? {
workspace_scope_id: scope.scope_id,
workspace_scope_owner_id: reservation.run_owner_id,
workspace_scope_revision: scope.revision,
workspace_deployment_id: scope.deployment_id,
}
: {}),
};
const config = {
configurable: {
...(scope && reservation
? {
workspace_scope_id: scope.scope_id,
workspace_scope_owner_id: reservation.run_owner_id,
workspace_scope_revision: scope.revision,
workspace_deployment_id: scope.deployment_id,
}
: {}),
...(body.review_mode ? { review_mode: body.review_mode } : {}),
...(isPlainObject(body.model_override) ? body.model_override : {}),
},
};
const run = await deployment.threadClient.runs.create(
threadId,
deployment.assistantId,
{
input: isPlainObject(body.input) ? body.input : undefined,
command: isPlainObject(body.command) ? body.command : undefined,
metadata,
config,
multitaskStrategy: "enqueue",
}
);
if (scope && reservation) {
await deployment.scopeRegistry!.bindRun(
scope.scope_id,
runRequestId,
run.run_id
);
}
return NextResponse.json(
{
threadId,
runId: run.run_id,
status: run.status,
turnId,
runRequestId,
},
{ status: 201 }
);
} catch (error) {
if (error instanceof ScopeRegistryClientError) {
return NextResponse.json(
{
error: error.message,
...(error.code ? { code: error.code } : {}),
},
{ status: error.status }
);
}
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to create run.",
},
{ status: error instanceof CutoverInProgressError ? 503 : 400 }
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from "next/server";
import {
assertCutoverIsIdle,
CutoverInProgressError,
getActiveDeployment,
} from "@/lib/server/activeDeployment";
import {
assertThreadMatchesScope,
sanitizeConversationThread,
} from "@/lib/server/conversationResponse";
export const runtime = "nodejs";
function errorResponse(error: unknown): NextResponse {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Conversation request failed.",
},
{ status: error instanceof CutoverInProgressError ? 503 : 500 }
);
}
export async function GET(request: NextRequest) {
try {
const deployment = await getActiveDeployment();
const limit = Math.min(
Math.max(
Number(request.nextUrl.searchParams.get("limit") ?? 20) || 20,
1
),
100
);
const offset = Math.max(
Number(request.nextUrl.searchParams.get("offset") ?? 0) || 0,
0
);
const threads = await deployment.threadClient.threads.search({
limit,
offset,
sortBy: "updated_at",
sortOrder: "desc",
metadata: { graph_id: deployment.assistantId },
});
const visible = threads.filter(
(thread) =>
(thread.metadata as Record<string, unknown> | undefined)
?.workspace_status !== "draft"
);
if (!deployment.scopeRegistry) {
if (deployment.isolationMode !== "legacy") {
throw new Error("Workspace scope service is unavailable.");
}
return NextResponse.json({
threads: visible.map(sanitizeConversationThread),
});
}
const verified = await Promise.all(
visible.map(async (thread) => {
try {
const scope = await deployment.scopeRegistry!.getByThread(
thread.thread_id
);
const metadata =
(thread.metadata as Record<string, unknown> | undefined) ?? {};
assertThreadMatchesScope(thread.thread_id, metadata, scope);
return sanitizeConversationThread(thread);
} catch {
return null;
}
})
);
return NextResponse.json({ threads: verified.filter(Boolean) });
} catch (error) {
return errorResponse(error);
}
}
export async function POST() {
try {
const deployment = await getActiveDeployment();
await assertCutoverIsIdle(deployment);
if (!deployment.scopeRegistry && deployment.isolationMode !== "legacy") {
throw new Error("Workspace scope service is unavailable.");
}
const thread = await deployment.threadClient.threads.create({
graphId: deployment.assistantId,
metadata: {
graph_id: deployment.assistantId,
workspace_status: "draft",
workspace_schema_version: 1,
},
});
if (!deployment.scopeRegistry) {
return NextResponse.json(
{ threadId: thread.thread_id, status: "draft" },
{ status: 201 }
);
}
try {
const scope = await deployment.scopeRegistry.provision(thread.thread_id);
await deployment.threadClient.threads.update(thread.thread_id, {
metadata: {
...((thread.metadata as Record<string, unknown> | undefined) ?? {}),
workspace_schema_version: 1,
workspace_scope_id: scope.scope_id,
workspace_scope_owner_id: scope.primary_owner_id,
workspace_scope_revision: scope.revision,
workspace_deployment_id: scope.deployment_id,
workspace_status: scope.state,
},
});
return NextResponse.json(
{ threadId: thread.thread_id, status: scope.state },
{ status: 201 }
);
} catch (error) {
await deployment.threadClient.threads
.delete(thread.thread_id)
.catch(() => {});
throw error;
}
} catch (error) {
return errorResponse(error);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { NextRequest } from "next/server";
import { proxyEvoScientistAdminRequest } from "@/lib/server/evoscientistAdminProxy";
export const runtime = "nodejs";
export async function PUT(request: NextRequest) {
return proxyEvoScientistAdminRequest(request, {
upstreamPath: "/api/default-model",
requestLabel: "Default model configuration",
maxBodyBytes: 8_192,
});
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { getActiveDeployment } from "@/lib/server/activeDeployment";
export const runtime = "nodejs";
function isAssistantId(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
value
);
}
export async function GET() {
try {
const deployment = await getActiveDeployment();
const assistantId = deployment.assistantId;
if (isAssistantId(assistantId)) {
return NextResponse.json({ assistant: await deployment.threadClient.assistants.get(assistantId) });
}
const assistants = await deployment.threadClient.assistants.search({
graphId: assistantId,
limit: 100,
});
const assistant =
assistants.find((item) => item.metadata?.["created_by"] === "system") ??
assistants[0];
if (!assistant) {
return NextResponse.json({ error: "No assistant found for this graph." }, { status: 404 });
}
return NextResponse.json({ assistant });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to resolve assistant." },
{ status: 502 }
);
}
}
-34
View File
@@ -1,34 +0,0 @@
import { NextResponse } from "next/server";
import { homedir } from "os";
import { join } from "path";
import { promises as fs } from "fs";
const DEFAULT_PORT = 6174;
const CONFIG_PATH = join(homedir(), ".config", "evoscientist", "config.yaml");
// Resolve the EvoScientist langgraph dev port the same way the backend does:
// env override > config.yaml > default. SECURITY: only the single
// `langgraph_dev_port` value is extracted — API keys and every other field in
// config.yaml are never read or returned.
async function resolvePort(): Promise<number> {
const envPort = process.env.EVOSCIENTIST_LANGGRAPH_DEV_PORT;
if (envPort && /^\d+$/.test(envPort.trim())) {
return parseInt(envPort.trim(), 10);
}
try {
const yaml = await fs.readFile(CONFIG_PATH, "utf-8");
const m = yaml.match(/^\s*langgraph_dev_port:\s*(\d+)\s*$/m);
if (m) return parseInt(m[1], 10);
} catch {
// No config file — fall through to the default.
}
return DEFAULT_PORT;
}
export async function GET() {
const port = await resolvePort();
return NextResponse.json({
port,
deploymentUrl: `http://127.0.0.1:${port}`,
});
}
+3 -21
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { isCrossOrigin } from "@/lib/server/workspace"; import { isCrossOrigin } from "@/lib/server/workspace";
import { getActiveDeployment } from "@/lib/server/activeDeployment";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -15,32 +16,13 @@ function fail(error: unknown, status = 400) {
); );
} }
function resolveDeploymentUrl(request: NextRequest): URL {
const raw = request.nextUrl.searchParams.get("deploymentUrl");
if (!raw?.trim()) throw new Error("A deployment URL is required.");
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Deployment URL must use http or https.");
}
url.search = "";
url.hash = "";
return url;
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
if (isCrossOrigin(request)) { if (isCrossOrigin(request)) {
return fail("Cross-origin model registry access is not allowed.", 403); return fail("Cross-origin model registry access is not allowed.", 403);
} }
const deploymentUrl = resolveDeploymentUrl(request) const deployment = await getActiveDeployment();
.toString() const upstream = await fetch(new URL("/api/models", deployment.langgraphApiUrl), {
.replace(/\/$/, "");
const headers: Record<string, string> = {};
const apiKey = request.headers.get("x-api-key");
if (apiKey) headers["X-Api-Key"] = apiKey;
const upstream = await fetch(`${deploymentUrl}/api/models`, {
headers,
cache: "no-store", cache: "no-store",
}); });
const body = await upstream.text(); const body = await upstream.text();
+12
View File
@@ -0,0 +1,12 @@
import { NextRequest } from "next/server";
import { proxyEvoScientistAdminRequest } from "@/lib/server/evoscientistAdminProxy";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
return proxyEvoScientistAdminRequest(request, {
upstreamPath: "/api/provider-actions",
requestLabel: "Provider action",
maxBodyBytes: 32_768,
});
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest } from "next/server";
import { proxyEvoScientistAdminRequest } from "@/lib/server/evoscientistAdminProxy";
export const runtime = "nodejs";
async function proxy(request: NextRequest) {
return proxyEvoScientistAdminRequest(request, {
upstreamPath: "/api/provider-profiles",
requestLabel: "Provider configuration",
maxBodyBytes: 512_000,
});
}
export async function GET(request: NextRequest) {
return proxy(request);
}
export async function PUT(request: NextRequest) {
return proxy(request);
}
+198
View File
@@ -0,0 +1,198 @@
import { NextRequest, NextResponse } from "next/server";
import {
assertCutoverIsIdle,
CutoverInProgressError,
getActiveDeployment,
} from "@/lib/server/activeDeployment";
import { assertThreadMatchesScope } from "@/lib/server/conversationResponse";
export const runtime = "nodejs";
const SCHEDULER_GRAPH_ID = "scheduler";
const SCHEDULED_RUN_KIND = "scheduled_task";
type CronLike = {
cron_id: string;
metadata?: Record<string, unknown>;
schedule?: string;
next_run_date?: string | null;
created_at?: string;
updated_at?: string;
};
function errorResponse(error: unknown, status = 400): NextResponse {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Scheduled task request failed." },
{ status: error instanceof CutoverInProgressError ? 503 : status }
);
}
async function scopedDeployment(threadId: string) {
if (!threadId) throw new Error("threadId is required.");
const deployment = await getActiveDeployment();
if (!deployment.scopeRegistry) throw new Error("Workspace scope service is unavailable.");
const scope = await deployment.scopeRegistry.getByThread(threadId);
const thread = await deployment.threadClient.threads.get(threadId);
assertThreadMatchesScope(
threadId,
thread.metadata as Record<string, unknown> | undefined,
scope
);
if (scope.state === "deleting" || scope.state === "deleted") {
throw new Error("Conversation is not available.");
}
return { deployment, scope };
}
function isTaskForScope(cron: CronLike, scopeId: string): boolean {
const metadata = cron.metadata ?? {};
return (
metadata.run_kind === SCHEDULED_RUN_KIND &&
metadata.workspace_scope_id === scopeId
);
}
function taskPayload(cron: CronLike) {
const metadata = cron.metadata ?? {};
return {
cron_id: cron.cron_id,
name: typeof metadata.name === "string" ? metadata.name : "Unnamed Task",
prompt: typeof metadata.prompt === "string" ? metadata.prompt : "",
schedule: typeof cron.schedule === "string" ? cron.schedule : "",
next_run_date: typeof cron.next_run_date === "string" ? cron.next_run_date : null,
created_at: typeof cron.created_at === "string" ? cron.created_at : "",
updated_at: typeof cron.updated_at === "string" ? cron.updated_at : "",
};
}
export async function GET(request: NextRequest) {
try {
const { deployment, scope } = await scopedDeployment(
request.nextUrl.searchParams.get("threadId") ?? ""
);
const crons = (await deployment.threadClient.crons.search({ limit: 200 })) as unknown as CronLike[];
return NextResponse.json({ tasks: crons.filter((cron) => isTaskForScope(cron, scope.scope_id)).map(taskPayload) });
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest) {
try {
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
if (body?.action === "run") {
if (typeof body.threadId !== "string" || typeof body.prompt !== "string" || body.prompt.length > 20_000) {
return NextResponse.json({ error: "Invalid scheduled task run." }, { status: 400 });
}
const { deployment, scope } = await scopedDeployment(body.threadId);
await assertCutoverIsIdle(deployment);
const thread = await deployment.threadClient.threads.create({
graphId: SCHEDULER_GRAPH_ID,
metadata: {
run_kind: SCHEDULED_RUN_KIND,
workspace_scope_id: scope.scope_id,
workspace_deployment_id: scope.deployment_id,
},
});
const owner = await deployment.scopeRegistry!.registerOwner(scope.scope_id, {
ownerType: "scheduler_run",
resourceId: thread.thread_id,
parentOwnerId: scope.primary_owner_id,
state: "active",
});
const run = await deployment.threadClient.runs.create(thread.thread_id, SCHEDULER_GRAPH_ID, {
input: { messages: [{ role: "user", content: body.prompt }] },
metadata: {
run_kind: SCHEDULED_RUN_KIND,
name: "manual-run",
prompt: body.prompt,
workspace_scope_id: scope.scope_id,
workspace_scope_owner_id: owner.owner_id,
workspace_deployment_id: scope.deployment_id,
},
config: {
configurable: {
workspace_scope_id: scope.scope_id,
workspace_scope_owner_id: owner.owner_id,
workspace_scope_revision: scope.revision,
workspace_deployment_id: scope.deployment_id,
},
},
});
return NextResponse.json({ runId: run.run_id }, { status: 201 });
}
if (
!body ||
typeof body.threadId !== "string" ||
typeof body.name !== "string" ||
typeof body.prompt !== "string" ||
typeof body.schedule !== "string" ||
body.name.length > 200 || body.prompt.length > 20_000 || body.schedule.length > 200
) {
return NextResponse.json({ error: "Invalid scheduled task." }, { status: 400 });
}
const { deployment, scope } = await scopedDeployment(body.threadId);
await assertCutoverIsIdle(deployment);
const owner = await deployment.scopeRegistry!.registerOwner(scope.scope_id, {
ownerType: "schedule",
parentOwnerId: scope.primary_owner_id,
});
try {
const cron = (await deployment.threadClient.crons.create(SCHEDULER_GRAPH_ID, {
input: { messages: [{ role: "user", content: body.prompt }] },
schedule: body.schedule,
metadata: {
run_kind: SCHEDULED_RUN_KIND,
name: body.name,
prompt: body.prompt,
workspace_scope_id: scope.scope_id,
workspace_scope_owner_id: owner.owner_id,
workspace_deployment_id: scope.deployment_id,
},
config: {
configurable: {
workspace_scope_id: scope.scope_id,
workspace_scope_owner_id: owner.owner_id,
workspace_scope_revision: scope.revision,
workspace_deployment_id: scope.deployment_id,
},
},
})) as unknown as CronLike;
await deployment.scopeRegistry!.bindOwner(scope.scope_id, owner.owner_id, cron.cron_id);
return NextResponse.json({ task: taskPayload(cron) }, { status: 201 });
} catch (error) {
await deployment.scopeRegistry!.bindOwner(scope.scope_id, owner.owner_id, `failed:${owner.owner_id}`, "terminal").catch(() => {});
throw error;
}
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(request: NextRequest) {
try {
const threadId = request.nextUrl.searchParams.get("threadId") ?? "";
const cronId = request.nextUrl.searchParams.get("cronId") ?? "";
if (!cronId) return NextResponse.json({ error: "cronId is required." }, { status: 400 });
const { deployment, scope } = await scopedDeployment(threadId);
const crons = (await deployment.threadClient.crons.search({ limit: 200 })) as unknown as CronLike[];
const cron = crons.find((item) => item.cron_id === cronId);
if (!cron) return NextResponse.json({ error: "Scheduled task not found." }, { status: 404 });
if (!isTaskForScope(cron, scope.scope_id)) return NextResponse.json({ error: "Scheduled task not found." }, { status: 404 });
await deployment.threadClient.crons.delete(cronId);
const owner = await deployment.scopeRegistry!
.getOwnerByResource(scope.scope_id, cronId)
.catch(() => null);
if (owner && !["terminal", "quarantined"].includes(owner.state)) {
await deployment.scopeRegistry!.bindOwner(
scope.scope_id,
owner.owner_id,
cronId,
"terminal"
);
}
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import {
boundedUsageQuery,
USAGE_CALLS_MAX_PAGE_SIZE,
USAGE_CALLS_PAGE_SIZE,
} from "@/lib/server/usageConfig";
import { usageCalls } from "@/lib/server/usageStore";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export function GET(request: Request) {
const input = new URL(request.url).searchParams;
const bounded = boundedUsageQuery(input);
if (!bounded.params)
return NextResponse.json({ error: bounded.error }, { status: 422 });
const rawLimit = input.get("limit");
const limit = rawLimit === null ? USAGE_CALLS_PAGE_SIZE : Number(rawLimit);
if (
!Number.isInteger(limit) ||
limit < 1 ||
limit > USAGE_CALLS_MAX_PAGE_SIZE
) {
return NextResponse.json({ error: "invalid_page_size" }, { status: 422 });
}
let cursor: { at: string; deployment: string; id: string } | null = null;
if (input.get("cursor")) {
try {
const decoded = JSON.parse(
Buffer.from(input.get("cursor")!, "base64url").toString("utf8")
) as {
at?: unknown;
deployment?: unknown;
id?: unknown;
};
if (
typeof decoded.at !== "string" ||
typeof decoded.deployment !== "string" ||
typeof decoded.id !== "string"
)
throw new Error();
cursor = {
at: decoded.at,
deployment: decoded.deployment,
id: decoded.id,
};
} catch {
return NextResponse.json({ error: "invalid_cursor" }, { status: 422 });
}
}
return NextResponse.json(usageCalls(bounded.params, limit, cursor), {
headers: { "Cache-Control": "no-store" },
});
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { authorizeUsageSink } from "@/lib/server/usageConfig";
import { collectorInstanceId } from "@/lib/server/usageStore";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export function GET(request: Request) {
if (!authorizeUsageSink(request)) {
return NextResponse.json(
{ status: "unauthorized", reason_code: "invalid_sink_token" },
{ status: 401 }
);
}
return NextResponse.json(
{
collector_instance_id: collectorInstanceId(),
supported_schema_versions: [1],
supported_topology: "same-host-integrated",
durable_ingest: true,
},
{ headers: { "Cache-Control": "no-store" } }
);
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import {
authorizeUsageSink,
USAGE_MAX_EVENT_BYTES,
} from "@/lib/server/usageConfig";
import { ingestUsageEvent } from "@/lib/server/usageStore";
import { validateUsageEvent } from "@/lib/usageTypes";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function POST(request: Request) {
if (!authorizeUsageSink(request)) {
return NextResponse.json(
{
status: "unauthorized",
event_id: null,
reason_code: "invalid_sink_token",
},
{ status: 401 }
);
}
const declared = Number(request.headers.get("content-length") ?? 0);
if (declared > USAGE_MAX_EVENT_BYTES) {
return NextResponse.json(
{ status: "rejected", event_id: null, reason_code: "event_too_large" },
{ status: 413 }
);
}
const raw = await request.text();
if (Buffer.byteLength(raw, "utf8") > USAGE_MAX_EVENT_BYTES) {
return NextResponse.json(
{ status: "rejected", event_id: null, reason_code: "event_too_large" },
{ status: 413 }
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return NextResponse.json(
{ status: "rejected", event_id: null, reason_code: "invalid_json" },
{ status: 400 }
);
}
if ((parsed as { schema_version?: unknown })?.schema_version !== 1) {
return NextResponse.json(
{
status: "schema_incompatible",
event_id: null,
reason_code: "unsupported_schema_version",
},
{ status: 426 }
);
}
const validation = validateUsageEvent(parsed);
if (!validation.value) {
return NextResponse.json(
{
status: "rejected",
event_id:
typeof (parsed as { event_id?: unknown })?.event_id === "string"
? (parsed as { event_id: string }).event_id
: null,
reason_code: "schema_validation_failed",
issues: validation.issues,
},
{ status: 422 }
);
}
const result = ingestUsageEvent(validation.value);
return NextResponse.json(result, {
status: result.status === "conflict" ? 409 : 200,
});
}
+124
View File
@@ -0,0 +1,124 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-usage-routes-"));
process.env.EVOSCIENTIST_DATA_DIR = dataDir;
process.env.EVOSCIENTIST_USAGE_SINK_TOKEN = "route-test-token";
process.env.EVOSCIENTIST_DEPLOYMENT_ID = "11111111-1111-4111-8111-111111111111";
process.env.EVOSCIENTIST_WORKSPACE_ID = "ws1_fixture";
const capabilities = await import("./capabilities/route");
const events = await import("./events/route");
const heartbeat = await import("./sources/heartbeat/route");
const summary = await import("./summary/route");
const store = await import("@/lib/server/usageStore");
const fixture = JSON.parse(
fs.readFileSync(
path.join(process.cwd(), "docs/schemas/fixtures/accepted/confirmed.json"),
"utf8"
)
);
function request(
url: string,
options: RequestInit = {},
authorized = true
): Request {
const headers = new Headers(options.headers);
if (authorized) headers.set("Authorization", "Bearer route-test-token");
return new Request(url, { ...options, headers });
}
describe("usage route contract", () => {
afterAll(() => {
store.closeUsageDb();
fs.rmSync(dataDir, { recursive: true, force: true });
});
it("requires the sink token on public ingest routes", async () => {
const response = capabilities.GET(
request("http://localhost/api/usage/capabilities", {}, false)
);
expect(response.status).toBe(401);
expect(await response.json()).toMatchObject({
status: "unauthorized",
reason_code: "invalid_sink_token",
});
});
it("advertises the frozen durable schema", async () => {
const response = capabilities.GET(
request("http://localhost/api/usage/capabilities")
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
supported_schema_versions: [1],
durable_ingest: true,
});
});
it("accepts and deduplicates a committed event", async () => {
const submit = () =>
events.POST(
request("http://localhost/api/usage/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fixture),
})
);
expect(await (await submit()).json()).toMatchObject({ status: "accepted" });
expect(await (await submit()).json()).toMatchObject({
status: "duplicate",
});
});
it("returns 426 without quarantining a future schema", async () => {
const response = await events.POST(
request("http://localhost/api/usage/events", {
method: "POST",
body: JSON.stringify({ ...fixture, schema_version: 2 }),
})
);
expect(response.status).toBe(426);
expect(await response.json()).toMatchObject({
status: "schema_incompatible",
reason_code: "unsupported_schema_version",
});
});
it("stores heartbeat health and rejects overlong query windows", async () => {
const heartbeatResponse = await heartbeat.POST(
request("http://localhost/api/usage/sources/heartbeat", {
method: "POST",
body: JSON.stringify({
deployment_id: fixture.deployment_id,
workspace_id: fixture.workspace_id,
emitter_version: "2.0",
schema_version: 1,
sender_status: "healthy",
spool_pending: 0,
spool_inflight: 0,
spool_quarantined: 0,
spool_bytes: 0,
first_loss_at: null,
tracking_degraded_reason: null,
last_error_code: null,
sent_at: new Date().toISOString(),
}),
})
);
expect(heartbeatResponse.status).toBe(200);
const response = summary.GET(
request(
"http://localhost/api/usage/summary?from=2025-01-01T00%3A00%3A00Z&to=2026-01-01T00%3A00%3A00Z"
)
);
expect(response.status).toBe(422);
expect(await response.json()).toEqual({ error: "time_range_too_large" });
});
});
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import {
authorizeUsageSink,
USAGE_MAX_EVENT_BYTES,
} from "@/lib/server/usageConfig";
import { recordHeartbeat } from "@/lib/server/usageStore";
import type { UsageHeartbeatV1 } from "@/lib/usageTypes";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const KEYS = new Set([
"deployment_id",
"workspace_id",
"emitter_version",
"schema_version",
"sender_status",
"spool_pending",
"spool_inflight",
"spool_quarantined",
"spool_bytes",
"first_loss_at",
"tracking_degraded_reason",
"last_error_code",
"sent_at",
]);
const UTC_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
function heartbeatIssues(value: unknown): string[] {
if (!value || typeof value !== "object" || Array.isArray(value))
return ["body_must_be_an_object"];
const body = value as Record<string, unknown>;
const issues: string[] = [];
for (const key of Object.keys(body))
if (!KEYS.has(key)) issues.push(`unknown_field:${key}`);
for (const key of KEYS)
if (!Object.prototype.hasOwnProperty.call(body, key))
issues.push(`missing_field:${key}`);
for (const key of [
"deployment_id",
"workspace_id",
"emitter_version",
] as const) {
if (typeof body[key] !== "string" || !body[key] || body[key].length > 256)
issues.push(`invalid_${key}`);
}
if (body.schema_version !== 1) issues.push("invalid_schema_version");
if (body.sender_status !== "healthy" && body.sender_status !== "degraded")
issues.push("invalid_sender_status");
for (const key of [
"spool_pending",
"spool_inflight",
"spool_quarantined",
"spool_bytes",
] as const) {
if (
typeof body[key] !== "number" ||
!Number.isSafeInteger(body[key]) ||
(body[key] as number) < 0
)
issues.push(`invalid_${key}`);
}
for (const key of [
"first_loss_at",
"tracking_degraded_reason",
"last_error_code",
] as const) {
if (
body[key] !== null &&
(typeof body[key] !== "string" || !body[key] || body[key].length > 512)
)
issues.push(`invalid_${key}`);
}
if (
typeof body.sent_at !== "string" ||
!UTC_TIMESTAMP.test(body.sent_at) ||
Number.isNaN(Date.parse(body.sent_at))
)
issues.push("invalid_sent_at");
if (
typeof body.first_loss_at === "string" &&
(!UTC_TIMESTAMP.test(body.first_loss_at) ||
Number.isNaN(Date.parse(body.first_loss_at)))
)
issues.push("invalid_first_loss_at");
return issues;
}
export async function POST(request: Request) {
if (!authorizeUsageSink(request))
return NextResponse.json(
{ status: "unauthorized", reason_code: "invalid_sink_token" },
{ status: 401 }
);
const raw = await request.text();
if (Buffer.byteLength(raw, "utf8") > USAGE_MAX_EVENT_BYTES)
return NextResponse.json(
{ status: "rejected", reason_code: "heartbeat_too_large" },
{ status: 413 }
);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return NextResponse.json(
{ status: "rejected", reason_code: "invalid_json" },
{ status: 400 }
);
}
const issues = heartbeatIssues(parsed);
if (issues.length)
return NextResponse.json(
{ status: "rejected", reason_code: "schema_validation_failed", issues },
{ status: 422 }
);
recordHeartbeat(parsed as UsageHeartbeatV1);
return NextResponse.json({
status: "accepted",
reason_code: "heartbeat_stored",
});
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { usageStatus } from "@/lib/server/usageStore";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export function GET(request: Request) {
return NextResponse.json(usageStatus(new URL(request.url).searchParams), {
headers: { "Cache-Control": "no-store" },
});
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { boundedUsageQuery } from "@/lib/server/usageConfig";
import { usageSummary } from "@/lib/server/usageStore";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export function GET(request: Request) {
const bounded = boundedUsageQuery(new URL(request.url).searchParams);
if (!bounded.params)
return NextResponse.json({ error: bounded.error }, { status: 422 });
return NextResponse.json(usageSummary(bounded.params), {
headers: { "Cache-Control": "no-store" },
});
}
+6 -3
View File
@@ -6,10 +6,13 @@ import { spawn } from "child_process";
import { Readable } from "stream"; import { Readable } from "stream";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { import {
getWorkspaceDir,
zipExcludeArgs, zipExcludeArgs,
isCrossOrigin, isCrossOrigin,
} from "@/lib/server/workspace"; } from "@/lib/server/workspace";
import {
ConversationWorkspaceError,
resolveConversationWorkspace,
} from "@/lib/server/conversationWorkspace";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -77,7 +80,7 @@ export async function GET(request: NextRequest) {
); );
} }
const workspaceDir = await getWorkspaceDir(); const { filesDir: workspaceDir } = await resolveConversationWorkspace(request);
tmpFile = join(tmpdir(), `evoscientist-workspace-${randomUUID()}.zip`); tmpFile = join(tmpdir(), `evoscientist-workspace-${randomUUID()}.zip`);
await zipWorkspace(workspaceDir, tmpFile, request.signal); await zipWorkspace(workspaceDir, tmpFile, request.signal);
@@ -106,7 +109,7 @@ export async function GET(request: NextRequest) {
? error.message ? error.message
: "Failed to package the workspace.", : "Failed to package the workspace.",
}, },
{ status: 400 } { status: error instanceof ConversationWorkspaceError ? error.status : 400 }
); );
} }
} }
+10 -7
View File
@@ -4,13 +4,16 @@ import { basename, extname } from "path";
import { Readable } from "stream"; import { Readable } from "stream";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { import {
getWorkspaceDir,
safeResolve, safeResolve,
isCrossOrigin, isCrossOrigin,
writeWorkspaceFile, writeWorkspaceFile,
deleteWorkspaceFile, deleteWorkspaceFile,
MAX_WORKSPACE_WRITE_BYTES, MAX_WORKSPACE_WRITE_BYTES,
} from "@/lib/server/workspace"; } from "@/lib/server/workspace";
import {
ConversationWorkspaceError,
resolveConversationWorkspace,
} from "@/lib/server/conversationWorkspace";
/** RFC 6266 Content-Disposition value with both an ASCII fallback and a UTF-8 /** RFC 6266 Content-Disposition value with both an ASCII fallback and a UTF-8
* `filename*` so non-ASCII names (e.g. Chinese) download with their real name * `filename*` so non-ASCII names (e.g. Chinese) download with their real name
@@ -75,7 +78,7 @@ export async function GET(request: NextRequest) {
} }
const download = request.nextUrl.searchParams.get("download") === "1"; const download = request.nextUrl.searchParams.get("download") === "1";
const workspaceDir = await getWorkspaceDir(); const { filesDir: workspaceDir } = await resolveConversationWorkspace(request);
// safeResolve canonicalizes + re-checks containment, so a symlink can't be // safeResolve canonicalizes + re-checks containment, so a symlink can't be
// used to read a file outside the workspace (or a hidden/internal entry). // used to read a file outside the workspace (or a hidden/internal entry).
const target = await safeResolve(workspaceDir, relPath); const target = await safeResolve(workspaceDir, relPath);
@@ -115,7 +118,7 @@ export async function GET(request: NextRequest) {
{ {
error: error instanceof Error ? error.message : "Failed to read file.", error: error instanceof Error ? error.message : "Failed to read file.",
}, },
{ status: 400 } { status: error instanceof ConversationWorkspaceError ? error.status : 400 }
); );
} }
} }
@@ -224,7 +227,7 @@ export async function PUT(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
const workspaceDir = await getWorkspaceDir(); const { filesDir: workspaceDir } = await resolveConversationWorkspace(request);
const result = await writeWorkspaceFile( const result = await writeWorkspaceFile(
workspaceDir, workspaceDir,
relPath, relPath,
@@ -236,7 +239,7 @@ export async function PUT(request: NextRequest) {
{ {
error: error instanceof Error ? error.message : "Failed to save file.", error: error instanceof Error ? error.message : "Failed to save file.",
}, },
{ status: 400 } { status: error instanceof ConversationWorkspaceError ? error.status : 400 }
); );
} }
} }
@@ -254,7 +257,7 @@ export async function DELETE(request: NextRequest) {
if (!relPath) { if (!relPath) {
return NextResponse.json({ error: "Missing path." }, { status: 400 }); return NextResponse.json({ error: "Missing path." }, { status: 400 });
} }
const workspaceDir = await getWorkspaceDir(); const { filesDir: workspaceDir } = await resolveConversationWorkspace(request);
await deleteWorkspaceFile(workspaceDir, relPath); await deleteWorkspaceFile(workspaceDir, relPath);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
@@ -263,7 +266,7 @@ export async function DELETE(request: NextRequest) {
error: error:
error instanceof Error ? error.message : "Failed to delete file.", error instanceof Error ? error.message : "Failed to delete file.",
}, },
{ status: 400 } { status: error instanceof ConversationWorkspaceError ? error.status : 400 }
); );
} }
} }
@@ -0,0 +1,66 @@
import { createReadStream } from "fs";
import { Readable } from "stream";
import { NextRequest, NextResponse } from "next/server";
import { isCrossOrigin, safeResolve } from "@/lib/server/workspace";
import {
resolveConversationWorkspace,
ConversationWorkspaceError,
} from "@/lib/server/conversationWorkspace";
import { requestOfficePreview } from "@/lib/server/officePreview";
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
try {
if (isCrossOrigin(request)) {
return NextResponse.json(
{ error: "Cross-origin workspace access is not allowed." },
{ status: 403 }
);
}
const relPath = request.nextUrl.searchParams.get("path");
if (!relPath) {
return NextResponse.json({ error: "Missing path." }, { status: 400 });
}
const workspace = await resolveConversationWorkspace(request);
const sourcePath = await safeResolve(workspace.filesDir, relPath);
const preview = await requestOfficePreview({
sourcePath,
runtimeDir: workspace.runtimeDir,
});
if (preview.status !== "ready") {
return NextResponse.json(
{
error:
preview.status === "processing"
? "Office preview is still being generated."
: preview.message,
},
{ status: preview.status === "processing" ? 409 : 422 }
);
}
const nodeStream = createReadStream(preview.pdfPath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
return new NextResponse(webStream, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": "inline; filename=office-preview.pdf",
"Content-Security-Policy": "sandbox",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Failed to read preview.",
},
{
status:
error instanceof ConversationWorkspaceError ? error.status : 400,
}
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { isCrossOrigin, safeResolve } from "@/lib/server/workspace";
import {
resolveConversationWorkspace,
ConversationWorkspaceError,
} from "@/lib/server/conversationWorkspace";
import { requestOfficePreview } from "@/lib/server/officePreview";
export const runtime = "nodejs";
function withPreviewUrl(request: NextRequest, relPath: string) {
const params = new URLSearchParams({
threadId: request.nextUrl.searchParams.get("threadId") ?? "",
path: relPath,
});
return `/api/workspace/preview/file?${params.toString()}`;
}
export async function GET(request: NextRequest) {
try {
if (isCrossOrigin(request)) {
return NextResponse.json(
{ error: "Cross-origin workspace access is not allowed." },
{ status: 403 }
);
}
const relPath = request.nextUrl.searchParams.get("path");
if (!relPath) {
return NextResponse.json({ error: "Missing path." }, { status: 400 });
}
const workspace = await resolveConversationWorkspace(request);
const sourcePath = await safeResolve(workspace.filesDir, relPath);
const preview = await requestOfficePreview({
sourcePath,
runtimeDir: workspace.runtimeDir,
});
if (preview.status === "ready") {
return NextResponse.json({
status: "ready",
mode: "pdf",
previewUrl: withPreviewUrl(request, relPath),
});
}
if (preview.status === "processing") {
return NextResponse.json(
{
status: "processing",
retryAfterMs: preview.retryAfterMs,
},
{ status: 202, headers: { "Cache-Control": "no-store" } }
);
}
return NextResponse.json(
{
status: preview.status,
reason: preview.reason,
message: preview.message,
},
{ status: preview.status === "failed" ? 422 : 200 }
);
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Failed to preview file.",
},
{
status:
error instanceof ConversationWorkspaceError ? error.status : 400,
}
);
}
}
+7 -4
View File
@@ -2,11 +2,14 @@ import { promises as fs } from "fs";
import { extname } from "path"; import { extname } from "path";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { import {
getWorkspaceDir,
safeResolve, safeResolve,
isHiddenEntry, isHiddenEntry,
isCrossOrigin, isCrossOrigin,
} from "@/lib/server/workspace"; } from "@/lib/server/workspace";
import {
ConversationWorkspaceError,
resolveConversationWorkspace,
} from "@/lib/server/conversationWorkspace";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -90,7 +93,8 @@ export async function GET(request: NextRequest) {
const relPath = request.nextUrl.searchParams.get("path") ?? ""; const relPath = request.nextUrl.searchParams.get("path") ?? "";
const recursive = request.nextUrl.searchParams.get("recursive") === "1"; const recursive = request.nextUrl.searchParams.get("recursive") === "1";
const workspaceDir = await getWorkspaceDir(); const workspace = await resolveConversationWorkspace(request);
const workspaceDir = workspace.filesDir;
const dir = await safeResolve(workspaceDir, relPath); const dir = await safeResolve(workspaceDir, relPath);
const stat = await fs.stat(dir); const stat = await fs.stat(dir);
@@ -165,7 +169,6 @@ export async function GET(request: NextRequest) {
path: relPath, path: relPath,
parent, parent,
entries, entries,
dir: workspaceDir,
}); });
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
@@ -173,7 +176,7 @@ export async function GET(request: NextRequest) {
error: error:
error instanceof Error ? error.message : "Failed to list workspace.", error instanceof Error ? error.message : "Failed to list workspace.",
}, },
{ status: 400 } { status: error instanceof ConversationWorkspaceError ? error.status : 400 }
); );
} }
} }
+8 -5
View File
@@ -1,7 +1,11 @@
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { basename, dirname, resolve } from "path"; import { basename, dirname, resolve } from "path";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getWorkspaceDir, hasControlChar } from "@/lib/server/workspace"; import { hasControlChar } from "@/lib/server/workspace";
import {
ConversationWorkspaceError,
resolveConversationWorkspace,
} from "@/lib/server/conversationWorkspace";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -104,9 +108,8 @@ export async function POST(request: NextRequest) {
return { file, fileName: sanitizeFileName(file.name) }; return { file, fileName: sanitizeFileName(file.name) };
}); });
// Lands in the working directory of the currently running deployment, so the // Files always land in the selected conversation's private files root.
// agent can read the files via its workspace file tools. const { filesDir: workspaceDir } = await resolveConversationWorkspace(request);
const workspaceDir = await getWorkspaceDir();
const uploadedFiles: { name: string; path: string; size: number }[] = []; const uploadedFiles: { name: string; path: string; size: number }[] = [];
const writtenPaths: string[] = []; const writtenPaths: string[] = [];
try { try {
@@ -139,7 +142,7 @@ export async function POST(request: NextRequest) {
error: error:
error instanceof Error ? error.message : "Failed to upload files.", error instanceof Error ? error.message : "Failed to upload files.",
}, },
{ status: 400 } { status: error instanceof ConversationWorkspaceError ? error.status : 400 }
); );
} }
} }
+347 -132
View File
@@ -1,12 +1,45 @@
"use client"; "use client";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, {
import { ChevronRight, ChevronUp, Loader2 } from "lucide-react"; useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
AlertCircle,
CheckCircle2,
ChevronRight,
ChevronUp,
Loader2,
ShieldAlert,
} from "lucide-react";
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import type { ActionRequest, ReviewConfig, ToolCall } from "@/app/types/types"; import type {
ActionRequest,
ReviewConfig,
SubAgent,
ToolCall,
} from "@/app/types/types";
import type { SubAgentStep } from "@/lib/subAgentActivity"; import type { SubAgentStep } from "@/lib/subAgentActivity";
import { ChatMessage } from "./ChatMessage"; import {
mapActionRequestsToToolCalls,
summarizeActionGroup,
} from "@/lib/actionGrouping";
import { mergeApprovalDecision } from "@/lib/approvalDecision";
import { formatToolLabel, toolArgumentPreview } from "@/lib/toolLabel";
import { autoApprovesTools, type ReviewMode } from "@/lib/reviewMode";
import {
extractStringFromMessageContent,
extractSubAgentContent,
} from "@/app/utils/utils";
import { CompactionSummary } from "./CompactionSummary"; import { CompactionSummary } from "./CompactionSummary";
import { MarkdownContent } from "./MarkdownContent";
import { SubAgentIndicator } from "./SubAgentIndicator";
import { SubAgentSteps } from "./SubAgentSteps";
import { ToolCallBox } from "./ToolCallBox";
import { StreamingMarkdown } from "./StreamingMarkdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface GroupedActionItem { export interface GroupedActionItem {
@@ -17,42 +50,47 @@ export interface GroupedActionItem {
interface ActionGroupProps { interface ActionGroupProps {
items: GroupedActionItem[]; items: GroupedActionItem[];
/** True if the very last message in the whole transcript is in this group
* and the run is still active — i.e. the group is currently being extended. */
isStreaming: boolean; isStreaming: boolean;
/** From `useCollapseAgentActions` — user preference. */
defaultCollapsed: boolean; defaultCollapsed: boolean;
/** From `useStickToBottom().isAtBottom` — auto-collapse only fires when
* the user is at the bottom (so a scrolled-up reader isn't jumped). */
isAtBottom: boolean; isAtBottom: boolean;
/** Id of the LAST message in the entire processedMessages list. Used to
* decide which ChatMessage should receive actionRequests / reviewConfigsMap. */
lastMessageId: string | undefined; lastMessageId: string | undefined;
// Pass-through ChatMessage props
isLoading: boolean; isLoading: boolean;
actionRequests: ActionRequest[]; actionRequests: ActionRequest[];
submittedActionRequestKeys: Set<string>; submittedActionRequestKeys: Set<string>;
onActionRequestSubmitted: (key: string) => void; onActionRequestSubmitted: (key: string) => void;
reviewConfigsMap: Map<string, ReviewConfig> | null; reviewConfigsMap: Map<string, ReviewConfig> | null;
stream?: unknown; stream?: unknown;
onResumeInterrupt: (value: unknown) => void; onResumeInterrupt: (value: unknown, nextReviewMode?: ReviewMode) => void;
graphId?: string; graphId?: string;
onEditMessage: (content: string) => void;
autoApprove: boolean; autoApprove: boolean;
subAgentSteps: Record<string, SubAgentStep[]>; subAgentSteps: Record<string, SubAgentStep[]>;
ui: any[] | undefined; ui: any[] | undefined;
// CompactionSummary anchoring: rendered before the matching item inside the group.
compactionAnchorId: string | null; compactionAnchorId: string | null;
summarizationEvent: { content: string; cutoffIndex: number } | null; summarizationEvent: { content: string; cutoffIndex: number } | null;
} }
// Last tool call name — what we surface in the header summary line. const compactMarkdownClass =
function lastToolName(items: GroupedActionItem[]): string { "text-xs leading-5 text-muted-foreground [&_blockquote]:my-2 [&_blockquote]:pl-2 [&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-sm [&_h2]:mb-2 [&_h2]:mt-3 [&_h2]:text-sm [&_h3]:mb-1.5 [&_h3]:mt-3 [&_h3]:text-xs [&_ol]:my-2 [&_ol]:pl-5 [&_p]:mb-2 [&_table]:text-[11px] [&_td]:p-1.5 [&_th]:p-1.5 [&_ul]:my-2 [&_ul]:pl-5";
for (let i = items.length - 1; i >= 0; i--) {
const tcs = items[i].toolCalls; function toSubAgent(toolCall: ToolCall): SubAgent | null {
if (tcs.length > 0) return tcs[tcs.length - 1].name || "tool"; if (toolCall.name !== "task") return null;
} const subAgentName = toolCall.args.subagent_type;
return "action"; if (typeof subAgentName !== "string" || !subAgentName.trim()) return null;
const status: SubAgent["status"] =
toolCall.status === "error"
? "error"
: toolCall.status === "completed"
? "completed"
: "pending";
return {
id: toolCall.id,
name: toolCall.name,
subAgentName,
input: toolCall.args,
output: toolCall.result ? { result: toolCall.result } : undefined,
status,
};
} }
export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
@@ -69,35 +107,45 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
stream, stream,
onResumeInterrupt, onResumeInterrupt,
graphId, graphId,
onEditMessage,
autoApprove, autoApprove,
subAgentSteps, subAgentSteps,
ui, ui,
compactionAnchorId, compactionAnchorId,
summarizationEvent, summarizationEvent,
}) { }) {
// Whether this group contains the message that an interrupt is currently const [open, setOpen] = useState(() => !defaultCollapsed);
// asking the user to approve. Tool-approval interrupts attach to the latest const [expandedSubAgents, setExpandedSubAgents] = useState<
// assistant message — so if `lastMessageId` belongs to this group AND there Record<string, boolean>
// are pending action requests, the user needs to see them. >({});
//
// Skip entirely when auto-approve is on: in that mode each interrupt is
// observed for one render tick before the auto-approval effect fires, so
// `actionRequests` is briefly non-empty per tool call. A force-open per flash
// would yank the section open dozens of times in a single turn — defeating
// the whole "don't bother me" intent of auto-approve.
const hasPendingApproval = useMemo(() => {
if (autoApprove) return false;
if (actionRequests.length === 0) return false;
if (lastMessageId === undefined) return false;
return items.some((item) => item.message.id === lastMessageId);
}, [autoApprove, actionRequests.length, lastMessageId, items]);
const [open, setOpen] = useState<boolean>(() => !defaultCollapsed);
const wasStreamingRef = useRef(isStreaming); const wasStreamingRef = useRef(isStreaming);
// Auto-collapse when streaming ends, but only if the user is at the bottom. const summary = useMemo(() => summarizeActionGroup(items), [items]);
// Approvals never force-open; their controls render in the preview below. const currentActionToolCalls = useMemo(
() =>
items.find((item) => item.message.id === lastMessageId)?.toolCalls ?? [],
[items, lastMessageId]
);
const groupContainsLastMessage = currentActionToolCalls.length > 0;
const actionRequestByToolCallId = useMemo(
() => mapActionRequestsToToolCalls(currentActionToolCalls, actionRequests),
[actionRequests, currentActionToolCalls]
);
const pendingApprovalCalls = useMemo(
() =>
currentActionToolCalls.filter(
(toolCall) =>
actionRequestByToolCallId.has(toolCall.id) &&
!submittedActionRequestKeys.has(toolCall.id)
),
[
actionRequestByToolCallId,
currentActionToolCalls,
submittedActionRequestKeys,
]
);
const hasPendingApproval =
!autoApprove && groupContainsLastMessage && pendingApprovalCalls.length > 0;
useEffect(() => { useEffect(() => {
const wasStreaming = wasStreamingRef.current; const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = isStreaming; wasStreamingRef.current = isStreaming;
@@ -110,27 +158,201 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
) { ) {
setOpen(false); setOpen(false);
} }
}, [isStreaming, hasPendingApproval, defaultCollapsed, isAtBottom]); }, [defaultCollapsed, hasPendingApproval, isAtBottom, isStreaming]);
// One AI message can carry several tool calls, so count the actual actions const actionRequestsKey = useMemo(
// rather than the number of message containers in this group. () =>
const count = items.reduce((total, item) => total + item.toolCalls.length, 0); JSON.stringify(
const toolName = lastToolName(items); actionRequests.map((request) => ({
const headerText = isStreaming name: request.name,
? `${count} action${count === 1 ? "" : "s"} running — ${toolName}` args: request.args,
: `${count} action${count === 1 ? "" : "s"} — last: ${toolName}`; }))
),
[actionRequests]
);
const pendingReviewDecisionsRef = useRef<Record<number, unknown>>({});
useEffect(() => {
pendingReviewDecisionsRef.current = {};
}, [actionRequestsKey]);
const handleResumeActionRequest = useCallback(
(actionIndex: number, value: any, nextReviewMode?: ReviewMode) => {
const decisions = value?.decisions;
if (!Array.isArray(decisions) || actionRequests.length === 0) {
onResumeInterrupt(value, nextReviewMode);
return;
}
if (
actionRequests.length === 1 ||
decisions.length === actionRequests.length
) {
onResumeInterrupt(value, nextReviewMode);
return;
}
const merged = mergeApprovalDecision(
actionRequests.length,
actionIndex,
decisions[0],
pendingReviewDecisionsRef.current,
nextReviewMode ? autoApprovesTools(nextReviewMode) : false
);
pendingReviewDecisionsRef.current = merged.pending;
if (!merged.decisions) return;
onResumeInterrupt({ decisions: merged.decisions }, nextReviewMode);
},
[actionRequests, onResumeInterrupt]
);
const toggleSubAgent = useCallback((id: string) => {
setExpandedSubAgents((current) => ({
...current,
[id]: !(current[id] ?? false),
}));
}, []);
const renderToolCall = useCallback(
(toolCall: ToolCall) => {
const actionRequestEntry = actionRequestByToolCallId.get(toolCall.id);
const subAgent = toSubAgent(toolCall);
if (subAgent && !actionRequestEntry) {
const expanded = expandedSubAgents[subAgent.id] ?? false;
const steps = subAgentSteps[subAgent.id] ?? [];
return (
<div
key={toolCall.id}
className="py-0.5"
>
<SubAgentIndicator
subAgent={subAgent}
onClick={() => toggleSubAgent(subAgent.id)}
isExpanded={expanded}
/>
{expanded && (
<div className="ml-2 mt-1 space-y-3 border-l border-border pl-3">
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
Input
</div>
<MarkdownContent
content={extractSubAgentContent(subAgent.input)}
className={compactMarkdownClass}
/>
</div>
{steps.length > 0 && (
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
Steps
</div>
<SubAgentSteps
steps={steps}
hideFinalText={!!subAgent.output}
compact
/>
</div>
)}
{subAgent.output && (
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
Output
</div>
<MarkdownContent
content={extractSubAgentContent(subAgent.output)}
className={compactMarkdownClass}
/>
</div>
)}
</div>
)}
</div>
);
}
const toolUiComponent = ui?.find(
(entry) => entry.metadata?.tool_call_id === toolCall.id
);
return (
<ToolCallBox
key={toolCall.id}
toolCall={toolCall}
uiComponent={toolUiComponent}
stream={stream}
graphId={graphId}
actionRequest={actionRequestEntry?.actionRequest}
actionRequestKey={actionRequestEntry ? toolCall.id : undefined}
actionRequestSubmitted={
actionRequestEntry
? submittedActionRequestKeys.has(toolCall.id)
: undefined
}
onActionRequestSubmitted={onActionRequestSubmitted}
reviewConfig={reviewConfigsMap?.get(toolCall.name)}
onResume={
actionRequestEntry
? (value, nextReviewMode) =>
handleResumeActionRequest(
actionRequestEntry.actionIndex,
value,
nextReviewMode
)
: onResumeInterrupt
}
isLoading={isLoading}
autoApprove={autoApprove}
compact
/>
);
},
[
actionRequestByToolCallId,
autoApprove,
expandedSubAgents,
graphId,
handleResumeActionRequest,
isLoading,
onActionRequestSubmitted,
onResumeInterrupt,
reviewConfigsMap,
stream,
subAgentSteps,
submittedActionRequestKeys,
toggleSubAgent,
ui,
]
);
const lastToolCall = summary.lastToolCall;
const lastToolLabel = lastToolCall
? formatToolLabel(lastToolCall.name, lastToolCall.args)
: "Working";
const lastToolPreview = lastToolCall
? toolArgumentPreview(lastToolCall.name, lastToolCall.args)
: "";
const actionNoun = summary.total === 1 ? "action" : "actions";
const primaryText = hasPendingApproval
? "Approval required"
: summary.failed > 0
? `${summary.failed} of ${summary.total} ${actionNoun} failed`
: isStreaming || summary.pending > 0 || summary.interrupted > 0
? `Working · ${summary.total} ${actionNoun}`
: `${summary.total} ${actionNoun} completed`;
const headerText = `${primaryText} · ${lastToolLabel}${
lastToolPreview ? ` · ${lastToolPreview}` : ""
}`;
return ( return (
<div className="my-2"> <section
className="my-2 overflow-hidden rounded-md border border-border bg-[var(--color-surface)]"
aria-label="Assistant work process"
>
<button <button
type="button" type="button"
aria-expanded={open} aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${headerText}`} aria-label={`${open ? "Collapse" : "Expand"} ${headerText}`}
title={headerText} title={headerText}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((current) => !current)}
className={cn( className="flex min-h-10 w-full items-center gap-2 px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
"group flex w-full items-center gap-2 rounded-md border border-border bg-[var(--color-surface)] px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
> >
<ChevronRight <ChevronRight
aria-hidden="true" aria-hidden="true"
@@ -139,91 +361,82 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
open && "rotate-90" open && "rotate-90"
)} )}
/> />
{isStreaming && ( {hasPendingApproval ? (
<ShieldAlert
aria-hidden="true"
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
) : summary.failed > 0 ? (
<AlertCircle
aria-hidden="true"
className="size-3.5 shrink-0 text-destructive"
/>
) : isStreaming || summary.pending > 0 || summary.interrupted > 0 ? (
<Loader2 <Loader2
aria-hidden="true" aria-hidden="true"
className="size-3.5 shrink-0 animate-spin text-[var(--brand)]" className="size-3.5 shrink-0 animate-spin text-[var(--brand)]"
/> />
) : (
<CheckCircle2
aria-hidden="true"
className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400"
/>
)} )}
<span className="truncate">{headerText}</span> <span className="shrink-0 font-medium text-foreground">
{primaryText}
</span>
<span aria-hidden="true">·</span>
<span className="min-w-0 flex-1 truncate">
{lastToolLabel}
{lastToolPreview ? ` · ${lastToolPreview}` : ""}
</span>
</button> </button>
{/* Collapsed approval preview — renders the single approval-bearing
message so the user can act without expanding the full timeline. {!open && hasPendingApproval && (
When open, this is empty and the same item renders inside the body. */} <div className="space-y-1 border-t border-border px-3 py-2">
{(() => { {pendingApprovalCalls.map(renderToolCall)}
if (open || !hasPendingApproval || lastMessageId === undefined) </div>
return null; )}
const previewItem = items.find((i) => i.message.id === lastMessageId);
if (!previewItem) return null;
const messageUi = ui?.filter(
(u) => u.metadata?.message_id === previewItem.message.id
);
return (
<div className="mt-2 space-y-2 border-l-2 border-border pl-3">
<ChatMessage
message={previewItem.message}
toolCalls={previewItem.toolCalls}
isLoading={isLoading}
isStreaming={isStreaming}
actionRequests={actionRequests}
submittedActionRequestKeys={submittedActionRequestKeys}
onActionRequestSubmitted={onActionRequestSubmitted}
reviewConfigsMap={reviewConfigsMap ?? undefined}
ui={messageUi}
stream={stream}
onResumeInterrupt={onResumeInterrupt}
graphId={graphId}
onEditMessage={onEditMessage}
autoApprove={autoApprove}
subAgentSteps={subAgentSteps}
/>
</div>
);
})()}
{open && ( {open && (
<div className="mt-2 space-y-2 border-l-2 border-border pl-3"> <div className="border-t border-border px-3 py-2">
{items.map((item) => { <div className="space-y-1 border-l border-border pl-3">
const isLastOverall = item.message.id === lastMessageId; {items.map((item, itemIndex) => {
const messageUi = ui?.filter( const content = extractStringFromMessageContent(item.message);
(u) => u.metadata?.message_id === item.message.id const showCompactionBefore =
); compactionAnchorId === item.message.id;
const showCompactionBefore = compactionAnchorId === item.message.id; const itemIsStreaming =
return ( isStreaming && item.message.id === lastMessageId;
<React.Fragment key={item.message.id}>
{showCompactionBefore && summarizationEvent && ( return (
<CompactionSummary <React.Fragment
content={summarizationEvent.content} key={item.message.id ?? `action-item-${itemIndex}`}
summarizedCount={summarizationEvent.cutoffIndex} >
/> {showCompactionBefore && summarizationEvent && (
)} <CompactionSummary
<ChatMessage content={summarizationEvent.content}
message={item.message} summarizedCount={summarizationEvent.cutoffIndex}
toolCalls={item.toolCalls} />
isLoading={isLoading} )}
isStreaming={isStreaming && isLastOverall} {content.trim() && (
actionRequests={isLastOverall ? actionRequests : undefined} <div className="px-2 py-1.5">
submittedActionRequestKeys={submittedActionRequestKeys} <StreamingMarkdown
onActionRequestSubmitted={onActionRequestSubmitted} content={content}
reviewConfigsMap={ isStreaming={itemIsStreaming}
isLastOverall ? reviewConfigsMap ?? undefined : undefined className={compactMarkdownClass}
} />
ui={messageUi} </div>
stream={stream} )}
onResumeInterrupt={onResumeInterrupt} {item.toolCalls.map(renderToolCall)}
graphId={graphId} </React.Fragment>
onEditMessage={onEditMessage} );
autoApprove={autoApprove} })}
subAgentSteps={subAgentSteps} </div>
/>
</React.Fragment>
);
})}
{/* Bottom collapse button — easy reach after scrolling through a long group. */}
<button <button
type="button" type="button"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="flex w-full items-center justify-center gap-1.5 rounded-md py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="mt-1 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-sm text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Collapse ${count} action${count === 1 ? "" : "s"}`} aria-label={`Collapse ${summary.total} ${actionNoun}`}
> >
<ChevronUp <ChevronUp
aria-hidden="true" aria-hidden="true"
@@ -233,6 +446,8 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
</button> </button>
</div> </div>
)} )}
</div> </section>
); );
}); });
ActionGroup.displayName = "ActionGroup";
+11 -9
View File
@@ -13,7 +13,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useQueryState } from "nuqs"; import { useQueryState } from "nuqs";
import { toast } from "sonner"; import { toast } from "sonner";
import { useClient } from "@/providers/ClientProvider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { extractStringFromMessageContent } from "@/app/utils/utils"; import { extractStringFromMessageContent } from "@/app/utils/utils";
import { import {
@@ -35,6 +34,10 @@ import {
type SubAgentStep, type SubAgentStep,
} from "@/lib/subAgentActivity"; } from "@/lib/subAgentActivity";
import { SubAgentSteps } from "@/app/components/SubAgentSteps"; import { SubAgentSteps } from "@/app/components/SubAgentSteps";
import {
getConversationAsyncTaskState,
runConversationAsyncTask,
} from "@/lib/conversationApi";
interface TaskDetail { interface TaskDetail {
loading: boolean; loading: boolean;
@@ -105,7 +108,6 @@ interface AgentsPanelProps {
} }
export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) { export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) {
const client = useClient();
const [threadId] = useQueryState("threadId"); const [threadId] = useQueryState("threadId");
const { tasks, loaded, error, refresh } = useAsyncAgents(threadId); const { tasks, loaded, error, refresh } = useAsyncAgents(threadId);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
@@ -149,9 +151,10 @@ export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) {
setChatBusy((b) => ({ ...b, [task.task_id]: true })); setChatBusy((b) => ({ ...b, [task.task_id]: true }));
setChatError((e) => ({ ...e, [task.task_id]: null })); setChatError((e) => ({ ...e, [task.task_id]: null }));
try { try {
const values = (await client.runs.wait(task.thread_id, task.agent_name, { if (!threadId) throw new Error("Open the conversation before contacting an agent.");
input: { messages: [{ type: "human", content: text }] }, const values = await runConversationAsyncTask(threadId, task.task_id, {
})) as { messages?: unknown[] } | null; messages: [{ type: "human", content: text }],
});
if (!mountedRef.current) return; if (!mountedRef.current) return;
const messages = Array.isArray(values?.messages) ? values.messages : []; const messages = Array.isArray(values?.messages) ? values.messages : [];
if (messages.length === 0) { if (messages.length === 0) {
@@ -268,9 +271,8 @@ export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) {
})); }));
(async () => { (async () => {
try { try {
const state = (await client.threads.getState(task.thread_id)) as { if (!threadId) throw new Error("Conversation is not selected.");
values?: { messages?: unknown[] }; const state = await getConversationAsyncTaskState(threadId, task.task_id);
};
if (cancelled) return; if (cancelled) return;
const { prompt, steps } = buildDetail(state.values?.messages ?? []); const { prompt, steps } = buildDetail(state.values?.messages ?? []);
if (!running) { if (!running) {
@@ -300,7 +302,7 @@ export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [expandedId, tasks, client]); }, [expandedId, tasks, threadId]);
const runningCount = useMemo(() => countRunning(tasks), [tasks]); const runningCount = useMemo(() => countRunning(tasks), [tasks]);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+32 -61
View File
@@ -10,6 +10,7 @@ import React, {
import { SubAgentIndicator } from "@/app/components/SubAgentIndicator"; import { SubAgentIndicator } from "@/app/components/SubAgentIndicator";
import { ToolCallBox } from "@/app/components/ToolCallBox"; import { ToolCallBox } from "@/app/components/ToolCallBox";
import { MarkdownContent } from "@/app/components/MarkdownContent"; import { MarkdownContent } from "@/app/components/MarkdownContent";
import { StreamingMarkdown } from "@/app/components/StreamingMarkdown";
import { SubAgentSteps } from "@/app/components/SubAgentSteps"; import { SubAgentSteps } from "@/app/components/SubAgentSteps";
import type { import type {
SubAgent, SubAgent,
@@ -27,6 +28,9 @@ import {
} from "@/app/utils/utils"; } from "@/app/utils/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { copyText } from "@/lib/clipboard"; import { copyText } from "@/lib/clipboard";
import { mapActionRequestsToToolCalls } from "@/lib/actionGrouping";
import { mergeApprovalDecision } from "@/lib/approvalDecision";
import { autoApprovesTools, type ReviewMode } from "@/lib/reviewMode";
import { toast } from "sonner"; import { toast } from "sonner";
interface ChatMessageProps { interface ChatMessageProps {
@@ -42,7 +46,7 @@ interface ChatMessageProps {
reviewConfigsMap?: Map<string, ReviewConfig>; reviewConfigsMap?: Map<string, ReviewConfig>;
ui?: any[]; ui?: any[];
stream?: any; stream?: any;
onResumeInterrupt?: (value: any) => void; onResumeInterrupt?: (value: any, nextReviewMode?: ReviewMode) => void;
graphId?: string; graphId?: string;
onEditMessage?: (content: string) => void; onEditMessage?: (content: string) => void;
autoApprove?: boolean; autoApprove?: boolean;
@@ -104,45 +108,10 @@ export const ChatMessage = React.memo<ChatMessageProps>(
}); });
}, [toolCalls]); }, [toolCalls]);
// Bind each pending approval request to the tool call it belongs to, keyed const actionRequestByToolCallId = useMemo(
// by tool-call id. Action requests carry no id, so match by (name, order of () => mapActionRequestsToToolCalls(toolCalls, actionRequests ?? []),
// appearance): walk this message's tool calls in order and hand out the [actionRequests, toolCalls]
// same-named requests in sequence. This makes two `execute` calls in one );
// turn each show their OWN args (a plain name→request map would collapse
// both onto the last request), while a tool that needs no approval simply
// consumes none.
const actionRequestByToolCallId = useMemo(() => {
const out = new Map<
string,
{ actionRequest: ActionRequest; actionIndex: number }
>();
if (!actionRequests || actionRequests.length === 0) return out;
const queues = new Map<
string,
{ actionRequest: ActionRequest; actionIndex: number }[]
>();
actionRequests.forEach((ar, actionIndex) => {
const list = queues.get(ar.name);
const entry = {
actionRequest: ar,
actionIndex,
};
if (list) list.push(entry);
else queues.set(ar.name, [entry]);
});
const cursor = new Map<string, number>();
for (const tc of toolCalls) {
if (tc.status !== "interrupted") continue;
const list = queues.get(tc.name);
if (!list) continue;
const i = cursor.get(tc.name) ?? 0;
if (i < list.length) {
out.set(tc.id, list[i]);
cursor.set(tc.name, i + 1);
}
}
return out;
}, [actionRequests, toolCalls]);
const actionRequestsKey = useMemo(() => { const actionRequestsKey = useMemo(() => {
return JSON.stringify( return JSON.stringify(
@@ -158,34 +127,35 @@ export const ChatMessage = React.memo<ChatMessageProps>(
}, [actionRequestsKey]); }, [actionRequestsKey]);
const handleResumeActionRequest = useCallback( const handleResumeActionRequest = useCallback(
(actionIndex: number, value: any) => { (actionIndex: number, value: any, nextReviewMode?: ReviewMode) => {
const decisions = value?.decisions; const decisions = value?.decisions;
if (!Array.isArray(decisions) || !actionRequests?.length) { if (!Array.isArray(decisions) || !actionRequests?.length) {
onResumeInterrupt?.(value); onResumeInterrupt?.(value, nextReviewMode);
return; return;
} }
if ( if (
actionRequests.length === 1 || actionRequests.length === 1 ||
decisions.length === actionRequests.length decisions.length === actionRequests.length
) { ) {
onResumeInterrupt?.(value); onResumeInterrupt?.(value, nextReviewMode);
return; return;
} }
const decision = decisions[0]; const merged = mergeApprovalDecision(
const next = { actionRequests.length,
...pendingReviewDecisionsRef.current, actionIndex,
[actionIndex]: decision, decisions[0],
}; pendingReviewDecisionsRef.current,
pendingReviewDecisionsRef.current = next; nextReviewMode ? autoApprovesTools(nextReviewMode) : false
);
const allDecided = actionRequests.every((_, index) => next[index]); pendingReviewDecisionsRef.current = merged.pending;
if (!allDecided) return; if (!merged.decisions) return;
onResumeInterrupt?.(
pendingReviewDecisionsRef.current = {}; {
onResumeInterrupt?.({ decisions: merged.decisions,
decisions: actionRequests.map((_, index) => next[index]), },
}); nextReviewMode
);
}, },
[actionRequests, onResumeInterrupt] [actionRequests, onResumeInterrupt]
); );
@@ -312,9 +282,9 @@ export const ChatMessage = React.memo<ChatMessageProps>(
{messageContent} {messageContent}
</p> </p>
) : hasContent ? ( ) : hasContent ? (
<MarkdownContent <StreamingMarkdown
content={messageContent} content={messageContent}
isStreaming={isStreaming} isStreaming={!!isStreaming}
/> />
) : null} ) : null}
</div> </div>
@@ -412,10 +382,11 @@ export const ChatMessage = React.memo<ChatMessageProps>(
reviewConfig={reviewConfig} reviewConfig={reviewConfig}
onResume={ onResume={
actionRequestEntry actionRequestEntry
? (value) => ? (value, nextReviewMode) =>
handleResumeActionRequest( handleResumeActionRequest(
actionRequestEntry.actionIndex, actionRequestEntry.actionIndex,
value value,
nextReviewMode
) )
: onResumeInterrupt : onResumeInterrupt
} }
+23 -125
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,156 +10,55 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { DEFAULT_ASSISTANT_ID, DeploymentConfig } from "@/lib/config";
import { useCollapseAgentActions } from "@/lib/uiSettings"; import { useCollapseAgentActions } from "@/lib/uiSettings";
import { ModelProvidersEditor } from "@/app/components/ModelProvidersEditor";
import { cn } from "@/lib/utils";
interface ConfigDialogProps { interface ConfigDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSave: (config: DeploymentConfig) => void;
initialConfig?: DeploymentConfig;
} }
export function ConfigDialog({ export function ConfigDialog({
open, open,
onOpenChange, onOpenChange,
onSave,
initialConfig,
}: ConfigDialogProps) { }: ConfigDialogProps) {
const [deploymentUrl, setDeploymentUrl] = useState( // The hook owns the UI-preference localStorage round-trip.
initialConfig?.deploymentUrl || "http://127.0.0.1:6174"
);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// UI preference: persisted independently of deployment config. The hook
// owns the localStorage round-trip; we read once and write on toggle.
const { value: collapseAgentActions, setValue: setCollapseAgentActions } = const { value: collapseAgentActions, setValue: setCollapseAgentActions } =
useCollapseAgentActions(); useCollapseAgentActions();
useEffect(() => {
if (!open) return;
setError(null);
if (initialConfig?.deploymentUrl) {
setDeploymentUrl(initialConfig.deploymentUrl);
return;
}
// First run (no saved config): prefill from the EvoScientist backend's
// configured port (config.yaml / env), instead of guessing the default.
fetch("/api/evosci-config")
.then((r) => r.json())
.then((d) => {
if (d?.deploymentUrl) setDeploymentUrl(d.deploymentUrl);
})
.catch(() => {
// Keep the hardcoded default already in state.
});
}, [open, initialConfig]);
const handleSave = () => { const handleSave = () => {
const url = deploymentUrl.trim();
if (!url) {
setError("Enter your deployment URL to continue.");
inputRef.current?.focus();
return;
}
try {
new URL(url);
} catch {
setError("Enter a valid URL, e.g. http://127.0.0.1:6174");
inputRef.current?.focus();
return;
}
onSave({
deploymentUrl: url,
// Fixed to the EvoScientist main agent (see DEFAULT_ASSISTANT_ID).
assistantId: DEFAULT_ASSISTANT_ID,
});
onOpenChange(false); onOpenChange(false);
}; };
return ( return (
<Dialog <Dialog open={open} onOpenChange={onOpenChange}>
open={open} <DialogContent className={cn("sm:max-w-5xl")}>
onOpenChange={onOpenChange}
>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Configuration</DialogTitle> <DialogTitle>Configuration</DialogTitle>
<DialogDescription> <DialogDescription>
The URL of your EvoScientist deployment. By default this is your Manage the model providers available to EvoScientist.
local deployment (detected automatically) — or a public URL from{" "}
<code>EvoSci deploy</code>. Saved in your browser&apos;s local
storage.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <ModelProvidersEditor />
<div className="grid gap-2"> <div className="flex items-start gap-2">
<Label htmlFor="deploymentUrl">Deployment URL</Label> <input
<Input id="collapseAgentActions"
ref={inputRef} type="checkbox"
id="deploymentUrl" checked={collapseAgentActions}
name="deploymentUrl" onChange={(e) => setCollapseAgentActions(e.target.checked)}
type="url" aria-label="Collapse agent actions by default"
inputMode="url" className="mt-1 size-4 rounded border-border accent-[var(--brand)]"
autoComplete="off" />
spellCheck={false} <Label
autoFocus htmlFor="collapseAgentActions"
placeholder="http://127.0.0.1:6174" className="text-sm font-normal leading-snug"
value={deploymentUrl} >
onChange={(e) => { Collapse agent actions by default
setDeploymentUrl(e.target.value); </Label>
if (error) setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing)
handleSave();
}}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "deploymentUrl-error" : undefined}
/>
{error && (
<p
id="deploymentUrl-error"
role="alert"
aria-live="polite"
className="text-sm text-destructive"
>
{error}
</p>
)}
</div>
<div className="flex items-start gap-2">
<input
id="collapseAgentActions"
type="checkbox"
checked={collapseAgentActions}
onChange={(e) => setCollapseAgentActions(e.target.checked)}
aria-label="Collapse agent actions by default"
aria-describedby="collapseAgentActions-description"
className="mt-1 size-4 rounded border-border accent-[var(--brand)]"
/>
<Label
htmlFor="collapseAgentActions"
className="text-sm font-normal leading-snug"
>
Collapse agent actions by default
<span
id="collapseAgentActions-description"
className="block text-xs text-muted-foreground"
>
Keeps tool-call sequences folded while running and after
completion. Approval controls remain visible.
</span>
</Label>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button variant="outline" onClick={() => onOpenChange(false)}>
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave}>Save</Button> <Button onClick={handleSave}>Save</Button>
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
export function DocxPreview({
document,
onError,
}: {
document: Blob;
onError: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
reportedRef.current = false;
setLoading(true);
const reportError = () => {
if (cancelled || reportedRef.current) return;
reportedRef.current = true;
onError();
};
const render = async () => {
if (!container) return;
container.replaceChildren();
try {
const { renderAsync } = await import("docx-preview");
if (cancelled) return;
await renderAsync(document, container, undefined, {
className: "docx-preview-document",
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
});
if (!cancelled) setLoading(false);
} catch {
container.replaceChildren();
reportError();
}
};
void render();
return () => {
cancelled = true;
container?.replaceChildren();
};
}, [document, onError]);
return (
<div className="relative h-full overflow-auto rounded-md border border-border bg-muted/30 p-4">
<div
ref={containerRef}
className="min-h-full w-max min-w-full"
/>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-[var(--color-surface)]">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
}
+26 -227
View File
@@ -5,269 +5,68 @@ import { Button } from "@/components/ui/button";
type HealthStatus = "checking" | "online" | "offline"; type HealthStatus = "checking" | "online" | "offline";
interface BackendInfo {
version?: string;
flags?: Record<string, boolean>;
}
interface HealthIndicatorProps {
deploymentUrl: string;
// Switch the app to a newly-detected backend URL (saves config + reconnects).
// When omitted, the "reconnect to detected backend" affordance is hidden.
onReconnect?: (url: string) => void;
}
const POLL_INTERVAL_MS = 10_000; const POLL_INTERVAL_MS = 10_000;
const REQUEST_TIMEOUT_MS = 4_000; const REQUEST_TIMEOUT_MS = 4_000;
const STATUS_META: Record<HealthStatus, { dot: string; label: string; pulse: boolean }> = {
// Theme tokens (light + dark defined in globals.css). The base's shadcn checking: { dot: "bg-[var(--color-warning)]", label: "Connecting...", pulse: true },
// primary/secondary background tokens are dead in this fork, so online: { dot: "bg-[var(--color-success)]", label: "Connected", pulse: false },
// we reference CSS vars directly via arbitrary-value classes (the entries below). offline: { dot: "bg-[var(--color-error)]", label: "Offline", pulse: false },
// NOTE: never put a bracketed class literal in a comment — Tailwind's content
// scanner picks it up and emits real (sometimes invalid) CSS.
const STATUS_META: Record<
HealthStatus,
{ dot: string; label: string; pulse: boolean }
> = {
checking: {
dot: "bg-[var(--color-warning)]",
label: "Connecting…",
pulse: true,
},
online: {
dot: "bg-[var(--color-success)]",
label: "Connected",
pulse: false,
},
offline: {
dot: "bg-[var(--color-error)]",
label: "Offline",
pulse: false,
},
}; };
/** Strip a trailing slash so two URLs that differ only by it compare equal. */ export function HealthIndicator() {
function normalizeUrl(url: string): string {
return url.trim().replace(/\/+$/, "");
}
/** Compact label for a URL, e.g. ":6174" or "host:8888", for a tight top bar. */
function shortUrlLabel(url: string): string {
try {
const u = new URL(url);
const isLocal =
u.hostname === "127.0.0.1" ||
u.hostname === "localhost" ||
u.hostname === "0.0.0.0";
const port = u.port || (u.protocol === "https:" ? "443" : "80");
return isLocal ? `:${port}` : `${u.hostname}:${port}`;
} catch {
return url;
}
}
/** Probe a backend's unauthenticated GET /info. Resolves true iff it answers OK. */
async function probeInfo(base: string): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(`${base}/info`, {
signal: controller.signal,
cache: "no-store",
});
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
/**
* Top-bar connection health light. Polls the backend's unauthenticated
* `GET /info` (a "simple" cross-origin GET — no custom headers, so no CORS
* preflight) and shows green/amber/red. Proves the UI can actually reach the
* langgraph backend, which is the single most confusing failure mode
* ("page opens but never replies"). Click to re-check on demand.
*
* Stale-URL recovery: when the saved deployment URL is unreachable, it re-probes
* the backend's currently-detected port via `/api/evosci-config`. If a DIFFERENT,
* reachable URL is found (the classic "EvoSci deploy moved ports" case), it
* surfaces a one-click "Reconnect" so the user doesn't have to hand-edit the URL
* in Settings. Polling pauses while the tab is hidden and resumes on return.
*/
export function HealthIndicator({
deploymentUrl,
onReconnect,
}: HealthIndicatorProps) {
const [status, setStatus] = useState<HealthStatus>("checking"); const [status, setStatus] = useState<HealthStatus>("checking");
const [info, setInfo] = useState<BackendInfo | null>(null);
// A different, reachable backend URL detected while the saved one is dead.
const [suggestedUrl, setSuggestedUrl] = useState<string | null>(null);
// Monotonic id so a slow check against a previous URL can't clobber a newer one.
const requestRef = useRef(0); const requestRef = useRef(0);
// Cancel the in-flight probe and suppress state updates after unmount.
const mountedRef = useRef(true); const mountedRef = useRef(true);
const controllerRef = useRef<AbortController | null>(null);
// When the saved URL is dead, ask our own /api/evosci-config what port the
// backend is actually configured on, then verify that alternate is reachable
// before offering it. requestId ties the result to the check that spawned it.
const findAlternate = useCallback(
async (requestId: number) => {
const current = normalizeUrl(deploymentUrl);
try {
const res = await fetch("/api/evosci-config", { cache: "no-store" });
const data = (await res.json().catch(() => null)) as {
deploymentUrl?: string;
} | null;
const detected = data?.deploymentUrl
? normalizeUrl(data.deploymentUrl)
: "";
if (requestId !== requestRef.current || !mountedRef.current) return;
// No alternate, or it's the same dead URL we already tried.
if (!detected || detected === current) {
setSuggestedUrl(null);
return;
}
const reachable = await probeInfo(detected);
if (requestId !== requestRef.current || !mountedRef.current) return;
setSuggestedUrl(reachable ? detected : null);
} catch {
if (requestId === requestRef.current && mountedRef.current) {
setSuggestedUrl(null);
}
}
},
[deploymentUrl]
);
const check = useCallback(async () => { const check = useCallback(async () => {
const requestId = ++requestRef.current; const requestId = ++requestRef.current;
const base = normalizeUrl(deploymentUrl);
if (!base) {
if (requestId === requestRef.current && mountedRef.current) {
setInfo(null);
setStatus("offline");
setSuggestedUrl(null);
}
return;
}
// Abort any probe still in flight before starting a new one.
controllerRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
controllerRef.current = controller;
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try { try {
const response = await fetch(`${base}/info`, { const response = await fetch("/api/deployment/assistant", {
signal: controller.signal, signal: controller.signal,
cache: "no-store", cache: "no-store",
}); });
if (requestId !== requestRef.current || !mountedRef.current) return; if (requestId === requestRef.current && mountedRef.current) {
if (!response.ok) { setStatus(response.ok ? "online" : "offline");
setStatus("offline");
void findAlternate(requestId);
return;
} }
const data = (await response
.json()
.catch(() => null)) as BackendInfo | null;
if (requestId !== requestRef.current || !mountedRef.current) return;
setInfo(data);
setStatus("online");
// Connected — drop any stale reconnect suggestion.
setSuggestedUrl(null);
} catch { } catch {
if (requestId === requestRef.current && mountedRef.current) { if (requestId === requestRef.current && mountedRef.current) {
setStatus("offline"); setStatus("offline");
void findAlternate(requestId);
} }
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
}, [deploymentUrl, findAlternate]); }, []);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
setStatus("checking"); void check();
check();
// Poll on an interval, but skip the network call while the tab is hidden —
// there's no point probing a backend the user isn't looking at.
const interval = setInterval(() => { const interval = setInterval(() => {
if (!document.hidden) check(); if (!document.hidden) void check();
}, POLL_INTERVAL_MS); }, POLL_INTERVAL_MS);
// Re-check when the tab regains focus/visibility or the network comes back,
// so a transient outage (or a pause while hidden) clears without a refresh.
const recheck = () => check();
const onVisible = () => {
if (!document.hidden) check();
};
window.addEventListener("focus", recheck);
window.addEventListener("online", recheck);
document.addEventListener("visibilitychange", onVisible);
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
controllerRef.current?.abort();
clearInterval(interval); clearInterval(interval);
window.removeEventListener("focus", recheck);
window.removeEventListener("online", recheck);
document.removeEventListener("visibilitychange", onVisible);
}; };
}, [check]); }, [check]);
const meta = STATUS_META[status]; const meta = STATUS_META[status];
const title = const title = status === "online" ? "Backend connected" : "Backend unavailable. Click to retry.";
status === "online"
? `Connected to ${deploymentUrl}${
info?.version ? ` · langgraph ${info.version}` : ""
}`
: status === "offline"
? `Can't reach the backend at ${deploymentUrl}. Is "EvoSci deploy" running? Click to retry.`
: `Checking connection to ${deploymentUrl}…`;
const showReconnect =
status === "offline" && suggestedUrl !== null && onReconnect !== undefined;
return ( return (
<div className="flex items-center gap-1"> <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" onClick={() => void check()}
onClick={check} title={title}
title={title} aria-label={title}
aria-label={title} className="h-8 gap-1.5 px-2 text-xs font-normal text-muted-foreground"
className="h-8 gap-1.5 px-2 text-xs font-normal text-muted-foreground" >
> <span
<span className={`size-2 shrink-0 rounded-full ${meta.dot} ${meta.pulse ? "animate-pulse" : ""}`}
className={`size-2 shrink-0 rounded-full ${meta.dot} ${ aria-hidden="true"
meta.pulse ? "animate-pulse" : "" />
}`} <span className="hidden sm:inline">{meta.label}</span>
aria-hidden="true" </Button>
/>
<span className="hidden sm:inline">{meta.label}</span>
</Button>
{showReconnect && (
<Button
variant="ghost"
size="sm"
onClick={() => {
const url = suggestedUrl;
if (!url) return;
setSuggestedUrl(null);
setStatus("checking");
onReconnect?.(url);
}}
title={`Backend detected at ${suggestedUrl}. Click to reconnect.`}
aria-label={`Reconnect to detected backend ${suggestedUrl}`}
className="h-8 gap-1 px-2 text-xs font-medium text-[var(--brand)] hover:text-[var(--brand)]"
>
Reconnect{" "}
<span className="tabular-nums">
{suggestedUrl ? shortUrlLabel(suggestedUrl) : ""}
</span>
</Button>
)}
</div>
); );
} }
@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { RegistryBuiltinProvidersEditor } from "@/app/components/RegistryBuiltinProvidersEditor";
import { ProviderProfilesEditor } from "@/app/components/ProviderProfilesEditor";
import { cn } from "@/lib/utils";
export function ModelProvidersEditor() {
const [source, setSource] = useState<"builtin" | "custom">("builtin");
return (
<div className="space-y-3">
<div
role="tablist"
aria-label="Model provider configuration sources"
className="flex border-b border-border"
>
{(
[
["builtin", "Built-in"],
["custom", "Custom"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={source === value}
onClick={() => setSource(value)}
className={cn(
"border-b-2 px-3 py-2 text-sm font-medium transition-colors",
source === value
? "border-[var(--brand)] text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{label}
</button>
))}
</div>
{source === "builtin" ? (
<RegistryBuiltinProvidersEditor />
) : (
<ProviderProfilesEditor />
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryState } from "nuqs";
import { import {
Network, Network,
Activity, Activity,
@@ -67,6 +68,7 @@ export function ResearchDashboard({
onNavigate, onNavigate,
onOpenThread, onOpenThread,
}: ResearchDashboardProps) { }: ResearchDashboardProps) {
const [threadId] = useQueryState("threadId");
const { data: threadPages } = useThreads({ limit: 100 }); const { data: threadPages } = useThreads({ limit: 100 });
const pinned = useMemo( const pinned = useMemo(
() => () =>
@@ -97,10 +99,16 @@ export function ResearchDashboard({
fetch("/api/memory/executions", { cache: "no-store" }).catch( fetch("/api/memory/executions", { cache: "no-store" }).catch(
() => null () => null
), ),
fetch("/api/workspace?recursive=1", { cache: "no-store" }).catch( threadId
() => null ? fetch(
), `/api/workspace?${new URLSearchParams({
listScheduledTasks().catch(() => []), threadId,
recursive: "1",
})}`,
{ cache: "no-store" }
).catch(() => null)
: Promise.resolve(null),
listScheduledTasks(threadId).catch(() => []),
]); ]);
const obs = obsRes ? await readJson<ObsGraphData>(obsRes) : null; const obs = obsRes ? await readJson<ObsGraphData>(obsRes) : null;
const exec = execRes const exec = execRes
@@ -151,7 +159,7 @@ export function ResearchDashboard({
if (reqRef.current === reqId) setReady(true); if (reqRef.current === reqId) setReady(true);
} }
})(); })();
}, []); }, [threadId]);
const hasStats = const hasStats =
ready && ready &&
+28
View File
@@ -0,0 +1,28 @@
"use client";
import React from "react";
import { useTypewriterText } from "@/app/hooks/useTypewriterText";
import { cn } from "@/lib/utils";
import { MarkdownContent } from "./MarkdownContent";
interface StreamingMarkdownProps {
content: string;
isStreaming: boolean;
className?: string;
}
export const StreamingMarkdown = React.memo<StreamingMarkdownProps>(
function StreamingMarkdown({ content, isStreaming, className }) {
const typed = useTypewriterText(content, isStreaming);
return (
<MarkdownContent
content={typed.text}
isStreaming={isStreaming || typed.isTyping}
className={cn(className, typed.isTyping && "typewriter-caret")}
/>
);
}
);
StreamingMarkdown.displayName = "StreamingMarkdown";
+10 -6
View File
@@ -41,7 +41,7 @@ import {
exportThread, exportThread,
} from "@/app/hooks/useThreads"; } from "@/app/hooks/useThreads";
import { useMemoryActivity } from "@/app/hooks/useMemoryActivity"; import { useMemoryActivity } from "@/app/hooks/useMemoryActivity";
import { getThreadAutoApprove } from "@/lib/autoApprove"; import { autoApprovesTools, getThreadReviewMode } from "@/lib/reviewMode";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -224,8 +224,9 @@ export function ThreadList({
// A thread belongs in "Requiring Attention" when its interrupt actually needs // A thread belongs in "Requiring Attention" when its interrupt actually needs
// the user. Single source of truth for both the bucket and the count badge. // the user. Single source of truth for both the bucket and the count badge.
// - `needsUserInput` (an `ask_user` interrupt is active) - auto-approve can // - `needsUserInput` (an `ask_user` interrupt is active) normally surfaces;
// NOT answer these, so the row must surface no matter what. // the open Full-approve thread is the exception because ChatInterface
// immediately resumes it with conservative assumptions.
// - A plain tool-approval interrupt that will NOT be auto-resolved: either // - A plain tool-approval interrupt that will NOT be auto-resolved: either
// auto-approve is off (the user has to approve it), OR this is not the // auto-approve is off (the user has to approve it), OR this is not the
// currently-open thread. The auto-resume effect lives only in the mounted // currently-open thread. The auto-resume effect lives only in the mounted
@@ -237,10 +238,13 @@ export function ThreadList({
// it really will resume a moment later. // it really will resume a moment later.
const needsAttention = useCallback( const needsAttention = useCallback(
(thread: ThreadItem): boolean => { (thread: ThreadItem): boolean => {
if (thread.needsUserInput) return true; const mode = getThreadReviewMode(thread.id);
const isCurrentThread = thread.id === currentThreadId;
if (thread.needsUserInput) {
return !(isCurrentThread && mode === "full");
}
if (thread.status !== "interrupted") return false; if (thread.status !== "interrupted") return false;
const willAutoResume = const willAutoResume = isCurrentThread && autoApprovesTools(mode);
thread.id === currentThreadId && getThreadAutoApprove(thread.id);
return !willAutoResume; return !willAutoResume;
}, },
[currentThreadId] [currentThreadId]
+46 -20
View File
@@ -4,14 +4,15 @@ import { useMemo, useState } from "react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { AlertCircle, Check, X, Pencil } from "lucide-react"; import { AlertCircle, Check, X, Pencil, ShieldCheck } from "lucide-react";
import type { ActionRequest, ReviewConfig } from "@/app/types/types"; import type { ActionRequest, ReviewConfig } from "@/app/types/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ReviewMode } from "@/lib/reviewMode";
interface ToolApprovalInterruptProps { interface ToolApprovalInterruptProps {
actionRequest: ActionRequest; actionRequest: ActionRequest;
reviewConfig?: ReviewConfig; reviewConfig?: ReviewConfig;
onResume: (value: any) => void; onResume: (value: any, nextReviewMode?: ReviewMode) => void;
isLoading?: boolean; isLoading?: boolean;
onSubmitted?: () => void; onSubmitted?: () => void;
} }
@@ -60,14 +61,14 @@ export function ToolApprovalInterrupt({
const allowedDecisions = reviewConfig?.allowedDecisions ?? const allowedDecisions = reviewConfig?.allowedDecisions ??
reviewConfig?.allowed_decisions ?? ["approve", "reject", "edit"]; reviewConfig?.allowed_decisions ?? ["approve", "reject", "edit"];
const submitDecision = (value: any) => { const submitDecision = (value: any, nextReviewMode?: ReviewMode) => {
flushSync(() => { flushSync(() => {
setSubmitted(true); setSubmitted(true);
if (onSubmitted) { if (onSubmitted) {
onSubmitted(); onSubmitted();
} }
}); });
onResume(value); onResume(value, nextReviewMode);
}; };
const handleApprove = () => { const handleApprove = () => {
@@ -76,6 +77,15 @@ export function ToolApprovalInterrupt({
}); });
}; };
const handleApproveAndAuto = () => {
submitDecision(
{
decisions: [{ type: "approve" }],
},
"auto"
);
};
const handleReject = () => { const handleReject = () => {
if (showRejectionInput) { if (showRejectionInput) {
submitDecision({ submitDecision({
@@ -325,22 +335,38 @@ export function ToolApprovalInterrupt({
</Button> </Button>
)} )}
{allowedDecisions.includes("approve") && ( {allowedDecisions.includes("approve") && (
<Button <>
type="button" <Button
size="sm" type="button"
onClick={handleApprove} size="sm"
disabled={isLoading} onClick={handleApprove}
className={cn( disabled={isLoading}
"bg-green-600 text-white hover:bg-green-700", className={cn(
"dark:bg-green-600 dark:hover:bg-green-700" "bg-green-600 text-white hover:bg-green-700",
)} "dark:bg-green-600 dark:hover:bg-green-700"
> )}
<Check >
size={14} <Check
aria-hidden="true" size={14}
/> aria-hidden="true"
{isLoading ? "Approving…" : "Approve"} />
</Button> {isLoading ? "Approving…" : "Approve once"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleApproveAndAuto}
disabled={isLoading}
className="border-amber-500/60 text-amber-800 hover:bg-amber-50 hover:text-amber-950 dark:text-amber-200 dark:hover:bg-amber-950/40 dark:hover:text-amber-100"
>
<ShieldCheck
size={14}
aria-hidden="true"
/>
Auto-approve this chat
</Button>
</>
)} )}
</> </>
)} )}
+19 -10
View File
@@ -21,7 +21,8 @@ import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui"; import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt"; import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
import { formatToolLabel } from "@/lib/toolLabel"; import { formatToolLabel, toolArgumentPreview } from "@/lib/toolLabel";
import type { ReviewMode } from "@/lib/reviewMode";
// One-line preview of a tool-call argument value, shown next to the key in // One-line preview of a tool-call argument value, shown next to the key in
// the collapsed args row. Strings have newlines collapsed; non-strings are // the collapsed args row. Strings have newlines collapsed; non-strings are
@@ -52,7 +53,7 @@ interface ToolCallBoxProps {
actionRequestSubmitted?: boolean; actionRequestSubmitted?: boolean;
onActionRequestSubmitted?: (key: string) => void; onActionRequestSubmitted?: (key: string) => void;
reviewConfig?: ReviewConfig; reviewConfig?: ReviewConfig;
onResume?: (value: any) => void; onResume?: (value: any, nextReviewMode?: ReviewMode) => void;
isLoading?: boolean; isLoading?: boolean;
autoApprove?: boolean; autoApprove?: boolean;
compact?: boolean; compact?: boolean;
@@ -119,16 +120,14 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
prevActionRequestRef.current = actionRequest; prevActionRequestRef.current = actionRequest;
}, [actionRequest, autoApprove]); }, [actionRequest, autoApprove]);
const { name, args, result, status } = useMemo(() => { const { name, argumentPreview, args, result, status } = useMemo(() => {
// Streaming can deliver args as a (possibly partial) JSON string, not an // Streaming can deliver args as a (possibly partial) JSON string, not an
// object — treat it as `unknown` and only expose a real object to the // object — treat it as `unknown` and only expose a real object to the
// args view, so Object.keys/entries never run on a string. // args view, so Object.keys/entries never run on a string.
const rawArgs: unknown = toolCall.args; const rawArgs: unknown = toolCall.args;
return { return {
// Targeted label overrides only (read_file/write_file/edit_file on
// /memories → "Reading memory"/"Updating memory", think_tool →
// "Reflection"); every other tool keeps its raw name.
name: formatToolLabel(toolCall.name, rawArgs), name: formatToolLabel(toolCall.name, rawArgs),
argumentPreview: toolArgumentPreview(toolCall.name, rawArgs),
args: args:
rawArgs && typeof rawArgs === "object" rawArgs && typeof rawArgs === "object"
? (rawArgs as Record<string, unknown>) ? (rawArgs as Record<string, unknown>)
@@ -141,7 +140,9 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
const statusIcon = useMemo(() => { const statusIcon = useMemo(() => {
switch (status) { switch (status) {
case "completed": case "completed":
return <CircleCheckBigIcon />; return (
<CircleCheckBigIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
);
case "error": case "error":
return ( return (
<AlertCircle <AlertCircle
@@ -212,16 +213,24 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
disabled={!hasContent} disabled={!hasContent}
> >
<div className="flex w-full items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{statusIcon} {statusIcon}
<span <span
className={cn( className={cn(
"text-[15px] font-medium tracking-[-0.6px] text-foreground", "min-w-0 truncate text-[15px] font-medium text-foreground",
compact && "text-xs tracking-normal" compact && "max-w-[50%] text-xs tracking-normal"
)} )}
> >
{name} {name}
</span> </span>
{compact && argumentPreview && (
<span
className="min-w-0 truncate text-xs font-normal text-muted-foreground"
title={argumentPreview}
>
{argumentPreview}
</span>
)}
</div> </div>
{hasContent && {hasContent &&
(isExpanded ? ( (isExpanded ? (
+382
View File
@@ -0,0 +1,382 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
ChevronDown,
LoaderCircle,
RefreshCw,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
formatTokenCount,
type UsageCall,
type UsageSourceStatus,
type UsageView,
usageCallTotal,
useUsageDetails,
} from "@/app/hooks/useUsage";
import { cn } from "@/lib/utils";
const VIEW_OPTIONS: Array<{ value: UsageView; label: string }> = [
{ value: "thread", label: "This chat" },
{ value: "workspace", label: "Workspace" },
{ value: "all", label: "All sources" },
];
const SCOPE_LABELS: Record<string, string> = {
main: "Main agent",
sync_subagent: "Sub-agent",
async_subagent: "Async agent",
tool_selector: "Tool selector",
summarizer: "Summarizer",
memory: "Memory",
scheduler: "Scheduler",
autoskills: "AutoSkills",
diagnostic: "Diagnostic",
skill_eval: "Skill evaluation",
unattributed: "Unattributed",
};
function shortId(value: string | null): string | null {
if (!value) return null;
return value.length > 10 ? `${value.slice(0, 8)}...` : value;
}
function callTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(date);
}
function CallToken({
label,
value,
unknown = false,
}: {
label: string;
value: string | null;
unknown?: boolean;
}) {
return (
<div className="min-w-0 text-right">
<div className="text-[10px] uppercase text-muted-foreground md:hidden">
{label}
</div>
<div
className={cn(
"mt-0.5 truncate font-mono text-xs tabular-nums md:mt-0 md:text-sm",
unknown && "font-sans text-muted-foreground"
)}
>
{unknown || value === null ? "Unknown" : formatTokenCount(value)}
</div>
</div>
);
}
function UsageCallRow({ call }: { call: UsageCall }) {
const total = usageCallTotal(call);
const unknown = call.usage_status === "unknown" || total === null;
const thread = shortId(call.thread_id);
const turn = shortId(call.turn_id);
const context = [
thread ? `Chat ${thread}` : null,
turn ? `Turn ${turn}` : null,
]
.filter(Boolean)
.join(" · ");
return (
<div
role="listitem"
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 border-b border-border/70 px-4 py-3 md:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)_6rem_6rem_6rem] md:items-center md:px-5"
>
<div className="min-w-0">
<div
className="truncate text-sm font-medium text-foreground"
title={call.upstream_model_id}
>
{call.model_alias || call.upstream_model_id}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{call.provider_profile_id} · {call.provider_adapter} ·{" "}
{call.usage_status === "confirmed" ? "Confirmed" : "Unknown"}
</div>
</div>
<div className="min-w-0 text-right md:text-left">
<div className="truncate text-xs font-medium md:text-sm">
{call.source_agent || SCOPE_LABELS[call.scope] || call.scope}
</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{SCOPE_LABELS[call.scope] || call.scope} ·{" "}
{callTime(call.completed_at)}
</div>
{context && (
<div
className="mt-0.5 hidden truncate text-[11px] text-muted-foreground md:block"
title={[call.thread_id, call.turn_id].filter(Boolean).join(" · ")}
>
{context}
</div>
)}
</div>
<div className="col-span-2 grid grid-cols-3 gap-3 border-t border-border/60 pt-2 md:col-span-1 md:contents md:border-0 md:pt-0">
<CallToken
label="Input"
value={call.input_tokens}
unknown={unknown}
/>
<CallToken
label="Output"
value={call.output_tokens}
unknown={unknown}
/>
<CallToken
label="Total"
value={total}
unknown={unknown}
/>
</div>
</div>
);
}
export function UsageDetailsDialog({
open,
onOpenChange,
threadId,
status,
statusLoading,
statusError,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
threadId: string | null;
status: UsageSourceStatus | undefined;
statusLoading: boolean;
statusError: unknown;
}) {
const [view, setView] = useState<UsageView>("thread");
useEffect(() => {
if (open) setView(threadId ? "thread" : "workspace");
}, [open, threadId]);
const unavailable =
Boolean(statusError) ||
!status ||
status.availability === "offline" ||
status.availability === "unavailable";
const details = useUsageDetails({
enabled: open && !unavailable,
view,
threadId,
status,
});
const description = useMemo(() => {
if (status?.availability === "degraded")
return "Model usage recorded during the last 7 days; statistics may be incomplete.";
if (unavailable) return "Statistics are unavailable for this backend.";
return "Model calls recorded during the last 7 days.";
}, [status?.availability, unavailable]);
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent
className={cn(
"gap-0 overflow-hidden p-0 sm:max-w-[min(56rem,calc(100vw-2rem))]",
unavailable
? "grid-rows-[auto_auto]"
: "h-[calc(100svh-1rem)] grid-rows-[auto_auto_auto_minmax(0,1fr)] sm:h-[min(44rem,calc(100svh-2rem))]"
)}
>
<DialogHeader className="border-b border-border px-4 py-4 pr-12 sm:px-5">
<DialogTitle>Token usage</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!unavailable && details.summary ? (
<div className="border-b border-border px-4 py-3 sm:px-5">
<div className="grid grid-cols-3 gap-3 text-center">
{[
["Input", details.summary.input_tokens],
["Output", details.summary.output_tokens],
["Total", details.summary.total_tokens],
].map(([label, value]) => (
<div key={label}>
<div className="text-[11px] text-muted-foreground">
{label}
</div>
<div className="mt-0.5 truncate font-mono text-sm font-semibold tabular-nums sm:text-base">
{formatTokenCount(value)}
</div>
</div>
))}
</div>
<div className="mt-2 flex items-center justify-center gap-4 text-xs text-muted-foreground">
<span>
{formatTokenCount(details.summary.confirmed_call_count)}{" "}
confirmed
</span>
<span>
{formatTokenCount(details.summary.unknown_call_count)} unknown
</span>
</div>
</div>
) : (
<div className="border-b border-border px-4 py-5 text-center text-sm text-muted-foreground">
{statusLoading || details.loading
? "Loading statistics..."
: "No compatible heartbeat"}
</div>
)}
{!unavailable && (
<div className="border-b border-border">
{status?.availability === "degraded" && (
<div className="flex items-start gap-2 border-b border-amber-300/60 bg-amber-50 px-4 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200 sm:px-5">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0">
Tracking incomplete · {formatTokenCount(status.spool_pending)}{" "}
pending · {formatTokenCount(status.spool_inflight)} in flight
· {formatTokenCount(status.spool_quarantined)} quarantined
{status.tracking_degraded_reason
? ` · ${status.tracking_degraded_reason}`
: ""}
</span>
</div>
)}
<div className="flex items-center justify-between gap-3 px-4 py-2 sm:px-5">
<div
role="group"
aria-label="Usage view"
className="flex min-w-0 items-center rounded-md border border-border bg-muted/40 p-0.5"
>
{VIEW_OPTIONS.map((option) => {
const disabled = option.value === "thread" && !threadId;
return (
<button
key={option.value}
type="button"
aria-pressed={view === option.value}
disabled={disabled}
onClick={() => setView(option.value)}
className={cn(
"min-w-0 rounded px-2 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40 sm:px-3",
view === option.value
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground"
)}
>
{option.label}
</button>
);
})}
</div>
<Button
type="button"
variant="ghost"
size="icon"
title="Refresh token usage"
aria-label="Refresh token usage"
disabled={details.refreshing}
onClick={() => void details.refresh()}
className="size-8"
>
<RefreshCw
className={cn(
"size-4",
details.refreshing &&
"animate-spin motion-reduce:animate-none"
)}
/>
</Button>
</div>
</div>
)}
{!unavailable && (
<div className="min-h-0">
{!unavailable && (
<div className="hidden grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)_6rem_6rem_6rem] border-b border-border bg-muted/30 px-5 py-2 text-[11px] font-medium text-muted-foreground md:grid">
<span>Model</span>
<span>Context</span>
<span className="text-right">Input</span>
<span className="text-right">Output</span>
<span className="text-right">Total</span>
</div>
)}
<ScrollArea className="h-full">
{details.error ? (
<div className="flex h-40 flex-col items-center justify-center gap-3 px-4 text-center text-sm text-muted-foreground">
<span>Couldn&apos;t load token usage.</span>
<Button
variant="outline"
size="sm"
onClick={() => void details.refresh()}
>
<RefreshCw /> Retry
</Button>
</div>
) : details.loading && details.items.length === 0 ? (
<div className="flex h-40 items-center justify-center text-muted-foreground">
<LoaderCircle className="size-5 animate-spin motion-reduce:animate-none" />
<span className="sr-only">Loading token calls</span>
</div>
) : !unavailable && details.items.length === 0 ? (
<div className="flex h-40 items-center justify-center px-4 text-center text-sm text-muted-foreground">
No model calls in this view.
</div>
) : (
<div
role="list"
aria-label="Model token usage calls"
>
{details.items.map((call) => (
<UsageCallRow
key={`${call.deployment_id}:${call.model_call_id}`}
call={call}
/>
))}
{details.hasMore && (
<div className="flex justify-center px-4 py-3">
<Button
variant="outline"
size="sm"
disabled={details.loadingMore}
onClick={details.loadMore}
>
{details.loadingMore ? (
<LoaderCircle className="animate-spin motion-reduce:animate-none" />
) : (
<ChevronDown />
)}
Load more
</Button>
</div>
)}
</div>
)}
</ScrollArea>
</div>
)}
</DialogContent>
</Dialog>
);
}
+441 -24
View File
@@ -1,6 +1,12 @@
"use client"; "use client";
import React, { useMemo, useState, useEffect, useRef } from "react"; import React, {
useMemo,
useState,
useEffect,
useRef,
useCallback,
} from "react";
import { import {
Download, Download,
Loader2, Loader2,
@@ -23,6 +29,7 @@ import { Button } from "@/components/ui/button";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { MarkdownContent } from "@/app/components/MarkdownContent"; import { MarkdownContent } from "@/app/components/MarkdownContent";
import { DocxPreview } from "@/app/components/DocxPreview";
const LANGUAGE_MAP: Record<string, string> = { const LANGUAGE_MAP: Record<string, string> = {
js: "javascript", js: "javascript",
@@ -60,6 +67,16 @@ const LANGUAGE_MAP: Record<string, string> = {
}; };
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]); const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]);
const WORD_EXTS = new Set(["doc", "docx", "docm", "dotx", "dotm"]);
const SPREADSHEET_EXTS = new Set([
"xls",
"xlsx",
"xlsm",
"xlsb",
"xltx",
"xltm",
"ods",
]);
// Extensions we render as text. Anything not here and not an image/pdf is // Extensions we render as text. Anything not here and not an image/pdf is
// treated as a binary download. // treated as a binary download.
const TEXT_EXTS = new Set([ const TEXT_EXTS = new Set([
@@ -78,32 +95,210 @@ const TEXT_EXTS = new Set([
// Inline text preview is capped — bigger files are offered as a download so we // Inline text preview is capped — bigger files are offered as a download so we
// never pull tens of MB into the browser just to render it. // never pull tens of MB into the browser just to render it.
const MAX_INLINE_TEXT_BYTES = 2 * 1024 * 1024; const MAX_INLINE_TEXT_BYTES = 2 * 1024 * 1024;
const MAX_OFFICE_PREVIEW_BYTES = 25 * 1024 * 1024;
const MAX_PREVIEW_SHEETS = 10;
const MAX_PREVIEW_ROWS = 1_000;
const MAX_PREVIEW_COLUMNS = 100;
export function workspaceFileUrl(path: string, download = false): string { export function workspaceFileUrl(
const qs = new URLSearchParams({ path }); threadId: string,
path: string,
download = false
): string {
const qs = new URLSearchParams({ threadId, path });
if (download) qs.set("download", "1"); if (download) qs.set("download", "1");
return `/api/workspace/file?${qs.toString()}`; return `/api/workspace/file?${qs.toString()}`;
} }
type Kind = "text" | "image" | "pdf" | "binary"; function workspacePreviewUrl(threadId: string, path: string): string {
return `/api/workspace/preview?${new URLSearchParams({
threadId,
path,
}).toString()}`;
}
type Kind = "text" | "image" | "pdf" | "word" | "spreadsheet" | "binary";
type SpreadsheetSheet = {
name: string;
rows: string[][];
truncated: boolean;
};
type OfficePreview =
| { kind: "pdf"; url: string }
| { kind: "docx"; document: Blob }
| {
kind: "spreadsheet";
sheets: SpreadsheetSheet[];
hasMoreSheets: boolean;
};
type OfficePreviewStatus =
| { status: "ready"; mode: "pdf"; previewUrl: string }
| { status: "processing"; retryAfterMs?: number }
| {
status: "fallback" | "failed";
reason: string;
message: string;
};
function kindOf(ext: string): Kind { function kindOf(ext: string): Kind {
if (IMAGE_EXTS.has(ext)) return "image"; if (IMAGE_EXTS.has(ext)) return "image";
if (ext === "pdf") return "pdf"; if (ext === "pdf") return "pdf";
if (WORD_EXTS.has(ext)) return "word";
if (SPREADSHEET_EXTS.has(ext)) return "spreadsheet";
if (TEXT_EXTS.has(ext)) return "text"; if (TEXT_EXTS.has(ext)) return "text";
return "binary"; return "binary";
} }
function spreadsheetCellText(value: unknown): string {
return value == null ? "" : String(value);
}
function previewSpreadsheet(
workbook: import("read-excel-file/browser").Sheet[]
): OfficePreview {
const visibleSheets = workbook.slice(0, MAX_PREVIEW_SHEETS);
const sheets = visibleSheets.map(({ sheet, data }) => {
const rows = data
.slice(0, MAX_PREVIEW_ROWS)
.map((row) =>
row
.slice(0, MAX_PREVIEW_COLUMNS)
.map((cell) => spreadsheetCellText(cell))
);
const hasExtraColumns = data.some(
(row) => row.length > MAX_PREVIEW_COLUMNS
);
return {
name: sheet,
rows,
truncated: data.length > MAX_PREVIEW_ROWS || hasExtraColumns,
};
});
return {
kind: "spreadsheet",
sheets,
hasMoreSheets: workbook.length > visibleSheets.length,
};
}
function directChildByName(
element: Element,
name: string
): Element | undefined {
return Array.from(element.children).find((child) => child.localName === name);
}
function normalizeInlineStringCells(
input: ArrayBuffer,
fflate: typeof import("fflate")
): Uint8Array | null {
const files = fflate.unzipSync(new Uint8Array(input));
let changed = false;
for (const [path, content] of Object.entries(files)) {
if (!/^xl\/worksheets\/[^/]+\.xml$/.test(path)) continue;
let sheetChanged = false;
const document = new DOMParser().parseFromString(
fflate.strFromU8(content),
"application/xml"
);
if (document.getElementsByTagName("parsererror").length > 0) {
throw new Error(`Could not parse worksheet XML: ${path}`);
}
const cells = Array.from(document.getElementsByTagName("*")).filter(
(element) =>
element.localName === "c" && element.getAttribute("t") === "inlineStr"
);
for (const cell of cells) {
if (directChildByName(cell, "is")) continue;
const text = Array.from(cell.getElementsByTagName("*")).reduce(
(value, element) =>
element.localName === "t"
? value + (element.textContent ?? "")
: value,
""
);
const value = directChildByName(cell, "v");
if (text) {
const inlineString = document.createElementNS(cell.namespaceURI, "is");
const textNode = document.createElementNS(cell.namespaceURI, "t");
textNode.textContent = text;
inlineString.append(textNode);
cell.replaceChildren(inlineString);
} else if (value) {
cell.setAttribute("t", "str");
} else {
cell.setAttribute("t", "z");
}
changed = true;
sheetChanged = true;
}
if (sheetChanged) {
files[path] = fflate.strToU8(
new XMLSerializer().serializeToString(document)
);
}
}
return changed ? fflate.zipSync(files) : null;
}
async function readWorkbook(arrayBuffer: ArrayBuffer) {
const { default: readXlsxFile } = await import("read-excel-file/browser");
try {
return await readXlsxFile(arrayBuffer);
} catch (error) {
if (!(error instanceof Error) || !/inline string/i.test(error.message)) {
throw error;
}
const fflate = await import("fflate");
const normalized = normalizeInlineStringCells(arrayBuffer, fflate);
if (!normalized) throw error;
const retriedBuffer = new ArrayBuffer(normalized.byteLength);
new Uint8Array(retriedBuffer).set(normalized);
return await readXlsxFile(retriedBuffer);
}
}
function spreadsheetColumnLabel(index: number): string {
let label = "";
for (let value = index + 1; value > 0; value = Math.floor((value - 1) / 26)) {
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
}
return label;
}
export const WorkspaceFileDialog = React.memo<{ export const WorkspaceFileDialog = React.memo<{
/** Path relative to the workspace root, or null to close. */ /** Path relative to the workspace root, or null to close. */
path: string | null; path: string | null;
/** Owning conversation. Workspace files are never global in strict mode. */
threadId: string | null;
/** Byte size from the listing — used to gate inline text preview. */ /** Byte size from the listing — used to gate inline text preview. */
size?: number; size?: number;
onClose: () => void; onClose: () => void;
/** Called after a successful save or delete so the listing can refresh. */ /** Called after a successful save or delete so the listing can refresh. */
onChanged?: () => void; onChanged?: () => void;
}>(({ path, size, onClose, onChanged }) => { }>(({ path, threadId, size, onClose, onChanged }) => {
const [content, setContent] = useState<string | null>(null); const [content, setContent] = useState<string | null>(null);
const [officePreview, setOfficePreview] = useState<OfficePreview | null>(
null
);
const [docxPreviewFallbackKey, setDocxPreviewFallbackKey] = useState<
string | null
>(null);
const [activeSheetIndex, setActiveSheetIndex] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -136,44 +331,158 @@ export const WorkspaceFileDialog = React.memo<{
const kind = kindOf(ext); const kind = kindOf(ext);
const tooBigForText = const tooBigForText =
kind === "text" && size != null && size > MAX_INLINE_TEXT_BYTES; kind === "text" && size != null && size > MAX_INLINE_TEXT_BYTES;
const supportsOfficePreview = kind === "word" || kind === "spreadsheet";
const supportsClientOfficeFallback = ext === "xlsx";
const tooBigForOffice =
supportsOfficePreview && size != null && size > MAX_OFFICE_PREVIEW_BYTES;
const editable = kind === "text" && !tooBigForText; const editable = kind === "text" && !tooBigForText;
const previewKey = `${threadId ?? ""}\0${path ?? ""}`;
const docxPreviewFallback = docxPreviewFallbackKey === previewKey;
const useDocxPreviewFallback = useCallback(() => {
setDocxPreviewFallbackKey(previewKey);
}, [previewKey]);
useEffect(() => { useEffect(() => {
if (!path || kind !== "text" || tooBigForText) { setDocxPreviewFallbackKey(null);
}, [path, threadId]);
useEffect(() => {
const loadText = kind === "text" && !tooBigForText;
const loadOffice = supportsOfficePreview;
if (!path || !threadId || (!loadText && !loadOffice)) {
setContent(null); setContent(null);
setOfficePreview(null);
setActiveSheetIndex(0);
return; return;
} }
let cancelled = false; let cancelled = false;
const controller = new AbortController();
setLoading(true); setLoading(true);
setError(null); setError(null);
setEditing(false); setEditing(false);
setActionError(null); setActionError(null);
fetch(workspaceFileUrl(path)) setContent(null);
.then(async (res) => { setOfficePreview(null);
setActiveSheetIndex(0);
const load = async () => {
if (loadText) {
const res = await fetch(workspaceFileUrl(threadId, path), {
signal: controller.signal,
});
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => null); const body = await res.json().catch(() => null);
throw new Error(body?.error || `Failed to load file (${res.status})`); throw new Error(body?.error || `Failed to load file (${res.status})`);
} }
return res.text(); const text = await res.text();
})
.then((text) => {
if (!cancelled) setContent(text); if (!cancelled) setContent(text);
}) return;
.catch((err) => { }
if (!cancelled) setError(err.message ?? "Failed to load file.");
if (tooBigForOffice) return;
if (ext === "docx" && !docxPreviewFallback) {
const res = await fetch(workspaceFileUrl(threadId, path), {
signal: controller.signal,
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error || `Failed to load file (${res.status})`);
}
const document = await res.blob();
if (!cancelled) setOfficePreview({ kind: "docx", document });
return;
}
let serverPreview: OfficePreviewStatus;
for (;;) {
const res = await fetch(workspacePreviewUrl(threadId, path), {
signal: controller.signal,
});
const body = (await res
.json()
.catch(() => null)) as OfficePreviewStatus | null;
if (
!body ||
(res.status !== 200 && res.status !== 202 && res.status !== 422)
) {
throw new Error(
(body as { error?: string } | null)?.error ||
`Failed to generate preview (${res.status})`
);
}
serverPreview = body;
if (serverPreview.status !== "processing") break;
const retryAfterMs = serverPreview.retryAfterMs ?? 1_000;
await new Promise((resolve) => setTimeout(resolve, retryAfterMs));
if (cancelled) return;
}
if (serverPreview.status === "ready") {
if (!cancelled)
setOfficePreview({ kind: "pdf", url: serverPreview.previewUrl });
return;
}
if (
serverPreview.status !== "fallback" ||
serverPreview.reason !== "converter_unavailable" ||
!supportsClientOfficeFallback
) {
throw new Error(serverPreview.message);
}
const res = await fetch(workspaceFileUrl(threadId, path), {
signal: controller.signal,
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error || `Failed to load file (${res.status})`);
}
const workbook = await readWorkbook(await res.arrayBuffer());
if (!cancelled) setOfficePreview(previewSpreadsheet(workbook));
};
void load()
.catch((err: unknown) => {
if (
!cancelled &&
!(err instanceof DOMException && err.name === "AbortError")
) {
setError(err instanceof Error ? err.message : "Failed to load file.");
}
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort();
}; };
}, [path, kind, tooBigForText]); }, [
path,
threadId,
kind,
ext,
supportsOfficePreview,
supportsClientOfficeFallback,
tooBigForText,
tooBigForOffice,
docxPreviewFallback,
]);
if (!path) return null; if (!path || !threadId) return null;
const isMarkdown = ext === "md" || ext === "markdown"; const isMarkdown = ext === "md" || ext === "markdown";
const language = LANGUAGE_MAP[ext] || "text"; const language = LANGUAGE_MAP[ext] || "text";
const activeSheet =
officePreview?.kind === "spreadsheet"
? officePreview.sheets[activeSheetIndex]
: null;
const activeSheetColumnCount = activeSheet
? activeSheet.rows.reduce((count, row) => Math.max(count, row.length), 0)
: 0;
const dirty = editing && content !== null && draft !== content; const dirty = editing && content !== null && draft !== content;
const runOrConfirmDiscard = (action: "close" | "cancel-edit") => { const runOrConfirmDiscard = (action: "close" | "cancel-edit") => {
@@ -199,7 +508,7 @@ export const WorkspaceFileDialog = React.memo<{
setSaving(true); setSaving(true);
setActionError(null); setActionError(null);
try { try {
const res = await fetch(workspaceFileUrl(path), { const res = await fetch(workspaceFileUrl(threadId, path), {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: draft }), body: JSON.stringify({ content: draft }),
@@ -223,7 +532,9 @@ export const WorkspaceFileDialog = React.memo<{
setDeleting(true); setDeleting(true);
setActionError(null); setActionError(null);
try { try {
const res = await fetch(workspaceFileUrl(path), { method: "DELETE" }); const res = await fetch(workspaceFileUrl(threadId, path), {
method: "DELETE",
});
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
throw new Error(data?.error || "Failed to delete."); throw new Error(data?.error || "Failed to delete.");
@@ -339,7 +650,7 @@ export const WorkspaceFileDialog = React.memo<{
asChild asChild
> >
<a <a
href={workspaceFileUrl(path, true)} href={workspaceFileUrl(threadId, path, true)}
download={name} download={name}
> >
<Download <Download
@@ -400,7 +711,7 @@ export const WorkspaceFileDialog = React.memo<{
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]"> <ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
<div className="flex items-center justify-center p-4"> <div className="flex items-center justify-center p-4">
<img <img
src={workspaceFileUrl(path)} src={workspaceFileUrl(threadId, path)}
alt={name} alt={name}
className="max-h-full max-w-full object-contain" className="max-h-full max-w-full object-contain"
/> />
@@ -408,15 +719,17 @@ export const WorkspaceFileDialog = React.memo<{
</ScrollArea> </ScrollArea>
) : kind === "pdf" ? ( ) : kind === "pdf" ? (
<iframe <iframe
src={workspaceFileUrl(path)} src={workspaceFileUrl(threadId, path)}
title={name} title={name}
className="h-full w-full rounded-md border border-border" className="h-full w-full rounded-md border border-border"
/> />
) : kind === "binary" || tooBigForText ? ( ) : kind === "binary" || tooBigForText || tooBigForOffice ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center"> <div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{tooBigForText {tooBigForText
? "This file is too large to preview inline." ? "This file is too large to preview inline."
: tooBigForOffice
? "Office files larger than 25 MB can't be previewed inline."
: "This file type can't be previewed."} : "This file type can't be previewed."}
</p> </p>
<Button <Button
@@ -425,7 +738,7 @@ export const WorkspaceFileDialog = React.memo<{
asChild asChild
> >
<a <a
href={workspaceFileUrl(path, true)} href={workspaceFileUrl(threadId, path, true)}
download={name} download={name}
> >
<Download <Download
@@ -445,8 +758,112 @@ export const WorkspaceFileDialog = React.memo<{
/> />
</div> </div>
) : error ? ( ) : error ? (
<div className="flex h-full items-center justify-center p-12"> <div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center">
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{error}</p>
<Button
variant="outline"
size="sm"
asChild
>
<a
href={workspaceFileUrl(threadId, path, true)}
download={name}
>
<Download
size={16}
className="mr-1"
aria-hidden="true"
/>
Download file
</a>
</Button>
</div>
) : officePreview?.kind === "pdf" ? (
<iframe
src={officePreview.url}
title={`${name} preview`}
className="h-full w-full rounded-md border border-border"
/>
) : officePreview?.kind === "docx" ? (
<DocxPreview
document={officePreview.document}
onError={useDocxPreviewFallback}
/>
) : officePreview?.kind === "spreadsheet" && activeSheet ? (
<div className="flex h-full flex-col overflow-hidden rounded-md border border-border bg-[var(--color-surface)]">
<div
className="flex shrink-0 overflow-x-auto border-b border-border px-2"
role="tablist"
aria-label="Workbook sheets"
>
{officePreview.sheets.map((sheet, index) => (
<button
key={sheet.name}
type="button"
role="tab"
aria-selected={index === activeSheetIndex}
className={`shrink-0 border-b-2 px-3 py-2 text-sm ${
index === activeSheetIndex
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveSheetIndex(index)}
>
{sheet.name}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-max border-collapse text-left text-sm">
<thead className="sticky top-0 z-10 bg-muted text-muted-foreground">
<tr>
<th className="sticky left-0 z-20 min-w-12 border-b border-r border-border bg-muted px-3 py-2 text-right font-medium">
#
</th>
{Array.from(
{ length: activeSheetColumnCount },
(_, index) => (
<th
key={index}
className="min-w-32 border-b border-r border-border px-3 py-2 font-medium"
>
{spreadsheetColumnLabel(index)}
</th>
)
)}
</tr>
</thead>
<tbody>
{activeSheet.rows.map((row, rowIndex) => (
<tr
key={rowIndex}
className="hover:bg-muted/50"
>
<th className="sticky left-0 z-10 border-b border-r border-border bg-[var(--color-surface)] px-3 py-2 text-right font-medium text-muted-foreground">
{rowIndex + 1}
</th>
{Array.from(
{ length: activeSheetColumnCount },
(_, columnIndex) => (
<td
key={columnIndex}
className="max-w-[28rem] whitespace-pre-wrap break-words border-b border-r border-border px-3 py-2 align-top"
>
{row[columnIndex]}
</td>
)
)}
</tr>
))}
</tbody>
</table>
</div>
{(activeSheet.truncated || officePreview.hasMoreSheets) && (
<p className="shrink-0 border-t border-border px-3 py-2 text-xs text-muted-foreground">
Preview is limited to the first {MAX_PREVIEW_SHEETS} sheets,{" "}
{MAX_PREVIEW_ROWS} rows, and {MAX_PREVIEW_COLUMNS} columns.
</p>
)}
</div> </div>
) : ( ) : (
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]"> <ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
+66 -28
View File
@@ -14,22 +14,30 @@ import {
Code2, Code2,
File as FileIcon, File as FileIcon,
} from "lucide-react"; } from "lucide-react";
import { useQueryState } from "nuqs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { WorkspaceFileDialog } from "@/app/components/WorkspaceFileDialog"; import { WorkspaceFileDialog } from "@/app/components/WorkspaceFileDialog";
import type { WorkspaceEntry } from "@/app/api/workspace/route"; import type { WorkspaceEntry } from "@/app/api/workspace/route";
async function listDir(path: string): Promise<WorkspaceEntry[]> { async function listDir(
const res = await fetch(`/api/workspace?${new URLSearchParams({ path })}`); threadId: string,
path: string
): Promise<WorkspaceEntry[]> {
const res = await fetch(
`/api/workspace?${new URLSearchParams({ threadId, path })}`
);
const body = await res.json().catch(() => null); const body = await res.json().catch(() => null);
if (!res.ok) throw new Error(body?.error || "Failed to list workspace."); if (!res.ok) throw new Error(body?.error || "Failed to list workspace.");
return (body?.entries ?? []) as WorkspaceEntry[]; return (body?.entries ?? []) as WorkspaceEntry[];
} }
async function listAll(): Promise<{ async function listAll(threadId: string): Promise<{
entries: WorkspaceEntry[]; entries: WorkspaceEntry[];
truncated: boolean; truncated: boolean;
}> { }> {
const res = await fetch("/api/workspace?recursive=1"); const res = await fetch(
`/api/workspace?${new URLSearchParams({ threadId, recursive: "1" })}`
);
const body = await res.json().catch(() => null); const body = await res.json().catch(() => null);
if (!res.ok) throw new Error(body?.error || "Failed to load workspace."); if (!res.ok) throw new Error(body?.error || "Failed to load workspace.");
return { return {
@@ -53,6 +61,9 @@ const CATEGORIES = [
"txt", "txt",
"docx", "docx",
"doc", "doc",
"docm",
"dotx",
"dotm",
"rtf", "rtf",
"odt", "odt",
], ],
@@ -85,6 +96,11 @@ const CATEGORIES = [
"tsv", "tsv",
"xlsx", "xlsx",
"xls", "xls",
"xlsm",
"xlsb",
"xltx",
"xltm",
"ods",
"parquet", "parquet",
"pkl", "pkl",
"npy", "npy",
@@ -136,6 +152,7 @@ for (const cat of CATEGORIES) {
type ViewMode = "tree" | "type"; type ViewMode = "tree" | "type";
export function WorkspacePanel() { export function WorkspacePanel() {
const [threadId] = useQueryState("threadId");
const [view, setView] = useState<ViewMode>("tree"); const [view, setView] = useState<ViewMode>("tree");
// --- Tree view state (listing cache keyed by dir path; "" = root) --- // --- Tree view state (listing cache keyed by dir path; "" = root) ---
@@ -157,31 +174,44 @@ export function WorkspacePanel() {
size: number; size: number;
} | null>(null); } | null>(null);
const loadDir = useCallback(async (path: string) => { const loadDir = useCallback(
setLoading((prev) => new Set(prev).add(path)); async (path: string) => {
try { if (!threadId) {
const entries = await listDir(path); setChildren({});
setChildren((prev) => ({ ...prev, [path]: entries })); setError("Start a conversation to use its workspace.");
if (path === "") setError(null); return [];
return entries;
} catch (err) {
if (path === "") {
setError(err instanceof Error ? err.message : "Failed to load.");
} }
throw err; setLoading((prev) => new Set(prev).add(path));
} finally { try {
setLoading((prev) => { const entries = await listDir(threadId, path);
const next = new Set(prev); setChildren((prev) => ({ ...prev, [path]: entries }));
next.delete(path); if (path === "") setError(null);
return next; return entries;
}); } catch (err) {
} if (path === "") {
}, []); setError(err instanceof Error ? err.message : "Failed to load.");
}
throw err;
} finally {
setLoading((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}
},
[threadId]
);
const loadAll = useCallback(async () => { const loadAll = useCallback(async () => {
if (!threadId) {
setAllFiles([]);
setError("Start a conversation to use its workspace.");
return;
}
setTypeLoading(true); setTypeLoading(true);
try { try {
const { entries, truncated } = await listAll(); const { entries, truncated } = await listAll(threadId);
setAllFiles(entries); setAllFiles(entries);
setTruncated(truncated); setTruncated(truncated);
setError(null); setError(null);
@@ -190,17 +220,20 @@ export function WorkspacePanel() {
} finally { } finally {
setTypeLoading(false); setTypeLoading(false);
} }
}, []); }, [threadId]);
// Initial tree load. loadDir surfaces root failures via `error`; catch the // Initial tree load. loadDir surfaces root failures via `error`; catch the
// rejection here so it doesn't become an unhandled promise rejection. // rejection here so it doesn't become an unhandled promise rejection.
useEffect(() => { useEffect(() => {
setChildren({});
setAllFiles(null);
setExpanded(new Set());
setSelected(null);
setRootLoading(true); setRootLoading(true);
void loadDir("") void loadDir("")
.catch(() => {}) .catch(() => {})
.finally(() => setRootLoading(false)); .finally(() => setRootLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps }, [threadId, loadDir]);
}, []);
// Load the flat listing the first time the by-type view is opened. // Load the flat listing the first time the by-type view is opened.
useEffect(() => { useEffect(() => {
@@ -389,7 +422,11 @@ export function WorkspacePanel() {
</div> </div>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<a <a
href="/api/workspace/download" href={
threadId
? `/api/workspace/download?${new URLSearchParams({ threadId })}`
: "#"
}
download download
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Download the whole workspace as a zip" title="Download the whole workspace as a zip"
@@ -434,6 +471,7 @@ export function WorkspacePanel() {
<WorkspaceFileDialog <WorkspaceFileDialog
path={selected?.path ?? null} path={selected?.path ?? null}
threadId={threadId}
size={selected?.size} size={selected?.size}
onClose={() => setSelected(null)} onClose={() => setSelected(null)}
onChanged={refresh} onChanged={refresh}
+28
View File
@@ -386,6 +386,34 @@ button {
} }
} }
@keyframes typewriterCaretBlink {
0%,
45% {
opacity: 1;
}
46%,
100% {
opacity: 0;
}
}
.typewriter-caret > :last-child::after {
content: "";
display: inline-block;
width: 2px;
height: 1em;
margin-left: 2px;
vertical-align: -0.12em;
background: currentColor;
animation: typewriterCaretBlink 0.9s steps(1, end) infinite;
}
@media (prefers-reduced-motion: reduce) {
.typewriter-caret > :last-child::after {
animation: none;
}
}
/* Custom scrollbar styles */ /* Custom scrollbar styles */
.scrollbar-pretty { .scrollbar-pretty {
overflow-y: scroll; overflow-y: scroll;
+4 -80
View File
@@ -1,27 +1,13 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import { import {
type EnrichedAsyncTask, type EnrichedAsyncTask,
parseAsyncTasks,
isTerminalStatus,
} from "@/lib/asyncAgents"; } from "@/lib/asyncAgents";
import { getConversationAsyncTasks } from "@/lib/conversationApi";
const DEFAULT_INTERVAL_MS = 3_000; const DEFAULT_INTERVAL_MS = 3_000;
/** The SDK throws HTTPError with a numeric `status` on non-2xx responses.
* 404 on runs.get means the task's thread/run no longer exists — the backend
* restores only main-graph threads across restarts, so sub-agent threads
* vanish while the conversation's async_tasks entries survive. */
function isNotFoundError(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
(err as { status?: unknown }).status === 404
);
}
interface UseAsyncAgentsResult { interface UseAsyncAgentsResult {
tasks: EnrichedAsyncTask[]; tasks: EnrichedAsyncTask[];
loaded: boolean; loaded: boolean;
@@ -46,23 +32,12 @@ export function useAsyncAgents(
): UseAsyncAgentsResult { ): UseAsyncAgentsResult {
const enabled = opts?.enabled ?? true; const enabled = opts?.enabled ?? true;
const intervalMs = opts?.intervalMs ?? DEFAULT_INTERVAL_MS; const intervalMs = opts?.intervalMs ?? DEFAULT_INTERVAL_MS;
const client = useClient();
const [tasks, setTasks] = useState<EnrichedAsyncTask[]>([]); const [tasks, setTasks] = useState<EnrichedAsyncTask[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Monotonic id so a slow poll can't overwrite a newer one (e.g. thread switch). // Monotonic id so a slow poll can't overwrite a newer one (e.g. thread switch).
const reqRef = useRef(0); const reqRef = useRef(0);
const mountedRef = useRef(true); const mountedRef = useRef(true);
// Runs that already 404'd. A 404 is permanent for a given thread/run (run
// registries are wiped on backend restart and never rebuilt), so remember
// them and skip re-polling — no point hitting the backend with a 404 per
// expired task every refresh cycle. Reset on thread/backend switch so a
// different deployment gets probed fresh.
const expiredRunsRef = useRef(new Set<string>());
useEffect(() => {
expiredRunsRef.current.clear();
}, [client, threadId]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!enabled || !threadId) { if (!enabled || !threadId) {
setTasks([]); setTasks([]);
@@ -71,59 +46,8 @@ export function useAsyncAgents(
} }
const reqId = ++reqRef.current; const reqId = ++reqRef.current;
try { try {
const state = (await client.threads.getState(threadId)) as { const enriched = (await getConversationAsyncTasks(threadId)).map(
values?: { async_tasks?: unknown }; (task): EnrichedAsyncTask => ({ ...task })
};
const base = parseAsyncTasks(state.values?.async_tasks);
// Resolve each task's REAL status from its own run, in parallel.
const enriched = await Promise.all(
base.map(async (t): Promise<EnrichedAsyncTask> => {
if (!t.run_id) {
return { ...t, liveStatus: t.status, startedAt: t.created_at };
}
const runKey = `${t.thread_id}:${t.run_id}`;
if (expiredRunsRef.current.has(runKey)) {
return {
...t,
liveStatus: "expired",
startedAt: t.created_at,
endedAt: t.last_updated_at ?? t.created_at,
};
}
try {
const run = (await client.runs.get(t.thread_id, t.run_id)) as {
status?: string;
created_at?: string;
updated_at?: string;
};
const liveStatus = run.status ?? t.status;
return {
...t,
liveStatus,
startedAt: run.created_at ?? t.created_at,
endedAt: isTerminalStatus(liveStatus)
? run.updated_at ?? t.last_updated_at
: undefined,
};
} catch (err) {
if (isNotFoundError(err)) {
// The run is provably gone. The cached state status would say
// "running" forever (it only refreshes when the main agent calls
// check_async_task) — surface "expired" instead: gray dot, no
// ticking timer, excluded from auto-report.
expiredRunsRef.current.add(runKey);
return {
...t,
liveStatus: "expired",
startedAt: t.created_at,
endedAt: t.last_updated_at ?? t.created_at,
};
}
// Transient failure (network blip, backend briefly down) — fall
// back to the cached state status.
return { ...t, liveStatus: t.status, startedAt: t.created_at };
}
})
); );
if (reqId !== reqRef.current || !mountedRef.current) return; if (reqId !== reqRef.current || !mountedRef.current) return;
setTasks(enriched); setTasks(enriched);
@@ -135,7 +59,7 @@ export function useAsyncAgents(
} finally { } finally {
if (reqId === reqRef.current && mountedRef.current) setLoaded(true); if (reqId === reqRef.current && mountedRef.current) setLoaded(true);
} }
}, [client, threadId, enabled]); }, [threadId, enabled]);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
+3 -1
View File
@@ -15,7 +15,9 @@ import {
export function useAutoNotify( export function useAutoNotify(
threadId: string | null threadId: string | null
): [boolean, (on: boolean) => void] { ): [boolean, (on: boolean) => void] {
const [on, setOn] = useState(() => getThreadAutoNotify(threadId)); // Keep the SSR and initial browser render deterministic; the persisted value
// is synchronized in the effect after hydration.
const [on, setOn] = useState(false);
useEffect(() => { useEffect(() => {
const sync = () => setOn(getThreadAutoNotify(threadId)); const sync = () => setOn(getThreadAutoNotify(threadId));
+33 -38
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getConfig } from "@/lib/config";
export interface ModelRegistryEntry { export interface ModelRegistryEntry {
/** Short name as the user types in `/model <name>`. */ /** Short name as the user types in `/model <name>`. */
@@ -15,9 +14,9 @@ export interface ModelRegistryEntry {
export interface ModelRegistry { export interface ModelRegistry {
entries: ReadonlyArray<ModelRegistryEntry>; entries: ReadonlyArray<ModelRegistryEntry>;
/** What `/model reset` would land on — the deployment-configured default. /** Effective default reported by the deployment. This is the persisted pair
* May be null when the backend can't resolve a default (older deployments * when available, otherwise the first enabled registry model. May be null
* without the endpoint, or a config that omitted the key). */ * when no model is configured or on older deployments. */
defaultEntry: { name: string; provider: string | null } | null; defaultEntry: { name: string; provider: string | null } | null;
} }
@@ -28,31 +27,26 @@ interface RegistryResponse {
const EMPTY: ModelRegistry = { entries: [], defaultEntry: null }; const EMPTY: ModelRegistry = { entries: [], defaultEntry: null };
// Module-level cache keyed by normalised deploymentUrl. The registry is static let cachedRegistry: Promise<ModelRegistry> | null = null;
// between deployment restarts — one network round-trip per URL per page load const MODELS_CHANGED_EVENT = "evoscientist-models-changed";
// is enough regardless of how many times ChatInterface mounts/unmounts.
// Failed fetches are evicted so the next mount retries.
const cache = new Map<string, Promise<ModelRegistry>>();
function fetchRegistry( export function invalidateAvailableModels(): void {
deploymentUrl: string, cachedRegistry = null;
apiKey: string if (typeof window !== "undefined") {
): Promise<ModelRegistry> { window.dispatchEvent(new Event(MODELS_CHANGED_EVENT));
const key = deploymentUrl.replace(/\/$/, ""); }
const hit = cache.get(key); }
if (hit) return hit;
const headers: Record<string, string> = {}; function fetchRegistry(): Promise<ModelRegistry> {
if (apiKey) headers["X-Api-Key"] = apiKey; if (cachedRegistry) return cachedRegistry;
const fetchJson = async (url: string): Promise<RegistryResponse> => { const fetchJson = async (url: string): Promise<RegistryResponse> => {
const r = await fetch(url, { headers }); const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`); if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()) as RegistryResponse; return (await r.json()) as RegistryResponse;
}; };
const p = fetchJson(`/api/models?deploymentUrl=${encodeURIComponent(key)}`) const pending = fetchJson("/api/models")
.catch(() => fetchJson(`${key}/api/models`))
.then(async (r) => { .then(async (r) => {
const entries: ModelRegistryEntry[] = []; const entries: ModelRegistryEntry[] = [];
if (Array.isArray(r.entries)) { if (Array.isArray(r.entries)) {
@@ -91,25 +85,25 @@ function fetchRegistry(
return { entries, defaultEntry } as ModelRegistry; return { entries, defaultEntry } as ModelRegistry;
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
cache.delete(key); cachedRegistry = null;
throw err; throw err;
}); });
cache.set(key, p); cachedRegistry = pending;
return p; return pending;
} }
/** /**
* Fetch the backend's authoritative model registry through the WebUI's * Fetch the backend's authoritative model registry through the WebUI's
* same-origin `/api/models` proxy first, falling back to * same-origin `/api/models` proxy first, falling back to
* `GET ${deploymentUrl}/api/models` for older/alternate deployments. Results * the same-origin model registry endpoint. Results
* are cached at module level — the registry is static between deployment * are cached at module level — the registry is static between deployment
* restarts, so remounting ChatInterface never triggers a redundant network * restarts, so remounting ChatInterface never triggers a redundant network
* request. * request.
* *
* Failures are non-fatal: the picker falls back to its curated * Failures are non-fatal but do not expose a hard-coded fallback list: the
* `COMMON_MODELS` list when `entries` is empty. Failed fetches are evicted * picker must only display models returned by the deployment configuration.
* from the cache so the next mount retries. * Failed fetches are evicted from the cache so the next mount retries.
*/ */
export function useAvailableModels(): { export function useAvailableModels(): {
registry: ModelRegistry; registry: ModelRegistry;
@@ -119,18 +113,19 @@ export function useAvailableModels(): {
const [registry, setRegistry] = useState<ModelRegistry>(EMPTY); const [registry, setRegistry] = useState<ModelRegistry>(EMPTY);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [refreshRevision, setRefreshRevision] = useState(0);
useEffect(() => { useEffect(() => {
const cfg = getConfig(); const refresh = () => setRefreshRevision((value) => value + 1);
if (!cfg) { window.addEventListener(MODELS_CHANGED_EVENT, refresh);
setLoading(false); return () => window.removeEventListener(MODELS_CHANGED_EVENT, refresh);
return; }, []);
}
let cancelled = false;
const apiKey =
cfg.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
fetchRegistry(cfg.deploymentUrl, apiKey) useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetchRegistry()
.then((result) => { .then((result) => {
if (cancelled) return; if (cancelled) return;
setRegistry(result); setRegistry(result);
@@ -148,7 +143,7 @@ export function useAvailableModels(): {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [refreshRevision]);
return { registry, loading, error }; return { registry, loading, error };
} }
+663 -141
View File
@@ -1,20 +1,50 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useStream } from "@langchain/langgraph-sdk/react";
import { type Message, type Assistant } from "@langchain/langgraph-sdk"; import { type Message, type Assistant } from "@langchain/langgraph-sdk";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
import type { TodoItem } from "@/app/types/types"; import type { TodoItem } from "@/app/types/types";
import { useClient } from "@/providers/ClientProvider";
import { useQueryState } from "nuqs"; import { useQueryState } from "nuqs";
import { parseSummarizationEvent } from "@/lib/summarization"; import { parseSummarizationEvent } from "@/lib/summarization";
import { findActiveTurnId } from "@/lib/usageTurn";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
MODEL_OVERRIDE_METADATA_KEY, MODEL_OVERRIDE_METADATA_KEY,
type ModelOverride, type ModelOverride,
} from "@/lib/modelCommand"; } from "@/lib/modelCommand";
import {
getNewChatModelPreference,
setNewChatModelPreference,
} from "@/lib/modelPreference";
import { setThreadModelOverride } from "@/app/hooks/useThreads"; import { setThreadModelOverride } from "@/app/hooks/useThreads";
import {
clearRunCursor,
getRunStreamCheckpoint,
isRunInProgress,
latestTurnId,
runRequestId,
runTurnId,
selectActiveRun,
setRunStreamCheckpoint,
type RecoverableRun,
} from "@/lib/runRecovery";
import { type ReviewMode } from "@/lib/reviewMode";
import {
StreamMessageAccumulator,
mergeStreamMessages,
type StreamMessageMetadata,
} from "@/lib/streamMessages";
import {
cancelConversationRun,
ConversationApiError,
createConversation,
createConversationRun,
getConversation,
getConversationRun,
joinConversationRunStream,
listConversationRuns,
putConversationFileState,
} from "@/lib/conversationApi";
export type StateType = { export type StateType = {
messages: Message[]; messages: Message[];
@@ -41,7 +71,7 @@ type InterruptLike = {
}; };
/** /**
* Sanitize a raw interrupt pulled from `client.threads.getState` before it is * Sanitize a raw interrupt returned by the conversation API before it is
* surfaced to the UI. The live SDK normalizes `stream.interrupt`, but the raw * surfaced to the UI. The live SDK normalizes `stream.interrupt`, but the raw
* persisted task interrupt is unvalidated — if its `value.action_requests` * persisted task interrupt is unvalidated — if its `value.action_requests`
* (or `review_configs`) is present but NOT an array, ChatInterface's * (or `review_configs`) is present but NOT an array, ChatInterface's
@@ -171,46 +201,62 @@ const EMPTY_FILES: Record<string, string> = {};
const EMPTY_ASYNC_TASKS: Record<string, unknown> = {}; const EMPTY_ASYNC_TASKS: Record<string, unknown> = {};
const EMPTY_SUB_AGENT_ACTIVITY: Record<string, never[]> = {}; const EMPTY_SUB_AGENT_ACTIVITY: Record<string, never[]> = {};
type TrackedRun = {
threadId: string;
runId: string;
turnId: string | null;
runRequestId: string | null;
};
type RunConnection = {
run: TrackedRun;
phase: "streaming" | "reconnecting";
};
export function useChat({ export function useChat({
activeAssistant, activeAssistant,
onHistoryRevalidate, onHistoryRevalidate,
thread,
}: { }: {
activeAssistant: Assistant | null; activeAssistant: Assistant | null;
onHistoryRevalidate?: () => void; onHistoryRevalidate?: () => void;
thread?: UseStreamThread<StateType>;
}) { }) {
const [threadId, setThreadId] = useQueryState("threadId"); const [threadId, setThreadId] = useQueryState("threadId");
const client = useClient(); void activeAssistant;
const [runConnection, setRunConnection] = useState<RunConnection | null>(
null
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [trackingRevision, setTrackingRevision] = useState(0);
const [recoveryRefreshVersion, setRecoveryRefreshVersion] = useState(0);
const lastErrorToastRef = useRef<{ key: string; at: number } | null>(null);
const activeRunRef = useRef<TrackedRun | null>(null);
const currentTurnIdRef = useRef<string | null>(null);
const currentTurnThreadIdRef = useRef<string | null>(null);
const resumeRequestIdsRef = useRef(new Map<string, string>());
const subscriptionControllerRef = useRef<AbortController | null>(null);
const stoppedRunIdsRef = useRef(new Set<string>());
const submittingRef = useRef(false);
const stream = useStream<StateType>({ const showError = useCallback((error: unknown, key: string) => {
assistantId: activeAssistant?.assistant_id || "", const message = formatStreamError(error);
client: client ?? undefined, const now = Date.now();
reconnectOnMount: false, if (
threadId: threadId ?? null, lastErrorToastRef.current?.key !== `${key}:${message}` ||
onThreadId: setThreadId, now - lastErrorToastRef.current.at > 10_000
defaultHeaders: { "x-auth-scheme": "langsmith" }, ) {
// Enable fetching state history when switching to existing threads lastErrorToastRef.current = { key: `${key}:${message}`, at: now };
fetchStateHistory: true, toast.error(message);
// Revalidate thread list when stream finishes, errors, or creates new }
// thread. Errors additionally surface a toast with the SDK's payload - }, []);
// without this the user only sees React's generic "An internal error
// occurred" and has to dig into the server log to learn that, e.g., a
// model provider returned a quota error.
onFinish: onHistoryRevalidate,
onError: (error) => {
onHistoryRevalidate?.();
toast.error(formatStreamError(error));
},
onCreated: onHistoryRevalidate,
experimental_thread: thread,
});
// Do not read `stream.values` or `stream.messages`. Both getters add a const isRunLoading = isSubmitting || runConnection !== null;
// high-frequency stream mode to the SDK request; each chunk then synchronously const isReconnecting = runConnection?.phase === "reconnecting";
// re-notifies React's external store. The UI refreshes from the persisted
// thread record below instead, while `isLoading` still drives run controls. // Do not attach message modes to the SDK hook's synchronous external store.
const liveInterrupt = stream.interrupt as InterruptLike | undefined; // The background subscriber below batches messages-tuple chunks before
// publishing them, while this hook remains responsible for history/interrupts.
const liveInterrupt: InterruptLike | undefined = undefined;
const isThreadLoading = false;
const liveInterruptKey = interruptValueKey(liveInterrupt); const liveInterruptKey = interruptValueKey(liveInterrupt);
// --- Resilient pending-state fallback ------------------------------------ // --- Resilient pending-state fallback ------------------------------------
@@ -245,6 +291,8 @@ export function useChat({
null null
); );
const [fetchedThreadId, setFetchedThreadId] = useState<string | null>(null); const [fetchedThreadId, setFetchedThreadId] = useState<string | null>(null);
const fetchedThreadIdRef = useRef<string | null>(null);
fetchedThreadIdRef.current = fetchedThreadId;
const recoveryRunRef = useRef(0); const recoveryRunRef = useRef(0);
// Per-thread model override. When set, gets folded into // Per-thread model override. When set, gets folded into
@@ -261,24 +309,30 @@ export function useChat({
// when `threadId` actually shows up. Without this, the user's first // when `threadId` actually shows up. Without this, the user's first
// message goes to the deployment default even after they picked a model // message goes to the deployment default even after they picked a model
// from the empty composer. // from the empty composer.
// localStorage is unavailable during SSR. Load the new-chat preference in
// the effect below so server and first client render stay identical.
const [modelOverride, setModelOverrideState] = useState<ModelOverride | null>( const [modelOverride, setModelOverrideState] = useState<ModelOverride | null>(
null null
); );
const pendingOverrideRef = useRef<ModelOverride | null>(null); const pendingOverrideRef = useRef<ModelOverride | null>(null);
const createdThreadIdRef = useRef<string | null>(null);
const ensureThreadPromiseRef = useRef<Promise<string> | null>(null);
useEffect(() => { useEffect(() => {
if (!threadId) { if (!threadId) {
// Don't clobber a pending pre-thread override — `buildRunConfig` still const remembered = getNewChatModelPreference();
// needs to read it for the first send. pendingOverrideRef.current = remembered;
if (!pendingOverrideRef.current) setModelOverrideState(null); createdThreadIdRef.current = null;
setModelOverrideState(remembered);
return; return;
} }
// Thread just came into existence (or we switched onto an existing one). // Thread just came into existence (or we switched onto an existing one).
// If we have a pending pre-thread override, write it through to metadata // If we have a pending pre-thread override, write it through to metadata
// and keep the local state as-is. Otherwise fetch the thread's persisted // and keep the local state as-is. Otherwise fetch the thread's persisted
// override and seed local state from it. // override and seed local state from it.
if (pendingOverrideRef.current) { if (createdThreadIdRef.current === threadId && pendingOverrideRef.current) {
const pending = pendingOverrideRef.current; const pending = pendingOverrideRef.current;
pendingOverrideRef.current = null; pendingOverrideRef.current = null;
createdThreadIdRef.current = null;
void (async () => { void (async () => {
try { try {
await setThreadModelOverride(threadId, pending); await setThreadModelOverride(threadId, pending);
@@ -289,12 +343,14 @@ export function useChat({
})(); })();
return; return;
} }
// Opening an existing thread from the New Chat screen must not write the
// remembered new-chat preference over that thread's own metadata.
pendingOverrideRef.current = null;
createdThreadIdRef.current = null;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
try { try {
const t = (await client.threads.get(threadId)) as { const { thread: t } = await getConversation(threadId);
metadata?: Record<string, unknown>;
};
if (cancelled) return; if (cancelled) return;
const raw = (t.metadata ?? {})[MODEL_OVERRIDE_METADATA_KEY]; const raw = (t.metadata ?? {})[MODEL_OVERRIDE_METADATA_KEY];
if ( if (
@@ -313,14 +369,23 @@ export function useChat({
} else { } else {
setModelOverrideState(null); setModelOverrideState(null);
} }
} catch { } catch (error) {
if (!cancelled) setModelOverrideState(null); if (cancelled) return;
setModelOverrideState(null);
if (error instanceof ConversationApiError && error.status === 404) {
activeRunRef.current = null;
setRunConnection(null);
void setThreadId(null);
toast.error(
"Conversation is no longer available. Started a new chat."
);
}
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [client, threadId]); }, [setThreadId, threadId]);
// Persist + apply locally. When the thread row exists, writes metadata // Persist + apply locally. When the thread row exists, writes metadata
// first so a reload keeps the choice. Pre-thread (new chat with no // first so a reload keeps the choice. Pre-thread (new chat with no
@@ -330,6 +395,7 @@ export function useChat({
const setModelOverride = useCallback( const setModelOverride = useCallback(
async (next: ModelOverride | null) => { async (next: ModelOverride | null) => {
setModelOverrideState(next); setModelOverrideState(next);
setNewChatModelPreference(next);
if (!threadId) { if (!threadId) {
pendingOverrideRef.current = next; pendingOverrideRef.current = next;
return; return;
@@ -340,7 +406,10 @@ export function useChat({
[threadId] [threadId]
); );
const liveMessages = EMPTY_MESSAGES; const liveMessages =
fetchedThreadId === threadId
? fetchedMessages ?? EMPTY_MESSAGES
: EMPTY_MESSAGES;
const liveMessageCount = liveMessages.length; const liveMessageCount = liveMessages.length;
useEffect(() => { useEffect(() => {
@@ -352,15 +421,14 @@ export function useChat({
setResolvedInterruptKey(null); setResolvedInterruptKey(null);
return; return;
} }
if (stream.isLoading) { if (isRunLoading) {
recoveryRunRef.current += 1; recoveryRunRef.current += 1;
setFetchedInterrupt(undefined); setFetchedInterrupt(undefined);
setResolvedInterruptKey(null); setResolvedInterruptKey(null);
return; return;
} }
// Updates-only mode intentionally leaves the live message array empty. The // The persisted record replaces the temporary chunk-assembled messages once
// persisted thread record is therefore the source of truth for the rendered // the run settles, and remains the source of truth for history/interrupts.
// conversation once the SDK settles.
const baseline = liveMessageCount; const baseline = liveMessageCount;
const recoveryRunId = ++recoveryRunRef.current; const recoveryRunId = ++recoveryRunRef.current;
let cancelled = false; let cancelled = false;
@@ -376,18 +444,9 @@ export function useChat({
// the full message history. We need both: state for run status // the full message history. We need both: state for run status
// (`next` / `tasks` / `interrupts`), record for the messages the UI // (`next` / `tasks` / `interrupts`), record for the messages the UI
// displays. Done in parallel to keep the round trip tight. // displays. Done in parallel to keep the round trip tight.
const [state, threadRecord] = await Promise.all([ const { state, thread: threadRecord } = await getConversation(threadId);
client.threads.getState(threadId) as Promise<{
tasks?: Array<{ interrupts?: unknown[] }>;
next?: unknown[];
values?: { messages?: Message[] };
}>,
client.threads.get(threadId) as Promise<{
values?: Partial<StateType>;
}>,
]);
if (cancelled || recoveryRunRef.current !== recoveryRunId) return; if (cancelled || recoveryRunRef.current !== recoveryRunId) return;
const values = threadRecord.values; const values = threadRecord.values as Partial<StateType> | undefined;
const msgs = values?.messages; const msgs = values?.messages;
const pending = latestTaskInterrupt(state.tasks); const pending = latestTaskInterrupt(state.tasks);
const stillPending = Array.isArray(state.next) && state.next.length > 0; const stillPending = Array.isArray(state.next) && state.next.length > 0;
@@ -396,9 +455,7 @@ export function useChat({
// Tool-approval interrupt reached — surface it and its matching message // Tool-approval interrupt reached — surface it and its matching message
// snapshot together. Mixing live messages with fetched interrupts is the // snapshot together. Mixing live messages with fetched interrupts is the
// race that hides approval cards for repeated execute calls. // race that hides approval cards for repeated execute calls.
setFetchedInterrupt( setFetchedInterrupt(safePending as InterruptLike);
safePending as InterruptLike
);
setResolvedInterruptKey(null); setResolvedInterruptKey(null);
if (Array.isArray(msgs)) { if (Array.isArray(msgs)) {
setFetchedThreadId(threadId); setFetchedThreadId(threadId);
@@ -447,14 +504,21 @@ export function useChat({
cancelled = true; cancelled = true;
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
}; };
// Precise deps on purpose: re-running on the whole `stream` object (new each // State is read from the BFF snapshot, so this effect never exposes a direct
// render) would loop the getState fetch. // LangGraph client or deployment credential to the browser.
}, [threadId, liveInterruptKey, stream.isLoading, client, liveMessageCount]); }, [
threadId,
liveInterruptKey,
isRunLoading,
liveMessageCount,
recoveryRefreshVersion,
]);
// Show the live interrupt unless it's the exact one the server told us was // Show the live interrupt unless it's the exact one the server told us was
// resolved (then fall through to the fetched one, usually undefined → composer // resolved (then fall through to the fetched one, usually undefined → composer
// unlocks). A new live interrupt has a different key, so it's never suppressed. // unlocks). A new live interrupt has a different key, so it's never suppressed.
const interrupt = const interrupt =
!isRunLoading &&
liveInterrupt && liveInterrupt &&
(resolvedInterruptKey === null || (resolvedInterruptKey === null ||
interruptValueKey(liveInterrupt) !== resolvedInterruptKey) interruptValueKey(liveInterrupt) !== resolvedInterruptKey)
@@ -478,7 +542,7 @@ export function useChat({
const messages = (() => { const messages = (() => {
if (!fetchedMessages || fetchedThreadId !== threadId) return liveMessages; if (!fetchedMessages || fetchedThreadId !== threadId) return liveMessages;
if (fetchedInterrupt) return fetchedMessages; if (fetchedInterrupt) return fetchedMessages;
if (!stream.isLoading) return fetchedMessages; if (!isRunLoading) return fetchedMessages;
if (fetchedMessages.length > liveMessages.length) return fetchedMessages; if (fetchedMessages.length > liveMessages.length) return fetchedMessages;
if ( if (
fetchedMessages.length === liveMessages.length && fetchedMessages.length === liveMessages.length &&
@@ -489,93 +553,549 @@ export function useChat({
return liveMessages; return liveMessages;
})(); })();
// Fold the per-thread model override into the assistant's base config. The const messagesRef = useRef(messages);
// backend reads `configurable.model` + `configurable.model_provider` per messagesRef.current = messages;
// request. We always send a `configurable` object (possibly empty) so the
// override leaves no trace on runs that don't need it.
const buildRunConfig = useCallback(() => {
const base = activeAssistant?.config ?? {};
const baseConfigurable =
(base as { configurable?: Record<string, unknown> }).configurable ?? {};
const configurable: Record<string, unknown> = { ...baseConfigurable };
if (modelOverride) {
configurable.model = modelOverride.model;
if (modelOverride.model_provider) {
configurable.model_provider = modelOverride.model_provider;
}
}
return { ...base, configurable, recursion_limit: 100 };
}, [activeAssistant?.config, modelOverride]);
const streamRef = useRef(stream);
streamRef.current = stream;
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);
threadIdRef.current = threadId; threadIdRef.current = threadId;
const buildRunConfigRef = useRef(buildRunConfig);
buildRunConfigRef.current = buildRunConfig;
const onHistoryRevalidateRef = useRef(onHistoryRevalidate); const onHistoryRevalidateRef = useRef(onHistoryRevalidate);
onHistoryRevalidateRef.current = onHistoryRevalidate; onHistoryRevalidateRef.current = onHistoryRevalidate;
const sendMessage = useCallback((content: string) => { const ensureThread = useCallback(async (): Promise<string> => {
// Keep the persisted snapshot visible and append the user message locally; const existingThreadId = threadIdRef.current;
// the next settled thread-record refresh replaces this optimistic snapshot. if (existingThreadId) return existingThreadId;
setFetchedInterrupt(undefined); if (ensureThreadPromiseRef.current) return ensureThreadPromiseRef.current;
setFetchedThreadId(threadIdRef.current);
setResolvedInterruptKey(null); const creation = (async () => {
recoveryRunRef.current += 1; const created = await createConversation();
const newMessage: Message = { id: uuidv4(), type: "human", content }; const createdThreadId = created.threadId;
setFetchedMessages((current) => [ createdThreadIdRef.current = createdThreadId;
...(current ?? EMPTY_MESSAGES), threadIdRef.current = createdThreadId;
newMessage, setFetchedThreadId(createdThreadId);
]); await setThreadId(createdThreadId);
streamRef.current.submit( return createdThreadId;
{ messages: [newMessage] }, })();
{ ensureThreadPromiseRef.current = creation;
optimisticValues: (prev) => ({ try {
messages: [...(prev.messages ?? []), newMessage], return await creation;
}), } finally {
config: buildRunConfigRef.current(), if (ensureThreadPromiseRef.current === creation) {
streamSubgraphs: false, ensureThreadPromiseRef.current = null;
streamMode: ["updates"],
streamResumable: false,
onDisconnect: "cancel",
} }
); }
// Update thread list immediately when sending a message }, [setThreadId]);
onHistoryRevalidateRef.current?.();
}, []); // Discover active runs from the backend, then subscribe independently. The
// run survives because it was created through `runs.create`; aborting this
// GET stream only drops the subscription. The checkpoint stores both the SSE
// cursor and assembled partial messages so a reload can resume without gaps.
useEffect(() => {
if (!threadId) {
subscriptionControllerRef.current?.abort();
subscriptionControllerRef.current = null;
activeRunRef.current = null;
setRunConnection(null);
return;
}
if (activeRunRef.current && activeRunRef.current.threadId !== threadId) {
activeRunRef.current = null;
setRunConnection(null);
}
if (currentTurnThreadIdRef.current !== threadId) {
currentTurnIdRef.current = null;
currentTurnThreadIdRef.current = threadId;
resumeRequestIdsRef.current.clear();
}
let disposed = false;
let wakeRetry: (() => void) | null = null;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let failedAttempts = 0;
let knownRun =
activeRunRef.current?.threadId === threadId ? activeRunRef.current : null;
const waitForRetry = (delay: number) =>
new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
if (retryTimer) clearTimeout(retryTimer);
retryTimer = undefined;
wakeRetry = null;
resolve();
};
wakeRetry = finish;
retryTimer = setTimeout(finish, delay);
});
const rememberRun = (
run: RecoverableRun,
phase: RunConnection["phase"]
) => {
const tracked: TrackedRun = {
threadId,
runId: run.run_id,
turnId: runTurnId(run) ?? currentTurnIdRef.current,
runRequestId: runRequestId(run),
};
knownRun = tracked;
activeRunRef.current = tracked;
if (tracked.turnId) {
currentTurnIdRef.current = tracked.turnId;
currentTurnThreadIdRef.current = threadId;
}
setRunConnection({ run: tracked, phase });
return tracked;
};
const clearTrackedRun = (runId: string) => {
if (activeRunRef.current?.runId === runId) {
activeRunRef.current = null;
}
setRunConnection((current) =>
current?.run.runId === runId ? null : current
);
clearRunCursor(threadId, runId);
setRecoveryRefreshVersion((version) => version + 1);
onHistoryRevalidateRef.current?.();
};
const listRuns = async () =>
(await listConversationRuns(threadId)) as RecoverableRun[];
const publishStreamMessages = (streamed: Message[]) => {
if (streamed.length === 0 || disposed) return;
const sameThread = fetchedThreadIdRef.current === threadId;
fetchedThreadIdRef.current = threadId;
setFetchedThreadId(threadId);
setFetchedMessages((current) =>
mergeStreamMessages(
sameThread ? current ?? EMPTY_MESSAGES : EMPTY_MESSAGES,
streamed
)
);
};
const seedPersistedThread = async () => {
try {
const { thread: threadRecord } = await getConversation(threadId);
if (disposed) return;
const values = threadRecord.values as Partial<StateType> | undefined;
const persisted = values?.messages;
if (Array.isArray(persisted)) {
const sameThread = fetchedThreadIdRef.current === threadId;
fetchedThreadIdRef.current = threadId;
setFetchedThreadId(threadId);
setFetchedMessages((current) =>
mergeStreamMessages(
persisted,
sameThread ? current ?? EMPTY_MESSAGES : EMPTY_MESSAGES
)
);
}
setFetchedValues(values ?? null);
} catch {
// The resumable SSE stream can still reconstruct the active response.
}
};
const followRuns = async () => {
while (!disposed) {
let runs: RecoverableRun[];
try {
runs = await listRuns();
if (disposed) return;
} catch {
if (knownRun) {
setRunConnection({ run: knownRun, phase: "reconnecting" });
}
failedAttempts += 1;
await waitForRetry(
Math.min(1_000 * 2 ** Math.min(failedAttempts, 3), 10_000)
);
continue;
}
const discoveredTurnId = latestTurnId(runs);
if (discoveredTurnId) {
currentTurnIdRef.current = discoveredTurnId;
currentTurnThreadIdRef.current = threadId;
}
for (const run of runs) {
if (!isRunInProgress(run.status)) {
clearRunCursor(threadId, run.run_id);
}
}
let candidate = selectActiveRun(runs);
if (!candidate && knownRun) {
try {
const current = await getConversationRun(threadId, knownRun.runId);
if (isRunInProgress(current.status)) {
candidate = current as RecoverableRun;
}
} catch {
// The next list refresh is authoritative.
}
}
if (!candidate) {
if (knownRun) clearTrackedRun(knownRun.runId);
return;
}
if (stoppedRunIdsRef.current.has(candidate.run_id)) return;
const tracked = rememberRun(candidate, "reconnecting");
await seedPersistedThread();
if (disposed) return;
const controller = new AbortController();
subscriptionControllerRef.current?.abort();
subscriptionControllerRef.current = controller;
let streamFailure: unknown;
const checkpoint = getRunStreamCheckpoint(threadId, tracked.runId);
const messageAccumulator = new StreamMessageAccumulator(
checkpoint?.messages
);
if (checkpoint?.messages.length) {
publishStreamMessages(checkpoint.messages);
}
let latestEventId = checkpoint?.lastEventId ?? "-1";
let streamDirty = false;
let frameId: number | undefined;
let flushTimer: ReturnType<typeof setTimeout> | undefined;
let checkpointTimer: ReturnType<typeof setTimeout> | undefined;
let lastCheckpointAt = 0;
const persistStreamCheckpoint = () => {
if (latestEventId === "-1") return;
lastCheckpointAt = Date.now();
setRunStreamCheckpoint(threadId, tracked.runId, {
lastEventId: latestEventId,
messages: messageAccumulator.snapshot(),
});
};
const flushStreamMessages = (forceCheckpoint = false) => {
if (frameId !== undefined) cancelAnimationFrame(frameId);
if (flushTimer !== undefined) clearTimeout(flushTimer);
frameId = undefined;
flushTimer = undefined;
if (streamDirty) {
streamDirty = false;
publishStreamMessages(messageAccumulator.snapshot());
}
if (checkpointTimer !== undefined && forceCheckpoint) {
clearTimeout(checkpointTimer);
checkpointTimer = undefined;
}
const checkpointDelay = 500 - (Date.now() - lastCheckpointAt);
if (forceCheckpoint || checkpointDelay <= 0) {
persistStreamCheckpoint();
} else if (checkpointTimer === undefined) {
checkpointTimer = setTimeout(() => {
checkpointTimer = undefined;
persistStreamCheckpoint();
}, checkpointDelay);
}
};
const scheduleStreamFlush = () => {
streamDirty = true;
frameId ??= requestAnimationFrame(() => flushStreamMessages());
flushTimer ??= setTimeout(flushStreamMessages, 100);
};
try {
setRunConnection({ run: tracked, phase: "streaming" });
for await (const event of joinConversationRunStream(
threadId,
tracked.runId,
{
signal: controller.signal,
lastEventId: latestEventId,
}
)) {
if (event.id) latestEventId = event.id;
if (
event.event === "messages" &&
Array.isArray(event.data) &&
event.data.length >= 2
) {
const metadata =
event.data[1] && typeof event.data[1] === "object"
? (event.data[1] as StreamMessageMetadata)
: undefined;
messageAccumulator.add(event.data[0], metadata);
}
scheduleStreamFlush();
if (event.event === "error") streamFailure = event.data;
}
} catch (error) {
if (!(error instanceof Error && error.name === "AbortError")) {
streamFailure = error;
}
} finally {
flushStreamMessages(true);
if (subscriptionControllerRef.current === controller) {
subscriptionControllerRef.current = null;
}
}
if (disposed || stoppedRunIdsRef.current.has(tracked.runId)) return;
try {
const completed = await getConversationRun(threadId, tracked.runId);
if (isRunInProgress(completed.status)) {
knownRun = tracked;
setRunConnection({ run: tracked, phase: "reconnecting" });
failedAttempts += 1;
await waitForRetry(
Math.min(1_000 * 2 ** Math.min(failedAttempts, 3), 10_000)
);
continue;
}
clearTrackedRun(tracked.runId);
knownRun = null;
failedAttempts = 0;
if (completed.status === "error" || completed.status === "timeout") {
showError(
streamFailure ?? `Run ended with status ${completed.status}.`,
tracked.runId
);
}
// A queued run may already be waiting behind the one that completed.
continue;
} catch {
knownRun = tracked;
setRunConnection({ run: tracked, phase: "reconnecting" });
failedAttempts += 1;
await waitForRetry(
Math.min(1_000 * 2 ** Math.min(failedAttempts, 3), 10_000)
);
}
}
};
const recoverWhenOnline = () => {
failedAttempts = 0;
wakeRetry?.();
};
window.addEventListener("online", recoverWhenOnline);
void followRuns();
return () => {
disposed = true;
subscriptionControllerRef.current?.abort();
subscriptionControllerRef.current = null;
if (retryTimer) clearTimeout(retryTimer);
wakeRetry?.();
window.removeEventListener("online", recoverWhenOnline);
};
}, [showError, threadId, trackingRevision]);
// A closed tab recovers on mount via run discovery. An already-open tab also
// rechecks when it regains focus, covering runs started from another tab.
useEffect(() => {
if (!threadId) return;
const rediscover = () => setTrackingRevision((version) => version + 1);
const onVisibilityChange = () => {
if (document.visibilityState === "visible") rediscover();
};
window.addEventListener("focus", rediscover);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
window.removeEventListener("focus", rediscover);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [threadId]);
const startBackgroundRun = useCallback(
async ({
input,
command,
turnId,
runRequestId: requestId,
interruptKey,
reviewMode,
optimisticMessageId,
}: {
input?: Record<string, unknown> | null;
command?: { resume: unknown };
turnId: string;
runRequestId: string;
interruptKey?: string | null;
reviewMode: ReviewMode;
optimisticMessageId?: string;
}) => {
if (submittingRef.current || activeRunRef.current) return;
submittingRef.current = true;
setIsSubmitting(true);
let usableThreadId = threadIdRef.current;
try {
if (!usableThreadId) {
usableThreadId = await ensureThread();
}
currentTurnIdRef.current = turnId;
currentTurnThreadIdRef.current = usableThreadId;
const createdRun = await createConversationRun(usableThreadId, {
turn_id: turnId,
run_request_id: requestId,
...(interruptKey ? { interrupt_key: interruptKey } : {}),
input,
command,
review_mode: reviewMode,
model_override: modelOverride,
});
const tracked: TrackedRun = {
threadId: usableThreadId,
runId: createdRun.runId,
turnId,
runRequestId: createdRun.runRequestId,
};
activeRunRef.current = tracked;
setRunConnection({ run: tracked, phase: "reconnecting" });
setTrackingRevision((version) => version + 1);
onHistoryRevalidateRef.current?.();
} catch (error) {
// If the POST response was lost after the backend created the run, find
// it by this concrete request id. A logical turn can legitimately have
// several runs while approvals are being resolved.
let recovered = false;
if (usableThreadId) {
try {
const runs = (await listConversationRuns(
usableThreadId
)) as RecoverableRun[];
const matching = runs.find(
(run) => runRequestId(run) === requestId
);
if (matching) {
recovered = true;
if (isRunInProgress(matching.status)) {
const tracked: TrackedRun = {
threadId: usableThreadId,
runId: matching.run_id,
turnId,
runRequestId: requestId,
};
activeRunRef.current = tracked;
setRunConnection({ run: tracked, phase: "reconnecting" });
setTrackingRevision((version) => version + 1);
} else {
setRecoveryRefreshVersion((version) => version + 1);
}
}
} catch {
// Surface the original creation failure below.
}
}
if (!recovered) {
if (optimisticMessageId) {
setFetchedMessages(
(current) =>
current?.filter(
(message) => message.id !== optimisticMessageId
) ?? null
);
}
showError(error, `create:${requestId}`);
}
onHistoryRevalidateRef.current?.();
} finally {
submittingRef.current = false;
setIsSubmitting(false);
}
},
[ensureThread, modelOverride, showError]
);
const sendMessage = useCallback(
(content: string, reviewMode: ReviewMode) => {
// Keep the persisted snapshot visible and append the user message locally;
// the next settled thread-record refresh replaces this optimistic snapshot.
setFetchedInterrupt(undefined);
setFetchedThreadId(threadIdRef.current);
setResolvedInterruptKey(null);
recoveryRunRef.current += 1;
const newMessage: Message = { id: uuidv4(), type: "human", content };
setFetchedMessages((current) => [
...(current ?? EMPTY_MESSAGES),
newMessage,
]);
onHistoryRevalidateRef.current?.();
void startBackgroundRun({
input: { messages: [newMessage] },
turnId: newMessage.id!,
runRequestId: uuidv4(),
reviewMode,
optimisticMessageId: newMessage.id,
});
},
[startBackgroundRun]
);
const setFiles = useCallback( const setFiles = useCallback(
async (files: Record<string, string>) => { async (files: Record<string, string>) => {
if (!threadId) return; if (!threadId) return;
// TODO: missing a way how to revalidate the internal state await putConversationFileState(threadId, files);
// I think we do want to have the ability to externally manage the state
await client.threads.updateState(threadId, { values: { files } });
}, },
[client, threadId] [threadId]
); );
const resumeInterrupt = useCallback((value: any) => { const resumeInterrupt = useCallback(
// Keep the transcript snapshot while the approval resumes; the next thread (value: any, reviewMode: ReviewMode, interruptKey?: string | null) => {
// record refresh replaces it with the completed turn. // A resume is a new backend run but remains part of the same logical turn.
setFetchedInterrupt(undefined); setFetchedInterrupt(undefined);
setResolvedInterruptKey(null); setResolvedInterruptKey(null);
recoveryRunRef.current += 1; recoveryRunRef.current += 1;
streamRef.current.submit(null, { const turnId =
command: { resume: value }, (currentTurnThreadIdRef.current === threadIdRef.current
config: buildRunConfigRef.current(), ? currentTurnIdRef.current
streamSubgraphs: false, : null) ??
streamMode: ["updates"], findActiveTurnId(messagesRef.current) ??
streamResumable: false, uuidv4();
onDisconnect: "cancel", const requestId =
}); interruptKey && resumeRequestIdsRef.current.has(interruptKey)
// Update thread list when resuming from interrupt ? resumeRequestIdsRef.current.get(interruptKey)!
onHistoryRevalidateRef.current?.(); : uuidv4();
}, []); if (interruptKey) {
resumeRequestIdsRef.current.set(interruptKey, requestId);
}
onHistoryRevalidateRef.current?.();
void startBackgroundRun({
input: null,
command: { resume: value },
turnId,
runRequestId: requestId,
interruptKey,
reviewMode,
});
},
[startBackgroundRun]
);
const stopStream = useCallback(() => { const stopStream = useCallback(() => {
streamRef.current.stop(); const active = activeRunRef.current;
}, []); if (!active) return;
stoppedRunIdsRef.current.add(active.runId);
subscriptionControllerRef.current?.abort();
subscriptionControllerRef.current = null;
activeRunRef.current = null;
setRunConnection(null);
clearRunCursor(active.threadId, active.runId);
setRecoveryRefreshVersion((version) => version + 1);
void cancelConversationRun(active.threadId, active.runId)
.then(() => stoppedRunIdsRef.current.delete(active.runId))
.catch((error) => {
stoppedRunIdsRef.current.delete(active.runId);
showError(error, `cancel:${active.runId}`);
setTrackingRevision((version) => version + 1);
})
.finally(() => onHistoryRevalidateRef.current?.());
}, [showError]);
return { return {
todos: fetchedValues?.todos ?? EMPTY_TODOS, todos: fetchedValues?.todos ?? EMPTY_TODOS,
@@ -588,9 +1108,11 @@ export function useChat({
ui: fetchedValues?.ui, ui: fetchedValues?.ui,
setFiles, setFiles,
messages, messages,
isLoading: stream.isLoading, isLoading: isRunLoading,
isThreadLoading: stream.isThreadLoading, isReconnecting,
isThreadLoading,
interrupt, interrupt,
ensureThread,
sendMessage, sendMessage,
stopStream, stopStream,
resumeInterrupt, resumeInterrupt,
+54 -72
View File
@@ -1,23 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { Cron } from "@langchain/langgraph-sdk"; import { useQueryState } from "nuqs";
import { Client } from "@langchain/langgraph-sdk";
import { getConfig } from "@/lib/config";
const SCHEDULED_RUN_KIND = "scheduled_task";
const SCHEDULER_GRAPH_ID = "scheduler";
function makeClient(): Client | null {
const config = getConfig();
if (!config) return null;
const apiKey =
config.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
return new Client({
apiUrl: config.deploymentUrl,
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
});
}
export interface ScheduledTask { export interface ScheduledTask {
cron_id: string; cron_id: string;
@@ -29,57 +13,55 @@ export interface ScheduledTask {
updated_at: string; updated_at: string;
} }
function parseCron(cron: Cron): ScheduledTask { function resolvedThreadId(threadId?: string | null): string {
const meta = (cron.metadata ?? {}) as Record<string, unknown>; if (threadId) return threadId;
return { if (typeof window === "undefined") return "";
cron_id: cron.cron_id, return new URLSearchParams(window.location.search).get("threadId") ?? "";
name:
typeof meta.name === "string" && meta.name ? meta.name : "Unnamed Task",
prompt: typeof meta.prompt === "string" ? meta.prompt : "",
schedule: cron.schedule,
next_run_date: cron.next_run_date ?? null,
created_at: cron.created_at,
updated_at: cron.updated_at,
};
} }
export async function listScheduledTasks(): Promise<ScheduledTask[]> { async function readJson<T>(response: Response): Promise<T> {
const client = makeClient(); const body = (await response.json().catch(() => null)) as { error?: string } | null;
if (!client) return []; if (!response.ok) throw new Error(body?.error || "Scheduled task request failed.");
// The TS SDK's crons.search doesn't support metadata filtering — fetch all return body as T;
// and filter client-side. Only EvoScientist crons carry run_kind in metadata. }
const crons = await client.crons.search({ limit: 200 });
return crons export async function listScheduledTasks(threadId?: string | null): Promise<ScheduledTask[]> {
.filter( const currentThreadId = resolvedThreadId(threadId);
(c) => if (!currentThreadId) return [];
(c.metadata as Record<string, unknown>)?.run_kind === SCHEDULED_RUN_KIND const result = await readJson<{ tasks?: ScheduledTask[] }>(
) await fetch(`/api/scheduled-tasks?${new URLSearchParams({ threadId: currentThreadId })}`, {
.map(parseCron); cache: "no-store",
})
);
return Array.isArray(result.tasks) ? result.tasks : [];
} }
export async function createScheduledTask(params: { export async function createScheduledTask(params: {
name: string; name: string;
prompt: string; prompt: string;
schedule: string; schedule: string;
threadId?: string | null;
}): Promise<ScheduledTask> { }): Promise<ScheduledTask> {
const client = makeClient(); const threadId = resolvedThreadId(params.threadId);
if (!client) throw new Error("No EvoScientist deployment configured."); if (!threadId) throw new Error("Open a conversation before creating a scheduled task.");
const cron = await client.crons.create(SCHEDULER_GRAPH_ID, { const result = await readJson<{ task: ScheduledTask }>(
input: { messages: [{ role: "user", content: params.prompt }] }, await fetch("/api/scheduled-tasks", {
schedule: params.schedule, method: "POST",
metadata: { headers: { "Content-Type": "application/json" },
run_kind: SCHEDULED_RUN_KIND, body: JSON.stringify({ ...params, threadId }),
name: params.name, })
prompt: params.prompt, );
}, return result.task;
});
return parseCron(cron as unknown as Cron);
} }
export async function deleteScheduledTask(cronId: string): Promise<void> { export async function deleteScheduledTask(cronId: string, threadId?: string | null): Promise<void> {
const client = makeClient(); const currentThreadId = resolvedThreadId(threadId);
if (!client) throw new Error("No EvoScientist deployment configured."); if (!currentThreadId) throw new Error("Open the scheduled task's conversation first.");
await client.crons.delete(cronId); await readJson(
await fetch(`/api/scheduled-tasks?${new URLSearchParams({ threadId: currentThreadId, cronId })}`, {
method: "DELETE",
})
);
} }
export async function updateScheduledTask(params: { export async function updateScheduledTask(params: {
@@ -87,6 +69,7 @@ export async function updateScheduledTask(params: {
name: string; name: string;
prompt: string; prompt: string;
schedule: string; schedule: string;
threadId?: string | null;
}): Promise<{ task: ScheduledTask; oldTaskDeleted: boolean }> { }): Promise<{ task: ScheduledTask; oldTaskDeleted: boolean }> {
const task = await createScheduledTask({ const task = await createScheduledTask({
name: params.name, name: params.name,
@@ -95,25 +78,23 @@ export async function updateScheduledTask(params: {
}); });
try { try {
await deleteScheduledTask(params.cronId); await deleteScheduledTask(params.cronId, params.threadId);
return { task, oldTaskDeleted: true }; return { task, oldTaskDeleted: true };
} catch { } catch {
return { task, oldTaskDeleted: false }; return { task, oldTaskDeleted: false };
} }
} }
export async function runScheduledTaskNow(prompt: string): Promise<void> { export async function runScheduledTaskNow(prompt: string, threadId?: string | null): Promise<void> {
const client = makeClient(); const currentThreadId = resolvedThreadId(threadId);
if (!client) throw new Error("No EvoScientist deployment configured."); if (!currentThreadId) throw new Error("Open the scheduled task's conversation first.");
const thread = await client.threads.create({ graphId: SCHEDULER_GRAPH_ID }); await readJson(
await client.runs.create(thread.thread_id, SCHEDULER_GRAPH_ID, { await fetch("/api/scheduled-tasks", {
input: { messages: [{ role: "user", content: prompt }] }, method: "POST",
metadata: { headers: { "Content-Type": "application/json" },
run_kind: SCHEDULED_RUN_KIND, body: JSON.stringify({ action: "run", threadId: currentThreadId, prompt }),
name: "manual-run", })
prompt, );
},
});
} }
export function useScheduledTasks(): { export function useScheduledTasks(): {
@@ -122,6 +103,7 @@ export function useScheduledTasks(): {
error: string | null; error: string | null;
refresh: () => void; refresh: () => void;
} { } {
const [threadId] = useQueryState("threadId");
const [tasks, setTasks] = useState<ScheduledTask[]>([]); const [tasks, setTasks] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -132,7 +114,7 @@ export function useScheduledTasks(): {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
listScheduledTasks() listScheduledTasks(threadId)
.then((result) => { .then((result) => {
if (!cancelled) { if (!cancelled) {
setTasks(result); setTasks(result);
@@ -155,7 +137,7 @@ export function useScheduledTasks(): {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [rev]); }, [rev, threadId]);
return { tasks, loading, error, refresh }; return { tasks, loading, error, refresh };
} }
+128 -265
View File
@@ -1,314 +1,178 @@
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import type { Thread } from "@langchain/langgraph-sdk"; import { patchConversation } from "@/lib/conversationApi";
import { Client } from "@langchain/langgraph-sdk";
import { getConfig } from "@/lib/config";
export interface ThreadItem { export interface ThreadItem {
id: string; id: string;
updatedAt: Date; updatedAt: Date;
status: Thread["status"]; status: string;
title: string; title: string;
description: string; description: string;
assistantId?: string; assistantId?: string;
pinned: boolean; pinned: boolean;
/** True when any of the thread's pending interrupts is an `ask_user` —
* i.e. the agent is asking the user a question that auto-approve can't
* resolve. Used by the sidebar to keep these threads in "Requiring
* Attention" even when auto-approve is on. */
needsUserInput: boolean; needsUserInput: boolean;
} }
interface ConversationThread {
thread_id: string;
updated_at: string;
status: string;
metadata?: Record<string, unknown>;
values?: { messages?: unknown[] };
interrupts?: Record<string, Array<{ value?: unknown }>>;
}
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
export function useThreads(props: { function contentText(content: unknown): string {
status?: Thread["status"]; if (typeof content === "string") return content;
limit?: number; if (!Array.isArray(content)) return "";
}) { return content
const pageSize = props.limit || DEFAULT_PAGE_SIZE; .map((part) =>
part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
? String((part as { text: string }).text)
: ""
)
.join("");
}
function toThreadItem(thread: ConversationThread): ThreadItem {
let title = "Untitled Thread";
let description = "";
const messages = Array.isArray(thread.values?.messages)
? thread.values?.messages
: [];
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const item = message as { type?: unknown; content?: unknown };
if (item.type === "human") {
const text = contentText(item.content).trim();
if (text) {
title = text.slice(0, 50) + (text.length > 50 ? "..." : "");
break;
}
}
}
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const item = message as { type?: unknown; content?: unknown };
if (item.type !== "ai") continue;
const text = contentText(item.content).trim();
if (text) {
description = text.slice(0, 100);
break;
}
}
if (title === "Untitled Thread" && description) {
title = description.slice(0, 50) + (description.length > 50 ? "..." : "");
}
const metadata = thread.metadata ?? {};
if (typeof metadata.title === "string" && metadata.title.trim()) {
title = metadata.title.trim();
}
let needsUserInput = false;
for (const interrupts of Object.values(thread.interrupts ?? {})) {
for (const interrupt of interrupts) {
const value = interrupt?.value as { type?: unknown } | undefined;
if (value?.type === "ask_user") {
needsUserInput = true;
break;
}
}
if (needsUserInput) break;
}
return {
id: thread.thread_id,
updatedAt: new Date(thread.updated_at),
status: thread.status,
title,
description,
assistantId:
typeof metadata.assistant_id === "string" ? metadata.assistant_id : undefined,
pinned: metadata.pinned === true,
needsUserInput,
};
}
export function useThreads(props: { status?: string; limit?: number }) {
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
return useSWRInfinite( return useSWRInfinite(
(pageIndex: number, previousPageData: ThreadItem[] | null) => { (pageIndex: number, previousPageData: ThreadItem[] | null) => {
const config = getConfig(); if (previousPageData && previousPageData.length === 0) return null;
const apiKey = return { pageIndex, pageSize, status: props.status };
config?.langsmithApiKey ||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
"";
if (!config) {
return null;
}
// If the previous page returned no items, we've reached the end
if (previousPageData && previousPageData.length === 0) {
return null;
}
return {
kind: "threads" as const,
pageIndex,
pageSize,
deploymentUrl: config.deploymentUrl,
assistantId: config.assistantId,
apiKey,
status: props?.status,
};
}, },
async ({ async ({
deploymentUrl,
assistantId,
apiKey,
status,
pageIndex, pageIndex,
pageSize, pageSize,
status,
}: { }: {
kind: "threads";
pageIndex: number; pageIndex: number;
pageSize: number; pageSize: number;
deploymentUrl: string; status?: string;
assistantId: string;
apiKey: string;
status?: Thread["status"];
}) => { }) => {
const client = new Client({ const params = new URLSearchParams({
apiUrl: deploymentUrl, limit: String(pageSize),
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {}, offset: String(pageIndex * pageSize),
}); });
if (status) params.set("status", status);
// Always scope the thread list to the selected assistant so we never const response = await fetch(`/api/conversations?${params}`, {
// show threads spawned by async sub-agents (e.g. writing-agent, cache: "no-store",
// data-analysis-agent) that share the same deployment store.
//
// Every thread carries both `graph_id` (the graph name) and
// `assistant_id` (a deterministic UUID per graph) in its metadata.
// A deployed UUID is matched against `assistant_id`; a graph name
// (the local-dev case, e.g. "EvoScientist") is matched against
// `graph_id`.
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
assistantId
);
const metadata = isUUID
? { assistant_id: assistantId }
: { graph_id: assistantId };
const threads = await client.threads.search({
limit: pageSize,
offset: pageIndex * pageSize,
sortBy: "updated_at" as const,
sortOrder: "desc" as const,
status,
metadata,
});
return threads.map((thread): ThreadItem => {
let title = "Untitled Thread";
let description = "";
try {
if (thread.values && typeof thread.values === "object") {
const values = thread.values as any;
const messages: any[] = Array.isArray(values.messages)
? values.messages
: [];
// Extract readable text from a string OR an array of content blocks
// (the latter is common for multi-part / attachment messages).
const textOf = (content: any): string => {
if (typeof content === "string") return content;
if (Array.isArray(content))
return content
.map((p: any) => (typeof p?.text === "string" ? p.text : ""))
.join("");
return "";
};
const firstHumanMessage = messages.find(
(m: any) => m.type === "human"
);
const humanText = textOf(firstHumanMessage?.content).trim();
if (humanText) {
title =
humanText.slice(0, 50) + (humanText.length > 50 ? "…" : "");
}
// Preview = the first AI message that actually has text. Agentic
// threads often open with tool-call-only AI messages (empty
// content), so picking the literal first AI message would leave the
// row blank (looking like an "empty" thread). Also join all text
// parts rather than just content[0].
for (const m of messages) {
if (m?.type !== "ai") continue;
const t = textOf(m.content).trim();
if (t) {
description = t.slice(0, 100);
break;
}
}
// If the first human message yielded no text (odd/attachment-only
// shape), fall back to the AI preview so the row isn't an opaque
// "Untitled Thread".
if (title === "Untitled Thread" && description) {
title =
description.slice(0, 50) + (description.length > 50 ? "…" : "");
}
}
} catch {
// Fallback to thread ID
title = `Thread ${thread.thread_id.slice(0, 8)}`;
}
// A user-set custom title (stored in metadata via rename) always wins.
const customTitle = (
thread.metadata as Record<string, unknown> | undefined
)?.title;
if (typeof customTitle === "string" && customTitle.trim()) {
title = customTitle.trim();
}
// Pinned state is stored in thread metadata (like the custom title),
// so it persists across reloads/devices via the backend store.
const pinned =
(thread.metadata as Record<string, unknown> | undefined)?.pinned ===
true;
// Walk `thread.interrupts` (Record<task_id, Interrupt[]>) and flag any
// value with `type: "ask_user"`. The auto-approver can't resolve those,
// so the sidebar should keep the row in "Requiring Attention" even when
// auto-approve is on for the thread.
let needsUserInput = false;
const interrupts = thread.interrupts as
| Record<string, Array<{ value?: unknown }>>
| undefined;
if (interrupts && typeof interrupts === "object") {
for (const list of Object.values(interrupts)) {
if (!Array.isArray(list)) continue;
for (const ir of list) {
const value = ir?.value as { type?: unknown } | undefined;
if (value && value.type === "ask_user") {
needsUserInput = true;
break;
}
}
if (needsUserInput) break;
}
}
return {
id: thread.thread_id,
updatedAt: new Date(thread.updated_at),
status: thread.status,
title,
description,
assistantId,
pinned,
needsUserInput,
};
}); });
const payload = (await response.json().catch(() => null)) as {
threads?: ConversationThread[];
error?: string;
} | null;
if (!response.ok) {
throw new Error(payload?.error || "Failed to load conversations.");
}
return (payload?.threads ?? []).map(toThreadItem);
}, },
{ { revalidateFirstPage: true, revalidateOnFocus: true }
revalidateFirstPage: true,
revalidateOnFocus: true,
}
); );
} }
// --- Thread mutations (used by the thread list's rename / delete actions) ---
function makeThreadsClient(): Client | null {
const config = getConfig();
if (!config) return null;
const apiKey =
config.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
return new Client({
apiUrl: config.deploymentUrl,
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
});
}
/** Permanently delete a thread. Throws if no deployment is configured. */
export async function deleteThread(id: string): Promise<void> { export async function deleteThread(id: string): Promise<void> {
const client = makeThreadsClient(); const response = await fetch(`/api/conversations/${encodeURIComponent(id)}`, {
if (!client) throw new Error("No EvoScientist deployment configured."); method: "DELETE",
await client.threads.delete(id); });
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error || "Failed to delete conversation.");
}
} }
async function updateThreadMetadata(
client: Client,
id: string,
patch: Record<string, unknown>
): Promise<void> {
const thread = await client.threads.get(id);
const metadata = {
...((thread.metadata as Record<string, unknown> | undefined) ?? {}),
...patch,
};
await client.threads.update(id, { metadata });
}
/**
* Rename a thread by storing a custom title in its metadata. The LangGraph
* thread PATCH replaces metadata, so read + merge first to preserve graph_id /
* assistant_id filter keys.
*/
export async function renameThread(id: string, title: string): Promise<void> { export async function renameThread(id: string, title: string): Promise<void> {
const client = makeThreadsClient(); await patchConversation(id, { title });
if (!client) throw new Error("No EvoScientist deployment configured.");
await updateThreadMetadata(client, id, { title });
} }
/**
* Pin or unpin a thread by storing a `pinned` flag in its metadata. Preserve
* the rest of the metadata for the same reason as `renameThread`.
*/
export async function pinThread(id: string, pinned: boolean): Promise<void> { export async function pinThread(id: string, pinned: boolean): Promise<void> {
const client = makeThreadsClient(); await patchConversation(id, { pinned });
if (!client) throw new Error("No EvoScientist deployment configured.");
await updateThreadMetadata(client, id, { pinned });
} }
/**
* Persist (or clear) the per-thread model override. Pass `null` to remove the
* key so the thread reverts to the deployment-default model. Reads on
* subsequent runs flow through `useChat` → `stream.submit({ config: ... })`,
* which the backend's `configurable_model` middleware resolves per request.
*/
export async function setThreadModelOverride( export async function setThreadModelOverride(
id: string, id: string,
override: { model: string; model_provider?: string } | null override: { model: string; model_provider?: string } | null
): Promise<void> { ): Promise<void> {
const client = makeThreadsClient(); await patchConversation(id, { model_override: override });
if (!client) throw new Error("No EvoScientist deployment configured.");
// Passing `null` here keeps the key present in metadata but explicitly
// un-set, which matches how langgraph treats absence-vs-null in the
// configurable middleware (`getattr(cfg, "model", None)` accepts both).
await updateThreadMetadata(client, id, { model_override: override });
} }
// Strip characters that are unsafe in filenames on Windows/macOS/Linux, then
// collapse whitespace. Keep this lenient — we just need a valid filename, not
// a slug.
function slugifyForFilename(input: string): string { function slugifyForFilename(input: string): string {
return ( return input
input // eslint-disable-next-line no-control-regex
// Stripping control chars is the point — silence the lint rule. .replace(/[\\/:*?"<>|\u0000-\u001F]+/g, "")
// eslint-disable-next-line no-control-regex .trim()
.replace(/[\\/:*?"<>|\u0000-\u001F]+/g, "") .replace(/\s+/g, "-")
.trim() .slice(0, 80);
.replace(/\s+/g, "-")
.slice(0, 80)
);
} }
/**
* Download the full thread state as JSON. We fetch via `client.threads.get`
* which returns the thread plus its current `values` (every message including
* tool calls, sub-agent output, and assistant thinking) — i.e. everything the
* backend has, suitable for debugging.
*/
export async function exportThread( export async function exportThread(
id: string, id: string,
filenameHint?: string filenameHint?: string
): Promise<void> { ): Promise<void> {
const client = makeThreadsClient(); const response = await fetch(`/api/conversations/${encodeURIComponent(id)}`, {
if (!client) throw new Error("No EvoScientist deployment configured."); cache: "no-store",
const thread = await client.threads.get(id); });
const thread = await response.json().catch(() => null);
if (!response.ok) throw new Error("Failed to export conversation.");
const blob = new Blob([JSON.stringify(thread, null, 2)], { const blob = new Blob([JSON.stringify(thread, null, 2)], {
type: "application/json", type: "application/json",
}); });
@@ -316,12 +180,11 @@ export async function exportThread(
const safeName = const safeName =
(filenameHint && slugifyForFilename(filenameHint)) || (filenameHint && slugifyForFilename(filenameHint)) ||
`thread-${id.slice(0, 8)}`; `thread-${id.slice(0, 8)}`;
const a = document.createElement("a"); const link = document.createElement("a");
a.href = url; link.href = url;
a.download = `${safeName}.json`; link.download = `${safeName}.json`;
document.body.appendChild(a); document.body.appendChild(link);
a.click(); link.click();
a.remove(); link.remove();
// Defer URL revocation a tick so Safari/Firefox finish the download trigger.
setTimeout(() => URL.revokeObjectURL(url), 0); setTimeout(() => URL.revokeObjectURL(url), 0);
} }
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
appendTypewriterUnits,
typewriterUnitsPerSecond,
} from "@/lib/typewriter";
interface TypewriterText {
text: string;
isTyping: boolean;
}
const MAX_FRAME_DELTA_MS = 50;
const MIN_PUBLISH_INTERVAL_MS = 32;
export function useTypewriterText(
targetText: string,
sourceStreaming: boolean
): TypewriterText {
const [displayedText, setDisplayedText] = useState(() =>
sourceStreaming ? "" : targetText
);
const displayedRef = useRef(displayedText);
const animatedRef = useRef(sourceStreaming);
const frameBudgetRef = useRef(0);
const lastFrameAtRef = useRef<number | null>(null);
const lastPublishAtRef = useRef<number | null>(null);
useEffect(() => {
let frameId: number | undefined;
let cancelled = false;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (sourceStreaming) animatedRef.current = true;
const publish = (next: string) => {
if (cancelled || next === displayedRef.current) return;
displayedRef.current = next;
setDisplayedText(next);
};
const finishImmediately = () => {
publish(targetText);
if (!sourceStreaming) animatedRef.current = false;
};
if (reduceMotion || (!sourceStreaming && !animatedRef.current)) {
frameId = requestAnimationFrame(finishImmediately);
return () => {
cancelled = true;
if (frameId !== undefined) cancelAnimationFrame(frameId);
};
}
if (!targetText.startsWith(displayedRef.current)) {
frameId = requestAnimationFrame(finishImmediately);
return () => {
cancelled = true;
if (frameId !== undefined) cancelAnimationFrame(frameId);
};
}
const tick = (timestamp: number) => {
if (cancelled) return;
const current = displayedRef.current;
if (current === targetText) {
if (!sourceStreaming) animatedRef.current = false;
return;
}
const previousAt = lastFrameAtRef.current ?? timestamp - 16;
const elapsed = Math.min(
Math.max(timestamp - previousAt, 0),
MAX_FRAME_DELTA_MS
);
lastFrameAtRef.current = timestamp;
const backlog = targetText.length - current.length;
frameBudgetRef.current +=
(typewriterUnitsPerSecond(backlog) * elapsed) / 1_000;
const units = Math.floor(frameBudgetRef.current);
const canPublish =
lastPublishAtRef.current === null ||
timestamp - lastPublishAtRef.current >= MIN_PUBLISH_INTERVAL_MS;
if (units > 0 && canPublish) {
frameBudgetRef.current -= units;
lastPublishAtRef.current = timestamp;
const next = appendTypewriterUnits(current, targetText, units);
if (next === targetText && !sourceStreaming) {
animatedRef.current = false;
}
publish(next);
}
if (displayedRef.current !== targetText) {
frameId = requestAnimationFrame(tick);
}
};
frameId = requestAnimationFrame(tick);
return () => {
cancelled = true;
if (frameId !== undefined) cancelAnimationFrame(frameId);
};
}, [sourceStreaming, targetText]);
return {
text: displayedText,
isTyping:
animatedRef.current && (sourceStreaming || displayedText !== targetText),
};
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
type UsageCall,
type UsageSourceStatus,
usageCallTotal,
usageViewParams,
} from "./useUsage";
const status: UsageSourceStatus = {
availability: "available",
reason_code: "healthy",
deployment_id: "deployment-a",
workspace_id: "workspace-a",
};
const confirmedCall: UsageCall = {
deployment_id: "deployment-a",
workspace_id: "workspace-a",
model_call_id: "call-a",
thread_id: "thread-a",
turn_id: "turn-a",
scope: "main",
source_agent: "EvoScientist",
provider_profile_id: "provider-a",
provider_adapter: "openai",
model_alias: "model-a",
upstream_model_id: "model-a",
usage_status: "confirmed",
input_tokens: "9007199254740991",
output_tokens: "9",
provider_total_tokens: null,
completed_at: "2026-07-16T12:00:03Z",
};
describe("usage detail helpers", () => {
it("builds isolated thread, workspace, and all-source filters", () => {
expect(
Object.fromEntries(usageViewParams("thread", "thread-a", status)!)
).toEqual({
deployment_id: "deployment-a",
workspace_id: "workspace-a",
thread_id: "thread-a",
});
expect(
Object.fromEntries(usageViewParams("workspace", "thread-a", status)!)
).toEqual({
deployment_id: "deployment-a",
workspace_id: "workspace-a",
});
expect(usageViewParams("all", "thread-a", undefined)?.toString()).toBe("");
expect(usageViewParams("thread", null, status)).toBeNull();
});
it("adds confirmed token counts exactly without Number precision loss", () => {
expect(usageCallTotal(confirmedCall)).toBe("9007199254741000");
expect(
usageCallTotal({
...confirmedCall,
usage_status: "unknown",
input_tokens: null,
output_tokens: null,
})
).toBeNull();
});
});
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
export interface UsageSummary {
input_tokens: string;
output_tokens: string;
total_tokens: string;
confirmed_call_count: string;
unknown_call_count: string;
by_scope: Array<{
key: string;
total_tokens: string;
confirmed_call_count: string;
unknown_call_count: string;
}>;
}
export interface UsageSourceStatus {
availability: "available" | "degraded" | "offline" | "unavailable";
reason_code: string;
deployment_id?: string;
workspace_id?: string;
spool_pending?: string;
spool_inflight?: string;
spool_quarantined?: string;
spool_bytes?: string;
first_loss_at?: string | null;
tracking_degraded_reason?: string | null;
last_seen_at?: string;
}
export type UsageView = "thread" | "workspace" | "all";
export interface UsageCall {
deployment_id: string;
workspace_id: string;
model_call_id: string;
thread_id: string | null;
turn_id: string | null;
scope: string;
source_agent: string | null;
provider_profile_id: string;
provider_adapter: string;
model_alias: string;
upstream_model_id: string;
usage_status: "confirmed" | "unknown";
input_tokens: string | null;
output_tokens: string | null;
provider_total_tokens: string | null;
completed_at: string;
}
export interface UsageCallsPage {
items: UsageCall[];
next_cursor: string | null;
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Usage API returned ${response.status}`);
return response.json() as Promise<T>;
}
export function useUsage(threadId: string | null) {
const status = useSWR<UsageSourceStatus>("/api/usage/status", fetchJson, {
refreshInterval: 15_000,
revalidateOnFocus: true,
});
const summary = useSWR<UsageSummary>(
threadId && status.data?.deployment_id && status.data.workspace_id
? `/api/usage/summary?${new URLSearchParams({
thread_id: threadId,
deployment_id: status.data.deployment_id,
workspace_id: status.data.workspace_id,
})}`
: null,
fetchJson,
{ refreshInterval: 5_000, revalidateOnFocus: true }
);
return {
status: status.data,
summary: summary.data,
loading: status.isLoading || (Boolean(threadId) && summary.isLoading),
error: status.error ?? summary.error,
};
}
export function usageViewParams(
view: UsageView,
threadId: string | null,
status: UsageSourceStatus | undefined
): URLSearchParams | null {
const params = new URLSearchParams();
if (view === "all") return params;
if (!status?.deployment_id || !status.workspace_id) return null;
params.set("deployment_id", status.deployment_id);
params.set("workspace_id", status.workspace_id);
if (view === "thread") {
if (!threadId) return null;
params.set("thread_id", threadId);
}
return params;
}
export function useUsageDetails({
enabled,
view,
threadId,
status,
}: {
enabled: boolean;
view: UsageView;
threadId: string | null;
status: UsageSourceStatus | undefined;
}) {
const query = useMemo(
() => usageViewParams(view, threadId, status)?.toString() ?? null,
[status, threadId, view]
);
const summary = useSWR<UsageSummary>(
enabled && query !== null ? `/api/usage/summary?${query}` : null,
fetchJson,
{ revalidateOnFocus: true }
);
const getKey = useCallback(
(_pageIndex: number, previous: UsageCallsPage | null) => {
if (!enabled || query === null || (previous && !previous.next_cursor))
return null;
const params = new URLSearchParams(query);
params.set("limit", "50");
if (previous?.next_cursor) params.set("cursor", previous.next_cursor);
return `/api/usage/calls?${params}`;
},
[enabled, query]
);
const pages = useSWRInfinite<UsageCallsPage>(getKey, fetchJson, {
revalidateFirstPage: true,
revalidateOnFocus: true,
});
const items = useMemo(
() => pages.data?.flatMap((page) => page.items) ?? [],
[pages.data]
);
const lastPage = pages.data?.at(-1);
const hasMore = Boolean(lastPage?.next_cursor);
const loadingMore =
pages.isValidating &&
Boolean(pages.data) &&
typeof pages.data?.[pages.size - 1] === "undefined";
const loadMore = useCallback(() => {
if (hasMore && !loadingMore) void pages.setSize(pages.size + 1);
}, [hasMore, loadingMore, pages]);
const refresh = useCallback(async () => {
await pages.setSize(1);
await Promise.all([summary.mutate(), pages.mutate()]);
}, [pages, summary]);
return {
summary: summary.data,
items,
loading: summary.isLoading || pages.isLoading,
refreshing: summary.isValidating || pages.isValidating,
loadingMore,
hasMore,
error: summary.error ?? pages.error,
loadMore,
refresh,
};
}
export function usageCallTotal(call: UsageCall): string | null {
if (
call.usage_status !== "confirmed" ||
call.input_tokens === null ||
call.output_tokens === null
)
return null;
try {
return (BigInt(call.input_tokens) + BigInt(call.output_tokens)).toString();
} catch {
return null;
}
}
export function formatTokenCount(value: string | null | undefined): string {
try {
return BigInt(value ?? "0").toLocaleString();
} catch {
return "0";
}
}
+1 -7
View File
@@ -1,13 +1,10 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app"; import { NuqsAdapter } from "nuqs/adapters/next/app";
import { ThemeProvider, ThemedToaster } from "@/providers/ThemeProvider"; import { ThemeProvider, ThemedToaster } from "@/providers/ThemeProvider";
import { THEME_STORAGE_KEY } from "@/lib/theme"; import { THEME_STORAGE_KEY } from "@/lib/theme";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import "./globals.css"; import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "EvoScientist WebUI", title: "EvoScientist WebUI",
description: description:
@@ -39,10 +36,7 @@ export default function RootLayout({
lang="en" lang="en"
suppressHydrationWarning suppressHydrationWarning
> >
<body <body suppressHydrationWarning>
className={inter.className}
suppressHydrationWarning
>
<script dangerouslySetInnerHTML={{ __html: themeScript }} /> <script dangerouslySetInnerHTML={{ __html: themeScript }} />
<NuqsAdapter> <NuqsAdapter>
<ThemeProvider> <ThemeProvider>
+24 -131
View File
@@ -3,11 +3,9 @@
import React, { useState, useEffect, useCallback, Suspense } from "react"; import React, { useState, useEffect, useCallback, Suspense } from "react";
import Image from "next/image"; import Image from "next/image";
import { useQueryState } from "nuqs"; import { useQueryState } from "nuqs";
import { getConfig, saveConfig, DeploymentConfig } from "@/lib/config";
import { ConfigDialog } from "@/app/components/ConfigDialog"; import { ConfigDialog } from "@/app/components/ConfigDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Assistant } from "@langchain/langgraph-sdk"; import type { Assistant } from "@langchain/langgraph-sdk";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import { import {
Settings, Settings,
SquarePen, SquarePen,
@@ -37,22 +35,19 @@ import { setThreadAutoApprove } from "@/lib/autoApprove";
import type { MainChatReporter } from "@/lib/asyncAgents"; import type { MainChatReporter } from "@/lib/asyncAgents";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const FALLBACK_ASSISTANT_ID = "EvoScientist";
interface HomePageInnerProps { interface HomePageInnerProps {
config: DeploymentConfig;
configDialogOpen: boolean; configDialogOpen: boolean;
setConfigDialogOpen: (open: boolean) => void; setConfigDialogOpen: (open: boolean) => void;
handleSaveConfig: (config: DeploymentConfig) => void;
authEnabled: boolean; authEnabled: boolean;
} }
function HomePageInner({ function HomePageInner({
config,
configDialogOpen, configDialogOpen,
setConfigDialogOpen, setConfigDialogOpen,
handleSaveConfig,
authEnabled, authEnabled,
}: HomePageInnerProps) { }: HomePageInnerProps) {
const client = useClient();
const [threadId, setThreadId] = useQueryState("threadId"); const [threadId, setThreadId] = useQueryState("threadId");
const [sidebar, setSidebar] = useQueryState("sidebar"); const [sidebar, setSidebar] = useQueryState("sidebar");
const [view, setView] = useQueryState("view"); const [view, setView] = useQueryState("view");
@@ -76,26 +71,18 @@ function HomePageInner({
const [changePasswordOpen, setChangePasswordOpen] = useState(false); const [changePasswordOpen, setChangePasswordOpen] = useState(false);
const fetchAssistant = useCallback(async () => { const fetchAssistant = useCallback(async () => {
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
config.assistantId
);
const resolve = async (): Promise<Assistant> => { const resolve = async (): Promise<Assistant> => {
// A UUID addresses one assistant directly; otherwise list the graph's const response = await fetch("/api/deployment/assistant", {
// assistants and prefer the system default (fall back to the first). cache: "no-store",
if (isUUID) {
return await client.assistants.get(config.assistantId);
}
const assistants = await client.assistants.search({
graphId: config.assistantId,
limit: 100,
}); });
const found = const body = (await response.json().catch(() => null)) as {
assistants.find((a) => a.metadata?.["created_by"] === "system") ?? assistant?: Assistant;
assistants[0]; error?: string;
if (!found) throw new Error("No assistant found for this graph."); } | null;
return found; if (!response.ok || !body?.assistant) {
throw new Error(body?.error || "No assistant found for this graph.");
}
return body.assistant;
}; };
// The langgraph backend may not be ready the instant the page mounts — the // The langgraph backend may not be ready the instant the page mounts — the
@@ -119,17 +106,17 @@ function HomePageInner({
// Fallback: address the graph directly by id (works on `langgraph dev`). // Fallback: address the graph directly by id (works on `langgraph dev`).
setAssistant({ setAssistant({
assistant_id: config.assistantId, assistant_id: FALLBACK_ASSISTANT_ID,
graph_id: config.assistantId, graph_id: FALLBACK_ASSISTANT_ID,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
config: {}, config: {},
metadata: {}, metadata: {},
version: 1, version: 1,
name: config.assistantId, name: FALLBACK_ASSISTANT_ID,
context: {}, context: {},
}); });
}, [client, config.assistantId]); }, []);
useEffect(() => { useEffect(() => {
fetchAssistant(); fetchAssistant();
@@ -263,7 +250,7 @@ function HomePageInner({
// Only force a fresh ChatProvider mount when the thread actually // Only force a fresh ChatProvider mount when the thread actually
// changes. Clicking the active thread row (e.g. to return to chat from // changes. Clicking the active thread row (e.g. to return to chat from
// the Memory view) used to bump the revision unconditionally, which // the Memory view) used to bump the revision unconditionally, which
// tore down ChatInterface and forced useStream to re-fetch the full // tore down ChatInterface and forced the conversation transport to re-fetch the full
// thread `/history` — defeating the keep-chat-mounted layout. // thread `/history` — defeating the keep-chat-mounted layout.
if (!sameThread) { if (!sameThread) {
setChatSessionRevision((revision) => revision + 1); setChatSessionRevision((revision) => revision + 1);
@@ -277,8 +264,6 @@ function HomePageInner({
<ConfigDialog <ConfigDialog
open={configDialogOpen} open={configDialogOpen}
onOpenChange={setConfigDialogOpen} onOpenChange={setConfigDialogOpen}
onSave={handleSaveConfig}
initialConfig={config}
/> />
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<header className="flex h-14 items-center justify-between gap-2 border-b border-border px-2.5 sm:px-4"> <header className="flex h-14 items-center justify-between gap-2 border-b border-border px-2.5 sm:px-4">
@@ -343,12 +328,7 @@ function HomePageInner({
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1 sm:gap-2"> <div className="flex shrink-0 items-center gap-1 sm:gap-2">
<HealthIndicator <HealthIndicator />
deploymentUrl={config.deploymentUrl}
onReconnect={(url) =>
handleSaveConfig({ ...config, deploymentUrl: url })
}
/>
<ThemeToggle /> <ThemeToggle />
{authEnabled && ( {authEnabled && (
<> <>
@@ -568,24 +548,8 @@ function HomePageInner({
} }
function HomePageContent() { function HomePageContent() {
const [config, setConfig] = useState<DeploymentConfig | null>(null);
const [configDialogOpen, setConfigDialogOpen] = useState(false); const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [authEnabled, setAuthEnabled] = useState(false); const [authEnabled, setAuthEnabled] = useState(false);
const [assistantId, setAssistantId] = useQueryState("assistantId");
// On mount, check for saved config, otherwise show config dialog
useEffect(() => {
const savedConfig = getConfig();
if (savedConfig) {
setConfig(savedConfig);
if (!assistantId) {
setAssistantId(savedConfig.assistantId);
}
} else {
setConfigDialogOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -602,83 +566,12 @@ function HomePageContent() {
}; };
}, []); }, []);
// If config changes, update the assistantId
useEffect(() => {
if (config && !assistantId) {
setAssistantId(config.assistantId);
}
}, [config, assistantId, setAssistantId]);
const handleSaveConfig = useCallback((newConfig: DeploymentConfig) => {
saveConfig(newConfig);
setConfig(newConfig);
}, []);
const signOut = useCallback(async () => {
try {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "same-origin",
});
} finally {
window.location.assign("/login");
}
}, []);
const langsmithApiKey =
config?.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
if (!config) {
return (
<>
<ConfigDialog
open={configDialogOpen}
onOpenChange={setConfigDialogOpen}
onSave={handleSaveConfig}
/>
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold">Welcome to EvoScientist</h1>
<p className="mt-2 text-muted-foreground">
Configure your deployment to get started
</p>
<Button
onClick={() => setConfigDialogOpen(true)}
className="mt-4"
>
Open Configuration
</Button>
{authEnabled && (
<Button
variant="ghost"
onClick={signOut}
className="mt-2"
>
<LogOut
className="size-4"
aria-hidden="true"
/>
Sign out
</Button>
)}
</div>
</div>
</>
);
}
return ( return (
<ClientProvider <HomePageInner
deploymentUrl={config.deploymentUrl} configDialogOpen={configDialogOpen}
apiKey={langsmithApiKey} setConfigDialogOpen={setConfigDialogOpen}
> authEnabled={authEnabled}
<HomePageInner />
config={config}
configDialogOpen={configDialogOpen}
setConfigDialogOpen={setConfigDialogOpen}
handleSaveConfig={handleSaveConfig}
authEnabled={authEnabled}
/>
</ClientProvider>
); );
} }

Some files were not shown because too many files have changed in this diff Show More